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

- 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
+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` (с вычищенными токенами). Документация формата ненадёжна —
источником истины служат живые данные.
- Проверяем идемпотентность: повторный разбор того же пакета не меняет
витрину.