- internal/fold — свёртка по идентификатору доставки, тело из архива: тот же код, каким пойдёт пересборка витрины - приём зовёт свёртку на context.WithoutCancel с собственным дедлайном; исход разбора на код ответа не влияет - слой доставки хранится в delivery.derived_layer и наследуется от ПРЕДШЕСТВУЮЩЕЙ доставки автоматизации: без границы по времени свёртка переставала быть функцией от префикса журнала (1737 объектов против 1742) - task verify:archive — сходимость на живом архиве, 99 доставок из 99
137 lines
11 KiB
Markdown
137 lines
11 KiB
Markdown
# CLAUDE.md
|
||
|
||
Памятка для работы над healthlog. Перед задачей прочитай также
|
||
[README.md](README.md), [docs/architecture.md](docs/architecture.md),
|
||
[docs/conventions.md](docs/conventions.md) и [docs/plan.md](docs/plan.md).
|
||
|
||
## Что это
|
||
|
||
Коллектор данных Apple Health. Принимает выгрузки из Health Auto Export и
|
||
родного экспорта Apple, хранит их и отдаёт другим моим проектам — через HTTP
|
||
API и через MCP. Это **хранилище, а не аналитика**: принять, дедуплицировать,
|
||
сохранить, отдать. Не переименовывать поля Apple, не интерпретировать
|
||
значения. Агрегат считается только в ответе на запрос и только там, где род
|
||
метрики измерен.
|
||
|
||
## Стек
|
||
|
||
Go, один статический бинарь (`CGO_ENABLED=0`). SQLite (`modernc.org/sqlite`,
|
||
чистый Go), `chi`, `sqlx`, `goose` (миграции), `pelletier/go-toml/v2`,
|
||
`log/slog`, ULID через `internal/ident`.
|
||
|
||
Module path — `git.vakhrushev.me/av/healthlog`.
|
||
|
||
## Инварианты
|
||
|
||
- **Точки хранятся дословно.** Часовой объект держит точки ровно в том виде,
|
||
в каком их прислал HAE. Начнём что-то отбрасывать внутри точки — потеряем
|
||
безвозвратно.
|
||
- **Хранилище — свёртка по журналу.** Экспорт Apple это снапшот всей истории,
|
||
доставки HAE после его даты — события поверх. Состояние всегда пересобираемо:
|
||
`import(экспорт) + replay(доставки по received_at)`. Поэтому сырой архив
|
||
живёт до следующего проверенного экспорта (~2 ГБ за квартал), а свёртка
|
||
обязана быть детерминированной. Что не восстанавливается — `stateOfMind`
|
||
(его в экспорте нет) и верхние слои за периоды с удалёнными доставками;
|
||
каталог обязан говорить об этом честно, а не досчитывать молча.
|
||
- **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор:
|
||
битый JSON — 400, непонятое содержимое — 200.
|
||
- **Ничего не теряем молча.** Идентичность — координаты
|
||
(`метрика + слой + начало + конец`), у точки-измерения конец равен началу:
|
||
под одной меткой лежит до трёх записей сна. Ключ одной формы для всех точек —
|
||
отдельного класса «эпизодных метрик» нет. `source` в ключ не входит, он
|
||
нестабилен. Хеш
|
||
канонизированного содержимого остался детектором изменений. При
|
||
столкновении выигрывает **более полная** точка, а не последняя. Изменение
|
||
запечатанного часа — `WARN`, но данные всё равно пишутся.
|
||
- **Дыры закрываются сами.** Три прохода разной глубины (5 минут / сутки /
|
||
неделя). Настройки данных у проходов теперь **разные** — намеренно, они
|
||
наполняют разные слои; это безопасно ровно потому, что слой входит в ключ.
|
||
- **Форма Apple не транслируется.** Значения отдаём как пришли, нормализовано
|
||
только время (`ts_utc` + офсет исходной зоны). Единственное добавление —
|
||
стабильный код рядом с переведённой строкой: HAE отдаёт «БДГ» и «Сидячий
|
||
образ жизни» на языке телефона, а родной экспорт — коды HealthKit, и без
|
||
словаря эти два источника не сойтись.
|
||
- **Своей агрегации в хранении нет — есть слои.** Метрика лежит в той
|
||
подробности, в какой пришла (`sample`/`raw`/`minute`/`hour`); слой выводится
|
||
из выравнивания меток, а не из заголовка HAE — тот врёт.
|
||
- **Агрегация в ответе — только измеренная.** Род свёртки выводится сверкой
|
||
слоёв между собой (часовое = сумма минутных → накопительная, = среднее →
|
||
мгновенная), а не размечается руками. Род неизвестен — свёртки нет. И
|
||
никогда не суммируем нижний слой HAE: это интерполяция, а не сэмплы.
|
||
- **Секреты не в логах** — токены приёма и чтения. Данные о здоровье
|
||
чувствительны: тела запросов только на `DEBUG` и с обрезкой.
|
||
|
||
## Команды
|
||
|
||
Запуск через [Task](https://taskfile.dev) (`task --list` — полный список):
|
||
|
||
- `task up` / `task restart` / `task down` — сервис в контейнере; **основной
|
||
способ запуска**, данные в `./data` переживают пересборку
|
||
- `task logs` / `task ps` — что происходит с сервисом
|
||
- `task gate` — детерминированный гейт ревью (build/vet/lint/test/race/
|
||
покрытие диффа/миграции/образцы конфига/секреты/данные в индексе)
|
||
- `task review:context` — вход для архитектурного прохода ревью
|
||
- `task run` — запуск из исходников, без контейнера
|
||
- `task build` — статический бинарь linux/amd64
|
||
- `task test` / `task lint` — тесты и golangci-lint
|
||
- `task verify:archive` — сходимость на живом архиве: весь `./data/raw` через
|
||
разбор, повтор обязан дать то же состояние. В гейт не входит намеренно —
|
||
минута прогона и данные, которых нет ни на какой другой машине
|
||
- `task tidy` — `go mod tidy`
|
||
- `task setup` — установка golangci-lint
|
||
|
||
## Процесс
|
||
|
||
Задачи — в [docs/backlog](docs/backlog/README.md) (один файл на задачу, индекс
|
||
производен). Порядок и его обоснование — в [docs/plan.md](docs/plan.md).
|
||
|
||
Работа над задачей идёт скиллом `healthlog-task-pipeline`: беклог → `opsx:explore` →
|
||
`opsx:propose` → ревью спек (профиль `design`) → `opsx:apply` → ревью кода →
|
||
`opsx:archive` → чистка беклога → коммит. Ревью — скилл `healthlog-review-pipeline`,
|
||
проходы — агенты `healthlog-review-*`.
|
||
|
||
**Действуем автономно.** Умолчание — делать, а не спрашивать. Вопрос, который
|
||
решать не мне, **вынимается блокером** в секцию `блокеры` беклога (что решить,
|
||
варианты с ценой каждого, что стоит без решения, рекомендация), задача
|
||
переформулируется на остаток, остаток доводится до коммита. Блокеры
|
||
разбираются пачками. Спрашиваем только про **необратимое**: деплой, выкладку
|
||
наружу, удаление или перезапись данных в `./data`.
|
||
|
||
Гейт блокирует: пока `task gate` красный, опиниативные проходы ревью не
|
||
запускаются.
|
||
|
||
**Поток не останавливается.** Телефон шлёт непрерывно и молча. Сломанный приём,
|
||
оставленный работать, теряет данные необратимо: доставка, не попавшая в
|
||
архив, в журнал не попадает вовсе — телефон её не перешлёт.
|
||
Ничего из `./data` не попадает ни в git, ни в логи выше `DEBUG`, ни в вывод
|
||
агента.
|
||
|
||
## Конвенции
|
||
|
||
Механизируемое проверяет `task lint` (`.golangci.yml`): форма логов
|
||
(`sloglint`), `fmt.Print*` / `os.Getenv` / `time.Now` мимо единых точек
|
||
(`forbidigo`), сравнение ошибок (`errorlint`), сторонние пакеты ошибок
|
||
(`depguard`). Пересказывать эти правила не нужно — линтер скажет точнее.
|
||
|
||
Прозой остаётся то, что правилом не выражается:
|
||
[docs/conventions.md](docs/conventions.md) — уровень лога по адресату,
|
||
единственный логирующий чекпоинт на доменной границе, трансляция ошибки на
|
||
внешней границе, самодокументируемый `config.example.toml`, время в БД в UTC
|
||
RFC 3339, ULID через `ident`.
|
||
|
||
Отдельно: **тесты на разбор формата HAE держим на реальных пакетах** в
|
||
`testdata`. Документация формата тонкая и местами расходится с тем, что
|
||
приложение реально шлёт, — источником истины служат живые данные.
|
||
|
||
Что показал реальный поток — [docs/local-research.md](docs/local-research.md).
|
||
Читать **до** работы над разбором: там же лежат находки, которых нет в
|
||
документации HAE (поле `source` существует; порядок ключей в JSON нестабилен,
|
||
поэтому хеш содержимого считается по канонической форме с рекурсивной
|
||
сортировкой; мелкая группировка даёт интерполяцию, а не измерения). Файл
|
||
пополняется по мере накопления доставок.
|
||
|
||
## Язык
|
||
|
||
- Документация, комментарии, сообщения коммитов — **русский**.
|
||
- Код и идентификаторы — английский.
|