# Схема базы 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`, раздел «Тренировки и прочие секции».