Метки времени в 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>
12 KiB
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):
- Backfill каждой timestamp-колонки в новый формат прямо на месте:
UPDATE <t> SET <col> = strftime('%Y-%m-%dT%H:%M:%SZ', <col>) WHERE <col> IS NOT NULL.strftimeразбирает старый пробел-формат и переписывает в RFC 3339. Идемпотентно по инстанту (тот же момент времени). - Пересоздать таблицы без
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]— вводится ради одной настройки, но это осознанный дом для будущих общих настроек; в примере идёт первой секцией.