- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
объявляет слой, измеренный род, его применимость к отданному ряду и границу
окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
записи: дословность содержимого точки иначе не удерживается, а оборванное
тело уходило под видом успешного `200`
12 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. -
Значение, читаемое табличной функцией 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 кончает метку, и условный запрос по такой метрике не сработает никогда.
Когда предел неудобен (значение нужно целиком), его заменяет форма: в метку уезжает хеш канонизированной строки, а не строка. Хеш здесь не секрет — он ограничитель длины и экранирование разом.