package store import ( "bytes" "context" "database/sql" "encoding/json" "errors" "fmt" "time" "git.vakhrushev.me/av/healthlog/internal/canon" ) // Роды таблиц сущностей. Тренировка живёт в своей таблице: у неё есть // заголовок, по которому идёт выборка, а у записи его нет. const ( workoutTable = "workout" recordTable = "record" ) // IncomingEntity — сущность с собственным идентификатором, пришедшая на запись. // // Содержимое хранится исходными байтами: сущность, пересобранная повторной // сериализацией, теряет литерал ровно так же, как точка. type IncomingEntity struct { ID string Kind string Name string Start time.Time End time.Time // OffsetSeconds — смещение зоны начала. OffsetSeconds int // Duration — длительность тренировки в секундах. nil означает «источник не // прислал» и отличим от нуля: ноль — законная длительность. Duration *float64 Raw json.RawMessage } // Incoming — всё, что дала одна доставка. Единицей записи является доставка, а // не точка и не сущность: частичное состояние ломает инвариант «состояние // пересобираемо». type Incoming struct { Points []IncomingPoint Workouts []IncomingEntity Records []IncomingEntity // Categories — наблюдённые категориальные значения доставки. Пишутся той же // транзакцией: состояние «точки легли, реестр нет» повторная свёртка не // чинит — хеш содержимого сойдётся, и объекты переписываться не станут. Categories []CategoryValue } // DeliveryRef — место доставки в журнале. Пара, а не идентификатор: по ней // разрешается тай-брейк между версиями сущности равной полноты, а порядок // журнала задан парой `(received_at, id)`. type DeliveryRef struct { ID string ReceivedAt time.Time } func (d DeliveryRef) before(other DeliveryRef) bool { if !d.ReceivedAt.Equal(other.ReceivedAt) { return d.ReceivedAt.Before(other.ReceivedAt) } return d.ID < other.ID } // EntityRef — координаты сущности для записи в лог. Содержимого не несёт: // маршрут тренировки — это геотрек до дома, а метки состояния разума — // измерение душевного состояния. type EntityRef struct { Kind string ID string } // maxEntityRefsReported — сколько координат сущностей попадает в лог. const maxEntityRefsReported = 5 // prepareEntities считает хеш каждой сущности. // // Вынесено из транзакции намеренно: хеширование материализует значение целиком // (маршрут — до мегабайта), а транзакция повторяется до пяти раз при занятости // базы. func prepareEntities(in []IncomingEntity, from DeliveryRef) ([]entityVersion, error) { if len(in) == 0 { return nil, nil } out := make([]entityVersion, 0, len(in)) for _, e := range in { v, err := newEntityVersion(e, from) if err != nil { return nil, err } out = append(out, v) } return out, nil } // clipRefs держит список координат в потолке: он зацепка для разбора, а не // отчёт; масштаб события считает счётчик. func clipRefs(refs []EntityRef) []EntityRef { if len(refs) <= maxEntityRefsReported { return refs } return refs[:maxEntityRefsReported] } // entityVersion — версия сущности вместе с тем, что нужно знать при выборе // победителя. // // Всё считается СРАЗУ и один раз на версию, до входа в транзакцию. Ленивость // здесь была мнимой: хеш всё равно требует полной канонической формы, то есть // самая дорогая работа платилась на каждой копии и так, а отложенный разбор // считал ту же форму ВТОРОЙ раз — и делал это внутри транзакции, которая // открыта `immediate` и повторяется до пяти раз при занятости базы. // // Баланс назван честно: на пути разошедшегося хеша (одна доставка из сорока // четырёх) стало на одну полную канонизацию меньше; на пути совпавшего хеша // добавился мелкий разбор в map[string]json.RawMessage — проход по телу без // разворачивания значений. Внутри транзакции для приехавших версий не остаётся // ничего. type entityVersion struct { raw json.RawMessage hash string from DeliveryRef key []byte fields canon.Fields // head — заголовок, который пишется колонками. У сохранённой версии он не // нужен: она либо побеждает и остаётся как есть, либо замещается целиком. head IncomingEntity } func newEntityVersion(e IncomingEntity, from DeliveryRef) (entityVersion, error) { v, err := analyzeVersion(e.Raw, from) if err != nil { return entityVersion{}, err } v.head = e return v, nil } // newStoredVersion собирает версию, прочитанную из витрины. // // Каноническая форма здесь НЕ считается, и это существенно: разбор сохранённой // версии — единственная работа, которая осталась внутри транзакции, открытой // `immediate`. Замер на тренировке в 168 КБ: полная канонизация с хешем — 4.5 мс // и 2.3 МБ на 38 тысячах аллокаций, множества ключей — 1.3 мс и 174 КБ на // тридцати. Хеш сохранённой уже лежит колонкой, а форма нужна ровно в одной // ветке тай-брейка (равные позиции журнала — та же доставка, свёрнутая // повторно) и считается там лениво. func newStoredVersion(raw json.RawMessage, hash string, from DeliveryRef) entityVersion { return entityVersion{ raw: raw, hash: hash, from: from, fields: canon.Analyze(raw), } } func analyzeVersion(raw json.RawMessage, from DeliveryRef) (entityVersion, error) { form, hash, err := canon.FormAndHash(raw) if err != nil { return entityVersion{}, fmt.Errorf("канонизация сущности: %w", err) } return entityVersion{ raw: raw, hash: hash, from: from, key: form, fields: canon.Analyze(raw), }, nil } // sortKey отдаёт каноническую форму версии, считая её при необходимости. // // Ленивость здесь одна на весь файл и нужна ровно сохранённой версии: у неё // форма требуется только в тай-брейке равных позиций журнала, а стоит она // втрое дороже разбора и платится под блокировкой записи. func (v *entityVersion) sortKey() []byte { if v.key == nil { v.key = canon.SortKey(v.raw) } return v.key } // pickEntity выбирает между сохранённой и приехавшей версией. // // Второй возврат — потеряла ли бы витрина содержание, приняв приехавшую. Это и // есть плата за отказ объединять поля: событие не предотвращается молча, а // считается и уходит в WARN. // // 1. приехавшая несёт всё содержание сохранённой и сверх того → приехавшая // 2. сохранённая несёт всё содержание приехавшей и сверх того → сохранённая // 3. содержание равно → версия из более поздней доставки ЖУРНАЛА // 4. наборы несравнимы → сохранённая // // Пункт 3 — не «побеждает приехавшая», и различие здесь в СИЛЕ гарантии, а не // в намерении. Порядок свёртки приведён к порядку журнала (барьер на // отложенной доставке в replay.Worker), но равенство неполное: строка учёта // становится видимой только после записи тела, и при конкурентном приёме // доставка с более ранней меткой сворачивается позже. Хранимая позиция журнала // это окно закрывает целиком, происхождение кандидата — нет. У точек колонки // провенанса нет и заводить её дорого, поэтому там взято происхождение вместе // с обеспеченным порядком; здесь колонка есть, и терять из-за неё гарантию // незачем. Критерий выбора записан в architecture.md, «Разрешение // столкновений». // // Равные позиции означают две версии одного ключа внутри ОДНОЙ доставки; там // решает минимум канонической формы, потому что порядок элементов в // JSON-массиве нестабилен. func pickEntity(stored, incoming *entityVersion) (takeIncoming, lost bool) { switch v := compareEntities(stored, incoming); v { case entityIncomingRicher: return true, false case entityStoredRicher: return false, true case entityIncomparable: // Несравнимы: у каждой версии есть содержание, которого нет у другой. // Объединение полей отвергнуто там же и по той же причине, что для // точек, — на живом потоке событие не наступало ни разу, — а из двух // версий остаётся сохранённая: правило называется «не теряет // содержания», и приехавшая его теряет. // // Исход здесь зависит от порядка свёртки, а не от журнала: в витрине // лежит победитель прошлых слияний, а не все кандидаты истории, и // «сохранённая выигрывает» означает разный итог при разном порядке. // Окно этого расхождения СУЖЕНО: доставка, получившая ErrBusy, больше // не перегоняется своими преемницами — проход воркера прекращается на // ней. Осталось окно конкурентного приёма, где строка учёта становится // видимой позже метки; там живой приём и пересборка на несравнимых // версиях законно расходятся, и воркер об этом пишет WARN. Счётчик // удержаний ниже — второе, что о расхождении сообщает. return false, true default: return laterInJournal(stored, incoming), false } } // entityVerdict — как соотносится СОДЕРЖАНИЕ двух версий одной сущности. // Нумерация с единицы: нулевое значение не должно выглядеть как «равны». type entityVerdict int const ( // entityEqualContent — множества содержательных ключей совпадают, длины // верхнеуровневых массивов тоже. Значения при этом могут расходиться: их // сравнение здесь неприменимо (см. canon.Fields.Covers). entityEqualContent entityVerdict = iota + 1 entityIncomingRicher entityStoredRicher entityIncomparable ) func compareEntities(stored, incoming *entityVersion) entityVerdict { storedCovers := stored.fields.Covers(incoming.fields) incomingCovers := incoming.fields.Covers(stored.fields) switch { case incomingCovers && storedCovers: return entityEqualContent case incomingCovers: return entityIncomingRicher case storedCovers: return entityStoredRicher default: return entityIncomparable } } // laterInJournal говорит, стоит ли приехавшая версия позже сохранённой в // журнале. Позиции равны у двух версий одного ключа внутри одной доставки; // там решает минимум канонической формы — порядок тотальный и от порядка // элементов в массиве не зависит. func laterInJournal(stored, incoming *entityVersion) bool { if stored.from.before(incoming.from) { return true } if incoming.from.before(stored.from) { return false } return bytes.Compare(incoming.sortKey(), stored.sortKey()) < 0 } // entityDominates говорит, СТРОГО ли a превосходит b по содержанию: покрывает и // не покрывается в ответ. // // Строгость обязательна. Covers — предпорядок, а не строгий порядок: две версии // могут покрывать друг друга взаимно (тот же набор ключей, другие значения), и // отбрасывание «всего, что кем-то покрыто» опустошило бы множество, потеряв обе. func entityDominates(a, b entityVersion) bool { return a.fields.Covers(b.fields) && !b.fields.Covers(a.fields) } // entityLess — тотальный порядок на версиях равного содержания. // // Сперва каноническая форма, потом ИСХОДНЫЕ БАЙТЫ. Второй разряд не украшение: // у сущностей версии с равной формой не схлопываются (в отличие от точек, где // это делает дедупликация по ключу), а у HAE порядок ключей в JSON и запись // числа нестабильны — то есть без него минимум неединствен, и в витрину лёг бы // тот элемент, что стоял в массиве раньше. Порядок элементов на проводе не // имеет права решать, какие байты хранятся. func entityLess(a, b entityVersion) bool { if c := bytes.Compare(a.key, b.key); c != 0 { return c < 0 } return bytes.Compare(a.raw, b.raw) < 0 } // dedupeEntities сворачивает версии одного ключа ВНУТРИ доставки — до сравнения // с сохранённой. // // Победитель здесь — функция МНОЖЕСТВА версий, а не порядка элементов массива: // сперва отбрасываются строго покрытые, среди оставшихся берётся минимум // тотального порядка. Попарная свёртка была неверна ровно так же, как она была // неверна для точек: покрытие — частичный порядок, тай-брейк — тотальный, и // вместе они дают нетранзитивную победу, при которой [A,B,C] и [B,C,A] дают // разных победителей. // // Версии с СОВПАВШЕЙ канонической формой схлопываются ДО выбора победителя, и // это не оптимизация ради красоты: выбор квадратичен по числу кандидатов, а их // число приходит из чужого тела. Точки схлопываются так же и в том же месте // (см. mergePoints). Внутри схлопнутой группы остаются минимальные байты — // тот же второй разряд тотального порядка, что и между группами. // // Второй возврат — счётчик «в одном теле приехали версии одного ключа с РАЗНЫМ // содержанием», симметричный по построению: считаются кандидаты, чья форма // отличается от формы победителя. По форме, а не по байтам: порядок ключей у // HAE нестабилен и дребезг последнего разряда тоже, так что побайтовый счётчик // срабатывал бы на норме потока и стал бы неотличим от шума ровно тогда, когда // понадобился бы. // // Счётчик отдельный от «удержаний», а не общий с ними. Две версии в одном теле — // это НЕ потеря содержания: победитель ложится в витрину целиком, и удерживать // нечего. Смешивать их значило бы отвечать одним числом на два вопроса, которые // лечатся по-разному, — а на число удержаний опирается единственный контроль // того, что правило покрытия не стало слишком строгим. func dedupeEntities(ctx context.Context, versions []entityVersion) ([]entityVersion, int, []EntityRef, error) { byKey := make(map[EntityRef][]entityVersion, len(versions)) order := make([]EntityRef, 0, len(versions)) for _, v := range versions { ref := EntityRef{Kind: v.head.Kind, ID: v.head.ID} if _, seen := byKey[ref]; !seen { order = append(order, ref) } byKey[ref] = append(byKey[ref], v) } out := make([]entityVersion, 0, len(order)) diverging := 0 var divergingAt []EntityRef for _, ref := range order { cands, dropped := collapseEqualForms(byKey[ref]) winner, _, err := pickBest(ctx, cands, entityDominates, entityLess) if err != nil { return nil, 0, nil, err } out = append(out, cands[winner]) // Схлопнутые копии победителя различием не считаются: их форма ему // равна. Считаются все прочие — и оставшиеся кандидаты, и те, что // схлопнулись в них. differing := 0 for i := range cands { if i != winner { differing += 1 + dropped[i] } } if differing > 0 { diverging += differing if len(divergingAt) < maxEntityRefsReported { divergingAt = append(divergingAt, ref) } } } return out, diverging, divergingAt, nil } // collapseEqualForms схлопывает версии с одинаковой канонической формой в одну, // оставляя минимальные исходные байты. Второй возврат — сколько копий сложилось // в каждого оставшегося кандидата (нужно счётчику различий). // // Схлопывание обязательно, а не желательно: без него тело с двадцатью тысячами // повторов одного `id` даёт четыреста миллионов сравнений покрытия, каждое с // обходом массивов. Тело в пределах приёма такое вмещает. func collapseEqualForms(versions []entityVersion) ([]entityVersion, []int) { byForm := make(map[string]int, len(versions)) out := make([]entityVersion, 0, len(versions)) dropped := make([]int, 0, len(versions)) for _, v := range versions { form := string(v.key) i, seen := byForm[form] if !seen { byForm[form] = len(out) out = append(out, v) dropped = append(dropped, 0) continue } dropped[i]++ if bytes.Compare(v.raw, out[i].raw) < 0 { // Байты решают внутри группы ровно так же, как между группами: // порядок элементов на проводе не имеет права выбирать содержимое. // Заголовок едет вместе с байтами — он от них производен. out[i] = v } } return out, dropped } // mergeEntities сливает сущности одной секции с сохранёнными. func mergeEntities(ctx context.Context, tx *sql.Tx, table string, versions []entityVersion, now time.Time) (written, held int, heldAt []EntityRef, err error) { for _, v := range versions { stored, found, err := readEntityHead(ctx, tx, table, v.head.Kind, v.head.ID) if err != nil { return 0, 0, nil, err } if !found { if err := writeEntity(ctx, tx, table, v, now); err != nil { return 0, 0, nil, err } written++ continue } // Хеш — детектор изменений: совпал, значит содержимое то же, и читать // его не приходится вовсе. Тренировка переприсылается каждой доставкой, // пока не доедет маршрут, — на живом архиве 44 копии дают три различных // содержимых. // // Но провенанс при этом обновить НАДО. Сохранённая позиция журнала // участвует в тай-брейке «содержание равно», и если в ней осталась // первая свёрнутая копия вместо победителя журнала, отложенная доставка // вернёт витрину к прежнему содержимому — то есть живая витрина // разойдётся с пересборкой, молча и в содержимом тренировки. // // Предел назван вслух: обновляется провенанс, но НЕ байты. При // совпавшей канонической форме в витрине остаются байты той доставки, // что свернулась первой, — а порядок ключей у HAE нестабилен, значит у // живого приёма и пересборки они могут различаться. Отпечаток этого не // различает (он считает по канонической форме), содержания не теряется // ничего, а переписывать мегабайтный маршрут на каждой из двадцати // шести присылок ради выбора между эквивалентными литералами — цена // несоразмерная. if stored.hash == v.hash { if stored.from.before(v.from) { if err := touchEntityProvenance(ctx, tx, table, v); err != nil { return 0, 0, nil, err } } continue } storedRaw, err := readEntityPayload(ctx, tx, table, v.head.Kind, v.head.ID) if err != nil { return 0, 0, nil, err } prev := newStoredVersion(storedRaw, stored.hash, stored.from) takeIncoming, lost := pickEntity(&prev, &v) if lost { held++ if len(heldAt) < maxEntityRefsReported { heldAt = append(heldAt, EntityRef{Kind: v.head.Kind, ID: v.head.ID}) } } if !takeIncoming { continue } if err := writeEntity(ctx, tx, table, v, now); err != nil { return 0, 0, nil, err } written++ } return written, held, heldAt, nil } // touchEntityProvenance поднимает провенанс сущности до более поздней доставки // журнала, не трогая содержимое. // // `updated_at` НЕ двигается, и это отдельное решение, а не экономия. Тренировка // приезжает до двадцати шести раз; бамп метки на каждой сделал бы её меткой // касания строки, а не изменения содержимого, и потребитель запроса «что // изменилось с момента X» получил бы двадцать шесть ложных изменений, // неотличимых от настоящего досчёта. Провенанс несёт собственную метку — // времени приёма своей доставки, — и для тай-брейка её достаточно. // // Счётчик записанных сущностей такое обновление тоже не увеличивает: он считает // СОДЕРЖИМОЕ витрины, и сравнимость его с прежними замерами важнее учёта // обновлённой ссылки. func touchEntityProvenance(ctx context.Context, tx *sql.Tx, table string, v entityVersion) error { q := `UPDATE ` + table + ` SET delivery_id = ?, delivery_received_at = ?` + entityWhere(table) args := append([]any{v.from.ID, FormatTime(v.from.ReceivedAt)}, entityKeyArgs(table, v.head.Kind, v.head.ID)...) res, err := tx.ExecContext(ctx, q, args...) if err != nil { return fmt.Errorf("update %s provenance: %w", table, err) } // Строка гарантированно существует: её заголовок прочитан этой же // транзакцией десятью строками выше. Ноль означал бы, что ключ собран не // теми колонками, — а провенанс в отпечаток витрины не входит, значит // молчаливый промах не поймает ни один оракул сходимости. Соседи по файлу // (FinishParse, MarkSealed) проверяют по той же причине. n, err := res.RowsAffected() if err != nil { return fmt.Errorf("update %s provenance: %w", table, err) } if n == 0 { return fmt.Errorf("update %s provenance: %w", table, ErrNotFound) } return nil } type storedEntityHead struct { hash string from DeliveryRef } func readEntityHead(ctx context.Context, tx *sql.Tx, table, kind, id string) (storedEntityHead, bool, error) { q := `SELECT content_hash, delivery_id, delivery_received_at FROM ` + table + entityWhere(table) var ( head storedEntityHead receivedAt string ) row := queryEntity(ctx, tx, q, table, kind, id) err := row.Scan(&head.hash, &head.from.ID, &receivedAt) if errors.Is(err, sql.ErrNoRows) { return storedEntityHead{}, false, nil } if err != nil { return storedEntityHead{}, false, fmt.Errorf("select %s: %w", table, err) } // Пустую метку не терпим: колонка NOT NULL без умолчания, и пустота здесь // означала бы дефект писателя. Молчаливый нулевой момент сделал бы // сохранённую версию «самой ранней в журнале», и её затирала бы любая // приехавшая — то есть дефект проявился бы потерей данных, а не отказом. head.from.ReceivedAt, err = ParseTime(receivedAt) if err != nil { return storedEntityHead{}, false, err } return head, true, nil } func readEntityPayload(ctx context.Context, tx *sql.Tx, table, kind, id string) (json.RawMessage, error) { q := `SELECT payload FROM ` + table + entityWhere(table) var payload []byte if err := queryEntity(ctx, tx, q, table, kind, id).Scan(&payload); err != nil { return nil, fmt.Errorf("select %s payload: %w", table, err) } raw, err := gunzipBytes(payload) if err != nil { return nil, err } return raw, nil } // entityWhere и queryEntity держат разницу между таблицами в одном месте: // у тренировки ключ — `id`, у записи — пара `kind + id`. func entityWhere(table string) string { if table == recordTable { return ` WHERE kind = ? AND id = ?` } return ` WHERE id = ?` } // entityKeyArgs — аргументы к entityWhere. Живут рядом с ним намеренно: число // `?` в тексте и длина этого среза обязаны меняться вместе, а компилятор их // соответствия не видит. Промах даст ошибку SQLite внутри транзакции слияния, // то есть на пути, который повторяется до пяти раз и оканчивается `failed` у // доставки, а не отказом сборки. func entityKeyArgs(table, kind, id string) []any { if table == recordTable { return []any{kind, id} } return []any{id} } func queryEntity(ctx context.Context, tx *sql.Tx, q, table, kind, id string) *sql.Row { return tx.QueryRowContext(ctx, q, entityKeyArgs(table, kind, id)...) } func writeEntity(ctx context.Context, tx *sql.Tx, table string, v entityVersion, now time.Time) error { payload, err := gzipBytes(v.raw) if err != nil { return err } stamp := FormatTime(now) received := FormatTime(v.from.ReceivedAt) if table == recordTable { const q = ` INSERT INTO record (kind, id, ts_utc, tz_offset, payload, content_hash, delivery_id, delivery_received_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (kind, id) DO UPDATE SET ts_utc = excluded.ts_utc, tz_offset = excluded.tz_offset, payload = excluded.payload, content_hash = excluded.content_hash, delivery_id = excluded.delivery_id, delivery_received_at = excluded.delivery_received_at, updated_at = excluded.updated_at` if _, err := tx.ExecContext(ctx, q, v.head.Kind, v.head.ID, FormatTime(v.head.Start), v.head.OffsetSeconds, payload, v.hash, v.from.ID, received, stamp, stamp); err != nil { return fmt.Errorf("upsert record: %w", err) } return nil } const q = ` INSERT INTO workout (id, name, start_utc, end_utc, tz_offset, duration_sec, payload, content_hash, delivery_id, delivery_received_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET name = excluded.name, start_utc = excluded.start_utc, end_utc = excluded.end_utc, tz_offset = excluded.tz_offset, duration_sec = excluded.duration_sec, payload = excluded.payload, content_hash = excluded.content_hash, delivery_id = excluded.delivery_id, delivery_received_at = excluded.delivery_received_at, updated_at = excluded.updated_at` var duration any if v.head.Duration != nil { duration = *v.head.Duration } if _, err := tx.ExecContext(ctx, q, v.head.ID, v.head.Name, FormatTime(v.head.Start), FormatTime(v.head.End), v.head.OffsetSeconds, duration, payload, v.hash, v.from.ID, received, stamp, stamp); err != nil { return fmt.Errorf("upsert workout: %w", err) } return nil } // Workout — тренировка, прочитанная из витрины. Нужна тестам и будущему // Read API: содержимое отдаётся целиком, заголовок — из колонок. type Workout struct { ID string Name string Start time.Time End time.Time OffsetSeconds int Duration *float64 Raw json.RawMessage Delivery string } // Workout читает тренировку по идентификатору. func (s *Store) Workout(ctx context.Context, id string) (Workout, error) { const q = ` SELECT id, name, start_utc, end_utc, tz_offset, duration_sec, payload, delivery_id FROM workout WHERE id = ?` var ( w Workout start, end string duration sql.NullFloat64 payload []byte deliveryFrom string ) err := s.db.QueryRowxContext(ctx, q, id). Scan(&w.ID, &w.Name, &start, &end, &w.OffsetSeconds, &duration, &payload, &deliveryFrom) if errors.Is(err, sql.ErrNoRows) { return Workout{}, ErrNotFound } if err != nil { return Workout{}, fmt.Errorf("select workout: %w", err) } if w.Start, err = ParseTime(start); err != nil { return Workout{}, err } if w.End, err = ParseTime(end); err != nil { return Workout{}, err } if duration.Valid { v := duration.Float64 w.Duration = &v } if w.Raw, err = gunzipBytes(payload); err != nil { return Workout{}, err } w.Delivery = deliveryFrom return w, nil } // Record — запись секции с собственным идентификатором. type Record struct { Kind string ID string TS time.Time OffsetSeconds int Raw json.RawMessage Delivery string } // Record читает запись по роду и идентификатору. func (s *Store) Record(ctx context.Context, kind, id string) (Record, error) { const q = ` SELECT kind, id, ts_utc, tz_offset, payload, delivery_id FROM record WHERE kind = ? AND id = ?` var ( r Record ts string payload []byte deliveryFrom string ) err := s.db.QueryRowxContext(ctx, q, kind, id). Scan(&r.Kind, &r.ID, &ts, &r.OffsetSeconds, &payload, &deliveryFrom) if errors.Is(err, sql.ErrNoRows) { return Record{}, ErrNotFound } if err != nil { return Record{}, fmt.Errorf("select record: %w", err) } if r.TS, err = ParseTime(ts); err != nil { return Record{}, err } if r.Raw, err = gunzipBytes(payload); err != nil { return Record{}, err } r.Delivery = deliveryFrom return r, nil } // CountWorkouts и CountRecords нужны отчёту пересборки: отпечаток отвечает // «да/нет», а по «да/нет» нельзя судить о направлении расхождения. func (s *Store) CountWorkouts(ctx context.Context) (int64, error) { var n int64 if err := s.db.GetContext(ctx, &n, `SELECT count(*) FROM workout`); err != nil { return 0, fmt.Errorf("count workouts: %w", err) } return n, nil } // CountRecords возвращает число записей секций с собственным `id`. func (s *Store) CountRecords(ctx context.Context) (int64, error) { var n int64 if err := s.db.GetContext(ctx, &n, `SELECT count(*) FROM record`); err != nil { return 0, fmt.Errorf("count records: %w", err) } return n, nil }