feat: разбор и хранение тренировок и состояния разума

- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной
  строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`;
  миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки
  с этими ключами
- сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет
  содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а
  при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА —
  «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина
  расходилась бы с пересборкой молча
- отпечаток витрины покрывает тренировки и записи и снимается одним снимком
  базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
This commit is contained in:
av
2026-08-02 13:05:16 +03:00
parent c28de9796e
commit f8200f7f80
47 changed files with 5817 additions and 301 deletions
+12 -11
View File
@@ -548,10 +548,11 @@ func FuzzParse(f *testing.F) {
})
}
// Половина живого потока состоит из непокрытых секций целиком (48 доставок из
// 99: workouts и stateOfMind). Без списка они неотличимы от разобранной
// доставки с пустой секцией метрик, и ретеншен, ориентируясь на статус, срезал
// бы тела, которые для stateOfMind единственный источник.
// Непокрытая секция неотличима от разобранной доставки с пустой секцией
// метрик, если её не назвать: ретеншен, ориентируясь на статус, срезал бы
// тела, которые для секций без экспорта Apple единственный источник. С тех пор
// как workouts и stateOfMind стали покрытыми, роль непокрытой в фикстуре
// играют секции, которых поток ещё не приносил.
func TestParseПеречисляетНепокрытыеСекции(t *testing.T) {
t.Parallel()
@@ -560,7 +561,7 @@ func TestParseПеречисляетНепокрытыеСекции(t *testing.
t.Fatalf("разбор: %v", err)
}
want := []string{"stateOfMind", "workouts"}
want := []string{"ecg", "symptoms"}
if !slices.Equal(res.Uncovered, want) {
t.Errorf("непокрытые %v, ожидались %v", res.Uncovered, want)
}
@@ -578,11 +579,11 @@ func TestParseНепокрытыеСекцииГраницыИДетермини
t.Run("доставка из одной непокрытой секции", func(t *testing.T) {
t.Parallel()
res, err := hae.Parse([]byte(`{"data":{"stateOfMind":[{"x":1}]}}`), hae.Meta{})
res, err := hae.Parse([]byte(`{"data":{"ecg":[{"x":1}]}}`), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if !slices.Equal(res.Uncovered, []string{"stateOfMind"}) {
if !slices.Equal(res.Uncovered, []string{"ecg"}) {
t.Errorf("непокрытые %v", res.Uncovered)
}
if len(res.Points) != 0 {
@@ -609,11 +610,11 @@ func TestParseНепокрытыеСекцииГраницыИДетермини
t.Parallel()
bodies := []string{
`{"data":{"workouts":[],"stateOfMind":[],"ecg":[]}}`,
`{"data":{"ecg":[],"workouts":[],"stateOfMind":[]}}`,
`{"data":{"stateOfMind":[],"ecg":[],"workouts":[],"ecg":[]}}`,
`{"data":{"symptoms":[],"medications":[],"ecg":[]}}`,
`{"data":{"ecg":[],"symptoms":[],"medications":[]}}`,
`{"data":{"medications":[],"ecg":[],"symptoms":[],"ecg":[]}}`,
}
want := []string{"ecg", "stateOfMind", "workouts"}
want := []string{"ecg", "medications", "symptoms"}
for _, b := range bodies {
res, err := hae.Parse([]byte(b), hae.Meta{})
if err != nil {