Files
av f8200f7f80 feat: разбор и хранение тренировок и состояния разума
- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной
  строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`;
  миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки
  с этими ключами
- сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет
  содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а
  при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА —
  «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина
  расходилась бы с пересборкой молча
- отпечаток витрины покрывает тренировки и записи и снимается одним снимком
  базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
2026-08-02 13:05:16 +03:00

24 KiB
Raw Permalink Blame History

ADDED Requirements

Requirement: Разбор секций с собственными идентификаторами

Система SHALL разбирать секции тела, элементы которых несут собственный id, в сущности, а не в точки: у сущности нет ни слоя, ни координатного ключа метрика + слой + начало + конец — её адресует сам id.

Покрываются две такие секции: data.workouts и data.stateOfMind. Секции ecg, symptoms, cycleTracking, medications и heartRateNotifications покрытыми MUST NOT становиться: живой поток не приносил их ни разу (118 доставок), их форма никем не наблюдалась, а полнота покрытия HealthKit ради полноты целью проекта не является. Они остаются в списке непокрытых, и доставка с ними остаётся partial.

Из тренировки разбор SHALL брать только то, по чему потом идёт выборка: идентификатор, имя, начало, конец, офсет исходной зоны и длительность. Всё остальное — включая маршрут, внутренние ряды (heartRateData, activeEnergy, heartRateRecovery) и сводки — MUST храниться содержимым сущности дословно, теми же байтами, какими пришло. Раскладывать структуру тренировки по колонкам значило бы решить за Apple, что в ней главное: сводки дублируют ряды (distance — это сумма walkingAndRunningDistance), а набор полей зависит от типа тренировки (у уличной есть route, avgSpeed, flightsClimbed, у домашней — temperature, humidity, intensity).

Длительность SHALL браться из тела, а не вычисляться из начала и конца: HAE шлёт 91.746 секунды при интервале в 91 секунду, и вычисленное значение молча разошлось бы с присланным. Отсутствие или нечисловое значение длительности сущность MUST NOT отбрасывать; такая длительность SHALL быть выражена отсутствием значения, а не нулём — ноль является законной длительностью, и потребитель не отличил бы «источник не прислал» от «измерено ноль».

Началом сущности SHALL быть start, при его отсутствии — date. Конец берётся из end; при отсутствии или неразбираемости конца он SHALL равняться началу, а истина остаётся в содержимом. Вырождение интервала здесь безопасно, в отличие от точки: ключ сущности — id, схлопывать координаты нечем. Офсет исходной зоны SHALL браться из начала: колонка одна, а тренировка через смену зоны дала бы два разных.

Из записи разбор SHALL брать идентификатор, род секции, метку времени и офсет; всё остальное хранится дословно. Род записи SHALL быть верхнеуровневым ключом секции HAE дословно (stateOfMind, не state_of_mind): инвариант «форма Apple не транслируется» относится и к именам секций.

Длина идентификатора SHALL быть ограничена, и сущность с более длинным id SHALL пропускаться тем же счётчиком, что и сущность без id. Идентификатор приходит из тела, которым отправитель управляет целиком, а уезжает и в ключ таблицы, и в записи лога; правило то же, что уже действует для имён непокрытых секций.

Ряд пульса внутри тренировки MUST NOT попадать в метрику heart_rate: это разные сущности хранилища. Пульс приезжает дважды — в общем потоке метрик и внутри тренировки, — и смешение задвоило бы ряд.

Сущность без id либо без разбираемой метки времени SHALL пропускаться со счётчиком, не роняя разбор остального: тело остаётся в архиве, и доставку подберёт пересборка, когда разбор научится её понимать.

Отсутствие покрытой секции в теле ошибкой быть MUST NOT: доставки из одних метрик — большинство потока.

Scenario: Тренировка разбирается вместе с маршрутом

  • WHEN тело содержит data.workouts с тренировкой, несущей route
  • THEN разбор отдаёт сущность с идентификатором, именем, началом, концом, офсетом и длительностью
  • AND её содержимое несёт маршрут и внутренние ряды исходными байтами

Scenario: Ряд пульса тренировки не становится метрикой

  • WHEN тренировка содержит heartRateData
  • THEN точки этого ряда не попадают в точки метрик
  • AND остаются внутри содержимого сущности

Scenario: Запись состояния разума разбирается

  • WHEN тело содержит data.stateOfMind с элементом, несущим id и start
  • THEN разбор отдаёт запись с родом stateOfMind, идентификатором, меткой времени и содержимым исходными байтами

Scenario: Сущность без идентификатора пропускается

  • WHEN элемент покрытой секции не несёт id либо id пуст
  • THEN сущность в результат разбора не попадает
  • AND факт учитывается счётчиком, а разбор остальных сущностей продолжается

Scenario: Элемент секции не является объектом

  • WHEN элемент покрытой секции не разбирается как объект JSON
  • THEN сущность в результат разбора не попадает
  • AND факт учитывается отдельным счётчиком, а соседние сущности разбираются как обычно

Отдельным, а не общим с «нет id»: доставка, где не разобрался сам элемент, — это сменившаяся форма секции, а доставка без id — сменившаяся форма идентификатора. Ронять из-за такого элемента всю доставку нельзя тем более: failed фоновая свёртка не подбирает никогда, и вместе с одной кривой тренировкой в него уехали бы записи stateOfMind той же доставки.

Scenario: Сущность без разбираемой метки времени пропускается

  • WHEN элемент покрытой секции несёт id, но его метка времени не разбирается ни одним из поддерживаемых форматов
  • THEN сущность в результат разбора не попадает
  • AND факт учитывается счётчиком

Scenario: Сущность со слишком длинным идентификатором пропускается

  • WHEN элемент покрытой секции несёт id длиннее предела
  • THEN сущность в результат разбора не попадает
  • AND факт учитывается тем же счётчиком, что и отсутствие id

Scenario: Длительность берётся из тела, а не из интервала

  • WHEN тренировка несёт duration равный 91.746 при интервале start/end в 91 секунду
  • THEN длительность сущности равна 91.746

Scenario: Тренировка без длительности сохраняется без неё

  • WHEN тренировка не несёт duration либо оно не является числом
  • THEN сущность сохраняется, а её длительность остаётся незаполненной
  • AND нулём она MUST NOT становиться

Scenario: Нечитаемый конец тренировки не отбрасывает её

  • WHEN тренировка несёт end, который не разбирается
  • THEN конец сущности равен её началу
  • AND исходное значение остаётся в содержимом дословно

Scenario: Незнакомое поле тренировки переживает разбор

  • WHEN тренировка несёт поле, которого разбор не знает
  • THEN оно сохраняется в содержимом сущности дословно
  • AND разбор не завершается ошибкой

Scenario: Непокрытая секция с собственными id остаётся непокрытой

  • WHEN тело содержит data.ecg
  • THEN ecg попадает в список непокрытых ключей
  • AND сущностей из неё разбор не отдаёт

MODIFIED Requirements

Requirement: Отказ разбора остаётся всё или ничего

Разбор SHALL оставаться операцией «всё или ничего»: ошибка, встреченная после того, как покрытая секция уже разобрана (обрезанное тело, мусор в следующем члене), MUST NOT оставлять в результате ни точек, ни сущностей — доставка считается неразобранной целиком.

Иначе часть данных оказалась бы в витрине под статусом, по которому доставку никто не подберёт, и свёртка перестала бы быть детерминированной по журналу.

Правило SHALL распространяться и на невыводимый слой: доставка, у которой есть метрики, но слой их не определяется, не сохраняет и своих сущностей, хотя слоя у сущности нет. Соблазн «сущности от слоя не зависят, запишем их» ломает то же «всё или ничего» — доставка получила бы failed при частично записанной витрине, и повторная свёртка перестала бы быть no-op. Цена названа вслух: если такая доставка когда-нибудь принесёт stateOfMind, его записи доедут не сразу, а пересборкой; тело при этом остаётся в архиве, и failed ретеншену трогать нельзя.

Повтор ключа покрытой секции в одном объекте data SHALL давать объединение секций, а не победу последней: молча терять данные нельзя. То же SHALL относиться к повтору самого члена data в теле — результаты накапливаются, включая список непокрытых ключей.

Scenario: Тело оборвано после секции метрик

  • WHEN тело содержит целую секцию metrics, а следующий член data оборван
  • THEN разбор завершается ошибкой и точек не отдаёт

Scenario: Тело оборвано после секции тренировок

  • WHEN тело содержит целую секцию workouts, а следующий член data оборван
  • THEN разбор завершается ошибкой и сущностей не отдаёт

Scenario: Невыводимый слой не сохраняет и сущностей

  • WHEN доставка несёт метрики, слой которых не определяется, и вместе с ними секцию stateOfMind
  • THEN разбор завершается ошибкой, ни точек, ни записей не отдаёт
  • AND список непокрытых ключей переживает отказ

Scenario: Секция метрик встречается дважды

  • WHEN объект data содержит два ключа metrics
  • THEN точки обеих секций попадают в результат

Scenario: Секция тренировок встречается дважды

  • WHEN объект data содержит два ключа workouts
  • THEN сущности обеих секций попадают в результат

Scenario: Член data встречается дважды

  • WHEN тело содержит два члена data, из которых первый несёт непокрытую секцию, а второй — покрытую
  • THEN данные покрытой секции попадают в результат
  • AND имя непокрытой секции остаётся в списке непокрытых ключей

Requirement: Разбор форматов времени

Система SHALL разбирать метку формата 2026-07-31 21:03:51 +0300 и приводить её к UTC, сохраняя офсет исходной зоны. В секции data.metrics других форматов меток не встречается.

Система SHALL разбирать вторым форматом RFC 3339 в UTC (2026-07-31T18:03:51Z): им приходят метки секции data.stateOfMind, тогда как тренировки и метрики шлют первый формат. Оба формата SHALL приниматься у любой метки сущности, а не приписываться секции жёстко: формы однозначны и не пересекаются, а HAE выравнивает секции между собой по ходу своих обновлений — stateOfMind уже шлёт стабильные коды HealthKit там, где старые секции шлют переводы. Приписанный секции формат ломался бы молча в день такого выравнивания.

Метка RFC 3339 в UTC даёт офсет 0, и это MUST означать «источник прислал UTC», а не «человек находился в нулевой зоне»: местной зоны у секции stateOfMind в потоке нет вовсе.

Метка точки при этом остаётся строгой — один формат, — и асимметрия намеренная. По метке точки выводится слой, причём по метке в исходной зоне; терпимость к RFC 3339 означала бы, что метка в UTC тихо портит выравнивание и часовая выгрузка складывается с минутной (наблюдалось: удвоение суммы за час). У сущности слоя нет, и терять на строгости нечего, а у точки строгий парсер отдаёт непонятую метку в счётчик пропусков — тело остаётся в архиве, и пересборка вернёт его, когда формат станет известен.

Unix-эпоха дробным числом (1785446196.4132624) встречается внутри heartbeatSeries и меткой точки не является. Система MUST NOT преобразовывать её: элементы серии проходят как исходные байты. Преобразование во time.Unix и обратно не гарантирует дословности, а серия составляет 93% объёма метрики heart_rate_variability.

Время внутри маршрута тренировки (route[].timestamp) меткой сущности тоже не является и MUST проходить дословно, не разбираясь.

Scenario: Локальное время со смещением

  • WHEN метка имеет вид 2026-07-31 21:03:51 +0300
  • THEN точка получает время в UTC и офсет +10800 секунд

Scenario: RFC 3339 в UTC

  • WHEN метка сущности имеет вид 2026-07-31T18:03:51Z
  • THEN сущность получает время в UTC и офсет 0

Scenario: Тренировка со временем в формате метрик

  • WHEN тренировка несёт start вида 2026-08-01 10:04:31 +0300
  • THEN сущность получает время в UTC и офсет +10800 секунд

Scenario: Время внутри серии ударов

  • WHEN точка метрики heart_rate_variability содержит heartbeatSeries
  • THEN элементы серии сохраняются исходными байтами вместе с их эпохой
  • AND серия не разворачивается в отдельные точки
  • AND эпоха внутри серии не разбирается и не преобразуется

Scenario: Время внутри маршрута не разбирается

  • WHEN тренировка содержит route с полем timestamp у каждой точки
  • THEN точки маршрута сохраняются исходными байтами
  • AND их метки не разбираются и не преобразуются

Requirement: Перечисление непокрытых секций доставки

Разбор SHALL перечислять верхнеуровневые ключи объекта data и возвращать вызывающему те из них, которые он не покрывает. Содержимое непокрытой секции MUST NOT удерживаться после того, как разбор прошёл мимо неё: тела доходят до 42 МиБ, и удержание кучи здесь — часть контракта, а не деталь реализации.

Покрытых ключей сегодня три — metrics, workouts и stateOfMind. Разбор и перечисление MUST ходить по одному объявленному множеству покрытых имён: состояние «секция разбирается, но числится непокрытой» невыразимо по построению.

Непокрытым ключ считается независимо от того, что лежит внутри: содержимое не интерпретируется, поэтому и о пустоте секции разбор честно ничего не знает. Измерено на живом архиве — пустых секций HAE не присылает ни разу (118 доставок).

Список SHALL быть каноничен: имена отсортированы, повторов нет. Порядок ключей в JSON от HAE нестабилен, а значение уезжает в базу и сравнивается между доставками.

Отсутствие непокрытых ключей и отсутствие секции metrics — разные события, и оба нормальны: половина потока состоит из доставок без метрик вовсе (53 из 118).

Scenario: Незнакомая секция попадает в список непокрытых

  • WHEN тело содержит data.ecg наряду с data.metrics
  • THEN разбор возвращает ecg в списке непокрытых ключей
  • AND точки секции metrics разбираются как обычно

Scenario: Доставка из одних тренировок непокрытых ключей не даёт

  • WHEN тело содержит только data.workouts
  • THEN разбор завершается без ошибки, точек нет, тренировки разобраны
  • AND список непокрытых ключей пуст

Scenario: Доставка из одного состояния разума непокрытых ключей не даёт

  • WHEN тело содержит только data.stateOfMind
  • THEN разбор завершается без ошибки, записи разобраны
  • AND список непокрытых ключей пуст

Scenario: Доставка из одних метрик непокрытых ключей не даёт

  • WHEN единственный ключ datametrics
  • THEN список непокрытых ключей пуст

Scenario: Один и тот же набор секций даёт один и тот же список

  • WHEN два тела несут те же секции в разном порядке, а одно из них повторяет непокрытый ключ дважды
  • THEN списки непокрытых ключей у них совпадают

Scenario: Содержимое непокрытой секции не удерживается в памяти

  • WHEN тело в десятки мегабайт состоит преимущественно из непокрытой секции
  • THEN после разбора удержано не больше четырёх размеров тела — та же граница, что и для тела из метрик
  • AND содержимое непокрытой секции в результат разбора не попадает