Files
jellybit/openspec/changes/archive/2026-07-04-time-storage-rfc3339/design.md
T
avandClaude Opus 4.8 5d5456fa68 Хранение времени: 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>
2026-07-04 11:32:07 +03:00

143 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Context
Метки времени хранятся как TEXT в формате `datetime('now')`
`2006-01-02 15:04:05` (UTC по конвенции, но не по значению). Формат выбирался
ради двух свойств: лексикографическая сортировка TEXT = хронология (нужно для
`ORDER BY COALESCE(source_added_at, created_at)` в списке загрузок) и байт-в-байт
совпадение меток из Go (`store.FormatTime`) с метками из `DEFAULT (datetime('now'))`.
Timestamp-колонки (актуальная схема, миграция `0006`): `download.created_at`,
`download.updated_at`, `download.source_added_at` (nullable), а также `created_at`
в `download_infohash`, `recognition`, `hint`, `override`, `metadata_candidate`,
`file_link` — всего 7 таблиц. Все INSERT в `store` сейчас полагаются на `DEFAULT`
(не перечисляют `created_at`). Одно инлайн-обновление вне хелпера:
`updated_at = datetime('now')` в `download.go:510`.
Зона отображения зашита в код: `mskLoc` (`Europe/Moscow`) в
`internal/httpapi/live.go:189-201`, используется `fmtDate`/`humanizeAge`.
## Goals / Non-Goals
**Goals:**
- Самоописываемое хранилище: зона зафиксирована в значении (RFC 3339, `Z`).
- Значения — валидный ISO 8601, пригодны напрямую для JSON API / `<time>` / JS.
- Единая, тестируемая точка генерации времени в Go (как `internal/ident` для id).
- Конфигурируемая зона отображения (дефолт UTC); хранение всегда UTC.
- Сохранить инвариант сортировки и ULID-корреляцию (хронология по `created_at`).
**Non-Goals:**
- Не меняем точность (остаётся секундная).
- Не трогаем формат времени в логах (уже RFC 3339 с долями секунды).
- Не вводим хранение в локальной зоне — БД остаётся строго UTC.
- Не добавляем таблицу/структуру данных (только формат значений и `DEFAULT`).
## Decisions
### 1. Формат хранения — RFC 3339, всегда UTC (`...Z`)
Форма `2006-01-02T15:04:05Z` = stdlib `time.RFC3339` для UTC-времени. Фиксированная
ширина (20 символов, константные `T` и `Z`) сохраняет лексикографический порядок,
поэтому `ORDER BY COALESCE(source_added_at, created_at)` продолжает давать верную
хронологию при смешивании заполненных и фолбек-значений.
`internal/store/download.go`:
- `sqliteTimeLayout` удаляется, `FormatTime(t) = t.UTC().Format(time.RFC3339)`,
`ParseTime(s) = time.Parse(time.RFC3339, s)`.
- `time.Parse(time.RFC3339, "...Z")` даёт `time.Time` в UTC — совместимо с
текущим контрактом `ParseTime` (всегда UTC).
### 2. Единая точка генерации: убрать `DEFAULT`, писать из Go
`DEFAULT (datetime('now'))` **снимается** со всех колонок. Время всегда пишет
приложение — зеркально принципу «ULID генерит только `internal/ident`». Вводим
`store.Now() time.Time` (тонкая обёртка над `time.Now().UTC()`) как единственную
точку получения «сейчас» в слое store — тестируемую и единообразную.
- Каждый **INSERT-сайт** (не метод!) явно передаёт `created_at` в INSERT. Сайтов
больше, чем методов: в `CreateDownloadIfNoActive` — вставка `download` и вставка
хешей `download_infohash`; плюс dedup top-up хешей и `AddInfohashes`
(`INSERT OR IGNORE`) — после снятия `DEFAULT`+`NOT NULL` время обязательно на
новой строке. Полный список — в tasks 3.1.
- Инлайн `updated_at = datetime('now')` (`download.go:510`) → параметр
`FormatTime(store.Now())`. **Ключевая ловушка:** нельзя оставить рядом
`datetime('now')` (пробел, без `Z`) и RFC 3339 из кода — сортировка сломается.
Почему убираем `DEFAULT`, а не переписываем его на `strftime(...Z)`: единая точка
генерации ценнее «страховочного» дефолта; два источника формата (SQLite-дефолт и
Go-хелпер) — именно то, что порождает риск расхождения. Побочная выгода —
**fail-loud**: забытый INSERT падает по `NOT NULL`, а не пишет тихо старый формат.
Выбор пользователя.
### 3. Миграция: rebuild без `DEFAULT` + backfill, атомарно
Одна goose-миграция (стиль `0006` — Go или SQL, table-rebuild, т.к. SQLite не
умеет `ALTER COLUMN ... DROP DEFAULT`):
1. Backfill каждой timestamp-колонки в новый формат прямо на месте:
`UPDATE <t> SET <col> = strftime('%Y-%m-%dT%H:%M:%SZ', <col>)
WHERE <col> IS NOT NULL`. `strftime` разбирает старый пробел-формат и
переписывает в RFC 3339. Идемпотентно по инстанту (тот же момент времени).
2. Пересоздать таблицы **без** `DEFAULT` на timestamp-колонках (в остальном
схема без изменений), перенести данные, FK, индексы.
Все три части (backfill + смена схемы + смена Go-кода) — **один change**: между
ними не должно быть окна, где часть строк в старом формате, а новые пишутся в
новом (смешанный формат ломает лексикографическое сравнение).
Порядок в миграции: сначала backfill существующих строк, затем rebuild — либо, по
образцу `0006`, всё внутри одной транзакции с temp-таблицами. Конкретную технику
(in-place `UPDATE` + rebuild vs. rebuild с `strftime` в `INSERT ... SELECT`)
фиксируем на этапе apply; обе эквивалентны по результату.
**Критично при rebuild:** новая схема ОБЯЗАНА байт-в-байт повторить из `0006`
все `REFERENCES ... ON DELETE CASCADE`, PK (включая составной
`download_infohash (infohash, download_id)`), `UNIQUE (override)` и индексы —
иначе тихо теряется каскад/индекс. Rebuild нужен только затем, что SQLite не умеет
`ALTER COLUMN DROP DEFAULT`; это самая рискованная часть change (ср. объём `0006`).
### 4. Настройка зоны отображения в конфиге
Новая секция `[general]` (общие настройки приложения), поле `timezone`, дефолт
`UTC`:
- Размещение: секция `[general]` — дом для кросс-каттинг настроек приложения;
`timezone` — её первое поле. В примере конфига `[general]` идёт **первой**
секцией. Отдельная секция (а не корневой ключ) снимает вопрос TOML-порядка
корневых ключей и оставляет место для будущих общих настроек.
- `internal/config`: секция `General struct { Timezone string }` +
распарсенный `DisplayLocation *time.Location`,
распарсенное на старте через `time.LoadLocation`. Пустое значение → `UTC`.
Нераспознаваемая зона → ошибка валидации конфига на старте (не тихий фолбэк).
- **Встраиваем `time/tzdata`** (`import _ "time/tzdata"` в точке входа): zoneinfo
всегда в статическом бинаре, независимо от окружения контейнера. Тогда
`time.LoadLocation` падает **только** на реально битом имени → жёсткая
валидация на старте корректна и однозначна, а прежний тихий фолбэк на UTC при
«нет zoneinfo» (текущее latent-поведение `mskLoc` — молча показывает UTC на
контейнере без tzdata) устраняется. Цена — ~450КБ к размеру бинаря; приемлемо
для эта «один статический бинарь». Соответственно сценарий «нет zoneinfo →
деградация» из спеки убран — он недостижим при встроенной базе.
- Проброс `*time.Location` в `internal/httpapi`; `fmtDate`/`humanizeAge`
(свободные функции — тянем параметром через `toView` либо делаем методами
`server`) используют её вместо зашитого `mskLoc`/`Europe/Moscow`. Guard:
`t.In(nil)` паникует — при незаданной зоне подставляем `time.UTC`.
Хранение настройка НЕ затрагивает — только рендеринг. Логи остаются в UTC.
## Risks / Trade-offs
- **Смешанный формат при частичной выкатке** — главный риск; снимается тем, что
backfill, схема и код едут одним change/деплоем. Откат — обратной миграцией
(`strftime('%Y-%m-%d %H:%M:%S', col)`), но проще не разрывать выкатку.
- **Пропущенное место записи** — любой забытый `datetime('now')` или сырой INSERT
без `created_at` даст рассинхрон. Митигируется аудитом (grep из proposal
показывает полный список: 7 Create-методов + 1 инлайн) и тестом сортировки.
- **Стороннее чтение старого дампа** — значения в старом формате остаются
парсибельными `strftime`, но `ParseTime` после перехода ждёт RFC 3339; это ок,
т.к. в живой БД после миграции все строки уже в новом формате.
- **Зашитое `2006-01-02` форматирование даты в UI** остаётся (это формат вывода,
не хранения) — меняется только зона, в которую переводится `time.Time`.
- **Новая секция `[general]`** — вводится ради одной настройки, но это
осознанный дом для будущих общих настроек; в примере идёт первой секцией.