заведён канон общих конвенций для личных проектов

- 13 конвенций по осям arch / lang / stack / common; репозитории берут
  оттуда копии в свой docs/conventions/ и коммитят их у себя
- conv — синхронизация копий: add / status / diff / pull / push, локальные
  регионы исключены из сравнения, поэтому расхождение не даёт шума
This commit is contained in:
av
2026-07-25 18:18:18 +03:00
commit 4a59c71737
15 changed files with 2142 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
# Канон конвенций
Общие конвенции для личных проектов. Репозитории берут отсюда копии в свой
`docs/conventions/`, коммитят их и живут дальше самостоятельно — как с
`ansible-roles`: канон не источник истины во время работы, а лавка, из
которой берут и в которую возвращают улучшения.
Правило то же, что у ролей: **деплоится и читается только то, что лежит в
git репозитория**. Канон никем не подключается на лету.
## Направление — конвенция → код
Конвенция формулируется независимо от конкретного приложения. Она задаёт
правило; код ему следует. Обратное направление запрещено: то, что
приложение уже делает иначе, **не является аргументом против правила** — это
отступление, и его место в локальном регионе того репозитория, а не в
переформулировке канона.
Отсюда практические следствия:
- в каноне нет утверждений о том, как что-то устроено в конкретном
репозитории («у нас так в девяти плейбуках из тридцати трёх») — только
нормы и условия их применимости;
- в каноне нет списка, кто на что подписан: подписка — свойство
репозитория, а не конвенции;
- расхождение канона с кодом чинится либо кодом, либо честной записью
отступления, либо — если правило оказалось неверным — правкой правила по
существу, а не подгонкой под факт.
## Оси
```
common/ как вести сами конвенции
arch/ решения, переживающие смену языка и инструментов
lang/<язык>/ как решение реализуется и механизируется в языке
stack/<стек>/ привязка к инструменту, хранилищу, транспорту
```
Тест — по тому, замена чего убивает правило:
> Умирает при смене **языка** → `lang/`. Умирает при смене **инструмента,
> хранилища или транспорта** → `stack/`. Не умирает ни от того, ни от
> другого → `arch/`.
`PK — ULID, генерирует приложение` не умирает ни от чего — это лежит в
данных → `arch/`. `internal/ident`, `ident.Parse` на границах умирают со
сменой языка → `lang/go/`. `enum как TEXT без CHECK` переживёт Go → Python,
но не переживёт уход от SQLite → это `stack/sqlite/`.
Ось определяется **природой правила**, а не тем, сколько сегодня
потребителей. Конвенция независима от приложений по построению, поэтому
арх-слой выделяется тогда, когда правило действительно не зависит от языка,
а не когда появился второй язык.
**Известный долг.** По этому тесту `lang/go/errors.md`, `lang/go/logging.md`
и `lang/go/db-schema.md` содержат невыделенные слои: у первых двух —
архитектурное ядро (уровень как адресат, что не логируем; трансляция ошибки
на внешней границе, приватный канал против публичного), у третьего — целый
пласт `stack/sqlite/` (типы колонок). Это не принцип, а незавершённая
работа.
## Расширение
Файл в `lang/` или `stack/` может объявить в шапке:
```yaml
extends: arch/db-identifiers.md
```
Расширение **только реализует и сужает** базу, но не отменяет её. Если
слою нужно противоречить базе — это сигнал одного из двух: либо у базы
неверно сформулировано условие применимости (чинится в каноне), либо
репозиторий на базу просто не подписан.
`extends` — документация связи, а не механизм: `conv` о ней только
напоминает при `add` и никак не следит за тем, чтобы база лежала рядом.
## Служебная разметка
**Шапка копии** ставится при `conv add` и в каноне не хранится:
```yaml
---
origin: arch/time.md # откуда взято
origin_hash: a1b2c3d4 # отпечаток канона на момент синхронизации
synced: 2026-07-25
local: нет # или: чем и почему разошлись
---
```
`origin_hash` — контент-отпечаток, а не git-SHA. Он позволяет отличать
«канон обновился» от «изменено локально»; без него `status` умеет только
«differs», а такой отчёт быстро перестают читать. Прочие ключи шапки
(`status`, `extends`) — часть документа: они сравниваются наравне с телом.
**Локальные регионы** — куски, принадлежащие репозиторию по определению.
Из сравнения исключаются, поэтому вечного шума в `diff` не дают:
```markdown
<!-- local:механизировано -->
`AUTOINCREMENT` в новых миграциях — `internal/archrules`.
<!-- /local -->
```
Имя обязательно — перенос при `pull` идёт по именам, безымянные регионы
`conv` отвергает. Что всегда локально:
- **механизация** — канон не знает, у кого линтер уже настроен;
- **отступления** — «у нас пока не так», честно и поимённо;
- **разрешение условия** — «Здесь: INTEGER PK, id наружу не выходят»;
- **эталоны и ссылки** — имена функций, файлов, ADR конкретного репозитория;
- **список конвенций** в README репозитория.
Путь файла в каноне и имя региона — это API: переименование осиротит все
копии (`origin` строковый). Переименовывать — только вместе с обходом
потребителей.
После `pull` копию нужно перечитать глазами: содержимое региона могло
устареть относительно переписанного вокруг текста, и автоматика этого не
увидит.
## Раскладка в репозитории
Копии повторяют структуру канона:
```
docs/conventions/
README.md собственный, не синхронизируется
arch/db-identifiers.md
lang/go/db-identifiers.md
```
Подписка не описана отдельным файлом — она **и есть** набор лежащих файлов,
видимый в `git ls-files`. README директории перечисляет их одной плоской
таблицей, чтобы вложенность не мешала навигации.
## Контракт с агентом
Копии — обычные файлы, и правка их агентом никак не отличима от правки
любого другого документа. Это главный канал тихого дрейфа, поэтому
`AGENTS.md` каждого потребителя должен явно говорить:
> Файлы в `docs/conventions/` с шапкой `origin:` — копии из канона
> `dev-conventions`. Репозиторное пишется только внутрь
> `<!-- local:… -->`. Правка вне регионов — либо `conv push` в канон, либо
> запись причины в `local:`.
## Команды
```bash
conv list # что есть в каноне
conv add arch/time.md # взять к себе (можно несколько за раз)
conv status # ok / изменено локально / канон обновился / разошлись
conv diff [arch/time.md] # чем копия отличается, без учёта локальных регионов
conv pull arch/time.md # забрать обновление канона (регионы переносятся)
conv push arch/time.md # вернуть локальное улучшение в канон
conv push --new lang/go/x.md # завести в каноне новую конвенцию
```
`status` и `diff` всегда завершаются кодом 0: это отчёт, а не проверка.
Расхождение — нормальное состояние, а постоянный шум в `diff` означает не
«догони канон», а «пора разрезать файл». Симметрично: разросшийся до спора
с базой локальный регион означает «пора чинить условие применимости в
каноне».
Запускать из корня репозитория:
```bash
~/projects/private/dev-conventions/conv status
```
Обёртка в раннере репозитория (`inv conventions -- status` для ansible,
`task conventions -- status` для Go) — тонкий проброс аргументов, чтобы
логика не размножалась по репозиториям в двух диалектах.
## Жизненный цикл
- **В канон.** Новая конвенция пишется в том репозитории, где заболело, и
продвигается `conv push --new`. Локальные регионы при этом опустошаются:
в канон едет только норма.
- **Из канона.** Устаревшая конвенция удаляется вместе с обходом
потребителей — тихо осиротить копии нельзя.
- **История.** Канон коммитится при каждом `push`: `origin_hash` отвечает
на «отличается ли», но только git канона отвечает на «почему база
сформулирована так».
+97
View File
@@ -0,0 +1,97 @@
---
status: рекомендуемая
---
# Категории директорий приложения
Всё, что приложение пишет на диск, делится на три категории по принципу
создания и ценности содержимого:
- **конфигурация** — то, что восстанавливается прогоном деплоя, в том числе
секреты;
- **данные** — то, что генерирует приложение и что нужно бэкапить;
- **кеш** — то, что генерирует приложение и что не нужно бэкапить:
приложение перегенерирует заново.
Цель — упростить оперирование данными. Категория сразу отвечает на два
вопроса, которые иначе приходится выяснять по коду приложения: **кто
создаёт** содержимое и **что будет, если его потерять**.
## Категории
| Категория | Директория | Создаёт | Потеря содержимого | Бэкап |
| --- | --- | --- | --- | --- |
| Конфигурация | `config/` | деплой | восстанавливается прогоном | не нужен |
| Данные | `data/` | приложение | невосполнима | обязателен |
| Кеш | `cache/` | приложение | приложение перегенерирует | не нужен |
Имена в таблице — умолчание для случая «одна директория на категорию».
**Категория может состоять из нескольких директорий**, и это нормально:
крупные файлы отделяют от базы, чтобы двигать их между дисками независимо
(`media/`, `uploads/` — та же категория «данные», что и `data/`).
Принадлежность к категории задаётся не именем, а участием в списке бэкапа.
Тест на границе данных и кеша: что будет, если сделать `rm -rf` и поднять
приложение заново. Поднимется само и наверстает — кеш. Не поднимется или
поднимется пустым — данные.
Конфигурацию бэкапить не только не нужно, но и не стоит: там лежат секреты,
а бэкапы уезжают в облако. Источник истины для конфигурации — репозиторий и
хранилище секретов, а не снапшот бэкапа.
## Данные, которые нельзя копировать на живую
Файловый снапшот работающей СУБД не гарантирует консистентности:
скопированный каталог может не восстановиться. Поэтому у категории «данные»
есть два способа попасть в бэкап:
- **копированием** — если файлы самодостаточны на любой момент времени;
- **дампом** — если консистентность обеспечивает только сама СУБД. Тогда
бэкапится директория дампов, а сырой каталог базы — нет.
Директория дампов — тоже данные, просто производные. Решение «копировать
или дампить» принимается **при заведении приложения**, а не при первой
неудачной попытке восстановления.
## Контракт с приложением
Категории — не только про деплой. Приложение **разводит свои записываемые
пути по категориям в конфигурации**, а не складывает всё в один каталог:
иначе категорию нельзя определить снаружи и список бэкапа приходится
составлять вручную, читая код.
- Путь к БД, загруженным файлам, сгенерированным артефактам — данные.
- Миниатюры, распакованные ассеты, кеш внешних ответов, индексы, которые
перестраиваются, — кеш. Даже если их дорого перестраивать: дорого ≠
невосполнимо.
- Приложение не пишет в директорию конфигурации: она может быть доступна
только на чтение.
Если приложение не умеет разделять, это его дефект, а не повод смешивать
категории в раскладке.
## Список бэкапа выводится, а не составляется
Список бэкапа получается из категорий по правилу: туда идут данные, не идут
конфигурация и кеш. Правило механическое — но его применяет человек или
шаблон, поэтому список обязан ссылаться на **те же** переменные путей, что
и создание директорий. Независимо набранный список — источник расхождения
между тем, что бэкапится, и тем, что нужно.
## Область действия
Раскладка меняется вместе с миграцией данных, поэтому конвенция применяется
к **новым приложениям**; существующие переезжают по мере касания, отдельной
кампанией не переписываются. Разделять данные и кеш задним числом имеет
смысл тогда, когда кеш заметен по объёму в бэкапе, а не ради самой схемы.
<!-- local:отступления -->
<!-- /local -->
## Связано
<!-- local:эталон -->
<!-- /local -->
<!-- local:связано -->
<!-- /local -->
+122
View File
@@ -0,0 +1,122 @@
---
status: рекомендуемая
---
# Конфигурация приложения
Как устроена конфигурация: где лежит, как попадает в процесс, что с
секретами и когда падает.
## Файл, а не окружение
**Конфигурация — файл.** Причины, по убыванию веса:
- **Один типизированный источник.** Файл несёт секции, комментарии,
единицы измерения и валидируется целиком. Окружение — плоский набор
нетипизированных строк, который приходится документировать отдельно;
появление второго канала конфигурации гарантирует расхождение между ними.
- **Окружение наследуется дочерними процессами.** Всё, что приложение
запускает — конвертер, `git`, шелл-хук, — по умолчанию получает копию
секретов, хотя они ему не нужны.
- **В контейнере окружение расползается по лишним поверхностям.**
`docker inspect` показывает его любому, у кого есть доступ к сокету
докера; переменные оседают в compose-файле и `.env` на диске — то есть
файл всё равно появляется, только без структуры и валидации.
Обратите внимание, чего в списке **нет**: `/proc/<pid>/environ` не является
аргументом — он имеет права `0400` и защищён проверкой `PTRACE_MODE_READ`,
то есть доступен ровно тому же кругу, что и файл под `0600`.
Запрет держится на «один источник» и на том, что все приложения свои. Для
стороннего образа, живущего на env, конвенция неприменима — это не повод
отказываться от неё для своих.
Практика:
- Формат — текстовый, с комментариями и секциями (TOML, YAML — по стеку).
- Имя по умолчанию фиксировано и ищется в рабочей директории процесса;
путь переопределяется опцией командной строки.
- Реальный конфиг не коммитится. В репозитории лежит **образец**.
## Грузим один раз, дальше не перечитываем
- Разбор — **один раз при старте**, в одну типизированную структуру.
Дальше по коду читаем только её: чтения файла в бизнес-коде нет.
- Конфиг **неизменяем** после старта; смена параметров — рестарт процесса.
Горячая перезагрузка — отдельное решение с отдельным обоснованием, а не
умолчание.
- Умолчания задаются в коде, файл их перекрывает. Образец при этом
перечисляет **все** поля, включая те, у которых есть умолчание: поле,
живущее только в коде, для читателя конфига не существует.
## Образец самодокументируем
Образец коммитим как единый справочник по конфигу: все секции и все поля.
**Каждое поле снабжаем комментарием**, из которого ясно:
- **зачем** поле — что оно меняет в поведении;
- **диапазон или допустимые значения** — перечисление либо границы;
- **единицы измерения**, если применимо: секунды/миллисекунды, байты, доля
`01`.
Так конфиг читается без открывания кода — этим он и полезен.
## Поля по дискриминатору `type`
Когда набор полей секции зависит от поля-дискриминатора (выбор одного из
бекендов или внешних сервисов), обязательность полей определяется его
значением, а не фиксирована для секции.
- **Валидация — по значению `type`**: для каждого поддерживаемого варианта
свой набор обязательных полей; поля других вариантов не требуются.
Неизвестное значение → ошибка на старте с перечислением поддерживаемых.
- **Образец — по значению `type`**: основной вариант предзаполнен рабочими
значениями, альтернативные — блоками-комментариями ниже, каждый со своим
описанием полей. Из примера видны все варианты, не открывая код.
## Секреты приносит деплой
Секреты доставляет **деплой**, рендеря их прямо в конфиг. Отдельного слоя
секретов в приложении нет — оно просто читает файл. Источник истины
секрета — внешнее хранилище деплоя, не репозиторий и не окружение.
- Рендеренный конфиг не коммитится; права `0600`, владелец — runtime-
пользователь.
- В образце секретные поля — пустые строки.
- Загрузчик на старте проверяет, что обязательные секреты не пусты: это
ловит криво отрендеренный шаблон до того, как он превратится в 401 от
внешнего API через час работы.
- В логи секреты не попадают.
<!-- local:секретные-поля -->
<!-- /local -->
## Валидация и fail-fast
Конфиг валидируем **на старте, до приёма трафика**. Невалидный конфиг —
запись уровня `ERROR` и выход с ненулевым кодом: не стартуем «наполовину».
Проверяем как минимум:
- обязательные поля заданы, обязательные секреты не пусты;
- пути существуют и доступны на запись/чтение по назначению;
- числовые диапазоны и единицы (доли, таймауты, счётчики попыток);
- строки, которые парсятся во что-то (длительности, зоны, URL), реально
парсятся;
- включённые секции консистентны: если интеграция включена — заданы все её
обязательные поля.
Проблемы собираем и показываем **разом**, а не по одной за запуск.
<!-- local:проверки -->
<!-- /local -->
## Связано
- `arch/time.md` — формат времени; зона отображения — единственный
конфигурируемый параметр времени, семантика описана там.
- `arch/app-directories.md` — конфиг лежит в категории «конфигурация» и
доступен приложению только на чтение.
<!-- local:связано -->
<!-- /local -->
+86
View File
@@ -0,0 +1,86 @@
---
status: рекомендуемая
---
# Идентификаторы сущностей
Как выбираются и как выглядят первичные ключи. Схема БД меняется тяжело,
поэтому конвенция применяется **к новым таблицам**; существующие живут как
есть и перечислены в отступлениях.
## Условие применимости
Вопрос задаётся **один раз на репозиторий**, а не по таблицам:
> Есть ли в приложении хотя бы одна сущность, которую адресуют извне — по
> id из URL, запроса API или callback-данных?
>
> - **Да** → весь репозиторий на сортируемый строковый id, который
> генерирует приложение (ULID), включая внутренние таблицы.
> - **Ни одной** → автоинкремент, и этого достаточно.
Критерий — именно **адресация**: снаружи по этому id возвращаются к
системе. Не «id виден в логе» — туда рано или поздно попадает любой
идентификатор, и по такому критерию вторая ветка была бы недостижима.
Почему квантор репозиторный, а не потабличный: внутренние сущности имеют
привычку становиться внешними, и тогда целочисленный id утекает в URL
задним числом. Плюс одна ментальная модель дешевле, чем спор при заведении
каждой таблицы.
**Что не является смешиванием.** Запрет касается двух видов
*сгенерированных суррогатных* ключей в одной базе. Естественные и составные
ключи у таблиц-деталей — третья категория, они допустимы всегда (см. ниже).
<!-- local:решение -->
<!-- /local -->
## Если ULID
- **PK — TEXT ULID** (26 символов Crockford base32), генерируется
**приложением** в момент создания записи, а не БД.
- Почему не UUID: UUIDv4 не сортируется по времени. UUIDv7 (RFC 9562)
сортируется, и против него остаются два довода — 36 символов против 26 и
дефисы: без них `grep` и двойной клик берут id целиком.
- Сортировка по времени создания даёт `ORDER BY id` = хронология с
точностью до миллисекунды. Внутри одной миллисекунды порядок произволен,
если генератор не монотонный, — на хронологию событий это не влияет.
- Глобальная уникальность across таблиц даёт побочный, но важный эффект:
голый `grep` по id находит все записи сущности независимо от имени поля.
- **Единая точка генерации и разбора.** Один модуль генерирует id и один
разбирает; самодельных генераторов по коду нет.
## Канонический вид и границы
- Генерим и храним id в **нижнем регистре**. Сравнение строк в БД обычно
побайтовое, поэтому регистр — не косметика, а корректность. Спецификация
ULID канонизирует верхний регистр, и библиотеки по умолчанию отдают
именно его — нижний обеспечивает единая точка генерации, поэтому звать
библиотеку мимо неё нельзя.
- Любой пришедший снаружи id **обязательно** проходит разбор до запроса к
БД: он валидирует формат и нормализует регистр.
- Синтаксически невалидный id, которым **адресуют ресурс**, трактуем как
несуществующую сущность (404), **без похода в БД**: это и дешевле, и
убирает целый класс запросов с мусором. Невалидный id, пришедший из
собственной формы или кнопки, — не «не найдено», а некорректный ввод: там
это признак устаревшего интерфейса или бага, и маскировать его под 404
значит терять диагностику.
## Естественные и составные ключи — для деталей
У таблиц-деталей и связей допустим естественный или составной ключ вместо
сгенерированного, когда он есть по природе данных. Отдельный id там —
мёртвый вес, который ещё и создаёт второй способ адресовать ту же строку.
Прочие генерируемые идентификаторы (батчи, задания, корреляционные ключи) —
через ту же единую точку: единый формат, сортируемость, корреляция в логах.
<!-- local:отступления -->
<!-- /local -->
## Связано
- `arch/time.md` — метки времени тоже генерирует приложение, а не схема.
<!-- local:связано -->
<!-- /local -->
+74
View File
@@ -0,0 +1,74 @@
---
status: рекомендуемая
---
# Время
Один формат времени на всё приложение: хранение, логи, API, обмен с
внешними системами. Разные форматы в разных слоях — источник ошибок,
которые всплывают через полгода на границе перехода на летнее время.
## Формат
- **RFC 3339, UTC, суффикс `Z`**: `2026-06-28T11:23:45Z`.
- **Ширина фиксируется на каждый носитель** и внутри него не плавает.
Лексикографическая сортировка равна хронологии только среди строк
одинаковой длины: `…00.123Z` сортируется раньше `…00.12Z`, хотя
хронологически позже. Ради этого формат и фиксируется — `ORDER BY
created_at` по текстовому полю обязан давать порядок событий.
- Разные носители могут иметь разную точность: строки БД и строки лога
между собой никогда не сравниваются. Требование — не «одна точность на
приложение», а «внутри колонки и внутри потока логов ширина одна».
- Локальное время не хранится и не передаётся **нигде** — ни в БД, ни в
логах, ни в JSON API.
## Генерирует приложение, а не хранилище
- Единая точка получения «сейчас» и единая точка форматирования и разбора —
как с идентификаторами (`arch/db-identifiers.md`). Прямые вызовы часов по
коду не разбросаны: иначе ни формат, ни зона не гарантированы.
- **Дефолты в схеме БД не используем.** Забытая вставка `created_at`
должна падать громко, а не тихо получать значение от БД — иначе
расходятся источник времени (сервер БД) и его формат.
## Длительность — не метка времени
Измерение длительности операции — отдельная величина: число (обычно
миллисекунды) в поле вида `duration_ms`, а не разность двух меток и не
время в формате выше. Засекает её тот слой, который делает вызов.
**Интервал измеряется монотонными часами процесса**, а не вычитанием
сохранённых меток: стенные часы подводит NTP, они могут шагнуть назад и
дать отрицательную длительность. Из этого следует, что источник меток
времени и источник интервалов — разные, даже если оба называются «часы».
## Зоны
Единственное место, где появляется не-UTC, — **отображение пользователю**.
Зона берётся из конфигурации (`arch/config.md`), значение по умолчанию —
`UTC`. На хранение, сортировку и логи она не влияет.
Если бизнес-логика оперирует календарными сущностями («сегодня»,
«за месяц»), зона указывается **явно** в месте вычисления — молчаливое
использование системной зоны процесса запрещено: она разная на ноутбуке и в
контейнере. По умолчанию это та же зона, что и для отображения; если
календарная логика требует другой, это записывается явно.
Конвенция описывает фиксацию **свершившихся моментов**. Планирование
будущих событий — отдельный случай (там хранят локальное время плюс имя
зоны, потому что правила зон меняются); пока такой сущности нет, правило не
формулируем.
<!-- local:механизировано -->
<!-- /local -->
<!-- local:отступления -->
<!-- /local -->
## Связано
- `arch/config.md` — где задаётся зона отображения.
- `arch/db-identifiers.md` — то же правило «генерирует приложение» для id.
<!-- local:связано -->
<!-- /local -->
+135
View File
@@ -0,0 +1,135 @@
---
status: обязательная
---
# Как мы ведём конвенции
Конвенция описывает повторяющийся выбор: как называть директории, как
раскладывать данные, как оформлять ошибки. Она отвечает на вопрос «как
принято», а не «что здесь происходит». Одна конвенция — один файл.
Механической проверки у самой этой конвенции нет — осознанное исключение:
проверять «правильно ли написана конвенция» нечем, а обязательный статус
нужен, чтобы правила ниже не обсуждались заново в каждом репозитории.
## Канон и копии
Файлы в этой директории с шапкой `origin:`**копии из общего канона**
`dev-conventions`, а не собственные документы репозитория. Отсюда:
- репозиторное пишется **только внутрь локальных регионов**
`<!-- local:имя --> … <!-- /local -->`: они исключены из сравнения с
каноном, и расхождение по ним — норма, а не дрейф;
- правка вне регионов означает одно из двух: улучшение, которое надо
вернуть в канон, или сознательное расхождение, записанное в ключ `local:`
шапки;
- состояние копий показывает `conv status`, различия — `conv diff`,
обновление из канона — `conv pull`; всё через раннер репозитория.
Имя региона обязательно и стабильно: перенос содержимого при обновлении
идёт по именам.
## Отличие от соседей
- `docs/adr/`**решение**, принятое однажды и постфактум («почему выбрали
Authelia, а не Keycloak»). Запись неизменяема.
- `docs/specs/` и OpenSpec, где они есть, — **что** система делает,
наблюдаемое поведение как контракт. Конвенция — **как** написан код;
в спеки она не переносится, это не capability.
- `docs/drafts/` — оперативная хроника и черновики, «что собираюсь сделать».
- `docs/conventions/`**правило на будущее**, применяемое многократно.
Живой документ: правится, когда договорённость меняется.
## Направление: конвенция → код
Конвенция формулируется независимо от того, как устроено конкретное
приложение. Код следует конвенции, а не наоборот.
Если код расходится с правилом — это отступление, и оно записывается в
локальный регион, а не переписывает правило. Правило меняется только тогда,
когда оно **неверно по существу**: содержит фактическую ошибку, внутреннее
противоречие или условие применимости, которое не даёт ответа.
Практическое следствие: в тексте конвенции не должно быть утверждений о
текущем состоянии репозитория. «Так сделано у нас» — это регион
отступлений; норма пишется в настоящем предписывающем времени.
## Статус
Каждая конвенция объявляет статус в шапке:
- **рекомендуемая** — так стоит делать в новом коде; существующий переезжает
по мере касания, отдельной кампанией не переписывается;
- **обязательная** — нарушение считается ошибкой; по возможности проверяется
линтером или хуком, а не вниманием.
Конвенция без механической проверки держится только на внимании — это
нормально для рекомендуемой и плохо для обязательной.
## Когда заводить
Когда одно и то же решение принимается третий раз и каждый раз чуть
по-другому. Единичный выбор — не конвенция; если он ещё и был спорным, ему
место в ADR.
Путь находки: **находка → конвенция → правило линтера → удаление прозы**.
Первые два шага делаются в репозитории, где заболело; общая часть
продвигается в канон.
## Прозой — только то, что не выражается правилом
Как только свойство удаётся проверить машиной, его формулировка перестаёт
работать: файл на несколько сотен строк размазывает внимание по
тривиальному, и человек с агентом добросовестно проверят именование, не
дойдя до формы решения.
Но удаление прозы в общем каноне устроено иначе, чем в одиночном
репозитории. Механизация — состояние **конкретного** репозитория:
- **из канона формулировка не удаляется**, пока правило не механизировано
у всех потребителей: иначе те, у кого линтера нет, останутся без правила;
- **факт механизации** фиксируется в локальном регионе `механизировано`
со ссылкой на конкретное правило;
- когда механизация стала общей (правило уехало в общий конфиг линтера или
в общую роль), формулировка удаляется из канона одним `push`.
## Трудноизменяемые слои
У схемы БД, формата хранения и раскладки директорий шкала
«рекомендуемая → переезжает по мере касания» не работает: таблица не
переезжает от того, что её потрогали. Для таких конвенций:
- **область действия пишется явно** — «применяется к новым таблицам и
миграциям», а не к состоянию схемы;
- **механизируется граница изменения, а не состояние** — линтер запрещает
`AUTOINCREMENT` в новых миграциях, а не в существующей схеме: старое не
падает, новая ошибка невозможна;
- **список отступлений постоянный**, а не список задач на дочистку.
## Честный список отступлений
В локальном регионе перечисляем отступления, которые уже есть в коде, —
иначе репозиторий делает вид, что правилу следует. У рекомендуемой
конвенции пустой список отступлений почти всегда означает, что их просто не
искали.
Отступление — это «правилу не следуем здесь и вот почему». Если регион
разросся до «мы это правило вообще не применяем», значит либо у правила
неверно сформулировано условие применимости (чинить в каноне), либо
репозиторию не нужна эта конвенция (не подписываться).
## Оформление
- Имя файла — kebab-case по теме: `app-directories.md`.
- Раздел «Связано» в конце: ADR с обоснованием, спеки, код, который эту
конвенцию механизирует. Репо-специфичная часть «Связано» — в локальном
регионе, канонические ссылки — в общем тексте.
- README директории перечисляет конвенции с однострочным описанием, чтобы
список читался без открывания файлов.
- **Короткие инварианты дублируются туда, что агент читает безусловно**
(`AGENTS.md` / `CLAUDE.md`): сама по себе конвенция агенту не видна, он
дойдёт до неё, только если его туда отправили. Детали остаются здесь,
в файл-точку-входа едет одна строка на правило.
<!-- local:точки-входа -->
<!-- /local -->
Executable
+515
View File
@@ -0,0 +1,515 @@
#!/usr/bin/env python3
"""conv — синхронизация конвенций между каноном и репозиторием.
Канон — эта директория. Репозиторий держит закоммиченные копии нужных
конвенций в docs/conventions/, повторяя структуру канона. Копия — источник
правды для репозитория; канон — лавка, из которой берут.
Служебная разметка копии:
---
origin: arch/time.md # откуда взято
origin_hash: a1b2c3d4 # отпечаток канона на момент синхронизации
synced: 2026-07-25
local: нет # или текст: чем и почему разошлись
---
Прочие ключи шапки (status, extends) — часть документа: они сравниваются
наравне с телом и приезжают из канона.
Локальные регионы — куски, которые по определению принадлежат репозиторию
(механизация, отступления, «здесь решили так»). Из сравнения исключаются:
<!-- local:механизировано -->
...
<!-- /local -->
Имя региона обязательно: перенос при pull идёт по именам.
Команды:
conv list что есть в каноне
conv add arch/time.md [...] взять конвенцию в репозиторий
conv status состояние копий репозитория
conv diff [arch/time.md] чем копия отличается от канона
conv pull arch/time.md забрать обновление канона
conv push arch/time.md вернуть локальное улучшение в канон
conv push --new lang/go/x.md завести в каноне новую конвенцию
Везде можно указать --repo <path> (по умолчанию — текущая директория)
и --dir <subpath> (по умолчанию docs/conventions), до или после команды.
"""
from __future__ import annotations
import argparse
import datetime
import difflib
import hashlib
import re
import sys
from pathlib import Path
from typing import NoReturn
CANON = Path(__file__).resolve().parent
CANON_TREES = ("common", "arch", "lang", "stack")
SERVICE_KEYS = ("origin", "origin_hash", "synced", "local")
DEFAULT_DIR = "docs/conventions"
ENC = "utf-8"
# Маркеры распознаются только в начале строки: так пример разметки внутри
# текста конвенции не превращается в настоящий регион.
REGION_RE = re.compile(
r"^<!--[ \t]*local(?::[ \t]*([^>]*?))?[ \t]*-->(.*?)^<!--[ \t]*/local[ \t]*-->",
re.DOTALL | re.MULTILINE,
)
OPEN_RE = re.compile(r"^<!--[ \t]*local", re.MULTILINE)
CLOSE_RE = re.compile(r"^<!--[ \t]*/local", re.MULTILINE)
def die(message: str) -> NoReturn:
print(f"conv: {message}", file=sys.stderr)
sys.exit(1)
# --- разметка --------------------------------------------------------------
def read(path: Path) -> str:
return path.read_text(encoding=ENC)
def write(path: Path, text: str) -> None:
path.write_text(text, encoding=ENC)
def split_front(text: str) -> tuple[dict[str, str], str]:
"""Отделяет YAML-шапку (плоский key: value) от тела."""
if not text.startswith("---\n"):
return {}, text
end = text.find("\n---\n", 4)
if end == -1:
return {}, text
meta: dict[str, str] = {}
for line in text[4:end].splitlines():
if ":" in line:
key, value = line.split(":", 1)
meta[key.strip()] = value.strip()
return meta, text[end + 5 :]
def join_front(meta: dict[str, str], body: str) -> str:
if not meta:
return body
lines = "\n".join(f"{k}:{' ' + v if v else ''}" for k, v in meta.items())
return f"---\n{lines}\n---\n{body}"
def doc_keys(meta: dict[str, str]) -> dict[str, str]:
return {k: v for k, v in meta.items() if k not in SERVICE_KEYS}
def regions(body: str) -> dict[str, str]:
"""Содержимое локальных регионов по имени.
Поднимает ValueError на разметке, из-за которой регион молча превратился
бы в обычный текст и потерялся при pull.
"""
matched = len(REGION_RE.findall(body))
if len(OPEN_RE.findall(body)) != matched or len(CLOSE_RE.findall(body)) != matched:
raise ValueError("непарный или нераспознанный маркер локального региона")
found: dict[str, str] = {}
for match in REGION_RE.finditer(body):
name = (match.group(1) or "").strip()
content = match.group(2)
if not name:
if content.strip():
raise ValueError(
"безымянный локальный регион с содержимым — дай ему имя"
)
continue
if name in found:
raise ValueError(f"локальный регион '{name}' встречается дважды")
found[name] = content
return found
def checked_regions(body: str, where: str) -> dict[str, str]:
try:
return regions(body)
except ValueError as exc:
die(f"{where}: {exc}")
def blank_regions(body: str) -> str:
"""Тело с опустошёнными локальными регионами — то, что сравнивается."""
def repl(match: re.Match[str]) -> str:
raw = (match.group(1) or "").strip()
head = f"<!-- local:{raw} -->" if raw else "<!-- local -->"
return f"{head}\n<!-- /local -->"
return REGION_RE.sub(repl, body)
def fill_regions(body: str, values: dict[str, str]) -> tuple[str, list[str]]:
"""Вставляет содержимое регионов по имени. Возвращает тело и имена,
которым не нашлось места."""
used: set[str] = set()
def repl(match: re.Match[str]) -> str:
raw = (match.group(1) or "").strip()
head = f"<!-- local:{raw} -->" if raw else "<!-- local -->"
if raw in values:
used.add(raw)
return f"{head}{values[raw]}<!-- /local -->"
return match.group(0)
filled = REGION_RE.sub(repl, body)
lost = [n for n, v in values.items() if n not in used and v.strip()]
return filled, lost
def fingerprint(meta: dict[str, str], body: str) -> str:
"""Отпечаток документа: ключи шапки плюс тело без локальных регионов."""
head = "\n".join(f"{k}={v}" for k, v in sorted(doc_keys(meta).items()))
return hashlib.sha256(f"{head}\n\n{blank_regions(body)}".encode(ENC)).hexdigest()[
:8
]
def today() -> str:
return datetime.date.today().isoformat()
# --- канон и репозиторий ---------------------------------------------------
def canon_list() -> list[str]:
out: list[str] = []
for tree in CANON_TREES:
root = CANON / tree
if root.is_dir():
out += [str(p.relative_to(CANON)) for p in sorted(root.rglob("*.md"))]
return out
def canon_read(origin: str) -> tuple[dict[str, str], str]:
path = CANON / origin
if not path.is_file():
die(f"в каноне нет {origin}")
return split_front(read(path))
def normalize_origin(name: str, *, must_exist: bool = True) -> str:
"""Принимает 'arch/time.md', 'arch/time' и однозначный хвост вроде 'time'."""
name = name.strip("/")
if not name.endswith(".md"):
name += ".md"
candidate = (CANON / name).resolve()
if candidate.is_relative_to(CANON):
rel = str(candidate.relative_to(CANON))
if rel.split("/")[0] in CANON_TREES and (not must_exist or candidate.is_file()):
return rel
matches = [c for c in canon_list() if c == name or c.endswith("/" + name)]
if len(matches) == 1:
return matches[0]
if not matches:
die(f"в каноне нет {name} (путь должен начинаться с {'/'.join(CANON_TREES)})")
die(f"неоднозначно: {name} → {', '.join(matches)}")
def repo_dir(args: argparse.Namespace) -> Path:
return (Path(str(args.repo)) / str(args.dir)).resolve()
def repo_copies(base: Path) -> tuple[dict[str, Path], list[Path], list[str]]:
"""origin → копия; плюс .md без шапки и сообщения о нечитаемых файлах."""
found: dict[str, Path] = {}
untracked: list[Path] = []
problems: list[str] = []
if not base.is_dir():
return found, untracked, problems
for path in sorted(base.rglob("*.md")):
try:
meta, _ = split_front(read(path))
except (OSError, UnicodeDecodeError) as exc:
problems.append(f"{path.name}: не читается ({type(exc).__name__})")
continue
origin = meta.get("origin")
if not origin:
if path.name != "README.md":
untracked.append(path)
continue
if origin in found:
problems.append(
f"{origin}: две копии ({found[origin]}, {path}) — вторая скрыта"
)
continue
found[origin] = path
return found, untracked, problems
def locate(args: argparse.Namespace, origin: str) -> Path:
"""Путь копии: по шапке, если она лежит не по канонному пути."""
base = repo_dir(args)
copies, _, _ = repo_copies(base)
return copies.get(origin, base / origin)
# --- состояние -------------------------------------------------------------
def state(
meta: dict[str, str], body: str, canon_meta: dict[str, str], canon_body: str
) -> str:
copy_fp = fingerprint(meta, body)
canon_fp = fingerprint(canon_meta, canon_body)
if copy_fp == canon_fp:
return "ok"
base = meta.get("origin_hash")
if not base:
return "нет origin_hash в шапке"
if base == canon_fp:
return "изменено локально"
if base == copy_fp:
return "канон обновился"
return "разошлись"
# --- команды ---------------------------------------------------------------
def cmd_list(args: argparse.Namespace) -> int:
for origin in canon_list():
meta, _ = canon_read(origin)
marks = []
if "extends" in meta:
marks.append(f"расширяет {meta['extends']}")
if "status" in meta:
marks.append(meta["status"])
tail = f" ({'; '.join(marks)})" if marks else ""
print(f"{origin}{tail}")
return 0
def cmd_add(args: argparse.Namespace) -> int:
base = repo_dir(args)
added = False
for raw in args.names:
origin = normalize_origin(raw)
target = base / origin
if target.exists():
print(f"{origin}: уже есть ({target}), пропускаю")
continue
canon_meta, canon_body = canon_read(origin)
checked_regions(canon_body, f"канон/{origin}")
meta: dict[str, str] = {
"origin": origin,
"origin_hash": fingerprint(canon_meta, canon_body),
"synced": today(),
"local": "нет",
}
meta.update(doc_keys(canon_meta))
target.parent.mkdir(parents=True, exist_ok=True)
write(target, join_front(meta, canon_body))
added = True
print(f"{origin} → {target}")
if "extends" in canon_meta:
print(f" расширяет {canon_meta['extends']} — возможно, нужна и она")
if added:
print("не забудь строку в docs/conventions/README.md")
return 0
def cmd_status(args: argparse.Namespace) -> int:
base = repo_dir(args)
copies, untracked, problems = repo_copies(base)
if not copies and not untracked and not problems:
print(f"в {base} нет копий конвенций")
return 0
width = max((len(o) for o in copies), default=0)
for origin, path in copies.items():
try:
meta, body = split_front(read(path))
except (OSError, UnicodeDecodeError) as exc:
print(f"{origin:<{width}} не читается ({type(exc).__name__})")
continue
if not (CANON / origin).is_file():
print(f"{origin:<{width}} нет в каноне")
continue
canon_meta, canon_body = canon_read(origin)
try:
regions(body)
except ValueError as exc:
print(f"{origin:<{width}} разметка: {exc}")
continue
local = meta.get("local", "нет")
note = "" if local == "нет" else f" [{local}]"
print(f"{origin:<{width}} {state(meta, body, canon_meta, canon_body)}{note}")
for path in untracked:
print(f"{path.name}: без шапки origin — не отслеживается")
for problem in problems:
print(problem)
return 0
def cmd_diff(args: argparse.Namespace) -> int:
base = repo_dir(args)
copies, _, _ = repo_copies(base)
targets = [normalize_origin(args.name)] if args.name else list(copies)
for origin in targets:
path = copies.get(origin)
if path is None:
print(f"{origin}: нет копии в репозитории")
continue
if not (CANON / origin).is_file():
print(f"{origin}: нет в каноне")
continue
meta, body = split_front(read(path))
canon_meta, canon_body = canon_read(origin)
if fingerprint(meta, body) == fingerprint(canon_meta, canon_body):
continue
sys.stdout.writelines(
difflib.unified_diff(
join_front(doc_keys(canon_meta), blank_regions(canon_body)).splitlines(
keepends=True
),
join_front(doc_keys(meta), blank_regions(body)).splitlines(
keepends=True
),
fromfile=f"канон/{origin}",
tofile=f"репо/{origin}",
)
)
return 0
def cmd_pull(args: argparse.Namespace) -> int:
origin = normalize_origin(args.name)
path = locate(args, origin)
if not path.is_file():
die(f"нет копии {origin} — сначала conv add {origin}")
meta, body = split_front(read(path))
canon_meta, canon_body = canon_read(origin)
checked_regions(canon_body, f"канон/{origin}")
local = checked_regions(body, f"репо/{origin}")
st = state(meta, body, canon_meta, canon_body)
if st == "ok":
fresh = fingerprint(canon_meta, canon_body)
if meta.get("origin_hash") != fresh:
meta["origin_hash"] = fresh
meta["synced"] = today()
write(path, join_front(meta, body))
print(f"{origin}: тексты совпадают, отпечаток освежён")
else:
print(f"{origin}: уже совпадает")
return 0
if st in ("изменено локально", "разошлись") and not args.force:
die(
f"{origin}: {st} — правки вне локальных регионов будут потеряны.\n"
f" посмотри conv diff {origin}, затем conv pull --force "
f"или conv push {origin}"
)
merged, lost = fill_regions(canon_body, local)
if lost and not args.force:
die(
f"{origin}: в каноне нет регионов {', '.join(lost)} — их содержимое "
f"пропадёт.\n перенеси вручную или conv pull --force"
)
for name in lost:
print(f" потерян локальный регион {name}")
new_meta = {k: meta[k] for k in SERVICE_KEYS if k in meta}
new_meta["origin_hash"] = fingerprint(canon_meta, canon_body)
new_meta["synced"] = today()
new_meta.update(doc_keys(canon_meta))
write(path, join_front(new_meta, merged))
print(f"{origin}: обновлено из канона — перечитай глазами, регионы могли устареть")
return 0
def cmd_push(args: argparse.Namespace) -> int:
origin = normalize_origin(args.name, must_exist=not args.new)
path = locate(args, origin)
if not path.is_file():
die(f"нет копии {origin}")
meta, body = split_front(read(path))
checked_regions(body, f"репо/{origin}")
target = CANON / origin
if not target.is_file():
if not args.new:
die(f"в каноне нет {origin} — заведи новую конвенцию через conv push --new")
target.parent.mkdir(parents=True, exist_ok=True)
write(target, join_front(doc_keys(meta), blank_regions(body)))
meta["origin_hash"] = fingerprint(doc_keys(meta), blank_regions(body))
meta["synced"] = today()
write(path, join_front(meta, body))
print(f"{origin}: заведена в каноне")
return 0
canon_meta, canon_body = canon_read(origin)
st = state(meta, body, canon_meta, canon_body)
if st == "ok":
print(f"{origin}: канон уже такой")
return 0
if st == "канон обновился":
die(
f"{origin}: копия не менялась, а канон ушёл вперёд — пушить нечего, нужен pull"
)
if st == "разошлись" and not args.force:
die(
f"{origin}: разошлись — канон менялся после синхронизации, "
f"его правки затрутся.\n посмотри conv diff {origin}, "
f"затем conv push --force"
)
write(target, join_front(doc_keys(meta), blank_regions(body)))
meta["origin_hash"] = fingerprint(doc_keys(meta), blank_regions(body))
meta["synced"] = today()
write(path, join_front(meta, body))
print(f"{origin}: канон обновлён из репозитория")
return 0
def main() -> int:
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--repo", default=".", help="корень репозитория")
common.add_argument("--dir", default=DEFAULT_DIR, help="где лежат конвенции")
parser = argparse.ArgumentParser(prog="conv", parents=[common], description=__doc__)
sub = parser.add_subparsers(dest="cmd", required=True)
sub.add_parser("list", parents=[common], help="что есть в каноне").set_defaults(
fn=cmd_list
)
p_add = sub.add_parser(
"add", parents=[common], help="взять конвенцию в репозиторий"
)
p_add.add_argument("names", nargs="+")
p_add.set_defaults(fn=cmd_add)
sub.add_parser("status", parents=[common], help="состояние копий").set_defaults(
fn=cmd_status
)
p_diff = sub.add_parser(
"diff", parents=[common], help="чем копия отличается от канона"
)
p_diff.add_argument("name", nargs="?")
p_diff.set_defaults(fn=cmd_diff)
p_pull = sub.add_parser("pull", parents=[common], help="забрать обновление канона")
p_pull.add_argument("name")
p_pull.add_argument("--force", action="store_true")
p_pull.set_defaults(fn=cmd_pull)
p_push = sub.add_parser("push", parents=[common], help="вернуть улучшение в канон")
p_push.add_argument("name")
p_push.add_argument("--force", action="store_true")
p_push.add_argument("--new", action="store_true", help="завести новый файл канона")
p_push.set_defaults(fn=cmd_push)
args = parser.parse_args()
return int(args.fn(args))
if __name__ == "__main__":
sys.exit(main())
+89
View File
@@ -0,0 +1,89 @@
---
status: рекомендуемая
extends: arch/config.md
---
# Конфигурация: реализация на Go
Как `arch/config.md` выглядит в Go-приложении.
## Формат и загрузчик
- TOML. Разбор и валидация — целиком в `internal/config`; наружу отдаётся
готовая структура `Config`.
- Одна корневая структура `Config` с под-структурами по секциям — имена
структур совпадают с именами секций, чтобы конфиг и код читались рядом.
- Умолчания — в `Default()`, поверх накладывается разобранный файл.
- Флаг `--config=path` переопределяет путь; по умолчанию `config.toml` в
рабочей директории, образец — `config.example.toml`.
## Длительности
`time.Duration` не разбирается из строки TOML сама по себе — нужен свой тип
с `UnmarshalText`, отдающий `time.Duration`:
```go
type Duration time.Duration
func (d *Duration) UnmarshalText(b []byte) error { }
func (d Duration) Std() time.Duration { }
```
Так в конфиге видна единица измерения (`poll_interval = "5s"`), а не голое
число. Цена: ошибка в длительности всплывает **на разборе TOML**, до общей
валидации, поэтому в общий сбор проблем она не попадает — про неё узнаёшь
отдельно и первой.
## Чтение окружения
Приложение не читает окружение для конфигурации. Механизируется
`forbidigo`, и паттерн должен покрывать **все** входы, а не только
`os.Getenv`:
```
^os\.(Getenv|LookupEnv|Environ|ExpandEnv)$
```
Правило про приложение, поэтому за его границей запрет не действует:
- **тесты** — не приложение: интеграционному тесту нормально брать
креды внешнего сервиса из окружения;
- **переменные рантайма** — те, что читает не наш код, а Go или ОС
(`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`).
Отдельный случай — переменные, которые читает **стандартная библиотека от
имени приложения**: дефолтный `http.Transport` уважает
`HTTP_PROXY`/`HTTPS_PROXY`. Формально их читает не наш код, но это
конфигурация поведения приложения, поэтому прокси задаётся полем конфига и
явным `Transport`, а не окружением.
## Валидация
- Проверки собираются `errors.Join`, чтобы за один запуск показать **все**
проблемы конфига, а не первую.
- IANA-зона валидируется `time.LoadLocation`. База зон встраивается
импортом `_ "time/tzdata"` **в `main`**, а не в библиотечном пакете:
иначе ~450 КБ zoneinfo навязываются каждому импортёру. Со встроенной
базой ошибка `LoadLocation` означает битое имя зоны, а не отсутствие
zoneinfo в контейнере.
- Невалидный конфиг — `slog` уровня `ERROR` и `os.Exit(1)` из `main`, до
старта серверов и воркеров.
## Секреты
Go-специфики нет: секреты приходят из деплоя уже в файле, проверка их
непустоты идёт вместе с остальной валидацией — см. базу.
<!-- local:поля -->
<!-- /local -->
<!-- local:механизировано -->
<!-- /local -->
## Связано
- `lang/go/time.md` — зона отображения и формат времени.
- `lang/go/logging.md``slog`, которым падает невалидный конфиг.
<!-- local:связано -->
<!-- /local -->
+50
View File
@@ -0,0 +1,50 @@
---
status: рекомендуемая
extends: arch/db-identifiers.md
---
# Идентификаторы: реализация на Go
Как `arch/db-identifiers.md` выглядит в Go-приложении, выбравшем ULID.
## Единая точка — `internal/ident`
- `ident.NewID()` — генерация. **PK сущности** генерируется в `Create`-методах
слоя `store`. Прочие идентификаторы (батч, задание, корреляционный ключ)
генерируются там, где начинается операция, — но тоже только через `ident`.
- `ident.NewIDAt(t)` — генерация с заданным временем, для бэкфилла в
Go-миграциях: сортировка id тогда сохраняет историческую хронологию, а не
момент прогона миграции.
- `ident.Parse()` — разбор и нормализация; зовётся на **входных границах**
(HTTP-роут, форма, callback бота), до обращения к store.
- Других генераторов и парсеров id в коде нет. Это то самое «единая точка»
из базовой конвенции; без него нормализация регистра неизбежно
где-нибудь пропускается.
## Типы
В структурах store и домена id — обычный `string`. Отдельный тип `ID`
заводим, только если появится вторая семья идентификаторов, которую можно
перепутать; до этого он даёт конверсии без выгоды. От перепутывания двух id
одной семьи в сигнатуре он всё равно не спасает — там помогают имена
параметров.
## Невалидный id на границе
Разбор не удался — дальше зависит от того, откуда id пришёл:
- **из пути или query URL** — сразу 404, без обращения к store и без
фабрикации доменной ошибки: снаружи это неотличимо от несуществующей
записи, и хорошо;
- **из собственной формы или callback-данных кнопки** — 400 либо понятное
сообщение («кнопка устарела»): это баг интерфейса или протухший экран, и
под «не найдено» его маскировать нельзя.
Транспорт не создаёт доменные sentinel'ы, чтобы тут же их сматчить, — это
инверсия правила «трансляция у источника» из `lang/go/errors.md`.
<!-- local:механизировано -->
<!-- /local -->
<!-- local:отступления -->
<!-- /local -->
+57
View File
@@ -0,0 +1,57 @@
---
status: рекомендуемая
---
# Схема и миграции (SQLite, Go)
Область действия — **новые миграции**. Существующая схема не переписывается;
линтер проверяет то, что добавляется, а не то, что уже лежит.
## Миграции
- Инструмент — goose, файлы миграций лежат рядом со store-слоем.
- **SQL-файл** для DDL: создание таблиц, индексы, изменение структуры.
- **Go-миграция** (`goose.AddMigrationContext`) — когда нужен код:
генерация идентификаторов, backfill, перенос данных между формами.
Не пытаемся выразить это SQL-ом ради единообразия.
- **В деплое движение только вперёд.** Down-миграция — инструмент
разработки, а не отката на сервере.
- **Down пишется, когда он честно обращает up**: убрать то, что up добавил.
Не пишется, когда up необратимо трансформирует данные, — тогда его
отсутствие честнее имитации, которая молча теряет колонку.
- При изменении структуры ER-схема в спеках обновляется **в том же
изменении**, а не «потом»: разошедшаяся схема хуже отсутствующей.
## Типы колонок
- **Enum-поля** (`state`, `kind`, …) — обычный `TEXT` **без `CHECK`**.
Допустимые значения держит код. `ALTER TABLE` в SQLite не умеет менять
ограничения ни в одной версии, поэтому каждое новое значение в
`CHECK(... IN (...))` означает пересоздание таблицы по 12-шаговой
процедуре; защита от невалидного значения всё равно нужна на уровне типов
Go.
- **Метки времени** — `TEXT` в формате из `arch/time.md`. Без
`DEFAULT (datetime('now'))`: помимо того, что время ставит приложение,
эта функция даёт `YYYY-MM-DD HH:MM:SS` — без `T` и без `Z`, то есть не
тот формат.
- **Булевы** — `INTEGER` 0/1. Отдельного типа в SQLite нет, а строка
`'true'` в булевом контексте приводится к **0** — то есть тихо
инвертирует смысл, а не просто ломает фильтрацию.
- **Первичные ключи** — если репозиторий взял `arch/db-identifiers.md`, то
по ней (без `AUTOINCREMENT`); иначе автоинкремент допустим.
<!-- local:механизировано -->
<!-- /local -->
<!-- local:отступления -->
<!-- /local -->
## Связано
- `arch/time.md` — формат меток времени.
- `arch/db-identifiers.md` — выбор первичных ключей.
- `lang/go/errors.md` — граничные ошибки `database/sql` транслируются в
доменные у источника, в слое store.
<!-- local:связано -->
<!-- /local -->
+140
View File
@@ -0,0 +1,140 @@
---
status: рекомендуемая
---
# Ошибки
Как ошибки строятся, оборачиваются и проверяются. Где и когда ошибку
**логировать** — в `lang/go/logging.md`, раздел «Ошибки» (коротко: лог один
раз на доменной границе).
## Базовая идиома: stdlib
- Только стандартный `errors` + `fmt.Errorf`. Контекст ошибки несёт `slog`,
а не стек: при дисциплине «каждый слой добавляет свой контекст» цепочка
сообщений локализует место не хуже стека, а стек-трейсы и Sentry
избыточны для домашнего сервиса.
- Если отладка начнёт упираться в «где именно родилась ошибка» — это
сигнал пересмотреть решение, а не дефолт, который можно обойти локально.
- Единственное исключение — восстановленная паника: у неё цепочки `%w` нет
вовсе (см. «panic»).
## Обёртка и контекст
Сервис — **приложение, а не библиотека**: внешнего Go-API нет, весь код
наш. Возражение против дефолтного `%w` («обёрнутая ошибка становится частью
API») относится к библиотекам, поэтому внутри приложения обёртка `%w`
**дефолт**, чтобы `errors.Is` и `errors.As` работали сквозь слои.
- Добавляем контекст обёрткой: `fmt.Errorf("parse magnet: %w", err)`.
- `%w` — когда вызывающий может инспектировать причину (обычный случай).
`%v` — когда причину сознательно **не** раскрываем, чтобы не завязывать
вызывающего на чужой тип ошибки.
- От утечки внутренних ошибок наружу защищаемся **не** через `%v` в
цепочке, а трансляцией на внешней границе (ниже).
Стиль сообщения:
- со строчной буквы, без точки в конце, без «failed to» и «error» — обёртка
и так читается как «контекст: причина»;
- контекст — операция или субъект: `"link target: %w"`, не
`"something failed"`;
- без заикания: каждый слой добавляет **свой** смысл, не повторяя нижний
(`"add to qbt: %w"`, а не `"add download failed: add to qbt failed: …"`).
## Две трансляции
Ошибка меняет форму дважды, и это разные преобразования.
**Первая — у источника, инфраструктурная → доменная.** Граничные ошибки
зависимостей транслируем там, где они возникли: `sql.ErrNoRows` → доменный
`store.ErrNotFound` в слое store, чтобы выше по коду не торчал
`database/sql`. То же для HTTP-клиентов, файловой системы, внешних SDK.
**Вторая — на внешней границе, доменная → пользовательская.** Описана
ниже, в разделе про каналы.
## Sentinel vs типизированные
- **Sentinel** (`var ErrNotFound = errors.New("not found")`) — для условий,
на которые ветвится код: нет записи, дубликат, неподдерживаемый источник.
Проверяем `errors.Is`.
- **Типизированная ошибка** (тип с полями и методом `Error()`) — когда
вызывающему нужны **данные** ошибки: поле валидации, код, лимит. Достаём
`errors.As`. Не плодим типы там, где хватает sentinel.
- Матчинг по тексту сообщения запрещён — это то же самое, что публичный
API из строки лога.
## Граница: приватный канал vs публичный
Внутри — богатые обёрнутые ошибки. На внешней границе форма зависит от
того, кто канал видит.
**Приватный канал — логи** (владелец сервиса). Полная ошибка со всей
цепочкой `%w` и контекстом. Пишется один раз на доменной границе.
**Публичный канал — пользовательские поверхности** (HTTP API, web-UI, бот).
Сюда отдаём:
- **человекочитаемое сообщение** по доменной ошибке — не сырой
`err.Error()` и не детали реализации (`database/sql`, пути, стек);
- **корреляционный ключ** для владельца — id сущности либо `request_id`,
чтобы по нему найти полную ошибку в логах. «При обработке загрузки
произошла ошибка, download_id=…» вместо «произошла ошибка»;
- **маппинг доменной ошибки → сообщение и, для HTTP, статус** — в одной
точке на все транспорты. У транспортов без статусов (бот) от маппинга
берётся только сообщение.
Новую штатную ветвь отказа (конфликт, валидация) заводим sentinel'ом и
**сразу добавляем в маппинг** — иначе `default` отдаст 500 «внутренняя
ошибка» на нормальный конфликт, а логирующая граница спишет его в `ERROR`
вместо `DEBUG`.
<!-- local:маппинг -->
<!-- /local -->
### Транзиентный ответ vs персистентная диагностика
У публичной границы две разные поверхности, и правило сырого текста для них
разное:
- **Транзиентный ответ на действие** (тело ответа, `?err=`, реплика бота по
результату команды) — строго нейтральный: маппинг выше, `err.Error()`
наружу не идёт, полная ошибка живёт в логах по корреляционному ключу.
- **Персистентная диагностика состояния** — причина ухода записи в
ошибочное состояние, сохранённая в БД и показываемая оператору. Здесь
сырой текст ошибки (пути, фрагмент ответа внешнего сервиса) допустим и
полезен — **но только пока поверхность видит исключительно владелец**.
Появился второй зритель или публичный доступ к экрану состояния —
поверхность стала публичным каналом, и правило нейтрального текста
распространяется на неё. Секреты запрещены абсолютно в обоих случаях;
источник вычищается на границе клиента.
Различие работает, только если поверхности не смешиваются в одном поле.
Диагностику кладём в **отдельное поле**, а не в доменное.
## panic
- `panic` — только для невосстановимого: нарушенный инвариант (баг
программиста), ошибка инициализации, из которой нельзя стартовать.
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой
ввод) — это значения `error`.
- **`recover` — на верхней границе каждой обрабатывающей единицы**, а не
только у HTTP:
- HTTP middleware — `net/http` сам восстанавливает панику в хендлере и
процесс не роняет, поэтому смысл своего `recover` в другом: отдать
контролируемый 500 и записать событие в `slog`, а не в stdlib-логгер;
- цикл обработки апдейтов бота и фоновый воркер — вот здесь паника в
горутине **действительно роняет процесс**, и `recover` обязателен.
`recover` работает только в той горутине, где случилась паника.
- **Логирующая recover-граница пишет `debug.Stack()`.** Это единственное
место, где нужен стек-трейс: у восстановленной паники нет цепочки `%w`, и
без стека «index out of range» не диагностируется вообще.
## Несколько ошибок
Сбор независимых ошибок (валидация конфига — все проблемы разом) —
`errors.Join`; проверка собранного по-прежнему через `errors.Is`.
<!-- local:механизировано -->
<!-- /local -->
+242
View File
@@ -0,0 +1,242 @@
---
status: рекомендуемая
extends: arch/time.md
---
# Логирование
Как и когда писать логи. Это правила оформления кода (How), а не
спецификация поведения: наблюдаемые требования к логам, входящие в контракт
функциональности, живут в спеках.
## Принципы
- Структурированный JSON (`slog.JSONHandler`), **один формат для dev и
prod**. Не потому, что текстовый вывод «расходит поля» — смена хендлера
структуру атрибутов не меняет; а потому, что с текстовым dev-выводом
перестаёшь ежедневно гонять собственные `jq`-пайплайны, и поломки
словаря замечаются только в проде.
- Сообщение (`msg`) — категория события; данные — в полях. Каждое поле —
отдельный ключ с типизированным значением: это даёт фильтрацию и
агрегацию через `jq`/DuckDB без регулярок.
```json
{"time":"2026-06-28T11:23:45.123Z","level":"INFO","msg":"download accepted","download_id":"01jz2k7f8q9r3s4t5v6w7x8y9z","media_type":"movie"}
```
## Время в записи
Поле `time` ставит `slog`, но **UTC он по умолчанию не даёт**: встроенные
хендлеры пишут время в зоне самого `time.Time`, то есть в локальной зоне
процесса. UTC ставится `ReplaceAttr` по `slog.TimeKey` — см.
`lang/go/time.md`. Точность `JSONHandler` — миллисекунды, фиксированная
ширина; это другая точность, чем в БД, и по `arch/time.md` так и должно
быть: ширина фиксируется на носитель.
## Сообщение
- `msg` — короткая **константа** в нижнем регистре: `download accepted`,
`recognition done`, `layout failed`. Данные — в атрибутах:
`log.Info("download accepted", "download_id", id)`.
- `msg` — чистая категория **без неймспейс-префикса**: `recognition done`,
а не `recognize: done`. Подсистема — отдельное поле, не текст.
- **Смена состояния сущности — единая категория** (`state transition`) с
полями `from`/`to`/`code`. Какое именно состояние и по какой причине —
это данные, а не текст. Тогда весь жизненный цикл собирается одним
фильтром. Физический эффект сверх перехода — отдельная запись своей
категории, она не подменяет запись перехода.
## Уровни
Принцип: уровень — это **адресат** («кому сообщение»), а не «насколько
громко сломалось».
| Уровень | Кому и когда |
|---|---|
| `DEBUG` | разработчику при отладке; в проде выключен |
| `INFO` | владельцу, аудит постфактум |
| `WARN` | владельцу, «может стать проблемой» |
| `ERROR` | владельцу, в разбор |
Правила:
- Уровень **не зависит от подсистемы**: `ERROR` везде одинаково серьёзен.
- `WARN` ≠ «ничего страшного». `WARN` = «может стать проблемой». Если это
не «может» — это `INFO`.
- Меняется адресат — меняется уровень. Невалидный ввод от пользователя —
`DEBUG` (норма, разбирать нечего), а не `ERROR`.
- **Событийное → `INFO`, рутинно-частое → `DEBUG`.** Операция по реальному
действию или изменению — `INFO`. Повторяющаяся служебная операция,
запускаемая таймером или поллингом и сама по себе не несущая события
(healthcheck, опрос статуса, авто-рефреш UI), — `DEBUG`: на `INFO` она
зашумляет аудит.
- `slog` не разделяет CRITICAL/FATAL — фатальный сбой на старте логируем
`ERROR` и завершаем процесс с ненулевым кодом.
## Поля: единый словарь
Главное условие — **одно поле, одно имя по всему коду** (не
`mediaType`/`media`/`media_type` вперемешку).
- Бизнес-поля — плоский `snake_case`.
- Системные домены — точечная иерархия (адаптация OpenTelemetry): `http.*`,
`ext.*`.
- JSON плоский: все поля на верхнем уровне, без вложенности.
| Когда добавляем | Поля |
|---|---|
| входящий HTTP-запрос (middleware) | `http.method`, `http.route`, `http.status_code`, `duration_ms`, `transport` — если транспортов больше одного |
| работа с сущностью (scoped-логгер) | `<entity>_id` и доменные атрибуты |
| запись об ошибке | `error` |
| вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
`service.*` и `host.*` не заводим — для одного бинаря на одном хосте это
шум. Если появятся несколько инстансов, добавим `service.version` одной
строкой при старте.
<!-- local:словарь -->
<!-- /local -->
## Корреляция по id сущности
Отдельный случайный `trace_id` не заводим, **если у сущностей есть
стабильные уникальные идентификаторы** — они и служат ключом корреляции.
(Как их выбирают — `arch/db-identifiers.md`, если конвенция взята.)
- Каждая запись, относящаяся к сущности, несёт её id в поле `<entity>_id`.
Для долгой операции — scoped-логгер, протаскиваемый через
`context.Context` сквозь асинхронные стадии, чтобы ключ дописывался сам:
```go
log := log.With("download_id", id)
ctx = logctx.With(ctx, log) // достаём логгер из ctx в каждой стадии
```
- Все записи одной операции собираются одним фильтром:
`jq 'select(.download_id=="01jz…")' app.jsonl`.
- Если id глобально уникален across сущностей, штатно работает и простой
`grep` по голому id — он находит все упоминания независимо от имени поля.
## Ошибки
Go-ошибки логируем **атрибутом**, не текстом сообщения:
`log.Error("layout failed", "error", err, "download_id", id)`. Ключ —
`error` (как по умолчанию в zap/zerolog: единый ключ важнее краткости).
- Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только
оборачивают и возвращают (`%w`), не логируя: контекст накапливается в
цепочке.
- Логируем ошибку **один раз — на границе доменного слоя**, которая
определяет исход операции. Логирует этот единый чокпоинт, а не каждый
транспорт: так транспорты остаются тонкими, и один сбой не даёт дублей.
<!-- local:границы -->
<!-- /local -->
- Транспорты переводят возвращённую ошибку в свой ответ (статус, сообщение
пользователю) и **не логируют** её повторно.
- **Уровень доменного отказа — по адресату, а не по месту.** У каждой
доменной ошибки ровно один логирующий; уровень выбирает он:
| Класс отказа | Кому | Уровень |
|---|---|---|
| штатный конфликт состояния или некорректный ввод | пользователю, он уже получил ответ | `DEBUG` |
| расхождение производного или учётного состояния, первичные данные целы | владельцу, «может стать проблемой» | `WARN` |
| сбой БД, ФС, недоступность зависимости | владельцу, в разбор | `ERROR` |
- Тот же класс отказа в **асинхронной стадии** (пользователь не ждёт)
адресован уже владельцу как деградация автоматики — уровень поднимается.
Коллизия в ручном действии — `DEBUG` (человек видит причину на экране), в
авто-обработке — `WARN` (автоматика не довела задачу).
- **Повторяющийся сбой фонового цикла — `WARN`, не `ERROR`.** Одиночный
промах тика транзиентен: следующий тик повторит. Тот же класс сбоя внутри
синхронной операции — `ERROR`, потому что операция провалилась целиком и
повтора нет. Уровень задаёт не текст ошибки, а **наличие штатного
повтора**.
## Два цикла повтора — не путать
Слово «ретрай» означает два разных механизма, и уровень считается по
каждому отдельно:
- **Повтор вызова внутри одной операции** (ретраи HTTP-клиента) — по нему
выбирается уровень **`ext`-записи**: `WARN` на попытку, `ERROR` когда
попытки исчерпаны.
- **Повтор тика внешним циклом** (поллинг, сверка) — по нему выбирается
уровень **доменной записи** об исходе тика: `WARN`, потому что следующий
тик повторит.
Из этого следует, что у лежащей зависимости `ext`-запись пишет `ERROR`
каждый тик. Это и есть механизм эскалации: доменный слой не паникует, а
телеметрия зависимости честно показывает, что она недоступна. Если поток
`ERROR` от поллинга мешает — это лечится понижением частоты тика или
подавлением повторов в самом клиенте, а не переклассификацией уровня.
## Внешние сервисы: логируем все вызовы
**Каждый** вызов внешнего сервиса логируется — это единственный способ
отличить «у нас баг» от «зависимость легла». Поля: `ext.service`,
`ext.operation` (логическая операция, не URL), `ext.status_code`,
`duration_ms`, `retry`.
Уровни:
- `INFO` — успешный **событийный** вызов;
- `DEBUG` — успешный **рутинно-частый** вызов (поллинг, авто-рефреш);
- `WARN` — попытка не удалась, делаем retry;
- `ERROR` — ретраи исчерпаны, сервис недоступен.
Завершённый HTTP-ответ с 4xx — это **успех на транспортном уровне**
(`ext.status_code` записан); решение «это ошибка» принимает доменный
вызывающий. Тело запроса и ответа — только на `DEBUG` и после вычистки
секретов.
## HTTP и healthcheck
- Входящие запросы логируем с `http.*` и `duration_ms` на **`INFO`**: это
аудит обращений, а не отладка. Уровень не понижается из-за кода ответа —
4xx остаётся `INFO`-записью доступа; решение «это ошибка» принимает
доменный слой и пишет свою запись.
- Для корреляции запроса допустим `request_id` — это отдельный слой от
корреляции по сущности и не противоречит отказу от `trace_id`.
- **Healthcheck, liveness, readiness — `DEBUG`.** Их дёргают периодически,
на `INFO` они забивают аудит; в проде с базовым `INFO` они не пишутся.
## Безопасность: что не логируем
Никаких секретов в полях и сообщениях: пароли и cookie сессий, API-ключи и
токены, `Authorization`-заголовки, аутентификационные параметры в ссылках.
- Тела ответов внешних API и сырой вывод LLM (недоверенный, может быть
большим) — только на `DEBUG`, с вычисткой и обрезкой по длине.
- При сомнении — не логируем значение, логируем факт его наличия
(`"has_api_key", true`).
- **Ошибка HTTP-транспорта несёт URL — потенциальный носитель секрета.**
`*url.Error` встраивает полный URL запроса, а секрет может жить прямо в
нём: токен в пути, `api_key` в query. Go редактирует только пароль из
userinfo, остального не трогает. Санитизируем на границе клиента **до**
лога и обёртки: разворачиваем `*url.Error` в первопричину. Цена —
теряется `Op` и сам факт «это был HTTP-транспорт» (`errors.Is` на причину
сохраняется); альтернатива с редактированием URL сохранила бы структуру,
но сложнее. Порядок важен: санитизация идёт **раньше** трансляции ошибки
в доменную (`lang/go/errors.md`), иначе секрет уедет в обёртку.
- Общее правило: **секрет не кладём в URL, если у API есть заголовок**
тогда его нет и в ошибке транспорта.
<!-- local:секреты -->
<!-- /local -->
## Куда пишем
- JSON в `stdout` одним потоком; сбор и ротацию делает окружение (docker,
journald). По файлам не маршрутизируем.
- Базовый уровень в проде — `INFO`, `DEBUG` включается конфигом. dev —
`DEBUG`.
## Анализ
- Повседневно — `jq`: `jq 'select(.download_id=="a1b2")' app.jsonl`.
- Тяжёлое (агрегации, JOIN) — DuckDB поверх JSONL прямо из файла.
<!-- local:механизировано -->
<!-- /local -->
+71
View File
@@ -0,0 +1,71 @@
---
status: рекомендуемая
extends: arch/time.md
---
# Время: реализация на Go
## Единая точка
- «Сейчас» берём у слоя хранилища — `store.Now()`, а не `time.Now()` по
коду. Ценность точки — **гарантированный UTC и один формат**: `Now()`
возвращает `time.Now().UTC()`, и ни одна ветка кода не может об этом
забыть. Побочно это единственное место, которое придётся превратить в
переменную или поле, если однажды понадобится подменять часы в тестах, —
но само по себе оно тестируемости не даёт.
- Форматирование и разбор — `store.FormatTime` / `store.ParseTime` поверх
`time.RFC3339`.
- Запрет прямого `time.Now()` механизируется `forbidigo`. Исключений
ровно два, и оба обязаны быть прописаны, иначе конвенция противоречит
сама себе: сама точка `Now()` и обёртка измерения длительности (ниже).
## Точность и разбор
- В БД — **секундная точность**, ширина 20 символов
(`2026-06-28T11:23:45Z`). Она получается сама: layout `time.RFC3339` не
содержит долей секунды, поэтому `Format` их не выведет.
- `time.RFC3339Nano` не используем: он отбрасывает хвостовые нули и ломает
фиксированную ширину.
- `time.Parse(time.RFC3339, …)` принимает и доли, и не-`Z` офсеты, то есть
канонический вид гарантирует **писатель**, а не читатель. Для одного
писателя этого достаточно; чужой вход нормализуем явно.
- В драйвер отдаём строку из `FormatTime`, а не `time.Time`: колонка —
`TEXT`, и промежуточное преобразование драйвером нам не нужно.
## Логи
`slog` по умолчанию **не даёт UTC**: встроенные хендлеры пишут время в зоне
самого `time.Time`, то есть в локальной зоне процесса, — на ноутбуке
разработчика логи молча поедут в `+03:00`. UTC ставится `ReplaceAttr` по
`slog.TimeKey`:
```go
func utcTime(_ []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
a.Value = slog.TimeValue(a.Value.Time().UTC())
}
return a
}
```
`JSONHandler` пишет миллисекунды — три знака, фиксированная ширина. Это
другая точность, чем в БД, и это нормально: ширина фиксируется на носитель
(см. базу).
## Длительность
Обёртка измерения — **легитимное исключение из запрета `time.Now()`**, и
без него не обойтись: `store.Now()` приводит время к UTC через `.UTC()`, а
это **срезает монотонную составляющую** `time.Time`. Интервал, посчитанный
по таким меткам, зависит от подводки часов. Поэтому обёртка берёт
`time.Now()` напрямую и считает `time.Since`с локальным `//nolint`.
## Зоны
`time/tzdata` импортируется в `main`, зона отображения валидируется
загрузчиком конфига — см. `lang/go/config.md`. Применяется она только в
шаблонах и форматтерах UI; календарные вычисления бизнес-логики берут зону
явно, как описано в базе.
<!-- local:механизировано -->
<!-- /local -->
+68
View File
@@ -0,0 +1,68 @@
---
status: рекомендуемая
extends: arch/app-directories.md
---
# Категории директорий: реализация в Ansible
Как `arch/app-directories.md` раскладывается на сервере плейбуком.
## Переменные и создание
- Директория объявляется переменной плейбука внутри `base_dir`, имя
переменной оканчивается на `_dir`. Для случая «одна директория на
категорию» это `config_dir`, `data_dir`, `cache_dir`; когда категория
состоит из нескольких, имя даётся по содержимому (`media_dir`,
`uploads_dir`, `dumps_dir`), а категория читается из списка бэкапа.
- Директории создаются **одной задачей циклом по списку**: список и есть
декларация того, что приложение пишет на диск. Разнесение по нескольким
задачам прячет эту декларацию.
- Владелец — пользователь, от имени которого работает приложение. Модель
выбирается на репозиторий: выделенный пользователь на приложение
(`app_owner_uid == app_owner_gid`) или общий `primary_user`. Какая модель
принята — фиксируется ниже.
<!-- local:модель-владельца -->
<!-- /local -->
## Список бэкапа
Плейбук кладёт в `base_dir` файл `backup-targets` — его читает оркестратор
бэкапов. Строки списка собираются из **тех же** переменных `*_dir`, что и
задача создания директорий: тогда переименование или перенос директории не
может разойтись с бэкапом.
В список идут директории категории «данные», включая директорию дампов, и
не идут конфигурация и кеш.
## Монтирование в контейнер
- Конфигурация — `:ro`, где приложение это позволяет. Приложение, которое
переписывает свой конфиг, монтируется на запись — это отступление, и оно
записывается.
- Данные и кеш — на запись.
- `docker-compose.yml` остаётся в корне `base_dir`: туда смотрит
`project_src` модуля `docker_compose_v2`.
## Секреты
Секреты приходят из vault-переменных и рендерятся шаблоном. Два способа, в
порядке предпочтения:
1. **В файл конфигурации** (роль `secrets`) — предпочтительный: секрет
лежит под `0600` у пользователя приложения, не наследуется дочерними
процессами и не виден в `docker inspect`.
2. **В `environment:` compose-файла** — когда приложение не умеет читать
секреты из файла. Задача рендера идёт с `no_log: true`.
Второй способ — вынужденный: он кладёт секрет в метаданные контейнера и в
файл compose на диске. Приложение, умеющее файловые секреты, переводится на
первый способ при ближайшем касании.
<!-- local:отступления -->
<!-- /local -->
## Связано
<!-- local:связано -->
<!-- /local -->
+211
View File
@@ -0,0 +1,211 @@
---
status: рекомендуемая
---
# Веб-UI на htmx
Как пишется код веб-UI: частичный своп фрагментов, поллинг живых
обновлений, обработчики действий, деградация без JS, ошибки. Что именно UI
показывает и какие действия обязан поддерживать — в спеках, не здесь.
Логирование запросов — `lang/go/logging.md` (HTTP-поля, рутинно-частое на
`DEBUG`). Трансляция доменных ошибок наружу — `lang/go/errors.md`
(приватный канал = логи, публичный = сообщение плюс корреляционный ключ).
Здесь — только специфика htmx-транспорта, без дублирования.
Утверждения о поведении htmx относятся к **2.x**: дефолты обработки
ответов между мажорами менялись.
## Стек и границы
htmx-first: роутер + серверные шаблоны + htmx. **Без шага сборки, без Node
и бандлера, без реактивных фреймворков.** htmx вендорится и самохостится,
без CDN.
- Свой JS сведён к минимуму: только то, что серверу знать не нужно
(например, копирование в буфер обмена). **Клиентского пересчёта доменного
состояния нет** — состояние считает сервер, клиент свопит присланную
разметку.
- Реактивный слой (Alpine.js и подобное) не вводим до появления виджета,
которому он действительно нужен, и вводим отдельным решением, а не
попутно.
## Единый источник разметки: партиал = страница = фрагмент
Переиспользуемый кусок — это именованный шаблон в `partials/`. Тот же
шаблон рендерится **и** инлайн на странице, **и** как ответ-фрагмент того
же обработчика. Отдельной разметки для фрагмента не заводим — иначе она
дрейфует от страницы.
**Инвариант: корень шаблона — элемент с целевым `id`.**
`hx-swap="outerHTML"` заменяет весь корневой узел; если ответный фрагмент не
несёт тот же корневой `id`, следующее действие или поллер не найдёт таргет.
Разметку и `id` держим в одном партиале.
Сборку view выносим в переиспользуемую функцию и зовём её и на полной
странице, и во фрагменте — чтобы htmx-ветка не копипастила сборку.
## Обработчик действия: ветвление htmx / редирект
htmx-запрос определяем по заголовку `HX-Request: true`. Обработчик зовёт
доменную операцию **одинаково** в обеих ветках и ветвится только после:
```go
actionErr := fn(r.Context(), id) // доменный вызов идентичен для htmx и не-htmx
if !isHTMX(r) {
redirect(w, r, id, msg) // без htmx — обычный PRG-редирект (303)
return
}
data, _ := s.deps.Read(r.Context(), id) // перечитать актуальное состояние
view := buildView(id, data, "") // тем же view-builder'ом
if actionErr != nil {
view.BlockError = userErr(r, actionErr, id)
}
s.render(w, "source_block", view) // фрагмент = тот же шаблон
```
`render` собирает именованный шаблон **в буфер** и только затем пишет
ответ — при ошибке шаблона клиент не получит «полустраницу».
## Одно действие — два региона: `hx-swap-oob`
Когда действие меняет не только свой регион (сменился выбор — обновилась и
панель действий), второй регион едет **тем же ответом** через
`hx-swap-oob="true"`. Оба фрагмента — обычные именованные партиалы с теми
же `id`, что и на странице; отдельной разметки под oob не заводим по тому
же правилу, что и для основного свопа.
Альтернатива — второй запрос с клиента — вводит гонку между двумя ответами
и лишний раунд-трип; `HX-Trigger` с последующим `hx-get` уместен только
если второй регион обновляется реже, чем происходит действие.
## Graceful degradation
Формы действий остаются обычными `<form method="post" action="…">`;
`hx-post`/`hx-target`/`hx-swap` лишь **накладываются сверху** на ту же
форму. Без JS действие работает через POST и редирект. `action` формы —
рабочий фолбэк, а не декорация.
Фильтр, поиск и пагинация списка — **серверные**, через GET-параметры, тоже
без JS. Клиентской фильтрации нет намеренно.
Требование распространяется на **действия и навигацию**. Интерактивный
виджет выбора, у которого нет осмысленного не-JS поведения, может требовать
JS — но это отступление, и оно записывается, а не подразумевается.
## Ошибки на htmx-пути: HTTP 200 плюс фрагмент
В htmx 2.x ответы 4xx/5xx по умолчанию **не свопят DOM**. Это настраивается
(`htmx.config.responseHandling`, расширение `response-targets`, слушатель
`htmx:responseError`), но любая настройка — это свой JS-конфиг на клиенте,
что противоречит разделу «Стек и границы». Поэтому сознательно берём
**200 с фрагментом**, несущим сообщение, и доменную ошибку на htmx-пути
**не** транслируем в HTTP-статус — в отличие от REST API и no-JS редиректа
с `?err=`.
- Сообщение — нейтральный текст публичного канала; сырой `err.Error()`
наружу не идёт.
- Ошибку кладём в **отдельное поле** под ошибку действия, не перегружая
доменные поля: у них может быть своё непустое значение, которое сообщение
перекроет.
- **При ошибке активное состояние не меняем** — перечитанный view
показывает прежний выбор плюс сообщение.
- Цена: в логе доступа провалившееся действие выглядит как `200`. Искать
его надо по доменной записи об исходе операции (`lang/go/logging.md`), а
не по коду ответа.
## Живой поллинг
Фрагмент-эндпоинт под `/fragments/…` плюс в разметке `hx-get`,
`hx-trigger="every Ns"`, `hx-swap="outerHTML"`:
```html
{{define "progress"}}<div id="item-live-{{.ID}}"
{{if .Active}} hx-get="/fragments/items/{{.ID}}/progress"
hx-trigger="every 3s" hx-swap="outerHTML"{{end}}>
...
</div>{{end}}
```
- **Поллер самозавершается.** Когда состояние выходит из «живого»,
фрагмент возвращается **без `hx-*`** — htmx больше не опрашивает. Условие
живости ведёт собственное состояние приложения, а не внешний сервис.
(Встроенная альтернатива — ответ со статусом 286 — не используется: она
не совместима с инвариантом «партиал = страница», свежезагруженная
страница тоже должна рендериться без поллера.)
- **`outerHTML`-своп всего фрагмента** удаляет старый узел вместе с его
поллером и инициализирует новый — двойного опроса нет **при условии
совпадения корневого `id`**.
- **Поллер не свопит контейнер с активными полями ввода.** Своп поддерева
теряет фокус, выделение и незасабмиченный текст внутри него: живость
включается только в состояниях, где редактировать нечего.
- **Инвариант: браузер не опрашивает внешний сервис напрямую** — только
свой сервер.
- Если тик **проксирует состояние внешнего сервиса**, данные берутся из
in-memory снимка, обновляемого воркером, без сети на каждый тик; контракт
снимка узкий и не зависит от способа доставки (путь к SSE остаётся
изолированным). Тик, показывающий **собственное** состояние приложения,
читает своё хранилище — это нормально и снимка не требует.
### Поллинг полной страницы
Когда живой фрагмент — это почти вся страница, отдельный
`/fragments/…`-роут дублировал бы обработчик. Тогда допустимо опрашивать
сам URL страницы и вырезать нужный узел на клиенте:
```html
hx-get="/item/{{.ID}}" hx-trigger="every 3s"
hx-select="#item-main" hx-swap="outerHTML"
```
Инвариант корневого `id` действует и здесь: `hx-select` должен выбирать тот
же узел, который свопится.
## Своп сохраняет контекст; выход — навигация
`hx-swap="outerHTML"` не сбрасывает прокрутку и не трогает серверные
фильтр, поиск и пагинацию (они в query). Внутри свопаемого поддерева
контекст **не** сохраняется — фокус, выделение и введённый текст теряются
(см. правило про поллер выше).
Действие **не должно уводить** пользователя со страницы, если предмет
остаётся на ней — своп на месте. Действие, после которого предмет
**покидает** страницу, остаётся обычной POST-формой **без `hx-*`** → полная
навигация. Маркер «это выход» — форма без htmx-атрибутов; так не нужен
`HX-Redirect`, а «уйти с экрана» выражено самой навигацией.
**Асинхронные действия.** Если доменное действие асинхронно (переводит в
промежуточное состояние, работу доделывает воркер), своп отдаёт
**промежуточное** состояние, а не мнимый результат; итог догоняет
самозавершающийся поллер. Не обещаем в UI мгновенный итог async-операции.
## Различение поверхности одного действия
Если один роут зовут с разных страниц и своп-ответ должен быть разным
фрагментом, различаем **явным скрытым полем формы** (`surface=list|detail`),
а не эвристикой по `HX-Target` или `Referer`: поле самодокументируемо и не
зависит от резолва таргета.
## Статика, вендоринг, кэш
Раздел не про htmx — это упаковка любого server-rendered приложения;
разъедется в языковой слой, когда понадобится там.
- Ассеты встроены в бинарь (`go:embed`) и отдаются с длинным иммутабельным
кэшем (`Cache-Control: public, max-age=31536000, immutable`).
- Меняемые ассеты (css/js) версионируются через `?v=<hash>` — короткий
sha256 содержимого; URL строит хелпер шаблона. Свежий деплой не отдаёт
устаревший файл.
- Вендор адресуется по **неизменному имени файла** и в `?v=` не нуждается.
В git его не коммитим: идемпотентная задача добывает его по манифесту
(`путь url sha256`) с проверкой контрольной суммы, и сборка от неё
зависит.
- Шрифты и скрипты — **self-hosted**, без внешних хостов: бинарь
самодостаточен, внешних ресурсов времени выполнения нет.
<!-- local:эталоны -->
<!-- /local -->
<!-- local:отступления -->
<!-- /local -->