feat: разбор и хранение тренировок и состояния разума

- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной
  строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`;
  миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки
  с этими ключами
- сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет
  содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а
  при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА —
  «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина
  расходилась бы с пересборкой молча
- отпечаток витрины покрывает тренировки и записи и снимается одним снимком
  базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
This commit is contained in:
av
2026-08-02 13:05:16 +03:00
parent c28de9796e
commit f8200f7f80
47 changed files with 5817 additions and 301 deletions
@@ -0,0 +1,123 @@
-- +goose Up
-- Вторая единица хранения витрины: сущность с собственным идентификатором.
-- Часовой объект ей не подходит — у неё есть естественный ключ, она редка (за
-- двое суток потока две тренировки и две записи состояния разума при 44 и 52
-- доставленных копиях), и группировать её по часам незачем.
--
-- Таблицы две, а не одна с колонкой рода: у тренировки есть заголовок, по
-- которому идёт выборка (имя, интервал, длительность), а у записи его нет.
-- Общая таблица либо теряла бы заголовок, либо держала колонки, пустые у пяти
-- родов из шести.
CREATE TABLE workout (
-- Идентификатор из HealthKit. Приходит из тела и ограничен по длине
-- разбором: уезжает и в ключ, и в записи лога.
id TEXT PRIMARY KEY,
-- Имя как прислал HAE, локализованное («В помещении Ходьба» — машинная
-- калька с Indoor Walk). Хранится дословно; стабильный код HealthKit
-- припишет задача словаря категориальных значений.
name TEXT NOT NULL DEFAULT '',
-- Интервал в UTC, RFC 3339. Конец, которого нет или который не читается,
-- равен началу: ключ — id, схлопывать координаты нечем, а истина остаётся
-- в payload.
start_utc TEXT NOT NULL,
end_utc TEXT NOT NULL,
-- Смещение зоны НАЧАЛА: колонка одна, а тренировка через смену зоны дала
-- бы два разных.
tz_offset INTEGER NOT NULL DEFAULT 0,
-- Длительность в секундах, как прислал HAE. NULL означает «источник не
-- прислал»: ноль — законная длительность, и потребитель, сложивший
-- столбец, иначе не отличил бы одно от другого. Не вычисляется из
-- интервала — HAE шлёт 91.746 при интервале в 91 секунду.
duration_sec REAL,
-- Тренировка целиком исходными байтами, сжатая gzip: заголовок, маршрут,
-- внутренние ряды и сводки. Маршрут — 95% веса (190 КБ из 199,6 у
-- десятиминутной прогулки), а такой JSON жмётся примерно в 25 раз.
-- Внутрь средствами SQL не заглянуть — та же плата, что у bucket.payload.
payload BLOB NOT NULL,
-- Хеш канонической формы содержимого: детектор изменений, а не ключ.
-- Тренировка переприсылается каждой доставкой, пока не доедет маршрут: 44
-- копии на живом архиве дают три различных содержимых.
content_hash TEXT NOT NULL,
-- Провенанс: доставка, ЧЬЯ ВЕРСИЯ лежит сейчас, и её метка приёма. Не
-- отчётность: по паре (received_at, id) разрешается тай-брейк между
-- версиями равной полноты. «Побеждает приехавшая» было бы функцией порядка
-- свёртки, а он порядку журнала не равен — доставка с более ранней меткой,
-- свёрнутая позже, вернула бы витрину к недосчитанной версии, и пересборка
-- разошлась бы с живым приёмом молча.
-- Без DEFAULT намеренно: единственный писатель заполняет обе колонки
-- всегда, а умолчание превратило бы его дефект из отказа вставки в тихо
-- неверный исход — строка с пустой меткой оказалась бы «самой ранней в
-- журнале», и её затирала бы любая приехавшая версия.
delivery_id TEXT NOT NULL,
delivery_received_at TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- Основной запрос трекера — «заголовки тренировок за период».
CREATE INDEX workout_start_utc ON workout (start_utc);
CREATE TABLE record (
-- Род — верхнеуровневый ключ секции HAE ДОСЛОВНО (`stateOfMind`, не
-- `state_of_mind`): инвариант «форма Apple не транслируется» относится и к
-- именам секций.
kind TEXT NOT NULL,
id TEXT NOT NULL,
-- Метка события в UTC и смещение исходной зоны. У stateOfMind HAE шлёт
-- RFC 3339 в UTC, поэтому смещение там всегда 0 — это значит «источник
-- прислал UTC», а не «человек был в Гринвиче»: местной зоны у секции в
-- потоке нет вовсе.
ts_utc TEXT NOT NULL,
tz_offset INTEGER NOT NULL DEFAULT 0,
payload BLOB NOT NULL,
content_hash TEXT NOT NULL,
delivery_id TEXT NOT NULL,
delivery_received_at TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
-- Ключ — ПАРА, а не один id. Собственный id наблюдался живьём только у
-- stateOfMind, где он UUID HealthKit; форма идентификатора остальных пяти
-- секций не наблюдалась никем, и короткий несквозной id в двух разных
-- секциях затёр бы одну запись другой молча. Пара стоит ноль: запросы к
-- записям всегда идут с родом.
PRIMARY KEY (kind, id)
);
-- «Записи такого-то рода за период» — единственная форма запроса к таблице.
CREATE INDEX record_kind_ts ON record (kind, ts_utc);
-- Покрыли секцию — пересверните. Список непокрытых ключей это снимок покрытия
-- на момент свёртки: доставки, свёрнутые до того, как workouts и stateOfMind
-- стали покрытыми, остались бы partial со старым списком, и ретеншен вечно
-- щадил бы тела, которые больше ничего не хранят сверх витрины.
--
-- Отбор по ЭЛЕМЕНТУ массива, а не по подстроке тела: имя секции приходит из
-- чужого тела, и LIKE '%workouts%' поймал бы ключ, лишь содержащий эту
-- подстроку. Перевод точечный, а не «все partial»: доставка с непокрытой ecg
-- пересворачивать нечего.
UPDATE delivery
SET parse_status = 'pending'
WHERE parse_status = 'partial'
AND EXISTS (
SELECT 1 FROM json_each(delivery.uncovered_sections)
WHERE json_each.value IN ('workouts', 'stateOfMind')
);
-- +goose Down
-- Строки, переведённые в pending, Down обратно не возвращает: какими они были,
-- восстановить неоткуда, а pending консервативен — ретеншен его не трогает.
DROP INDEX record_kind_ts;
DROP TABLE record;
DROP INDEX workout_start_utc;
DROP TABLE workout;