brainy/src/db/stableEqual.ts

65 lines
2.3 KiB
TypeScript
Raw Normal View History

feat(8.0): temporal range verbs — diff, history, since(gen|Date), asOf{exclusive}, transactionLog window asOf() answers "state AT a point"; these answer "what happened BETWEEN two points" and "one entity's whole history", all on the existing generation records. - Extract resolveAsOfGeneration() as the shared gen|Date→{generation,timestamp} chokepoint (reachability + Date semantics identical everywhere). asOf()'s inclusive path is byte-identical — db-mvcc still 25/25. - asOf(target, { exclusive }) — strict-before (resolved−1, clamped at gen 0, never a RangeError). - db.since(Db | generation | Date) — overload; number/Date resolve via the shared resolver (new DbHost.resolveGeneration); EXCLUSIVE lower bound; since(db) === since(db.generation). Same-store guard via Db.belongsToStore. - brain.diff(a, b) → { added, removed, modified } split by nouns/verbs. EARNS its name: candidate set is ONLY changedBetween(gLow,gHigh), each classified by existence at both endpoints + a key-order-insensitive value compare (new src/db/stableEqual.ts) — a touched-but-reverted / born-and-died id is in no bucket. - brain.history(id, { from, to }) → every distinct version oldest→newest, each value === asOf(version.generation).get(id); null = removal; kind auto-detected (throws on UUID-space collision). New store helper generationsTouching(). - brain.transactionLog({ from, to, limit }) — INCLUSIVE generation/Date window (contrast since's exclusive lower bound); limit applied last. - Compaction policy locked: diff/since THROW GenerationCompactedError below the horizon; history TRUNCATES to it. Types DiffResult/HistoryVersion/EntityHistory exported from the package root. Tests: tests/integration/db-temporal.test.ts (8 proofs incl. diff-earns-its-name, history↔asOf cross-check, the composition proof, granularity, compaction contrast) + tests/unit/db/stableEqual.test.ts (6). docs/guides/snapshots-and-time-travel.md + RELEASES updated. 1477 unit + db-temporal 8 + db-mvcc 25 green.
2026-06-19 13:21:02 -07:00
/**
* @module db/stableEqual
* @description Key-order-insensitive deep equality, used by {@link Brainy.diff}
* to decide whether an id present at both endpoints actually *changed* value.
*
* A naive `JSON.stringify(a) === JSON.stringify(b)` is wrong for this: object key
* order is insignificant for stored-metadata equality, but `stringify` is
* order-sensitive, so `{a:1,b:2}` and `{b:2,a:1}` would mis-compare as changed and
* a no-op re-write would show up as a spurious `modified`. This walks both values
* structurally object keys compared as a set, array elements compared in order
* (arrays ARE order-significant) over the JSON-shaped values that live in
* generation records.
*/
/**
* @description Deep-equal two JSON-shaped values, insensitive to object key order.
* @param a - First value.
* @param b - Second value.
* @returns `true` if structurally equal (key order ignored; array order honored;
* `NaN` equals `NaN`).
* @example
* stableDeepEqual({ a: 1, b: 2 }, { b: 2, a: 1 }) // → true
* stableDeepEqual([1, 2], [2, 1]) // → false (order matters)
*/
export function stableDeepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true
// NaN is never === itself, but for a stable value comparison NaN equals NaN.
if (typeof a === 'number' && typeof b === 'number') {
return Number.isNaN(a) && Number.isNaN(b)
}
// Distinct primitives, or exactly one of them null/non-object → not equal
// (the `a === b` above already returned true for equal primitives and for
// both-null).
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
return false
}
const aIsArr = Array.isArray(a)
const bIsArr = Array.isArray(b)
if (aIsArr !== bIsArr) return false
if (aIsArr) {
const aa = a as unknown[]
const ba = b as unknown[]
if (aa.length !== ba.length) return false
for (let i = 0; i < aa.length; i++) {
if (!stableDeepEqual(aa[i], ba[i])) return false
}
return true
}
const ao = a as Record<string, unknown>
const bo = b as Record<string, unknown>
const aKeys = Object.keys(ao)
const bKeys = Object.keys(bo)
if (aKeys.length !== bKeys.length) return false
for (const k of aKeys) {
if (!Object.prototype.hasOwnProperty.call(bo, k)) return false
if (!stableDeepEqual(ao[k], bo[k])) return false
}
return true
}