Хранение времени: 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,2 @@
schema: spec-driven
created: 2026-07-04
@@ -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]`** — вводится ради одной настройки, но это
осознанный дом для будущих общих настроек; в примере идёт первой секцией.
@@ -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-корреляция и хронология сохраняются (значения меток
эквивалентны по моменту); секунды не теряются.
@@ -0,0 +1,30 @@
## ADDED Requirements
### Requirement: Таймзона отображения времени
Веб-UI SHALL отображать все временные метки (абсолютные даты добавления и
создания, относительная давность) в **таймзоне отображения из конфигурации**
(`[general].timezone`, дефолт `UTC`). Зона MUST NOT быть зашита в код.
Метки в БД хранятся всегда в UTC (RFC 3339); конвертация в зону отображения
SHALL выполняться только на рендеринге, не затрагивая хранение и сортировку.
База зон (zoneinfo) SHALL встраиваться в бинарь (`time/tzdata`), поэтому зоны
доступны независимо от окружения контейнера. Некорректное (нераспознаваемое)
значение `timezone` в конфиге SHALL приводить к ошибке на старте приложения
(валидация конфигурации), а не к тихой подмене зоны на рендеринге.
#### Scenario: Дата в сконфигурированной зоне
- **WHEN** в конфиге `timezone = "Europe/Moscow"` и рендерится карточка загрузки
- **THEN** абсолютная дата добавления показана в зоне `Europe/Moscow`
- **AND** та же метка в БД хранится в UTC с суффиксом `Z`
#### Scenario: Зона по умолчанию — UTC
- **WHEN** `[general].timezone` в конфиге не задан
- **THEN** времена в веб-UI отображаются в UTC
#### Scenario: Невалидная зона в конфиге
- **WHEN** `[general].timezone` содержит нераспознаваемое значение
- **THEN** приложение завершается с ошибкой конфигурации на старте
@@ -0,0 +1,76 @@
## 1. Ревью дизайна (чекпоинт ДО кода)
- [x] 1.1 Согласовать design.md: стратегию миграции/backfill, снятие `DEFAULT`,
секцию `[general]` с `timezone`. Правки внести до реализации.
## 2. Хелперы формата времени (store)
- [x] 2.1 `internal/store/download.go`: убрать `sqliteTimeLayout`; `FormatTime`
`t.UTC().Format(time.RFC3339)`, `ParseTime``time.Parse(time.RFC3339, s)`.
Обновить комментарии (формат `...Z`, не `datetime('now')`), включая коммент про
`SourceAddedAt` на `download.go:118` (ссылается на `sqliteTimeLayout`).
- [x] 2.2 Добавить `store.Now()` (обёртка над `time.Now().UTC()`) как единую
точку получения «сейчас» в слое store.
- [x] 2.3 Проверить `migrations/0006_ulid_identity.go:204` (`parseCreatedAt`
парсит старый формат) — это историческая миграция, формат менять не нужно;
убедиться, что она не ломается и не пересекается с новой.
## 3. Убрать DEFAULT и писать время из Go
- [x] 3.1 Каждый INSERT-**сайт** со временем явно передаёт `created_at`/`updated_at`
(= `FormatTime(store.Now())`). Полный список сайтов (не методов!):
`download.go``CreateDownloadIfNoActive` (вставка download :219 + вставка
хешей :228), dedup top-up хешей :203 (`INSERT OR IGNORE`), `AddInfohashes` :306
(`INSERT OR IGNORE`); `recognition.go``CreateRecognition` :72, `AddHint` :108,
`SetOverride` :130, `AddFileLink` :185, `AddMetadataCandidate` :332. Для
`INSERT OR IGNORE` в `download_infohash` `created_at` обязателен на новой строке
(после снятия `DEFAULT` + `NOT NULL`); при конфликте PK строка не вставляется — ок.
- [x] 3.2 `download.go:510`: инлайн `updated_at = datetime('now')` → параметр
`FormatTime(store.Now())` (sortability-ловушка — проверить, что не осталось
сырых `datetime('now')` в SQL: `grep -rn "datetime('now')" internal`).
## 4. Миграция БД (goose)
- [x] 4.1 Новая миграция (стиль `0006`): backfill каждой timestamp-колонки
`strftime('%Y-%m-%dT%H:%M:%SZ', col)` (7 таблиц: download.created_at/updated_at/
source_added_at, download_infohash/recognition/hint/override/metadata_candidate/
file_link .created_at) + rebuild таблиц без `DEFAULT` на timestamp-колонках.
- [x] 4.2 Down-миграция: обратный backfill `strftime('%Y-%m-%d %H:%M:%S', col)` +
восстановление `DEFAULT`.
- [x] 4.3 Тест миграции: строки со старым форматом → новый формат; хронология
сортировки сохранена; `source_added_at IS NULL` не затронут; обратный backfill
(down) на строке с `T`/`Z` даёт старый формат.
- [x] 4.4 Обновить существующий `TestUlidMigration` (`migration_test.go:100-101`):
ассерт ждёт старый формат `2026-01-01 10:00:00`, после новой миграции метки
станут `...T...Z` — поправить ожидаемое (заодно канарейка, что backfill отработал).
## 5. Настройка таймзоны отображения
- [x] 5.1 `internal/config`: секция `[general]` (`General struct`) с полем
`Timezone string` (дефолт `UTC`) + распарсенный `*time.Location`; жёсткая
валидация на старте через `time.LoadLocation`, невалидная зона → ошибка
конфигурации (без рантайм-фолбэка).
- [x] 5.1a Встроить zoneinfo: `import _ "time/tzdata"` в точке входа
(`cmd/jellybit`). Тогда `LoadLocation` падает только на битом имени.
- [x] 5.2 `internal/httpapi`: пробросить `*time.Location` до `fmtDate`/`humanizeAge`
(свободные функции — тянуть параметром через `toView`, вызовы `httpapi.go:288,545`,
либо сделать методами `server`); заменить зашитый `mskLoc` (`live.go:189-201`)
целиком (после встраивания tzdata рантайм-фолбэк на UTC внутри `mskLoc` не нужен).
Guard: `t.In(nil)` паникует — при незаданной зоне подставлять `time.UTC`; тест-хелпер
`testRouterLive` тоже задаёт loc.
- [x] 5.3 Обновить пример конфига (`config.example.toml`/деплой): секция
`[general]` первой, `timezone`; для этого сервера — `Europe/Moscow`.
## 6. Документация
- [x] 6.1 `docs/conventions/database.md:42-43`: формат меток → RFC 3339 (UTC,
`Z`), генерация только из Go (`store.Now`/`FormatTime`), без `DEFAULT`.
- [x] 6.2 `docs/conventions/config.md`: зона отображения конфигурируема (дефолт
UTC); хранение и логи — UTC.
- [x] 6.3 `docs/specs/database.md`: комментарий формата timestamp-колонок в ER.
## 7. Проверка и ревью кода (чекпоинт до archive)
- [x] 7.1 `task test` / `task lint` зелёные; тест сортировки списка на смешанных
и однотипных метках.
- [x] 7.2 Ревью кода реализации; затем `openspec validate --strict` и archive.
+29
View File
@@ -333,3 +333,32 @@ MUST NOT показываться в карточке списка — он до
- **THEN** он не показывается в карточке списка, но доступен на странице
`/download/{id}`
### Requirement: Таймзона отображения времени
Веб-UI SHALL отображать все временные метки (абсолютные даты добавления и
создания, относительная давность) в **таймзоне отображения из конфигурации**
(`[general].timezone`, дефолт `UTC`). Зона MUST NOT быть зашита в код.
Метки в БД хранятся всегда в UTC (RFC 3339); конвертация в зону отображения
SHALL выполняться только на рендеринге, не затрагивая хранение и сортировку.
База зон (zoneinfo) SHALL встраиваться в бинарь (`time/tzdata`), поэтому зоны
доступны независимо от окружения контейнера. Некорректное (нераспознаваемое)
значение `timezone` в конфиге SHALL приводить к ошибке на старте приложения
(валидация конфигурации), а не к тихой подмене зоны на рендеринге.
#### Scenario: Дата в сконфигурированной зоне
- **WHEN** в конфиге `timezone = "Europe/Moscow"` и рендерится карточка загрузки
- **THEN** абсолютная дата добавления показана в зоне `Europe/Moscow`
- **AND** та же метка в БД хранится в UTC с суффиксом `Z`
#### Scenario: Зона по умолчанию — UTC
- **WHEN** `[general].timezone` в конфиге не задан
- **THEN** времена в веб-UI отображаются в UTC
#### Scenario: Невалидная зона в конфиге
- **WHEN** `[general].timezone` содержит нераспознаваемое значение
- **THEN** приложение завершается с ошибкой конфигурации на старте