Files
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

12 KiB
Raw Permalink Blame History

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] — вводится ради одной настройки, но это осознанный дом для будущих общих настроек; в примере идёт первой секцией.