Дозакрыты находки ревью по слиянию сущностей

- Правило покрытия получило второй разряд (условный, как у точек), запрет
  вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и
  ряд из null больше не затирают маршрут. Победитель внутри доставки стал
  функцией множества версий — общим помощником с точками, — а провенанс
  поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину
  к прежнему содержимому.
- Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной
  записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма
  считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает
  на старте; текст ошибки разбора не несёт значений из тела.
- Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты:
  безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя
  был квадратичен по числу присланных версий одного ключа.
This commit is contained in:
av
2026-08-02 16:38:18 +03:00
parent 51a5272c96
commit 8331328134
52 changed files with 4921 additions and 481 deletions
@@ -0,0 +1,21 @@
## ADDED Requirements
### Requirement: Отказ учёта доставки называет класс причины
Система SHALL логировать отказ, случившийся **после** того, как тело легло в архив, но до появления учётной записи, так, чтобы владелец отличал **занятость базы** от прочих причин. Различается именно занятость: у неё уже есть доменная ошибка, и она означает конкуренцию за запись, которая будет повторяться.
Расширять признак до «обстоятельств вообще» система MUST NOT, хотя предикат с таким смыслом в проекте есть: он включает ещё и отмену работы снаружи, а на этом пути отмена невозможна по построению — учёт ведётся на контексте, переживающем обрыв соединения. Назвать отменённую работу занятостью базы значило бы отправить владельца искать конкуренцию там, где её нет.
Уровень при этом остаётся `ERROR` независимо от класса: тело лежит в архиве без
учётной записи, то есть осиротело, и вернуть его в журнал может только
пересборка. Занятость базы этого не отменяет — она объясняет причину, а не
снимает работу. Смысл различения в другом: занятость означает конкуренцию за
запись, которая будет повторяться и лечится не тем же, чем лечится сбой диска
или испорченная база.
#### Scenario: Занятая база при учёте доставки видна как отдельный класс
- **WHEN** запись учёта доставки не проходит из-за занятости базы
- **THEN** отказ логируется на уровне `ERROR` вместе с путём тела в архиве
- **AND** запись отличает занятость базы от прочих причин отказа
- **AND** тот же отказ по другой причине этого признака не несёт
@@ -0,0 +1,265 @@
## MODIFIED 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 стоить одного поля, а не сущности.**
Каждое поле заголовка читается мягко: строка берётся, когда значение является
строкой, и считается отсутствующей во всех прочих случаях. Правило уже записано
для длительности («нечисловое значение — это пропуск ОДНОГО поля, а не сломанная
сущность») и распространяется на весь заголовок. Иначе `name`, приехавшее
числом, уносит тренировку вместе с маршрутом, а доставка при этом числится
разобранной.
Мягкость MUST достигаться конструкцией, которая **не полагается на дозаполнение
остальных полей** библиотекой разбора: `encoding/json` при несовпадении типа
«skips that field and completes the unmarshaling as best it can», но тут же
оговаривает, что дозаполнение полей **после** проблемного не гарантировано.
Разбор, построенный на распознавании ошибки типа постфактум, перестал бы быть
функцией тела: одна и та же тренировка давала бы разный заголовок.
Граница правила называется вслух: оно закрывает смену **типа** значения, но не
смену **формата строки**. Наблюдавшийся дрейф — формата дат (разбор дат уже
зависит от секции пакета), и метка в незнакомом формате по-прежнему уносит
сущность целиком; закрыть это может только хранение сущности с неразобранной
меткой, а это отдельная задача. Пропуск при этом перестаёт быть невидимым: он
доходит до учётной записи доставки.
Идентификатор исключением из мягкости MUST быть: сущность без строкового `id`
не адресуема, и приведение чужого нестрокового значения к строке было бы
выдумыванием идентичности за источник. Такая сущность пропускается тем же
счётчиком, что и сущность без `id`.
Началом сущности при **присутствующем, но непрочитанном** `start` подставляться
`date` MUST NOT — включая `start: null`.
«Значение не той формы» и «значения нет» здесь различаются: фолбэк на `date`
существует для сущностей, у которых `start` не прислан вовсе, а подстановка
другого поля вместо непонятого даёт метку **другого момента времени**, ничем не
отличимую от настоящей. Такой `start` SHALL считаться неразбираемой меткой —
тем же исходом и тем же счётчиком, что метка незнакомого формата. Различать
надо именно «ключ был», а не «значение не той формы»: `null` тоже не даёт
строки, и без этого различения он молча уводил бы тренировку на другой момент
времени.
Мягкое чтение SHALL задавать поле целиком на каждое вхождение ключа, а не
накапливать признаки между вызовами. JSON допускает повтор ключа с семантикой
«побеждает последнее», и разбор ей уже следует; накопленный признак сделал бы
заголовок функцией истории вызовов, а не тела.
Элемент секции, не являющийся объектом JSON, SHALL уходить в счётчик «не
разобралось как объект» — включая `null`. Разбор в структуру на `null` ошибки не
даёт, поэтому такой элемент без явной проверки попадал бы в счётчик «нет `id`»,
и сменившаяся форма СЕКЦИИ диагностировалась бы как сменившаяся форма
ИДЕНТИФИКАТОРА — ради различения которых два счётчика и заведены.
Длительность 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 пропускаться со
счётчиком, не роняя разбор остального: тело остаётся в архиве, и доставку
подберёт пересборка, когда разбор научится её понимать. Счётчики пропусков 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` и `start` поле `name` приехало
числом
- **THEN** тренировка попадает в результат разбора с пустым именем
- **AND** её содержимое сохраняется дословно, включая маршрут
#### Scenario: Конец не той формы не уносит тренировку
- **WHEN** у тренировки с корректными `id` и `start` поле `end` приехало числом
- **THEN** тренировка попадает в результат разбора, а конец равен началу
#### Scenario: Сущность без идентификатора пропускается
- **WHEN** элемент покрытой секции не несёт `id`, либо `id` пуст, либо `id`
приехал не строкой
- **THEN** сущность в результат разбора не попадает
- **AND** факт учитывается счётчиком, а разбор остальных сущностей продолжается
#### Scenario: Элемент секции не является объектом
- **WHEN** элемент покрытой секции не разбирается как объект JSON
- **THEN** сущность в результат разбора не попадает
- **AND** факт учитывается **отдельным** счётчиком, а соседние сущности
разбираются как обычно
Отдельным, а не общим с «нет `id`»: доставка, где не разобрался сам элемент, —
это сменившаяся форма секции, а доставка без `id` — сменившаяся форма
идентификатора. Ронять из-за такого элемента всю доставку нельзя тем более:
`failed` фоновая свёртка не подбирает никогда, и вместе с одной кривой
тренировкой в него уехали бы записи `stateOfMind` той же доставки.
#### Scenario: Элемент секции не объект — свой счётчик
- **WHEN** элемент покрытой секции пришёл как `null`, строка, число или массив
- **THEN** факт учитывается счётчиком «не разобралось как объект»
- **AND** счётчик «нет `id`» не растёт
#### Scenario: Повтор ключа метки решается последним значением
- **WHEN** у элемента ключ `start` встречается дважды, и валидная метка стоит
последней
- **THEN** сущность попадает в результат разбора с этой меткой
#### Scenario: Сущность без разбираемой метки времени пропускается
- **WHEN** элемент покрытой секции несёт `id`, но его метка времени не
разбирается ни одним из поддерживаемых форматов либо пришла не строкой
(включая `null`)
- **THEN** сущность в результат разбора не попадает
- **AND** факт учитывается счётчиком
- **AND** поле `date` вместо непонятого `start` не подставляется
#### 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** сущностей из неё разбор не отдаёт
## ADDED Requirements
### Requirement: Диагностика разбора не несёт значений из тела
Сообщение об ошибке разбора MUST NOT содержать значений из тела доставки. Оно
SHALL называть **тип** встреченного токена и смещение во входе — по смещению
место находится в теле, лежащем в архиве, а значение из тела в логе не имеет
права быть в принципе.
Тип SHALL называться словарём JSON (`object`, `array`, `string`, `number`,
`bool`, `null`), а не именем типа языка реализации: имя типа для делимитера не
говорит ничего — какая скобка встретилась вместо ожидаемой, из него не следует,
— а сам делимитер принадлежит фиксированному набору и содержимого не раскрывает,
поэтому печатается значением.
Смещение SHALL указывать на место **перед** виновным токеном и от длины его
значения зависеть MUST NOT. Декодер сообщает позицию как конец последнего
возвращённого токена, поэтому взятая после чтения она отличалась бы от начала
проблемы ровно на длину значения — то есть на восемь мегабайт в том самом
случае, ради которого требование написано, и обещание «место находится в теле»
не выполнялось бы.
Это не стиль, а тот же инвариант, что уже записан для точек: данные о здоровье
чувствительнее токенов, тела запросов пишутся только на `DEBUG` и с обрезкой.
Подстановка токена целиком инвариант обходит: тело в 8 МиБ даёт текст ошибки в
8 МиБ, который уходит атрибутом `error` на уровень `WARN` — то есть содержимое
доставки оказывается в логе полностью и без обрезки.
Предел SHALL держаться самим сообщением, а не обрезкой на стороне
логирующего: обрезка живёт в другом месте и о новой ошибке разбора не узнает.
Правило SHALL распространяться и на **чужие** причины: ошибка библиотеки разбора
кладёт в текст литерал значения, поэтому причина, приходящая извне, обрезается по
названной длине на границе. Тот же предел SHALL действовать на проверке формы
конверта при приёме — она пользуется той же библиотекой, и её отказ логируется на
`DEBUG`, где инвариант тоже требует обрезки.
#### Scenario: Огромное значение не доезжает до текста ошибки
- **WHEN** тело содержит на месте ожидаемого объекта строку в несколько
мегабайт
- **THEN** разбор завершается ошибкой
- **AND** длина текста ошибки не зависит от длины этого значения
- **AND** текст называет тип токена словарём JSON и смещение перед токеном
- **AND** смещение не меняется, если то же значение сделать длиннее
@@ -0,0 +1,81 @@
## MODIFIED Requirements
### Requirement: База назначения пригодна к подмене
База назначения SHALL нести полноценный учёт доставок, а не только объекты
витрины: подменяется файл базы **целиком**, а не одна таблица.
Состав переноса нормируется явно, потому что колонки `delivery` двух разных
родов:
```
факты журнала id, received_at, automation_name, automation_id, aggregation,
period, session_id, bytes, sha256, raw_path, headers
← переносятся дословно
производные parse_status, points, derived_layer, uncovered_sections,
skipped_entities ← начинаются пустыми
```
Перечень производных полей SHALL пополняться **тем же изменением**, которое
заводит новое поле: он единственное место, где сказано, чему нельзя пережить
пересборку, и следующий автор решает по нему. Поле, не внесённое в перечень,
однажды перенесут «для полноты учёта».
Факты журнала SHALL переноситься дословно, включая записи, тела которых в
архиве уже нет. Заголовки восстановлению не подлежат ничем — в архиве их нет, —
и неполный перенос уничтожил бы их первой же подменой, а с ними и вывод слоя
для **всех** доставок, не только подобранных. Запись без тела при этом не
сворачивается и в наследовании слоя не участвует: выведенного слоя у неё нет.
Производные от разбора поля MUST начинаться пустыми. Перенос `derived_layer`
особенно опасен и незаметен: доставка, чей повторный разбор отказал (штатный
исход, когда слой не выводится), сохранила бы слой **прежнего** разбора, и
следующая доставка той же автоматизации унаследовала бы его. Витрина снова стала
бы функцией предыдущего прогона, а не журнала, причём оба прогона были бы
самосогласованы — проверка «повторная пересборка ничего не меняет» этого не
ловит. Для числа пропущенных сущностей цена та же и хуже: пустота у него значит
«не измерялось», и перенесённое число выдавало бы измерение прежнего разбора за
измерение текущего — а по нему принимается необратимое решение об удалении тела.
#### Scenario: Учёт переносится полностью
- **WHEN** пересборка завершилась
- **THEN** число строк учёта в базе назначения равно числу строк рабочей базы
плюс число подобранных тел
- **AND** заголовки перенесённых доставок совпадают с рабочей базой дословно
#### Scenario: Слой прошлого разбора в наследование не попадает
- **WHEN** в рабочей базе у доставок проставлен `derived_layer`
- **THEN** отпечаток пересобранной витрины совпадает с отпечатком пересборки
того же журнала из учёта без проставленных слоёв
#### Scenario: Число пропущенных сущностей не переносится из журнала
- **WHEN** в рабочей базе у доставки проставлено число пропущенных сущностей, а
тела этой доставки в архиве уже нет
- **THEN** в базе назначения её число пропущенных сущностей отсутствует
## ADDED Requirements
### Requirement: Отчёт пересборки показывает удержанные версии сущностей
Отчёт пересборки SHALL называть число версий сущностей, удержанных правилом «не
теряем содержания», — тем же счётчиком, что ведёт свёртка.
Без него правило слияния сущностей проверить нечем. Сходимость отпечатка его не
проверяет **по построению**: живой приём и пересборка пользуются одним правилом
и одинаково сойдутся на одинаково удержанной версии. То есть слишком строгое
правило — например, замораживающее тренировку на старой версии из-за исчезнувшего
ключа с пустым значением — выглядело бы как идеальная сходимость. Счётчик
несравнимых наборов точек выведен в отчёт по ровно той же причине и тем же
рассуждением.
Число SHALL печататься всегда, а не только при ненулевом значении: ноль здесь
утверждение, а не отсутствие новостей.
#### Scenario: Удержанная версия видна в отчёте пересборки
- **WHEN** журнал содержит доставку, приехавшая версия сущности в которой
теряет содержание сохранённой
- **THEN** отчёт пересборки называет число удержанных версий больше нуля
@@ -0,0 +1,527 @@
## MODIFIED Requirements
### Requirement: Замена версии сущности не теряет содержания
Сущность с собственным `id` SHALL замещаться **целиком**, а не сливаться по
полям: она приезжает повторно, пока источник её досчитывает. Замер на живом
архиве: одна тренировка приехала 26 раз в трёх различных содержимых — сперва
добавились `stepCadence` и `stepCount` вместе с изменившимся рядом
`activeEnergy`, затем при том же наборе полей досчитались `totalEnergy` и
`basalEnergy`.
Замещение MUST быть условным: приехавшая версия побеждает, **если не теряет
содержания** сохранённой. Порядок разбора:
```
1. хеш канонического содержимого совпал → содержимое не пишется,
провенанс поднимается до
более поздней позиции журнала
2. содержание приехавшей покрывает сохранённую
и сверх того → приехавшая замещает целиком
3. приехавшая теряет содержание сохранённой → остаётся сохранённая,
счётчик + WARN
4. содержание сравнимо, наборы равны → версия из более поздней
доставки журнала
5. наборы несравнимы → остаётся сохранённая,
счётчик + WARN
```
**Содержание сравнивается множествами ключей и формой их значений — но не
значениями.** Сравнение полноты, принятое для точек, здесь неприменимо: оно
гасит отношение включения, когда значения общих содержательных ключей
разошлись, а у сущности они расходятся **всегда** — источник её досчитывает.
Проверено: сохранённая тренировка с маршрутом против приехавшей без маршрута
даёт «надмножество» при неизменных значениях и «равенство» при изменившихся, то
есть на живых данных защита не сработала бы вовсе, а тест на фикстуре с
неизменёнными значениями остался бы зелёным. Условия «значения общих ключей
совпали» здесь быть MUST NOT.
Покрытие SHALL проверяться четырьмя условиями, все — по верхнему уровню
содержимого:
1. каждый ключ сохранённой **с непустым значением** есть у приехавшей и тоже
непуст;
2. **при равенстве множеств содержательных ключей** — каждый ключ сохранённой,
включая пустые, есть у приехавшей. Тот же второй разряд записан для точек, и
с тем же условием: иначе ключ с пустым значением исчезает по жребию
тай-брейка. Безусловным он быть MUST NOT — проверено оракулом: версия с
пустым ключом и без маршрута оказывалась несравнимой с законным досчётом, у
которого маршрут приехал, а этого ключа нет, и маршрут не доезжал НИКОГДА;
3. форма значения не вырождается: где у сохранённой объект, у приехавшей MUST
быть объект; где массив — массив. Версия, подменившая объект или массив
скаляром, покрывающей быть MUST NOT — иначе «скелет» из скаляров и
`null`-ов той же длины признаётся равным настоящей тренировке и выигрывает
тай-брейк журнала;
4. верхнеуровневый массив не теряет ни длины, ни **содержательных элементов**:
усечённый маршрут (три точки вместо 593) ключа не теряет, а маршрут из
`[null,null,null]` не теряет и длины — притом что маршрут это 95%
содержимого тренировки. Досчёт ряды удлиняет, поэтому и укорачивание, и
опустошение элементов — законные признаки «приехало меньше».
Содержательность элемента ряда SHALL определяться **той же пустотой**, что и
содержательность поля точки: `null`, пустая строка, ноль в любой записи, пустой
объект, пустой массив; `false` содержателен. Второй словарь пустоты в проекте
завёл бы два ответа на один вопрос. Цена этого выбора называется вслух: ряд из
настоящих нулей (`[0,0,0]`) считается лишённым содержания, поэтому версия с
таким рядом сохранённую не заместит. Ошибка направлена в безопасную сторону —
правило удерживает, а не затирает, — и событие видно счётчиком; наблюдённые ряды
HAE состоят из объектов, а не из чисел.
Условия 3 и 4 применяются к ключам, содержательным у сохранённой версии.
Ключ, содержания не несущий, проверяется только на присутствие (условие 2):
формы у пустоты нет, и требовать её сохранения означало бы отличать `[]` от `0`
там, где ни то, ни другое ничего не несёт.
Предел правила называется вслух и не закрывается: сокращение **внутри**
элемента ряда (точка маршрута без `altitude` при непустом элементе и той же
длине) не ловится ничем, кроме сверки с телом в архиве.
Содержимое сущности, не разбирающееся как объект JSON, SHALL давать пустые
множества ключей — то же правило, что для точки: такая версия проигрывает любой
версии с содержанием и не загрязняет наблюдение о несравнимых наборах.
Единственная причина повторной присылки — доезжающий маршрут, то есть рост:
обратного за 44 доставленные копии не случилось ни разу. Но восстановление
требует пересборки всего журнала, поэтому событие делается наблюдаемым, а не
необратимым.
**Тай-брейк при равных наборах — позиция доставки в журнале `(received_at, id)`,
а не порядок свёртки.** «Побеждает приехавшая» было бы функцией порядка
свёртки, а он порядку журнала не равен: воркер сворачивает в порядке журнала
только среди видимых ему доставок и абсолютного порядка при конкурентных
приёмах не обещает. Доставка с более ранней меткой, свёрнутая позже, вернула бы
витрину к недосчитанной версии, и пересборка разошлась бы с живым приёмом молча,
в содержимом тренировки. Позиция журнала снимает это: исход зависит от журнала,
а не от того, кто раньше добрался до базы.
Ровно поэтому **провенанс сущности SHALL обновляться и тогда, когда хеш
совпал**: сохранённая позиция журнала участвует в тай-брейке пункта 4, и если
в ней осталась первая свёрнутая копия вместо победителя журнала, отложенная
доставка вернёт витрину к прежнему содержимому — то есть живая витрина
разойдётся с пересборкой. Обновление MUST касаться **только** провенанса;
содержимое при совпавшем хеше не переписывается, счётчик записанных сущностей
не растёт (он считает содержимое витрины, и его сравнимость с прежними замерами
важнее учёта обновления), и метка изменения содержимого не двигается тоже:
иначе она стала бы меткой касания строки и дребезжала бы двадцать шесть раз на
неизменившейся тренировке, а потребитель запроса «что изменилось с момента X»
получил бы шум, неотличимый от настоящего досчёта. Провенанс несёт собственную
метку — времени приёма своей доставки, — и для тай-брейка её достаточно.
Обновление провенанса SHALL быть идемпотентным: равные позиции журнала (та же
доставка, свёрнутая повторно) ничего не меняют.
Слово «провенанс» у сущности и у часового объекта означает **разное**, и это
называется вслух: у объекта хранится доставка, **создавшая** его, и она не
поднимается никогда; у сущности — доставка, **чья версия лежит сейчас**, и она
поднимается до максимума по журналу среди версий с этим содержимым. Причина в
том, что у объекта нет замещения версии целиком, а у сущности только оно и есть.
Чтение сохранённой версии, сравнение и запись результата SHALL идти **одной
транзакцией**: хеш и провенанс, на которых держится весь тай-брейк, читаются
там же, где пишется исход. Оптимистичное чтение до транзакции допустимо только
с перепроверкой обоих внутри — иначе две конкурентные свёртки одной сущности
прочитают одну и ту же старую позицию, обе решат «я позже», и победит та, что
закоммитила последней: исход снова станет функцией порядка коммитов, а не
журнала, причём молча.
Отличие от точки здесь содержательное: у точки на одних координатах законно
встречаются два разных измерения, и предпочитать позднее нет оснований — там
исход решает порядок канонических форм. У сущности `id` — идентичность одного
объекта HealthKit, и вторая версия есть тот же объект, пересчитанный источником;
тай-брейк по канонической форме заморозил бы тренировку на произвольной из
версий навсегда, вместе с недосчитанной энергией.
Версии одного ключа **внутри одной доставки** позициями не различаются, и
победитель среди них SHALL быть **функцией множества версий, а не порядка
элементов массива**: сперва отбрасываются строго покрытые кем-то из остальных,
среди оставшихся берётся минимум канонической формы. «Строго покрыта» означает
«покрыта другой версией и сама её не покрывает»: покрытие — предпорядок, две
версии могут покрывать друг друга взаимно, и отбрасывание всего покрытого
опустошило бы множество, потеряв обе. Порядок при этом обязан быть **тотальным
до конца**: при совпавших канонических формах решает минимум исходных байтов —
иначе победителем оказывается тот, кто стоял в массиве раньше, а порядок ключей
в JSON от HAE нестабилен, и в хранилище легли бы разные байты при одинаковом
содержимом. Попарная свёртка здесь
неверна ровно так же, как она была неверна для точек: покрытие — частичный
порядок, тай-брейк — тотальный, и вместе они дают нетранзитивное отношение
победы, при котором `[A,B,C]` и `[B,C,A]` дают разных победителей, а порядок
элементов в JSON-массиве нестабилен. Сворачиваться между собой такие версии
SHALL до сравнения с сохранённой.
Факт «в одном теле приехали две версии одного ключа с разным содержанием» SHALL
считаться **симметрично** и тоже быть функцией множества: считаются кандидаты,
чья каноническая форма отличается от формы победителя. Счётчик этот SHALL быть
ОТДЕЛЬНЫМ от счётчика удержаний: две версии в одном теле содержания не теряют —
победитель ложится в витрину целиком, — и одно число на два события отвечало бы
ни на одно. На счётчик удержаний опирается единственный контроль того, что
правило покрытия не стало слишком строгим; примесь делает его неотличимым от
шума.
Версии с совпавшей канонической формой SHALL схлопываться ДО выбора победителя.
Выбор квадратичен по числу кандидатов, а их число приходит из чужого тела; без
схлопывания тело в пределах приёма занимает свёртку на часы. Отбор SHALL видеть
отмену: иначе дедлайн свёртки, заведённый ровно против зависшей работы, не
значит ничего. Побайтовое различие при
совпавшей канонической форме событием MUST NOT считаться — порядок ключей в
JSON от HAE нестабилен и дребезг последнего разряда double тоже, так что
счётчик по байтам срабатывал бы на измеренной норме потока. Различие
**содержимого** при совпадающих множествах ключей и длинах массивов считаться
SHALL: сегодня ровно этот случай даёт ноль и молчащий счётчик.
Поля версий MUST NOT объединяться: несравнимые наборы (приехавшая принесла
новые ключи и потеряла старые) разрешаются в пользу сохранённой и считаются
тем же счётчиком. Объединение отвергнуто там же и по той же причине, что для
точек: на живом потоке событие не наступало, и вместо реализации заведено
наблюдение.
Исход SHALL быть функцией журнала в его порядке. Остаточный предел называется
вслух: сравнение сохранённой с приехавшей попарно — в витрине лежит победитель
прошлых слияний, а не все кандидаты истории, — поэтому при несравнимых наборах
(пункт 5) исход зависит от порядка проигрывания. Тот же предел есть у часового
объекта; пункты 1–4 от порядка свёртки не зависят, а пункт 5 сопровождается
счётчиком и `WARN`.
#### Scenario: Доехавший маршрут замещает тренировку без маршрута
- **WHEN** та же тренировка приезжает повторно, добавив `route`
- **THEN** в хранилище лежит версия с маршрутом
#### Scenario: Досчитанные значения при том же наборе полей побеждают
- **WHEN** та же тренировка приезжает повторно с тем же набором полей и
изменившимися значениями, доставкой с более поздней позицией журнала
- **THEN** в хранилище лежит приехавшая версия
#### Scenario: Версия из более ранней доставки не откатывает витрину
- **WHEN** две доставки несут одну тренировку с равными наборами полей, и
свёрнута сперва более поздняя по журналу, затем более ранняя
- **THEN** в хранилище лежит версия из более поздней доставки
- **AND** тот же исход даёт свёртка в обратном порядке
#### Scenario: Обеднённая версия сохранённую не затирает
- **WHEN** та же тренировка приезжает повторно **без** `route`, который был у
сохранённой, **и** с изменившимися значениями общих полей
- **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается счётчиком и записью `WARN` с идентификатором
тренировки
#### Scenario: Усечённый маршрут сохранённый не затирает
- **WHEN** та же тренировка приезжает повторно с тем же набором полей, но
`route` короче сохранённого
- **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается тем же счётчиком
#### Scenario: Маршрут из пустых элементов сохранённый не затирает
- **WHEN** та же тренировка приезжает повторно с `route` той же длины, все
элементы которого пусты (`null` либо пустой объект)
- **THEN** в хранилище остаётся сохранённая версия с координатами маршрута
- **AND** факт учитывается тем же счётчиком
#### Scenario: Скелет из скаляров сохранённую тренировку не затирает
- **WHEN** та же тренировка приезжает повторно, где каждый вложенный объект
заменён числом, а каждый массив — массивом той же длины из `null`
- **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается тем же счётчиком
#### Scenario: Ключ с пустым значением не исчезает по жребию
- **WHEN** та же тренировка приезжает повторно без ключа, значение которого у
сохранённой было пустым, при совпадающих содержательных ключах
- **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается тем же счётчиком удержаний
#### Scenario: Пустой ключ не запирает законный досчёт
- **WHEN** у сохранённой версии есть ключ с пустым значением, а приехавшая его
не несёт, но приносит содержательный ключ, которого у сохранённой не было
- **THEN** приехавшая замещает сохранённую
- **AND** счётчик удержаний не растёт
#### Scenario: Две версии одной сущности в одном теле
- **WHEN** тело содержит два элемента секции с одним `id`
- **THEN** исход не зависит от их порядка в массиве
- **AND** счётчик различающихся версий тоже не зависит от их порядка
#### Scenario: Три версии одной сущности в одном теле
- **WHEN** тело содержит три элемента секции с одним `id`, из которых один
покрывает второй, а третий несравним с обоими
- **THEN** победитель одинаков при любой перестановке этих трёх элементов
#### Scenario: Две версии разного содержания при равной длине массивов
- **WHEN** тело содержит два элемента секции с одним `id`, содержимое которых
различается, но множества ключей и длины верхнеуровневых массивов совпадают
- **THEN** факт учитывается счётчиком различающихся версий
#### Scenario: Разные байты при совпавшей канонической форме событием не считаются
- **WHEN** тело содержит два элемента секции с одним `id`, различающихся только
порядком ключей либо записью числа
- **THEN** счётчик различающихся версий не растёт
- **AND** в хранилище лежат одни и те же байты при любой перестановке элементов
#### Scenario: Повторная присылка обновляет провенанс
- **WHEN** та же сущность приезжает повторно с тем же содержимым доставкой,
стоящей в журнале позже сохранённой
- **THEN** содержимое не переписывается
- **AND** провенанс сущности указывает на более позднюю доставку
#### Scenario: Отложенная доставка не возвращает витрину к прежнему содержимому
- **WHEN** журнал несёт содержимое A, затем B, затем снова A, и доставка с B
свёрнута последней
- **THEN** содержимое сущности и отпечаток витрины совпадают со свёрткой того
же журнала в его порядке
#### Scenario: Составной ключ не даёт коллизии отпечатка
- **WHEN** две витрины различаются только тем, где проходит граница между родом
и идентификатором записи
- **THEN** отпечатки не совпадают
#### Scenario: Несравнимые наборы полей не объединяются
- **WHEN** приехавшая версия несёт содержательный ключ, которого нет у
сохранённой, и теряет содержательный ключ, который у сохранённой есть
- **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается тем же счётчиком
#### Scenario: Повторная свёртка того же журнала состояния не меняет
- **WHEN** те же доставки сворачиваются повторно в том же порядке
- **THEN** содержимое сущностей не меняется
### Requirement: Хранение сущностей с собственным идентификатором
Система SHALL хранить тренировки и записи секций с собственным `id` **не**
часовыми объектами, а по одной строке на сущность: у них есть естественный
ключ, они редки (за двое суток потока — две тренировки и две записи состояния
разума), и группировать их по часам незачем.
Единиц хранения две:
```
тренировка ключ id
заголовок колонками: имя, начало, конец, офсет зоны, длительность
запись ключ род секции + id
заголовок колонками: род, метка времени, офсет зоны
```
Сущность SHALL нести **провенанс** — идентификатор доставки, чья версия лежит
сейчас, и метку приёма этой доставки. Он нужен не отчётности: по нему
разрешается тай-брейк между версиями равной полноты (см. «Замена версии
сущности…»), и без него `WARN` об удержанной обеднённой версии не связать с
телом в архиве.
Длительность тренировки SHALL допускать отсутствие значения, отличимое от нуля:
ноль — законная длительность, и потребитель, сложивший столбец, иначе не отличил
бы «источник не прислал» от «измерено ноль».
Ключ записи SHALL быть парой `род + id`, а не одним `id`. Собственный `id`
наблюдался живьём только у `stateOfMind`, где он UUID HealthKit; форма
идентификатора остальных пяти секций не наблюдалась никем, и короткий
несквозной `id` в двух разных секциях затёр бы одну запись другой молча. Пара
стоит ноль: запросы к записям всегда идут с родом.
Содержимое сущности SHALL храниться **дословно** — теми же байтами, какими
пришло, включая маршрут, внутренние ряды и сводки. Заголовок колонками
существует ради выборки по времени и не является разбором содержимого: любая
следующая колонка была бы решением за Apple о том, что в тренировке главное.
Ряд пульса внутри тренировки MUST лежать в её содержимом, а не в объектах
метрики `heart_rate`: это разные таблицы, и смешение задвоило бы ряд.
Сущности доставки SHALL записываться **той же транзакцией**, что и её точки.
Доставка — единица свёртки; частичное состояние ломает инвариант «состояние
пересобираемо», а наблюдение «секции не смешиваются в одной доставке» собрано
за двое суток и основанием для второй транзакции не является.
Система SHALL хранить рядом с сущностью хеш её канонического содержимого и
пропускать запись содержимого, если хеш не изменился. Тренировка
переприсылается каждой доставкой автоматизации, пока не доедет маршрут: на
живом архиве 44 доставленные копии дают три различных содержимых.
Сравнение SHALL начинаться с хеша, читаемого **без** содержимого сохранённой
сущности: маршрут доходит до мегабайта, разжимать и канонизировать его на каждой
из 44 копий не за чем.
Каноническая форма приехавшей сущности SHALL считаться **один раз на версию и
до входа в транзакцию**, а хеш SHALL браться из уже посчитанной формы. Внутри
транзакции канонизации приехавших версий быть MUST NOT: транзакция открывается
`immediate`, то есть блокирует запись, и повторяется до пяти раз при занятости
базы — измерено, что тело 40 МиБ даёт пик кучи 768 МиБ, а тело 63 МиБ удерживает
блокировку 5.019 с при `busy_timeout` 5000, после чего конкурентная доставка
исчерпывает повторы. Считать форму дважды (в хеше и в сравнении) система MUST
NOT: это ровно та же работа над теми же байтами.
Остаточный предел называется вслух: разбор **сохранённой** версии остаётся
внутри транзакции — её содержимое читается оттуда же и только когда хеш
разошёлся. Значит удержание блокировки по-прежнему пропорционально размеру
сохранённой сущности, и класс отказа «конкурентный приём исчерпал повторы → 500
по доставке, тело которой уже в архиве» этим требованием **не закрывается**, а
лишь становится различимым в логе. Закрыть его может только предел на размер
сущности вместе с потоковым расчётом — отдельная задача.
#### Scenario: Тренировка хранится одной строкой с маршрутом
- **WHEN** приезжает тренировка с маршрутом
- **THEN** она хранится одной строкой, адресуемой своим `id`
- **AND** маршрут и внутренние ряды лежат в её содержимом дословно
#### Scenario: Ряд пульса тренировки не попадает в метрику
- **WHEN** тренировка несёт `heartRateData`
- **THEN** объектов метрики `heart_rate` эта доставка не создаёт
#### Scenario: Записи разных родов с одинаковым id не сталкиваются
- **WHEN** две записи разных родов приезжают с одним и тем же `id`
- **THEN** в хранилище лежат обе
#### Scenario: Повторная присылка той же тренировки не пишет в базу
- **WHEN** приезжает тренировка, содержимое которой совпадает с сохранённым, и
её доставка стоит в журнале не позже сохранённой
- **THEN** хеш совпадает и запись не выполняется
#### Scenario: Отказ посреди доставки не оставляет части сущностей
- **WHEN** свёртка доставки прерывается на середине
- **THEN** не записывается ни одна сущность этой доставки
#### Scenario: Каноническая форма сущности считается один раз
- **WHEN** доставка с сущностью сворачивается, и транзакция повторяется из-за
занятости базы
- **THEN** каноническая форма приехавшей сущности не пересчитывается ни на
повторе, ни отдельно от хеша
## ADDED Requirements
### Requirement: Открытие базы отказывает при схеме из будущего
Открытие витрины SHALL сверять версию схемы базы с версией, вшитой в бинарь, до
наката миграций. Версия базы **выше** версии бинаря MUST быть отказом с
указанием обеих, а не поводом мигрировать: прецедент уже записан для открытия
только на чтение — «расхождение версий — отказ, а не повод мигрировать».
Без этого откат бинаря проходит молча: старый бинарь поверх новой схемы
стартует успешно, незнакомые секции игнорирует и доставки за окно отката
помечает разобранными — то есть ничто не намекает, что для этого окна нужна
пересборка. Класс «молчание», и цена его растёт вместе с ретеншеном: после
удаления тел окно становится невосстановимым.
Асимметрия относится **только к открытию с накатом миграций**: там версия базы
ниже версии бинаря отказом быть MUST NOT — ради этого случая миграции и
существуют. Открытие **только на чтение** сохраняет строгое равенство версий,
как уже нормировано пересборкой: утилита, которой достаточно прочитать учёт, на
базе старее бинаря читала бы колонки, которых там ещё нет. Ослабление этого
отказа настоящим требованием запрещено.
В одно место SHALL выноситься **чтение** версии, а не сравнение: сравнивают эти
два способа открытия по-разному, а читают одинаково. Отсутствие журнала
миграций (новая база) SHALL означать версию 0, и распознаваться это MUST по
структуре базы, а не по тексту ошибки драйвера — сообщения драйвера контрактом
не являются, и это уже записанное правило проекта.
Читать версию система SHALL средствами того же инструмента миграций, которым их
накатывает, если он это умеет: имя таблицы учёта, имя колонки и правило
«максимум = текущая версия» принадлежат ему, и рукописная копия его приватной
схемы разошлась бы при обновлении зависимости — причём не отказом, а тем, что
страж перестал бы ловить. Если цена такого чтения неприемлема (например, оно
требует записи на соединении только для чтения), копия допустима, но SHALL жить
одной функцией с названной вслух причиной.
Эксплуатационная цена отказа называется вслух, потому что она реальна: сервис не
поднимется, а телефон шлёт непрерывно и молча, и доставка, не попавшая в архив,
в журнал не попадает вовсе. Выбор сделан так потому, что откат бинаря — действие
оператора, который в этот момент рядом и видит отказ сразу, а дыры плотных
метрик закрывают широкий и глубокий проходы синхронизации. Не закрывается ими
`stateOfMind`: у него доставки HAE единственный источник, и окно простоя для
него — потеря без возврата. Молчаливый старт при этом стоит дороже: он портит
витрину за всё окно отката, и узнать об этом неоткуда.
#### Scenario: Старый бинарь не открывает базу из будущего
- **WHEN** в журнале миграций базы стоит версия выше последней, вшитой в бинарь
- **THEN** открытие завершается отказом с указанием обеих версий
- **AND** миграции не накатываются
#### Scenario: Новая база открывается и мигрирует
- **WHEN** базы ещё нет либо журнал миграций пуст
- **THEN** открытие проходит и накатывает миграции до версии бинаря
#### Scenario: Открытие только на чтение остаётся строгим
- **WHEN** версия схемы базы ниже последней, вшитой в бинарь, и база
открывается только на чтение
- **THEN** открытие завершается отказом с указанием обеих версий
### Requirement: Пропущенные сущности видны в учётной записи доставки
Учётная запись доставки SHALL нести число сущностей, которые разбор пропустил:
без `id`, с непомерно длинным `id`, с неразбираемой меткой времени или не
разобравшихся как объект.
Причина не в отчётности. Ретеншен сырого архива решает «что потеряется, если
тело удалить», **по базе**, и сегодня получает ответ «терять нечего» ровно там,
где потеряна тренировка с маршрутом: сущность в витрину не попала, список
непокрытых секций пуст, статус `parsed`. Лог здесь не годится — он ротируется,
а решение об удалении тела необратимо.
Число SHALL замещаться целиком при каждой свёртке доставки, включая замещение
нулём: иначе доставка, пропуски которой исчезли вместе с поумневшим разбором,
осталась бы помеченной навсегда. Записываться оно SHALL в обоих исходах свёртки
— и при успехе, и при отказе, если разбор успел досчитать, — тем же правилом,
каким уже записывается список непокрытых секций.
**«Не измерялось» SHALL быть отличимо от нуля, и на пути отказа тоже.** Разбор,
вернувший ошибку, отдаёт нулевые счётчики по построению, а не по измерению;
записать этот ноль значило бы объявить проверенной доставку, содержимое которой
никто не смотрел. Число SHALL записываться только когда разбор досчитал; во всех
прочих исходах колонка MUST оставаться нетронутой — той же идиомой, какой уже
сохраняется выведенный слой. Доставки, свёрнутые разбором,
который пропусков не считал, значения не имеют, и подстановка нуля объявила бы
их проверенными: ретеншен получил бы то самое ложное «терять нечего», ради
которого счётчик и заводится, — только теперь с видом измерения. Поэтому
колонка допускает отсутствие значения, миграция его не подставляет, а читатель,
принимающий по счётчику необратимое решение, SHALL трактовать отсутствие как
«не удалять». Замер на живом архиве (118 тел) даёт ноль пропусков всех классов,
то есть исторический корпус ничего не потерял, — но «ничего не потерял по
замеру» и «проверено этим разбором» это разные утверждения, и колонка обязана
их различать.
Счётчик — производное от разбора поле: пересборка витрины SHALL начинать его
пустым и переносить из журнала MUST NOT, иначе свежая витрина унаследует
измерение прежнего разбора.
Статус разбора от пропуска сущности меняться MUST NOT: `partial` определён
списком непокрытых секций, и второй источник истины для него завёл бы ровно то
расхождение читателей, которое учёт частичного разбора запрещает явно.
#### Scenario: Пропущенная сущность видна в учёте доставки
- **WHEN** тело несёт покрытую секцию, один элемент которой не разобрался
- **THEN** число пропущенных сущностей у доставки больше нуля
- **AND** соседние сущности той же секции сохранены
#### Scenario: Пересвёртка без пропусков обнуляет счётчик
- **WHEN** доставка с ненулевым числом пропущенных сущностей сворачивается
повторно разбором, который эти элементы понимает
- **THEN** число пропущенных сущностей у доставки равно нулю
#### Scenario: Доставка, свёрнутая до появления счётчика, отличима от нулевой
- **WHEN** доставка была свёрнута разбором, который пропусков не считал, и с тех
пор не пересворачивалась
- **THEN** её число пропущенных сущностей отсутствует, а не равно нулю