package migrations import ( "context" "database/sql" "fmt" "github.com/pressly/goose/v3" ) func init() { goose.AddMigrationContext(upRFC3339Time, downRFC3339Time) } // upRFC3339Time переводит хранение временных меток на RFC 3339 (UTC, суффикс Z, // напр. «2006-01-02T15:04:05Z») и убирает `DEFAULT (datetime('now'))` со всех // колонок: время отныне пишет только приложение (store.Now/FormatTime) — единая // точка генерации, fail-loud при забытой вставке (NOT NULL без DEFAULT). // // SQLite не умеет ALTER COLUMN DROP DEFAULT, поэтому перестраиваем 7 таблиц по // образцу миграции 0006 (FK включены): создаём *_new без DEFAULT на метках, // копируем данные с переформатированием меток через strftime (id и связи НЕ // меняются — момент времени сохраняется, ULID-корреляция цела), удаляем старые // (дети первыми), переименовываем (SQLite ≥3.25 переписывает REFERENCES), // пересоздаём индексы и проверяем целостность FK. func upRFC3339Time(ctx context.Context, tx *sql.Tx) error { return rebuildTimeColumns(ctx, tx, "", rfc3339Expr) } // downRFC3339Time возвращает прежний формат меток (`YYYY-MM-DD HH:MM:SS`) и // `DEFAULT (datetime('now'))` на колонках. Обратима без потерь: strftime // разбирает RFC 3339 (T/Z) и момент времени сохраняется. func downRFC3339Time(ctx context.Context, tx *sql.Tx) error { return rebuildTimeColumns(ctx, tx, " DEFAULT (datetime('now'))", sqliteExpr) } // rfc3339Expr / sqliteExpr — SQL-выражение переформатирования метки col в // целевой формат (strftime корректно разбирает и пробел-, и T/Z-форму на входе). func rfc3339Expr(col string) string { return fmt.Sprintf("strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', %s)", col) } func sqliteExpr(col string) string { return fmt.Sprintf("strftime('%%Y-%%m-%%d %%H:%%M:%%S', %s)", col) } // rebuildTimeColumns перестраивает все таблицы с временными метками: tsDefault — // суффикс DEFAULT для колонок created_at/updated_at ("" — без дефолта); ts — // выражение переформатирования метки при копировании. func rebuildTimeColumns(ctx context.Context, tx *sql.Tx, tsDefault string, ts func(string) string) error { stmts := make([]string, 0, 28) stmts = append(stmts, createTimeTables(tsDefault)...) stmts = append(stmts, copyTimeTables(ts)...) stmts = append(stmts, dropRenameReindex...) for _, stmt := range stmts { if _, err := tx.ExecContext(ctx, stmt); err != nil { return fmt.Errorf("rfc3339 time migration: %q: %w", stmt, err) } } return checkForeignKeys(ctx, tx) } // createTimeTables — CREATE *_new для всех таблиц; d подставляет DEFAULT на // метки created_at/updated_at (прочие DEFAULT — ”, 0, '[]', 1 — неизменны). func createTimeTables(d string) []string { return []string{ fmt.Sprintf(`CREATE TABLE download_new ( id TEXT PRIMARY KEY, source_type TEXT NOT NULL, source_ref TEXT NOT NULL, display_name TEXT NOT NULL DEFAULT '', context TEXT NOT NULL DEFAULT '', state TEXT NOT NULL, error_code TEXT, error_msg TEXT, source_miss_count INTEGER NOT NULL DEFAULT 0, source_added_at TEXT, created_at TEXT NOT NULL%[1]s, updated_at TEXT NOT NULL%[1]s )`, d), fmt.Sprintf(`CREATE TABLE download_infohash_new ( download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE, infohash TEXT NOT NULL, kind TEXT NOT NULL, created_at TEXT NOT NULL%[1]s, PRIMARY KEY (infohash, download_id) )`, d), fmt.Sprintf(`CREATE TABLE recognition_new ( id TEXT PRIMARY KEY, download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE, attempt_no INTEGER NOT NULL DEFAULT 1, is_current INTEGER NOT NULL DEFAULT 1, media_type TEXT, title TEXT, original_title TEXT, year INTEGER, provider TEXT, provider_id TEXT, confidence REAL, reasons TEXT NOT NULL DEFAULT '[]', raw_llm TEXT, plan TEXT, created_at TEXT NOT NULL%[1]s )`, d), fmt.Sprintf(`CREATE TABLE hint_new ( id TEXT PRIMARY KEY, download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE, text TEXT NOT NULL, created_at TEXT NOT NULL%[1]s )`, d), fmt.Sprintf(`CREATE TABLE override_new ( id TEXT PRIMARY KEY, download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE, field TEXT NOT NULL, value TEXT NOT NULL, created_at TEXT NOT NULL%[1]s, UNIQUE (download_id, field) )`, d), fmt.Sprintf(`CREATE TABLE metadata_candidate_new ( id TEXT PRIMARY KEY, recognition_id TEXT NOT NULL REFERENCES recognition_new (id) ON DELETE CASCADE, provider TEXT NOT NULL, provider_id TEXT NOT NULL, title TEXT, year INTEGER, chosen INTEGER NOT NULL DEFAULT 0, url TEXT, created_at TEXT NOT NULL%[1]s )`, d), fmt.Sprintf(`CREATE TABLE file_link_new ( id TEXT PRIMARY KEY, download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE, apply_batch_id TEXT NOT NULL, src_path TEXT NOT NULL, dst_path TEXT NOT NULL, kind TEXT NOT NULL, status TEXT NOT NULL, size INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL%[1]s )`, d), } } // copyTimeTables — INSERT ... SELECT в *_new с переформатированием меток через // ts(col). Списки колонок явные; strftime(NULL) = NULL — nullable source_added_at // корректно щадится. func copyTimeTables(ts func(string) string) []string { return []string{ fmt.Sprintf(`INSERT INTO download_new (id, source_type, source_ref, display_name, context, state, error_code, error_msg, source_miss_count, source_added_at, created_at, updated_at) SELECT id, source_type, source_ref, display_name, context, state, error_code, error_msg, source_miss_count, %s, %s, %s FROM download`, ts("source_added_at"), ts("created_at"), ts("updated_at")), fmt.Sprintf(`INSERT INTO download_infohash_new (download_id, infohash, kind, created_at) SELECT download_id, infohash, kind, %s FROM download_infohash`, ts("created_at")), fmt.Sprintf(`INSERT INTO recognition_new (id, download_id, attempt_no, is_current, media_type, title, original_title, year, provider, provider_id, confidence, reasons, raw_llm, plan, created_at) SELECT id, download_id, attempt_no, is_current, media_type, title, original_title, year, provider, provider_id, confidence, reasons, raw_llm, plan, %s FROM recognition`, ts("created_at")), fmt.Sprintf(`INSERT INTO hint_new (id, download_id, text, created_at) SELECT id, download_id, text, %s FROM hint`, ts("created_at")), fmt.Sprintf(`INSERT INTO override_new (id, download_id, field, value, created_at) SELECT id, download_id, field, value, %s FROM override`, ts("created_at")), fmt.Sprintf(`INSERT INTO metadata_candidate_new (id, recognition_id, provider, provider_id, title, year, chosen, url, created_at) SELECT id, recognition_id, provider, provider_id, title, year, chosen, url, %s FROM metadata_candidate`, ts("created_at")), fmt.Sprintf(`INSERT INTO file_link_new (id, download_id, apply_batch_id, src_path, dst_path, kind, status, size, created_at) SELECT id, download_id, apply_batch_id, src_path, dst_path, kind, status, size, %s FROM file_link`, ts("created_at")), } } // dropRenameReindex — удаление старых таблиц (дети первыми, FK включены), // переименование *_new и пересоздание индексов (канонический набор из 0006). var dropRenameReindex = []string{ `DROP TABLE file_link`, `DROP TABLE metadata_candidate`, `DROP TABLE override`, `DROP TABLE hint`, `DROP TABLE recognition`, `DROP TABLE download_infohash`, `DROP TABLE download`, `ALTER TABLE download_new RENAME TO download`, `ALTER TABLE download_infohash_new RENAME TO download_infohash`, `ALTER TABLE recognition_new RENAME TO recognition`, `ALTER TABLE hint_new RENAME TO hint`, `ALTER TABLE override_new RENAME TO override`, `ALTER TABLE metadata_candidate_new RENAME TO metadata_candidate`, `ALTER TABLE file_link_new RENAME TO file_link`, `CREATE INDEX idx_download_state ON download (state)`, `CREATE INDEX idx_download_infohash_download ON download_infohash (download_id)`, `CREATE INDEX idx_recognition_download ON recognition (download_id)`, `CREATE INDEX idx_hint_download ON hint (download_id)`, `CREATE INDEX idx_candidate_recognition ON metadata_candidate (recognition_id)`, `CREATE INDEX idx_file_link_download ON file_link (download_id)`, `CREATE INDEX idx_file_link_batch ON file_link (apply_batch_id)`, }