Files
healthlog/docs/architecture.md
T
av 5ae0c5ff81 reindex: пересборка витрины проигрыванием журнала
- `healthlog reindex` собирает витрину из журнала (тела архива + учёт
  доставок) в ОТДЕЛЬНЫЙ файл базы, строго по `(received_at, id)`; рабочую
  базу читает без наката миграций и не трогает вовсе. Подмену делает
  человек при остановленном сервисе: переименование поверх открытого
  дескриптора портит базу молча.
- Журналом считается архив, а не таблица доставок: тело без учётной записи
  заводится заново (метка из ULID, размер и хеш по распакованному телу),
  запись без тела переносится, но не сворачивается. Оракул сходимости
  встроен — два отпечатка и «объектов было/стало»; пустой журнал успехом не
  считается.
- Прогон живого архива переехал на новый пакет: второго проигрывателя
  журнала в проекте не осталось, а его утверждение о ключе сна перестало
  быть константой, протухающей с каждой доставкой.
2026-08-02 09:07:46 +03:00

953 lines
78 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.
# Архитектура
## Назначение
healthlog принимает выгрузки Apple Health из приложения Health Auto Export
(далее HAE) и родного экспорта Apple Health, хранит их и отдаёт другим
приложениям — в том числе агентам, через MCP. Он не переименовывает поля и не
интерпретирует значения; агрегаты считает только в ответе на запрос и только
там, где род метрики измерен, а не угадан.
## Принципы
- **Один статический бинарь** (`CGO_ENABLED=0`), доставка — docker-образом.
- **Точки хранятся дословно.** Часовой объект держит точки ровно в том виде,
в каком их прислал HAE — без переименований, пересчётов и отбрасывания
незнакомых полей. Поэтому хранилище само по себе является полной копией
данных, а не производной выжимкой.
- **Хранилище — свёртка по журналу, а не единственная копия.** Экспорт Apple
это снапшот всей истории, доставки HAE после его даты — события поверх
снапшота. Состояние всегда пересобираемо: `import(экспорт) + replay(доставки)`.
Отсюда срок жизни сырого архива — до следующего проверенного экспорта, а не
произвольные две недели. Исключение названо вслух: `stateOfMind` в экспорт не
попадает, см. «Хранилище».
- **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор
(см. «Приём»).
- **Ничего не теряем молча.** Идентичность — устойчивые координаты
(`метрика + слой + начало + конец`; у точки-измерения конец равен началу);
`source` в ключ не входит, он
нестабилен. Хеш канонизированного содержимого остаётся детектором изменений,
чтобы не писать зря. При столкновении выигрывает более полная точка, а не
последняя пришедшая: бедная доставка не должна стирать поля у богатой.
Полнота — **множество** ключей с непустым значением, а не их число (см.
«Разрешение столкновений»).
- **Дыры закрываются сами.** Данные приходят несколькими проходами разной
глубины, поэтому пропущенная доставка не оставляет постоянного пробела —
см. «Модель синхронизации».
- **Форма Apple не транслируется.** Значения отдаются такими, какими пришли;
нормализовано только время. Единственное добавление — стабильный код рядом
с переведённой строкой (см. «Категориальные значения»): он приписывается, а
не подменяет.
- **Своей агрегации в хранении нет — есть слои.** Метрика лежит в тех
разрезах подробности, в которых пришла (`sample`/`raw`/`minute`/`hour`);
переагрегирования при записи не происходит никогда.
- **Агрегация в ответе — только измеренная.** Read API умеет свести метрику к
запрошенной сетке, но род свёртки (сумма или среднее) выведен сверкой слоёв
между собой, а не проставлен вручную. Где род неизвестен, агрегация не
предлагается: отдаются значения как есть.
- **Минимум компонентов** — один процесс, SQLite, файлы. Без очередей и
внешних зависимостей.
## Формат Health Auto Export
Документация формата скудная: [help.healthyapps.dev](https://help.healthyapps.dev/en/health-auto-export/automations/rest-api/)
и [wiki Lybron/health-auto-export](https://github.com/Lybron/health-auto-export/wiki/API-Export---JSON-Format).
Ниже — то, на что мы опираемся; всё остальное уточняем по реальным пакетам.
Автоматизация HAE шлёт **POST** с JSON-телом и своими заголовками:
`automation-name`, `automation-id`, `automation-aggregation`,
`automation-period`, `session-id`. Свои заголовки (токен) добавляются в
настройках автоматизации. Большой экспорт может приехать несколькими
запросами (**Batch Requests**) — поэтому идемпотентность нужна на уровне
точки, а не пакета.
Мета-информации в **теле нет вообще** — только `{"data": {…секции…}}`. Из
заголовков в коде опираемся лишь на `automation-id` (стабильный UUID
автоматизации) и `session-id`: `automation-aggregation` и `automation-period`
называют настройку, а не фактический режим, и значение `Default` соответствует
трём разным поведениям (находка 31). Гранулярность и охват определяем по самим
данным. Полный набор заголовков сохраняется в `delivery.headers` — документация
заведомо неполна, и именно из незадокументированного вышли самые полезные
находки.
```
{"data": {"metrics": [...], "workouts": [...], "stateOfMind": [...],
"medications": [...], "symptoms": [...], "cycleTracking": [...],
"ecg": [...], "heartRateNotifications": [...]}}
```
Метрика — `{"name": "heart_rate", "units": "count/min", "data": [...]}`.
**Форма точки зависит от метрики**: обычная — `{qty, date}`, пульс —
`{Min, Avg, Max, date}`, давление — `{systolic, diastolic}`, сон — набор
интервалов и фаз, глюкоза — плюс `mealTime`. Единой формы значения нет;
общее — только момент времени.
Вопреки документации, в точке **есть поле `source`** — какие устройства
вложились в значение (составное, через `|`). Что ещё документация описывает
неверно и как поток выглядит на самом деле — [local-research.md](local-research.md).
Даты приходят строкой с офсетом: `2026-07-31 12:00:00 +0300` — не RFC 3339.
Тренировка (v2) несёт стабильный `id` из HealthKit, `start`/`end`/`duration`,
опционально `route` (точки GPS) и `heartRateData`.
**Наша настройка:** несуммированные данные (переключатель «Суммировать
данные» выключен, группировка при этом недоступна). Причина — суммированные
значения досчитываются задним числом: минутное ведро уезжает неполным и в
следующей доставке приезжает полным
([local-research.md](local-research.md), находка 10). На несуммированных
данных расхождений не наблюдалось (находка 3), поэтому идентичность по
содержимому работает без оговорок. Заодно сохраняются детали, которые
группировка съедает: эпизоды сна и межударные интервалы (находки 6, 19).
Чего это **не** даёт: настоящих сэмплов. Накопительные метрики (энергия,
шаги, дистанция) в любом режиме приходят посекундной сеткой — нарезкой
реальных сэмплов длиной 1–12 секунд, с сохранением итога и потерей границ
интервала. Порядка 135 тысяч точек в сутки (находки 20, 23).
Поля `start`/`end` есть только у дискретных метрик (пульс, сатурация, сон,
HRV); у накопительных — только `date`. Поэтому точка относится к часу **по
`date`**, а вопрос о сэмплах, пересекающих границу часа, касается сотой доли
данных (находка 21).
## Модель синхронизации
У модели два независимых измерения: **глубина окна** (как далеко назад
переспрашиваем) и **подробность** (в какой слой попадут данные). Проходы
задаются их сочетанием.
По глубине — три прохода, догоняющие друг друга:
| проход | расписание | период | зачем |
|---|---|---|---|
| быстрый | каждые 5 минут | Since Last Sync | свежесть |
| средний | 3–4 раза в день | **Today** | чинит пропуски за сутки |
| глубокий | раз в сутки | **Previous 7 Days** | чинит всё остальное |
По подробности — что в какой слой:
| подробность | набор метрик | слой |
|---|---|---|
| без группировки | только несуммируемые: сон, пульс, HRV | `raw` |
| минутная | все метрики здоровья | `minute` |
| часовая | все метрики здоровья | `hour` |
| ручной экспорт | всё, раз в 2–3 месяца | `sample` |
Набор нижнего слоя определяется не важностью метрики, а тем, **можно ли её
складывать**. Для пульса и HRV посекундная подробность несёт форму сигнала,
которой в минутном разрезе нет. Для шагов и энергии нижний слой HAE — это
интерполяция, которая не сходится в сверке (находка 34); держать её значило бы
хранить втрое больший объём ради худших чисел.
Секции без группировки в интерфейсе HAE (`stateOfMind`, `symptoms`, `ecg`,
`heartRateNotifications`, `cycleTracking`, `medications`) идут как есть — у них
подробности нет, есть только глубина окна.
Средний и глубокий проходы используют **фиксированные окна, а не метку
синхронизации** — это принципиально. Инкрементальный режим проверен и
**теряет данные**: в окне, которое он якобы покрыл, широкая выгрузка нашла
13 961 точку, включая фазы сна за три часа и весь глубокий сон той ночи
(находка 29). Фиксированное окно идемпотентно по построению и не зависит ни от
какой метки.
Расписание — пожелание, а не гарантия: iOS не даёт приложению запускаться в
заданное время, а к данным Health доступа нет вовсе, пока телефон заблокирован
(находка 28). Поэтому проходы привязываются к моментам, когда телефон заведомо
разблокирован (триггер из Shortcuts по времени суток), а поток считается
пачечным: тишина ночью, всплеск утром.
Гарантия починки:
```
дыра моложе суток → закроется в течение часа
дыра моложе недели → закроется в течение суток
дыра старше недели → не закроется; лечится `healthlog import`
```
Широкие проходы почти бесплатны именно из-за часовых объектов: глубокий проход
переприсылает неделю, но это 26 метрик × 168 часов ≈ 4400 сравнений хеша,
почти все из которых сойдутся, и записи не будет.
**Прежнее правило «настройки данных у всех проходов одинаковы» снято.** Оно
существовало потому, что метрика, приехавшая с разной группировкой,
перетирала сама себя по одному ключу (находка 14). С тех пор слой вошёл в
ключ, и минутная точка с часовой больше не сталкиваются — они в разных рядах.
Именно это и позволяет наполнять слои разными автоматизациями намеренно.
Условие, при котором это безопасно: слой выводится **из выравнивания меток, а
не из настройки автоматизации**. Перенастроил автоматизацию — данные просто
пойдут в другой слой, без порчи уже накопленного.
### Досчёт задним числом
Метрики правятся после факта, и глубина правки резко разная по классам:
- **Количественные** (пульс, шаги, энергия) человек руками не правит; они
опаздывают на часы. Наблюдались правки хвоста возрастом до 22 минут.
Недельного глубокого прохода достаточно.
- **Ручные записи** (`symptoms`, `medications`, `stateOfMind`,
`cycleTracking`) заводятся задним числом на недели и месяцы — симптом или
приём лекарства можно отметить за прошлую дату.
Поэтому окно досчёта **не единое**. Растягивать глубокий проход на месяц по
всем метрикам в минутном разрезе нельзя: тела запросов и так доходили до
42 МБ (находка 23), а месяц минутных данных — это десятки мегабайт на каждую
доставку. Вместо этого редкий широкий проход **только по ручным секциям**: их
единицы записей, и месячное окно там почти ничего не стоит.
Правило слияния одинаково для всех проходов, и порядок прихода значения не
имеет. Но «последние данные всегда актуализируют картину» — неверно и никогда
не было верным: при столкновении выигрывает более полная точка, а не последняя
пришедшая (см. «Разрешение столкновений»).
Автоматизации различимы по заголовку `automation-id`; имена стоит задать,
иначе `automation-name` приходит пустым (находка 12).
## Компоненты
| Пакет | Ответственность |
| ---------- | ------------------------------------------------------ |
| `config` | загрузка и валидация TOML-конфига |
| `logging` | сборка slog-логгера |
| `ident` | генерация и разбор ULID |
| `archive` | сырой архив: запись тела, чтение для reindex, ретеншен |
| `hae` | разбор формата HAE, канонизация, хеш содержимого |
| `ingest` | use-case приёма, общий для HTTP и CLI `import` |
| `fold` | свёртка одной доставки в часовые объекты |
| `replay` | проигрывание журнала в витрину: состав, порядок, отчёт |
| `store` | SQLite: доставки, часовые объекты, тренировки, записи |
| `httpapi` | приём и read API |
## Приём
```
запрос → токен → лимит тела, gzip → проверка формы JSON
→ запись тела в архив → строка в delivery → 200
→ разбор → запись в витрину
```
Код ответа определяется **доставкой**, не разбором:
- **400** — тело не разбирается как JSON ожидаемой верхнеуровневой формы.
Это проблема транспорта (обрыв, обрезанное тело), и отправителю о ней
надо сказать.
- **200** — тело сохранено в архив. Дальше даже полный провал разбора
(незнакомая метрика, новая форма точки) не меняет ответ: данные уже в
безопасности, исход разбора виден в логе, в `delivery.parse_status` и в
`/stats`, а доразобрать их можно командой `reindex`.
#### Частичный разбор
Разбор покрывает секцию `metrics`; `workouts`, `stateOfMind`, `symptoms`, `ecg`
и прочие проходят мимо. Это половина потока: 48 доставок из 99 не несут
`metrics` вовсе (находка 50).
Такая доставка получает статус `partial`, а имена непокрытых секций — колонку
`delivery.uncovered_sections`. Статус отвечает на вопрос «разобрано ли всё»,
список — «что именно осталось»; спрашивать полагается статус. Без этого
различения `parsed` означал бы «разобрано» и для доставки, из которой не
прочитано ни байта, а ретеншен, поверив ему, срезал бы тело — необратимо для
`stateOfMind`, которого в экспорте Apple нет.
Перечисление идёт **в том же проходе**, что и разбор метрик: значение
непокрытой секции проглатывается декодированием в выбрасываемый `RawMessage`,
поэтому копия одна, живёт до следующего члена и удерживается ноль (измерено:
тело 40 МиБ, из которых почти всё — непокрытая секция, удерживает 0 МиБ).
Пропуск ручным счётом глубины по токенам этого не даёт: делимитеры идут мимо
сканера, ограничитель вложенности `encoding/json` не работает, и тело из
вложенных скобок съедает память вместо отказа.
`partial` — не отклонение, а установившееся состояние, поэтому уровень лога от
него не растёт. Постоянный `WARN` каждые пять минут обесценил бы уровень.
**Правило для будущих задач: покрыли секцию — пересверните.** Список это снимок
покрытия на момент свёртки; доставки, свёрнутые до того, как секция стала
покрытой, останутся `partial` со старым списком, и ретеншен будет вечно щадить
ненужные тела. Задача, которая начинает разбирать секцию, тем же изменением
переводит `partial`-строки с этим ключом в `pending`.
- **413** — тело больше допустимого. Граница стоит на **распакованном**
потоке, а не только на сжатом: `MaxBytesReader` поверх `r.Body` ограничивает
то, что приехало по сети, а в память попадает то, что из этого развернулось.
Измерено: 400 КиБ сжатого тела давали 400 МиБ и гигабайт выделений при
лимите в мегабайт. Потолок степени сжатия gzip около 1030:1, так что при
штатных 64 МиБ речь о десятках гигабайт на запрос, и параллельные
складываются. Цена отказа здесь наивысшая в проекте: приём — единственное
место, где поток вообще существует, и доставка, не попавшая в архив, не
попадает в журнал. Та же граница действует при чтении тела из архива —
иначе тело между двумя границами принималось бы с `200`, а потом вечно
валилось бы при каждой пересборке.
Причина такого разделения: неизвестно, шлёт ли HAE отклонённый пакет
повторно при периоде «Since Last Sync». Если не шлёт, строгий приём означал
бы дыру в истории. Многоуровневая синхронизация страхует тот же риск с другой
стороны — но полагаться только на неё нельзя: она чинит дыры за неделю, а не
за год.
## Хранилище
### Сырой архив и восстановление состояния
`raw/ГГГГ/ММ/ДД/<ulid>.json.gz` — тело запроса как пришло, не редактируется.
Два источника вместе образуют **полный журнал событий**, а хранилище —
свёртку по нему:
```
состояние = import(последний проверенный экспорт) ← снапшот всей истории
+ replay(доставки после его даты) ← хвост событий
```
Экспорт Apple — не просто «источник истины для нижнего слоя», а снапшот: он
содержит всю историю целиком (3.6 млн записей с 2019 года). Доставки HAE после
его даты — события поверх снапшота. Значит любое повреждение хранилища,
включая ошибку в нашем разборе любой давности, лечится пересборкой, а не
восстановлением из бекапа.
Отсюда три следствия, каждое из которых меняет реализацию.
**Срок жизни архива определяется циклом экспорта, а не календарём.** Прежние
14 дней были произвольным числом. Правильное правило: доставки хранятся **до
следующего проверенного экспорта**, иначе в журнале появится дыра между концом
ретеншена и датой снапшота. Цена измерена: поток даёт ~23 МБ архива в сутки,
то есть ~2 ГБ за квартал между экспортами. Это дёшево за возможность
пересобрать что угодно.
**Свёртка обязана быть детерминированной.** Проигрывание должно давать то же
состояние, что и приём в реальном времени. Слияние «выигрывает более полная
точка» коммутативно и порядка не требует; но когда две одинаково полные точки
несут разные значения, исход решает порядок — поэтому воспроизведение идёт
строго по `received_at`, а не по порядку файлов в каталоге.
**`reindex` и `import` — одна операция, а не две.** Восстановление это импорт
снапшота плюс проигрывание хвоста; отдельной «пересборки из архива» не
существует, она просто вырожденный случай с пустым снапшотом. Проигрывание
живёт в `internal/replay`; `healthlog import` добавит стадию снапшота **перед**
ним, а не заведёт вторую похожую операцию.
**Журналом считается архив, а не таблица доставок.** Перечислять строки
`delivery` значило бы пересобирать витрину из витрины. Тело может лежать в
архиве без учётной записи: приём кладёт его на диск раньше строки в базе
(обратный порядок дал бы учтённую доставку без данных), и отказ на вставке
оставляет тело без учёта — такое тело пересборка заводит заново, восстанавливая
метку приёма из ULID, а размер и хеш пересчитывая по распакованному телу.
Обратный случай — строка без тела — станет штатным вместе с ретеншеном и потому
считается, а не роняет прогон.
**Заголовков доставки в архиве нет**, и это named предел модели: они живут
только в `delivery`, поэтому пересборка читает рабочую базу, а полная потеря
базы деградирует вывод слоя навсегда. Закрывается это тем, что заголовки надо
класть в архив рядом с телом (так делает WARC) — отдельная задача беклога.
#### Пересборка идёт в отдельный файл, а подмену делает человек
Пересборка обязана начинаться с **пустой** витрины: точки из объекта не
удаляются никогда, поэтому проигрывание поверх накопленного оставило бы в ней
результат прежнего, неверного разбора — то есть не сделало бы того, ради чего
она существует.
Начать с пустой можно двумя способами, и выбран второй.
- **Очистить рабочую витрину и проиграть в неё же** — отвергнуто. Единственная
необратимая операция всей задачи (`DELETE FROM bucket`) выполнялась бы **до**
того, как станет известно, удалась ли пересборка; отказ на середине оставлял
бы витрину пустой наполовину в состоянии, неотличимом от нормального.
- **Собрать рядом и подменить** — взято. Это blue-green rebuild проекции,
стандартный приём event sourcing («вместо усечения существующей модели строим
новую в параллельном хранилище и переключаем чтение»); той же формы `_reindex`
с переключением алиаса в Elasticsearch и собственный `VACUUM INTO` SQLite.
Отказ становится бесплатным: рабочая база не тронута, промежуточный файл
удаляется.
- **Теневая таблица в той же базе** (`bucket_new` → переименование в
транзакции) — отвергнуто дважды. Имя `bucket` зашито литералом во весь слой
записи, то есть вариант требует параметризовать таблицей самый опасный код
проекта ради операции раз в полгода; и он не решает того, ради чего
затевался, — живой приём во время пересборки пишет в **старую** таблицу, и
при подмене его точки пропадают.
**Подмену рабочей базы делает человек, и это не лень.** Файл базы держит
открытым процесс сервиса, а переименование не касается уже открытого
дескриптора: процесс продолжит писать в отвязанный inode, читатели увидят новый
файл, данные разойдутся молча. Документация SQLite называет переименование
используемого файла прямой причиной порчи базы. Безопасная подмена требует
остановленного сервиса, а остановить его команда не может — сервисом управляет
окружение снаружи, и CLI, делающий вид, что управляет, обещал бы безопасность,
которой не обеспечивает. Поэтому команда печатает процедуру, а выполняет её
человек:
```
task down
healthlog reindex --config ./config.toml
mv ./data/healthlog.db.rebuild ./data/healthlog.db
rm -f ./data/healthlog.db-wal ./data/healthlog.db-shm
task up
```
Пересборка при этом **читает рабочую базу без наката миграций**: обычное
открытие мигрирует безусловно, а миграции меняют и данные (та, что ввела
частичный разбор, переписала `parse_status` у всех строк). Расхождение версии
схемы — отказ с указанием обеих, а не миграция под работающим сервисом.
**Оракул сходимости встроен в команду**: печатаются отпечаток рабочей витрины и
отпечаток пересобранной, снятые так, что первый берётся **до** проигрывания —
иначе под живым приёмом он движется, и ответ «разошлись» не значил бы ничего.
Пустой журнал при этом успехом не считается: отпечаток пустой витрины совпадает
с отпечатком пустой витрины, то есть выглядит идеальной сходимостью, а человек,
выполнивший напечатанную процедуру, заменил бы накопленное пустым.
Что пересборка **не** переносит: признак `sealed` (правила его выставления ещё
нет, переносить нечего) и производные от разбора поля учёта — `parse_status`,
`points`, `derived_layer`, `uncovered_sections`. Последнее не косметика:
доставка, чей повторный разбор отказал, отдала бы в наследование слой прежнего
разбора, и витрина снова стала бы функцией предыдущего прогона, а не журнала.
#### Что не восстанавливается, и это сказано вслух
Модель почти полна, но не полностью — умолчать об этом опаснее, чем признать.
**`stateOfMind` в экспорте отсутствует вовсе.** Проверено на свежем экспорте:
ни одного типа со словом `StateOfMind` (есть только `MindfulSession` — это
минуты осознанности, другое). Состояние разума живёт **только** в доставках
HAE. Значит для него доставки не хвост журнала, а единственный источник: либо
они не удаляются никогда, либо его история держится на самих сохранённых
записях и восстановлению не подлежит.
**Верхние слои за периоды с удалёнными доставками.** После проигрывания
снапшота у старого периода будет только слой `sample`; `minute` и `hour` за
него не воскреснут. Посчитать их вниз из `sample` технически можно — и нельзя
по инварианту: это была бы **наша** агрегация под видом присланной.
Поэтому правило: **восстановление не обязано быть побайтным, оно обязано быть
честным.** Каталог разрезов показывает, какие слои есть за какой период; после
пересборки старый период честно объявляет один слой вместо трёх, а не
притворяется, что ничего не изменилось.
### Устаревание нижнего слоя
Родной экспорт Apple Health точнее HAE (находка 34) и делается раз в 2–3
месяца. Данные HAE в нижнем слое старше последнего экспорта избыточны: тот же
период лежит в слое `sample` подробнее и честнее.
Два ограничения, без которых правило опасно:
**Пометка вешается по загруженному экспорту, а не по сделанному.** Условие —
экспорт разобран, и проверено, что он **покрывает период**: непрерывность по
дням и сходимость сумм с часовым слоем. Иначе срок жизни данных повисает на
ручной операции, которую можно забыть или сделать наполовину, — а этот
механизм уже протекал: «Since Last Sync» молча потерял 13 961 точку, включая
ночь сна целиком (находка 29).
**Чистится только нижний слой.** Разница между слоями — три порядка: `hour`
это ~100 координат в сутки, `minute` ~3 700, `raw` ~100 000 (находка 41).
Удаление верхних слоёв не экономит ничего, но ломает ответы на исторические
запросы. Всё давление по объёму создаёт нижний слой, и ровно там экспорт —
настоящее надмножество.
Пометка «устарело» **не равна удалению**: сперва данные помечаются и остаются
доступными, удаление — отдельный шаг с собственным сроком. Пока восстановление
из экспорта не проверено на живых данных хотя бы раз, удаление не включается
вовсе.
Оговорка: для секций, которых в экспорте нет (ЭКГ выгружается отдельными CSV,
судьба `stateOfMind` и лекарств не проверена), экспорт источником истины не
является и правило к ним неприменимо — их держим всегда.
**Это осознанная смена источника истины.** Пока тело в архиве, истина — оно;
после удаления истиной остаются часовые объекты. Инвариант, который держит
конструкцию: **объект хранит точки дословно**. Если разбор начнёт что-то
отбрасывать или нормализовать внутри точки, срок хранения архива станет
сроком жизни данных.
### Часовые объекты метрик
Точки метрик хранятся не по одной, а **пачками: один объект = одна метрика за
один час UTC**.
```
delivery(id, received_at, automation_name, automation_id, aggregation,
period, session_id, bytes, sha256, raw_path, parse_status, points,
headers, derived_layer)
bucket(metric, layer, hour_utc, units, payload BLOB, content_hash, points,
first_ts, last_ts, first_delivery_id, sealed, created_at, updated_at)
PK (metric, layer, hour_utc) WITHOUT ROWID
workout(id PK, name, start_utc, end_utc, tz_offset, duration_sec,
payload JSON, delivery_id, updated_at)
record(id PK, kind, ts_utc, tz_offset, payload JSON,
delivery_id, updated_at)
INDEX (kind, ts_utc)
```
Зачем пачками:
- **Строк на два порядка меньше** — 26 метрик × 24 часа = 624 объекта в сутки
вместо ~155 тысяч точек. За год 228 тысяч строк вместо 55 миллионов.
- **Дедупликация дешевеет во столько же раз.** Повторная доставка того же часа
— одно сравнение хеша вместо тысяч поисков по точкам. Это и делает широкие
проходы синхронизации почти бесплатными.
- **Хранение сжимается.** `payload` — gzip-BLOB: наблюдаемое сжатие такого
JSON — примерно 25 раз, то есть ~2 МБ в сутки вместо ~50 МБ. Цена: внутрь
объекта не заглянуть SQL-функциями, разбор только в приложении. Для
хранилища, которое отдаёт диапазоны точек, это не потеря.
### Слои гранулярности
Одна и та же метрика может приходить с разной подробностью: несуммированной,
минутной, часовой. Мы **не сводим их к одной** и не агрегируем сами — храним
разрезами и говорим клиенту, какие разрезы есть.
```
sample настоящие сэмплы HealthKit с интервалами start/end — только из
ручного экспорта Apple Health, HAE такого не отдаёт (находка 34)
raw метки на произвольной секунде heart_rate 00:02:07
minute метки выровнены на минуту heart_rate 00:02:00
hour метки выровнены на час heart_rate 00:00:00
```
Почему не переагрегируем **при записи**: правильный способ свёртки зависит от
метрики (сумма для энергии, среднее для пульса), и ошибка здесь необратима —
исходные точки уже не вернуть. При записи слои остаются раздельными всегда.
Свести их **в ответе** можно, и Read API это делает, — но род свёртки не
проставляется вручную, а **измеряется**: одна метрика лежит в минутном и
часовом разрезе одновременно, и если часовое значение сходится с суммой
минутных, метрика накопительная; если со средним — мгновенная. Форма точки
рода не выдаёт (`Avg`/`Min`/`Max` есть только у `heart_rate`), единицы дают
процентов девяносто и ломаются на краях — `six_minute_walking_test_distance`
в метрах складывать нельзя, а `walking_running_distance` в километрах можно
(находка 40). Где данных на сверку не хватило, род остаётся неизвестным и
агрегация по метрике не предлагается вовсе.
Отдельно: **нижний слой HAE не суммируется никогда.** Он не сэмплы, а
посекундная развёртка (находка 34) и в сверке не сходится — сумма по нему
даёт завышение. Накопительные метрики агрегируются только из `minute`,
`hour` или `sample`.
**Слой — это режим выгрузки, которым пришли данные**, а не измеренное
разрешение каждой метрики. Различие принципиально: частота метрик разная —
пульс идёт секундами, VO₂ max случается раз в неделю, — и выводить слой из
частоты значило бы дробить редкие метрики между слоями без всякого смысла.
Режим же общий для доставки, и редкая метрика просто наследует его.
**Определяется по данным, а не по заголовку.** `automation-aggregation`
непригоден: значение `Default` соответствует трём разным режимам сразу
(находка 31). Правило:
1. **Плотная метрика** (не меньше десяти точек в доставке) классифицируется
**сама по себе** по выравниванию своих меток — по **самому мелкому**
встретившемуся, а не преобладающему: метка ровно на часе одновременно
является и минутной, и у плотных метрик они перемешаны (`active_energy`
1320 минутных и 21 часовая). У десяти несуммированных точек шанс всем лечь
на ровную минуту исчезающе мал.
2. **Редкая метрика** (меньше десяти точек) наследует **преобладающий слой
доставки** — самый мелкий среди плотных. У неё выравнивание ничего не
доказывает, а Apple многие редкие показатели пишет прямо на границе часа.
3. Плотных метрик в доставке нет вовсе — слой наследуется от **предшествующей**
доставки той же автоматизации; если её не было, берём **надёжный** заголовок
(`Minutes``minute`, `Hours``hour`). Иначе точки не сохраняются вовсе:
молчаливый `raw` создал бы призрачный разрез, который поедет в каталог и в
правило Read API «самый мелкий слой, покрывающий диапазон».
Слово «предшествующей» в третьем пункте несёт вес: слой обязан быть функцией от
**префикса журнала**. Наследование от последней доставки вообще делает свёртку
зависящей от истории, и пересборка даёт не то состояние, что живой приём —
поймано прогоном архива, 1737 объектов против 1742 (docs/review-journal.md).
Классифицировать доставку целиком нельзя: при перенастройке автоматизации
приезжают **смешанные доставки**, где часть метрик уже минутная, а часть ещё
посекундная. Одна такая доставка, отнесённая к слою целиком, сложила минутные
точки с посекундными и удвоила сумму за час (находка 35).
Заголовок сохраняем и сверяем с выведенным; расхождение и смену режима у
автоматизации пишем `WARN` — так видна перенастройка, а не тихий дребезг.
Почему не по метрике отдельно (проверено на живых данных, находка 33): одна
доставка законно содержит метрики разной подробности — `apple_stand_hour`
почасовой по своей природе, `sleep_analysis` в минутном режиме превращается в
суточный агрегат на `00:00:00`, а `heart_rate` рядом с ними идёт с секундной
точностью. Классификация каждой по отдельности растащила бы одну выгрузку по
трём слоям.
**Исключение — `sleep_analysis`.** Под этим именем HAE шлёт две несовместимые
схемы: поэпизодную (`start`/`end`/`value`/`qty`) и суточную сводку
(`totalSleep`/`core`/`rem`/`deep`/`awake`, метка на местной полуночи). Общих
полей, кроме `date` и `source`, у них нет, источники тоже разные — эпизоды от
стороннего приложения, сводка от часов (находка 38). Правило выравнивания на
сводке даст `hour`, хотя это суточный итог, а не часовой разрез. Поэтому в
каталоге они разводятся на два имени — `sleep_analysis` и
`sleep_analysis_summary`, — и слой у сводки не выводится, а фиксирован как
`day`. Хранение остаётся дословным: разводятся имена, а не содержимое.
Пересборка применяет к уже разобранному **исправленный** разбор — это и есть
причина держать сырой архив. Точнее она именно этим, а не тем, что видит более
длинный ряд: слой обязан оставаться функцией **префикса** журнала, и наследование
«от последней доставки вообще» уже ловили дефектом (1737 объектов против 1742,
`docs/review-journal.md`).
Следствие: **пересечение наборов метрик между автоматизациями перестаёт быть
проблемой**. Минутный и несуммированный `heart_rate` наполняют разные слои и
не смешиваются в одном ряду; если же две автоматизации шлют одну метрику с
одинаковой гранулярностью, это честный дубликат, и его схлопывает хеш.
Слои считаются **вниз, но не вверх**: из `raw` получается `hour`, обратно —
нет. Поэтому самый мелкий слой стоит держать, пока он не станет дорог; цена
измерена — около 730 МБ в год против 20 МБ у минутного. Страховка на случай,
если мелкий слой всё-таки выключат: **ручной экспорт из Apple Health**
восстанавливает нижний слой целиком через `healthlog import`.
**Идентичность точки — координаты, а не содержимое.**
```
ключ: метрика + слой + начало + конец конец = начало, если end нет
значения: qty / Min / Avg / Max / source / … ← перезаписываются
```
**Ключ — интервал, а не метка.** Метка на записи сна не уникальна: под одним
`date` лежит до трёх записей, и это не дефект, а способ Apple выразить
вложенность «в кровати» и фазы внутри неё. Измерено на всём корпусе (находка
47): 174 координаты против 170 по метке, ноль столкновений против 33 **внутри
одной доставки**, где тай-брейк по времени приёма неприменим в принципе.
`value` в ключе ничего не добавляет.
Форма ключа **одна для всех точек**. Интервалы несут 22 метрики, а не только
сон; `start`, когда он есть, всегда равен `date`; обе формы точки не
смешиваются внутри метрики одной доставки. Поэтому отдельного класса «эпизодных
метрик» нет — нечего выводить и нечего поддерживать в каталоге и Read API. Час
объекта берётся по началу, иначе принадлежность объекту зависела бы от
длительности.
`HKObject.uuid` дал бы идентичность даром, но в выгрузку Apple он не попадает —
там у записи только `type`, `sourceName`, `sourceVersion`, `creationDate`,
`startDate`, `endDate`, `value`. Значит модель обязана выражаться через
`start`/`end`, иначе `import(экспорт)` не сойдётся с `replay(HAE)`.
Мы дважды пробовали адресовать точку хешем её содержимого и дважды получали
задвоение на живых данных:
- **числа сериализуются нестабильно** — 45 507 из 71 730 повторно приехавших
точек различались последним разрядом double (`0.09523182962471353` против
`…52`), то есть 63% повторов выглядели новыми (находка 30);
- **`source` нестабилен** — то же измерение с тем же значением приезжает то
как `Apple Watch Ultra 3|iPad (Anton)`, то как `Apple Watch Ultra 3`:
Health переосмысливает атрибуцию задним числом. Минутный слой за 31 июля
оказался задвоен целиком, 120 точек в часе вместо 60 (находка 36).
Хеш при этом остаётся — но как **детектор изменений**, а не как ключ: совпал с
сохранённым, значит писать нечего. Считается он по канонической форме с
рекурсивной сортировкой ключей и округлением чисел до ~12 значащих цифр
(иначе, см. выше, «изменилось» будет срабатывать всегда).
Объект целиком тоже адресуется хешем — им сравниваются часовые пачки, чтобы
широкий проход не переписывал неизменившееся.
**Запись — слияние, а не вставка.** Приход новых точек за уже существующий час
означает: прочитать объект, влить точки (объединение по хешу точки),
отсортировать по времени, записать обратно. Точки из объекта не удаляются
никогда.
**`sealed`** отмечает часы, которые уже не должны меняться (старше окна
досчёта). Изменение запечатанного объекта — не отказ, а **сигнал**: пишем
`WARN` и всё равно сохраняем. Так мы узнаём реальную глубину досчёта из
эксплуатации, а не из предположений.
#### Разрешение столкновений
По одним координатам приезжают разные содержимые: 2 897 случаев из 444 256
координат, 0.65% (находка 49). Выигрывает **более полная** точка, и полнота —
это сравнение **множеств** ключей с непустым значением, а не их числа.
Число сравнимо всегда и потому отвечает там, где ответа нет: точка
`{"qty":0,"a":0,"b":0,"c":{},"d":[]}` несла «пять значащих полей» против двух у
настоящего измерения и стирала его безвозвратно. Множества дают три исхода
вместо одного — надмножество, равенство, несравнимость, — и только первый
означает «полнее».
Пусто — `null`, пустая строка, нулевое число, пустой объект и пустой массив;
`false` содержателен (`isIndoor: false` — тренировка на улице). Считается по
разобранному значению, а не по байтам: иначе `0.0` и `{ }` прошли бы как
содержание. `source` не участвует — он нестабилен.
Надмножество побеждает только тогда, когда **несёт то же содержание**: значения
общих содержательных ключей должны совпасть. Иначе точки несут разные
измерения, и надмножество имён о полноте не говорит ничего — пара уходит в
тай-брейк. Без этого условия `{date, qty:0.001, p1:null, p2:null}` вытесняло бы
`{date, qty:72.5}`, то есть точка без единого измерения стирала бы измерение.
Разрядов сравнения два: сперва ключи с содержанием, при их равенстве (и
совпадении значений) — все ключи. Второй разряд бережёт поля, которые не несут
содержания, но и теряться не должны: `{date, qty:10, Min:0, Max:0}` не
проигрывает `{date, qty:10}` по жребию. Несравнимость на втором разряде исходом
не является: лишние ключи там заведомо пусты, объединять в них нечего.
**Победитель — функция множества точек, а не порядка их поступления.** Попарная
свёртка этого не даёт: полнота — частичный порядок, тай-брейк — тотальный, и
вместе они образуют нетранзитивное отношение победы, то есть цикл. При цикле
повторная свёртка одной и той же доставки меняет содержимое объекта, и витрина
перестаёт быть свёрткой журнала. Поэтому кандидаты координаты собираются
вместе: отбрасываются превзойдённые по полноте, среди оставшихся берётся
минимум по каноническому порядку. Обе операции зависят только от состава
множества.
**Несравнимые множества не сливаются, а считаются.** Объединение полей — самая
дорогая часть правила — на живом потоке не потребовалось ни разу (0 из 2 897),
поэтому вместо реализации стоит счётчик и `WARN` с координатами объекта. Если
событие наступит, оно будет видно, а не додумано заранее.
**Тай-брейк при равной полноте не выбран.** Сегодня это порядок канонических
форм, и он измеримо смещён: в 96% случаев берёт меньшее значение. Правильный
выбор зависит от рода метрики, а род измеряется сверкой слоёв между собой —
значит он и станет известен точно, вместо того чтобы быть угаданным.
### Категориальные значения
HAE отдаёт перечислимые значения строками из локали телефона, а не кодами:
фаза сна приезжает как «БДГ», контекст пульса — как «Сидячий образ жизни», тип
тренировки — как «В помещении Ходьба» (машинная калька с `Indoor Walk`). При
этом `stateOfMind` в том же пакете шлёт честные коды HealthKit
(`momentary_emotion`, `slightly_pleasant`, `drained`) — значит дело не в
приложении, а в том, что старые секции тянут строки из UI (находка 37).
Оставить как есть нельзя по трём причинам, и третья решающая:
1. Клиент вынужден угадывать словарь вместо того, чтобы сравнивать с кодом.
2. Смена языка телефона молча расколет историю: та же фаза сна станет другим
значением, и по координатному ключу это неотличимо от изменения данных.
3. **Родной экспорт Apple говорит кодами** (`HKCategoryValueSleepAnalysisAsleepREM`).
Он объявлен источником истины, и на нём держится ретеншен нижнего слоя —
но сверить покрытие по этим полям было бы нечем.
Поэтому строка **хранится дословно, а рядом кладётся выведенный код**:
```
value "БДГ" ← как прислал HAE
value_code "HKCategoryValueSleepAnalysisAsleepREM" ← выведено по словарю
```
Словарь ключуется парой `(локаль, строка)`; локаль берётся из
`Accept-Language`, который мы уже сохраняем (находка 32). Для незнакомой
строки код пустой — пустота честнее догадки, и она же видна в `/stats` как
список того, что пора добавить в словарь.
Дословность инварианта не нарушена: код **приписывается**, а не подменяет
строку. Обратное преобразование всегда возможно.
### Тренировки и прочие секции
Тренировка адресуется своим `id` из HealthKit и **перезаписывается**: она
может приехать повторно, когда доедет маршрут. `record` держит секции с
собственными идентификаторами (`stateOfMind`, `ecg`, `symptoms`,
`cycleTracking`, `medications`, `heartRateNotifications`) — модель та же.
Пачками они не хранятся: у них есть естественный ключ, они редки, и
группировать их по часам незачем.
**Тренировка не разворачивается.** Заголовок — колонками, всё остальное,
включая маршрут и внутренние ряды, — блобом `payload`. Структура тренировки
разнородна и избыточна (сводки дублируют ряды, находка 15); раскладывать её в
таблицы значило бы решить за Apple, что в ней главное.
**Пульс приедет дважды** — в общем потоке метрики `heart_rate` и внутри
объекта тренировки. Это ожидаемо, они лежат в разных таблицах и не
смешиваются.
### Время
Точка внутри объекта хранится дословно, вместе с исходной строкой даты. Для
адресации и выборок используется нормализованное время: `hour_utc` у объекта,
`ts_utc` + офсет исходной зоны у записей с собственным ключом. Офсет нужен,
чтобы клиент мог считать сутки и по UTC, и по местному времени: без него
суточные ряды незаметно поехали бы после смены часового пояса.
**Формат даты зависит от секции пакета**: метрики и тренировки шлют
`2026-07-31 21:03:51 +0300`, `stateOfMind` — RFC 3339 в UTC (`…T18:03:51Z`).
Одного парсера недостаточно (находка 16).
## Read API
```
GET /api/v1/metrics каталог: имя, units, род, слои с диапазонами
GET /api/v1/metrics/{name}?from&to&bucket&layer точки метрики, при желании свёрнутые
GET /api/v1/workouts?from&to заголовки тренировок
GET /api/v1/workouts/{id} тренировка целиком, с маршрутом
GET /api/v1/records/{kind}?from&to прочие секции
GET /api/v1/schema схемы всего, что есть в хранилище
GET /api/v1/metrics/{name}/schema схема и статистика одной метрики
GET /stats последняя доставка, счётчики, тишина по потоку
GET /healthz
```
Хранение пачками на контракт не влияет: `GET /metrics/{name}` собирает ответ
из часовых объектов, попавших в диапазон, и отдаёт точки. Клиент про объекты
не знает — это деталь хранения, а не API.
**Слои, наоборот, часть контракта.** Каталог показывает, какие разрезы есть и
за какой период:
```json
{"metric": "heart_rate", "units": "count/min", "aggregation": "instant",
"layers": [
{"layer": "raw", "from": "2026-07-30", "to": "2026-08-01", "points": 2078},
{"layer": "minute", "from": "2026-07-25", "to": "2026-08-01", "points": 14203}
]}
```
`aggregation` — измеренный род (`cumulative` / `instant` / `unknown`), от него
зависит, что вообще можно спросить.
Параметр `layer` выбирает разрез. Если он не указан — берём **самый мелкий
слой, покрывающий весь запрошенный диапазон**. Молча переключать слой на
границе периода нельзя: ряд поедет незаметно для клиента.
### Свёртка и размер ответа
Запросов к метрике ровно два, и это один запрос с необязательным параметром:
```
?from&to все значения за период вес, лекарства, симптомы
?from&to&bucket=day значения с разбивкой шаги, энергия
```
Главный потребитель — агент, у которого ограничен контекст. «Пульс за неделю»
без разбивки — это десятки тысяч точек в минутном слое и сотни тысяч в
нижнем. Правило:
- **Разбивка не задана, ответ не влезает** — сервер сам берёт сетку погрубее,
чтобы влезло, и называет её в ответе. Агент всегда получает ответ и может
переспросить уже.
- **Разбивка задана явно, ответ не влезает** — это ошибка, а не тихая
подмена. В теле ошибки — число точек по каждой доступной сетке, чтобы
второй запрос был заведомо успешным.
Различие существенно: «указали уровень» работает как информация в первом
случае и как защита во втором. Иначе агент, попросивший минутную сетку,
получил бы суточные суммы и заметил бы это, только прочитав поле, которое
вполне может не прочитать.
Свёртка применяет род из каталога: `cumulative` — сумма, `instant`
среднее с `min`/`max` рядом. При `unknown` свёртка не выполняется, а параметр
`bucket` отвергается ошибкой. Накопительные метрики никогда не сворачиваются
из нижнего слоя HAE — только из `minute`, `hour` или `sample`.
### Форма ответа
Нормализованная оболочка, сырое содержимое:
```json
{"layer": "minute", "bucket": "hour", "aggregation": "sum",
"points": [
{"ts": "2026-07-31T09:00:00Z", "tz_offset": 10800, "units": "count",
"values": {"qty": 812}}
]}
```
`layer`, `bucket` и `aggregation` присутствуют всегда, даже когда свёртки не
было (`"bucket": null`): клиент не должен выводить их наличием или
отсутствием поля.
Время приведено к единому виду, значения отданы как пришли: ни
переименований, ни пересчёта единиц. Метрик у Apple много и они разные —
семантику разбирает клиент по имени метрики. Полная нормализация означала бы,
что каждая новая метрика требует правки коллектора, а незнакомая теряется.
### MCP
Поверх Read API — адаптер MCP, чтобы агент подключался без промежуточного
кода. Инструментов ровно два, по числу форм запроса выше, плюс каталог.
Собственной логики в адаптере нет: он переводит вызовы в те же обработчики.
**Транспорт — HTTP** (Streamable HTTP), не stdio: сервис живёт на VPS, и агент
ходит к нему по сети. Отсюда следствия:
- MCP — это **эндпоинт того же процесса**, а не отдельная подкоманда: тот же
бинарь, тот же порт, тот же Caddy впереди с TLS.
- Аутентификация — **тот же токен чтения** в `Authorization: Bearer`, что и у
Read API. Отдельного контура доступа не заводим: MCP не даёт ничего, чего
не даёт HTTP, и права у них обязаны совпадать.
- Правило размера ответа (см. выше) здесь не украшение, а необходимость:
сетевой агент не имеет возможности «посмотреть поближе» иначе, чем
повторным вызовом.
## Самоописание
Сервис описывает свои данные сам: клиент (в том числе AI-агент) не должен
угадывать структуру по выборке — он запрашивает схему и сразу знает, что
лежит в наборе. Слоя два.
**Схема контракта** — форма конверта, который отдаёт API (`ts`, `tz_offset`,
`units`, `values`, заголовок тренировки, ошибка). Наша, статичная, пишется
руками.
**Каталог разрезов** — какие слои есть у метрики и за какие периоды. Отвечает
на вопрос «что вообще можно спросить», прежде чем клиент спросит.
**Схема содержимого** — что лежит внутри `values` у конкретной метрики.
**Выводится из данных**, а не ведётся вручную: метрик у Apple больше сотни, и
рукописный каталог описывал бы документацию HAE, а не то, что он реально
прислал. Выведенная схема производна ровно так же, как витрина: считается тем
же проходом разбора, инкрементально при приёме и целиком при `reindex`.
Незнакомая метрика описывает себя сама, без релиза.
Схема отдаётся **вместе со статистикой** — для потребителя она важнее
формального типа:
```json
{"metric": "heart_rate", "points": 412355,
"first_ts": "2019-03-02T…", "last_ts": "2026-07-31T…",
"units": ["count/min"],
"fields": {"Min": {"type": "number", "presence": 1.0},
"Avg": {"type": "number", "presence": 1.0},
"Max": {"type": "number", "presence": 1.0}}}
```
Вывод ограничен по глубине вложенности — иначе схема тренировки с маршрутом
разрослась бы до размеров самих данных. Форма точки маршрута при этом
описывается: блоб трека не непрозрачен, это массив однотипных объектов.
## Аутентификация
Статический токен в заголовке `Authorization: Bearer …`; список допустимых
токенов — в конфиге. HAE умеет слать произвольные заголовки, этого достаточно.
Токены **раздельные**: на запись (приём) и на чтение. Клиент, читающий
данные, не может писать. MCP пользуется токеном чтения — отдельного контура
у него нет, см. «MCP».
Наружу открыты два контура: приём (телефон) и чтение вместе с MCP (агенты и
приложения). Оба через Caddy с TLS, оба с разными токенами.
## Деплой
VPS **rivendell** (Timeweb), доступен всегда. Перед сервисом — **Caddy**, он
терминирует TLS; сам сервис слушает plain HTTP. Приём открыт наружу на
отдельном поддомене — телефон должен доставать до него из любой сети, иначе
экспорт копится и уезжает пачкой при возвращении домой.
Сборка — на локальной машине: статический бинарь и docker-образ; на сервер
едет готовый образ. Go-тулчейн на сервере не нужен.
Тома: каталог сырого архива и файл SQLite — на постоянном хранении, конфиг
(с токенами) — отдельно, `0600`.
## Открытые вопросы
- Механизм доставки образа и запуска на rivendell (compose руками / плейбук).
- **Предел размера ответа** — в точках или в оценке байт. Точки считать
проще, но у `heart_rate_variability` с `heartbeatSeries` точка на два
порядка тяжелее, чем у `step_count` (находка 39).
- **Хранить ли `heartbeatSeries` целиком.** 93% объёма HRV ради данных,
которых нет ни в одном из планируемых запросов.
- **Есть ли `stateOfMind`, симптомы и лекарства в родном экспорте.** От этого
зависит, применимо ли к ним устаревание нижнего слоя.
- **Как проверять покрытие экспортом** — до какой строгости. Непрерывности по
дням и сходимости сумм, вероятно, хватит, но порог не выбран.
- **Хранилище под аналитику.** Сейчас SQLite: часовые объекты дают ~260 тыс.
строк на слой в год независимо от плотности точек, а плоская таблица по
грубым слоям (`hour` ~36 тыс. строк в год, `minute` ~1.4 млн) делает
`GROUP BY` дешёвым без разжатия блобов. DuckDB рассматривался и отложен:
чистого Go-драйвера нет, любой требует cgo, что стоит нам
`CGO_ENABLED=0` и одного статического бинаря. Дверь при этом открыта —
DuckDB читает и parquet, и файл SQLite напрямую, так что выгрузка в parquet
остаётся отдельной командой на случай тяжёлой аналитики снаружи.