Files
healthlog/docs/conventions.md
T
av 5e2385ba6e добавлены документация проекта и каркас разработки
- README, CLAUDE.md, docs: назначение и границы, архитектура, конвенции, план
- docs/local-research.md — 36 находок по формату Health Auto Export, снятых на
  живых данных; документация приложения местами расходится с тем, что оно шлёт
- Taskfile, .golangci.yml, самодокументируемый config.example.toml
2026-08-01 12:37:03 +03:00

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