Хранение времени: 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
@@ -0,0 +1,76 @@
## 1. Ревью дизайна (чекпоинт ДО кода)
- [x] 1.1 Согласовать design.md: стратегию миграции/backfill, снятие `DEFAULT`,
секцию `[general]` с `timezone`. Правки внести до реализации.
## 2. Хелперы формата времени (store)
- [x] 2.1 `internal/store/download.go`: убрать `sqliteTimeLayout`; `FormatTime`
`t.UTC().Format(time.RFC3339)`, `ParseTime``time.Parse(time.RFC3339, s)`.
Обновить комментарии (формат `...Z`, не `datetime('now')`), включая коммент про
`SourceAddedAt` на `download.go:118` (ссылается на `sqliteTimeLayout`).
- [x] 2.2 Добавить `store.Now()` (обёртка над `time.Now().UTC()`) как единую
точку получения «сейчас» в слое store.
- [x] 2.3 Проверить `migrations/0006_ulid_identity.go:204` (`parseCreatedAt`
парсит старый формат) — это историческая миграция, формат менять не нужно;
убедиться, что она не ломается и не пересекается с новой.
## 3. Убрать DEFAULT и писать время из Go
- [x] 3.1 Каждый INSERT-**сайт** со временем явно передаёт `created_at`/`updated_at`
(= `FormatTime(store.Now())`). Полный список сайтов (не методов!):
`download.go``CreateDownloadIfNoActive` (вставка download :219 + вставка
хешей :228), dedup top-up хешей :203 (`INSERT OR IGNORE`), `AddInfohashes` :306
(`INSERT OR IGNORE`); `recognition.go``CreateRecognition` :72, `AddHint` :108,
`SetOverride` :130, `AddFileLink` :185, `AddMetadataCandidate` :332. Для
`INSERT OR IGNORE` в `download_infohash` `created_at` обязателен на новой строке
(после снятия `DEFAULT` + `NOT NULL`); при конфликте PK строка не вставляется — ок.
- [x] 3.2 `download.go:510`: инлайн `updated_at = datetime('now')` → параметр
`FormatTime(store.Now())` (sortability-ловушка — проверить, что не осталось
сырых `datetime('now')` в SQL: `grep -rn "datetime('now')" internal`).
## 4. Миграция БД (goose)
- [x] 4.1 Новая миграция (стиль `0006`): backfill каждой timestamp-колонки
`strftime('%Y-%m-%dT%H:%M:%SZ', col)` (7 таблиц: download.created_at/updated_at/
source_added_at, download_infohash/recognition/hint/override/metadata_candidate/
file_link .created_at) + rebuild таблиц без `DEFAULT` на timestamp-колонках.
- [x] 4.2 Down-миграция: обратный backfill `strftime('%Y-%m-%d %H:%M:%S', col)` +
восстановление `DEFAULT`.
- [x] 4.3 Тест миграции: строки со старым форматом → новый формат; хронология
сортировки сохранена; `source_added_at IS NULL` не затронут; обратный backfill
(down) на строке с `T`/`Z` даёт старый формат.
- [x] 4.4 Обновить существующий `TestUlidMigration` (`migration_test.go:100-101`):
ассерт ждёт старый формат `2026-01-01 10:00:00`, после новой миграции метки
станут `...T...Z` — поправить ожидаемое (заодно канарейка, что backfill отработал).
## 5. Настройка таймзоны отображения
- [x] 5.1 `internal/config`: секция `[general]` (`General struct`) с полем
`Timezone string` (дефолт `UTC`) + распарсенный `*time.Location`; жёсткая
валидация на старте через `time.LoadLocation`, невалидная зона → ошибка
конфигурации (без рантайм-фолбэка).
- [x] 5.1a Встроить zoneinfo: `import _ "time/tzdata"` в точке входа
(`cmd/jellybit`). Тогда `LoadLocation` падает только на битом имени.
- [x] 5.2 `internal/httpapi`: пробросить `*time.Location` до `fmtDate`/`humanizeAge`
(свободные функции — тянуть параметром через `toView`, вызовы `httpapi.go:288,545`,
либо сделать методами `server`); заменить зашитый `mskLoc` (`live.go:189-201`)
целиком (после встраивания tzdata рантайм-фолбэк на UTC внутри `mskLoc` не нужен).
Guard: `t.In(nil)` паникует — при незаданной зоне подставлять `time.UTC`; тест-хелпер
`testRouterLive` тоже задаёт loc.
- [x] 5.3 Обновить пример конфига (`config.example.toml`/деплой): секция
`[general]` первой, `timezone`; для этого сервера — `Europe/Moscow`.
## 6. Документация
- [x] 6.1 `docs/conventions/database.md:42-43`: формат меток → RFC 3339 (UTC,
`Z`), генерация только из Go (`store.Now`/`FormatTime`), без `DEFAULT`.
- [x] 6.2 `docs/conventions/config.md`: зона отображения конфигурируема (дефолт
UTC); хранение и логи — UTC.
- [x] 6.3 `docs/specs/database.md`: комментарий формата timestamp-колонок в ER.
## 7. Проверка и ревью кода (чекпоинт до archive)
- [x] 7.1 `task test` / `task lint` зелёные; тест сортировки списка на смешанных
и однотипных метках.
- [x] 7.2 Ревью кода реализации; затем `openspec validate --strict` и archive.