заведён канон общих конвенций для личных проектов
- 13 конвенций по осям arch / lang / stack / common; репозитории берут оттуда копии в свой docs/conventions/ и коммитят их у себя - conv — синхронизация копий: add / status / diff / pull / push, локальные регионы исключены из сравнения, поэтому расхождение не даёт шума
This commit is contained in:
@@ -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 канона отвечает на «почему база
|
||||||
|
сформулирована так».
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
---
|
||||||
|
status: рекомендуемая
|
||||||
|
---
|
||||||
|
|
||||||
|
# Категории директорий приложения
|
||||||
|
|
||||||
|
Всё, что приложение пишет на диск, делится на три категории по принципу
|
||||||
|
создания и ценности содержимого:
|
||||||
|
|
||||||
|
- **конфигурация** — то, что восстанавливается прогоном деплоя, в том числе
|
||||||
|
секреты;
|
||||||
|
- **данные** — то, что генерирует приложение и что нужно бэкапить;
|
||||||
|
- **кеш** — то, что генерирует приложение и что не нужно бэкапить:
|
||||||
|
приложение перегенерирует заново.
|
||||||
|
|
||||||
|
Цель — упростить оперирование данными. Категория сразу отвечает на два
|
||||||
|
вопроса, которые иначе приходится выяснять по коду приложения: **кто
|
||||||
|
создаёт** содержимое и **что будет, если его потерять**.
|
||||||
|
|
||||||
|
## Категории
|
||||||
|
|
||||||
|
| Категория | Директория | Создаёт | Потеря содержимого | Бэкап |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| Конфигурация | `config/` | деплой | восстанавливается прогоном | не нужен |
|
||||||
|
| Данные | `data/` | приложение | невосполнима | обязателен |
|
||||||
|
| Кеш | `cache/` | приложение | приложение перегенерирует | не нужен |
|
||||||
|
|
||||||
|
Имена в таблице — умолчание для случая «одна директория на категорию».
|
||||||
|
**Категория может состоять из нескольких директорий**, и это нормально:
|
||||||
|
крупные файлы отделяют от базы, чтобы двигать их между дисками независимо
|
||||||
|
(`media/`, `uploads/` — та же категория «данные», что и `data/`).
|
||||||
|
Принадлежность к категории задаётся не именем, а участием в списке бэкапа.
|
||||||
|
|
||||||
|
Тест на границе данных и кеша: что будет, если сделать `rm -rf` и поднять
|
||||||
|
приложение заново. Поднимется само и наверстает — кеш. Не поднимется или
|
||||||
|
поднимется пустым — данные.
|
||||||
|
|
||||||
|
Конфигурацию бэкапить не только не нужно, но и не стоит: там лежат секреты,
|
||||||
|
а бэкапы уезжают в облако. Источник истины для конфигурации — репозиторий и
|
||||||
|
хранилище секретов, а не снапшот бэкапа.
|
||||||
|
|
||||||
|
## Данные, которые нельзя копировать на живую
|
||||||
|
|
||||||
|
Файловый снапшот работающей СУБД не гарантирует консистентности:
|
||||||
|
скопированный каталог может не восстановиться. Поэтому у категории «данные»
|
||||||
|
есть два способа попасть в бэкап:
|
||||||
|
|
||||||
|
- **копированием** — если файлы самодостаточны на любой момент времени;
|
||||||
|
- **дампом** — если консистентность обеспечивает только сама СУБД. Тогда
|
||||||
|
бэкапится директория дампов, а сырой каталог базы — нет.
|
||||||
|
|
||||||
|
Директория дампов — тоже данные, просто производные. Решение «копировать
|
||||||
|
или дампить» принимается **при заведении приложения**, а не при первой
|
||||||
|
неудачной попытке восстановления.
|
||||||
|
|
||||||
|
## Контракт с приложением
|
||||||
|
|
||||||
|
Категории — не только про деплой. Приложение **разводит свои записываемые
|
||||||
|
пути по категориям в конфигурации**, а не складывает всё в один каталог:
|
||||||
|
иначе категорию нельзя определить снаружи и список бэкапа приходится
|
||||||
|
составлять вручную, читая код.
|
||||||
|
|
||||||
|
- Путь к БД, загруженным файлам, сгенерированным артефактам — данные.
|
||||||
|
- Миниатюры, распакованные ассеты, кеш внешних ответов, индексы, которые
|
||||||
|
перестраиваются, — кеш. Даже если их дорого перестраивать: дорого ≠
|
||||||
|
невосполнимо.
|
||||||
|
- Приложение не пишет в директорию конфигурации: она может быть доступна
|
||||||
|
только на чтение.
|
||||||
|
|
||||||
|
Если приложение не умеет разделять, это его дефект, а не повод смешивать
|
||||||
|
категории в раскладке.
|
||||||
|
|
||||||
|
## Список бэкапа выводится, а не составляется
|
||||||
|
|
||||||
|
Список бэкапа получается из категорий по правилу: туда идут данные, не идут
|
||||||
|
конфигурация и кеш. Правило механическое — но его применяет человек или
|
||||||
|
шаблон, поэтому список обязан ссылаться на **те же** переменные путей, что
|
||||||
|
и создание директорий. Независимо набранный список — источник расхождения
|
||||||
|
между тем, что бэкапится, и тем, что нужно.
|
||||||
|
|
||||||
|
## Область действия
|
||||||
|
|
||||||
|
Раскладка меняется вместе с миграцией данных, поэтому конвенция применяется
|
||||||
|
к **новым приложениям**; существующие переезжают по мере касания, отдельной
|
||||||
|
кампанией не переписываются. Разделять данные и кеш задним числом имеет
|
||||||
|
смысл тогда, когда кеш заметен по объёму в бэкапе, а не ради самой схемы.
|
||||||
|
|
||||||
|
<!-- local:отступления -->
|
||||||
|
<!-- /local -->
|
||||||
|
|
||||||
|
## Связано
|
||||||
|
|
||||||
|
<!-- local:эталон -->
|
||||||
|
<!-- /local -->
|
||||||
|
|
||||||
|
<!-- local:связано -->
|
||||||
|
<!-- /local -->
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
---
|
||||||
|
status: рекомендуемая
|
||||||
|
---
|
||||||
|
|
||||||
|
# Конфигурация приложения
|
||||||
|
|
||||||
|
Как устроена конфигурация: где лежит, как попадает в процесс, что с
|
||||||
|
секретами и когда падает.
|
||||||
|
|
||||||
|
## Файл, а не окружение
|
||||||
|
|
||||||
|
**Конфигурация — файл.** Причины, по убыванию веса:
|
||||||
|
|
||||||
|
- **Один типизированный источник.** Файл несёт секции, комментарии,
|
||||||
|
единицы измерения и валидируется целиком. Окружение — плоский набор
|
||||||
|
нетипизированных строк, который приходится документировать отдельно;
|
||||||
|
появление второго канала конфигурации гарантирует расхождение между ними.
|
||||||
|
- **Окружение наследуется дочерними процессами.** Всё, что приложение
|
||||||
|
запускает — конвертер, `git`, шелл-хук, — по умолчанию получает копию
|
||||||
|
секретов, хотя они ему не нужны.
|
||||||
|
- **В контейнере окружение расползается по лишним поверхностям.**
|
||||||
|
`docker inspect` показывает его любому, у кого есть доступ к сокету
|
||||||
|
докера; переменные оседают в compose-файле и `.env` на диске — то есть
|
||||||
|
файл всё равно появляется, только без структуры и валидации.
|
||||||
|
|
||||||
|
Обратите внимание, чего в списке **нет**: `/proc/<pid>/environ` не является
|
||||||
|
аргументом — он имеет права `0400` и защищён проверкой `PTRACE_MODE_READ`,
|
||||||
|
то есть доступен ровно тому же кругу, что и файл под `0600`.
|
||||||
|
|
||||||
|
Запрет держится на «один источник» и на том, что все приложения свои. Для
|
||||||
|
стороннего образа, живущего на env, конвенция неприменима — это не повод
|
||||||
|
отказываться от неё для своих.
|
||||||
|
|
||||||
|
Практика:
|
||||||
|
|
||||||
|
- Формат — текстовый, с комментариями и секциями (TOML, YAML — по стеку).
|
||||||
|
- Имя по умолчанию фиксировано и ищется в рабочей директории процесса;
|
||||||
|
путь переопределяется опцией командной строки.
|
||||||
|
- Реальный конфиг не коммитится. В репозитории лежит **образец**.
|
||||||
|
|
||||||
|
## Грузим один раз, дальше не перечитываем
|
||||||
|
|
||||||
|
- Разбор — **один раз при старте**, в одну типизированную структуру.
|
||||||
|
Дальше по коду читаем только её: чтения файла в бизнес-коде нет.
|
||||||
|
- Конфиг **неизменяем** после старта; смена параметров — рестарт процесса.
|
||||||
|
Горячая перезагрузка — отдельное решение с отдельным обоснованием, а не
|
||||||
|
умолчание.
|
||||||
|
- Умолчания задаются в коде, файл их перекрывает. Образец при этом
|
||||||
|
перечисляет **все** поля, включая те, у которых есть умолчание: поле,
|
||||||
|
живущее только в коде, для читателя конфига не существует.
|
||||||
|
|
||||||
|
## Образец самодокументируем
|
||||||
|
|
||||||
|
Образец коммитим как единый справочник по конфигу: все секции и все поля.
|
||||||
|
**Каждое поле снабжаем комментарием**, из которого ясно:
|
||||||
|
|
||||||
|
- **зачем** поле — что оно меняет в поведении;
|
||||||
|
- **диапазон или допустимые значения** — перечисление либо границы;
|
||||||
|
- **единицы измерения**, если применимо: секунды/миллисекунды, байты, доля
|
||||||
|
`0–1`.
|
||||||
|
|
||||||
|
Так конфиг читается без открывания кода — этим он и полезен.
|
||||||
|
|
||||||
|
## Поля по дискриминатору `type`
|
||||||
|
|
||||||
|
Когда набор полей секции зависит от поля-дискриминатора (выбор одного из
|
||||||
|
бекендов или внешних сервисов), обязательность полей определяется его
|
||||||
|
значением, а не фиксирована для секции.
|
||||||
|
|
||||||
|
- **Валидация — по значению `type`**: для каждого поддерживаемого варианта
|
||||||
|
свой набор обязательных полей; поля других вариантов не требуются.
|
||||||
|
Неизвестное значение → ошибка на старте с перечислением поддерживаемых.
|
||||||
|
- **Образец — по значению `type`**: основной вариант предзаполнен рабочими
|
||||||
|
значениями, альтернативные — блоками-комментариями ниже, каждый со своим
|
||||||
|
описанием полей. Из примера видны все варианты, не открывая код.
|
||||||
|
|
||||||
|
## Секреты приносит деплой
|
||||||
|
|
||||||
|
Секреты доставляет **деплой**, рендеря их прямо в конфиг. Отдельного слоя
|
||||||
|
секретов в приложении нет — оно просто читает файл. Источник истины
|
||||||
|
секрета — внешнее хранилище деплоя, не репозиторий и не окружение.
|
||||||
|
|
||||||
|
- Рендеренный конфиг не коммитится; права `0600`, владелец — runtime-
|
||||||
|
пользователь.
|
||||||
|
- В образце секретные поля — пустые строки.
|
||||||
|
- Загрузчик на старте проверяет, что обязательные секреты не пусты: это
|
||||||
|
ловит криво отрендеренный шаблон до того, как он превратится в 401 от
|
||||||
|
внешнего API через час работы.
|
||||||
|
- В логи секреты не попадают.
|
||||||
|
|
||||||
|
<!-- local:секретные-поля -->
|
||||||
|
<!-- /local -->
|
||||||
|
|
||||||
|
## Валидация и fail-fast
|
||||||
|
|
||||||
|
Конфиг валидируем **на старте, до приёма трафика**. Невалидный конфиг —
|
||||||
|
запись уровня `ERROR` и выход с ненулевым кодом: не стартуем «наполовину».
|
||||||
|
|
||||||
|
Проверяем как минимум:
|
||||||
|
|
||||||
|
- обязательные поля заданы, обязательные секреты не пусты;
|
||||||
|
- пути существуют и доступны на запись/чтение по назначению;
|
||||||
|
- числовые диапазоны и единицы (доли, таймауты, счётчики попыток);
|
||||||
|
- строки, которые парсятся во что-то (длительности, зоны, URL), реально
|
||||||
|
парсятся;
|
||||||
|
- включённые секции консистентны: если интеграция включена — заданы все её
|
||||||
|
обязательные поля.
|
||||||
|
|
||||||
|
Проблемы собираем и показываем **разом**, а не по одной за запуск.
|
||||||
|
|
||||||
|
<!-- local:проверки -->
|
||||||
|
<!-- /local -->
|
||||||
|
|
||||||
|
## Связано
|
||||||
|
|
||||||
|
- `arch/time.md` — формат времени; зона отображения — единственный
|
||||||
|
конфигурируемый параметр времени, семантика описана там.
|
||||||
|
- `arch/app-directories.md` — конфиг лежит в категории «конфигурация» и
|
||||||
|
доступен приложению только на чтение.
|
||||||
|
|
||||||
|
<!-- local:связано -->
|
||||||
|
<!-- /local -->
|
||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
@@ -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())
|
||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
Reference in New Issue
Block a user