From 4a59c717375d37b747d5269a7d410eda17fcf8d7 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sat, 25 Jul 2026 18:18:18 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B7=D0=B0=D0=B2=D0=B5=D0=B4=D1=91=D0=BD=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=BD=D0=BE=D0=BD=20=D0=BE=D0=B1=D1=89=D0=B8=D1=85?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BD=D0=B2=D0=B5=D0=BD=D1=86=D0=B8=D0=B9=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=BB=D0=B8=D1=87=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 13 конвенций по осям arch / lang / stack / common; репозитории берут оттуда копии в свой docs/conventions/ и коммитят их у себя - conv — синхронизация копий: add / status / diff / pull / push, локальные регионы исключены из сравнения, поэтому расхождение не даёт шума --- README.md | 185 +++++++++++ arch/app-directories.md | 97 ++++++ arch/config.md | 122 ++++++++ arch/db-identifiers.md | 86 ++++++ arch/time.md | 74 +++++ common/conventions-guide.md | 135 ++++++++ conv | 515 +++++++++++++++++++++++++++++++ lang/go/config.md | 89 ++++++ lang/go/db-identifiers.md | 50 +++ lang/go/db-schema.md | 57 ++++ lang/go/errors.md | 140 +++++++++ lang/go/logging.md | 242 +++++++++++++++ lang/go/time.md | 71 +++++ stack/ansible/app-directories.md | 68 ++++ stack/htmx/web-ui.md | 211 +++++++++++++ 15 files changed, 2142 insertions(+) create mode 100644 README.md create mode 100644 arch/app-directories.md create mode 100644 arch/config.md create mode 100644 arch/db-identifiers.md create mode 100644 arch/time.md create mode 100644 common/conventions-guide.md create mode 100755 conv create mode 100644 lang/go/config.md create mode 100644 lang/go/db-identifiers.md create mode 100644 lang/go/db-schema.md create mode 100644 lang/go/errors.md create mode 100644 lang/go/logging.md create mode 100644 lang/go/time.md create mode 100644 stack/ansible/app-directories.md create mode 100644 stack/htmx/web-ui.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..b85551d --- /dev/null +++ b/README.md @@ -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 + +`AUTOINCREMENT` в новых миграциях — `internal/archrules`. + +``` + +Имя обязательно — перенос при `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`. Репозиторное пишется только внутрь +> ``. Правка вне регионов — либо `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 канона отвечает на «почему база + сформулирована так». diff --git a/arch/app-directories.md b/arch/app-directories.md new file mode 100644 index 0000000..3872399 --- /dev/null +++ b/arch/app-directories.md @@ -0,0 +1,97 @@ +--- +status: рекомендуемая +--- + +# Категории директорий приложения + +Всё, что приложение пишет на диск, делится на три категории по принципу +создания и ценности содержимого: + +- **конфигурация** — то, что восстанавливается прогоном деплоя, в том числе + секреты; +- **данные** — то, что генерирует приложение и что нужно бэкапить; +- **кеш** — то, что генерирует приложение и что не нужно бэкапить: + приложение перегенерирует заново. + +Цель — упростить оперирование данными. Категория сразу отвечает на два +вопроса, которые иначе приходится выяснять по коду приложения: **кто +создаёт** содержимое и **что будет, если его потерять**. + +## Категории + +| Категория | Директория | Создаёт | Потеря содержимого | Бэкап | +| --- | --- | --- | --- | --- | +| Конфигурация | `config/` | деплой | восстанавливается прогоном | не нужен | +| Данные | `data/` | приложение | невосполнима | обязателен | +| Кеш | `cache/` | приложение | приложение перегенерирует | не нужен | + +Имена в таблице — умолчание для случая «одна директория на категорию». +**Категория может состоять из нескольких директорий**, и это нормально: +крупные файлы отделяют от базы, чтобы двигать их между дисками независимо +(`media/`, `uploads/` — та же категория «данные», что и `data/`). +Принадлежность к категории задаётся не именем, а участием в списке бэкапа. + +Тест на границе данных и кеша: что будет, если сделать `rm -rf` и поднять +приложение заново. Поднимется само и наверстает — кеш. Не поднимется или +поднимется пустым — данные. + +Конфигурацию бэкапить не только не нужно, но и не стоит: там лежат секреты, +а бэкапы уезжают в облако. Источник истины для конфигурации — репозиторий и +хранилище секретов, а не снапшот бэкапа. + +## Данные, которые нельзя копировать на живую + +Файловый снапшот работающей СУБД не гарантирует консистентности: +скопированный каталог может не восстановиться. Поэтому у категории «данные» +есть два способа попасть в бэкап: + +- **копированием** — если файлы самодостаточны на любой момент времени; +- **дампом** — если консистентность обеспечивает только сама СУБД. Тогда + бэкапится директория дампов, а сырой каталог базы — нет. + +Директория дампов — тоже данные, просто производные. Решение «копировать +или дампить» принимается **при заведении приложения**, а не при первой +неудачной попытке восстановления. + +## Контракт с приложением + +Категории — не только про деплой. Приложение **разводит свои записываемые +пути по категориям в конфигурации**, а не складывает всё в один каталог: +иначе категорию нельзя определить снаружи и список бэкапа приходится +составлять вручную, читая код. + +- Путь к БД, загруженным файлам, сгенерированным артефактам — данные. +- Миниатюры, распакованные ассеты, кеш внешних ответов, индексы, которые + перестраиваются, — кеш. Даже если их дорого перестраивать: дорого ≠ + невосполнимо. +- Приложение не пишет в директорию конфигурации: она может быть доступна + только на чтение. + +Если приложение не умеет разделять, это его дефект, а не повод смешивать +категории в раскладке. + +## Список бэкапа выводится, а не составляется + +Список бэкапа получается из категорий по правилу: туда идут данные, не идут +конфигурация и кеш. Правило механическое — но его применяет человек или +шаблон, поэтому список обязан ссылаться на **те же** переменные путей, что +и создание директорий. Независимо набранный список — источник расхождения +между тем, что бэкапится, и тем, что нужно. + +## Область действия + +Раскладка меняется вместе с миграцией данных, поэтому конвенция применяется +к **новым приложениям**; существующие переезжают по мере касания, отдельной +кампанией не переписываются. Разделять данные и кеш задним числом имеет +смысл тогда, когда кеш заметен по объёму в бэкапе, а не ради самой схемы. + + + + +## Связано + + + + + + diff --git a/arch/config.md b/arch/config.md new file mode 100644 index 0000000..5a02998 --- /dev/null +++ b/arch/config.md @@ -0,0 +1,122 @@ +--- +status: рекомендуемая +--- + +# Конфигурация приложения + +Как устроена конфигурация: где лежит, как попадает в процесс, что с +секретами и когда падает. + +## Файл, а не окружение + +**Конфигурация — файл.** Причины, по убыванию веса: + +- **Один типизированный источник.** Файл несёт секции, комментарии, + единицы измерения и валидируется целиком. Окружение — плоский набор + нетипизированных строк, который приходится документировать отдельно; + появление второго канала конфигурации гарантирует расхождение между ними. +- **Окружение наследуется дочерними процессами.** Всё, что приложение + запускает — конвертер, `git`, шелл-хук, — по умолчанию получает копию + секретов, хотя они ему не нужны. +- **В контейнере окружение расползается по лишним поверхностям.** + `docker inspect` показывает его любому, у кого есть доступ к сокету + докера; переменные оседают в compose-файле и `.env` на диске — то есть + файл всё равно появляется, только без структуры и валидации. + +Обратите внимание, чего в списке **нет**: `/proc//environ` не является +аргументом — он имеет права `0400` и защищён проверкой `PTRACE_MODE_READ`, +то есть доступен ровно тому же кругу, что и файл под `0600`. + +Запрет держится на «один источник» и на том, что все приложения свои. Для +стороннего образа, живущего на env, конвенция неприменима — это не повод +отказываться от неё для своих. + +Практика: + +- Формат — текстовый, с комментариями и секциями (TOML, YAML — по стеку). +- Имя по умолчанию фиксировано и ищется в рабочей директории процесса; + путь переопределяется опцией командной строки. +- Реальный конфиг не коммитится. В репозитории лежит **образец**. + +## Грузим один раз, дальше не перечитываем + +- Разбор — **один раз при старте**, в одну типизированную структуру. + Дальше по коду читаем только её: чтения файла в бизнес-коде нет. +- Конфиг **неизменяем** после старта; смена параметров — рестарт процесса. + Горячая перезагрузка — отдельное решение с отдельным обоснованием, а не + умолчание. +- Умолчания задаются в коде, файл их перекрывает. Образец при этом + перечисляет **все** поля, включая те, у которых есть умолчание: поле, + живущее только в коде, для читателя конфига не существует. + +## Образец самодокументируем + +Образец коммитим как единый справочник по конфигу: все секции и все поля. +**Каждое поле снабжаем комментарием**, из которого ясно: + +- **зачем** поле — что оно меняет в поведении; +- **диапазон или допустимые значения** — перечисление либо границы; +- **единицы измерения**, если применимо: секунды/миллисекунды, байты, доля + `0–1`. + +Так конфиг читается без открывания кода — этим он и полезен. + +## Поля по дискриминатору `type` + +Когда набор полей секции зависит от поля-дискриминатора (выбор одного из +бекендов или внешних сервисов), обязательность полей определяется его +значением, а не фиксирована для секции. + +- **Валидация — по значению `type`**: для каждого поддерживаемого варианта + свой набор обязательных полей; поля других вариантов не требуются. + Неизвестное значение → ошибка на старте с перечислением поддерживаемых. +- **Образец — по значению `type`**: основной вариант предзаполнен рабочими + значениями, альтернативные — блоками-комментариями ниже, каждый со своим + описанием полей. Из примера видны все варианты, не открывая код. + +## Секреты приносит деплой + +Секреты доставляет **деплой**, рендеря их прямо в конфиг. Отдельного слоя +секретов в приложении нет — оно просто читает файл. Источник истины +секрета — внешнее хранилище деплоя, не репозиторий и не окружение. + +- Рендеренный конфиг не коммитится; права `0600`, владелец — runtime- + пользователь. +- В образце секретные поля — пустые строки. +- Загрузчик на старте проверяет, что обязательные секреты не пусты: это + ловит криво отрендеренный шаблон до того, как он превратится в 401 от + внешнего API через час работы. +- В логи секреты не попадают. + + + + +## Валидация и fail-fast + +Конфиг валидируем **на старте, до приёма трафика**. Невалидный конфиг — +запись уровня `ERROR` и выход с ненулевым кодом: не стартуем «наполовину». + +Проверяем как минимум: + +- обязательные поля заданы, обязательные секреты не пусты; +- пути существуют и доступны на запись/чтение по назначению; +- числовые диапазоны и единицы (доли, таймауты, счётчики попыток); +- строки, которые парсятся во что-то (длительности, зоны, URL), реально + парсятся; +- включённые секции консистентны: если интеграция включена — заданы все её + обязательные поля. + +Проблемы собираем и показываем **разом**, а не по одной за запуск. + + + + +## Связано + +- `arch/time.md` — формат времени; зона отображения — единственный + конфигурируемый параметр времени, семантика описана там. +- `arch/app-directories.md` — конфиг лежит в категории «конфигурация» и + доступен приложению только на чтение. + + + diff --git a/arch/db-identifiers.md b/arch/db-identifiers.md new file mode 100644 index 0000000..8d4062c --- /dev/null +++ b/arch/db-identifiers.md @@ -0,0 +1,86 @@ +--- +status: рекомендуемая +--- + +# Идентификаторы сущностей + +Как выбираются и как выглядят первичные ключи. Схема БД меняется тяжело, +поэтому конвенция применяется **к новым таблицам**; существующие живут как +есть и перечислены в отступлениях. + +## Условие применимости + +Вопрос задаётся **один раз на репозиторий**, а не по таблицам: + +> Есть ли в приложении хотя бы одна сущность, которую адресуют извне — по +> id из URL, запроса API или callback-данных? +> +> - **Да** → весь репозиторий на сортируемый строковый id, который +> генерирует приложение (ULID), включая внутренние таблицы. +> - **Ни одной** → автоинкремент, и этого достаточно. + +Критерий — именно **адресация**: снаружи по этому id возвращаются к +системе. Не «id виден в логе» — туда рано или поздно попадает любой +идентификатор, и по такому критерию вторая ветка была бы недостижима. + +Почему квантор репозиторный, а не потабличный: внутренние сущности имеют +привычку становиться внешними, и тогда целочисленный id утекает в URL +задним числом. Плюс одна ментальная модель дешевле, чем спор при заведении +каждой таблицы. + +**Что не является смешиванием.** Запрет касается двух видов +*сгенерированных суррогатных* ключей в одной базе. Естественные и составные +ключи у таблиц-деталей — третья категория, они допустимы всегда (см. ниже). + + + + +## Если 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 там — +мёртвый вес, который ещё и создаёт второй способ адресовать ту же строку. + +Прочие генерируемые идентификаторы (батчи, задания, корреляционные ключи) — +через ту же единую точку: единый формат, сортируемость, корреляция в логах. + + + + +## Связано + +- `arch/time.md` — метки времени тоже генерирует приложение, а не схема. + + + diff --git a/arch/time.md b/arch/time.md new file mode 100644 index 0000000..76f1853 --- /dev/null +++ b/arch/time.md @@ -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`. На хранение, сортировку и логи она не влияет. + +Если бизнес-логика оперирует календарными сущностями («сегодня», +«за месяц»), зона указывается **явно** в месте вычисления — молчаливое +использование системной зоны процесса запрещено: она разная на ноутбуке и в +контейнере. По умолчанию это та же зона, что и для отображения; если +календарная логика требует другой, это записывается явно. + +Конвенция описывает фиксацию **свершившихся моментов**. Планирование +будущих событий — отдельный случай (там хранят локальное время плюс имя +зоны, потому что правила зон меняются); пока такой сущности нет, правило не +формулируем. + + + + + + + +## Связано + +- `arch/config.md` — где задаётся зона отображения. +- `arch/db-identifiers.md` — то же правило «генерирует приложение» для id. + + + diff --git a/common/conventions-guide.md b/common/conventions-guide.md new file mode 100644 index 0000000..8198b99 --- /dev/null +++ b/common/conventions-guide.md @@ -0,0 +1,135 @@ +--- +status: обязательная +--- + +# Как мы ведём конвенции + +Конвенция описывает повторяющийся выбор: как называть директории, как +раскладывать данные, как оформлять ошибки. Она отвечает на вопрос «как +принято», а не «что здесь происходит». Одна конвенция — один файл. + +Механической проверки у самой этой конвенции нет — осознанное исключение: +проверять «правильно ли написана конвенция» нечем, а обязательный статус +нужен, чтобы правила ниже не обсуждались заново в каждом репозитории. + +## Канон и копии + +Файлы в этой директории с шапкой `origin:` — **копии из общего канона** +`dev-conventions`, а не собственные документы репозитория. Отсюда: + +- репозиторное пишется **только внутрь локальных регионов** + ``: они исключены из сравнения с + каноном, и расхождение по ним — норма, а не дрейф; +- правка вне регионов означает одно из двух: улучшение, которое надо + вернуть в канон, или сознательное расхождение, записанное в ключ `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`): сама по себе конвенция агенту не видна, он + дойдёт до неё, только если его туда отправили. Детали остаются здесь, + в файл-точку-входа едет одна строка на правило. + + + diff --git a/conv b/conv new file mode 100755 index 0000000..971d88a --- /dev/null +++ b/conv @@ -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) — часть документа: они сравниваются +наравне с телом и приезжают из канона. + +Локальные регионы — куски, которые по определению принадлежат репозиторию +(механизация, отступления, «здесь решили так»). Из сравнения исключаются: + + + ... + + +Имя региона обязательно: перенос при 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 (по умолчанию — текущая директория) +и --dir (по умолчанию 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"^(.*?)^", + re.DOTALL | re.MULTILINE, +) +OPEN_RE = re.compile(r"^" if raw else "" + return f"{head}\n" + + 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"" if raw else "" + if raw in values: + used.add(raw) + return f"{head}{values[raw]}" + 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()) diff --git a/lang/go/config.md b/lang/go/config.md new file mode 100644 index 0000000..297a97c --- /dev/null +++ b/lang/go/config.md @@ -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-специфики нет: секреты приходят из деплоя уже в файле, проверка их +непустоты идёт вместе с остальной валидацией — см. базу. + + + + + + + +## Связано + +- `lang/go/time.md` — зона отображения и формат времени. +- `lang/go/logging.md` — `slog`, которым падает невалидный конфиг. + + + diff --git a/lang/go/db-identifiers.md b/lang/go/db-identifiers.md new file mode 100644 index 0000000..89736cd --- /dev/null +++ b/lang/go/db-identifiers.md @@ -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`. + + + + + + diff --git a/lang/go/db-schema.md b/lang/go/db-schema.md new file mode 100644 index 0000000..e077a92 --- /dev/null +++ b/lang/go/db-schema.md @@ -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`); иначе автоинкремент допустим. + + + + + + + +## Связано + +- `arch/time.md` — формат меток времени. +- `arch/db-identifiers.md` — выбор первичных ключей. +- `lang/go/errors.md` — граничные ошибки `database/sql` транслируются в + доменные у источника, в слое store. + + + diff --git a/lang/go/errors.md b/lang/go/errors.md new file mode 100644 index 0000000..87bfcba --- /dev/null +++ b/lang/go/errors.md @@ -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`. + + + + +### Транзиентный ответ 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`. + + + diff --git a/lang/go/logging.md b/lang/go/logging.md new file mode 100644 index 0000000..a971272 --- /dev/null +++ b/lang/go/logging.md @@ -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-логгер) | `_id` и доменные атрибуты | +| запись об ошибке | `error` | +| вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` | + +`service.*` и `host.*` не заводим — для одного бинаря на одном хосте это +шум. Если появятся несколько инстансов, добавим `service.version` одной +строкой при старте. + + + + +## Корреляция по id сущности + +Отдельный случайный `trace_id` не заводим, **если у сущностей есть +стабильные уникальные идентификаторы** — они и служат ключом корреляции. +(Как их выбирают — `arch/db-identifiers.md`, если конвенция взята.) + +- Каждая запись, относящаяся к сущности, несёт её id в поле `_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`), не логируя: контекст накапливается в + цепочке. +- Логируем ошибку **один раз — на границе доменного слоя**, которая + определяет исход операции. Логирует этот единый чокпоинт, а не каждый + транспорт: так транспорты остаются тонкими, и один сбой не даёт дублей. + + + + +- Транспорты переводят возвращённую ошибку в свой ответ (статус, сообщение + пользователю) и **не логируют** её повторно. +- **Уровень доменного отказа — по адресату, а не по месту.** У каждой + доменной ошибки ровно один логирующий; уровень выбирает он: + + | Класс отказа | Кому | Уровень | + |---|---|---| + | штатный конфликт состояния или некорректный ввод | пользователю, он уже получил ответ | `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 есть заголовок** — + тогда его нет и в ошибке транспорта. + + + + +## Куда пишем + +- JSON в `stdout` одним потоком; сбор и ротацию делает окружение (docker, + journald). По файлам не маршрутизируем. +- Базовый уровень в проде — `INFO`, `DEBUG` включается конфигом. dev — + `DEBUG`. + +## Анализ + +- Повседневно — `jq`: `jq 'select(.download_id=="a1b2")' app.jsonl`. +- Тяжёлое (агрегации, JOIN) — DuckDB поверх JSONL прямо из файла. + + + diff --git a/lang/go/time.md b/lang/go/time.md new file mode 100644 index 0000000..1957959 --- /dev/null +++ b/lang/go/time.md @@ -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; календарные вычисления бизнес-логики берут зону +явно, как описано в базе. + + + diff --git a/stack/ansible/app-directories.md b/stack/ansible/app-directories.md new file mode 100644 index 0000000..8f609ca --- /dev/null +++ b/stack/ansible/app-directories.md @@ -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`. Какая модель + принята — фиксируется ниже. + + + + +## Список бэкапа + +Плейбук кладёт в `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 на диске. Приложение, умеющее файловые секреты, переводится на +первый способ при ближайшем касании. + + + + +## Связано + + + diff --git a/stack/htmx/web-ui.md b/stack/htmx/web-ui.md new file mode 100644 index 0000000..6013b98 --- /dev/null +++ b/stack/htmx/web-ui.md @@ -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 + +Формы действий остаются обычными `
`; +`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"}}
+ ... +
{{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=` — короткий + sha256 содержимого; URL строит хелпер шаблона. Свежий деплой не отдаёт + устаревший файл. +- Вендор адресуется по **неизменному имени файла** и в `?v=` не нуждается. + В git его не коммитим: идемпотентная задача добывает его по манифесту + (`путь url sha256`) с проверкой контрольной суммы, и сборка от неё + зависит. +- Шрифты и скрипты — **self-hosted**, без внешних хостов: бинарь + самодостаточен, внешних ресурсов времени выполнения нет. + + + + + +