- беклог и план переехали в docs/tasks (38 задач, 11 целей), слаги переименованы с транслита на английские, 85 ссылок поправлены - conventions.md разобран в docs/conventions/, local-research.md — в docs/research/, review-journal.md — в docs/review.md с разделом настройки конвейера; заведены security.md, adr/ и .pm.json - шаг docs.py check добавлен в task gate; поведение в architecture.md помечено девятью маркерами долга, database.md получил настройки с числовым значением
416 lines
36 KiB
Markdown
416 lines
36 KiB
Markdown
## Context
|
||
|
||
Разбор покрывает одну секцию тела — `metrics`. Остальное перечисляется в
|
||
`delivery.uncovered_sections`, доставка получает `partial`, тело живёт в архиве.
|
||
Замер по 118 доставкам архива: `metrics` — 65 доставок, `workouts` — 27,
|
||
`stateOfMind` — 26; ни одна доставка не несла двух секций сразу.
|
||
|
||
Тренировка и состояние разума устроены иначе, чем метрика, и это не стилистика:
|
||
|
||
- у них есть **собственный `id`** (UUID из HealthKit) — координатный ключ
|
||
`метрика + слой + начало + конец` им не нужен;
|
||
- они **редки**: за двое суток потока — 2 разных тренировки и 2 разных записи
|
||
состояния разума, при 44 и 52 доставленных копиях соответственно;
|
||
- у них **нет слоя**: подробности выгрузки у этих секций в интерфейсе HAE не
|
||
бывает, есть только глубина окна;
|
||
- тренировка **тяжёлая**: маршрут — 95% её веса (190 КБ из 199,6 КБ у
|
||
десятиминутной прогулки), и приезжает она повторно, пока маршрут не доедет.
|
||
|
||
Замер поведения при переприсылке (тот же архив, группировка по `id`):
|
||
|
||
```
|
||
тренировка A 26 копий 3 различных содержимых поля росли, не убывали
|
||
тренировка B 18 копий 1 содержимое маршрут с первой копии
|
||
stateOfMind 26 и 26 копий, по 1 содержимому каждая
|
||
```
|
||
|
||
Что именно менялось у тренировки A между версиями:
|
||
|
||
```
|
||
версия 0 → 1 +stepCadence, +stepCount, изменилось значение activeEnergy
|
||
версия 1 → 2 набор полей тот же, изменились totalEnergy и basalEnergy
|
||
```
|
||
|
||
То есть тренировка досчитывается задним числом ровно так же, как минутное ведро
|
||
(находка 10), и при этом набор полей за весь корпус ни разу не уменьшился.
|
||
|
||
## Goals / Non-Goals
|
||
|
||
**Goals:**
|
||
|
||
- Тренировка лежит в витрине целиком, вместе с маршрутом и внутренними рядами,
|
||
дословно и без интерпретации.
|
||
- Состояние разума лежит записями — секция, которой нет в экспорте Apple, больше
|
||
не зависит от того, что тело не удалили.
|
||
- Свёртка остаётся детерминированной: `reindex` даёт то же состояние, что живой
|
||
приём, и это проверяется отпечатком, а не «числом строк».
|
||
- Правило «при столкновении выигрывает более полная версия» продолжает
|
||
действовать и для сущности с собственным `id`.
|
||
|
||
**Non-Goals:**
|
||
|
||
- **Отдача наружу.** Read API в проекте нет вовсе; форма конверта, выбор слоя и
|
||
предел размера ответа проектируются задачей `read-api-points`. Два эндпоинта,
|
||
введённые раньше конверта, задали бы контракт мимоходом.
|
||
- **Секции, которых поток не приносил** (`ecg`, `symptoms`, `cycleTracking`,
|
||
`medications`, `heartRateNotifications`). Модель под них закладывается —
|
||
таблица `record` ключуется родом секции, — но разбор не пишется вслепую: их
|
||
формы никто не видел, а задача `unseen-sections-check` существует ровно про
|
||
момент, когда они появятся.
|
||
- **Разворачивание маршрута** в таблицу точек — отдельная идея беклога, у неё нет
|
||
клиента.
|
||
- **Словарь категориальных значений** (`name` тренировки — «В помещении Ходьба»,
|
||
машинная калька) — отдельная задача; здесь строка хранится дословно.
|
||
|
||
## Decisions
|
||
|
||
### 1. Две таблицы, а не одна с колонкой рода
|
||
|
||
`workout` и `record` разведены, как и записано в `docs/architecture.md`.
|
||
|
||
Общая таблица `entity(kind, id, …)` выглядит экономнее и хуже по существу: у
|
||
тренировки есть заголовок, который нужен запросом «что было за период» —
|
||
`name`, `start`, `end`, `duration`, — а у записи состояния разума его нет.
|
||
Общая таблица либо теряет заголовок (тогда список тренировок требует разжатия
|
||
каждого блоба), либо заводит колонки, пустые у пяти родов из шести.
|
||
|
||
Отвергнуто и обратное — таблица на каждый род секции: шесть почти одинаковых
|
||
таблиц, и каждая новая секция требует миграции. `record` ключуется родом, и
|
||
новая секция добавляется одной строкой в множество покрытых имён.
|
||
|
||
### 2. Ключ `record` — `(kind, id)`, а не один `id`
|
||
|
||
Отклонение от схемы, набросанной в `architecture.md` (`record(id PK, kind, …)`),
|
||
и оно намеренное. У `stateOfMind` `id` — настоящий UUID HealthKit, но остальные
|
||
пять секций живьём не видели никто: форма их идентификатора неизвестна, и
|
||
короткий несквозной `id` в двух разных секциях молча затёр бы одну запись
|
||
другой. Пара стоит ноль (запросы к `record` всегда идут с родом: `GET
|
||
/records/{kind}`) и снимает целый класс.
|
||
|
||
Ключ `workout` — `id`: род у него один.
|
||
|
||
### 3. Сущность заменяется целиком; побеждает не последняя, а не теряющая полей
|
||
|
||
Центральное решение задачи. Тренировка «перезаписывается» (беклог,
|
||
`architecture.md`), но инвариант проекта гласит «при столкновении выигрывает
|
||
более полная точка, а не последняя пришедшая». Развилку решает замер выше.
|
||
|
||
Правило:
|
||
|
||
```
|
||
1. каноническая форма совпала с сохранённой → записи нет (хеш-детектор)
|
||
2. приехавшая несёт всё, что сохранённая, и
|
||
сверх того → приехавшая замещает целиком
|
||
3. приехавшая теряет содержание сохранённой → остаётся сохранённая,
|
||
счётчик + WARN
|
||
4. содержание сравнимо (наборы равны) → версия из БОЛЕЕ ПОЗДНЕЙ
|
||
доставки журнала
|
||
5. наборы несравнимы → остаётся сохранённая,
|
||
счётчик + WARN
|
||
```
|
||
|
||
**«Теряет содержание» считается по множеству ключей, а не по `canon.Relate`.**
|
||
Это единственная деталь, где реализация не может переиспользовать правило точек
|
||
как есть, и цена ошибки здесь — маршрут. Проверено выполненной командой на копии
|
||
пакета `canon`:
|
||
|
||
```
|
||
сохранённая vs обеднённая, значения общих полей те же : superset
|
||
сохранённая vs обеднённая, значения общих полей иные : equal
|
||
сохранённая vs усечённый маршрут (2 точки → 1) : equal
|
||
```
|
||
|
||
`canon.Fields.Relate` гасит отношение включения до `equal`, когда значения
|
||
общих содержательных ключей разошлись, — и это верно для точки (надмножество
|
||
имён при других значениях означает другое измерение), но неверно для сущности:
|
||
замер выше говорит, что между версиями тренировки значения меняются **всегда**.
|
||
То есть настоящая обеднённая версия пришла бы с изменёнными значениями,
|
||
получила бы `equal` и заместила бы сохранённую целиком, а тест на наивной
|
||
фикстуре (значения не тронуты) остался бы зелёным.
|
||
|
||
Поэтому в `canon` заводится вторая, явная операция — сравнение **множеств
|
||
содержательных ключей** без условия о совпадении значений, поверх уже
|
||
существующего внутреннего `relateKeys`. Именно в `canon`, а не в `store`:
|
||
пакет заведён ради единственной реализации сравнения, и вторая копия разошлась
|
||
бы с первой молча.
|
||
|
||
Полнота меряется **верхним уровнем** ключей и длиной верхнеуровневых массивов.
|
||
Второе добавлено намеренно: усечённый маршрут (3 точки вместо 593) ключа не
|
||
теряет, поэтому одних множеств мало, а маршрут — 95% веса тренировки. Досчёт
|
||
ряды удлиняет, а не укорачивает, так что укорачивание — законный сигнал
|
||
«приехало меньше». Предел правила назван вслух: сокращение **внутри** элемента
|
||
ряда (точка маршрута потеряла `altitude`) не ловится ничем.
|
||
|
||
**Тай-брейк при равных наборах — позиция доставки в журнале, а не порядок
|
||
свёртки.** У точки при равной полноте исход решает порядок канонических форм
|
||
(`canon.Less`) — тай-брейк, который намеренно не выбран, пока не измерен род
|
||
агрегации. Приложи его к тренировке — и на наших же данных версия 1 → 2 (набор
|
||
полей тот же, досчитаны `totalEnergy` и `basalEnergy`) осталась бы на
|
||
произвольной из двух **навсегда**: тренировка замерла бы с недосчитанной
|
||
энергией. Причина расхождения содержательная: у точки на одной координате
|
||
законно встречаются два разных измерения (разные устройства,
|
||
пересэмплирование), и предпочитать позднее нет оснований; у сущности `id` —
|
||
идентичность одного объекта HealthKit, и вторая версия есть тот же объект,
|
||
пересчитанный источником.
|
||
|
||
Отвергнуто и напрашивавшееся «побеждает приехавшая»: приехавшая — это функция
|
||
**порядка свёртки**, а он не равен порядку журнала. Спека приёма говорит прямо,
|
||
что воркер сворачивает в порядке `(received_at, id)` только среди **видимых**
|
||
ему доставок, а абсолютного порядка при конкурентных приёмах не обещает
|
||
(`docs/architecture.md`, «Предел порядка назван вслух»; открытый блокер
|
||
`journal-order-on-ingest.md`). Доставка с более ранней меткой, свёрнутая
|
||
позже, вернула бы витрину к недосчитанной версии — и `reindex` разошёлся бы с
|
||
живым приёмом **молча**, в содержимом тренировки. Поэтому сущность несёт
|
||
провенанс — `delivery_id` и `received_at` своей доставки, — а тай-брейк
|
||
сравнивает пару `(received_at, id)`. Тогда исход при равных наборах зависит
|
||
только от журнала, а не от того, кто раньше добрался до базы.
|
||
|
||
Провенанс нужен и сам по себе: у часового объекта он обязателен («провенанс для
|
||
разбора слияний»), а `WARN` об удержанной обеднённой версии без него не связать
|
||
с телом в архиве.
|
||
|
||
Две версии с одинаковым ключом **внутри одной доставки** (позиции равны)
|
||
разрешаются минимумом канонической формы: порядок элементов в JSON-массиве
|
||
нестабилен, и опираться на него нельзя.
|
||
|
||
Почему **не** голый upsert по `id` (как делает сервер HealthyApps поверх
|
||
MongoDB и как просилось из формулировки «перезаписывается»): единственный
|
||
сценарий, ради которого тренировка приезжает повторно, — доезжающий маршрут,
|
||
то есть рост. Обратное — приезд версии без маршрута — за 44 доставленные копии
|
||
не случилось ни разу, но стоит 95% содержимого тренировки, а восстановление
|
||
требует пересборки всего журнала. Условие пункта 3 стоит одного сравнения
|
||
множеств и делает событие **наблюдаемым** вместо необратимого.
|
||
|
||
Несравнимые наборы (приехавшая принесла новые ключи и потеряла старые) в пункте
|
||
5 разрешаются в пользу сохранённой: поля не объединяются, объединение отвергнуто
|
||
там же, где для точек, — на живом потоке событие не наступало ни разу, и вместо
|
||
реализации заведено наблюдение.
|
||
|
||
**Остаточный предел назван вслух.** Слияние попарное — сохранённая против
|
||
приехавшей, — поэтому при несравнимых наборах (пункт 5) исход зависит от порядка
|
||
проигрывания. Тот же предел есть у часового объекта: в объекте лежит победитель
|
||
прошлых слияний, а не все кандидаты истории. Пункты 2–4 от порядка свёртки не
|
||
зависят, а пункт 5 сопровождается счётчиком и `WARN`, поэтому событие не будет
|
||
молчаливым.
|
||
|
||
### 4. Свёртка доставки остаётся одной транзакцией
|
||
|
||
`MergePoints` превращается в `Merge(ctx, Incoming{Points, Workouts, Records},
|
||
deliveryID)`: точки, тренировки и записи одной доставки пишутся одной
|
||
транзакцией. Спека хранения требует этого прямо («Доставка SHALL сворачиваться
|
||
одной транзакцией»), и требование не про точки, а про доставку: частичное
|
||
состояние ломает инвариант «состояние пересобираемо».
|
||
|
||
Наблюдение «ни одна доставка не несла двух секций сразу» (находка 50) собрано за
|
||
двое суток и основанием для второй транзакции не является.
|
||
|
||
Имя `MergePoints` уходит: метод перестал сливать одни точки, а два метода с
|
||
двумя транзакциями были бы вторым способом делать то же самое.
|
||
|
||
### 5. `payload` — сжатый блоб, как у часового объекта
|
||
|
||
Дословные байты сущности, gzip. Тот же приём и по той же причине, что у
|
||
`bucket`: маршрут — 95% веса тренировки, JSON такого рода жмётся примерно в
|
||
25 раз, а прогулка в час даёт порядка мегабайта. Цена названа там же и здесь та
|
||
же: внутрь `payload` не заглянуть SQL-функциями. Для хранилища, которое отдаёт
|
||
тренировку целиком, это не потеря; заголовок, по которому идёт выборка, лежит
|
||
колонками.
|
||
|
||
Отвергнуто хранение текстом (как было набросано в `architecture.md`, `payload
|
||
JSON`): второе кодирование для той же по природе величины стоило бы дороже
|
||
любой выгоды от `json_extract`, а объём — сотни мегабайт в год против десятков.
|
||
|
||
### 6. Заголовок тренировки — ровно то, по чему идёт выборка
|
||
|
||
`name`, `start_utc`, `end_utc`, `tz_offset`, `duration_sec`. Больше ничего:
|
||
любая следующая колонка — это решение за Apple о том, что в тренировке главное
|
||
(находка 15: сводки дублируют ряды, `distance` — это сумма
|
||
`walkingAndRunningDistance`).
|
||
|
||
`duration` берётся из тела, а не считается как `end - start`: HAE шлёт
|
||
91.746 секунды при интервале в 91 секунду, и вычисленное значение молча
|
||
разошлось бы с присланным. Отсутствует или не число — колонка **`NULL`**, а не
|
||
ноль: ноль — законная длительность, и потребитель, просуммировавший столбец, не
|
||
отличил бы «источник не прислал» от «измерено ноль». Тело в `payload` дословно в
|
||
любом случае.
|
||
|
||
`end` нечитаем или отсутствует — `end_utc` равен `start_utc`. У точки
|
||
вырождение интервала в мгновение запрещено, потому что схлопывает координату; у
|
||
сущности ключ — `id`, схлопывать нечего, а истина остаётся в `payload`. Офсет
|
||
берётся из `start`: колонка одна, а пробежка через смену зоны дала бы два
|
||
разных.
|
||
|
||
Метка записи — `start`, при его отсутствии `date`. `end` в заголовок не идёт:
|
||
у рода `daily_mood` он может отстоять от начала на сутки, и вторая колонка
|
||
понадобится вместе с запросом, которого пока нет.
|
||
|
||
`name` локализован («В помещении Ходьба»); хранится дословно, стабильный код
|
||
припишет задача словаря категориальных значений.
|
||
|
||
Значение `kind` у записи — верхнеуровневый ключ секции HAE **дословно**
|
||
(`stateOfMind`, не `state_of_mind`): инвариант «форма Apple не транслируется»
|
||
относится и к именам секций, а переименование после мерджа стоило бы миграции
|
||
данных.
|
||
|
||
### 7. Метка времени: у сущности оба известных формата, у точки — один
|
||
|
||
`parseEntityTime` пробует формат HAE (`2026-07-31 21:03:51 +0300`), затем
|
||
RFC 3339 (`2026-07-31T18:03:51Z`). Форматы измерены (находка 16: у тренировок
|
||
первый, у `stateOfMind` второй) и не пересекаются.
|
||
|
||
Отвергнуто приписывание формата секции: оно точнее описывает сегодняшний день и
|
||
ломается молча в тот, когда HAE выровняет секции между собой — а он к этому идёт
|
||
(`stateOfMind` уже шлёт честные коды HealthKit там, где старые секции шлют
|
||
переводы, находка 37). Цена терпимости нулевая: неоднозначности между двумя
|
||
формами нет.
|
||
|
||
**Точка остаётся строгой, и это не забывчивость.** У точки по метке выводится
|
||
слой, причём по метке **местной**: метка в UTC объявила бы часовую выгрузку
|
||
минутной, и минутный слой сложился бы с часовым (находка 35 — ровно такое
|
||
удвоение уже наблюдалось). Терпимый парсер там означал бы тихую порчу разреза;
|
||
строгий отдаёт непонятую метку в счётчик пропусков и `WARN`, а тело остаётся в
|
||
архиве. У сущности слоя нет, и терять на строгости нечего — асимметрия
|
||
намеренная.
|
||
|
||
Следствие, которое надо назвать вслух: у `stateOfMind` `tz_offset` всегда `0`,
|
||
потому что HAE прислал UTC, а не потому, что человек был в Гринвиче. Местная
|
||
зона этой секции в потоке отсутствует.
|
||
|
||
### 8. Отпечаток витрины покрывает сущности, и снимается одним снимком
|
||
|
||
`Store.Fingerprint` — единственный оракул сходимости `reindex` и `task
|
||
verify:archive`. Оставить его отпечатком одних часовых объектов значило бы
|
||
получить «состояние сошлось» при разъехавшихся тренировках — то есть сломать
|
||
проверку молча, ровно тем изменением, которое добавляет данные.
|
||
|
||
Три раздела читаются **одной read-only транзакцией**. Сегодня отпечаток — один
|
||
`SELECT`, то есть один снимок; три запроса подряд вне транзакции в режиме WAL
|
||
дают три снимка, а рабочий отпечаток снимается под живым приёмом. Свёртка,
|
||
закоммитившаяся между запросами, дала бы смесь «объекты до» и «тренировки
|
||
после», то есть ложное «разошлись» у единственного оракула. Прецедент в
|
||
проекте есть — `Store.Bucket` уже читает в `BeginTx(ReadOnly)`.
|
||
|
||
Строки разделов идут с константным тегом впереди (`b|`, `w|`, `r|`): без него
|
||
строка одного раздела может совпасть со строкой другого — та же причина, по
|
||
которой поля переменной длины уже идут с длиной впереди.
|
||
|
||
Отчёт `reindex` расширяется вместе с отпечатком: счётчики тренировок и записей
|
||
«было и стало» рядом с числом объектов, и «покрыта новая секция» в перечне
|
||
ожидаемых классов расхождения. Иначе первый же прогон после мерджа даст
|
||
гарантированное расхождение отпечатков при неизменившемся числе объектов — и
|
||
оракул выродится в шум ровно тогда, когда по нему принимается необратимое
|
||
решение о подмене базы.
|
||
|
||
### 9. Покрытыми становятся ровно две секции
|
||
|
||
`covered()` — множество из трёх имён: `metrics`, `workouts`, `stateOfMind`.
|
||
Прочие секции с собственным `id` остаются в списке непокрытых, доставка с ними
|
||
остаётся `partial`, тело — в архиве. Это честно: формы этих секций никто не
|
||
видел, а «полнота покрытия HealthKit ради полноты» целью проекта не является
|
||
(паспорт).
|
||
|
||
Следствие, которое надо назвать вслух и передать дальше: доставка из одного
|
||
`stateOfMind` теперь получает `parsed` с пустым списком непокрытых, то есть
|
||
становится **неотличимой** от доставки из метрик — а метрики восстановимы из
|
||
экспорта Apple, состояние разума нет (находка 46). До этой задачи защита
|
||
работала побочным эффектом непокрытости. Ретеншена в проекте нет, поэтому здесь
|
||
ничего не ломается сегодня; но предусловие, которое задача
|
||
`raw-archive-retention` считала снятым, снова открыто, и это записывается в
|
||
её файл тем же изменением.
|
||
|
||
### 9а. Отказ разбора остаётся «всё или ничего» — теперь и для сущностей
|
||
|
||
Действующее требование сформулировано через точки, потому что другого результата
|
||
у разбора не было. Три ветки надо назвать явно, иначе каждая решается
|
||
реализацией молча:
|
||
|
||
- **Тело оборвано после уже разобранной секции.** Разбор отдаёт ошибку и
|
||
**ни точек, ни сущностей**: иначе часть данных легла бы в витрину под
|
||
статусом, по которому доставку никто не подберёт.
|
||
- **Слой метрик не выводится, а в теле есть сущности.** Доставка целиком уходит
|
||
в `failed`, сущности не пишутся. Соблазн «сущностям слой не нужен, запишем
|
||
их» ломает то же «всё или ничего»: доставка получила бы `failed` при частично
|
||
записанной витрине, и повторная свёртка перестала бы быть no-op. Тело
|
||
остаётся в архиве, доставку вернёт пересборка. Цена названа: если такая
|
||
доставка когда-нибудь принесёт `stateOfMind`, его записи доедут не сразу, а
|
||
ретеншен `failed`-тела трогать не вправе.
|
||
- **Повтор ключа покрытой секции в одном `data`.** Секции **объединяются**, как
|
||
уже задано для `metrics`. Заодно чинится существующий дефект уровнем выше:
|
||
`decodeEnvelope` при повторе самого члена `data` результат второго члена
|
||
**присваивает**, а не добавляет, и имена первого глушатся общим `seen` — тело
|
||
с двумя `data` доезжает до `parsed` с молча потерянной секцией.
|
||
|
||
### 9б. Пределы на чужие строки
|
||
|
||
`id` приходит из тела и ничем не ограничен, а уезжает и в первичный ключ, и в
|
||
записи лога. Предел — 128 байт (UUID HealthKit — 36); сущность с более длинным
|
||
`id` пропускается тем же счётчиком, что и сущность без `id`. Правило то же, что
|
||
уже действует для имён непокрытых секций, и оно снимает класс, а не случай.
|
||
|
||
### 10. Миграция пересворачивает то, что стало покрытым
|
||
|
||
Спека хранения уже требует: «Задача, которая начинает разбирать секцию, тем же
|
||
изменением SHALL переводить `partial`-строки с этим ключом в `pending`».
|
||
Миграция `00007` переводит в `pending` доставки, у которых в
|
||
`uncovered_sections` встречается `workouts` или `stateOfMind`. Дальше их
|
||
подберёт обычный проход фонового воркера — отдельного кода для этого не
|
||
существует.
|
||
|
||
Перевод точечный, а не «все `partial`»: список — снимок покрытия, и доставка с
|
||
непокрытой `ecg` пересворачивать нечего.
|
||
|
||
## Risks / Trade-offs
|
||
|
||
- **Приехала версия без маршрута, а поля при этом переименовались** → правило
|
||
пункта 5 удержит сохранённую версию навсегда, и новых полей витрина не
|
||
увидит. → Счётчик и `WARN` с `id` сущности; тело в архиве, `reindex` применит
|
||
исправленное правило. Событие не наблюдалось ни разу.
|
||
- **Сокращение внутри элемента ряда правилом не ловится.** Длина
|
||
верхнеуровневых массивов сравнивается, а точка маршрута, потерявшая
|
||
`altitude`, — нет. → Названо вслух; ловится только сверкой с телом в архиве.
|
||
- **Маршрут удлиняет транзакцию свёртки.** Верхняя граница задаётся не примером,
|
||
а пределом тела приёма: 64 МиБ распакованного тела из одних тренировок дают
|
||
десятки мегабайт содержимого в одной транзакции, а задача беклога «Цена
|
||
слияния на широкой доставке» уже описывает, как 63 МБ на одной координате
|
||
держат транзакцию дольше `busy_timeout`. → Хеш-детектор снимает 41 запись из
|
||
44 на нашем корпусе, а сравнение начинается с узкого `SELECT content_hash`,
|
||
без чтения и разжатия блоба. Отдельной задачи не заводим: случай выражается
|
||
той же беклоговой задачей, что и точки.
|
||
- **Канонизация маршрута разворачивает его в дерево `any`.** `canon.Form`
|
||
материализует значение целиком — та самая форма, от которой отказался разбор
|
||
тела (197 МиБ кучи против 54 МиБ на теле 42 МиБ). → Хеш приехавшей сущности
|
||
считается **один раз на доставку**, до входа в транзакцию, а не на каждой из
|
||
пяти попыток повтора при занятости базы.
|
||
- **`import` родного экспорта Apple не даст `id` тренировки.** В `export.xml`
|
||
элемент `Workout` идентификатора не несёт — `dogsheep/healthkit-to-sqlite`
|
||
поэтому адресует тренировку **хешем содержимого** (`hash_id="id"` в
|
||
sqlite-utils). Значит импорт снапшота задвоит тренировки, приехавшие от HAE, —
|
||
ровно та же дыра, что у точек, где её закрыли ключом `start + end`. → Предел
|
||
назван здесь и заводится задачей беклога; сегодня импорта нет, и решать это
|
||
до его формы значило бы угадывать.
|
||
- **`tz_offset` у записей состояния разума всегда ноль** — не потеря наша, а
|
||
форма источника. → Названо в `docs/database.md`, чтобы клиент не считал по
|
||
нему местные сутки.
|
||
- **Фикстуры собираются из живого архива.** Тренировка несёт координаты
|
||
маршрута, запись состояния разума — эмоциональные метки; и то и другое
|
||
чувствительнее токенов. → Скрипт `tmp/research/fixtures.py` расширяется:
|
||
вычищаются числа (включая широту и долготу), UUID, метки RFC 3339 и словарные
|
||
значения `stateOfMind`; сохраняются форма литерала, структура и порядок
|
||
ключей. Проверка «в индексе нет данных о здоровье» остаётся за гейтом.
|
||
|
||
## Migration Plan
|
||
|
||
Миграция `00007_workout_record.sql`:
|
||
|
||
1. `CREATE TABLE workout` и `CREATE TABLE record` с индексами по времени.
|
||
2. `UPDATE delivery SET parse_status = 'pending'` для строк, чей
|
||
`uncovered_sections` содержит `workouts` или `stateOfMind`.
|
||
|
||
Откат (`Down`) снимает таблицы; восстановление содержимого — обычная пересборка
|
||
из архива, витрина производна по построению. Строки, переведённые в `pending`,
|
||
`Down` обратно не возвращает: какими они были, восстановить неоткуда, а
|
||
`pending` консервативен — ретеншен его не трогает.
|
||
|
||
Живой сервис миграцию переживает: новые таблицы никого не блокируют, `UPDATE`
|
||
идёт по 118 строкам.
|