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
+144
View File
@@ -0,0 +1,144 @@
package hae
import (
"bytes"
"encoding/json"
"strconv"
"time"
)
// maxEntityID — предел длины идентификатора сущности.
//
// `id` приходит из тела, которым отправитель управляет целиком, а уезжает и в
// первичный ключ таблицы, и в записи лога. UUID HealthKit — 36 байт, так что
// запас велик; правило то же, что уже действует для имён непокрытых секций, и
// оно снимает класс, а не случай.
const maxEntityID = 128
// rfc3339Layout — второй формат метки, которым HAE шлёт stateOfMind
// (находка 16). Метрики и тренировки идут первым, `timeLayout`.
const rfc3339Layout = time.RFC3339
// entityHead — поля сущности, нужные разбору. Всё остальное остаётся в Raw и
// хранится дословно.
//
// Длительность читается сырым сообщением, а не числом: нечисловое значение —
// это пропуск ОДНОГО поля, а не сломанная сущность, и типизированное поле
// уводило бы всю тренировку в счётчик «не разобралась как объект».
type entityHead struct {
ID string `json:"id"`
Name string `json:"name"`
Date string `json:"date"`
Start string `json:"start"`
End string `json:"end"`
Duration json.RawMessage `json:"duration"`
}
// decodeEntities разбирает элементы одной покрытой секции в сущности.
//
// Пропуск одного элемента не уносит соседей: у каждого класса пропуска свой
// счётчик, тело остаётся в архиве, и доставку вернёт пересборка, когда разбор
// научится понимать пропущенное.
func decodeEntities(raws []json.RawMessage, kind string, res *Result) []Entity {
if len(raws) == 0 {
return nil
}
out := make([]Entity, 0, len(raws))
for _, raw := range raws {
var head entityHead
if err := json.Unmarshal(raw, &head); err != nil {
res.SkippedEntityMalformed++
continue
}
if head.ID == "" || len(head.ID) > maxEntityID {
res.SkippedNoID++
continue
}
start, ok := parseEntityTime(firstNonEmpty(head.Start, head.Date))
if !ok {
res.SkippedEntityNoTime++
continue
}
// Конец, которого нет или который не читается, равен началу. У точки то
// же вырождение запрещено — там оно схлопнуло бы две записи в одну
// координату, — а сущность адресуется своим `id`, и схлопывать нечего.
// Истина при этом остаётся в Raw дословно.
end := start
if head.End != "" {
if e, ok := parseEntityTime(head.End); ok {
end = e
}
}
_, offset := start.Zone()
e := Entity{
ID: head.ID,
Kind: kind,
Name: head.Name,
Start: start.UTC(),
End: end.UTC(),
OffsetSeconds: offset,
Duration: parseDuration(head.Duration),
Raw: raw,
}
out = append(out, e)
}
if len(out) == 0 {
return nil
}
return out
}
// parseEntityTime разбирает метку сущности, принимая оба измеренных формата.
//
// Оба, а не приписанный секции: формы однозначны и не пересекаются (RFC 3339
// несёт `T` и `Z`), а HAE выравнивает секции между собой по ходу своих
// обновлений — `stateOfMind` уже шлёт стабильные коды HealthKit там, где старые
// секции шлют переводы. Приписанный секции формат ломался бы молча в день
// такого выравнивания.
//
// Метка ТОЧКИ остаётся строгой (parseTime), и асимметрия намеренная: по метке
// точки выводится слой, причём по метке в исходной зоне. Терпимость там
// означала бы, что метка в UTC тихо портит выравнивание и часовая выгрузка
// складывается с минутной; у сущности слоя нет, и терять на строгости нечего.
func parseEntityTime(s string) (time.Time, bool) {
if s == "" {
return time.Time{}, false
}
if t, err := time.Parse(timeLayout, s); err == nil {
return t, true
}
if t, err := time.Parse(rfc3339Layout, s); err == nil {
return t, true
}
return time.Time{}, false
}
// parseDuration переводит длительность в секунды.
//
// Отсутствие и нечисловое значение дают nil, а не ноль: ноль — законная
// длительность, и потребитель, сложивший столбец, иначе не отличил бы
// «источник не прислал» от «измерено ноль». Вычислять длительность из
// интервала нельзя: HAE шлёт 91.746 при интервале в 91 секунду.
func parseDuration(raw json.RawMessage) *float64 {
lit := bytes.TrimSpace(raw)
if len(lit) == 0 {
return nil
}
v, err := strconv.ParseFloat(string(lit), 64)
if err != nil {
return nil
}
return &v
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}