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

@ -116,6 +116,16 @@ export interface DbHost<T = any> {
find(query: string | FindParams<T>): Promise<Result<T>[]>
/** Live `brain.related()` (current-generation fast path). */
related(paramsOrId?: string | RelatedParams): Promise<Relation<T>[]>
/**
* Resolve a `generation | Date` to a concrete generation (+ timestamp) via the
* brain's shared `asOf` resolver — so `since(gen|Date)` shares `asOf`'s
* reachability and `Date` semantics. `exclusive` pins the generation before the
* resolved one.
*/
resolveGeneration(
target: number | Date,
options?: { exclusive?: boolean }
): Promise<{ generation: number; timestamp: number }>
/** Materialize an entity from a generation record's raw stored objects. */
entityFromRecord(
id: string,
@ -213,6 +223,16 @@ export class Db<T = any> {
return this.gen
}
/**
* @internal Same-instance guard for cross-`Db` operations like
* `Brainy.diff()`: whether this view is backed by `store`. Lets `Brainy`
* reject a `Db` endpoint from a different brain without exposing the private
* host.
*/
belongsToStore(store: GenerationStore): boolean {
return this.host.store === store
}
/**
* The view's wall-clock timestamp (ms since epoch): pin time for `now()`,
* commit time for `transact()`, and the resolved commit time for `asOf()`.
@ -752,27 +772,54 @@ export class Db<T = any> {
/**
* @description The ids changed by committed transactions in the interval
* `(priorDb.generation, this.generation]` the diff between two pinned
* views of the same store. Single-operation writes between commits are not
* reflected (documented 8.0 history granularity).
* `(lowerBound, this.generation]` the diff between an earlier state and this
* view. The lower bound is **exclusive** (contrast `transactionLog`'s inclusive
* window). Single-operation writes between commits are not reflected (documented
* 8.0 history granularity).
*
* @param priorDb - An earlier view of the SAME store.
* The lower bound may be:
* - **a `Db`** an earlier view of the SAME store (its pinned generation);
* - **a generation number** used directly as the exclusive lower bound;
* - **a `Date`** resolved to the generation committed at or before it.
*
* `db.since(prior)` equals `db.since(prior.generation)` by construction.
*
* @param lowerBound - An earlier `Db`, a generation number, or a `Date`.
* @returns Changed entity and relation ids, sorted.
* @throws RangeError when `priorDb` is newer than this view, or Error when
* the views belong to different stores.
* @throws RangeError when the resolved lower bound is newer than this view.
* @throws Error when a `Db` lower bound belongs to a different store.
* @throws GenerationCompactedError when a number/`Date` lower bound is below the
* compaction horizon.
* @example
* const before = brain.now()
* await brain.transact(ops)
* await brain.now().since(before) // ids changed by the transaction
* await brain.now().since(before.generation)
* await brain.now().since(new Date(Date.now() - 3_600_000))
*/
async since(priorDb: Db<T>): Promise<ChangedIds> {
async since(lowerBound: Db<T> | number | Date): Promise<ChangedIds> {
this.assertUsable('since')
if (priorDb.host.store !== this.host.store) {
throw new Error('since(): both Db values must come from the same Brainy instance')
let fromGen: number
if (lowerBound instanceof Db) {
if (lowerBound.host.store !== this.host.store) {
throw new Error('since(): both Db values must come from the same Brainy instance')
}
fromGen = lowerBound.gen
} else {
// number = exclusive lower-bound generation; Date resolves via the shared
// asOf resolver (same reachability gate). changedBetween treats fromGen as
// exclusive, so since(db) === since(db.generation).
fromGen = (await this.host.resolveGeneration(lowerBound)).generation
}
if (priorDb.gen > this.gen) {
if (fromGen > this.gen) {
throw new RangeError(
`since(): priorDb is at generation ${priorDb.gen}, which is newer than this view ` +
`(generation ${this.gen}). Call newerDb.since(olderDb).`
`since(): lower-bound generation ${fromGen} is newer than this view ` +
`(generation ${this.gen}). Pass an earlier generation/Date, or call newerDb.since(olderDb).`
)
}
return this.host.store.changedBetween(priorDb.gen, this.gen)
return this.host.store.changedBetween(fromGen, this.gen)
}
// ==========================================================================