Хранение времени: 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:
@@ -11,6 +11,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
_ "time/tzdata" // встроенная база zoneinfo: general.timezone работает на любом хосте
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/logging"
|
"git.vakhrushev.me/av/jellybit/internal/logging"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -148,6 +148,10 @@ func runServe(args []string) error {
|
|||||||
logger.Info("jellyfin rescan enabled", "url", cfg.Jellyfin.URL)
|
logger.Info("jellyfin rescan enabled", "url", cfg.Jellyfin.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loc, err := cfg.DisplayLocation() // валидность уже проверена config.Load
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
router, err := httpapi.NewRouter(httpapi.Deps{
|
router, err := httpapi.NewRouter(httpapi.Deps{
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
Ingestor: ingestor,
|
Ingestor: ingestor,
|
||||||
@@ -155,6 +159,7 @@ func runServe(args []string) error {
|
|||||||
Reader: st,
|
Reader: st,
|
||||||
Reviewer: wrk,
|
Reviewer: wrk,
|
||||||
Live: wrk,
|
Live: wrk,
|
||||||
|
Loc: loc,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
# config.toml в рабочей директории; путь переопределяется опцией --config=path.
|
# config.toml в рабочей директории; путь переопределяется опцией --config=path.
|
||||||
# Для локального запуска укажите существующие каталоги и db_path -> ./jellybit.db.
|
# Для локального запуска укажите существующие каталоги и db_path -> ./jellybit.db.
|
||||||
|
|
||||||
|
[general]
|
||||||
|
# Общие настройки приложения.
|
||||||
|
timezone = "UTC" # таймзона ОТОБРАЖЕНИЯ времени в веб-UI (IANA, напр. "Europe/Moscow"); хранение всегда в UTC. Пусто → UTC
|
||||||
|
|
||||||
[qbittorrent]
|
[qbittorrent]
|
||||||
url = "http://qbit:8989" # адрес qBittorrent WebUI; в docker-сети — по имени сервиса
|
url = "http://qbit:8989" # адрес qBittorrent WebUI; в docker-сети — по имени сервиса
|
||||||
username = "admin" # логин WebUI
|
username = "admin" # логин WebUI
|
||||||
|
|||||||
@@ -112,9 +112,17 @@ Ansible + Vault). Приложение просто читает TOML — отд
|
|||||||
- диапазоны: `recognition.auto_confidence_threshold` ∈ [0, 1],
|
- диапазоны: `recognition.auto_confidence_threshold` ∈ [0, 1],
|
||||||
`llm.max_retries` ≥ 0;
|
`llm.max_retries` ≥ 0;
|
||||||
- длительности парсятся (`llm.timeout`, `worker.poll_interval`, …);
|
- длительности парсятся (`llm.timeout`, `worker.poll_interval`, …);
|
||||||
|
- `general.timezone` — распознаваемая IANA-зона (валидируется
|
||||||
|
`time.LoadLocation`; zoneinfo встроен через `time/tzdata`, поэтому ошибка =
|
||||||
|
битое имя, а не отсутствие базы в окружении);
|
||||||
- включённые секции консистентны: `metadata.tmdb.enabled` → задан `api_key`;
|
- включённые секции консистентны: `metadata.tmdb.enabled` → задан `api_key`;
|
||||||
`jellyfin.enabled` → заданы `url`+`api_key`; `telegram.enabled` → `token`.
|
`jellyfin.enabled` → заданы `url`+`api_key`; `telegram.enabled` → `token`.
|
||||||
|
|
||||||
|
**Таймзоны.** Хранение времени в БД и логи — всегда UTC. Зона **отображения** в
|
||||||
|
веб-UI задаётся `[general].timezone` (дефолт `UTC`); только она конфигурируема,
|
||||||
|
на хранение/сортировку/логи не влияет. Бизнес-логика оперирует временем с явным
|
||||||
|
TZ (см. [CLAUDE.md](../../CLAUDE.md)).
|
||||||
|
|
||||||
## Структура в коде
|
## Структура в коде
|
||||||
|
|
||||||
- Весь разбор и валидация — в `internal/config`; наружу отдаётся готовая
|
- Весь разбор и валидация — в `internal/config`; наружу отдаётся готовая
|
||||||
|
|||||||
@@ -39,8 +39,14 @@
|
|||||||
|
|
||||||
- Enum-поля (`state`, `kind`, …) — обычный `TEXT` без `CHECK`; допустимые
|
- Enum-поля (`state`, `kind`, …) — обычный `TEXT` без `CHECK`; допустимые
|
||||||
значения держит код (`internal/store`).
|
значения держит код (`internal/store`).
|
||||||
- Временные метки — `TEXT DEFAULT (datetime('now'))` (UTC), формат
|
- Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, напр.
|
||||||
`store.ParseTime`/`FormatTime`.
|
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
|
||||||
|
лексикографическую сортировку TEXT = хронологию (`ORDER BY created_at`).
|
||||||
|
Единая точка генерации — приложение: `store.Now()` + `store.FormatTime`/
|
||||||
|
`ParseTime` (аналогично `ident.NewID` для id); `DEFAULT (datetime('now'))` на
|
||||||
|
колонках **не используется** (fail-loud при забытой вставке: `NOT NULL` без
|
||||||
|
дефолта). Зона хранения всегда UTC; таймзона отображения в UI — конфиг
|
||||||
|
`[general].timezone`.
|
||||||
- Миграции — goose (`internal/store/migrations`): SQL-файлы для DDL;
|
- Миграции — goose (`internal/store/migrations`): SQL-файлы для DDL;
|
||||||
Go-миграции (`goose.AddMigrationContext`) — когда нужен код (генерация
|
Go-миграции (`goose.AddMigrationContext`) — когда нужен код (генерация
|
||||||
id, backfill). При изменении структуры обновляем ER-схему
|
id, backfill). При изменении структуры обновляем ER-схему
|
||||||
|
|||||||
+14
-10
@@ -11,12 +11,16 @@
|
|||||||
>
|
>
|
||||||
> Состояние на: миграции `0001_init`, `0002_recognition_plan`,
|
> Состояние на: миграции `0001_init`, `0002_recognition_plan`,
|
||||||
> `0003_source_miss_count`, `0004_candidate_url`, `0005_display_name`,
|
> `0003_source_miss_count`, `0004_candidate_url`, `0005_display_name`,
|
||||||
> `0006_ulid_identity` (Go-миграция: ULID-идентификаторы, `download_infohash`).
|
> `0006_ulid_identity` (Go-миграция: ULID-идентификаторы, `download_infohash`),
|
||||||
|
> `0007_file_link_size`, `0008_rfc3339_time` (метки времени → RFC 3339 UTC,
|
||||||
|
> `DEFAULT` убран).
|
||||||
|
|
||||||
Назначение таблиц и почему так — [architecture.md](architecture.md) →
|
Назначение таблиц и почему так — [architecture.md](architecture.md) →
|
||||||
«Хранилище». Значения `state` и переходы — [workflow.md](workflow.md).
|
«Хранилище». Значения `state` и переходы — [workflow.md](workflow.md).
|
||||||
Первичные ключи — ULID (TEXT, lowercase), генерятся приложением
|
Первичные ключи — ULID (TEXT, lowercase), генерятся приложением
|
||||||
(`internal/ident`) — см. [конвенцию](../conventions/database.md).
|
(`internal/ident`) — см. [конвенцию](../conventions/database.md). Метки времени
|
||||||
|
(`created_at`/`updated_at`) — TEXT в RFC 3339, UTC (суффикс `Z`); пишет
|
||||||
|
приложение (`store.Now`/`FormatTime`), без `DEFAULT` на колонках.
|
||||||
|
|
||||||
## ER-диаграмма
|
## ER-диаграмма
|
||||||
|
|
||||||
@@ -40,15 +44,15 @@ erDiagram
|
|||||||
TEXT error_msg "nullable"
|
TEXT error_msg "nullable"
|
||||||
INTEGER source_miss_count "NOT NULL DEFAULT 0; дебаунс пропажи источника (миграция 0003)"
|
INTEGER source_miss_count "NOT NULL DEFAULT 0; дебаунс пропажи источника (миграция 0003)"
|
||||||
TEXT source_added_at "nullable; время добавления в qBittorrent (added_on), базис сортировки (миграция 0005)"
|
TEXT source_added_at "nullable; время добавления в qBittorrent (added_on), базис сортировки (миграция 0005)"
|
||||||
TEXT created_at "NOT NULL DEFAULT datetime('now')"
|
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
||||||
TEXT updated_at "NOT NULL DEFAULT datetime('now')"
|
TEXT updated_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
||||||
}
|
}
|
||||||
|
|
||||||
download_infohash {
|
download_infohash {
|
||||||
TEXT download_id PK_FK "NOT NULL; ON DELETE CASCADE; PK(infohash, download_id)"
|
TEXT download_id PK_FK "NOT NULL; ON DELETE CASCADE; PK(infohash, download_id)"
|
||||||
TEXT infohash PK "NOT NULL; lowercase hex (40 — v1, 64 — v2)"
|
TEXT infohash PK "NOT NULL; lowercase hex (40 — v1, 64 — v2)"
|
||||||
TEXT kind "NOT NULL; v1|v2"
|
TEXT kind "NOT NULL; v1|v2"
|
||||||
TEXT created_at "NOT NULL DEFAULT datetime('now')"
|
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
||||||
}
|
}
|
||||||
|
|
||||||
recognition {
|
recognition {
|
||||||
@@ -66,14 +70,14 @@ erDiagram
|
|||||||
TEXT reasons "NOT NULL DEFAULT '[]'; JSON: причины не-авто"
|
TEXT reasons "NOT NULL DEFAULT '[]'; JSON: причины не-авто"
|
||||||
TEXT raw_llm "nullable; сырой ответ LLM"
|
TEXT raw_llm "nullable; сырой ответ LLM"
|
||||||
TEXT plan "nullable; JSON recognize.Plan (миграция 0002)"
|
TEXT plan "nullable; JSON recognize.Plan (миграция 0002)"
|
||||||
TEXT created_at "NOT NULL DEFAULT datetime('now')"
|
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
||||||
}
|
}
|
||||||
|
|
||||||
hint {
|
hint {
|
||||||
TEXT id PK "ULID"
|
TEXT id PK "ULID"
|
||||||
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
|
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
|
||||||
TEXT text "NOT NULL"
|
TEXT text "NOT NULL"
|
||||||
TEXT created_at "NOT NULL DEFAULT datetime('now')"
|
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
||||||
}
|
}
|
||||||
|
|
||||||
override {
|
override {
|
||||||
@@ -81,7 +85,7 @@ erDiagram
|
|||||||
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
|
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
|
||||||
TEXT field "NOT NULL; UNIQUE(download_id, field)"
|
TEXT field "NOT NULL; UNIQUE(download_id, field)"
|
||||||
TEXT value "NOT NULL"
|
TEXT value "NOT NULL"
|
||||||
TEXT created_at "NOT NULL DEFAULT datetime('now')"
|
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata_candidate {
|
metadata_candidate {
|
||||||
@@ -93,7 +97,7 @@ erDiagram
|
|||||||
INTEGER year "nullable"
|
INTEGER year "nullable"
|
||||||
TEXT url "nullable; ссылка на страницу на сайте провайдера"
|
TEXT url "nullable; ссылка на страницу на сайте провайдера"
|
||||||
INTEGER chosen "NOT NULL DEFAULT 0; 0/1"
|
INTEGER chosen "NOT NULL DEFAULT 0; 0/1"
|
||||||
TEXT created_at "NOT NULL DEFAULT datetime('now')"
|
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
||||||
}
|
}
|
||||||
|
|
||||||
file_link {
|
file_link {
|
||||||
@@ -105,7 +109,7 @@ erDiagram
|
|||||||
TEXT kind "NOT NULL; video|subtitle|..."
|
TEXT kind "NOT NULL; video|subtitle|..."
|
||||||
TEXT status "NOT NULL; linked|..."
|
TEXT status "NOT NULL; linked|..."
|
||||||
INTEGER size "NOT NULL DEFAULT 0; размер файла (байт), фолбэк размера раздачи"
|
INTEGER size "NOT NULL DEFAULT 0; размер файла (байт), фолбэк размера раздачи"
|
||||||
TEXT created_at "NOT NULL DEFAULT datetime('now')"
|
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const DefaultPath = "config.toml"
|
|||||||
|
|
||||||
// Config — корневая конфигурация сервиса (см. config.example.toml).
|
// Config — корневая конфигурация сервиса (см. config.example.toml).
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
General General `toml:"general"`
|
||||||
QBittorrent QBittorrent `toml:"qbittorrent"`
|
QBittorrent QBittorrent `toml:"qbittorrent"`
|
||||||
Paths Paths `toml:"paths"`
|
Paths Paths `toml:"paths"`
|
||||||
Storage Storage `toml:"storage"`
|
Storage Storage `toml:"storage"`
|
||||||
@@ -30,6 +31,15 @@ type Config struct {
|
|||||||
Log Log `toml:"log"`
|
Log Log `toml:"log"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// General — общие настройки приложения.
|
||||||
|
type General struct {
|
||||||
|
// Timezone — таймзона ОТОБРАЖЕНИЯ времени в веб-UI (IANA, напр.
|
||||||
|
// "Europe/Moscow"). Хранение всегда UTC; настройка влияет только на рендеринг.
|
||||||
|
// Пусто → UTC. База зон встроена (time/tzdata), поэтому имя валидируется
|
||||||
|
// одинаково на любом хосте (см. DisplayLocation).
|
||||||
|
Timezone string `toml:"timezone"`
|
||||||
|
}
|
||||||
|
|
||||||
// QBittorrent — доступ к qBittorrent WebUI и раскладка путей загрузок.
|
// QBittorrent — доступ к qBittorrent WebUI и раскладка путей загрузок.
|
||||||
type QBittorrent struct {
|
type QBittorrent struct {
|
||||||
URL string `toml:"url"`
|
URL string `toml:"url"`
|
||||||
@@ -152,10 +162,26 @@ func (d *Duration) UnmarshalText(text []byte) error {
|
|||||||
// Std возвращает обычный time.Duration.
|
// Std возвращает обычный time.Duration.
|
||||||
func (d Duration) Std() time.Duration { return time.Duration(d) }
|
func (d Duration) Std() time.Duration { return time.Duration(d) }
|
||||||
|
|
||||||
|
// DisplayLocation возвращает таймзону отображения времени в веб-UI (пусто → UTC).
|
||||||
|
// Ошибка — если имя зоны не распознано; валидируется на старте (validate).
|
||||||
|
// Зоны доступны на любом хосте: база zoneinfo встроена в бинарь (time/tzdata),
|
||||||
|
// поэтому ошибка означает именно битое имя, а не отсутствие zoneinfo.
|
||||||
|
func (c *Config) DisplayLocation() (*time.Location, error) {
|
||||||
|
if c.General.Timezone == "" {
|
||||||
|
return time.UTC, nil
|
||||||
|
}
|
||||||
|
loc, err := time.LoadLocation(c.General.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("general.timezone %q: %w", c.General.Timezone, err)
|
||||||
|
}
|
||||||
|
return loc, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Default возвращает конфиг с разумными умолчаниями; значения из файла
|
// Default возвращает конфиг с разумными умолчаниями; значения из файла
|
||||||
// перекрывают их при загрузке.
|
// перекрывают их при загрузке.
|
||||||
func Default() *Config {
|
func Default() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
|
General: General{Timezone: "UTC"},
|
||||||
QBittorrent: QBittorrent{
|
QBittorrent: QBittorrent{
|
||||||
URL: "http://qbit:8989",
|
URL: "http://qbit:8989",
|
||||||
Username: "admin",
|
Username: "admin",
|
||||||
@@ -229,6 +255,10 @@ func (c *Config) validate() error {
|
|||||||
if c.LLM.Type != "openai-compat" {
|
if c.LLM.Type != "openai-compat" {
|
||||||
errs = append(errs, fmt.Errorf("unsupported llm.type %q (supported: openai-compat)", c.LLM.Type))
|
errs = append(errs, fmt.Errorf("unsupported llm.type %q (supported: openai-compat)", c.LLM.Type))
|
||||||
}
|
}
|
||||||
|
// Таймзона отображения: имя должно распознаваться (zoneinfo встроен).
|
||||||
|
if _, err := c.DisplayLocation(); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
|
||||||
// Медиа-пути песочницы: абсолютные, без traversal, существующие каталоги.
|
// Медиа-пути песочницы: абсолютные, без traversal, существующие каталоги.
|
||||||
for _, p := range []struct{ name, path string }{
|
for _, p := range []struct{ name, path string }{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// validCfg возвращает минимально валидный конфиг поверх Default() с медиа-путями
|
// validCfg возвращает минимально валидный конфиг поверх Default() с медиа-путями
|
||||||
@@ -55,6 +56,24 @@ func TestValidate_KeylessLocalLLM(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDisplayLocation — зона отображения: пусто → UTC, валидная зона грузится,
|
||||||
|
// zoneinfo встроен (time/tzdata) → доступна на любом хосте.
|
||||||
|
func TestDisplayLocation(t *testing.T) {
|
||||||
|
empty := &Config{}
|
||||||
|
if loc, err := empty.DisplayLocation(); err != nil || loc != time.UTC {
|
||||||
|
t.Fatalf("пустая зона → UTC, got %v, %v", loc, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &Config{General: General{Timezone: "Europe/Moscow"}}
|
||||||
|
loc, err := c.DisplayLocation()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Europe/Moscow должна грузиться (tzdata встроен): %v", err)
|
||||||
|
}
|
||||||
|
if loc.String() != "Europe/Moscow" {
|
||||||
|
t.Fatalf("loc = %q, want Europe/Moscow", loc.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidate_Errors(t *testing.T) {
|
func TestValidate_Errors(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -74,6 +93,7 @@ func TestValidate_Errors(t *testing.T) {
|
|||||||
{"jellyfin enabled no url", func(c *Config) { c.Jellyfin.Enabled = true; c.Jellyfin.URL = "" }, "jellyfin.url"},
|
{"jellyfin enabled no url", func(c *Config) { c.Jellyfin.Enabled = true; c.Jellyfin.URL = "" }, "jellyfin.url"},
|
||||||
{"jellyfin enabled no key", func(c *Config) { c.Jellyfin.Enabled = true; c.Jellyfin.URL = "http://j"; c.Jellyfin.APIKey = "" }, "jellyfin.api_key"},
|
{"jellyfin enabled no key", func(c *Config) { c.Jellyfin.Enabled = true; c.Jellyfin.URL = "http://j"; c.Jellyfin.APIKey = "" }, "jellyfin.api_key"},
|
||||||
{"telegram enabled no token", func(c *Config) { c.Telegram.Enabled = true }, "telegram.token"},
|
{"telegram enabled no token", func(c *Config) { c.Telegram.Enabled = true }, "telegram.token"},
|
||||||
|
{"bad timezone", func(c *Config) { c.General.Timezone = "Mars/Phobos" }, "general.timezone"},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -10,6 +10,24 @@ import (
|
|||||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestFmtDateZone — дата отображается в переданной таймзоне: полуночное UTC-время
|
||||||
|
// сдвигается на следующий день под Europe/Moscow (UTC+3), в UTC остаётся прежним.
|
||||||
|
func TestFmtDateZone(t *testing.T) {
|
||||||
|
// 2026-06-14 22:30 UTC == 2026-06-15 01:30 MSK.
|
||||||
|
ts := time.Date(2026, 6, 14, 22, 30, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
if got := fmtDate(ts, time.UTC); got != "2026-06-14" {
|
||||||
|
t.Fatalf("UTC: got %q, want 2026-06-14", got)
|
||||||
|
}
|
||||||
|
msk, err := time.LoadLocation("Europe/Moscow")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load Europe/Moscow (tzdata встроен): %v", err)
|
||||||
|
}
|
||||||
|
if got := fmtDate(ts, msk); got != "2026-06-15" {
|
||||||
|
t.Fatalf("MSK: got %q, want 2026-06-15", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHumanizeAge(t *testing.T) {
|
func TestHumanizeAge(t *testing.T) {
|
||||||
now := time.Date(2026, 7, 4, 12, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 7, 4, 12, 0, 0, 0, time.UTC)
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ type Deps struct {
|
|||||||
Reader Reader
|
Reader Reader
|
||||||
Reviewer Reviewer
|
Reviewer Reviewer
|
||||||
Live LiveStatus
|
Live LiveStatus
|
||||||
|
// Loc — таймзона отображения дат в веб-UI (хранение всегда UTC). nil → UTC.
|
||||||
|
Loc *time.Location
|
||||||
}
|
}
|
||||||
|
|
||||||
type server struct {
|
type server struct {
|
||||||
@@ -89,6 +91,9 @@ func NewRouter(d Deps) (http.Handler, error) {
|
|||||||
if d.Live == nil {
|
if d.Live == nil {
|
||||||
d.Live = noLive{} // источник телеметрии не подключён — деградируем штатно
|
d.Live = noLive{} // источник телеметрии не подключён — деградируем штатно
|
||||||
}
|
}
|
||||||
|
if d.Loc == nil {
|
||||||
|
d.Loc = time.UTC // таймзона отображения не задана — показываем в UTC
|
||||||
|
}
|
||||||
s := &server{deps: d, tmpl: tmpl, assetVer: assetVer}
|
s := &server{deps: d, tmpl: tmpl, assetVer: assetVer}
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
@@ -285,7 +290,7 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for _, d := range downloads {
|
for _, d := range downloads {
|
||||||
v := toView(d, now)
|
v := s.toView(d, now)
|
||||||
// Живой снимок читаем для всех карточек (map-lookup, без сети/БД): рейтинг
|
// Живой снимок читаем для всех карточек (map-lookup, без сети/БД): рейтинг
|
||||||
// и размер нужны в любом состоянии, пока торрент есть в qBittorrent.
|
// и размер нужны в любом состоянии, пока торрент есть в qBittorrent.
|
||||||
l, ok := s.liveFor(d)
|
l, ok := s.liveFor(d)
|
||||||
@@ -542,7 +547,7 @@ func toDTO(d store.Download) downloadDTO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func toView(d store.Download, now time.Time) downloadView {
|
func (s *server) toView(d store.Download, now time.Time) downloadView {
|
||||||
state := string(d.State)
|
state := string(d.State)
|
||||||
v := downloadView{
|
v := downloadView{
|
||||||
ID: d.ID,
|
ID: d.ID,
|
||||||
@@ -561,7 +566,7 @@ func toView(d store.Download, now time.Time) downloadView {
|
|||||||
// Дата добавления в карточке — всегда (source_added_at → фолбэк created_at,
|
// Дата добавления в карточке — всегда (source_added_at → фолбэк created_at,
|
||||||
// как в порядке списка); неразбираемое время просто опускаем.
|
// как в порядке списка); неразбираемое время просто опускаем.
|
||||||
if t, ok := addedTime(d); ok {
|
if t, ok := addedTime(d); ok {
|
||||||
v.Added = fmtDate(t)
|
v.Added = fmtDate(t, s.deps.Loc)
|
||||||
v.AddedAgo = humanizeAge(t, now)
|
v.AddedAgo = humanizeAge(t, now)
|
||||||
}
|
}
|
||||||
return v
|
return v
|
||||||
|
|||||||
@@ -186,19 +186,11 @@ func fmtRatio(r float64) string {
|
|||||||
return fmt.Sprintf("%.2f", r)
|
return fmt.Sprintf("%.2f", r)
|
||||||
}
|
}
|
||||||
|
|
||||||
// mskLoc — TZ сервера (Europe/Moscow) для показа дат в веб-UI (инвариант «время
|
// fmtDate — абсолютная дата добавления для карточки в таймзоне отображения
|
||||||
// с явным TZ»). Если zoneinfo недоступен в окружении — деградируем до UTC:
|
// (general.timezone; хранение всегда UTC). loc не бывает nil — NewRouter
|
||||||
// дата остаётся корректной с точностью до смещения, UI не падает.
|
// подставляет UTC по умолчанию.
|
||||||
var mskLoc = func() *time.Location {
|
func fmtDate(t time.Time, loc *time.Location) string {
|
||||||
if loc, err := time.LoadLocation("Europe/Moscow"); err == nil {
|
return t.In(loc).Format("2006-01-02")
|
||||||
return loc
|
|
||||||
}
|
|
||||||
return time.UTC
|
|
||||||
}()
|
|
||||||
|
|
||||||
// fmtDate — абсолютная дата добавления для карточки в TZ сервера.
|
|
||||||
func fmtDate(t time.Time) string {
|
|
||||||
return t.In(mskLoc).Format("2006-01-02")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// humanizeAge — относительная давность («5 дней назад») от now до t. Будущее
|
// humanizeAge — относительная давность («5 дней назад») от now до t. Будущее
|
||||||
|
|||||||
+27
-22
@@ -115,7 +115,7 @@ type Download struct {
|
|||||||
SourceMissCount int `db:"source_miss_count"`
|
SourceMissCount int `db:"source_miss_count"`
|
||||||
// SourceAddedAt — время добавления торрента в qBittorrent (added_on), базис
|
// SourceAddedAt — время добавления торрента в qBittorrent (added_on), базис
|
||||||
// сортировки списка. NULL, пока воркер не наблюдал раздачу. Хранится в
|
// сортировки списка. NULL, пока воркер не наблюдал раздачу. Хранится в
|
||||||
// формате sqliteTimeLayout (UTC), как created_at.
|
// формате RFC 3339 (UTC, суффикс Z), как created_at.
|
||||||
SourceAddedAt sql.NullString `db:"source_added_at"`
|
SourceAddedAt sql.NullString `db:"source_added_at"`
|
||||||
CreatedAt string `db:"created_at"`
|
CreatedAt string `db:"created_at"`
|
||||||
UpdatedAt string `db:"updated_at"`
|
UpdatedAt string `db:"updated_at"`
|
||||||
@@ -148,22 +148,25 @@ func (d Download) PrimaryInfohash() string {
|
|||||||
return d.Infohashes[0].Infohash
|
return d.Infohashes[0].Infohash
|
||||||
}
|
}
|
||||||
|
|
||||||
// sqliteTimeLayout — формат меток datetime('now') в SQLite (UTC).
|
// ParseTime разбирает временную метку хранилища (RFC 3339, всегда UTC).
|
||||||
const sqliteTimeLayout = "2006-01-02 15:04:05"
|
|
||||||
|
|
||||||
// ParseTime разбирает временную метку SQLite (datetime('now'), всегда UTC).
|
|
||||||
func ParseTime(s string) (time.Time, error) {
|
func ParseTime(s string) (time.Time, error) {
|
||||||
return time.ParseInLocation(sqliteTimeLayout, s, time.UTC)
|
return time.Parse(time.RFC3339, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatTime форматирует время в формат меток SQLite (datetime('now'), UTC) —
|
// FormatTime форматирует время в формат меток хранилища — RFC 3339 в UTC
|
||||||
// байт-в-байт тот же, что у created_at/updated_at. Нужен, чтобы лексикографи-
|
// (суффикс Z), напр. «2006-01-02T15:04:05Z». Единый формат всех меток; DEFAULT
|
||||||
// ческое сравнение строк времени (COALESCE(source_added_at, created_at) в
|
// в схеме нет, время всегда пишет приложение через Now (см. ниже). Фиксированная
|
||||||
// сортировке списка) давало верный хронологический порядок.
|
// ширина сохраняет лексикографическое сравнение строк времени = хронологию
|
||||||
|
// (COALESCE(source_added_at, created_at) в сортировке списка).
|
||||||
func FormatTime(t time.Time) string {
|
func FormatTime(t time.Time) string {
|
||||||
return t.UTC().Format(sqliteTimeLayout)
|
return t.UTC().Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Now — единая точка получения «сейчас» для меток времени store (UTC). Всё время
|
||||||
|
// в БД генерирует приложение через неё (аналогично ident.NewID для id), а не
|
||||||
|
// SQLite DEFAULT: один источник формата, тестируемая точка.
|
||||||
|
func Now() time.Time { return time.Now().UTC() }
|
||||||
|
|
||||||
// CreatedTime возвращает время создания загрузки как time.Time (UTC).
|
// CreatedTime возвращает время создания загрузки как time.Time (UTC).
|
||||||
func (d Download) CreatedTime() (time.Time, error) { return ParseTime(d.CreatedAt) }
|
func (d Download) CreatedTime() (time.Time, error) { return ParseTime(d.CreatedAt) }
|
||||||
|
|
||||||
@@ -182,6 +185,7 @@ func (s *Store) CreateDownloadIfNoActive(ctx context.Context, d *Download, hashe
|
|||||||
if len(norm) == 0 {
|
if len(norm) == 0 {
|
||||||
return nil, fmt.Errorf("create download: no infohash")
|
return nil, fmt.Errorf("create download: no infohash")
|
||||||
}
|
}
|
||||||
|
now := FormatTime(Now())
|
||||||
|
|
||||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -200,8 +204,8 @@ func (s *Store) CreateDownloadIfNoActive(ctx context.Context, d *Download, hashe
|
|||||||
// нему создал бы вторую активную задачу.
|
// нему создал бы вторую активную задачу.
|
||||||
for _, h := range norm {
|
for _, h := range norm {
|
||||||
if _, err := tx.ExecContext(ctx,
|
if _, err := tx.ExecContext(ctx,
|
||||||
`INSERT OR IGNORE INTO download_infohash (download_id, infohash, kind) VALUES (?, ?, ?)`,
|
`INSERT OR IGNORE INTO download_infohash (download_id, infohash, kind, created_at) VALUES (?, ?, ?, ?)`,
|
||||||
existing.ID, h, HashKind(h)); err != nil {
|
existing.ID, h, HashKind(h), now); err != nil {
|
||||||
return nil, fmt.Errorf("create download: top up infohash: %w", err)
|
return nil, fmt.Errorf("create download: top up infohash: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,17 +220,17 @@ func (s *Store) CreateDownloadIfNoActive(ctx context.Context, d *Download, hashe
|
|||||||
|
|
||||||
d.ID = ident.NewID()
|
d.ID = ident.NewID()
|
||||||
const q = `
|
const q = `
|
||||||
INSERT INTO download (id, source_type, source_ref, display_name, context, state)
|
INSERT INTO download (id, source_type, source_ref, display_name, context, state, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
if _, err := tx.ExecContext(ctx, q,
|
if _, err := tx.ExecContext(ctx, q,
|
||||||
d.ID, d.SourceType, d.SourceRef, d.DisplayName, d.Context, d.State); err != nil {
|
d.ID, d.SourceType, d.SourceRef, d.DisplayName, d.Context, d.State, now, now); err != nil {
|
||||||
return nil, fmt.Errorf("insert download: %w", err)
|
return nil, fmt.Errorf("insert download: %w", err)
|
||||||
}
|
}
|
||||||
d.Infohashes = d.Infohashes[:0]
|
d.Infohashes = d.Infohashes[:0]
|
||||||
for _, h := range norm {
|
for _, h := range norm {
|
||||||
if _, err := tx.ExecContext(ctx,
|
if _, err := tx.ExecContext(ctx,
|
||||||
`INSERT INTO download_infohash (download_id, infohash, kind) VALUES (?, ?, ?)`,
|
`INSERT INTO download_infohash (download_id, infohash, kind, created_at) VALUES (?, ?, ?, ?)`,
|
||||||
d.ID, h, HashKind(h)); err != nil {
|
d.ID, h, HashKind(h), now); err != nil {
|
||||||
return nil, fmt.Errorf("insert download infohash: %w", err)
|
return nil, fmt.Errorf("insert download infohash: %w", err)
|
||||||
}
|
}
|
||||||
d.Infohashes = append(d.Infohashes, Infohash{DownloadID: d.ID, Infohash: h, Kind: HashKind(h)})
|
d.Infohashes = append(d.Infohashes, Infohash{DownloadID: d.ID, Infohash: h, Kind: HashKind(h)})
|
||||||
@@ -286,6 +290,7 @@ func (s *Store) AddInfohashes(ctx context.Context, downloadID string, hashes []s
|
|||||||
if len(norm) == 0 {
|
if len(norm) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
now := FormatTime(Now())
|
||||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("add infohashes to %s: begin tx: %w", downloadID, err)
|
return fmt.Errorf("add infohashes to %s: begin tx: %w", downloadID, err)
|
||||||
@@ -303,8 +308,8 @@ func (s *Store) AddInfohashes(ctx context.Context, downloadID string, hashes []s
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, err := tx.ExecContext(ctx,
|
if _, err := tx.ExecContext(ctx,
|
||||||
`INSERT OR IGNORE INTO download_infohash (download_id, infohash, kind) VALUES (?, ?, ?)`,
|
`INSERT OR IGNORE INTO download_infohash (download_id, infohash, kind, created_at) VALUES (?, ?, ?, ?)`,
|
||||||
downloadID, h, HashKind(h)); err != nil {
|
downloadID, h, HashKind(h), now); err != nil {
|
||||||
return fmt.Errorf("add infohash %s to %s: %w", h, downloadID, err)
|
return fmt.Errorf("add infohash %s to %s: %w", h, downloadID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -507,9 +512,9 @@ UPDATE download
|
|||||||
SET state = ?,
|
SET state = ?,
|
||||||
error_code = ?,
|
error_code = ?,
|
||||||
error_msg = ?,
|
error_msg = ?,
|
||||||
updated_at = datetime('now')
|
updated_at = ?
|
||||||
WHERE id = ?`
|
WHERE id = ?`
|
||||||
args := []any{string(state), nullArg(errCode), nullArg(errMsg), id}
|
args := []any{string(state), nullArg(errCode), nullArg(errMsg), FormatTime(Now()), id}
|
||||||
if !reviveOK && !state.IsTerminal() {
|
if !reviveOK && !state.IsTerminal() {
|
||||||
q += ` AND state NOT IN (` + placeholders(&args, terminalStates) + `)`
|
q += ` AND state NOT IN (` + placeholders(&args, terminalStates) + `)`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -320,8 +320,8 @@ func TestActivateExcludesSelf(t *testing.T) {
|
|||||||
// Легаси/аварийное состояние: у newer тот же хеш h (мимо API — гард
|
// Легаси/аварийное состояние: у newer тот же хеш h (мимо API — гард
|
||||||
// такого не создаст, но обязан не маскировать).
|
// такого не создаст, но обязан не маскировать).
|
||||||
if _, err := st.DB.ExecContext(ctx,
|
if _, err := st.DB.ExecContext(ctx,
|
||||||
`INSERT INTO download_infohash (download_id, infohash, kind) VALUES (?, ?, 'v1')`,
|
`INSERT INTO download_infohash (download_id, infohash, kind, created_at) VALUES (?, ?, 'v1', ?)`,
|
||||||
newer, h); err != nil {
|
newer, h, FormatTime(Now())); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
@@ -97,7 +98,9 @@ func TestUlidMigration(t *testing.T) {
|
|||||||
if len(three.Infohashes) != 0 {
|
if len(three.Infohashes) != 0 {
|
||||||
t.Fatalf("хеши #3 = %+v, want пусто", three.Infohashes)
|
t.Fatalf("хеши #3 = %+v, want пусто", three.Infohashes)
|
||||||
}
|
}
|
||||||
if one.CreatedAt != "2026-01-01 10:00:00" || one.UpdatedAt != "2026-01-01 11:00:00" {
|
// Метки переформатированы миграцией 0008 в RFC 3339 (UTC, суффикс Z) —
|
||||||
|
// момент сохранён; заодно канарейка, что backfill 0008 отработал на данных.
|
||||||
|
if one.CreatedAt != "2026-01-01T10:00:00Z" || one.UpdatedAt != "2026-01-01T11:00:00Z" {
|
||||||
t.Fatalf("метки #1 = %q / %q", one.CreatedAt, one.UpdatedAt)
|
t.Fatalf("метки #1 = %q / %q", one.CreatedAt, one.UpdatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,3 +143,58 @@ func TestUlidMigration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRFC3339TimeMigration проверяет миграцию 0008: при Up метки переформатиру-
|
||||||
|
// ются из datetime('now') (пробел) в RFC 3339 (UTC, суффикс Z), при Down — обра-
|
||||||
|
// тно; NULL source_added_at не затрагивается, момент времени сохраняется.
|
||||||
|
func TestRFC3339TimeMigration(t *testing.T) {
|
||||||
|
dbPath := t.TempDir() + "/time.db"
|
||||||
|
db, err := sqlx.Connect("sqlite",
|
||||||
|
"file:"+dbPath+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = db.Close() }()
|
||||||
|
|
||||||
|
goose.SetBaseFS(migrationsFS)
|
||||||
|
goose.SetLogger(goose.NopLogger())
|
||||||
|
if err := goose.SetDialect("sqlite3"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Схема до 0008: DEFAULT (datetime('now')), формат меток с пробелом.
|
||||||
|
if err := goose.UpTo(db.DB, "migrations", 7); err != nil {
|
||||||
|
t.Fatalf("up to 7: %v", err)
|
||||||
|
}
|
||||||
|
// Строка со старой меткой; source_added_at = NULL (проверяем щажение).
|
||||||
|
if _, err := db.Exec(`INSERT INTO download (id, source_type, source_ref, state, created_at, updated_at)
|
||||||
|
VALUES ('d1', 'magnet', 'magnet:x', 'downloading', '2026-03-01 12:00:00', '2026-03-01 13:00:00')`); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
read := func() (string, string, sql.NullString) {
|
||||||
|
var c, u string
|
||||||
|
var sa sql.NullString
|
||||||
|
if err := db.QueryRowx(
|
||||||
|
`SELECT created_at, updated_at, source_added_at FROM download WHERE id='d1'`).
|
||||||
|
Scan(&c, &u, &sa); err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
return c, u, sa
|
||||||
|
}
|
||||||
|
|
||||||
|
// Up 0008 → RFC 3339.
|
||||||
|
if err := goose.UpTo(db.DB, "migrations", 8); err != nil {
|
||||||
|
t.Fatalf("up to 8: %v", err)
|
||||||
|
}
|
||||||
|
if c, u, sa := read(); c != "2026-03-01T12:00:00Z" || u != "2026-03-01T13:00:00Z" || sa.Valid {
|
||||||
|
t.Fatalf("после up: %q / %q / sa.Valid=%v, want RFC 3339 и NULL sa", c, u, sa.Valid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Down 0008 → формат с пробелом.
|
||||||
|
if err := goose.DownTo(db.DB, "migrations", 7); err != nil {
|
||||||
|
t.Fatalf("down to 7: %v", err)
|
||||||
|
}
|
||||||
|
if c, u, sa := read(); c != "2026-03-01 12:00:00" || u != "2026-03-01 13:00:00" || sa.Valid {
|
||||||
|
t.Fatalf("после down: %q / %q / sa.Valid=%v, want формат с пробелом и NULL sa", c, u, sa.Valid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/pressly/goose/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
goose.AddMigrationContext(upRFC3339Time, downRFC3339Time)
|
||||||
|
}
|
||||||
|
|
||||||
|
// upRFC3339Time переводит хранение временных меток на RFC 3339 (UTC, суффикс Z,
|
||||||
|
// напр. «2006-01-02T15:04:05Z») и убирает `DEFAULT (datetime('now'))` со всех
|
||||||
|
// колонок: время отныне пишет только приложение (store.Now/FormatTime) — единая
|
||||||
|
// точка генерации, fail-loud при забытой вставке (NOT NULL без DEFAULT).
|
||||||
|
//
|
||||||
|
// SQLite не умеет ALTER COLUMN DROP DEFAULT, поэтому перестраиваем 7 таблиц по
|
||||||
|
// образцу миграции 0006 (FK включены): создаём *_new без DEFAULT на метках,
|
||||||
|
// копируем данные с переформатированием меток через strftime (id и связи НЕ
|
||||||
|
// меняются — момент времени сохраняется, ULID-корреляция цела), удаляем старые
|
||||||
|
// (дети первыми), переименовываем (SQLite ≥3.25 переписывает REFERENCES),
|
||||||
|
// пересоздаём индексы и проверяем целостность FK.
|
||||||
|
func upRFC3339Time(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
return rebuildTimeColumns(ctx, tx, "", rfc3339Expr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// downRFC3339Time возвращает прежний формат меток (`YYYY-MM-DD HH:MM:SS`) и
|
||||||
|
// `DEFAULT (datetime('now'))` на колонках. Обратима без потерь: strftime
|
||||||
|
// разбирает RFC 3339 (T/Z) и момент времени сохраняется.
|
||||||
|
func downRFC3339Time(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
return rebuildTimeColumns(ctx, tx, " DEFAULT (datetime('now'))", sqliteExpr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rfc3339Expr / sqliteExpr — SQL-выражение переформатирования метки col в
|
||||||
|
// целевой формат (strftime корректно разбирает и пробел-, и T/Z-форму на входе).
|
||||||
|
func rfc3339Expr(col string) string {
|
||||||
|
return fmt.Sprintf("strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', %s)", col)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sqliteExpr(col string) string {
|
||||||
|
return fmt.Sprintf("strftime('%%Y-%%m-%%d %%H:%%M:%%S', %s)", col)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rebuildTimeColumns перестраивает все таблицы с временными метками: tsDefault —
|
||||||
|
// суффикс DEFAULT для колонок created_at/updated_at ("" — без дефолта); ts —
|
||||||
|
// выражение переформатирования метки при копировании.
|
||||||
|
func rebuildTimeColumns(ctx context.Context, tx *sql.Tx, tsDefault string, ts func(string) string) error {
|
||||||
|
stmts := make([]string, 0, 28)
|
||||||
|
stmts = append(stmts, createTimeTables(tsDefault)...)
|
||||||
|
stmts = append(stmts, copyTimeTables(ts)...)
|
||||||
|
stmts = append(stmts, dropRenameReindex...)
|
||||||
|
for _, stmt := range stmts {
|
||||||
|
if _, err := tx.ExecContext(ctx, stmt); err != nil {
|
||||||
|
return fmt.Errorf("rfc3339 time migration: %q: %w", stmt, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return checkForeignKeys(ctx, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTimeTables — CREATE *_new для всех таблиц; d подставляет DEFAULT на
|
||||||
|
// метки created_at/updated_at (прочие DEFAULT — ”, 0, '[]', 1 — неизменны).
|
||||||
|
func createTimeTables(d string) []string {
|
||||||
|
return []string{
|
||||||
|
fmt.Sprintf(`CREATE TABLE download_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
source_type TEXT NOT NULL,
|
||||||
|
source_ref TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL DEFAULT '',
|
||||||
|
context TEXT NOT NULL DEFAULT '',
|
||||||
|
state TEXT NOT NULL,
|
||||||
|
error_code TEXT,
|
||||||
|
error_msg TEXT,
|
||||||
|
source_miss_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
source_added_at TEXT,
|
||||||
|
created_at TEXT NOT NULL%[1]s,
|
||||||
|
updated_at TEXT NOT NULL%[1]s
|
||||||
|
)`, d),
|
||||||
|
fmt.Sprintf(`CREATE TABLE download_infohash_new (
|
||||||
|
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
|
||||||
|
infohash TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL%[1]s,
|
||||||
|
PRIMARY KEY (infohash, download_id)
|
||||||
|
)`, d),
|
||||||
|
fmt.Sprintf(`CREATE TABLE recognition_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
|
||||||
|
attempt_no INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_current INTEGER NOT NULL DEFAULT 1,
|
||||||
|
media_type TEXT,
|
||||||
|
title TEXT,
|
||||||
|
original_title TEXT,
|
||||||
|
year INTEGER,
|
||||||
|
provider TEXT,
|
||||||
|
provider_id TEXT,
|
||||||
|
confidence REAL,
|
||||||
|
reasons TEXT NOT NULL DEFAULT '[]',
|
||||||
|
raw_llm TEXT,
|
||||||
|
plan TEXT,
|
||||||
|
created_at TEXT NOT NULL%[1]s
|
||||||
|
)`, d),
|
||||||
|
fmt.Sprintf(`CREATE TABLE hint_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL%[1]s
|
||||||
|
)`, d),
|
||||||
|
fmt.Sprintf(`CREATE TABLE override_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
|
||||||
|
field TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL%[1]s,
|
||||||
|
UNIQUE (download_id, field)
|
||||||
|
)`, d),
|
||||||
|
fmt.Sprintf(`CREATE TABLE metadata_candidate_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
recognition_id TEXT NOT NULL REFERENCES recognition_new (id) ON DELETE CASCADE,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
provider_id TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
year INTEGER,
|
||||||
|
chosen INTEGER NOT NULL DEFAULT 0,
|
||||||
|
url TEXT,
|
||||||
|
created_at TEXT NOT NULL%[1]s
|
||||||
|
)`, d),
|
||||||
|
fmt.Sprintf(`CREATE TABLE file_link_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
|
||||||
|
apply_batch_id TEXT NOT NULL,
|
||||||
|
src_path TEXT NOT NULL,
|
||||||
|
dst_path TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL%[1]s
|
||||||
|
)`, d),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyTimeTables — INSERT ... SELECT в *_new с переформатированием меток через
|
||||||
|
// ts(col). Списки колонок явные; strftime(NULL) = NULL — nullable source_added_at
|
||||||
|
// корректно щадится.
|
||||||
|
func copyTimeTables(ts func(string) string) []string {
|
||||||
|
return []string{
|
||||||
|
fmt.Sprintf(`INSERT INTO download_new
|
||||||
|
(id, source_type, source_ref, display_name, context, state, error_code,
|
||||||
|
error_msg, source_miss_count, source_added_at, created_at, updated_at)
|
||||||
|
SELECT id, source_type, source_ref, display_name, context, state, error_code,
|
||||||
|
error_msg, source_miss_count, %s, %s, %s
|
||||||
|
FROM download`, ts("source_added_at"), ts("created_at"), ts("updated_at")),
|
||||||
|
fmt.Sprintf(`INSERT INTO download_infohash_new (download_id, infohash, kind, created_at)
|
||||||
|
SELECT download_id, infohash, kind, %s FROM download_infohash`, ts("created_at")),
|
||||||
|
fmt.Sprintf(`INSERT INTO recognition_new
|
||||||
|
(id, download_id, attempt_no, is_current, media_type, title, original_title,
|
||||||
|
year, provider, provider_id, confidence, reasons, raw_llm, plan, created_at)
|
||||||
|
SELECT id, download_id, attempt_no, is_current, media_type, title, original_title,
|
||||||
|
year, provider, provider_id, confidence, reasons, raw_llm, plan, %s
|
||||||
|
FROM recognition`, ts("created_at")),
|
||||||
|
fmt.Sprintf(`INSERT INTO hint_new (id, download_id, text, created_at)
|
||||||
|
SELECT id, download_id, text, %s FROM hint`, ts("created_at")),
|
||||||
|
fmt.Sprintf(`INSERT INTO override_new (id, download_id, field, value, created_at)
|
||||||
|
SELECT id, download_id, field, value, %s FROM override`, ts("created_at")),
|
||||||
|
fmt.Sprintf(`INSERT INTO metadata_candidate_new
|
||||||
|
(id, recognition_id, provider, provider_id, title, year, chosen, url, created_at)
|
||||||
|
SELECT id, recognition_id, provider, provider_id, title, year, chosen, url, %s
|
||||||
|
FROM metadata_candidate`, ts("created_at")),
|
||||||
|
fmt.Sprintf(`INSERT INTO file_link_new
|
||||||
|
(id, download_id, apply_batch_id, src_path, dst_path, kind, status, size, created_at)
|
||||||
|
SELECT id, download_id, apply_batch_id, src_path, dst_path, kind, status, size, %s
|
||||||
|
FROM file_link`, ts("created_at")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropRenameReindex — удаление старых таблиц (дети первыми, FK включены),
|
||||||
|
// переименование *_new и пересоздание индексов (канонический набор из 0006).
|
||||||
|
var dropRenameReindex = []string{
|
||||||
|
`DROP TABLE file_link`,
|
||||||
|
`DROP TABLE metadata_candidate`,
|
||||||
|
`DROP TABLE override`,
|
||||||
|
`DROP TABLE hint`,
|
||||||
|
`DROP TABLE recognition`,
|
||||||
|
`DROP TABLE download_infohash`,
|
||||||
|
`DROP TABLE download`,
|
||||||
|
`ALTER TABLE download_new RENAME TO download`,
|
||||||
|
`ALTER TABLE download_infohash_new RENAME TO download_infohash`,
|
||||||
|
`ALTER TABLE recognition_new RENAME TO recognition`,
|
||||||
|
`ALTER TABLE hint_new RENAME TO hint`,
|
||||||
|
`ALTER TABLE override_new RENAME TO override`,
|
||||||
|
`ALTER TABLE metadata_candidate_new RENAME TO metadata_candidate`,
|
||||||
|
`ALTER TABLE file_link_new RENAME TO file_link`,
|
||||||
|
`CREATE INDEX idx_download_state ON download (state)`,
|
||||||
|
`CREATE INDEX idx_download_infohash_download ON download_infohash (download_id)`,
|
||||||
|
`CREATE INDEX idx_recognition_download ON recognition (download_id)`,
|
||||||
|
`CREATE INDEX idx_hint_download ON hint (download_id)`,
|
||||||
|
`CREATE INDEX idx_candidate_recognition ON metadata_candidate (recognition_id)`,
|
||||||
|
`CREATE INDEX idx_file_link_download ON file_link (download_id)`,
|
||||||
|
`CREATE INDEX idx_file_link_batch ON file_link (apply_batch_id)`,
|
||||||
|
}
|
||||||
@@ -71,11 +71,12 @@ func (s *Store) CreateRecognition(ctx context.Context, r *Recognition, reasons [
|
|||||||
const q = `
|
const q = `
|
||||||
INSERT INTO recognition
|
INSERT INTO recognition
|
||||||
(id, download_id, attempt_no, is_current, media_type, title, original_title,
|
(id, download_id, attempt_no, is_current, media_type, title, original_title,
|
||||||
year, provider, provider_id, confidence, reasons, raw_llm, plan)
|
year, provider, provider_id, confidence, reasons, raw_llm, plan, created_at)
|
||||||
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
if _, err := tx.ExecContext(ctx, q,
|
if _, err := tx.ExecContext(ctx, q,
|
||||||
r.ID, r.DownloadID, nextAttempt, r.MediaType, r.Title, r.OriginalTitle,
|
r.ID, r.DownloadID, nextAttempt, r.MediaType, r.Title, r.OriginalTitle,
|
||||||
r.Year, r.Provider, r.ProviderID, r.Confidence, string(reasonsJSON), r.RawLLM, r.Plan); err != nil {
|
r.Year, r.Provider, r.ProviderID, r.Confidence, string(reasonsJSON), r.RawLLM, r.Plan,
|
||||||
|
FormatTime(Now())); err != nil {
|
||||||
return "", fmt.Errorf("insert recognition: %w", err)
|
return "", fmt.Errorf("insert recognition: %w", err)
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
@@ -105,8 +106,8 @@ func (s *Store) GetCurrentRecognition(ctx context.Context, downloadID string) (*
|
|||||||
// AddHint добавляет текстовую подсказку ревьюера к загрузке.
|
// AddHint добавляет текстовую подсказку ревьюера к загрузке.
|
||||||
func (s *Store) AddHint(ctx context.Context, downloadID string, text string) error {
|
func (s *Store) AddHint(ctx context.Context, downloadID string, text string) error {
|
||||||
if _, err := s.DB.ExecContext(ctx,
|
if _, err := s.DB.ExecContext(ctx,
|
||||||
`INSERT INTO hint (id, download_id, text) VALUES (?, ?, ?)`,
|
`INSERT INTO hint (id, download_id, text, created_at) VALUES (?, ?, ?, ?)`,
|
||||||
ident.NewID(), downloadID, text); err != nil {
|
ident.NewID(), downloadID, text, FormatTime(Now())); err != nil {
|
||||||
return fmt.Errorf("add hint: %w", err)
|
return fmt.Errorf("add hint: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -127,9 +128,9 @@ func (s *Store) ListHints(ctx context.Context, downloadID string) ([]string, err
|
|||||||
// SetOverride пиннит значение поля (upsert по (download_id, field)).
|
// SetOverride пиннит значение поля (upsert по (download_id, field)).
|
||||||
func (s *Store) SetOverride(ctx context.Context, downloadID string, field, value string) error {
|
func (s *Store) SetOverride(ctx context.Context, downloadID string, field, value string) error {
|
||||||
const q = `
|
const q = `
|
||||||
INSERT INTO override (id, download_id, field, value) VALUES (?, ?, ?, ?)
|
INSERT INTO override (id, download_id, field, value, created_at) VALUES (?, ?, ?, ?, ?)
|
||||||
ON CONFLICT (download_id, field) DO UPDATE SET value = excluded.value`
|
ON CONFLICT (download_id, field) DO UPDATE SET value = excluded.value`
|
||||||
if _, err := s.DB.ExecContext(ctx, q, ident.NewID(), downloadID, field, value); err != nil {
|
if _, err := s.DB.ExecContext(ctx, q, ident.NewID(), downloadID, field, value, FormatTime(Now())); err != nil {
|
||||||
return fmt.Errorf("set override %q: %w", field, err)
|
return fmt.Errorf("set override %q: %w", field, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -181,12 +182,13 @@ func (s *Store) CreateFileLinks(ctx context.Context, links []FileLink) error {
|
|||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
now := FormatTime(Now())
|
||||||
const q = `
|
const q = `
|
||||||
INSERT INTO file_link (id, download_id, apply_batch_id, src_path, dst_path, kind, status, size)
|
INSERT INTO file_link (id, download_id, apply_batch_id, src_path, dst_path, kind, status, size, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
for _, l := range links {
|
for _, l := range links {
|
||||||
if _, err := tx.ExecContext(ctx, q,
|
if _, err := tx.ExecContext(ctx, q,
|
||||||
ident.NewID(), l.DownloadID, l.ApplyBatchID, l.SrcPath, l.DstPath, l.Kind, l.Status, l.Size); err != nil {
|
ident.NewID(), l.DownloadID, l.ApplyBatchID, l.SrcPath, l.DstPath, l.Kind, l.Status, l.Size, now); err != nil {
|
||||||
return fmt.Errorf("insert file_link: %w", err)
|
return fmt.Errorf("insert file_link: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -328,12 +330,13 @@ func (s *Store) CreateCandidates(ctx context.Context, cands []MetadataCandidate)
|
|||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
now := FormatTime(Now())
|
||||||
const q = `
|
const q = `
|
||||||
INSERT INTO metadata_candidate (id, recognition_id, provider, provider_id, title, year, url)
|
INSERT INTO metadata_candidate (id, recognition_id, provider, provider_id, title, year, url, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
for _, c := range cands {
|
for _, c := range cands {
|
||||||
if _, err := tx.ExecContext(ctx, q,
|
if _, err := tx.ExecContext(ctx, q,
|
||||||
ident.NewID(), c.RecognitionID, c.Provider, c.ProviderID, c.Title, c.Year, c.URL); err != nil {
|
ident.NewID(), c.RecognitionID, c.Provider, c.ProviderID, c.Title, c.Year, c.URL, now); err != nil {
|
||||||
return fmt.Errorf("insert candidate: %w", err)
|
return fmt.Errorf("insert candidate: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ import (
|
|||||||
|
|
||||||
// фиксированные метки времени для детерминированных таймаут-тестов.
|
// фиксированные метки времени для детерминированных таймаут-тестов.
|
||||||
const (
|
const (
|
||||||
timeNow = "2026-06-14 10:00:00"
|
timeNow = "2026-06-14T10:00:00Z"
|
||||||
timeOld = "2026-06-14 08:00:00" // 2 часа назад
|
timeOld = "2026-06-14T08:00:00Z" // 2 часа назад
|
||||||
timeRecent = "2026-06-14 09:59:00" // 1 минута назад
|
timeRecent = "2026-06-14T09:59:00Z" // 1 минута назад
|
||||||
)
|
)
|
||||||
|
|
||||||
type fakeStore struct {
|
type fakeStore struct {
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -333,3 +333,32 @@ MUST NOT показываться в карточке списка — он до
|
|||||||
- **THEN** он не показывается в карточке списка, но доступен на странице
|
- **THEN** он не показывается в карточке списка, но доступен на странице
|
||||||
`/download/{id}`
|
`/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** приложение завершается с ошибкой конфигурации на старте
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user