добавлен словарь категориальных значений 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
+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] + "…"
}
+285
View File
@@ -0,0 +1,285 @@
package store_test
import (
"context"
"fmt"
"sync"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/store"
)
func category(metric, field, value, code string) store.CategoryValue {
return store.CategoryValue{Metric: metric, Field: field, Value: value, Code: code}
}
func mergeCategories(t *testing.T, st *store.Store, d store.DeliveryRef, vs ...store.CategoryValue) {
t.Helper()
if _, err := st.Merge(context.Background(), store.Incoming{Categories: vs}, d); err != nil {
t.Fatalf("слияние наблюдений: %v", err)
}
}
func categories(t *testing.T, st *store.Store) []store.CategoryValue {
t.Helper()
got, err := st.CategoryValues(context.Background())
if err != nil {
t.Fatalf("чтение реестра: %v", err)
}
return got
}
func TestMergeCategoriesКладётНаблюдение(t *testing.T) {
t.Parallel()
st := open(t)
d := from(t, "01AAA", "2026-08-03T10:00:00Z")
mergeCategories(t, st, d,
category("sleep_analysis", "value", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified"),
category("heart_rate", "context", "Не задано", ""))
got := categories(t, st)
if len(got) != 2 {
t.Fatalf("строк реестра %d, ожидалось 2: %+v", len(got), got)
}
// Порядок детерминирован ключом.
if got[0].Metric != "heart_rate" || got[1].Metric != "sleep_analysis" {
t.Errorf("порядок реестра не по ключу: %+v", got)
}
// Пустой код — законное состояние: словарь этой строки не знает.
if got[0].Code != "" {
t.Errorf("контекст пульса получил код %q", got[0].Code)
}
if got[1].Code != "HKCategoryValueSleepAnalysisAsleepUnspecified" {
t.Errorf("фаза сна получила код %q", got[1].Code)
}
if got[1].FirstDeliveryID != d.ID || !got[1].FirstSeen.Equal(d.ReceivedAt) {
t.Errorf("провенанс %s/%s, ожидался %s/%s",
got[1].FirstDeliveryID, got[1].FirstSeen, d.ID, d.ReceivedAt)
}
}
// Повторная свёртка той же доставки не меняет ни одной колонки: иначе состояние
// зависело бы от числа прогонов, а пересборка перестала бы быть no-op.
func TestMergeCategoriesПовторНичегоНеМеняет(t *testing.T) {
t.Parallel()
st := open(t)
d := from(t, "01AAA", "2026-08-03T10:00:00Z")
v := category("sleep_analysis", "value", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified")
mergeCategories(t, st, d, v)
first := fingerprint(t, st)
before := categories(t, st)
mergeCategories(t, st, d, v)
if got := fingerprint(t, st); got != first {
t.Error("повторная свёртка сдвинула отпечаток")
}
after := categories(t, st)
if len(after) != len(before) || after[0] != before[0] {
t.Errorf("повтор изменил реестр: было %+v, стало %+v", before, after)
}
}
// Провенанс — МИНИМУМ по журналу, а не последняя запись: иначе проигрывание
// журнала давало бы не то, что живой приём, и порядок свёртки решал бы исход.
//
// Три доставки во всех шести порядках, а не пара: пара доказывает
// коммутативность и молчит про ассоциативность.
func TestMergeCategoriesПровенансНеЗависитОтПорядка(t *testing.T) {
t.Parallel()
deliveries := []store.DeliveryRef{
from(t, "01AAA", "2026-08-01T10:00:00Z"),
from(t, "01BBB", "2026-08-02T10:00:00Z"),
// Та же секунда, что у предыдущей: порядок задаёт идентификатор, и
// сравнение по одной метке сделало бы исход зависящим от того, какая
// свернулась раньше.
from(t, "01AAB", "2026-08-02T10:00:00Z"),
}
v := category("sleep_analysis", "value", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified")
orders := [][]int{{0, 1, 2}, {0, 2, 1}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {2, 1, 0}}
var want string
for _, order := range orders {
t.Run(fmt.Sprint(order), func(t *testing.T) {
st := open(t)
for _, i := range order {
mergeCategories(t, st, deliveries[i], v)
}
got := categories(t, st)
if len(got) != 1 {
t.Fatalf("строк реестра %d, ожидалась одна", len(got))
}
if got[0].FirstDeliveryID != "01AAA" {
t.Errorf("провенанс %s, ожидалась самая ранняя доставка 01AAA", got[0].FirstDeliveryID)
}
// Отпечаток обязан совпасть у всех шести порядков.
fp := fingerprint(t, st)
if want == "" {
want = fp
} else if fp != want {
t.Errorf("порядок %v дал другой отпечаток", order)
}
})
}
}
// Наблюдение в отпечаток входит, выведенный код — нет. Код производен от
// словаря в бинаре, а не от журнала: включённый в отпечаток, он заставил бы
// всякое пополнение словаря давать расхождение при совпавшем журнале.
func TestFingerprintРеестрБезКода(t *testing.T) {
t.Parallel()
d := from(t, "01AAA", "2026-08-03T10:00:00Z")
withCode := open(t)
mergeCategories(t, withCode, d, category("sleep_analysis", "value", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified"))
otherCode := open(t)
mergeCategories(t, otherCode, d, category("sleep_analysis", "value", "Во сне", ""))
if fingerprint(t, withCode) != fingerprint(t, otherCode) {
t.Error("расхождение только по коду сдвинуло отпечаток — оракул сходимости стал функцией версии словаря")
}
otherValue := open(t)
mergeCategories(t, otherValue, d, category("sleep_analysis", "value", "Бодрствование", "HKCategoryValueSleepAnalysisAwake"))
if fingerprint(t, withCode) == fingerprint(t, otherValue) {
t.Error("разошедшееся наблюдение отпечаток не сдвинуло")
}
}
// Значение приходит из тела дословно и может содержать что угодно, включая
// признак раздела и разделители полей. Длина впереди каждого поля — то, что не
// даёт двум разным состояниям витрины сойтись отпечатком.
func TestFingerprintРазделительВЗначенииГраницуНеПодделывает(t *testing.T) {
t.Parallel()
d := from(t, "01AAA", "2026-08-03T10:00:00Z")
a := open(t)
mergeCategories(t, a, d, category("sleep", "value|c|9", "x", ""))
b := open(t)
mergeCategories(t, b, d, category("sleep", "value", "|c|9|x", ""))
if fingerprint(t, a) == fingerprint(t, b) {
t.Error("разное разбиение тех же байтов по полям дало один отпечаток")
}
}
// Пустой реестр отпечаток не ломает: раздел, у которого нет строк, не пишет в
// хеш ничего.
func TestFingerprintПустойРеестр(t *testing.T) {
t.Parallel()
empty := open(t)
ctx := context.Background()
fp, err := empty.Fingerprint(ctx)
if err != nil {
t.Fatalf("отпечаток пустой витрины: %v", err)
}
if fp == "" {
t.Error("отпечаток пустой витрины пуст")
}
n, err := empty.CountCategoryValues(ctx)
if err != nil {
t.Fatalf("счёт реестра: %v", err)
}
if n != 0 {
t.Errorf("строк реестра %d, ожидался ноль", n)
}
}
// Отказ слияния не оставляет строк реестра: доставка — единица свёртки, и
// частичное состояние повторная свёртка не чинит.
func TestMergeCategoriesОтказНеОставляетСтрок(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
// Точка, содержимое которой не канонизируется, роняет всю транзакцию.
in := store.Incoming{
Points: []store.IncomingPoint{point(t, "m", "hour", "2026-08-03T10:00:00Z", "2026-08-03T10:00:00Z", `{{{`)},
Categories: []store.CategoryValue{category("sleep_analysis", "value", "Во сне", "код")},
}
if _, err := st.Merge(ctx, in, from(t, "01AAA", "2026-08-03T10:00:00Z")); err == nil {
t.Skip("слияние не отказало — проверять нечего")
}
if got := categories(t, st); len(got) != 0 {
t.Errorf("после отказа в реестре %d строк: %+v", len(got), got)
}
}
// Доставки приходят внахлёст, а провенанс реестра выбирается правилом
// «минимум по журналу» прямо в SQL. Правило, ни разу не исполненное
// конкурентно, проверено ровно наполовину: у точек такой прогон есть с самого
// начала (TestMergeКонкурентноеСлияниеНеТеряетТочки), у реестра его не было.
//
// Порядок горутин недетерминирован намеренно — в этом весь смысл: исход обязан
// быть функцией МНОЖЕСТВА доставок, а не того, кто первым добрался до
// транзакции.
func TestMergeCategoriesКонкурентноНеПортитПровенанс(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
const writers = 8
const perWriter = 10
shared := category("sleep_analysis", "value", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified")
var wg sync.WaitGroup
errs := make(chan error, writers)
for w := range writers {
wg.Go(func() {
for i := range perWriter {
// Метка приёма растёт вместе с номером доставки: самая ранняя —
// у первой итерации первого писателя, и она обязана победить
// независимо от того, кто дошёл до базы раньше.
n := w*perWriter + i
d := store.DeliveryRef{
ID: fmt.Sprintf("d%03d", n),
ReceivedAt: ts(t, "2026-08-03T10:00:00Z").Add(time.Duration(n) * time.Second),
}
// Общий ключ у всех писателей плюс свой собственный: проверяется
// и спор за одну строку, и параллельная вставка разных.
own := category("heart_rate", "context", fmt.Sprintf("контекст-%03d", n), "")
if _, err := st.Merge(ctx, store.Incoming{Categories: []store.CategoryValue{shared, own}}, d); err != nil {
errs <- err
return
}
}
})
}
wg.Wait()
close(errs)
for err := range errs {
t.Fatalf("конкурентное слияние реестра: %v", err)
}
got := categories(t, st)
if len(got) != writers*perWriter+1 {
t.Fatalf("строк реестра %d, ожидалось %d", len(got), writers*perWriter+1)
}
for _, v := range got {
if v.Metric != "sleep_analysis" {
continue
}
if v.FirstDeliveryID != "d000" {
t.Errorf("провенанс общей строки %q, ожидалась самая ранняя доставка d000", v.FirstDeliveryID)
}
if !v.FirstSeen.Equal(ts(t, "2026-08-03T10:00:00Z")) {
t.Errorf("метка первой встречи %s, ожидалась 2026-08-03T10:00:00Z", v.FirstSeen)
}
}
}
+7 -2
View File
@@ -376,18 +376,23 @@ type DeliveryBody struct {
RawPath string
AutomationID string
Aggregation string
// Headers — заголовки запроса JSON-объектом. Разбору нужен ровно один из
// них, `Accept-Language`: он задаёт язык, на котором приехали
// категориальные строки, и сужает поиск по словарю. Колонки под язык нет
// намеренно — она была бы вторым домом факта, который уже лежит здесь.
Headers string
}
// DeliveryForParse возвращает сведения о доставке, нужные разбору.
func (s *Store) DeliveryForParse(ctx context.Context, id string) (DeliveryBody, error) {
const q = `
SELECT id, received_at, raw_path, automation_id, aggregation
SELECT id, received_at, raw_path, automation_id, aggregation, headers
FROM delivery WHERE id = ?`
var d DeliveryBody
var receivedAt string
err := s.db.QueryRowxContext(ctx, q, id).
Scan(&d.ID, &receivedAt, &d.RawPath, &d.AutomationID, &d.Aggregation)
Scan(&d.ID, &receivedAt, &d.RawPath, &d.AutomationID, &d.Aggregation, &d.Headers)
if errors.Is(err, sql.ErrNoRows) {
return DeliveryBody{}, ErrNotFound
}
+4
View File
@@ -46,6 +46,10 @@ type Incoming struct {
Points []IncomingPoint
Workouts []IncomingEntity
Records []IncomingEntity
// Categories — наблюдённые категориальные значения доставки. Пишутся той же
// транзакцией: состояние «точки легли, реестр нет» повторная свёртка не
// чинит — хеш содержимого сойдётся, и объекты переписываться не станут.
Categories []CategoryValue
}
// DeliveryRef — место доставки в журнале. Пара, а не идентификатор: по ней
@@ -0,0 +1,67 @@
-- +goose Up
-- Реестр категориальных значений: какие перечислимые строки поток приносил и
-- какой у них стабильный код HealthKit.
--
-- HAE отдаёт фазу сна как «БДГ», контекст пульса как «Сидячий образ жизни», тип
-- тренировки как «В помещении Ходьба» — строками локали телефона, а родной
-- экспорт Apple говорит кодами (`HKCategoryValueSleepAnalysisAsleepREM`).
-- Источники несопоставимы, и на этой сверке стоит устаревание нижнего слоя.
-- Словарь фаз сна выведен сопоставлением потока с экспортом за тот же период
-- (docs/research/apple-health.md, находка 43).
--
-- ПОЧЕМУ ОТДЕЛЬНАЯ ТАБЛИЦА, А НЕ ПОЛЕ РЯДОМ СО СТРОКОЙ В ТОЧКЕ. Точка хранится
-- исходными байтами; дописать в неё ключ можно только пересериализацией, а она
-- теряет литерал — ровно то, от чего эти байты и защищают. Параллельный массив
-- кодов в `bucket` завёл бы производную величину в путь слияния и хеширования:
-- правило полноты, тай-брейк и `content_hash` пришлось бы учить носить код, не
-- давая ему влиять на исход. Это правка на поверхности critical-инвариантов
-- ради нуля новых сведений — код есть функция от того, что уже лежит. И
-- пополнение словаря переписывало бы каждый объект с фазами сна; здесь меняется
-- десяток строк.
--
-- ПОЧЕМУ ЛОКАЛИ НЕТ В КЛЮЧЕ. Она приезжает заголовком `Accept-Language`, а
-- заголовков в сыром архиве не лежит: они были заголовками запроса, а не телом.
-- Доставка, восстановленная из осиротевшего тела, приходит без локали — и ключ
-- с локалью положил бы вторую строку на то же значение, то есть состояние стало
-- бы функцией от того, уцелела ли учётная строка. Ключ по трём полям — функция
-- одних тел. На каком языке приехала строка, восстанавливается по доставке
-- провенанса: `first_delivery_id` → `delivery.headers`.
--
-- ПОЧЕМУ ПРОВЕНАНС ТОЛЬКО ПЕРВОЙ ВСТРЕЧИ. Минимум по журналу идемпотентен при
-- повторной свёртке той же доставки; счётчик встреч не идемпотентен и сделал бы
-- состояние зависящим от числа прогонов. «Когда эта строка появилась впервые»
-- отвечает на вопрос о смене языка телефона — «сколько раз» не отвечает ни на
-- один заданный.
--
-- `code` — КЭШ. Он производная не от журнала, а от словаря в бинаре, и потому
-- в отпечаток витрины не входит (см. Fingerprint): включённый туда, он заставил
-- бы всякое пополнение словаря давать расхождение при побайтно совпавшем
-- журнале, а человек, принимающий по отпечатку необратимое решение о подмене
-- базы, читал бы это как дефект. Строка, переставшая приезжать, держит код
-- прежнего словаря до пересборки — осознанная цена, названная вслух.
--
-- Пустой код — законное состояние: он означает «словарь этой строки не знает».
-- Пустая строка, а не NULL: различать «кода нет» и «кода не считали» здесь
-- нечем — вывод кода идёт на каждой встрече, и третьего состояния у него не
-- бывает.
--
-- `WITHOUT ROWID`: обращение всегда по полному первичному ключу, а строк
-- единицы — на живом потоке различных значений по всем трём полям около
-- одиннадцати.
--
-- Data-миграции нет и быть не может: коды выводятся из ТЕЛ, а тела лежат в
-- архиве, а не в базе. Реестр рабочей витрины наполняется по мере свёртки новых
-- доставок и целиком — пересборкой. Отсюда первое расхождение отпечатков после
-- выкатки: оно законно, и отчёт `reindex` называет его ожидаемым классом.
CREATE TABLE category_value (
metric TEXT NOT NULL,
field TEXT NOT NULL,
value TEXT NOT NULL,
code TEXT NOT NULL,
first_seen_utc TEXT NOT NULL,
first_delivery_id TEXT NOT NULL,
PRIMARY KEY (metric, field, value)
) WITHOUT ROWID;
-- +goose Down
DROP TABLE category_value;