Хранение времени: 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>
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
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)`,
|
||||
}
|
||||
Reference in New Issue
Block a user