diff --git a/Sense/server/cmd/migrate/migration/version/2026081419000_media.go b/Sense/server/cmd/migrate/migration/version/2026081419000_media.go index 98c503e..29cfeed 100644 --- a/Sense/server/cmd/migrate/migration/version/2026081419000_media.go +++ b/Sense/server/cmd/migrate/migration/version/2026081419000_media.go @@ -1,7 +1,9 @@ package version import ( + "fmt" "runtime" + "strings" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -19,6 +21,9 @@ func init() { func migrateSenseMedia(db *gorm.DB, version string) error { return db.Transaction(func(tx *gorm.DB) error { + if err := prepareLegacyMediaRouteSchema(tx); err != nil { + return err + } if err := tx.AutoMigrate(&media.Route{}); err != nil { return err } @@ -58,3 +63,122 @@ func migrateSenseMedia(db *gorm.DB, version string) error { return tx.Create(&common.Migration{Version: version}).Error }) } + +type mediaPathConstraint struct { + Name string + Columns string +} + +// prepareLegacyMediaRouteSchema makes the old PostgreSQL table safe for GORM. +// Older Sense builds used a database-named UNIQUE(path) constraint and lacked +// the runtime-state columns now required by the Route model. +func prepareLegacyMediaRouteSchema(tx *gorm.DB) error { + if tx.Dialector.Name() != "postgres" { + return nil + } + var tableCount int64 + if err := tx.Raw(`SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = current_schema() + AND table_name = 'sense_media_routes'`).Scan(&tableCount).Error; err != nil { + return fmt.Errorf("inspect legacy media route table: %w", err) + } + if tableCount == 0 { + return nil + } + if err := tx.Exec(`LOCK TABLE "sense_media_routes" IN ACCESS EXCLUSIVE MODE`).Error; err != nil { + return fmt.Errorf("lock sense_media_routes for legacy migration: %w", err) + } + if err := normalizeLegacyMediaPathConstraint(tx); err != nil { + return err + } + if err := initializeLegacyMediaRuntimeColumns(tx); err != nil { + return err + } + return nil +} + +func normalizeLegacyMediaPathConstraint(tx *gorm.DB) error { + var constraints []mediaPathConstraint + if err := tx.Raw(`SELECT c.conname AS name, + (SELECT string_agg(a.attname, ',' ORDER BY key.ordinality) + FROM unnest(c.conkey) WITH ORDINALITY AS key(attnum, ordinality) + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = key.attnum) AS columns + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + WHERE n.nspname = current_schema() + AND t.relname = 'sense_media_routes' + AND c.contype = 'u' + AND EXISTS ( + SELECT 1 + FROM unnest(c.conkey) AS key(attnum) + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = key.attnum + WHERE a.attname = 'path' + ) + ORDER BY c.conname`).Scan(&constraints).Error; err != nil { + return fmt.Errorf("inspect legacy media path constraints: %w", err) + } + if len(constraints) == 0 { + return nil + } + if len(constraints) != 1 || constraints[0].Columns != "path" { + return fmt.Errorf("sense_media_routes.path has unsupported legacy uniqueness structure; migration rolled back") + } + const expectedName = "uni_sense_media_routes_path" + if constraints[0].Name == expectedName { + return nil + } + var conflictingNameCount int64 + if err := tx.Raw(`SELECT COUNT(*) + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + WHERE n.nspname = current_schema() + AND t.relname = 'sense_media_routes' + AND c.conname = ?`, expectedName).Scan(&conflictingNameCount).Error; err != nil { + return fmt.Errorf("inspect target media path constraint name: %w", err) + } + if conflictingNameCount != 0 { + return fmt.Errorf("sense_media_routes has a conflicting target constraint name; migration rolled back") + } + rename := fmt.Sprintf( + `ALTER TABLE "sense_media_routes" RENAME CONSTRAINT %s TO %s`, + quotePostgresIdentifier(constraints[0].Name), + quotePostgresIdentifier(expectedName), + ) + if err := tx.Exec(rename).Error; err != nil { + return fmt.Errorf("normalize legacy media path constraint name: %w", err) + } + return nil +} + +func initializeLegacyMediaRuntimeColumns(tx *gorm.DB) error { + for _, statement := range []struct { + name string + sql string + }{ + {name: "add source_ready", sql: `ALTER TABLE "sense_media_routes" ADD COLUMN IF NOT EXISTS "source_ready" boolean`}, + {name: "add failure_count", sql: `ALTER TABLE "sense_media_routes" ADD COLUMN IF NOT EXISTS "failure_count" bigint`}, + {name: "add last_error_code", sql: `ALTER TABLE "sense_media_routes" ADD COLUMN IF NOT EXISTS "last_error_code" varchar(64)`}, + {name: "initialize runtime state", sql: `UPDATE "sense_media_routes" + SET "source_ready" = COALESCE("source_ready", false), + "failure_count" = COALESCE("failure_count", 0), + "last_error_code" = COALESCE("last_error_code", '') + WHERE "source_ready" IS NULL + OR "failure_count" IS NULL + OR "last_error_code" IS NULL`}, + {name: "require source_ready", sql: `ALTER TABLE "sense_media_routes" ALTER COLUMN "source_ready" SET NOT NULL`}, + {name: "require failure_count", sql: `ALTER TABLE "sense_media_routes" ALTER COLUMN "failure_count" SET NOT NULL`}, + {name: "require last_error_code", sql: `ALTER TABLE "sense_media_routes" ALTER COLUMN "last_error_code" SET NOT NULL`}, + } { + if err := tx.Exec(statement.sql).Error; err != nil { + return fmt.Errorf("%s for legacy media routes: %w", statement.name, err) + } + } + return nil +} + +func quotePostgresIdentifier(value string) string { + return `"` + strings.ReplaceAll(value, `"`, `""`) + `"` +} diff --git a/Sense/server/cmd/migrate/migration/version/2026081419000_media_test.go b/Sense/server/cmd/migrate/migration/version/2026081419000_media_test.go index 4d06942..834f6dc 100644 --- a/Sense/server/cmd/migrate/migration/version/2026081419000_media_test.go +++ b/Sense/server/cmd/migrate/migration/version/2026081419000_media_test.go @@ -2,6 +2,7 @@ package version import ( "os" + "strings" "testing" "gorm.io/driver/postgres" @@ -21,34 +22,177 @@ func TestMediaMigrationOnPostgres(t *testing.T) { if err != nil { t.Fatal(err) } - if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil { + const schema = "sense_media_95_test" + if err = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil { t.Fatal(err) } - t.Cleanup(func() { - db.Exec("DROP TABLE IF EXISTS sense_media_routes, sys_role_menu, sys_menu, sys_role, casbin_rule, sys_migration CASCADE") + if err = db.Exec("CREATE SCHEMA " + schema).Error; err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE") }) + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + sqlDB.SetMaxOpenConns(1) + if err = db.Exec("SET search_path TO " + schema).Error; err != nil { + t.Fatal(err) + } + + t.Run("migrates the legacy path constraint and preserves routes", func(t *testing.T) { + resetLegacyMediaMigration(t, db, `UNIQUE (path)`) + if err = db.Exec(`INSERT INTO sense_media_routes + (id, device_id, profile_token, path, desired, actual, readers, detail, version, updated_at) + VALUES + ('route-1', 'device-1', 'profile-1', 'camera-1', 'running', 'stopped', 0, '', 1, now()), + ('route-2', 'device-2', 'profile-2', 'camera-2', 'stopped', 'stopped', 0, '', 1, now())`).Error; err != nil { + t.Fatal(err) + } + const migrationVersion = "2026081419000_media.go" + if err = migrateSenseMedia(db, migrationVersion); err != nil { + t.Fatal(err) + } + var routes, menus, policies, applied int64 + if err = db.Model(&media.Route{}).Count(&routes).Error; err != nil { + t.Fatal(err) + } + if err = db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseMedia%").Count(&menus).Error; err != nil { + t.Fatal(err) + } + if err = db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/media%").Count(&policies).Error; err != nil { + t.Fatal(err) + } + if err = db.Model(&common.Migration{}).Where("version = ?", migrationVersion).Count(&applied).Error; err != nil { + t.Fatal(err) + } + if routes != 2 || menus != 3 || policies != 12 || applied != 1 { + t.Fatalf("routes=%d menus=%d policies=%d applied=%d", routes, menus, policies, applied) + } + assertMediaPathUniqueIndex(t, db) + if err = db.Exec(`UPDATE sense_media_routes SET path = 'camera-1' WHERE id = 'route-2'`).Error; err == nil { + t.Fatal("expected path uniqueness violation") + } + var runtimeState struct { + SourceReady bool + FailureCount int64 + LastError string + } + if err = db.Raw(`SELECT source_ready, failure_count, last_error_code AS last_error + FROM sense_media_routes WHERE id = 'route-1'`).Scan(&runtimeState).Error; err != nil { + t.Fatal(err) + } + if runtimeState.SourceReady || runtimeState.FailureCount != 0 || runtimeState.LastError != "" { + t.Fatalf("unexpected migrated runtime state: %#v", runtimeState) + } + if err = db.Transaction(prepareLegacyMediaRouteSchema); err != nil { + t.Fatalf("repeat compatibility migration: %v", err) + } }) + + t.Run("does nothing when the table is absent", func(t *testing.T) { + if err = db.Exec(`DROP TABLE IF EXISTS sense_media_routes`).Error; err != nil { + t.Fatal(err) + } + if err = db.Transaction(prepareLegacyMediaRouteSchema); err != nil { + t.Fatal(err) + } + }) + + t.Run("creates a fresh media table", func(t *testing.T) { + resetEmptyMediaMigration(t, db) + if err = migrateSenseMedia(db, "2026081419000_media_fresh.go"); err != nil { + t.Fatal(err) + } + var routes int64 + if err = db.Model(&media.Route{}).Count(&routes).Error; err != nil { + t.Fatal(err) + } + if routes != 0 { + t.Fatalf("fresh route count=%d", routes) + } + assertMediaPathUniqueIndex(t, db) + }) + + t.Run("rejects an unsafe composite path constraint", func(t *testing.T) { + resetLegacyMediaMigration(t, db, `UNIQUE (path, device_id)`) + if err = db.Transaction(prepareLegacyMediaRouteSchema); err == nil || !strings.Contains(err.Error(), "unsupported legacy uniqueness") { + t.Fatalf("expected unsupported uniqueness error, got %v", err) + } + var constraints int64 + if err = db.Raw(`SELECT COUNT(*) FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + WHERE t.relname = 'sense_media_routes' AND c.contype = 'u'`).Scan(&constraints).Error; err != nil { + t.Fatal(err) + } + if constraints != 1 { + t.Fatalf("constraint rollback count=%d", constraints) + } + }) +} + +func resetEmptyMediaMigration(t *testing.T, db *gorm.DB) { + t.Helper() + if err := db.Exec(`DROP TABLE IF EXISTS sense_media_routes, sys_role_menu, sys_menu, sys_role, casbin_rule, sys_migration CASCADE`).Error; err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil { + t.Fatal(err) + } for _, role := range []string{"implementation_operator", "site_admin", "viewer"} { - if err = db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil { + if err := db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil { t.Fatal(err) } } - if err = migrateSenseMedia(db, "2026081419000_media.go"); err != nil { +} + +func resetLegacyMediaMigration(t *testing.T, db *gorm.DB, pathConstraint string) { + t.Helper() + if err := db.Exec(`DROP TABLE IF EXISTS sense_media_routes, sys_role_menu, sys_menu, sys_role, casbin_rule, sys_migration CASCADE`).Error; err != nil { t.Fatal(err) } - var routes, menus, policies, applied int64 - if err = db.Model(&media.Route{}).Count(&routes).Error; err != nil { + createRouteTable := `CREATE TABLE sense_media_routes ( + id text PRIMARY KEY, + device_id text NOT NULL, + profile_token text NOT NULL, + path text NOT NULL, + desired text NOT NULL, + actual text NOT NULL, + readers integer NOT NULL DEFAULT 0, + detail text NOT NULL DEFAULT '', + version bigint NOT NULL, + updated_at timestamptz NOT NULL, + ` + pathConstraint + ` + )` + if err := db.Exec(createRouteTable).Error; err != nil { t.Fatal(err) } - if err = db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseMedia%").Count(&menus).Error; err != nil { + if err := db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil { t.Fatal(err) } - if err = db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/media%").Count(&policies).Error; err != nil { - t.Fatal(err) - } - if err = db.Model(&common.Migration{}).Where("version = ?", "2026081419000_media.go").Count(&applied).Error; err != nil { - t.Fatal(err) - } - if routes != 0 || menus != 3 || policies != 12 || applied != 1 { - t.Fatalf("routes=%d menus=%d policies=%d applied=%d", routes, menus, policies, applied) + for _, role := range []string{"implementation_operator", "site_admin", "viewer"} { + if err := db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil { + t.Fatal(err) + } } } + +func assertMediaPathUniqueIndex(t *testing.T, db *gorm.DB) { + t.Helper() + var indexes []struct { + IndexName string + IndexDef string + } + if err := db.Raw(`SELECT indexname AS index_name, indexdef AS index_def + FROM pg_indexes + WHERE schemaname = current_schema() + AND tablename = 'sense_media_routes' + ORDER BY indexname`).Scan(&indexes).Error; err != nil { + t.Fatal(err) + } + for _, index := range indexes { + if strings.Contains(index.IndexDef, "UNIQUE INDEX") && strings.HasSuffix(index.IndexDef, " (path)") { + return + } + } + t.Fatalf("missing unique path index: %#v", indexes) +}