Files
healthlog/docs/conventions.md
T
av f8200f7f80 feat: разбор и хранение тренировок и состояния разума
- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной
  строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`;
  миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки
  с этими ключами
- сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет
  содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а
  при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА —
  «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина
  расходилась бы с пересборкой молча
- отпечаток витрины покрывает тренировки и записи и снимается одним снимком
  базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
2026-08-02 13:05:16 +03:00

11 KiB
Raw Blame History

Конвенции кода

Как пишем код (How), а не что система делает (What — в architecture.md). Перенесено из jellybit и сжато под масштаб этого проекта.

Язык

  • Документация, комментарии, сообщения коммитов — русский.
  • Код и идентификаторы — английский.

Ошибки

  • Только стандартный errors + fmt.Errorf. Сторонних пакетов ошибок нет: контекст несёт slog, стек-трейсы для домашнего сервиса избыточны.
  • Контекст добавляем обёрткой %w — это дефолт, чтобы errors.Is/As работали сквозь слои. %v — только когда причину сознательно не раскрываем.
  • Стиль сообщения: со строчной, без точки, без «failed to». Контекст — операция или субъект ("open archive: %w"), каждый слой добавляет свой смысл, не повторяя нижний.
  • Граничные ошибки транслируем в доменные у источника: sql.ErrNoRowsstore.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 там, где он есть по природе данных: 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 тем же изменением.

Тесты

  • Тесты на разбор формата HAE держим на реальных пакетах, сложенных в testdata (с вычищенными токенами). Документация формата ненадёжна — источником истины служат живые данные.
  • Проверяем идемпотентность: повторный разбор того же пакета не меняет витрину.
  • Где код выбирает между двумя версиями одних данных, тест обязан прогнать обе стороны и хотя бы одну перестановку трёх. Пример на паре доказывает коммутативность и молчит про ассоциативность, а сломаться правило может именно на ней: полнота — частичный порядок, тай-брейк — тотальный, и их попарная свёртка дала нетранзитивное отношение победы, из-за которого одна и та же доставка меняла витрину при каждой пересборке. Ревью дизайна этого не увидело, ревью кода увидело только перебором троек. Правилом линтера не выражается — отсюда проза.