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

@ -134,8 +134,12 @@ import type {
TransactOptions,
TransactReceipt,
TxLogEntry,
TxOperation
TxOperation,
DiffResult,
EntityHistory,
HistoryVersion
} from './db/types.js'
import { stableDeepEqual } from './db/stableEqual.js'
import type { VersionedIndexProvider } from './plugin.js'
import type { Operation } from './transaction/types.js'
@ -5024,18 +5028,48 @@ export class Brainy<T = any> implements BrainyInterface<T> {
* `compactHistory()` never rewrites the log entries may reference
* generations whose record-sets were already reclaimed.
*
* @param options - `limit` caps the number of entries returned (newest
* first; default: all).
* @param options - `from`/`to` (generation number or `Date`) bound the window
* to commits in `[from, to]` INCLUSIVE on both ends a log window names
* commits, in deliberate contrast to `since()`'s EXCLUSIVE lower bound.
* `limit` caps the result and is applied LAST (after windowing), newest
* first. All omitted = the whole log. `from`/`to` resolve through the same
* reachability gate as `asOf()`.
* @returns Committed transaction entries, newest first.
* @throws RangeError when the resolved `from` generation is after `to`.
* @example
* await brain.transact([{ op: 'update', id, metadata: { v: 2 } }], { meta: { author: 'sync-job' } })
* const [latest] = await brain.transactionLog({ limit: 1 })
* latest.meta // { author: 'sync-job' }
* // Commits in a window, newest first:
* const window = await brain.transactionLog({ from: 10, to: 20 })
*/
async transactionLog(options?: { limit?: number }): Promise<TxLogEntry[]> {
async transactionLog(options?: {
from?: number | Date
to?: number | Date
limit?: number
}): Promise<TxLogEntry[]> {
await this.ensureInitialized()
const entries = await this.generationStore.txLog()
entries.reverse()
let entries = await this.generationStore.txLog() // oldest first
if (options?.from !== undefined || options?.to !== undefined) {
const fromGen =
options?.from !== undefined
? (await this.resolveAsOfGeneration(options.from)).generation
: 0
const toGen =
options?.to !== undefined
? (await this.resolveAsOfGeneration(options.to)).generation
: this.generationStore.generation()
if (fromGen > toGen) {
throw new RangeError(
`transactionLog(): resolved from-generation ${fromGen} is after to-generation ${toGen}`
)
}
// Inclusive on BOTH ends (a log window names the commits it spans).
entries = entries.filter((e) => e.generation >= fromGen && e.generation <= toGen)
}
entries.reverse() // newest first
return options?.limit !== undefined ? entries.slice(0, options.limit) : entries
}
@ -5087,7 +5121,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
* @param options - HOW to export (vectors / VFS bytes / edge policy). See {@link ExportOptions}.
* @returns A versioned, portable `PortableGraph` document.
* @example
* const backup = await brain.export({ ids }, { includeVectors: true })
* const graph = await brain.export({ ids }, { includeVectors: true })
*/
async export(selector: ExportSelector = {}, options: ExportOptions = {}): Promise<PortableGraph> {
return this.now().export(selector, options)
@ -5196,6 +5230,11 @@ export class Brainy<T = any> implements BrainyInterface<T> {
* Release the returned `Db` when done.
*
* @param target - Generation number, `Date`, or snapshot directory path.
* @param options - `exclusive: true` pins the generation immediately BEFORE the
* one `target` resolves to (strict-before): `asOf(g, { exclusive: true })` is
* the state at `g 1`; strict-before the first commit pins generation 0 (the
* empty pre-commit state), never a `RangeError`. Ignored for the snapshot
* (string) form.
* @returns A `Db` pinned at the resolved state.
* @throws GenerationCompactedError when the generation's records were
* reclaimed by `compactHistory()`.
@ -5203,9 +5242,13 @@ export class Brainy<T = any> implements BrainyInterface<T> {
* @example
* const yesterday = await brain.asOf(new Date(Date.now() - 86_400_000))
* const atGen42 = await brain.asOf(42)
* const beforeGen42 = await brain.asOf(42, { exclusive: true }) // state at gen 41
* const fromSnapshot = await brain.asOf('/backups/2026-06-01')
*/
async asOf(target: number | Date | string): Promise<Db<T>> {
async asOf(
target: number | Date | string,
options?: { exclusive?: boolean }
): Promise<Db<T>> {
await this.ensureInitialized()
if (typeof target === 'string') {
@ -5219,21 +5262,278 @@ export class Brainy<T = any> implements BrainyInterface<T> {
return Brainy.load<T>(target)
}
const { generation, timestamp } = await this.resolveAsOfGeneration(target, options)
return this.createPinnedDb({ generation, timestamp })
}
/**
* @description The shared `generation | Date` `{ generation, timestamp }`
* resolver used by every gen/Date-accepting temporal verb (`asOf`, `since`,
* `diff`, `transactionLog`) so reachability, `RangeError`, and `Date` semantics
* stay identical across all of them. The inclusive path is byte-for-byte the
* pre-extraction `asOf` logic; `exclusive` pins the generation immediately
* before the resolved one (clamped at 0, never a `RangeError`). All resolved
* generations pass through `assertReachable` (so a compacted target throws
* {@link GenerationCompactedError}); `history()` deliberately does NOT use this
* resolver it truncates below the horizon rather than throwing.
* @param target - A generation number or a `Date`.
* @param options - `exclusive: true` for strict-before (resolved 1).
*/
private async resolveAsOfGeneration(
target: number | Date,
options?: { exclusive?: boolean }
): Promise<{ generation: number; timestamp: number }> {
const exclusive = options?.exclusive === true
let generation: number
let timestamp: number
if (target instanceof Date) {
const resolved = await this.generationStore.resolveTimestamp(target.getTime())
this.generationStore.assertReachable(resolved.generation)
generation = resolved.generation
timestamp = resolved.entry?.timestamp ?? target.getTime()
if (exclusive) {
generation = Math.max(0, resolved.generation - 1)
this.generationStore.assertReachable(generation)
timestamp =
(await this.generationStore.commitTimestampAtOrBefore(generation)) ?? target.getTime()
} else {
this.generationStore.assertReachable(resolved.generation)
generation = resolved.generation
timestamp = resolved.entry?.timestamp ?? target.getTime()
}
} else {
this.generationStore.assertReachable(target)
generation = target
generation = exclusive ? Math.max(0, target - 1) : target
this.generationStore.assertReachable(generation)
timestamp =
(await this.generationStore.commitTimestampAtOrBefore(target)) ?? Date.now()
(await this.generationStore.commitTimestampAtOrBefore(generation)) ?? Date.now()
}
return { generation, timestamp }
}
/**
* @description Compare two states of the brain and report what CHANGED between
* them entities/relationships `added`, `removed`, or `modified`, each split by
* kind. Unlike a raw touched-id list, `diff` EARNS its name: the candidate set is
* ONLY the ids a committed transaction touched in the interval, and each is then
* resolved at BOTH endpoints and classified by existence and a stable value
* comparison. An id touched within the interval but whose endpoint states are
* identical (e.g. changed then reverted) appears in NONE of the three buckets.
*
* Orientation is `a → b`: `added` = exists at `b` not `a`; `removed` = exists at
* `a` not `b`; `modified` = exists at both with a changed stored value
* (key-order-insensitive). Endpoints may be passed in either order.
*
* @param a - The "before" state: a generation number, a `Date`, or a `Db`.
* @param b - The "after" state: a generation number, a `Date`, or a `Db`.
* @returns A {@link DiffResult} (each bucket's ids sorted).
* @throws GenerationCompactedError when a number/`Date` endpoint is below the horizon.
* @example
* const before = brain.generation()
* await brain.transact(ops)
* const d = await brain.diff(before, brain.generation())
* d.added.nouns // ids created by the transaction
* d.modified.nouns // ids whose stored value actually changed
*/
async diff(a: number | Date | Db<T>, b: number | Date | Db<T>): Promise<DiffResult> {
await this.ensureInitialized()
const gA = await this.resolveDiffEndpoint(a)
const gB = await this.resolveDiffEndpoint(b)
const result: DiffResult = {
added: { nouns: [], verbs: [] },
removed: { nouns: [], verbs: [] },
modified: { nouns: [], verbs: [] },
fromGeneration: gA,
toGeneration: gB
}
return this.createPinnedDb({ generation, timestamp })
// Candidate set: ONLY the ids touched in the interval (never all ids) — this
// is what lets diff classify rather than dump raw changed-ids.
const gLow = Math.min(gA, gB)
const gHigh = Math.max(gA, gB)
if (gLow === gHigh) return result // same state → no changes
const changed = await this.generationStore.changedBetween(gLow, gHigh)
for (const kind of ['noun', 'verb'] as const) {
const ids = kind === 'noun' ? changed.nouns : changed.verbs
for (const id of ids) {
const stateA = await this.recordStateAt(kind, id, gA)
const stateB = await this.recordStateAt(kind, id, gB)
const bucket = (name: 'added' | 'removed' | 'modified') =>
kind === 'noun' ? result[name].nouns : result[name].verbs
if (!stateA.exists && stateB.exists) bucket('added').push(id)
else if (stateA.exists && !stateB.exists) bucket('removed').push(id)
else if (
stateA.exists &&
stateB.exists &&
!stableDeepEqual(stateA.metadata, stateB.metadata)
) {
bucket('modified').push(id)
}
// both absent, or both exist and identical → in NO bucket (earns the name)
}
}
for (const name of ['added', 'removed', 'modified'] as const) {
result[name].nouns.sort()
result[name].verbs.sort()
}
return result
}
/**
* @description Every distinct version of ONE entity or relationship over a
* generation range, oldest newest the per-id companion to `asOf()`. Each
* {@link HistoryVersion} ties to the trusted `asOf()` path: its `value` equals
* `(await brain.asOf(version.generation)).get(id)`. Consecutive identical states
* are collapsed, and a leading "did not exist yet" baseline is dropped, so the
* first version is the id's first real state in the range and a `null` value
* marks a removal.
*
* Range defaults to `[horizon, currentGeneration]`. Unlike `diff`/`since`, a
* `from` below the compaction horizon is TRUNCATED to the horizon, not thrown
* history is best-effort over the records that survive.
*
* @param id - The entity or relationship id (kind auto-detected).
* @param options - `from`/`to` (generation number or `Date`) bound the range.
* @returns An {@link EntityHistory} (`versions` oldest first; empty if the id is
* unknown over the range).
* @throws Error if `id` resolves in BOTH the entity and relationship spaces.
* @example
* const h = await brain.history(invoiceId)
* h.versions.map(v => v.value?.metadata?.status) // the status at each change
*/
async history(
id: string,
options?: { from?: number | Date; to?: number | Date }
): Promise<EntityHistory<T>> {
await this.ensureInitialized()
const store = this.generationStore
const horizon = store.horizon()
const current = store.generation()
// history TRUNCATES below the horizon (never throws): resolve raw, then clamp.
const rawFrom =
options?.from !== undefined ? await this.resolveRawGeneration(options.from) : horizon
const rawTo =
options?.to !== undefined ? await this.resolveRawGeneration(options.to) : current
const fromGen = Math.min(Math.max(rawFrom, horizon), current)
const toGen = Math.min(Math.max(rawTo, 0), current)
const kind = await this.detectIdKind(id, horizon, current)
if (kind === null || fromGen > toGen) {
return { id, kind: kind ?? 'noun', versions: [] }
}
// Snapshot generations: the range start (state as-of fromGen) plus every
// committed change-point in (fromGen, toGen].
const touches = await store.generationsTouching(kind, id, fromGen, toGen)
const snapshotGens = [fromGen, ...touches]
const versions: HistoryVersion<T>[] = []
let prev: { exists: boolean; metadata: any } | null = null
for (const g of snapshotGens) {
const state = await this.recordStateAt(kind, id, g)
if (
prev !== null &&
prev.exists === state.exists &&
stableDeepEqual(prev.metadata, state.metadata)
) {
continue // collapse consecutive identical states
}
prev = { exists: state.exists, metadata: state.metadata }
const timestamp = (await store.commitTimestampAtOrBefore(g)) ?? 0
let value: Entity<T> | Relation<T> | null = null
if (state.exists) {
value =
kind === 'noun'
? await this.entityFromGenerationRecord(
id,
{ metadata: state.metadata, vector: state.vector },
false
)
: this.relationFromGenerationRecord(id, {
metadata: state.metadata,
vector: state.vector
})
}
versions.push({ generation: g, timestamp, value })
}
// Drop a leading "did not exist yet" baseline so history starts at the id's
// first real state (internal/trailing nulls = real removals stay).
while (versions.length > 0 && versions[0].value === null) versions.shift()
return { id, kind, versions }
}
/** Resolve a `Db | generation | Date` diff endpoint to a concrete generation. */
private async resolveDiffEndpoint(endpoint: number | Date | Db<T>): Promise<number> {
if (endpoint instanceof Db) {
if (!endpoint.belongsToStore(this.generationStore)) {
throw new Error('diff(): a Db endpoint must come from this same Brainy instance')
}
return endpoint.generation
}
return (await this.resolveAsOfGeneration(endpoint)).generation
}
/**
* Resolve a `generation | Date` to a raw generation number WITHOUT the
* reachability assertion for `history()`, which truncates below the horizon
* rather than throwing.
*/
private async resolveRawGeneration(target: number | Date): Promise<number> {
if (target instanceof Date) {
return (await this.generationStore.resolveTimestamp(target.getTime())).generation
}
return target
}
/**
* The stored state of `id` at pinned generation `gen`, normalizing `resolveAt`'s
* three sources to `{ exists, metadata, vector }`. A `'current'` source means the
* live storage state IS the state at `gen`, so it reads the live metadata.
*/
private async recordStateAt(
kind: 'noun' | 'verb',
id: string,
gen: number
): Promise<{ exists: boolean; metadata: any; vector: any }> {
const r = await this.generationStore.resolveAt(kind, id, gen)
if (r.source === 'absent') return { exists: false, metadata: null, vector: null }
if (r.source === 'record') return { exists: true, metadata: r.metadata, vector: r.vector }
const metadata =
kind === 'noun'
? await this.storage.getNounMetadata(id)
: await this.storage.getVerbMetadata(id)
return { exists: metadata !== null, metadata, vector: null }
}
/**
* Auto-detect whether `id` is an entity (`'noun'`) or relationship (`'verb'`) by
* its presence live and in the generation deltas over `(fromGen, toGen]`. Returns
* `null` for an unknown id; throws on the (UUID-space) collision where the id
* resolves in both spaces.
*/
private async detectIdKind(
id: string,
fromGen: number,
toGen: number
): Promise<'noun' | 'verb' | null> {
const liveNoun = (await this.storage.getNounMetadata(id)) !== null
const liveVerb = (await this.storage.getVerbMetadata(id)) !== null
const nounTouched =
(await this.generationStore.generationsTouching('noun', id, fromGen, toGen)).length > 0
const verbTouched =
(await this.generationStore.generationsTouching('verb', id, fromGen, toGen)).length > 0
const isNoun = liveNoun || nounTouched
const isVerb = liveVerb || verbTouched
if (isNoun && isVerb) {
throw new Error(
`history(): id '${id}' resolves in BOTH the entity and relationship spaces — ` +
`ambiguous (should not happen with UUID ids).`
)
}
if (isNoun) return 'noun'
if (isVerb) return 'verb'
return null
}
/**
@ -5471,6 +5771,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
get: (id, options) => this.get(id, options),
find: (query) => this.find(query),
related: (paramsOrId) => this.related(paramsOrId),
resolveGeneration: (target, options) => this.resolveAsOfGeneration(target, options),
entityFromRecord: (id, record, includeVectors) =>
this.entityFromGenerationRecord(id, record, includeVectors),
relationFromRecord: (id, record) => this.relationFromGenerationRecord(id, record),

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)
}
// ==========================================================================

View file

@ -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
View 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
}

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
// ============================================================================

View file

@ -172,7 +172,10 @@ export type {
TxLogEntry,
CompactHistoryOptions,
CompactHistoryResult,
ChangedIds
ChangedIds,
DiffResult,
HistoryVersion,
EntityHistory
} from './db/types.js'
// Optional provider capability for generation-aware native indexes
export { isVersionedIndexProvider } from './plugin.js'