добавлен словарь категориальных значений HAE → коды HealthKit

- фазы сна, контекст пульса и имена тренировок попадают в реестр
  `category_value` (миграция 00010): строка хранится дословно, выведенный код
  лежит рядом отдельной записью, а не полем внутри точки
- словарь и синонимы кодов живут в бинаре (`internal/healthkit`); локаль из
  `Accept-Language` сужает поиск, но в ключ реестра не входит — заголовков в
  сыром архиве нет
- наблюдение входит в отпечаток витрины, выведенный код — нет: он производная
  от словаря, а не от журнала
This commit is contained in:
av
2026-08-04 07:32:19 +03:00
parent eb3fca77ee
commit 1b649ba3d5
40 changed files with 4317 additions and 42 deletions
+150
View File
@@ -0,0 +1,150 @@
package store
import (
"context"
"database/sql"
"fmt"
"time"
)
// CategoryValue — строка реестра категориальных значений: перечислимая строка,
// которую приносил поток, и выведенный для неё код HealthKit.
//
// Значение хранится дословно; код — кэш чистой функции от словаря в бинаре, и в
// отпечаток витрины он не входит (см. fingerprintCategories).
type CategoryValue struct {
// Metric — имя метрики или секции, ТО ЖЕ, которым адресуется единица
// хранения.
Metric string
// Field — имя поля внутри точки или сущности, дословно как у HAE.
Field string
// Value — строка, как прислал HAE.
Value string
// Code — канонический код HealthKit; пустой означает «словарь не знает».
Code string
// FirstSeen и FirstDeliveryID — провенанс ПЕРВОЙ встречи по порядку журнала.
// Заполняются хранилищем, вызывающему при записи не нужны.
FirstSeen time.Time
FirstDeliveryID string
}
// mergeCategories записывает наблюдения доставки в реестр.
//
// Правило записи одно и оно решает всё: код обновляется ВСЕГДА, провенанс —
// только вниз, к более раннему месту в журнале. Иначе повторная свёртка той же
// доставки меняла бы состояние, а проигрывание журнала в порядке `(received_at,
// id)` давало бы не то, что живой приём.
//
// Сравнение провенанса идёт парой `(received_at, id)`, а не одной меткой:
// доставки одной секунды в журнале упорядочены идентификатором, и сравнение по
// одной метке сделало бы исход зависящим от того, какая из них свернулась
// раньше. Пара выражена лексикографически прямо в SQL — переносить сравнение в
// Go значило бы читать строку, решать и писать, то есть добавлять обращение к
// базе на каждое наблюдение внутри транзакции.
func mergeCategories(ctx context.Context, tx *sql.Tx, values []CategoryValue, from DeliveryRef) error {
if len(values) == 0 {
return nil
}
const q = `
INSERT INTO category_value
(metric, field, value, code, first_seen_utc, first_delivery_id)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (metric, field, value) DO UPDATE SET
code = excluded.code,
first_seen_utc = CASE
WHEN (excluded.first_seen_utc, excluded.first_delivery_id)
< (category_value.first_seen_utc, category_value.first_delivery_id)
THEN excluded.first_seen_utc ELSE category_value.first_seen_utc END,
first_delivery_id = CASE
WHEN (excluded.first_seen_utc, excluded.first_delivery_id)
< (category_value.first_seen_utc, category_value.first_delivery_id)
THEN excluded.first_delivery_id ELSE category_value.first_delivery_id END`
stmt, err := tx.PrepareContext(ctx, q)
if err != nil {
return fmt.Errorf("prepare category upsert: %w", err)
}
defer func() { _ = stmt.Close() }()
at := FormatTime(from.ReceivedAt)
for _, v := range values {
if _, err := stmt.ExecContext(ctx, v.Metric, v.Field, v.Value, v.Code, at, from.ID); err != nil {
// Значение в текст ошибки не попадает: строка категориального
// значения — данные о здоровье наравне со значением точки.
//
// Координаты обрезаются, хотя сегодня они приходят из объявленного
// списка полей и произвольной строкой из тела быть не могут. Предел
// стоит здесь, а не держится на форме того списка: правило вида
// «поле `value` у метрик с таким-то именем» уронило бы имя метрики
// из чужого тела прямо в `ERROR`, и заметить это было бы нечем.
return fmt.Errorf("upsert category value (%s/%s): %w",
clipCoord(v.Metric), clipCoord(v.Field), err)
}
}
return nil
}
// CategoryValues возвращает реестр целиком в детерминированном порядке.
//
// Реестр мал по построению — на живом потоке различных значений около
// одиннадцати, — поэтому страничного чтения у него нет и заводить его незачем:
// границы разбора не дают доставке положить больше 64 значений, а число полей
// объявлено списком.
func (s *Store) CategoryValues(ctx context.Context) ([]CategoryValue, error) {
const q = `
SELECT metric, field, value, code, first_seen_utc, first_delivery_id
FROM category_value ORDER BY metric, field, value`
rows, err := s.db.QueryContext(ctx, q)
if err != nil {
return nil, fmt.Errorf("select category values: %w", err)
}
defer func() { _ = rows.Close() }()
var out []CategoryValue
for rows.Next() {
var v CategoryValue
var firstSeen string
if err := rows.Scan(&v.Metric, &v.Field, &v.Value, &v.Code, &firstSeen, &v.FirstDeliveryID); err != nil {
return nil, fmt.Errorf("scan category value: %w", err)
}
v.FirstSeen, err = ParseTime(firstSeen)
if err != nil {
return nil, err
}
out = append(out, v)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("select category values: %w", err)
}
return out, nil
}
// CountCategoryValues возвращает число строк реестра.
//
// Нужно отчёту пересборки: реестр — единица хранения витрины, и без счётчика
// «до и после» расхождение отпечатков по нему безадресно — числа объектов,
// тренировок и записей при этом не меняются. Перечислением отвечать нельзя:
// сами строки — данные о здоровье, а отчёт печатается человеку в терминал.
func (s *Store) CountCategoryValues(ctx context.Context) (int64, error) {
var n int64
if err := s.db.GetContext(ctx, &n, `SELECT count(*) FROM category_value`); err != nil {
return 0, fmt.Errorf("count category values: %w", err)
}
return n, nil
}
// maxCoordInLog — предел длины координаты наблюдения в тексте ошибки.
//
// Та же граница и по той же причине, что у имени метрики в координатах
// столкновения: значение из тела ничем не ограничено, а предел приёма — 64 МиБ.
const maxCoordInLog = 64
func clipCoord(s string) string {
if len(s) <= maxCoordInLog {
return s
}
return s[:maxCoordInLog] + "…"
}