4,816 bytes · the TypeScript implementation · view raw
import { type RecordChange, type RecordDiff } from"./collections_diff_types.ts";
/** An open record, the manifest's `record`: a JSON-ish map whose shape is not known ahead of time. */type DiffRecord = Readonly<Record<string, unknown>>;
/** Largest integer JavaScript can hold exactly; beyond it the three languages disagree. */const SAFE_INTEGER = 9007199254740991;
function isMap(value: unknown): value is DiffRecord {
returntypeof value === "object" && value !== null && !Array.isArray(value);
}
/** * A record's key as text, rendered the way collections.group-by-key renders a * group name so the two agree on what "the same key" means. Null when absent. */function keyOf(value: unknown, key: string): string | null {
if (value === undefined || value === null) returnnull;
if (typeof value === "string") return value;
if (typeof value === "boolean") return value ? "true" : "false";
if (typeof value === "number") {
if (!Number.isInteger(value)) {
thrownew TypeError(`cannot diff by the fractional number ${value} at "${key}"`);
}
if (Math.abs(value) > SAFE_INTEGER) {
thrownew RangeError(`cannot diff by the out-of-range number ${value} at "${key}"`);
}
return String(value);
}
thrownew TypeError(`cannot diff by the list or map at "${key}"`);
}
/** * Deep JSON equality, spelled out so all three languages agree: numbers by * value (1 equals 1.0), no coercion between types (true is not 1, "1" is not * 1), lists in order, and a missing map field equal to a null one. */function same(a: unknown, b: unknown): boolean {
const aNull = a === undefined || a === null;
const bNull = b === undefined || b === null;
if (aNull || bNull) return aNull && bNull;
if (typeof a === "boolean" || typeof a === "number" || typeof a === "string") {
returntypeof a === typeof b && a === b;
}
if (Array.isArray(a)) {
if (!Array.isArray(b) || a.length !== b.length) returnfalse;
for (let i = 0; i < a.length; i++) if (!same(a[i], b[i])) returnfalse;
returntrue;
}
if (isMap(a) && isMap(b)) {
for (const k of Object.keys(a)) if (!same(a[k], b[k])) returnfalse;
for (const k of Object.keys(b)) if (!(k in a) && !same(undefined, b[k])) returnfalse;
returntrue;
}
returnfalse;
}
/** Code point order; JavaScript's `<` compares UTF-16 code units instead. */function compareCodePoints(a: string, b: string): number {
const ca = Array.from(a);
const cb = Array.from(b);
const shared = Math.min(ca.length, cb.length);
for (let i = 0; i < shared; i++) {
const x = ca[i].codePointAt(0) as number;
const y = cb[i].codePointAt(0) as number;
if (x !== y) return x < y ? -1 : 1;
}
return ca.length === cb.length ? 0 : ca.length < cb.length ? -1 : 1;
}
/** Key every record of one list, refusing missing and duplicate keys. */function index(records: readonly DiffRecord[], key: string, side: string): Map<string, number> {
const byKey = new Map<string, number>();
records.forEach((record, i) => {
const k = keyOf(isMap(record) ? record[key] : undefined, key);
if (k === null) {
thrownew TypeError(`record ${i} in ${side} has no value at "${key}"`);
}
// Picking one of two duplicates would report changes that never happened.if (byKey.has(k)) {
thrownew RangeError(`duplicate key "${k}" in ${side}`);
}
byKey.set(k, i);
});
return byKey;
}
/** * Compare two lists of records matched by `key`: what was added, what was * removed, and what changed and in which fields. */exportfunction diffByKey(
before: readonly DiffRecord[],
after: readonly DiffRecord[],
key: string,
): RecordDiff<DiffRecord> {
if (!Array.isArray(before) || !Array.isArray(after)) {
thrownew TypeError("diffByKey needs two lists of records");
}
if (typeof key !== "string" || key.length === 0) {
thrownew TypeError("diffByKey needs a non-empty key name");
}
const beforeKeys = index(before, key, "before");
const afterKeys = index(after, key, "after");
const added: DiffRecord[] = [];
const changed: RecordChange<DiffRecord>[] = [];
let unchanged = 0;
for (const [k, i] of afterKeys) {
const next = after[i];
const j = beforeKeys.get(k);
if (j === undefined) {
added.push(next);
continue;
}
const prev = before[j];
const names = new Set<string>([...Object.keys(prev), ...Object.keys(next)]);
const fields = [...names].filter((name) => !same(prev[name], next[name])).sort(compareCodePoints);
if (fields.length === 0) unchanged++;
else changed.push({ key: k, before: prev, after: next, fields });
}
const removed: DiffRecord[] = [];
for (const [k, j] of beforeKeys) {
if (!afterKeys.has(k)) removed.push(before[j]);
}
return { added, removed, changed, unchanged };
}