From 37a0b0ba94a0a2c485a52ee02f65dc90a4b2e86b Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 20:26:03 +0100 Subject: [PATCH] feat(core): source record snapshots with stable hash and gzipped versions Co-Authored-By: Claude Opus 5.5 --- packages/core/src/sync/snapshot.ts | 75 ++++++++++++++++++++++++ packages/core/test/sync/snapshot.test.ts | 31 ++++++++++ 2 files changed, 106 insertions(+) create mode 100644 packages/core/src/sync/snapshot.ts create mode 100644 packages/core/test/sync/snapshot.test.ts diff --git a/packages/core/src/sync/snapshot.ts b/packages/core/src/sync/snapshot.ts new file mode 100644 index 0000000..59b2362 --- /dev/null +++ b/packages/core/src/sync/snapshot.ts @@ -0,0 +1,75 @@ +import type { SourceRecord } from "@sttracker/plugin-sdk/types"; + +/** + * The part of a `SourceRecord` that the change engine compares: everything except `key` and `raw`. + * `deleted` is always a boolean and `mergedIntoKey` always present (null when unset), so an absent + * flag and an explicit `false` hash the same. + */ +export interface NormalizedRecord { + kind: SourceRecord["kind"]; + work?: unknown; + entry?: Record; + engagement?: Record; + listen?: Record; + list?: Record; + deleted: boolean; + mergedIntoKey: string | null; +} + +/** JSON with object keys sorted at every depth; `undefined` members are dropped, as in `JSON.stringify`. */ +export function stableStringify(v: unknown): string { + return JSON.stringify(sortKeys(v)) ?? "null"; +} + +function sortKeys(v: unknown): unknown { + if (Array.isArray(v)) return v.map((x) => (x === undefined ? null : sortKeys(x))); + if (v === null || typeof v !== "object") return v; + const out: Record = {}; + for (const k of Object.keys(v).sort()) { + const x = (v as Record)[k]; + if (x !== undefined) out[k] = sortKeys(x); + } + return out; +} + +/** True when two values serialise identically (`undefined` counts as `null`). */ +export function sameValue(a: unknown, b: unknown): boolean { + return stableStringify(a ?? null) === stableStringify(b ?? null); +} + +export function normalizeRecord(r: SourceRecord): NormalizedRecord { + const base = { kind: r.kind, deleted: r.deleted === true, mergedIntoKey: r.mergedIntoKey ?? null }; + const plain = (o: unknown) => JSON.parse(stableStringify(o)) as Record; + switch (r.kind) { + case "entry": + return { ...base, work: plain(r.work), entry: plain(r.entry) }; + case "engagement": + return { ...base, work: plain(r.work), engagement: plain(r.engagement) }; + case "listen": + return { ...base, listen: plain(r.listen) }; + case "list": + return { ...base, list: plain(r.list) }; + } +} + +/** sha256 (hex) of the stable JSON of the normalised record. The raw payload is not part of it. */ +export function hashRecord(r: SourceRecord): string { + return new Bun.CryptoHasher("sha256").update(stableStringify(normalizeRecord(r))).digest("hex"); +} + +/** What a `source_record_versions.raw_gz` holds: the normalised record (for diffing) and the raw payload. */ +export interface StoredVersion { + normalized: NormalizedRecord; + raw: unknown; +} + +/** gzip (`Bun.gzipSync`) + base64 of `{ normalized, raw }`. */ +export function encodeVersion(r: SourceRecord): string { + const json = JSON.stringify({ normalized: normalizeRecord(r), raw: r.raw ?? null }); + return Buffer.from(Bun.gzipSync(new TextEncoder().encode(json))).toString("base64"); +} + +export function decodeVersion(s: string): StoredVersion { + const bytes = Bun.gunzipSync(Buffer.from(s, "base64")); + return JSON.parse(new TextDecoder().decode(bytes)) as StoredVersion; +} diff --git a/packages/core/test/sync/snapshot.test.ts b/packages/core/test/sync/snapshot.test.ts new file mode 100644 index 0000000..8e14eba --- /dev/null +++ b/packages/core/test/sync/snapshot.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from "bun:test"; +import type { SourceRecord } from "@sttracker/plugin-sdk/types"; +import { decodeVersion, encodeVersion, hashRecord, normalizeRecord } from "../../src/sync/snapshot.ts"; + +const rec = (raw: unknown, entry: Record): SourceRecord => + ({ key: "k", kind: "entry", work: { title: "T", mediaType: "book", externalIds: { a: "1", b: "2" } }, entry, raw }) as SourceRecord; + +test("hash ignores raw payload and key order", async () => { + const a = rec({ x: 1 }, { rating: 1, status: "planned" }); + const b = rec({ y: 2 }, { status: "planned", rating: 1 }); + expect(await hashRecord(a)).toBe(await hashRecord(b)); + expect(await hashRecord(a)).toMatch(/^[0-9a-f]{64}$/); +}); + +test("hash covers normalised fields, deleted and mergedIntoKey", async () => { + const base = rec({}, { rating: 1 }); + const h = await hashRecord(base); + expect(await hashRecord(rec({}, { rating: 2 }))).not.toBe(h); + expect(await hashRecord({ ...base, deleted: true })).not.toBe(h); + expect(await hashRecord({ ...base, mergedIntoKey: "z" })).not.toBe(h); + expect(await hashRecord({ ...base, deleted: false })).toBe(h); +}); + +test("versions round-trip the normalised record and the raw payload", () => { + const r = rec({ deep: { list: [1, 2] } }, { rating: 5 }); + const encoded = encodeVersion(r); + expect(typeof encoded).toBe("string"); + const v = decodeVersion(encoded); + expect(v.raw).toEqual({ deep: { list: [1, 2] } }); + expect(v.normalized).toEqual(normalizeRecord(r)); +});