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

- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной
  строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`;
  миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки
  с этими ключами
- сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет
  содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а
  при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА —
  «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина
  расходилась бы с пересборкой молча
- отпечаток витрины покрывает тренировки и записи и снимается одним снимком
  базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
This commit is contained in:
av
2026-08-02 13:05:16 +03:00
parent c28de9796e
commit f8200f7f80
47 changed files with 5817 additions and 301 deletions
+66
View File
@@ -246,6 +246,72 @@ func (f Fields) Relate(g Fields) Fullness {
return FullnessEqual
}
// Covers говорит, несёт ли f всё СОДЕРЖАНИЕ g: каждый содержательный ключ g
// есть у f, и ни один верхнеуровневый массив не стал короче.
//
// Отдельно от Relate, и это не дубль. Relate гасит отношение включения до
// FullnessEqual, когда значения общих содержательных ключей разошлись, — верно
// для точки (надмножество имён при других значениях означает другое
// измерение), но неверно для сущности с собственным `id`: у неё вторая версия
// есть тот же объект, пересчитанный источником, и значения между версиями
// расходятся ВСЕГДА. Приложи Relate к тренировке — и обеднённая версия
// получила бы «равенство» и заместила бы сохранённую вместе с маршрутом
// (95% её веса), а тест на фикстуре с неизменёнными значениями остался бы
// зелёным.
//
// Длина верхнеуровневых массивов сравнивается потому, что усечённый маршрут
// (три точки вместо 593) ключа не теряет. Досчёт ряды удлиняет, поэтому
// укорачивание — законный признак «приехало меньше». Предел правила назван
// вслух: сокращение ВНУТРИ элемента ряда (точка маршрута без altitude) не
// ловится ничем, кроме сверки с телом в архиве.
//
// Длины считаются здесь, а не в Analyze: Analyze зовётся на каждый кандидат
// слияния точек, и разбор heartbeatSeries на каждой точке стоил бы дороже
// самого сравнения.
func (f Fields) Covers(g Fields) bool {
for k, gv := range g.full {
fv, ok := f.full[k]
if !ok {
return false
}
gn, gok := arrayLen(gv)
if !gok {
continue
}
fn, fok := arrayLen(fv)
if !fok || fn < gn {
return false
}
}
return true
}
// arrayLen возвращает число элементов верхнеуровневого массива. Второй возврат
// — является ли значение массивом вообще.
//
// Элементы проглатываются в выбрасываемый RawMessage: считать нужно только
// количество, а материализация маршрута в дерево значений стоила бы того же,
// от чего отказался разбор тела.
func arrayLen(raw json.RawMessage) (int, bool) {
if len(bytes.TrimSpace(raw)) == 0 || bytes.TrimSpace(raw)[0] != '[' {
return 0, false
}
dec := json.NewDecoder(bytes.NewReader(raw))
if _, err := dec.Token(); err != nil { // открывающая скобка
return 0, false
}
n := 0
for dec.More() {
var skip json.RawMessage
if err := dec.Decode(&skip); err != nil {
return 0, false
}
n++
}
return n, true
}
// agreeOnShared говорит, совпадают ли значения ключей, содержательных у обеих
// точек. Сравнение каноническое: порядок ключей и дребезг последнего разряда
// расхождением не считаются.