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.
This commit is contained in:
David Snelling 2026-06-19 13:21:02 -07:00
parent 373a48122d
commit 2c84f86815
10 changed files with 1069 additions and 37 deletions

View file

@ -25,7 +25,7 @@
* `docs/ADR-001-generational-mvcc.md` for the full justification.
*/
import type { AddParams, UpdateParams, RelateParams } from '../types/brainy.types.js'
import type { AddParams, UpdateParams, RelateParams, Entity, Relation } from '../types/brainy.types.js'
// ============================================================================
// Transaction operations (brain.transact input)
@ -193,6 +193,58 @@ export interface ChangedIds {
toGeneration: number
}
/**
* @description The result of {@link Brainy.diff}: ids that came into existence,
* went away, or changed value between two states, each split by kind. Unlike the
* raw touched-id set of {@link ChangedIds}, `diff` EARNS its name it resolves
* each touched id at BOTH endpoints and classifies by existence (added/removed)
* and stable value comparison (modified). An id touched within the interval but
* whose endpoint states are identical (touched-then-reverted) appears in NONE of
* the three buckets. Orientation is `a → b`: `added` exists at `b` not `a`.
*/
export interface DiffResult {
/** Ids absent at `a`, present at `b`. */
added: { nouns: string[]; verbs: string[] }
/** Ids present at `a`, absent at `b`. */
removed: { nouns: string[]; verbs: string[] }
/** Ids present at both endpoints with a changed stored value. */
modified: { nouns: string[]; verbs: string[] }
/** The resolved generation of endpoint `a`. */
fromGeneration: number
/** The resolved generation of endpoint `b`. */
toGeneration: number
}
/**
* @description One version of a single entity or relationship in its
* {@link EntityHistory} the materialized state at a committed generation that
* changed it. `value` is `null` when the id did not exist at that version
* (created-after / removed). Granularity is `transact()` commits.
*/
export interface HistoryVersion<T = any> {
/** The committed generation that produced this version. */
generation: number
/** Commit timestamp of that generation (ms since epoch). */
timestamp: number
/** Materialized state at this version, or `null` if the id did not exist then. */
value: Entity<T> | Relation<T> | null
}
/**
* @description The result of {@link Brainy.history}: every distinct version of
* ONE id over a generation range, oldest newest. Consecutive identical states
* are collapsed (one entry per distinct state). `kind` is auto-detected from the
* id's presence in the noun vs verb spaces.
*/
export interface EntityHistory<T = any> {
/** The id whose history this is. */
id: string
/** Whether `id` is an entity (`'noun'`) or relationship (`'verb'`). */
kind: 'noun' | 'verb'
/** Distinct versions, oldest first. */
versions: HistoryVersion<T>[]
}
// ============================================================================
// Persisted record shapes
// ============================================================================