- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
объявляет слой, измеренный род, его применимость к отданному ряду и границу
окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
записи: дословность содержимого точки иначе не удерживается, а оборванное
тело уходило под видом успешного `200`
111 lines
12 KiB
Markdown
111 lines
12 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`.
|
|
|
|
- **Значение, читаемое табличной функцией SQLite (`json_each` и родня), проходит
|
|
проверку ВНУТРИ её аргумента, а не условием в `WHERE`.** Функция получает
|
|
значение строки раньше, чем применится фильтр, и порядок этот SQLite не
|
|
обещает: неразбираемое значение роняет **весь** запрос, а не пропускает
|
|
строку. Условие в `WHERE` работает, пока планировщик проталкивает его вниз, и
|
|
перестаёт молча. Проверено на закреплённом драйвере: одна испорченная строка
|
|
`delivery.uncovered_sections` обесценивала и сверку новизны (вечное «сверка не
|
|
состоялась» на каждой доставке), и перечень целиком.
|
|
|
|
## Предикат выбора источника и предикат отбора данных — одна граница
|
|
|
|
Объекты витрины адресуются часом, а точки отбираются точной меткой. Выборка
|
|
объектов поэтому обязана быть **шире** запроса (точка `10:59` живёт в объекте
|
|
`10:00`) — и ровно здесь появляется разрыв: множество «слои, у которых есть
|
|
объекты в периоде» не совпадает с множеством «слои, у которых есть точки в
|
|
периоде».
|
|
|
|
Правило: **решение о том, откуда брать данные, принимается по той же границе, по
|
|
которой данные потом отбираются.** Иначе узел выбирает источник, в котором после
|
|
точного отбора не остаётся ничего, и отдаёт пустоту при непустых данных
|
|
соседнего источника — молча, потому что и выбор, и отбор по отдельности верны.
|
|
|
|
Прецедент: правило выбора слоя в Read API мерило охват часами объектов, а ряд
|
|
отбирало метками точек; на периоде короче часа ответ уходил пустым при непустых
|
|
минутных данных (ADR-2026-08-04-sloy-vybiraetsya-po-ohvatu-tochek).
|
|
|
|
## Значение из чужого тела имеет предел длины у КАЖДОГО адресата
|
|
|
|
Правило `docs/security.md` про предел длины читается как «в ключ, в лог, в
|
|
отчёт» — и адресаты кончаются не там. Имя метрики уезжает ещё и в заголовок
|
|
ответа: без предела `ETag` растёт вместе с именем, а кавычка внутри имени по
|
|
RFC 9110 кончает метку, и условный запрос по такой метрике не сработает никогда.
|
|
|
|
Когда предел неудобен (значение нужно целиком), его заменяет **форма**: в метку
|
|
уезжает хеш канонизированной строки, а не строка. Хеш здесь не секрет — он
|
|
ограничитель длины и экранирование разом.
|