Repository Public
1import { createHash } from 'node:crypto';23/**4 * Canonical JSON: object keys sorted, `undefined`/functions/symbols dropped (null inside arrays),5 * non-finite numbers → null, `toJSON` honoured (Date → ISO string), bigint → decimal string.6 * Strings are made storable in Postgres jsonb: NUL → U+FFFD and lone surrogates → U+FFFD.7 * Everything that enters the journal goes through this function, and every value handed back to run8 * logic is `JSON.parse(canonicalJson(x))`, so first execution and replay see byte-identical data.9 */10export function canonicalJson(value: unknown): string {11 const out = enc(value, new Set<object>());12 return out === undefined ? 'null' : out;13}1415function cleanString(s: string): string {16 let t = s;17 if (t.includes('\u0000')) t = t.replaceAll('\u0000', '\uFFFD');18 // ES2024 string methods (Node ≥ 20); the project lib target is ES2023.19 const w = t as string & { isWellFormed(): boolean; toWellFormed(): string };20 return w.isWellFormed() ? t : w.toWellFormed();21}2223function enc(v: unknown, seen: Set<object>): string | undefined {24 if (v === null) return 'null';25 switch (typeof v) {26 case 'string':27 return JSON.stringify(cleanString(v));28 case 'number':29 return Number.isFinite(v) ? JSON.stringify(v) : 'null';30 case 'boolean':31 return v ? 'true' : 'false';32 case 'bigint':33 return JSON.stringify(v.toString());34 case 'undefined':35 case 'function':36 case 'symbol':37 return undefined;38 default:39 break;40 }41 const o = v as object & { toJSON?: unknown };42 if (typeof o.toJSON === 'function') return enc((o.toJSON as () => unknown)(), seen);43 if (seen.has(o)) throw new TypeError('canonicalJson: circular structure');44 seen.add(o);45 try {46 if (Array.isArray(o)) return `[${o.map((x) => enc(x, seen) ?? 'null').join(',')}]`;47 if (ArrayBuffer.isView(o)) throw new TypeError('canonicalJson: binary data is not JSON-serialisable (store an artifact)');48 const rec = o as Record<string, unknown>;49 const parts: string[] = [];50 for (const k of Object.keys(rec).sort()) {51 const e = enc(rec[k], seen);52 if (e !== undefined) parts.push(`${JSON.stringify(cleanString(k))}:${e}`);53 }54 return `{${parts.join(',')}}`;55 } finally {56 seen.delete(o);57 }58}5960/** Round-trip through canonical JSON (what replay will return). */61export function canonicalize<T>(value: T): T {62 return JSON.parse(canonicalJson(value)) as T;63}6465export function sha256Hex(data: string | Uint8Array): string {66 return createHash('sha256').update(data).digest('hex');67}6869export function requestHash(request: unknown): string {70 return sha256Hex(canonicalJson(request));71}7273/** Stable idempotency key of a step: sha256(run_id ':' seq). */74export function idemKeyFor(runId: string, seq: number): string {75 return sha256Hex(`${runId}:${seq}`);76}77