Хранение времени: 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:
+27
-22
@@ -115,7 +115,7 @@ type Download struct {
|
||||
SourceMissCount int `db:"source_miss_count"`
|
||||
// SourceAddedAt — время добавления торрента в qBittorrent (added_on), базис
|
||||
// сортировки списка. NULL, пока воркер не наблюдал раздачу. Хранится в
|
||||
// формате sqliteTimeLayout (UTC), как created_at.
|
||||
// формате RFC 3339 (UTC, суффикс Z), как created_at.
|
||||
SourceAddedAt sql.NullString `db:"source_added_at"`
|
||||
CreatedAt string `db:"created_at"`
|
||||
UpdatedAt string `db:"updated_at"`
|
||||
@@ -148,22 +148,25 @@ func (d Download) PrimaryInfohash() string {
|
||||
return d.Infohashes[0].Infohash
|
||||
}
|
||||
|
||||
// sqliteTimeLayout — формат меток datetime('now') в SQLite (UTC).
|
||||
const sqliteTimeLayout = "2006-01-02 15:04:05"
|
||||
|
||||
// ParseTime разбирает временную метку SQLite (datetime('now'), всегда UTC).
|
||||
// ParseTime разбирает временную метку хранилища (RFC 3339, всегда UTC).
|
||||
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) —
|
||||
// байт-в-байт тот же, что у created_at/updated_at. Нужен, чтобы лексикографи-
|
||||
// ческое сравнение строк времени (COALESCE(source_added_at, created_at) в
|
||||
// сортировке списка) давало верный хронологический порядок.
|
||||
// FormatTime форматирует время в формат меток хранилища — RFC 3339 в UTC
|
||||
// (суффикс Z), напр. «2006-01-02T15:04:05Z». Единый формат всех меток; DEFAULT
|
||||
// в схеме нет, время всегда пишет приложение через Now (см. ниже). Фиксированная
|
||||
// ширина сохраняет лексикографическое сравнение строк времени = хронологию
|
||||
// (COALESCE(source_added_at, created_at) в сортировке списка).
|
||||
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).
|
||||
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 {
|
||||
return nil, fmt.Errorf("create download: no infohash")
|
||||
}
|
||||
now := FormatTime(Now())
|
||||
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -200,8 +204,8 @@ func (s *Store) CreateDownloadIfNoActive(ctx context.Context, d *Download, hashe
|
||||
// нему создал бы вторую активную задачу.
|
||||
for _, h := range norm {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT OR IGNORE INTO download_infohash (download_id, infohash, kind) VALUES (?, ?, ?)`,
|
||||
existing.ID, h, HashKind(h)); err != nil {
|
||||
`INSERT OR IGNORE INTO download_infohash (download_id, infohash, kind, created_at) VALUES (?, ?, ?, ?)`,
|
||||
existing.ID, h, HashKind(h), now); err != nil {
|
||||
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()
|
||||
const q = `
|
||||
INSERT INTO download (id, source_type, source_ref, display_name, context, state)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
INSERT INTO download (id, source_type, source_ref, display_name, context, state, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
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)
|
||||
}
|
||||
d.Infohashes = d.Infohashes[:0]
|
||||
for _, h := range norm {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO download_infohash (download_id, infohash, kind) VALUES (?, ?, ?)`,
|
||||
d.ID, h, HashKind(h)); err != nil {
|
||||
`INSERT INTO download_infohash (download_id, infohash, kind, created_at) VALUES (?, ?, ?, ?)`,
|
||||
d.ID, h, HashKind(h), now); err != nil {
|
||||
return nil, fmt.Errorf("insert download infohash: %w", err)
|
||||
}
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
now := FormatTime(Now())
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT OR IGNORE INTO download_infohash (download_id, infohash, kind) VALUES (?, ?, ?)`,
|
||||
downloadID, h, HashKind(h)); err != nil {
|
||||
`INSERT OR IGNORE INTO download_infohash (download_id, infohash, kind, created_at) VALUES (?, ?, ?, ?)`,
|
||||
downloadID, h, HashKind(h), now); err != nil {
|
||||
return fmt.Errorf("add infohash %s to %s: %w", h, downloadID, err)
|
||||
}
|
||||
}
|
||||
@@ -507,9 +512,9 @@ UPDATE download
|
||||
SET state = ?,
|
||||
error_code = ?,
|
||||
error_msg = ?,
|
||||
updated_at = datetime('now')
|
||||
updated_at = ?
|
||||
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() {
|
||||
q += ` AND state NOT IN (` + placeholders(&args, terminalStates) + `)`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user