добавлены документация проекта и каркас разработки

- README, CLAUDE.md, docs: назначение и границы, архитектура, конвенции, план
- docs/local-research.md — 36 находок по формату Health Auto Export, снятых на
  живых данных; документация приложения местами расходится с тем, что оно шлёт
- Taskfile, .golangci.yml, самодокументируемый config.example.toml
This commit is contained in:
av
2026-08-01 12:37:03 +03:00
commit 5e2385ba6e
12 changed files with 2318 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
# Сборка
/healthlog
# Реальный конфиг (токены), локальная БД и сырой архив
/config.toml
*.db
*.db-wal
*.db-shm
/raw/
# Временные файлы
/tmp/
# IDE
/.idea/
/.vscode/
+88
View File
@@ -0,0 +1,88 @@
# Конфиг golangci-lint (схема v2; устанавливается через `task setup`).
#
# Базовый набор v2 (`default: standard`) — errcheck, govet, ineffassign,
# staticcheck, unused. Сверх него включены линтеры, механизирующие конвенции
# из docs/conventions.md: то, что проверяет правило, не остаётся прозой.
version: "2"
linters:
enable:
- misspell
# docs/conventions.md, «Логи»: msg — константная категория, данные — в
# полях, единый стиль ключ-значение.
- sloglint
# docs/conventions.md: без fmt.Print* (логируем через slog), конфиг только
# из TOML (env не используем), время — только store.Now().
- forbidigo
# docs/conventions.md, «Ошибки»: сравнение через errors.Is/As.
- errorlint
# docs/conventions.md, «Ошибки»: ошибки — только stdlib.
- depguard
settings:
sloglint:
no-mixed-args: true # не мешать пары «ключ-значение» с slog.Attr
kv-only: true # принятый в проекте стиль вызова
static-msg: true # msg — константа, без fmt.Sprintf и интерполяции
forbidigo:
forbid:
- pattern: ^fmt\.Print.*$
msg: логируем через slog, в stdout напрямую не пишем (docs/conventions.md)
- pattern: ^os\.Getenv$
msg: конфигурация только из TOML, env для конфига не используем (docs/conventions.md)
- pattern: ^time\.Now$
msg: время генерирует store.Now() (UTC, единая точка) — docs/conventions.md
errorlint:
# Обёртка вида fmt.Errorf("%w: %v", ErrSentinel, err) осознанна: sentinel
# раскрываем для errors.Is, причину — намеренно нет.
errorf: false
asserts: true
comparison: true
depguard:
rules:
main:
deny:
- pkg: github.com/pkg/errors
desc: ошибки — только stdlib errors + fmt.Errorf (docs/conventions.md)
- pkg: github.com/cockroachdb/errors
desc: стек-трейсы избыточны, контекст несёт slog (docs/conventions.md)
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
rules:
# CLI — другая поверхность: печатает результат в stdout, это не логи.
- path: ^cmd/
linters: [forbidigo]
# Единая точка генерации id и времени — им time.Now по определению можно.
- path: ^internal/(ident|store)/
text: time.Now
linters: [forbidigo]
# Замер длительности запроса — не метка времени в БД: засекается на
# входе транспорта и никуда не сохраняется.
- path: ^internal/httpapi/
text: time.Now
linters: [forbidigo]
# В тестах время задаётся явно, чтобы не зависеть от часов машины.
- path: _test\.go$
text: time.Now
linters: [forbidigo]
formatters:
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
+82
View File
@@ -0,0 +1,82 @@
# 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,
хранит их и отдаёт другим моим проектам через HTTP API. Это **хранилище, а не
аналитика**: принять, дедуплицировать, сохранить, отдать. Не считать агрегаты,
не переименовывать поля 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. На этом инварианте держится всё остальное: сырой
архив живёт лишь 14 дней, дальше истина — сами объекты. Начнём что-то
отбрасывать внутри точки — срок хранения архива станет сроком жизни данных.
- **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор:
битый JSON — 400, непонятое содержимое — 200.
- **Ничего не теряем молча.** Идентичность — хеш **канонизированного**
(рекурсивно отсортированного) содержимого: повтор не меняет ничего,
различие сохраняется. Изменение запечатанного часа — `WARN`, но данные
всё равно пишутся.
- **Дыры закрываются сами.** Три прохода синхронизации разной глубины
(5 минут / час / сутки), настройки данных у всех одинаковы.
- **Форма Apple не транслируется.** Значения отдаём как пришли, нормализовано
только время (`ts_utc` + офсет исходной зоны).
- **Своей агрегации нет — есть слои.** Метрика хранится в той подробности, в
какой пришла (`raw`/`minute`/`hour`); слой выводится из выравнивания меток,
а не из заголовка HAE — тот врёт. Клиенту показываем каталог разрезов, выбор
за ним.
- **Секреты не в логах** — токены приёма и чтения. Данные о здоровье
чувствительны: тела запросов только на `DEBUG` и с обрезкой.
## Команды
Запуск через [Task](https://taskfile.dev) (`task --list` — полный список):
- `task run` — локальный запуск (`--config ./config.toml`)
- `task build` — статический бинарь linux/amd64
- `task test` / `task lint` — тесты и golangci-lint
- `task tidy``go mod tidy`
- `task setup` — установка golangci-lint
## Конвенции
Механизируемое проверяет `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 нестабилен,
поэтому хеш содержимого считается по канонической форме с рекурсивной
сортировкой; мелкая группировка даёт интерполяцию, а не измерения). Файл
пополняется по мере накопления доставок.
## Язык
- Документация, комментарии, сообщения коммитов — **русский**.
- Код и идентификаторы — английский.
+93
View File
@@ -0,0 +1,93 @@
# 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 (куплен, пожизненный премиум).
Другие источники не поддерживаем.
## Как устроено
```
iPhone ──HTTPS POST──► healthlog ──► сырой архив (файлы, .json.gz)
│ │
│ └── источник истины, не трогаем
SQLite-витрина ──► HTTP read API ──► мои приложения
(пересобирается из архива)
```
Приём сначала кладёт тело запроса на диск как есть и только потом разбирает.
Значит, ошибка в нашем разборе не может привести к потере данных: витрина
пересобирается из архива командой `healthlog reindex`.
Подробности — [docs/architecture.md](docs/architecture.md).
## Состояние
В разработке. Готовы шаги 1–2 из 8: сервис принимает пакеты и складывает их в
сырой архив. Разбора, витрины и read API ещё нет — план в
[docs/plan.md](docs/plan.md).
## Команды
```
healthlog serve приём + read API
healthlog import заливка файлов в архив и витрину (шаг 6)
healthlog reindex пересборка витрины из сырого архива (шаг 3)
healthlog healthcheck проверка живости для docker HEALTHCHECK
```
## Локальный запуск
Конфиг необязателен — без него берутся умолчания (`: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":[]}}'
```
### Подключение телефона по локальной сети
Сервис слушает все интерфейсы (`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 (шаг 8).
## Документация
- [docs/architecture.md](docs/architecture.md) — устройство, схема данных, API, принятые решения
- [docs/conventions.md](docs/conventions.md) — как пишем код
- [docs/plan.md](docs/plan.md) — шаги и отложенное
- [docs/local-research.md](docs/local-research.md) — что показал реальный поток
Health Auto Export; источник истины по формату, документация приложения
местами расходится с тем, что оно шлёт
+54
View File
@@ -0,0 +1,54 @@
# yaml-language-server: $schema=https://taskfile.dev/schema.json
#
# Запуск команд проекта через Task (https://taskfile.dev).
# Список задач: `task --list`.
version: '3'
vars:
BINARY: healthlog
PKG: ./cmd/healthlog
# Версии инструментов для воспроизводимой установки (см. задачу setup).
GOLANGCI_VERSION: v2.12.2
tasks:
default:
desc: Список доступных задач
cmds:
- task --list
silent: true
run:
desc: 'Локальный запуск (config.toml не обязателен — есть умолчания)'
cmds:
- go run {{.PKG}} --config ./config.toml | hl
build:
desc: Статический бинарь linux/amd64 для сервера
cmds:
- CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o {{.BINARY}} {{.PKG}}
test:
desc: Прогон тестов
cmds:
- go test ./...
lint:
desc: Запуск golangci-lint
cmds:
- golangci-lint run
tidy:
desc: go mod tidy
cmds:
- go mod tidy
clean:
desc: Удалить собранный бинарь
cmds:
- rm -f {{.BINARY}}
setup:
desc: Установка инструментов разработки
cmds:
- go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@{{.GOLANGCI_VERSION}}
+30
View File
@@ -0,0 +1,30 @@
# Образец конфигурации healthlog.
#
# Реальный config.toml не коммитится (в нём токены), права 0600.
# Все поля необязательны: без файла сервис поднимается на умолчаниях, что
# удобно для локального запуска. Умолчание каждого поля указано ниже.
[server]
addr = ":8080" # адрес прослушивания; ":8080" — все интерфейсы (нужно, чтобы телефон достучался по локальной сети)
read_timeout = "5m" # на всё чтение запроса вместе с телом; Go-duration. Щедро: экспорт истории — десятки мегабайт по мобильной сети
write_timeout = "30s" # на отправку ответа; Go-duration
[auth]
# Токены проверяются как `Authorization: Bearer <токен>`.
# Health Auto Export умеет слать произвольные заголовки — токен задаётся в
# настройках автоматизации.
# ПУСТОЙ СПИСОК = ПРОВЕРКА ВЫКЛЮЧЕНА. Так можно в доверенной локальной сети;
# сервис предупреждает об этом на старте записью `write auth disabled`.
write_tokens = [] # токены на приём данных
read_tokens = [] # токены на чтение (read API появится позже)
[storage]
db_path = "./healthlog.db" # файл SQLite-витрины; каталог должен существовать
archive_dir = "./raw" # корень сырого архива; создаётся при старте
[ingest]
max_body_mb = 64 # максимальный размер тела запроса, МиБ; целое > 0. Больше — 413
[log]
level = "info" # debug | info | warn | error
format = "json" # json | text (text удобен при локальной отладке)
+474
View File
@@ -0,0 +1,474 @@
# Архитектура
## Назначение
healthlog принимает выгрузки Apple Health из приложения Health Auto Export
(далее HAE), хранит их и отдаёт другим приложениям. Он не обрабатывает данные:
не считает агрегаты, не переименовывает поля, не интерпретирует значения.
## Принципы
- **Один статический бинарь** (`CGO_ENABLED=0`), доставка — docker-образом.
- **Точки хранятся дословно.** Часовой объект держит точки ровно в том виде,
в каком их прислал HAE — без переименований, пересчётов и отбрасывания
незнакомых полей. Поэтому хранилище само по себе является полной копией
данных, а не производной выжимкой.
- **Сырой архив — страховка разбора, а не вечный склад.** Тело запроса ложится
на диск до разбора и живёт ограниченный срок (по умолчанию две недели):
этого хватает, чтобы пережить ошибку в нашем разборе и пересобрать
хранилище (`healthlog reindex`). Дальше источником истины остаются часовые
объекты — прямое следствие пункта выше, см. «Хранилище».
- **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор
(см. «Приём»).
- **Ничего не теряем молча.** Идентичность — хеш канонизированного
содержимого: повтор не меняет ничего, различие сохраняется. Схлопывания «на
всякий случай» нет.
- **Дыры закрываются сами.** Данные приходят несколькими проходами разной
глубины, поэтому пропущенная доставка не оставляет постоянного пробела —
см. «Модель синхронизации».
- **Форма Apple не транслируется.** Значения отдаются такими, какими пришли;
нормализовано только время.
- **Своей агрегации нет — есть разрезы.** Метрика хранится в тех слоях
подробности, в которых пришла (`raw`/`minute`/`hour`); сводить их к одному
или досчитывать свои значило бы принимать предметные решения, которых
хранилище принять не может.
- **Минимум компонентов** — один процесс, 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).
## Модель синхронизации
Данные приходят **тремя проходами разной глубины** — три автоматизации HAE с
одинаковыми настройками данных, различающиеся только расписанием и периодом:
| проход | расписание | период | зачем |
|---|---|---|---|
| быстрый | каждые 5 минут | Since Last Sync | свежесть |
| средний | 3–4 раза в день | **Today** | чинит пропуски за сутки |
| глубокий | раз в сутки | **Previous 7 Days** | чинит всё остальное |
Средний и глубокий проходы используют **фиксированные окна, а не метку
синхронизации** — это принципиально. Инкрементальный режим проверен и
**теряет данные**: в окне, которое он якобы покрыл, широкая выгрузка нашла
13 961 точку, включая фазы сна за три часа и весь глубокий сон той ночи
(находка 29). Фиксированное окно идемпотентно по построению и не зависит ни от
какой метки.
Расписание — пожелание, а не гарантия: iOS не даёт приложению запускаться в
заданное время, а к данным Health доступа нет вовсе, пока телефон заблокирован
(находка 28). Поэтому проходы привязываются к моментам, когда телефон заведомо
разблокирован (триггер из Shortcuts по времени суток), а поток считается
пачечным: тишина ночью, всплеск утром.
Гарантия починки:
```
дыра моложе суток → закроется в течение часа
дыра моложе недели → закроется в течение суток
дыра старше недели → не закроется; лечится `healthlog import`
```
Широкие проходы почти бесплатны именно из-за часовых объектов: глубокий проход
переприсылает неделю, но это 26 метрик × 168 часов ≈ 4400 сравнений хеша,
почти все из которых сойдутся, и записи не будет.
**Обязательное правило: настройки данных у всех трёх проходов одинаковы**
тот же набор метрик, то же суммирование, та же группировка. Иначе одна метрика
приезжает из разных источников с разной гранулярностью, значения по одному
ключу расходятся и проходы начинают перетирать друг друга (находка 14).
Автоматизации различимы по заголовку `automation-id`; имена стоит задать,
иначе `automation-name` приходит пустым (находка 12).
## Компоненты
| Пакет | Ответственность |
| ---------- | ------------------------------------------------------ |
| `config` | загрузка и валидация TOML-конфига |
| `logging` | сборка slog-логгера |
| `ident` | генерация и разбор ULID |
| `archive` | сырой архив: запись тела, чтение для reindex, ретеншен |
| `hae` | разбор формата HAE, канонизация, хеш содержимого |
| `ingest` | use-case приёма, общий для HTTP и CLI `import` |
| `store` | SQLite: доставки, часовые объекты, тренировки, записи |
| `httpapi` | приём и read API |
## Приём
```
запрос → токен → лимит тела, gzip → проверка формы JSON
→ запись тела в архив → строка в delivery → 200
→ разбор → запись в витрину
```
Код ответа определяется **доставкой**, не разбором:
- **400** — тело не разбирается как JSON ожидаемой верхнеуровневой формы.
Это проблема транспорта (обрыв, обрезанное тело), и отправителю о ней
надо сказать.
- **200** — тело сохранено в архив. Дальше даже полный провал разбора
(незнакомая метрика, новая форма точки) не меняет ответ: данные уже в
безопасности, исход разбора виден в логе, в `delivery.parse_status` и в
`/stats`, а доразобрать их можно командой `reindex`.
Причина такого разделения: неизвестно, шлёт ли HAE отклонённый пакет
повторно при периоде «Since Last Sync». Если не шлёт, строгий приём означал
бы дыру в истории. Многоуровневая синхронизация страхует тот же риск с другой
стороны — но полагаться только на неё нельзя: она чинит дыры за неделю, а не
за год.
## Хранилище
### Сырой архив — короткая страховка
`raw/ГГГГ/ММ/ДД/<ulid>.json.gz` — тело запроса как пришло, не редактируется.
Живёт **ограниченный срок** (`storage.raw_retention`, по умолчанию 14 дней),
после чего удаляется.
Смысл срока: архив нужен, чтобы пережить ошибку в **нашем** разборе и
пересобрать хранилище (`healthlog reindex`). Двух недель на это заведомо
хватает. Вечно хранить его незачем — часовые объекты держат те же точки
дословно, так что архив дублировал бы данные, а не страховал их.
**Это осознанная смена источника истины.** Пока тело в архиве, истина — оно;
после удаления истиной остаются часовые объекты. Инвариант, который держит
конструкцию: **объект хранит точки дословно**. Если разбор начнёт что-то
отбрасывать или нормализовать внутри точки, срок хранения архива станет
сроком жизни данных.
### Часовые объекты метрик
Точки метрик хранятся не по одной, а **пачками: один объект = одна метрика за
один час UTC**.
```
delivery(id, received_at, automation_name, automation_id, aggregation,
period, session_id, bytes, sha256, raw_path, parse_status, points,
headers)
bucket(metric, layer, hour_utc, hash, points_count, first_ts, last_ts,
units, payload BLOB, first_delivery_id, updated_at, sealed)
PK (metric, layer, hour_utc)
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
```
Почему не своя агрегация: сложить точки в часовые средние нетрудно, но
правильный способ зависит от метрики (сумма для энергии, среднее для пульса,
максимум для чего-то ещё), и это уже предметное знание, которого у хранилища
нет. Разрезы — честнее.
**Слой — это режим выгрузки, которым пришли данные**, а не измеренное
разрешение каждой метрики. Различие принципиально: частота метрик разная —
пульс идёт секундами, VO₂ max случается раз в неделю, — и выводить слой из
частоты значило бы дробить редкие метрики между слоями без всякого смысла.
Режим же общий для доставки, и редкая метрика просто наследует его.
**Определяется по данным, а не по заголовку.** `automation-aggregation`
непригоден: значение `Default` соответствует трём разным режимам сразу
(находка 31). Правило:
1. **Плотная метрика** (не меньше десяти точек в доставке) классифицируется
**сама по себе** по выравниванию своих меток. У десяти несуммированных
точек шанс всем лечь на ровную минуту исчезающе мал.
2. **Редкая метрика** (меньше десяти точек) наследует **преобладающий слой
доставки** — самый мелкий среди плотных. У неё выравнивание ничего не
доказывает, а Apple многие редкие показатели пишет прямо на границе часа.
3. Плотных метрик в доставке нет вовсе — слой наследуется от предыдущей
доставки той же автоматизации; если её не было, берём заголовок
(`Minutes``minute`, `Hours``hour`, иначе `raw`).
Классифицировать доставку целиком нельзя: при перенастройке автоматизации
приезжают **смешанные доставки**, где часть метрик уже минутная, а часть ещё
посекундная. Одна такая доставка, отнесённая к слою целиком, сложила минутные
точки с посекундными и удвоила сумму за час (находка 35).
Заголовок сохраняем и сверяем с выведенным; расхождение и смену режима у
автоматизации пишем `WARN` — так видна перенастройка, а не тихий дребезг.
Почему не по метрике отдельно (проверено на живых данных, находка 33): одна
доставка законно содержит метрики разной подробности — `apple_stand_hour`
почасовой по своей природе, `sleep_analysis` в минутном режиме превращается в
суточный агрегат на `00:00:00`, а `heart_rate` рядом с ними идёт с секундной
точностью. Классификация каждой по отдельности растащила бы одну выгрузку по
трём слоям.
Пересчёт при `reindex` идёт по всей истории сразу и потому точнее, чем на
приёме: это ещё одна причина держать сырой архив.
Следствие: **пересечение наборов метрик между автоматизациями перестаёт быть
проблемой**. Минутный и несуммированный `heart_rate` наполняют разные слои и
не смешиваются в одном ряду; если же две автоматизации шлют одну метрику с
одинаковой гранулярностью, это честный дубликат, и его схлопывает хеш.
Слои считаются **вниз, но не вверх**: из `raw` получается `hour`, обратно —
нет. Поэтому самый мелкий слой стоит держать, пока он не станет дорог; цена
измерена — около 730 МБ в год против 20 МБ у минутного. Страховка на случай,
если мелкий слой всё-таки выключат: **ручной экспорт из Apple Health**
восстанавливает нижний слой целиком через `healthlog import`.
**Идентичность точки — координаты, а не содержимое.**
```
ключ: метрика + слой + метка времени
значения: qty / Min / Avg / Max / source / … ← перезаписываются
```
Мы дважды пробовали адресовать точку хешем её содержимого и дважды получали
задвоение на живых данных:
- **числа сериализуются нестабильно** — 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` и всё равно сохраняем. Так мы узнаём реальную глубину досчёта из
эксплуатации, а не из предположений.
### Тренировки и прочие секции
Тренировка адресуется своим `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}?layer&from&to&cursor точки метрики
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",
"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}
]}
```
Параметр `layer` выбирает разрез. Если он не указан — отдаём **самый мелкий
слой, покрывающий весь запрошенный диапазон**, и в ответе всегда называем,
какой слой отдан. Молча переключать слой на границе периода нельзя: ряд поедет
незаметно для клиента.
Форма точки в ответе — нормализованная оболочка, сырое содержимое:
```json
{"ts": "2026-07-31T09:00:00Z", "tz_offset": 10800, "units": "count",
"values": {"qty": 8}}
```
Время приведено к единому виду, значения отданы как пришли: ни
переименований, ни пересчёта единиц. Метрик у Apple много и они разные —
семантику разбирает клиент по имени метрики. Полная нормализация означала бы,
что каждая новая метрика требует правки коллектора, а незнакомая теряется.
## Самоописание
Сервис описывает свои данные сам: клиент (в том числе 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 умеет слать произвольные заголовки, этого достаточно.
Токены **раздельные**: на запись (приём) и на чтение. Клиент, читающий
данные, не может писать.
## Деплой
VPS **rivendell** (Timeweb), доступен всегда. Перед сервисом — **Caddy**, он
терминирует TLS; сам сервис слушает plain HTTP. Приём открыт наружу на
отдельном поддомене — телефон должен доставать до него из любой сети, иначе
экспорт копится и уезжает пачкой при возвращении домой.
Сборка — на локальной машине: статический бинарь и docker-образ; на сервер
едет готовый образ. Go-тулчейн на сервере не нужен.
Тома: каталог сырого архива и файл SQLite — на постоянном хранении, конфиг
(с токенами) — отдельно, `0600`.
## Открытые вопросы
- Механизм доставки образа и запуска на rivendell (compose руками / плейбук).
+109
View File
@@ -0,0 +1,109 @@
# Конвенции кода
Как пишем код (How), а не что система делает (What — в
[architecture.md](architecture.md)). Перенесено из jellybit и сжато под
масштаб этого проекта.
## Язык
- Документация, комментарии, сообщения коммитов — **русский**.
- Код и идентификаторы — **английский**.
## Ошибки
- Только стандартный `errors` + `fmt.Errorf`. Сторонних пакетов ошибок нет:
контекст несёт `slog`, стек-трейсы для домашнего сервиса избыточны.
- Контекст добавляем обёрткой `%w` — это дефолт, чтобы `errors.Is`/`As`
работали сквозь слои. `%v` — только когда причину сознательно не
раскрываем.
- Стиль сообщения: со строчной, без точки, без «failed to». Контекст —
операция или субъект (`"open archive: %w"`), каждый слой добавляет **свой**
смысл, не повторяя нижний.
- Граничные ошибки транслируем в доменные у источника: `sql.ErrNoRows`
`store.ErrNotFound` внутри `store`, чтобы выше не торчал `database/sql`.
- **Sentinel** (`var ErrNotFound = errors.New(...)`) — для условий, на которые
ветвится код. **Типизированная ошибка** — когда вызывающему нужны данные
ошибки. Не плодим типы там, где хватает sentinel.
- Наружу (HTTP) отдаём человекочитаемое сообщение по доменной ошибке, не
сырой `err.Error()`. Маппинг доменная ошибка → статус живёт в одной точке
в `httpapi`; новая штатная ветвь отказа заводится sentinel'ом и
добавляется туда, иначе `default` отдаст 500 на нормальный конфликт.
- Собрать независимые ошибки (валидация конфига — все проблемы разом) —
`errors.Join`.
- `panic` — только невосстановимое: нарушенный инвариант, сбой инициализации.
`recover` — на верхней границе HTTP-обработчика.
- Глушить ошибку без лога — только с однострочным комментарием «почему».
## Логи
Структурированный JSON (`log/slog`) в stdout, один формат для dev и prod.
Сбор и ротацию делает окружение.
- `msg` — короткая константа в нижнем регистре, категория события
(`delivery accepted`, `parse failed`). Данные — атрибутами, не в тексте.
Подсистему выносим в поле `capability` (`ingest`/`parse`/`query`), не в
префикс сообщения.
- **Уровень — это адресат, а не громкость поломки:**
| Уровень | Кому | Примеры |
|---|---|---|
| `DEBUG` | разработчику при отладке | `/healthz`, тела запросов, шаги разбора |
| `INFO` | владельцу, аудит постфактум | принята доставка, разбор завершён, старт |
| `WARN` | владельцу, «может стать проблемой» | точка не разобрана, незнакомая форма метрики |
| `ERROR` | владельцу, в разбор | не записался архив, сбой БД |
- Невалидный ввод от отправителя — `DEBUG`, а не `ERROR`: это норма, разбирать
нечего. `WARN` ≠ «ничего страшного», `WARN` = «может стать проблемой».
- Событийное → `INFO`, рутинно-частое (healthcheck, поллинг) → `DEBUG`.
- **Либо лог, либо возврат, не оба.** Промежуточные слои только оборачивают и
возвращают. Ошибка логируется **один раз**, на границе доменного слоя,
которая определяет исход операции (`ingest`) — не в транспорте. Транспорт
переводит ошибку в ответ и не логирует повторно.
- Ошибка — атрибутом: `log.Error("parse failed", "error", err, "delivery_id", id)`.
- Время в логах — UTC, RFC 3339 с долями секунды.
- Корреляция — по `delivery_id` (ULID), отдельный `trace_id` не заводим.
- **Секреты в логи не попадают**: токены приёма и чтения, `Authorization`.
При сомнении логируем факт наличия, не значение.
- Данные о здоровье — чувствительные. Тела запросов пишем только на `DEBUG`
и с обрезкой по длине.
## Конфигурация
- Только **TOML**, никаких env-переменных: окружение наследуется дочерними
процессами и видно через `/proc/<pid>/environ` — для токенов это слабее
файла под `0600`.
- Грузим один раз при старте в типизированную `Config`; дальше по коду читаем
только её. Конфиг неизменяем — смена параметров означает рестарт.
- Имя по умолчанию — `config.toml` в рабочей директории, переопределяется
`--config=path`.
- `config.example.toml` коммитим как единый самодокументируемый справочник:
**каждое поле с комментарием**, из которого ясно зачем оно, каков диапазон
допустимых значений и в каких единицах. Секретные поля — пустые.
- Реальный `config.toml` не коммитится; секреты рендерит деплой.
- **Валидация на старте, до приёма трафика.** Невалидный конфиг — `ERROR` и
выход с ненулевым кодом. Не стартуем «наполовину».
## База данных и идентификаторы
- Первичные ключи сущностей — **TEXT ULID**, генерируется приложением
(`internal/ident`). Сортируется по времени создания, удобен в логах и URL.
Разбор внешнего id — `ident.Parse` на входной границе; синтаксически
невалидный id — 404 без похода в БД.
- Естественный ключ вместо ULID там, где он есть по природе данных: `sample`
и `record` — по хешу содержимого, `workout` — по `id` из HealthKit.
- Временные метки — `TEXT` в RFC 3339, **UTC**, суффикс `Z`. Фиксированная
ширина сохраняет лексикографическую сортировку = хронологию. Единая точка
генерации — `store.Now()`, а не дефолт в схеме: забытая вставка должна
падать громко.
- Enum-поля — обычный `TEXT` без `CHECK`, допустимые значения держит код.
- Миграции — goose (`internal/store/migrations`), SQL для DDL. При изменении
структуры обновляем схему в [architecture.md](architecture.md) тем же
изменением.
## Тесты
- Тесты на разбор формата HAE держим на **реальных пакетах**, сложенных в
`testdata` (с вычищенными токенами). Документация формата ненадёжна —
источником истины служат живые данные.
- Проверяем идемпотентность: повторный разбор того же пакета не меняет
витрину.
File diff suppressed because it is too large Load Diff
+79
View File
@@ -0,0 +1,79 @@
# План
Живой документ: шаги отмечаем по мере готовности, отложенное копится внизу.
## Ближайшая цель
Минимальное ядро, которое принимает и сохраняет пакет. Проверяем его
**локально**: сервис поднят на рабочей машине, телефон шлёт на её IP по
локальной сети. Ни деплоя, ни домена, ни TLS для этого не нужно — они
понадобятся, когда сервис поедет на rivendell (шаг 8).
Это шаги 12.
## Шаги
- [x] **1. Каркас.** `Taskfile.yml`, `.golangci.yml`, `CLAUDE.md`, TOML-конфиг
с валидацией на старте, логгер, подкоманда `serve` с `/healthz`.
- [x] **2. Приём без разбора.** `POST /api/v1/ingest`: лимит тела, gzip,
запись тела в архив, строка в `delivery`. Разбора ещё нет.
**подключаем телефон по локальной сети**
- [ ] **3. Разбор и хранилище.** Миграции, часовые объекты метрик
(`bucket`, ключ `метрика + слой + час`) + `workout`/`record` по своим
`id`, вывод слоя из выравнивания меток, канонизация с округлением чисел
и хеш содержимого, слияние точек в объект, два формата дат,
`healthlog reindex`.
- [ ] **4. Read API.** Каталог метрик со слоями и диапазонами, точки с
пагинацией и выбором слоя (сборка из часовых объектов), тренировки,
записи.
- [ ] **5. Самоописание.** Каталог разрезов + выведенные из данных схемы
содержимого со статистикой + статичная схема контракта API.
- [ ] **6. `healthlog import`.** Заливка полной истории кусками по годам.
- [ ] **7. Наблюдаемость.** `/stats`: последняя доставка, счётчики, тишина.
- [ ] **8. Деплой.** `Dockerfile`, сборка образа локально, доставка на
rivendell, конфиг Caddy, поддомен, токены.
Порядок неслучаен. После шага 2 автоматизация в Health Auto Export включена и
копит настоящие пакеты в сыром архиве. Документация формата HAE скудная,
поэтому разбор на шаге 3 пишем по реальным данным, а не по догадкам — и
заодно видим фактический объём и характер потока.
## Отложено
- ~~Отсев идентичных тел доставок по `sha256`.~~ **Вычеркнуто:** находка 2
показала, что порядок ключей в JSON нестабилен, поэтому два пакета с одними
и теми же данными почти никогда не совпадают побайтно — хеш тела не
сработает. Дедупликация возможна только по канонизированному содержимому,
а это и делает хеш часового объекта. Отдельная механика не нужна.
- **Ретеншен сырого архива** (`storage.raw_retention`, 14 дней) — удаление
старых тел. Пока архив не подчищается; включить после того, как разбор
устоится, иначе страховка исчезнет раньше, чем перестанет быть нужна.
- **Порог `sealed`** — с какого возраста час считается запечатанным. Ставим по
факту: сначала пишем `WARN` на изменение старых объектов и смотрим, какая
глубина досчёта встречается в жизни (наблюдалось до 22 минут, находка 10).
- **Алерт «данных нет N часов».** Тихо сломавшаяся автоматизация — главный
эксплуатационный риск коллектора. В v1 факт виден в `/stats`; активное
уведомление добавим после.
- **Аннотации к схемам** — человеческие описания метрик поверх выведенных
схем, либо рукописный каталог. Выбор зависит от того, насколько стабильным
окажется формат; меняться он может только с обновлением Health Auto Export,
а это отслеживается.
- **Схема тренировок** — глубину вывода определим по факту, когда увидим,
как приходят маршруты.
- **Пометка локализованных полей в схемах.** `context`, `value` и подобные
приходят на языке телефона и изменятся при смене языка iOS — клиентам не
стоит завязываться на конкретные строки
([local-research.md](local-research.md), находка 8).
- **Вторая автоматизация без группировки** для `sleep_analysis` и
`heart_rate_variability` — минутная группировка съедает фазы сна и
межударные интервалы ценой ~130 точек в сутки (находка 6).
- **Переход на более грубый нижний слой.** Посекундный слой стоит ~730 МБ в
год против ~20 МБ у минутного. Когда решим, что мелкая подробность не нужна,
достаточно выключить несуммированную автоматизацию — старые данные останутся
в своих слоях, переписывать ничего не придётся. Обратный путь тоже есть:
ручной экспорт из Apple Health + `healthlog import` восстанавливает нижний
слой.
- **NDJSON-поток** для больших выборок из read API.
- **Слой агрегаций** поверх сырья (суточные/недельные срезы).
- **Разворачивание маршрутов тренировок** в отдельную таблицу — если появится
клиент, которому мало отдачи тренировки одним пакетом.
+28
View File
@@ -0,0 +1,28 @@
module git.vakhrushev.me/av/healthlog
go 1.26.5
require (
github.com/go-chi/chi/v5 v5.3.1
github.com/jmoiron/sqlx v1.4.0
github.com/oklog/ulid/v2 v2.1.2
github.com/pelletier/go-toml/v2 v2.4.3
github.com/pressly/goose/v3 v3.27.3
modernc.org/sqlite v1.55.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.23 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sethvargo/go-retry v0.4.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.74.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+85
View File
@@ -0,0 +1,85 @@
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec=
github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA=
github.com/pressly/goose/v3 v3.27.3/go.mod h1:Dag+xpV6o20HR2LFY1j0q6MDwc3f7vPUFDA77R+0yGY=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw=
github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.3 h1:a4J+Z8aVaxPyjyxRAdJzw246PqpcFGvVPnfT/AuM5Ws=
modernc.org/libc v1.74.3/go.mod h1:4H7h/MJ8wnjL8RAbp9v3OXgnk22X7MouHIhDbvP3gj4=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=