- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`; миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки с этими ключами - сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА — «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина расходилась бы с пересборкой молча - отпечаток витрины покрывает тренировки и записи и снимается одним снимком базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
132 lines
11 KiB
Markdown
132 lines
11 KiB
Markdown
# Конвенции кода
|
||
|
||
Как пишем код (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 там, где он есть по природе данных: `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` (с вычищенными токенами). Документация формата ненадёжна —
|
||
источником истины служат живые данные.
|
||
- Проверяем идемпотентность: повторный разбор того же пакета не меняет
|
||
витрину.
|
||
- **Где код выбирает между двумя версиями одних данных, тест обязан прогнать
|
||
обе стороны и хотя бы одну перестановку трёх.** Пример на паре доказывает
|
||
коммутативность и молчит про ассоциативность, а сломаться правило может
|
||
именно на ней: полнота — частичный порядок, тай-брейк — тотальный, и их
|
||
попарная свёртка дала нетранзитивное отношение победы, из-за которого одна
|
||
и та же доставка меняла витрину при каждой пересборке. Ревью дизайна этого
|
||
не увидело, ревью кода увидело только перебором троек. Правилом линтера не
|
||
выражается — отсюда проза.
|