httpapi: точки метрики за период отдаются одним запросом

- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
  объявляет слой, измеренный род, его применимость к отданному ряду и границу
  окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
  точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
  под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
  хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
  записи: дословность содержимого точки иначе не удерживается, а оборванное
  тело уходило под видом успешного `200`
This commit is contained in:
av
2026-08-04 18:46:45 +03:00
parent b819b77f62
commit 29ca8d415c
36 changed files with 4721 additions and 58 deletions
@@ -0,0 +1,73 @@
## Why
Точки лежат в витрине и наружу не отдаются: «вес за год» достаётся только
`sqlite3` на хосте. Каталог уже отвечает, **что** есть, — но ни один из трёх
потребителей не может получить сами значения.
Ответ обязан быть самоописательным. Метрика лежит сразу в нескольких слоях
подробности, а род её свёртки не объявлен источником, а **измерен** окном в 48
самых свежих **общих** часов — окном, которое замирает при выключенной минутной
автоматизации HAE и продолжает объявлять род. Клиент, не видящий ни слоя, ни
границы этого окна, принимает решение по числу, происхождения которого не знает.
## What Changes
- Новый читающий маршрут `GET /api/v1/metrics/{name}?from&to&layer` — все точки
метрики за период, одним запросом, без доступа к файлу базы.
- Конверт ответа объявляет **фактические** параметры выдачи: `metric`, `from`,
`to`, `layer`, `bucket`, `aggregation` (род, его применимость к отданному ряду
и граница окна измерения), `points`. Поля присутствуют всегда — клиент не
выводит их наличием или отсутствием.
- Слой выбирается правилом, а не молча: параметр `layer` задаёт разрез явно, без
него берётся слой с наибольшим охватом **точек** внутри запрошенного периода,
при равенстве — самый мелкий. Смены слоя внутри одного ответа не бывает.
- Род свёртки в конверте — тот же измеренный `catalog.Style`, что у каталога, и
измеряется он тем же правилом и тем же окном. Род неизвестен — так и сказано
словом `unknown`, а не молчанием. Рядом едет применимость: `cumulative` на
нижнем слое HAE объявляется неприменимым, иначе конверт приглашает потребителя
сложить интерполяцию и завысить втрое.
- Свёртка в этом изменении **не выполняется**: `bucket` всегда `null`, а
присутствие параметра `bucket` в запросе отвергается `400`, а не игнорируется
молча.
- Ответ снимается **одной транзакцией чтения**, а метка ответа включает
канонизированную форму запроса **и горизонт измерения** — иначе соседняя
задача условного запроса подтвердит `304` на сменившемся роде.
- Форма провода — по образцу каталога (ADR
`ADR-2026-08-04-forma-provoda-prinadlezhit-transportu`): типы `*Wire` в
`internal/httpapi`, перевод присваиванием, строка в таблице образцов
`wire_internal_test.go`.
Схема базы не трогается: обе выборки отвечают по существующим ключу и
покрывающему индексу `bucket_catalog`.
## Capabilities
### New Capabilities
- `points`: точки метрики за период — форма запроса и его разбор, правило выбора
слоя, состав конверта ответа, объявление измеренного рода вместе с границей
окна измерения, дословность значений точки.
### Modified Capabilities
- `read-api`: добавляются два общих правила читающих маршрутов — сериализация
ответа не экранирует содержимое (иначе `&` внутри дословно сохранённой точки
уезжает как `&`) и ответ чтения помечается непригодным для разделяемого
кеша. Оба механизма общие (`writeJSON`, `setReadHeaders`), и решать их заново
на каждом маршруте — тот же второй способ.
`catalog` (правило измерения рода) применяется новым маршрутом без изменения его
требований.
## Impact
- `internal/httpapi` — маршрут, разбор параметров, форма провода точек,
выключение HTML-экранирования в общем `writeJSON`.
- `internal/points` (новый) — use-case «точки метрики за период»: выбор слоя,
сборка ряда, измерение рода и метка ответа по одной метрике.
- `internal/store` — один вход чтения ряда (`ReadSeries`) в одной транзакции:
охваты слоёв в периоде, точки выбранного слоя, объекты окна измерения.
- `docs/architecture.md` — разделы «Read API» (форма ответа, правило выбора
слоя) и таблица компонентов.
- Потребители: HTTP-клиенты; следом этот же конверт копируют свёртка по сетке,
условный запрос по точкам, тренировки, записи и MCP.