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:
parent
373a48122d
commit
2c84f86815
10 changed files with 1069 additions and 37 deletions
73
src/db/db.ts
73
src/db/db.ts
|
|
@ -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)
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
|
|
|
|||
|
|
@ -583,6 +583,32 @@ export class GenerationStore {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description The committed generations in `(fromGen, toGen]` that touched a
|
||||
* single id, ascending. The per-id companion to {@link changedBetween} — used
|
||||
* by `history()` to walk one entity's change-points without scanning every id.
|
||||
* No all-ids index: it consults the same cached per-generation deltas.
|
||||
* @param kind - `'noun'` for entities, `'verb'` for relationships.
|
||||
* @param id - The id to track.
|
||||
* @param fromGen - Exclusive lower bound.
|
||||
* @param toGen - Inclusive upper bound.
|
||||
*/
|
||||
async generationsTouching(
|
||||
kind: 'noun' | 'verb',
|
||||
id: string,
|
||||
fromGen: number,
|
||||
toGen: number
|
||||
): Promise<number[]> {
|
||||
const result: number[] = []
|
||||
for (const gen of this.committedGens) {
|
||||
if (gen <= fromGen || gen > toGen) continue
|
||||
const delta = await this.getDelta(gen)
|
||||
const touched = kind === 'noun' ? delta.nouns : delta.verbs
|
||||
if (touched.has(id)) result.push(gen)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Assert that generation `gen` is reachable (not compacted
|
||||
* away) and within the committed range, then return it. Used by `asOf()`.
|
||||
|
|
|
|||
64
src/db/stableEqual.ts
Normal file
64
src/db/stableEqual.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* @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
|
||||
}
|
||||
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue