Хранение времени: 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:
av
2026-07-04 11:32:07 +03:00
co-authored by Claude Opus 4.8
parent bb245a90a3
commit 5d5456fa68
23 changed files with 766 additions and 69 deletions
+16 -13
View File
@@ -71,11 +71,12 @@ func (s *Store) CreateRecognition(ctx context.Context, r *Recognition, reasons [
const q = `
INSERT INTO recognition
(id, download_id, attempt_no, is_current, media_type, title, original_title,
year, provider, provider_id, confidence, reasons, raw_llm, plan)
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
year, provider, provider_id, confidence, reasons, raw_llm, plan, created_at)
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
if _, err := tx.ExecContext(ctx, q,
r.ID, r.DownloadID, nextAttempt, r.MediaType, r.Title, r.OriginalTitle,
r.Year, r.Provider, r.ProviderID, r.Confidence, string(reasonsJSON), r.RawLLM, r.Plan); err != nil {
r.Year, r.Provider, r.ProviderID, r.Confidence, string(reasonsJSON), r.RawLLM, r.Plan,
FormatTime(Now())); err != nil {
return "", fmt.Errorf("insert recognition: %w", err)
}
if err := tx.Commit(); err != nil {
@@ -105,8 +106,8 @@ func (s *Store) GetCurrentRecognition(ctx context.Context, downloadID string) (*
// AddHint добавляет текстовую подсказку ревьюера к загрузке.
func (s *Store) AddHint(ctx context.Context, downloadID string, text string) error {
if _, err := s.DB.ExecContext(ctx,
`INSERT INTO hint (id, download_id, text) VALUES (?, ?, ?)`,
ident.NewID(), downloadID, text); err != nil {
`INSERT INTO hint (id, download_id, text, created_at) VALUES (?, ?, ?, ?)`,
ident.NewID(), downloadID, text, FormatTime(Now())); err != nil {
return fmt.Errorf("add hint: %w", err)
}
return nil
@@ -127,9 +128,9 @@ func (s *Store) ListHints(ctx context.Context, downloadID string) ([]string, err
// SetOverride пиннит значение поля (upsert по (download_id, field)).
func (s *Store) SetOverride(ctx context.Context, downloadID string, field, value string) error {
const q = `
INSERT INTO override (id, download_id, field, value) VALUES (?, ?, ?, ?)
INSERT INTO override (id, download_id, field, value, created_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (download_id, field) DO UPDATE SET value = excluded.value`
if _, err := s.DB.ExecContext(ctx, q, ident.NewID(), downloadID, field, value); err != nil {
if _, err := s.DB.ExecContext(ctx, q, ident.NewID(), downloadID, field, value, FormatTime(Now())); err != nil {
return fmt.Errorf("set override %q: %w", field, err)
}
return nil
@@ -181,12 +182,13 @@ func (s *Store) CreateFileLinks(ctx context.Context, links []FileLink) error {
}
defer func() { _ = tx.Rollback() }()
now := FormatTime(Now())
const q = `
INSERT INTO file_link (id, download_id, apply_batch_id, src_path, dst_path, kind, status, size)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
INSERT INTO file_link (id, download_id, apply_batch_id, src_path, dst_path, kind, status, size, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
for _, l := range links {
if _, err := tx.ExecContext(ctx, q,
ident.NewID(), l.DownloadID, l.ApplyBatchID, l.SrcPath, l.DstPath, l.Kind, l.Status, l.Size); err != nil {
ident.NewID(), l.DownloadID, l.ApplyBatchID, l.SrcPath, l.DstPath, l.Kind, l.Status, l.Size, now); err != nil {
return fmt.Errorf("insert file_link: %w", err)
}
}
@@ -328,12 +330,13 @@ func (s *Store) CreateCandidates(ctx context.Context, cands []MetadataCandidate)
}
defer func() { _ = tx.Rollback() }()
now := FormatTime(Now())
const q = `
INSERT INTO metadata_candidate (id, recognition_id, provider, provider_id, title, year, url)
VALUES (?, ?, ?, ?, ?, ?, ?)`
INSERT INTO metadata_candidate (id, recognition_id, provider, provider_id, title, year, url, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
for _, c := range cands {
if _, err := tx.ExecContext(ctx, q,
ident.NewID(), c.RecognitionID, c.Provider, c.ProviderID, c.Title, c.Year, c.URL); err != nil {
ident.NewID(), c.RecognitionID, c.Provider, c.ProviderID, c.Title, c.Year, c.URL, now); err != nil {
return fmt.Errorf("insert candidate: %w", err)
}
}