# healthlog

Коллектор данных Apple Health. Принимает выгрузки из
[Health Auto Export](https://www.healthyapps.dev/apps/health-auto-export/),
складывает их в единое хранилище и отдаёт другим моим проектам через HTTP API.

## Зачем

Данные о здоровье и тренировках нужны сразу трём моим приложениям: агенту-медику
(анализ здоровья), трекеру (разбор тренировок) и игре (мотиватор по активности).
Интегрировать каждое из них с Health Auto Export по отдельности — значит в
каждом писать приём, дедупликацию и хранение заново.

healthlog делает это один раз. Телефон шлёт данные в него, все остальные
проекты берут данные из него.

## Границы

Это **хранилище**, а не аналитика. healthlog принимает, дедуплицирует,
хранит и отдаёт. Он не переименовывает поля Apple и не интерпретирует
значения — этим занимается тот, кто данные читает.

Одну уступку хранилище всё же делает: оно умеет свести метрику к запрошенной
сетке («шаги по дням»). Иначе каждый из клиентов повторял бы одну и ту же
логику выбора слоя, а ошибиться в ней легко — просуммировать не тот разрез и
получить завышение втрое. Но род свёртки не проставлен вручную, а **измерен**
сверкой слоёв между собой; где измерить не вышло, свёртка не предлагается
вовсе.

Источников два: Health Auto Export (куплен, пожизненный премиум) — ежедневный
поток, и родной экспорт Apple Health раз в 2–3 месяца — источник истины для
нижнего слоя.

## Как устроено

```
экспорт Apple ────────┐   снапшот всей истории, раз в 2–3 месяца
                      ▼
iPhone ──HTTPS POST──► healthlog ──► журнал доставок (.json.gz)
                            │              │
                            │              └── события поверх снапшота
                            ▼
                       SQLite ──┬──► HTTP read API ──► мои приложения
                    (свёртка по  │
                     журналу)    └──► MCP ───────────► агенты
```

Приём сначала кладёт тело запроса на диск как есть и только потом разбирает.
Значит, ошибка в разборе не теряет данные: состояние всегда пересобирается
свёрткой `import(экспорт) + replay(доставки)`. Отсюда и главный инвариант —
точки хранятся дословно: журнал, из которого что-то выброшено, перестаёт быть
журналом.

Подробности — [docs/architecture.md](docs/architecture.md).

## Состояние

В разработке. Готовы каркас и приём, большая часть разбора: сервис принимает
пакеты, складывает их в сырой архив и **разбирает метрики в часовые объекты**
— с выводом слоя из данных, канонизацией содержимого и слиянием точек по
полноте. Тренировки и записи со своим `id` (`workouts`, `stateOfMind`) тоже
разбираются; секции, которых разбор не покрывает, принимаются, хранятся и
честно помечаются как неразобранные — а имя, которого поток раньше не приносил,
даёт `WARN` в логе свёртки один раз и попадает в перечень `healthlog uncovered`.

Есть и пересборка: `healthlog reindex` проигрывает журнал доставок в свежую
витрину и сверяет её отпечаток с накопленной — на живом архиве из 116 тел
пересборка воспроизводима и повторный прогон ничего не меняет.

Маршрутов чтения открыто два. **Каталог разрезов** (`GET /api/v1/metrics`) под
токеном чтения отдаёт слои с диапазонами и измеренный род агрегации, а повтор
неизменившегося отвечает `304` по `ETag` — снимок витрины при этом не
открывается. **Точки метрики за период** (`GET /api/v1/metrics/{name}`) едут
одним запросом: слой выбирается по охвату точек внутри периода, а род агрегации
приезжает вместе с данными и с явным указанием, применим ли он к ряду. Журнал
WAL разбирается фоновым чекпойнтом по таймеру.

Чего ещё нет: свёртки по сетке, условного запроса по точкам, тренировок и
записей наружу. Что умеет и чего не умеет —
[docs/tasks/ROADMAP.md](docs/tasks/ROADMAP.md).

Разведка формата закончена: 50 находок на живом потоке, половина расходится с
документацией Health Auto Export — [docs/research/apple-health.md](docs/research/apple-health.md).

## Команды

```
healthlog serve        приём + read API                    (MCP — в планах)
healthlog import       родной экспорт Apple Health         (в планах)
healthlog reindex      пересборка витрины из журнала
healthlog uncovered    перечень секций, которых разбор не покрыл
healthlog healthcheck  проверка живости для docker HEALTHCHECK
```

### Пересборка витрины

Разбор пишется по реальным данным и будет ошибаться. Исправленный разбор
применяется к уже разобранному пересборкой:

```
healthlog reindex --config ./config.toml
```

Команда собирает витрину в **отдельный файл** рядом с рабочей базой и печатает
два отпечатка — рабочей витрины и пересобранной. Рабочую базу она не трогает
вовсе (открывает её только на чтение и без наката миграций), поэтому запускать
её при живом сервисе безопасно — так и стоит делать, если нужно просто сверить.

**Применить** результат — другое дело. Подмена возможна только при остановленном
сервисе: он держит файл базы открытым, и переименование поверх живого процесса
портит базу молча. Сервис при этом надо остановить **до** пересборки, а не после:
доставки, приехавшие за время прогона, в собранный файл не попадут, и подмена
стёрла бы их учёт вместе с заголовками, которые не восстанавливаются ниоткуда.
Команда это проверяет и в таком случае процедуру подмены не печатает вовсе.

```
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
```

Прогон идёт линейно по архиву: на 116 телах — около полуминуты, и время растёт
вместе с архивом. Свободного места нужно не меньше текущего размера базы:
собранный файл ложится рядом с ней, на тот же том.

Прогон, убитый жёстко (`SIGKILL`, потеря питания), оставляет рядом с базой файлы
`*.partial*` — это его недособранный результат. Штатное прерывание (`Ctrl+C`) их
убирает само; оставшиеся можно удалять руками, следующему прогону они не мешают.

## Локальный запуск

Конфиг необязателен — без него берутся умолчания (`:8080`, `./healthlog.db`,
`./raw`). Для своих значений скопируй `config.example.toml` в `config.toml`.

```
task run
```

Проверка:

```
curl localhost:8080/healthz
curl -X POST localhost:8080/api/v1/ingest -d '{"data":{"metrics":[]}}'
curl localhost:8080/api/v1/metrics    # каталог: слои, диапазоны, род агрегации

# повтор неизменившегося не стоит ничего: метка из ответа возвращается условием
curl -i -H 'If-None-Match: W/"…"' localhost:8080/api/v1/metrics   # 304
```

### Подключение телефона по локальной сети

Сервис слушает все интерфейсы (`addr = ":8080"`), так что телефон в той же
сети достучится по IP машины. В Health Auto Export заводится одна
автоматизация: **REST API**, формат **JSON**, минимальная гранулярность
(«Summarize Data» выключен), URL вида `http://<ip-машины>:8080/api/v1/ingest`.

Если `auth.write_tokens` пуст, проверка токена выключена — для доверенной
локальной сети этого достаточно, сервис пишет об этом `write auth disabled`
на старте. Для доступа снаружи понадобится и токен, и TLS — это цель
«Сервис доступен телефону из любой сети».


## Документация

- [docs/passport.md](docs/passport.md) — цель проекта, типовые сценарии
  работы, референсы: чужие проекты, у которых смотрим решения, прежде чем
  придумывать своё
- [docs/architecture.md](docs/architecture.md) — устройство: принципы,
  компоненты, внешние границы, эксплуатация, деплой
- [docs/database.md](docs/database.md) — схема хранилища и настройки с
  числовым значением
- [docs/adr/](docs/adr/README.md) — почему решено именно так
- [docs/conventions/](docs/conventions/README.md) — как пишем код
- [docs/security.md](docs/security.md) — периметр и модель угроз
- [docs/review.md](docs/review.md) — настройка конвейера ревью и журнал дефектов
- [docs/tasks/ROADMAP.md](docs/tasks/ROADMAP.md) — что приложение уже умеет и
  чего ещё не умеет
- [docs/tasks/BACKLOG.md](docs/tasks/BACKLOG.md) — что брать следующим, включая
  отложенные идеи
- [docs/research/apple-health.md](docs/research/apple-health.md) — что показал реальный поток
  Health Auto Export; источник истины по формату, документация приложения
  местами расходится с тем, что оно шлёт
