feat(core): source record snapshots with stable hash and gzipped versions
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
33631aef3c
commit
37a0b0ba94
@@ -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<string, unknown>;
|
||||
engagement?: Record<string, unknown>;
|
||||
listen?: Record<string, unknown>;
|
||||
list?: Record<string, unknown>;
|
||||
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<string, unknown> = {};
|
||||
for (const k of Object.keys(v).sort()) {
|
||||
const x = (v as Record<string, unknown>)[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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
@@ -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<string, unknown>): 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));
|
||||
});
|
||||
Reference in New Issue
Block a user