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; }