# База данных и идентификаторы Схема как таковая — в [database.md](../database.md); здесь только правила, по которым она пишется. - Первичные ключи сущностей — **TEXT ULID**, генерируется приложением (`internal/ident`). Сортируется по времени создания, удобен в логах и URL. Разбор внешнего id — `ident.Parse` на входной границе; синтаксически невалидный id — 404 без похода в БД. - Естественный ключ вместо ULID там, где он есть по природе данных: `workout` — по `id` из HealthKit, `record` — по паре `род секции + id` (форму идентификатора у пяти из шести секций живьём никто не видел, и несквозной `id` в двух секциях затёр бы одну запись другой молча). - Новая единица хранения тем же изменением входит в **отпечаток витрины** и в счётчики отчёта пересборки. Отпечаток отвечает «да/нет» за витрину целиком, и единица, которой нет в счётчиках, делает расхождение безадресным: человек видит «не совпало» при неизменившемся числе объектов и принимает по этому необратимое решение о подмене базы. - **Провенанс, входящий в отпечаток, обязан быть явной функцией журнала.** «Кто первым записал строку» — функция порядка свёртки, а он порядку журнала не равен: живой приём и пересборка разойдутся при одинаковом журнале. Там, где провенанс в отпечаток не идёт, слабое правило допустимо и должно быть названо слабым на месте — иначе его скопируют туда, где оно неверно (`bucket` против `category_value`). - **Колонка, производная от бинаря, а не от журнала, в отпечаток не входит.** Кэш чистой функции (код по словарю, справочное имя) в отпечатке превращает всякую правку бинаря в расхождение при побайтно совпавшем журнале — и человек, принимающий по отпечатку необратимое решение о подмене базы, читает это как дефект. Правильность самой производной проверяют её тесты: это другой вопрос, и смешение обесценивает оракул сходимости. - **Граница на число элементов, набираемых из чужого тела, применяется при накоплении, а не при выдаче.** Накопитель без границы растёт вместе с телом, а тело контролирует отправитель; отказ по памяти в фоновой горутине не перехватывается, и перезапуск берёт ту же доставку. Усечение при этом обязано остаться функцией множества (например, N наименьших ключей), иначе порядок элементов на проводе решает состав витрины. - Правило выбора между двумя версиями одних данных объявляется либо **функцией множества версий**, либо явно **функцией порядка журнала** — третьего состояния нет. «Побеждает последняя свёрнутая» третьим состоянием и является: порядок свёртки сам по себе порядку журнала не равен, и живая витрина расходится с пересборкой молча. Объявив правило функцией порядка журнала, изменение обязано **внести плату целиком**: привести порядок свёртки к журнальному (барьер на отложенной доставке), назвать остаточное окно и сделать его наблюдаемым, а равенство «пересборка = приём» доказать оракулом с отрицательным контролем. Так сделано для точек; у сущностей на тот же вопрос отвечает хранимая позиция журнала, и её гарантия строго сильнее — критерий выбора в `architecture.md`, «Разрешение столкновений». - Любое значение из чужого JSON, попадающее в ключ, в лог или в отчёт, имеет названный предел длины (имена непокрытых секций, `id` сущности). - **Колонка, по которой принимается необратимое решение, отличает ноль от «не измерялось».** Миграция, добавляющая такую колонку, не подставляет ноль историческим строкам: ноль означает «проверено, пусто», а не «не знаем», и подстановка выдаёт неизмеренное за измеренное — с видом измерения. Пример: `delivery.skipped_entities`, по которому ретеншен решает, можно ли удалить тело. - **Метка изменения строки меняется только при изменении содержимого.** Апдейт, трогающий одни метаданные (провенанс, ссылки), `updated_at` не двигает — иначе она становится меткой касания, и запрос «что изменилось с момента X» получает столько ложных изменений, сколько раз источник переприслал то же самое (у тренировки — двадцать шесть). - **Новая производная от разбора колонка в момент появления вносится в перечень того, что пересборка не переносит.** Перечень — единственное место, где это сказано, и следующий автор решает по нему; поле, не внесённое туда, однажды перенесут «для полноты учёта», и витрина снова станет функцией предыдущего прогона. - Временные метки — `TEXT` в RFC 3339, **UTC**, суффикс `Z`. Фиксированная ширина сохраняет лексикографическую сортировку = хронологию. Единая точка генерации — `store.Now()`, а не дефолт в схеме: забытая вставка должна падать громко. - Enum-поля — обычный `TEXT` без `CHECK`, допустимые значения держит код. - Миграции — goose (`internal/store/migrations`), SQL для DDL. При изменении структуры обновляем схему в [database.md](../database.md) тем же изменением — это проверяет `task gate`. - **Значение, читаемое табличной функцией SQLite (`json_each` и родня), проходит проверку ВНУТРИ её аргумента, а не условием в `WHERE`.** Функция получает значение строки раньше, чем применится фильтр, и порядок этот SQLite не обещает: неразбираемое значение роняет **весь** запрос, а не пропускает строку. Условие в `WHERE` работает, пока планировщик проталкивает его вниз, и перестаёт молча. Проверено на закреплённом драйвере: одна испорченная строка `delivery.uncovered_sections` обесценивала и сверку новизны (вечное «сверка не состоялась» на каждой доставке), и перечень целиком.