Files
healthlog/docs/database.md
T
av 8331328134 Дозакрыты находки ревью по слиянию сущностей
- Правило покрытия получило второй разряд (условный, как у точек), запрет
  вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и
  ряд из null больше не затирают маршрут. Победитель внутри доставки стал
  функцией множества версий — общим помощником с точками, — а провенанс
  поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину
  к прежнему содержимому.
- Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной
  записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма
  считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает
  на старте; текст ошибки разбора не несёт значений из тела.
- Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты:
  безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя
  был квадратичен по числу присланных версий одного ключа.
2026-08-02 16:38:18 +03:00

148 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Схема базы
SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, файлы в
`internal/store/migrations`. Время в текстовых колонках всегда **UTC RFC 3339**
с секундной точностью: фиксированная ширина делает лексикографическую
сортировку `TEXT` совпадающей с хронологией.
**База производна.** Источник истины — сырой архив тел (`data/raw`); состояние
пересобирается свёрткой `import(экспорт Apple) + replay(доставки по received_at)`.
Поэтому терять базу неприятно, но не смертельно, а вот терять архив — смертельно.
```
┌────────────────────────────┐ ┌──────────────────────────────┐
│ delivery │ │ bucket │
│ ───────────────────────── │ │ ──────────────────────────── │
│ id TEXT PK │ │ metric TEXT ┐ │
│ received_at TEXT │ │ layer TEXT ├ PK │
│ automation_name TEXT │ ┄┄┄┄┄▶ │ hour_utc TEXT ┘ │
│ automation_id TEXT │ первая │ units TEXT │
│ aggregation TEXT │ доставка │ payload BLOB │
│ period TEXT │ часа │ content_hash TEXT │
│ session_id TEXT │ │ points INTEGER │
│ bytes INTEGER │ │ first_ts TEXT │
│ sha256 TEXT │ │ last_ts TEXT │
│ raw_path TEXT │ │ first_delivery_id TEXT │
│ parse_status TEXT │ │ sealed INTEGER │
│ points INTEGER │ │ created_at TEXT │
│ headers TEXT │ │ updated_at TEXT │
│ derived_layer TEXT │ └──────────────────────────────┘
│ uncovered_sections TEXT │
│ skipped_entities INTEGER? │
└────────────────────────────┘
┊ ┌──────────────────────────┐ ┌──────────────────────────┐
┊ │ workout │ │ record │
┊ доставка, │ ─────────────────────── │ │ ─────────────────────── │
└┄┄┄ чья версия ┄┄┄┄▶ │ id TEXT PK│ │ kind TEXT ┐ │
лежит сейчас │ name TEXT │ │ id TEXT ┘PK│
│ start_utc TEXT │ │ ts_utc TEXT │
│ end_utc TEXT │ │ tz_offset INTEGER│
│ tz_offset INTEGER│ │ payload BLOB │
│ duration_sec REAL? │ │ content_hash TEXT │
│ payload BLOB │ │ delivery_id TEXT │
│ content_hash TEXT │ │ delivery_received_at TEXT│
│ delivery_id TEXT │ │ created_at TEXT │
│ delivery_received_at TEXT│ │ updated_at TEXT │
│ created_at TEXT │ └──────────────────────────┘
│ updated_at TEXT │
└──────────────────────────┘
```
Связь `bucket.first_delivery_id → delivery.id` **внешним ключом не объявлена**
намеренно: доставки подрезаются ретеншеном до следующего проверенного экспорта,
а объекты живут дольше. Ссылка — провенанс для разбора слияний, а не
целостность.
## `delivery` — учёт принятых пакетов
Одна строка на принятое тело. Само тело лежит в архиве по `raw_path`
(относительный путь внутри `data/raw`).
| Колонка | Смысл |
|---|---|
| `id` | ULID, он же имя файла в архиве |
| `received_at` | время приёма, UTC |
| `automation_name`, `automation_id` | какая автоматизация HAE прислала; `automation_id` нужен выводу слоя — по нему наследуется слой доставки без плотных метрик |
| `aggregation` | заголовок `automation-aggregation`. Режима **не означает**: значение `Default` наблюдалось у посекундного, минутного и часового режимов одновременно |
| `period` | заголовок периода (`Since Last Sync` и прочие) |
| `bytes`, `sha256` | размер и хеш тела; хеш пока только для учёта |
| `parse_status` | `pending` / `parsed` / `partial` / `failed`. Код ответа приёма от него **не зависит**: сохранили — значит приняли. `pending` означает «ЭТИМ разбором ещё не смотрели», а не «тела не касались»: миграция 00005 перевела сюда доставки, разобранные кодом, который частичного разбора не различал |
| `points` | сколько точек дал разбор |
| `headers` | все заголовки запроса JSON-объектом, кроме несущих секреты |
| `uncovered_sections` | секции тела, которых разбор не покрыл, JSON-массивом имён; пустой список — `[]`. Ответ на вопрос «что останется потерянным, если тело удалить»: для `stateOfMind` он необратим, в экспорте Apple секции нет. Ретеншен обязан спрашивать его прежде, чем срезать тело |
| `skipped_entities` | сколько сущностей с собственным `id` разбор пропустил (нет `id`, `id` длиннее предела, метка не разбирается, элемент не объект). Вторая половина ответа на «что потеряется, если тело удалить»: список непокрытых секций про пропущенную сущность молчит. **NULL означает «не измерялось»** и нулю не равен — так выглядят доставки, свёрнутые разбором, который пропусков не считал; читатель, принимающий по счётчику необратимое решение, обязан трактовать NULL как «не удалять» |
| `derived_layer` | слой, выведенный для этой доставки. Нужен не отчётности, а самому выводу: доставка без плотных метрик наследует последний надёжно выведенный слой той же автоматизации, и без хранения этой памяти первая такая доставка после перезапуска осталась бы без слоя |
Индексы: `delivery_received_at` (порядок журнала), `delivery_sha256` (учёт
повторов), `delivery_automation_layer` (поиск последнего слоя автоматизации),
`delivery_pending` (очередь свёртки).
`delivery_pending` **частичный** — только строки со статусом `pending`. Таблица
и есть очередь фоновой свёртки: воркер выбирает неразобранные доставки в
порядке журнала чаще, чем раз в минуту. В установившемся режиме в индексе
ноль-одна строка, тогда как полный индекс по `parse_status` хранил бы всю
историю ради выборки из одной.
## `bucket` — часовой объект точек
Единица хранения — час одной метрики в одном слое, а не отдельная точка.
| Колонка | Смысл |
|---|---|
| `metric` | имя метрики как прислал HAE. Исключение — `sleep_analysis_summary`: под именем `sleep_analysis` приезжают две несовместимые схемы, и они разводятся на разные имена |
| `layer` | `sample` / `raw` / `minute` / `hour` / `day`. Выводится из выравнивания меток |
| `hour_utc` | начало часа, UTC. Час берётся **по началу точки** |
| `units` | единицы метрики. Внутри точки их нет, они живут на уровне метрики |
| `payload` | точки часа: JSON-массив исходных байтов, gzip. Точки упорядочены по началу |
| `content_hash` | хеш канонической формы — **детектор изменений**, не ключ. Совпал — записи нет |
| `points` | сколько точек внутри |
| `first_ts`, `last_ts` | границы содержимого; каталогу разрезов, чтобы не разжимать блоб ради диапазона |
| `first_delivery_id` | доставка, создавшая объект |
| `sealed` | час, в который досчёт не ожидается. Правило перевода пока не определено |
Таблица `WITHOUT ROWID`: обращение всегда по полному первичному ключу, и
лишний уровень косвенности через rowid ни разу не нужен.
**Идентичность точки внутри объекта** — координаты
`метрика + слой + начало + конец`, у точки-измерения конец равен началу.
`source` в ключ не входит: он нестабилен и переписывается задним числом. При
столкновении выигрывает более полная точка, а не последняя пришедшая.
## `workout` и `record` — сущности с собственным `id`
Вторая единица хранения витрины. Часовой объект им не подходит: у них есть
естественный ключ, они редки (за двое суток потока — две тренировки и две
записи состояния разума при 44 и 52 доставленных копиях), и группировать их по
часам незачем.
Таблицы две, а не одна с колонкой рода: у тренировки есть заголовок, по
которому идёт выборка (имя, интервал, длительность), а у записи его нет. Общая
таблица либо теряла бы заголовок, либо держала колонки, пустые у пяти родов из
шести.
| Колонка | Смысл |
|---|---|
| `workout.id` | идентификатор из HealthKit. Приходит из тела и ограничен по длине разбором: уезжает и в ключ, и в записи лога |
| `record.kind` + `record.id` | ключ — **пара**. Собственный `id` наблюдался живьём только у `stateOfMind`, где он UUID; форма идентификатора остальных пяти секций не наблюдалась никем, и короткий несквозной `id` в двух разных секциях затёр бы одну запись другой молча |
| `kind` | верхнеуровневый ключ секции HAE **дословно** (`stateOfMind`, не `state_of_mind`): инвариант «форма Apple не транслируется» относится и к именам секций |
| `start_utc` / `end_utc` / `ts_utc` | UTC RFC 3339. Конец, которого нет или который не читается, равен началу: ключ — `id`, схлопывать координаты нечем, а истина остаётся в `payload` |
| `tz_offset` | смещение зоны **начала**. У `stateOfMind` всегда `0` — это значит «источник прислал UTC», а не «человек был в Гринвиче»: местной зоны у секции в потоке нет вовсе. Клиент, считающий по нему местные сутки, ошибётся |
| `duration_sec` | длительность тренировки в секундах, как прислал HAE. **`NULL` означает «источник не прислал»**: ноль — законная длительность. Не вычисляется из интервала — HAE шлёт 91.746 при интервале в 91 секунду |
| `payload` | сущность целиком исходными байтами, gzip: заголовок, маршрут, внутренние ряды и сводки. Маршрут — 95% веса тренировки, а такой JSON жмётся примерно в 25 раз. Внутрь SQL-функциями не заглянуть — та же плата, что у `bucket.payload` |
| `content_hash` | хеш канонической формы: детектор изменений, не ключ. Тренировка переприсылается каждой доставкой, пока не доедет маршрут (44 копии дают три различных содержимых) |
| `delivery_id`, `delivery_received_at` | провенанс: доставка, **чья версия лежит сейчас**, и её метка приёма. Не отчётность: по паре разрешается тай-брейк между версиями равной полноты |
Индексы: `workout_start_utc` («заголовки тренировок за период» — основной запрос
трекера), `record_kind_ts` («записи такого-то рода за период» — единственная
форма запроса к таблице).
**Ряд пульса внутри тренировки лежит в её `payload`, а не в объектах метрики
`heart_rate`.** Пульс приезжает дважды — в общем потоке и внутри тренировки; это
разные таблицы, и смешение задвоило бы ряд.
**Замена версии условна.** Приехавшая побеждает, если не теряет содержания
сохранённой (множество ключей с непустым значением плюс длины верхнеуровневых
массивов); при равных наборах выигрывает версия из более поздней доставки
журнала, а не свёрнутая последней. Подробности и обоснование — в
`architecture.md`, раздел «Тренировки и прочие секции».