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.
64 lines
2.3 KiB
TypeScript
64 lines
2.3 KiB
TypeScript
/**
|
|
* @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
|
|
}
|