Метки времени в 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>
203 lines
9.4 KiB
Go
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)`,
|
|
}
|