Хранение времени: 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,57 @@
## Why
Временные метки в БД хранятся как TEXT в формате `datetime('now')`
`2006-01-02 15:04:05`: пробел вместо `T`, без суффикса зоны. UTC гарантируется
только конвенцией и кодом, но не самим значением — тот, кто откроет базу
напрямую (sqlite3 CLI, дамп, сторонний инструмент), не видит зону, а строка не
является валидным ISO 8601 (footgun для `new Date()` в веб-UI, требует
переформатирования для JSON API / `<time datetime>`). Одновременно зона
отображения зашита в код (`Europe/Moscow`), хотя серверы часто настроены по UTC.
## What Changes
- Формат хранения меток времени переходит на **RFC 3339, всегда UTC с суффиксом
`Z`** (`2006-01-02T15:04:05Z`). Секундная точность сохраняется. Фиксированная
ширина сохраняет лексикографическую сортировку TEXT = хронологию.
- **Единая точка генерации времени в Go** (зеркально принципу «ULID генерит
только `internal/ident`»): `DEFAULT (datetime('now'))` **убирается** со всех
колонок; метки времени всегда пишет приложение через `store.FormatTime`.
- Go-хелперы `FormatTime`/`ParseTime` переводятся на stdlib `time.RFC3339`.
- **BREAKING (хранение):** одноразовая goose-миграция пересобирает таблицы без
`DEFAULT` и backfill-ит существующие строки в новый формат. Backfill + смена
схемы + смена кода — один change, чтобы не возникло окна со смешанными
форматами (иначе ломается лексикографическая сортировка `COALESCE`).
- Добавляется секция конфига **`[general]`** (общие настройки) с явной настройкой
**таймзоны отображения** (`timezone`, дефолт `UTC`), валидируемой на старте через
`time.LoadLocation`. Хранение остаётся всегда UTC — настройка влияет только на
рендеринг времени в веб-UI, заменяя зашитый `Europe/Moscow`.
## Capabilities
### New Capabilities
_Нет._ Формат хранения времени — кросс-каттинг **конвенция** (`how we store`),
по правилам проекта живёт в `docs/conventions/database.md`, а не в OpenSpec.
### Modified Capabilities
- `web-ui`: временные метки в интерфейсе SHALL отображаться в таймзоне из
конфига (`[general].timezone`, дефолт UTC), а не в жёстко зашитой `Europe/Moscow`.
## Impact
- **Код:** `internal/store/download.go` (`sqliteTimeLayout``time.RFC3339`,
`FormatTime`/`ParseTime`, инлайн `updated_at = datetime('now')`); Create-методы
`store` (`download.go`, `recognition.go`) — все INSERT, полагавшиеся на
`DEFAULT`, теперь явно передают `created_at`/`updated_at`; новый `store.Now()`
как единая тестируемая точка.
- **Миграции:** новая goose-миграция — rebuild 7 таблиц без `DEFAULT` + backfill
колонок `created_at`/`updated_at`/`source_added_at`.
- **Конфиг:** новая секция `[general]` с полем `timezone` в `internal/config`,
жёсткая валидация на старте, проброс зоны в `internal/httpapi` (замена `mskLoc`
в `live.go`). Встраивание zoneinfo — `import _ "time/tzdata"` в `cmd/jellybit`
(устраняет latent silent-UTC на контейнере без tzdata; +~450КБ к бинарю).
- **Docs:** `docs/conventions/database.md`, `docs/conventions/config.md`,
`docs/specs/database.md` (комментарий формата в ER-схеме).
- **Совместимость:** ULID-корреляция и хронология сохраняются (значения меток
эквивалентны по моменту); секунды не теряются.