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
@@ -0,0 +1,80 @@
## Why
Половина живого потока разбором не покрыта. Замер по 118 доставкам архива:
65 несут `metrics`, 27 — `workouts`, 26 — `stateOfMind`. Тренировки и состояние
разума сохраняются в архив и числятся `partial`, но в витрину не попадают:
трекеру (второй потребитель паспорта) взять тренировку неоткуда, агенту-медику
состояние разума — тоже.
Для `stateOfMind` это дороже, чем для метрик: в родном экспорте Apple секции нет
ни одним типом (находка 46), то есть доставки HAE — её **единственный** источник,
и ретеншен архива без разобранной секции нельзя включать вовсе.
## What Changes
- Разбор покрывает две новые секции тела: `workouts` и `stateOfMind`. Остальные
секции с собственными `id` (`ecg`, `symptoms`, `cycleTracking`, `medications`,
`heartRateNotifications`) остаются непокрытыми намеренно — живьём поток их не
приносил ни разу, и модель под них закладывается, а разбор — нет.
- Две новые таблицы: `workout` (заголовок колонками, всё остальное, включая
маршрут и внутренние ряды, — `payload` дословно) и `record` (секции с
собственным `id`, ключ `kind + id`).
- Сущность с собственным `id` **заменяется целиком**, а не сливается по полям.
Правило замены названо явно: приехавшая версия побеждает, если не теряет
содержания сохранённой; иначе сохранённая остаётся, факт считается и идёт в
`WARN`. При равных наборах полей выигрывает версия из более поздней доставки
**журнала**, а не свёрнутая последней, — иначе живая витрина расходилась бы с
пересборкой молча.
- Сущность несёт провенанс — доставку своей версии и её метку приёма.
- Разбор метки времени принимает второй формат — RFC 3339 в UTC, которым HAE шлёт
`stateOfMind` (находка 16).
- Отпечаток витрины (оракул сходимости `reindex`) покрывает тренировки и записи,
а не одни часовые объекты, и снимается одним снимком базы. Отчёт `reindex`
считает «до и после» по каждой единице хранения и называет «покрыта новая
секция» ожидаемым классом расхождения.
- «Отказ разбора — всё или ничего» распространяется на сущности явно, включая
ветку невыводимого слоя и повтор ключа секции.
- Миграция переводит в `pending` доставки, у которых в списке непокрытых секций
стоят ставшие покрытыми имена, — правило «покрыли секцию — пересверните» уже
записано в спеке хранения.
- **Не входит:** отдача тренировок и записей наружу. Read API в проекте пока нет
вовсе; его форма (конверт ответа, выбор слоя, предел размера) проектируется
задачей `read-api-tochki`, и вводить два эндпоинта раньше конверта значило бы
задать контракт мимоходом.
## Capabilities
### New Capabilities
Новых нет: тренировка и запись — это то же хранилище и тот же разбор, только
другая единица хранения. Отдельная capability создала бы второй словарь для того
же домена.
### Modified Capabilities
- `parsing`: покрытых секций становится три вместо одной; появляется разбор
сущностей с собственным `id` и второй формат метки времени (RFC 3339),
оставленный предыдущей дельтой явно ненормированным; «всё или ничего»
распространяется на сущности.
- `storage`: появляется вторая единица хранения — сущность с собственным `id`, со
своим правилом замены версии; отпечаток витрины перестаёт быть отпечатком одних
часовых объектов; запрет на данные о здоровье в логах распространяется на
содержимое сущностей.
- `reindex`: счётчики отчёта покрывают все единицы хранения, а «покрыта новая
секция» становится названным классом ожидаемого расхождения.
## Impact
- `internal/hae` — разбор двух секций, второй формат метки, новые счётчики
пропусков.
- `internal/store` — таблицы `workout` и `record`, слияние доставки одной
транзакцией вместе с точками, отпечаток витрины.
- `internal/fold` — счётчики и единственный логирующий чекпоинт свёртки.
- `internal/replay` — ничего, кроме того, что отпечаток стал шире: пересборка
зовёт ту же свёртку.
- Миграция `00007` — две таблицы и перевод `partial`-доставок в `pending`.
- `docs/database.md`, `docs/architecture.md`, `docs/local-research.md` — схема,
правило замены версии и находка о поведении тренировки при переприсылке.
- Фикстуры `internal/hae/testdata` и скрипт их сборки `tmp/research/fixtures.py`:
тренировка с маршрутом и состояние разума — на реальных пакетах с вычищенными
измерениями.