Files
healthlog/internal/store/category.go
T
av 1b649ba3d5 добавлен словарь категориальных значений HAE → коды HealthKit
- фазы сна, контекст пульса и имена тренировок попадают в реестр
  `category_value` (миграция 00010): строка хранится дословно, выведенный код
  лежит рядом отдельной записью, а не полем внутри точки
- словарь и синонимы кодов живут в бинаре (`internal/healthkit`); локаль из
  `Accept-Language` сужает поиск, но в ключ реестра не входит — заголовков в
  сыром архиве нет
- наблюдение входит в отпечаток витрины, выведенный код — нет: он производная
  от словаря, а не от журнала
2026-08-04 07:32:19 +03:00

151 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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] + "…"
}