reindex: пересборка витрины проигрыванием журнала

- `healthlog reindex` собирает витрину из журнала (тела архива + учёт
  доставок) в ОТДЕЛЬНЫЙ файл базы, строго по `(received_at, id)`; рабочую
  базу читает без наката миграций и не трогает вовсе. Подмену делает
  человек при остановленном сервисе: переименование поверх открытого
  дескриптора портит базу молча.
- Журналом считается архив, а не таблица доставок: тело без учётной записи
  заводится заново (метка из ULID, размер и хеш по распакованному телу),
  запись без тела переносится, но не сворачивается. Оракул сходимости
  встроен — два отпечатка и «объектов было/стало»; пустой журнал успехом не
  считается.
- Прогон живого архива переехал на новый пакет: второго проигрывателя
  журнала в проекте не осталось, а его утверждение о ключе сна перестало
  быть константой, протухающей с каждой доставкой.
This commit is contained in:
av
2026-08-02 09:07:46 +03:00
parent 84bcbbea5c
commit 5ae0c5ff81
36 changed files with 4452 additions and 258 deletions
+20
View File
@@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"strings"
"time"
"github.com/oklog/ulid/v2"
)
@@ -22,6 +23,25 @@ func NewID() string {
return strings.ToLower(ulid.Make().String())
}
// TimeOf возвращает время создания идентификатора: UTC, секундная точность —
// ровно та форма, в которой время хранится (см. store.Now).
//
// Нужен пересборке витрины: у тела, лежащего в архиве без учётной записи,
// другого источника метки приёма нет. Дата каталога архива не годится — она
// задаёт сутки, а порядок проигрывания нужен внутри суток.
//
// `.UTC()` здесь обязателен и не для красоты: ulid.Time собирает время через
// time.Unix, то есть в локальной зоне машины, а Truncate работает с абсолютной
// длительностью и зону не нормализует. Без приведения метка подобранного тела
// сравнивалась бы с меткой из БД по-разному на разных машинах.
func TimeOf(s string) (time.Time, error) {
id, err := ulid.ParseStrict(strings.ToUpper(strings.TrimSpace(s)))
if err != nil {
return time.Time{}, fmt.Errorf("%w: %q", ErrInvalid, s)
}
return ulid.Time(id.Time()).UTC().Truncate(time.Second), nil
}
// Parse валидирует внешний идентификатор и приводит его к каноническому виду.
// Вызывается на входных границах (HTTP, CLI) до запроса к БД: сравнение строк
// в SQLite побайтовое, а base32 ULID при декодировании нечувствителен к