добавлен словарь категориальных значений 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
+71 -8
View File
@@ -246,7 +246,8 @@ func (s *Store) Merge(ctx context.Context, in Incoming, from DeliveryRef) (Merge
stats.RecordsWritten = written
stats.EntitiesHeld += held
stats.HeldAt = clipRefs(append(stats.HeldAt, heldAt...))
return nil
return mergeCategories(ctx, tx, in.Categories, from)
})
if err != nil {
return MergeStats{}, err
@@ -602,6 +603,18 @@ func readBucket(ctx context.Context, tx *sql.Tx, key bucketKey) (Bucket, bool, e
}, true, nil
}
// writeBucket пишет часовой объект.
//
// `first_delivery_id` в `DO UPDATE` НЕ входит намеренно: провенанс объекта —
// «кто создал строку», то есть функция ПОРЯДКА СВЁРТКИ, а не журнала. Это
// допустимо ровно потому, что в отпечаток витрины он не идёт
// (см. fingerprintBuckets): расхождение по нему ненаблюдаемо и решений по нему
// не принимают.
//
// Единице хранения, чей провенанс входит в отпечаток, такого правила МАЛО — там
// нужен явный минимум по журналу, иначе живой приём и пересборка разойдутся при
// одинаковом журнале. Образец — mergeCategories в category.go. Сказано здесь,
// потому что копировать будут отсюда: этот upsert старше и проще.
func writeBucket(ctx context.Context, tx *sql.Tx, b Bucket, now time.Time) error {
payload, err := encodePayload(b.Points)
if err != nil {
@@ -806,10 +819,12 @@ func (s *Store) CountBuckets(ctx context.Context) (int64, error) {
// Значит «объектов столько же» совпадёт и при заведомо сломанном правиле, а
// отпечаток — нет.
//
// Покрывает ВСЕ единицы хранения — часовые объекты, тренировки и записи.
// Отпечаток одних объектов давал бы «состояние сошлось» при разъехавшихся
// тренировках, то есть ломался бы молча тем самым изменением, которое добавило
// данные.
// Покрывает ВСЕ единицы хранения — часовые объекты, тренировки, записи и реестр
// категориальных значений. Отпечаток одних объектов давал бы «состояние
// сошлось» при разъехавшихся тренировках, то есть ломался бы молча тем самым
// изменением, которое добавило данные.
//
// От реестра берётся НАБЛЮДЕНИЕ, но не выведенный код: см. fingerprintCategories.
//
// Все разделы читаются ОДНИМ снимком базы: отпечаток рабочей витрины снимается
// под живым приёмом, и запросы вне общей транзакции дали бы смесь «объекты до»
@@ -830,17 +845,65 @@ func (s *Store) Fingerprint(ctx context.Context) (string, error) {
if err := fingerprintEntities(ctx, tx, h); err != nil {
return "", err
}
if err := fingerprintCategories(ctx, tx, h); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// Признак раздела впереди строки: без него строка одного раздела может совпасть
// со строкой другого, и два разных состояния витрины дали бы один отпечаток.
const (
fpBucket = "b"
fpWorkout = "w"
fpRecord = "r"
fpBucket = "b"
fpWorkout = "w"
fpRecord = "r"
fpCategory = "c"
)
// fingerprintCategories добавляет в отпечаток реестр категориальных значений —
// его НАБЛЮДЕНИЕ, но не выведенный код.
//
// Ключ и провенанс — функция журнала: те же тела в том же порядке дают их
// побайтно. Код — функция журнала И версии словаря в бинаре. Включи его сюда, и
// отпечаток перестал бы отвечать на свой единственный вопрос («дал ли повтор
// журнала то же состояние») ровно тогда, когда его задают: всякое пополнение
// словаря давало бы расхождение при побайтно совпавшем журнале, а человек,
// принимающий по отпечатку необратимое решение о подмене базы, читал бы это как
// дефект. Правильность вывода кода проверяют тесты словаря — это другой вопрос,
// и смешение обесценило бы оракул.
//
// Значений наружу отпечаток не раскрывает: он хеш.
func fingerprintCategories(ctx context.Context, tx *sql.Tx, h io.Writer) error {
const q = `
SELECT metric, field, value, first_seen_utc, first_delivery_id
FROM category_value ORDER BY metric, field, value`
rows, err := tx.QueryContext(ctx, q)
if err != nil {
return fmt.Errorf("select category values: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var metric, field, value, firstSeen, deliveryID string
if err := rows.Scan(&metric, &field, &value, &firstSeen, &deliveryID); err != nil {
return fmt.Errorf("scan category value: %w", err)
}
// Длина впереди каждого поля переменной длины: значение приходит из тела
// дословно и может содержать что угодно, включая признак раздела и
// разделители. Без длины пара (`a`, `b|c`) дала бы ту же строку, что
// (`a|b`, `c`), — то есть два разных состояния витрины сошлись бы
// отпечатком. Тот же приём в fingerprintBuckets и по той же причине.
fmt.Fprintf(h, "%s|%d:%s|%d:%s|%d:%s|%s|%d:%s\n",
fpCategory, len(metric), metric, len(field), field,
len(value), value, firstSeen, len(deliveryID), deliveryID)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("select category values: %w", err)
}
return nil
}
func fingerprintBuckets(ctx context.Context, tx *sql.Tx, h io.Writer) error {
const q = `
SELECT metric, layer, hour_utc, content_hash, points, units, sealed FROM bucket