# Конвенции кода Как пишем код (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//environ` — для токенов это слабее файла под `0600`. - Грузим один раз при старте в типизированную `Config`; дальше по коду читаем только её. Конфиг неизменяем — смена параметров означает рестарт. - Имя по умолчанию — `config.toml` в рабочей директории, переопределяется `--config=path`. - `config.example.toml` коммитим как единый самодокументируемый справочник: **каждое поле с комментарием**, из которого ясно зачем оно, каков диапазон допустимых значений и в каких единицах. Секретные поля — пустые. - Реальный `config.toml` не коммитится; секреты рендерит деплой. - **Валидация на старте, до приёма трафика.** Невалидный конфиг — `ERROR` и выход с ненулевым кодом. Не стартуем «наполовину». ## База данных и идентификаторы - Первичные ключи сущностей — **TEXT ULID**, генерируется приложением (`internal/ident`). Сортируется по времени создания, удобен в логах и URL. Разбор внешнего id — `ident.Parse` на входной границе; синтаксически невалидный id — 404 без похода в БД. - Естественный ключ вместо ULID там, где он есть по природе данных: `workout` — по `id` из HealthKit, `record` — по паре `род секции + id` (форму идентификатора у пяти из шести секций живьём никто не видел, и несквозной `id` в двух секциях затёр бы одну запись другой молча). - Новая единица хранения тем же изменением входит в **отпечаток витрины** и в счётчики отчёта пересборки. Отпечаток отвечает «да/нет» за витрину целиком, и единица, которой нет в счётчиках, делает расхождение безадресным: человек видит «не совпало» при неизменившемся числе объектов и принимает по этому необратимое решение о подмене базы. - Правило выбора между двумя версиями одних данных объявляется либо **функцией множества версий**, либо явно **функцией порядка журнала** — третьего состояния нет. «Побеждает последняя пришедшая» третьим состоянием и является: порядок свёртки порядку журнала не равен, и живая витрина расходится с пересборкой молча. - Любое значение из чужого JSON, попадающее в ключ, в лог или в отчёт, имеет названный предел длины (имена непокрытых секций, `id` сущности). - Временные метки — `TEXT` в RFC 3339, **UTC**, суффикс `Z`. Фиксированная ширина сохраняет лексикографическую сортировку = хронологию. Единая точка генерации — `store.Now()`, а не дефолт в схеме: забытая вставка должна падать громко. - Enum-поля — обычный `TEXT` без `CHECK`, допустимые значения держит код. - Миграции — goose (`internal/store/migrations`), SQL для DDL. При изменении структуры обновляем схему в [architecture.md](architecture.md) тем же изменением. ## Тесты - Тесты на разбор формата HAE держим на **реальных пакетах**, сложенных в `testdata` (с вычищенными токенами). Документация формата ненадёжна — источником истины служат живые данные. - Проверяем идемпотентность: повторный разбор того же пакета не меняет витрину. - **Где код выбирает между двумя версиями одних данных, тест обязан прогнать обе стороны и хотя бы одну перестановку трёх.** Пример на паре доказывает коммутативность и молчит про ассоциативность, а сломаться правило может именно на ней: полнота — частичный порядок, тай-брейк — тотальный, и их попарная свёртка дала нетранзитивное отношение победы, из-за которого одна и та же доставка меняла витрину при каждой пересборке. Ревью дизайна этого не увидело, ревью кода увидело только перебором троек. Правилом линтера не выражается — отсюда проза.