конвенции отделены от обвязки

- сами конвенции переехали в conventions/, описательное — в корень:
  LANGUAGE.md (язык записи) и GUIDE.md (как ведут конвенции)
- conv синхронизирует только conventions/, пути в origin даются
  относительно неё — раскладка копий в репозиториях не меняется
This commit is contained in:
av
2026-07-25 19:23:32 +03:00
parent 62c5645dd4
commit 0842850fae
16 changed files with 42 additions and 23 deletions
+137
View File
@@ -0,0 +1,137 @@
# Идентификаторы сущностей
Как выбираются и как выглядят первичные ключи сущностей. Форма записи —
`LANGUAGE.md`.
## Область действия
Схема базы меняется тяжело: таблица не переезжает от того, что её
потрогали. Поэтому правила распространяются на **новые таблицы**;
существующие живут как есть и перечисляются в отступлениях, причём этот
список постоянный, а не список задач на дочистку.
## Правила
### R1. Вид первичного ключа выбирается один раз на репозиторий
**ДОЛЖЕН.** Репозиторий отвечает на один вопрос и держит ответ для всех
своих таблиц:
> Есть ли в приложении хотя бы одна сущность, которую адресуют **извне** —
> по идентификатору из URL, запроса API или callback-данных?
| № | Ответ | Вид ключа |
|---|---|---|
| R1.1 | да, хотя бы одна | сортируемый строковый идентификатор, который генерирует приложение (ULID) — во **всех** таблицах, включая внутренние |
| R1.2 | ни одной | автоинкремент |
**Почему.** Квантор репозиторный, а не потабличный, по двум причинам.
Внутренние сущности имеют привычку становиться внешними — и тогда
целочисленный идентификатор утекает в URL задним числом, а миграция ключа
на живых данных стоит несопоставимо дороже, чем взять строковый сразу.
Вторая причина дешевле, но важнее в быту: одна ментальная модель избавляет
от спора при заведении каждой таблицы.
Критерий — именно **адресация**: снаружи по этому идентификатору
возвращаются к системе. Не «виден в логе»: туда рано или поздно попадает
любой идентификатор, и по такому критерию ветка R1.2 была бы недостижима.
Запрет смешивания касается двух видов **сгенерированных суррогатных**
ключей. Естественные и составные ключи у таблиц-деталей (R6) — третья
категория, они допустимы при любом ответе.
<!-- local:решение -->
<!-- /local -->
### R2. При выборе R1.1 идентификатор генерирует приложение, а не база
**ДОЛЖЕН.** Значение ключа известно до вставки строки.
**Почему.** Идентификатор нужен раньше, чем база ответит: его пишут в лог
начатой операции, кладут в связанные записи одной транзакции и возвращают
клиенту. Генерация на стороне базы вынуждает либо ждать `last_insert_rowid`
и достраивать связи вторым проходом, либо иметь два источника истины о
моменте создания.
### R3. Генерация и разбор идентификаторов — в единственной точке
**ДОЛЖЕН.** Один модуль порождает идентификаторы, он же их разбирает.
Самодельных генераторов и парсеров в коде нет.
**Почему.** Нормализация регистра (R4) и проверка формата обязаны
применяться ко всем идентификаторам без исключения. Любая вторая точка
входа рано или поздно окажется той, где нормализацию забыли, — и дефект
проявится не там, где создан.
### R4. Канонический вид — нижний регистр
**ДОЛЖЕН.** Идентификаторы порождаются и хранятся в нижнем регистре.
**Почему.** Сравнение строк в базе обычно побайтовое, поэтому регистр — не
косметика, а корректность поиска. Спецификация ULID канонизирует **верхний**
регистр, и библиотеки по умолчанию отдают именно его: без единой точки (R3)
разный регистр появится в базе сам собой.
### R5. Внешний идентификатор разбирается до обращения к базе
**ДОЛЖЕН.** Значение, пришедшее снаружи, проходит разбор и нормализацию
раньше, чем по нему делается запрос. Реакция на неудачный разбор зависит от
источника:
| № | Откуда пришёл | Разбор не удался → |
|---|---|---|
| R5.1 | путь или query URL | «не найдено» без обращения к хранилищу |
| R5.2 | собственная форма, данные кнопки | «некорректный ввод» либо «элемент устарел» |
**Почему.** Синтаксически невалидное значение не может соответствовать
записи, поэтому поход в базу за ним — заведомо холостой; отсекая его на
границе, мы дёшево снимаем целый класс мусорного трафика.
Разделение R5.1 и R5.2 нужно, потому что источники значат разное. Мусор в
URL — это чужая или протухшая ссылка, и «не найдено» описывает ситуацию
точно. Мусор из собственной формы — это баг интерфейса или устаревший
экран; ответ «не найдено» здесь скрывает дефект и лишает диагностики
единственный момент, когда он заметен.
### R6. У таблиц-деталей допустим естественный или составной ключ
**ДОПУСКАЕТСЯ.** Когда ключ есть по природе данных, отдельный
сгенерированный идентификатор не заводится.
**Почему.** Суррогат поверх естественного ключа создаёт второй способ
адресовать ту же строку — а значит, возможность рассинхрона между ними и
лишний вопрос «по какому из них искать» на каждом запросе. Дополнительной
информации он не несёт.
### R7. Прочие генерируемые идентификаторы — через ту же точку
**ДОЛЖЕН.** Идентификаторы, не являющиеся первичными ключами (батч,
задание, корреляционный ключ), порождаются тем же модулем (R3) и в том же
формате.
**Почему.** Единый формат делает работающим главный побочный эффект
строковых идентификаторов: `grep` по голому значению собирает все
упоминания сущности в логах независимо от имени поля. Второй формат
идентификаторов эту возможность отменяет ровно для тех записей, где она
чаще всего нужна.
## Почему ULID, а не UUID
Ветка R1.1 требует **сортируемый** строковый идентификатор. UUIDv4 не
сортируется по времени вовсе. UUIDv7 (RFC 9562) сортируется — и против него
остаются два довода: 36 символов против 26 и дефисы, из-за которых
идентификатор не берётся ни двойным кликом, ни `grep`-ом как одно слово.
Сортировка даёт `ORDER BY id` = хронология с точностью до миллисекунды;
внутри одной миллисекунды порядок произволен, если генератор не монотонный,
— на порядок событий это не влияет.
<!-- local:отступления -->
<!-- /local -->
## Связано
- `arch/time.md` — метки времени тоже генерирует приложение, а не схема.
<!-- local:связано -->
<!-- /local -->