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
331
src/brainy.ts
331
src/brainy.ts
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue