Files
avandClaude Opus 4.8 5d5456fa68 Хранение времени: RFC 3339 (UTC) + таймзона отображения в конфиге
Метки времени в SQLite переведены с формата datetime('now')
(«2006-01-02 15:04:05») на RFC 3339 всегда-UTC («2006-01-02T15:04:05Z»):
самоописываемое хранилище (зона в значении), валидный ISO 8601, единый
формат с логами. Фиксированная ширина сохраняет лексикографическую
сортировку TEXT = хронологию (COALESCE(source_added_at, created_at)).

- Единая точка генерации времени в Go: store.Now()/FormatTime; DEFAULT
  (datetime('now')) снят со всех колонок — время всегда пишет приложение
  (зеркально ident.NewID для id), fail-loud при забытой вставке (NOT NULL).
  Все INSERT-сайты в store передают created_at/updated_at явно.
- Миграция 0008 (rebuild 7 таблиц без DEFAULT + backfill strftime, FK/PK/
  индексы сохранены байт-в-байт по образцу 0006); симметричная down.
- Новая секция конфига [general] с полем timezone (дефолт UTC) — зона
  ОТОБРАЖЕНИЯ в веб-UI; хранение остаётся UTC. Жёсткая валидация зоны на
  старте; zoneinfo встроен (time/tzdata), заменён зашитый Europe/Moscow.
- Тесты: round-trip миграции (up/down, NULL source_added_at), валидация
  зоны, сдвиг даты по зоне; обновлены фикстуры и TestUlidMigration.
- Docs: конвенции database/config, ER-схема; спека web-ui (таймзона).

OpenSpec change time-storage-rfc3339 (заархивирован).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 11:32:07 +03:00

203 lines
9.4 KiB
Go

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)`,
}