# Идентификаторы сущностей Как выбираются и как выглядят первичные ключи сущностей. Форма записи — `LANGUAGE.md`. ## Область действия Схема базы меняется тяжело: таблица не переезжает от того, что её потрогали. Поэтому правила распространяются на **новые таблицы**; существующие живут как есть и перечисляются в отступлениях, причём этот список постоянный, а не список задач на дочистку. ## Правила ### R1. Вид первичного ключа выбирается один раз на репозиторий **ДОЛЖЕН.** Репозиторий отвечает на один вопрос и держит ответ для всех своих таблиц: > Есть ли в приложении хотя бы одна сущность, которую адресуют **извне** — > по идентификатору из URL, запроса API или callback-данных? | № | Ответ | Вид ключа | |---|---|---| | R1.1 | да, хотя бы одна | сортируемый строковый идентификатор, который генерирует приложение (ULID) — во **всех** таблицах, включая внутренние | | R1.2 | ни одной | автоинкремент | **Почему.** Квантор репозиторный, а не потабличный, по двум причинам. Внутренние сущности имеют привычку становиться внешними — и тогда целочисленный идентификатор утекает в URL задним числом, а миграция ключа на живых данных стоит несопоставимо дороже, чем взять строковый сразу. Вторая причина дешевле, но важнее в быту: одна ментальная модель избавляет от спора при заведении каждой таблицы. Критерий — именно **адресация**: снаружи по этому идентификатору возвращаются к системе. Не «виден в логе»: туда рано или поздно попадает любой идентификатор, и по такому критерию ветка R1.2 была бы недостижима. Запрет смешивания касается двух видов **сгенерированных суррогатных** ключей. Естественные и составные ключи у таблиц-деталей (R6) — третья категория, они допустимы при любом ответе. ### 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` = хронология с точностью до миллисекунды; внутри одной миллисекунды порядок произволен, если генератор не монотонный, — на порядок событий это не влияет. ## Связано - `arch/time.md` — метки времени тоже генерирует приложение, а не схема.