Files
healthlog/docs/conventions/storage.md
T
av b278501a6e store: при равной полноте точек побеждает пришедшая доставка
- байтовый порядок канонических форм остался тай-брейком только внутри одной
  доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил
  меньшее значение, из-за чего step_count терял род и verify:archive был красным
- правило перестало быть коммутативным осознанно, поэтому порядок свёртки
  приведён к журнальному: проход воркера прекращается на отложенной доставке,
  а свёртка вне порядка журнала пишет WARN
- заведены счётчики PointsHeld и PointsErased — удержание полнотой и
  единственное направление, в котором правило теряет содержание
2026-08-04 11:16:24 +03:00

74 lines
8.2 KiB
Markdown

# База данных и идентификаторы
Схема как таковая — в [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`.