Files
STTracker/packages/core/src/sync/snapshot.ts
T

76 lines
2.9 KiB
TypeScript

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