Files
dev-conventions/conventions/arch/db-identifiers.md
T
av 0fc1994db7 идентификатор новой сущности — всегда ULID
- R1 больше не ветвится по признаку внешней адресуемости: заранее отличить
  внутренние сущности, которые станут внешними, невозможно
- целочисленный ключ остаётся у существующих схем и идёт вместе с
  AUTOINCREMENT — переиспользованный rowid молча наводит протухшую ссылку
  на другую строку (db-schema R12)
- таблица R5 ограничена внешними источниками, регион «решение» убран
2026-07-25 20:15:35 +03:00

10 KiB

Идентификаторы сущностей

Как выбираются и как выглядят первичные ключи сущностей. Форма записи — LANGUAGE.md.

Область действия

Схема базы меняется тяжело: таблица не переезжает от того, что её потрогали. Поэтому правила распространяются на новые таблицы; существующие живут как есть и перечисляются в отступлениях, причём этот список постоянный, а не список задач на дочистку. Целочисленные ключи существующих приложений — именно такой случай: они не мигрируют, и правила их работы описаны в конвенции схемы, а не здесь.

Правила

R1. Первичный ключ новой сущности — ULID

ДОЛЖЕН. Новая сущность получает сортируемый строковый идентификатор, который порождает приложение, — во всех таблицах, включая те, что снаружи не адресуются.

Почему. Ветвления здесь нет намеренно, хотя напрашивается: «эту сущность снаружи не адресуют, ей хватит целого числа». Внутренние сущности имеют привычку становиться внешними — и тогда целочисленный идентификатор утекает в URL задним числом, а миграция ключа на живых данных стоит несопоставимо дороже, чем взять строковый сразу. Заранее отличить те, с кем это случится, не получается: если бы получалось, они бы уже назывались внешними.

Второй довод дешевле, но важнее в быту: одна ментальная модель избавляет от спора при заведении каждой таблицы и делает идентификатор глобальным — уникальным across таблиц, а не только внутри своей. На этом держится корреляция по логам (R7).

Правило про сгенерированные суррогатные ключи. Естественные и составные ключи у таблиц-деталей (R6) — третья категория, они допустимы всегда.

R2. Идентификатор генерирует приложение, а не база

ДОЛЖЕН. Значение ключа известно до вставки строки.

Почему. Идентификатор нужен раньше, чем база ответит: его пишут в лог начатой операции, кладут в связанные записи одной транзакции и возвращают клиенту. Генерация на стороне базы вынуждает либо ждать last_insert_rowid и достраивать связи вторым проходом, либо иметь два источника истины о моменте создания.

R3. Генерация и разбор идентификаторов — в единственной точке

ДОЛЖЕН. Один модуль порождает идентификаторы, он же их разбирает. Самодельных генераторов и парсеров в коде нет.

Почему. Нормализация регистра (R4) и проверка формата обязаны применяться ко всем идентификаторам без исключения. Любая вторая точка входа рано или поздно окажется той, где нормализацию забыли, — и дефект проявится не там, где создан.

R4. Канонический вид — нижний регистр

ДОЛЖЕН. Идентификаторы порождаются и хранятся в нижнем регистре.

Почему. Сравнение строк в базе обычно побайтовое, поэтому регистр — не косметика, а корректность поиска. Спецификация ULID канонизирует верхний регистр, и библиотеки по умолчанию отдают именно его: без единой точки (R3) разный регистр появится в базе сам собой.

R5. Внешний идентификатор разбирается до обращения к базе

ДОЛЖЕН. Значение, пришедшее снаружи, проходит разбор и нормализацию раньше, чем по нему делается запрос. Реакция на неудачный разбор зависит от источника:

Откуда пришёл Разбор не удался →
R5.1 путь или query URL «не найдено» без обращения к хранилищу
R5.2 собственная форма, данные кнопки «некорректный ввод» либо «элемент устарел»

Почему. Синтаксически невалидное значение не может соответствовать записи, поэтому поход в базу за ним — заведомо холостой; отсекая его на границе, мы дёшево снимаем целый класс мусорного трафика.

Разделение R5.1 и R5.2 нужно, потому что источники значат разное. Мусор в URL — это чужая или протухшая ссылка, и «не найдено» описывает ситуацию точно. Мусор из собственной формы — это баг интерфейса или устаревший экран; ответ «не найдено» здесь скрывает дефект и лишает диагностики единственный момент, когда он заметен.

Таблица перечисляет внешние источники — те, откуда значение приходит вместе с запросом, и правило говорит, что отдать в ответ. Идентификатор из конфигурации, из собственной базы или из фикстуры сюда не относится: он ничего не отдаёт наружу, а его невалидность означает, что сломано у нас. Формат идентификатора в конфигурации проверяется на старте (arch/config.md R18), невалидное значение в собственной базе — нарушенный инвариант единой точки (R3).

R6. У таблиц-деталей допустим естественный или составной ключ

ДОПУСКАЕТСЯ. Когда ключ есть по природе данных, отдельный сгенерированный идентификатор не заводится.

Почему. Суррогат поверх естественного ключа создаёт второй способ адресовать ту же строку — а значит, возможность рассинхрона между ними и лишний вопрос «по какому из них искать» на каждом запросе. Дополнительной информации он не несёт.

R7. Прочие генерируемые идентификаторы — через ту же точку

ДОЛЖЕН. Идентификаторы, не являющиеся первичными ключами (батч, задание, корреляционный ключ), порождаются тем же модулем (R3) и в том же формате.

Почему. Единый формат делает работающим главный побочный эффект строковых идентификаторов: grep по голому значению собирает все упоминания сущности в логах независимо от имени поля. Второй формат идентификаторов эту возможность отменяет ровно для тех записей, где она чаще всего нужна.

Почему ULID, а не UUID

R1 требует сортируемый строковый идентификатор. UUIDv4 не сортируется по времени вовсе. UUIDv7 (RFC 9562) сортируется — и против него остаются два довода: 36 символов против 26 и дефисы, из-за которых идентификатор не берётся ни двойным кликом, ни grep-ом как одно слово.

Сортировка даёт ORDER BY id = хронология с точностью до миллисекунды; внутри одной миллисекунды порядок произволен, если генератор не монотонный, — на порядок событий это не влияет.

Связано

  • arch/time.md — метки времени тоже генерирует приложение, а не схема.