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

8.2 KiB

База данных и идентификаторы

Схема как таковая — в 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 тем же изменением — это проверяет task gate.