Хранение времени: 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:
@@ -0,0 +1,142 @@
|
||||
## 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]`** — вводится ради одной настройки, но это
|
||||
осознанный дом для будущих общих настроек; в примере идёт первой секцией.
|
||||
Reference in New Issue
Block a user