- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`; миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки с этими ключами - сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА — «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина расходилась бы с пересборкой молча - отпечаток витрины покрывает тренировки и записи и снимается одним снимком базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
24 KiB
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 единственный ключ
data—metrics - THEN список непокрытых ключей пуст
Scenario: Один и тот же набор секций даёт один и тот же список
- WHEN два тела несут те же секции в разном порядке, а одно из них повторяет непокрытый ключ дважды
- THEN списки непокрытых ключей у них совпадают
Scenario: Содержимое непокрытой секции не удерживается в памяти
- WHEN тело в десятки мегабайт состоит преимущественно из непокрытой секции
- THEN после разбора удержано не больше четырёх размеров тела — та же граница, что и для тела из метрик
- AND содержимое непокрытой секции в результат разбора не попадает