SPB Git

Repository Public

canonical.ts 2.8 KB 77 lines typescript

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
Files main
Menu

Theme