From 74c8697e57b8875b5265d9a6483554a0395f2599 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 23 May 2026 09:01:21 -0400 Subject: [PATCH] fix: hub-side dedup for ESP32 offline status replay (CUB-239) - Add migration 002: UNIQUE index on status_logs(camera_id, recorded_at) - Upgrade migration system to version-tracked (schema_version table) - Prevents race-condition double-inserts that application-level COUNT(*) check cannot guard against - Complements existing application-level dedup from CUB-230 --- internal/db/db.go | 52 ++++++++++++++----- .../db/migrations/002_dedup_unique_index.sql | 8 +++ 2 files changed, 48 insertions(+), 12 deletions(-) create mode 100644 internal/db/migrations/002_dedup_unique_index.sql diff --git a/internal/db/db.go b/internal/db/db.go index 1af91c0..90329a6 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -4,6 +4,7 @@ package db import ( "database/sql" _ "embed" + "fmt" "log" "os" "path/filepath" @@ -14,13 +15,16 @@ import ( //go:embed migrations/001_create_tables.sql var migration001 string +//go:embed migrations/002_dedup_unique_index.sql +var migration002 string + // DB wraps the sql.DB with connection-level settings. type DB struct { *sql.DB } // Open opens the SQLite database at the given path, enables WAL mode, -// and runs all migrations if the tables don't exist yet. +// and runs all migrations using a schema_version table for tracking. func Open(path string) (*DB, error) { // Ensure the directory exists dir := filepath.Dir(path) @@ -45,22 +49,46 @@ func Open(path string) (*DB, error) { return nil, err } - // Check if tables already exist (idempotent migration) - var count int - if err := db.QueryRow(` - SELECT COUNT(*) FROM sqlite_master - WHERE type='table' AND name IN ('cameras', 'status_logs', 'recording_events', 'settings') - `).Scan(&count); err != nil { + // Ensure schema_version table exists for migration tracking + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)`); err != nil { db.Close() return nil, err } - if count < 4 { - log.Printf("Running migrations for %s...", path) - if err := migrate(db, migration001); err != nil { - db.Close() - return nil, err + // Read current schema version (0 if table is empty) + var currentVersion int + if err := db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_version`).Scan(¤tVersion); err != nil { + db.Close() + return nil, err + } + + // Migration definitions: ordered list of (version, sql) + type migration struct { + version int + sql string + } + migrations := []migration{ + {1, migration001}, + {2, migration002}, + } + + for _, m := range migrations { + if currentVersion >= m.version { + continue } + log.Printf("Running migration %d for %s...", m.version, path) + if err := migrate(db, m.sql); err != nil { + db.Close() + return nil, fmt.Errorf("migration %d: %w", m.version, err) + } + if _, err := db.Exec(`INSERT INTO schema_version (version) VALUES (?)`, m.version); err != nil { + db.Close() + return nil, fmt.Errorf("record migration %d: %w", m.version, err) + } + log.Printf("Migration %d complete", m.version) + } + + if currentVersion < len(migrations) { log.Println("Migrations complete") } diff --git a/internal/db/migrations/002_dedup_unique_index.sql b/internal/db/migrations/002_dedup_unique_index.sql new file mode 100644 index 0000000..9e6d1ab --- /dev/null +++ b/internal/db/migrations/002_dedup_unique_index.sql @@ -0,0 +1,8 @@ +-- Migration: 002_dedup_unique_index +-- Add a UNIQUE index on (camera_id, recorded_at) to enforce hub-side +-- deduplication for ESP32 offline status replay (CUB-239). +-- This prevents race-condition double-inserts that a pure SELECT COUNT(*) +-- check cannot guard against. + +CREATE UNIQUE INDEX IF NOT EXISTS idx_status_logs_unique_entry + ON status_logs(camera_id, recorded_at); -- 2.53.0