feat(8.0): full query surface at historical generations via ephemeral index materialization

Historical Db values (now()/asOf() pins that history has moved past) now
serve the COMPLETE query surface - vector/hybrid search, graph traversal,
cursor pagination, and aggregation - by materializing ephemeral in-memory
indexes over the exact at-generation record set. The historical-query
throw is gone; NotYetSupportedAtHistoricalGenerationError is deleted.

Materializer (Brainy.materializeAtGeneration):
- Copies the at-G record set (live bytes for ids untouched since the pin,
  immutable before-images otherwise) into a fresh MemoryStorage; a final
  reconciliation pass under the commit mutex makes the copy exact even
  when transactions commit mid-build.
- Opens a read-only Brainy over the copy: init rebuilds the metadata and
  graph-adjacency indexes from the records; the vector index is built by
  inserting every at-G vector (the at-G HNSW graph never existed on disk,
  so there is nothing to restore). Host embedder and aggregate definitions
  are shared - no second model load, aggregates backfill at-G values.
- Cost is the documented contract: O(n at G) time and memory, ONCE per Db
  (handle cached; freed by release(), with a FinalizationRegistry backstop
  that also closes leaked readers). A native VersionedIndexProvider serves
  the same reads from retained segments with no rebuild.

Db routing (src/db/db.ts): metadata-level find()/related() keep the free
record path; index-only dimensions (query/vector/near/connected/cursor/
aggregate/includeRelations/non-metadata modes) route to the cached
materialization; unsupported where-operators on the record path re-route
there too instead of erroring. Speculative with() overlays keep the one
honest boundary - SpeculativeOverlayError (overlay entities carry no
embeddings, so index reads over them would be silently incomplete);
metadata find()/get()/filter related() work on overlays.

UpdateParams.vector contract now honored: an explicit pre-computed vector
applies directly (with dimension validation) in update() and transact
update ops, re-indexing HNSW - previously it was silently ignored unless
data also changed.

GraphAdjacencyIndex: adjacency now derives from the two verb-id LSM trees
filtered through the live-verb tombstone set (entity->entity edge trees
deleted - they carried no verb ids, so removeVerb could never tombstone
them and traversal served stale neighbors forever). Neighbor reads batch-
load live verbs via the unified cache; addVerb seeds the cache.

Proofs (tests/integration/db-mvcc.test.ts, 24 green): historical vector
search finds old vector placement including since-deleted entities;
historical graph traversal walks the old wiring after a rewire; historical
aggregation computes at-G group values; asOf() pins get the same surface;
the materialization builds once per Db and release() closes the ephemeral
reader (it refuses reads afterwards); overlays throw the documented error.
ADR-001 updated to the no-throws historical model.
This commit is contained in:
David Snelling 2026-06-11 08:12:11 -07:00
parent 8f93add705
commit e5feae4104
11 changed files with 869 additions and 340 deletions

View file

@ -12,13 +12,27 @@
* **Read semantics.** While no transaction has committed past the pinned
* generation, every read delegates straight to the live brain the pinned
* view IS the current state, and the existing fast paths serve it untouched.
* Once later transactions commit, the `Db` resolves reads through the
* generational record layer: `get()` and metadata-level `find()`/`related()`
* remain fully correct at the pinned generation (changed ids are resolved
* from immutable before-images; unchanged ids still ride the live fast
* path), while index-accelerated queries (semantic/vector search) throw the
* documented {@link NotYetSupportedAtHistoricalGenerationError} an honest
* boundary, never silently-wrong results.
* Once later transactions commit, the `Db` serves the FULL query surface at
* the pinned generation through two complementary paths:
*
* - `get()`, metadata-level `find()`, and filter-based `related()` resolve
* through the generational record layer (changed ids from immutable
* before-images; unchanged ids still ride the live fast path) no
* materialization cost.
* - Index-accelerated queries (semantic/vector search, graph traversal,
* cursors, aggregation) are served by **at-generation index
* materialization**: on first use, the host rebuilds ephemeral in-memory
* indexes (the same vector/metadata/graph index classes the live brain
* uses) over the exact at-generation record set, caches them on this `Db`,
* and frees them on {@link Db.release}. This costs O(n at G) time and
* memory ONCE per `Db` the open-core price of historical index queries;
* a native {@link ../plugin.js VersionedIndexProvider} serves the same
* reads from its retained segments without any rebuild.
*
* Speculative `with()` overlays keep one honest boundary: overlay entities
* carry no embeddings, so index-accelerated queries on overlays throw
* {@link SpeculativeOverlayError} instead of returning silently-incomplete
* results.
*
* **History granularity.** Generation records are written per `transact()`
* batch. Single-operation writes (`add`/`update`/`relate`/ outside
@ -43,11 +57,27 @@ import type {
Result
} from '../types/brainy.types.js'
import { v4 as uuidv4 } from '../universal/uuid.js'
import { NotYetSupportedAtHistoricalGenerationError } from './errors.js'
import { SpeculativeOverlayError } from './errors.js'
import type { GenerationStore } from './generationStore.js'
import type { ChangedIds, TransactReceipt, TxOperation } from './types.js'
import { entityMatchesFind, resolveEntityField, UnsupportedWhereOperatorError } from './whereMatcher.js'
/**
* @description The query surface a historical materialization exposes back
* to its `Db`: the full live read pipeline of an ephemeral reader brain
* whose storage holds the exact at-generation record set. Produced by
* {@link DbHost.materializeAt}; closed (indexes freed) via `close()` when
* the owning `Db` is released.
*/
export interface HistoricalQueryHandle<T = any> {
/** Full `find()` surface (vector/semantic/graph/cursor/aggregate) at the pinned generation. */
find(query: string | FindParams<T>): Promise<Result<T>[]>
/** Full `getRelations()` surface (including cursor pagination) at the pinned generation. */
getRelations(paramsOrId?: string | GetRelationsParams): Promise<Relation<T>[]>
/** Free the materialized indexes (closes the ephemeral reader). Idempotent. */
close(): Promise<void>
}
/**
* @description The speculative overlay carried by a `db.with()` value:
* per-id replacement entities/relations (`null` = tombstone). Reads on the
@ -86,6 +116,13 @@ export interface DbHost<T = any> {
relationFromRecord(id: string, record: { metadata: any; vector: any | null }): Relation<T> | null
/** Snapshot the store at `generation` into `targetPath` (throws if the pin is stale). */
persistPinned(targetPath: string, generation: number): Promise<void>
/**
* Build the at-`generation` index materialization: an ephemeral in-memory
* reader over the exact record set at that generation, serving the full
* query surface. O(n at G) called at most once per `Db` (cached by the
* caller) and freed via the returned handle's `close()`.
*/
materializeAt(generation: number): Promise<HistoricalQueryHandle<T>>
/** Add one refcounted pin (store + versioned providers) on `generation`. */
pinGeneration(generation: number): void
/** Release one refcounted pin (store + versioned providers) on `generation`. */
@ -134,6 +171,11 @@ export class Db<T = any> {
private readonly overlay?: SpeculativeOverlay<T>
private readonly closeOnRelease?: () => Promise<void>
private isReleased = false
/**
* Cached at-generation index materialization (built lazily by the first
* index-accelerated query at a historical pin; freed on release()).
*/
private materializedHandle?: Promise<HistoricalQueryHandle<T>>
/**
* Per-operation receipt (committed generation, timestamp, resolved ids in
@ -220,25 +262,31 @@ export class Db<T = any> {
}
/**
* @description Metadata-level `find()` **as of this view's generation**.
* @description The full `find()` surface **as of this view's generation**.
*
* At the current generation (nothing committed past the pin, no overlay)
* this delegates to `brain.find()` untouched the full query surface,
* including semantic search. At a historical generation, or on a
* speculative view, only the metadata dimensions are supported (`type`,
* `subtype`, `where`, `service`, `excludeVFS`, `orderBy`/`order`,
* `limit`/`offset`): results are computed by combining the live index for
* unchanged ids with per-entity evaluation of the same filters against
* generation records / overlay entries set-correct at the pinned
* generation. Index-only dimensions (`query`, `vector`, `near`,
* `connected`, `cursor`, `aggregate`, ) throw
* {@link NotYetSupportedAtHistoricalGenerationError}.
* Routing:
* - **Current generation, no overlay** delegates to `brain.find()`
* untouched (the existing fast paths).
* - **Historical generation** metadata dimensions (`type`, `subtype`,
* `where`, `service`, `excludeVFS`, `orderBy`/`order`,
* `limit`/`offset`) are computed from the generational record layer
* directly (no materialization cost): the live index serves unchanged
* ids, and per-entity evaluation of the same filters covers
* record-resolved ids set-correct at the pinned generation.
* Index-accelerated dimensions (`query`, `vector`, `near`, `connected`,
* `cursor`, `aggregate`, `includeRelations`, non-metadata search modes)
* are served by the at-generation index materialization built lazily
* on first use (O(n at G) time and memory, once per `Db`; see the module
* doc), then cached until {@link Db.release}.
* - **Speculative overlay** metadata dimensions only; index-accelerated
* dimensions throw {@link SpeculativeOverlayError} (overlay entities
* carry no embeddings see the error's documentation).
*
* Result ordering is deterministic when `orderBy` is supplied; without it,
* overlay/record-resolved matches follow the live-index matches.
* record-path results list live-index matches before record-resolved
* matches, while materialized results follow the live engine's ordering.
*
* @param query - A `FindParams` object, or a string (semantic search
* current-generation views only).
* @param query - A `FindParams` object, or a string (semantic search).
* @returns Matching results as of this generation (score `1.0` for
* record-resolved metadata matches, mirroring live metadata-only finds).
*/
@ -252,7 +300,12 @@ export class Db<T = any> {
return this.host.find(query)
}
this.assertMetadataOnlyFind(params)
if (this.overlay) {
this.assertOverlayCompatibleFind(params)
} else if (findRequiresIndexes(params)) {
const materialized = await this.materialize()
return materialized.find(params)
}
const limit = params.limit ?? 10
const offset = params.offset ?? 0
@ -307,41 +360,48 @@ export class Db<T = any> {
return merged.slice(offset, offset + limit)
} catch (err) {
if (err instanceof UnsupportedWhereOperatorError) {
throw new NotYetSupportedAtHistoricalGenerationError(
`metadata find with where-operator '${err.operator}'`,
this.gen,
this.host.store.generation()
)
// The record-path's per-entity matcher doesn't implement this
// where-operator. On an overlay that is a hard boundary; at a
// historical generation the materialization serves it via the full
// live query engine.
if (this.overlay) {
throw new SpeculativeOverlayError(
`metadata find with where-operator '${err.operator}'`,
this.gen
)
}
const materialized = await this.materialize()
return materialized.find(params)
}
throw err
}
}
/**
* @description Semantic (vector) search **at the current generation only**.
* Convenience for `find({ query, ...options })`. Vector indexes serve the
* live state; at a historical generation, or on a speculative overlay
* (whose entities carry no embeddings), this throws the documented
* {@link NotYetSupportedAtHistoricalGenerationError} persist the
* generation you need and `Brainy.load()` it for the full query surface.
* @description Semantic (vector) search **as of this view's generation**
* convenience for `find({ query, ...options })`. At the current generation
* the live vector index serves it directly; at a historical generation the
* at-generation index materialization serves it (built lazily on first
* use O(n at G) once per `Db`, freed on release; see the module doc).
* Speculative overlays throw {@link SpeculativeOverlayError}: overlay
* entities carry no embeddings, so the result would silently exclude them.
*
* @param query - Natural-language search query.
* @param options - Additional `FindParams` (limit, filters, ).
* @returns Semantic search results (current-generation views only).
* @throws NotYetSupportedAtHistoricalGenerationError at historical
* generations and on speculative overlays.
* @returns Semantic search results as of this generation.
* @throws SpeculativeOverlayError on speculative `with()` overlays.
*/
async search(query: string, options?: Omit<FindParams<T>, 'query'>): Promise<Result<T>[]> {
this.assertUsable('search')
if (!this.isHistorical() && !this.overlay) {
if (this.overlay) {
throw new SpeculativeOverlayError('vector search', this.gen)
}
if (!this.isHistorical()) {
return this.host.find({ ...(options ?? {}), query })
}
throw new NotYetSupportedAtHistoricalGenerationError(
this.overlay ? 'vector search (speculative overlay)' : 'vector search',
this.gen,
this.host.store.generation()
)
const materialized = await this.materialize()
return materialized.find({ ...(options ?? {}), query })
}
/**
@ -351,8 +411,9 @@ export class Db<T = any> {
* resolved from generation records; overlay relations (speculative
* `relate`/`unrelate`) and cascade tombstones (relations touching a
* speculatively deleted entity) are applied on top. Cursor pagination is
* index-only and throws the documented historical error on
* non-current/speculative views.
* index-only: at a historical generation it is served by the
* at-generation index materialization (O(n at G) once per `Db`); on a
* speculative overlay it throws {@link SpeculativeOverlayError}.
*
* @param paramsOrId - An entity id (shorthand for `{ from: id }`) or a
* `GetRelationsParams` filter object.
@ -370,11 +431,11 @@ export class Db<T = any> {
}
if (params.cursor !== undefined) {
throw new NotYetSupportedAtHistoricalGenerationError(
'cursor pagination on related()',
this.gen,
this.host.store.generation()
)
if (this.overlay) {
throw new SpeculativeOverlayError('cursor pagination on related()', this.gen)
}
const materialized = await this.materialize()
return materialized.getRelations(params)
}
const limit = params.limit ?? 100
@ -449,8 +510,8 @@ export class Db<T = any> {
* (`with()` never invokes the embedder).
* - `remove` cascades on the read side: relations touching the removed
* entity disappear from `related()` without being enumerated.
* - `relate` deduplicates against edges visible in this view when verb
* history allows the check, mirroring `brain.relate()`.
* - `relate` deduplicates against the edges visible in this view (overlay
* first, then the record layer), mirroring `brain.relate()`.
* - Brain-level vocabulary/subtype enforcement does not run overlays
* never persist, so there is nothing to protect.
*
@ -541,8 +602,8 @@ export class Db<T = any> {
if (!from) throw new Error(`with(): source entity ${op.from} not found`)
if (!to) throw new Error(`with(): target entity ${op.to} not found`)
// Dedupe against the view (overlay first, then committed edges
// when verb history allows the read) — mirror of relate().
// Dedupe against the view (overlay first, then the edges visible
// at this generation via the record layer) — mirror of relate().
let duplicate: Relation<T> | undefined
for (const relation of overlay.verbs.values()) {
if (relation && relation.from === op.from && relation.to === op.to && relation.type === op.type) {
@ -551,14 +612,8 @@ export class Db<T = any> {
}
}
if (!duplicate) {
try {
const existing = await this.related({ from: op.from, type: op.type })
duplicate = existing.find((relation) => relation.to === op.to)
} catch (err) {
if (!(err instanceof NotYetSupportedAtHistoricalGenerationError)) throw err
// Verb history can't serve the check at this generation —
// proceed with overlay-only dedupe (documented).
}
const existing = await this.related({ from: op.from, type: op.type })
duplicate = existing.find((relation) => relation.to === op.to)
}
if (duplicate) break
@ -653,22 +708,20 @@ export class Db<T = any> {
* `persist()` requires this view to still be the store's **latest**
* generation (snapshotting captures current bytes): pin with `brain.now()`
* and persist before further writes. A view that history has moved past
* throws {@link NotYetSupportedAtHistoricalGenerationError}; speculative
* overlays cannot be persisted (commit them with `brain.transact()` first).
* throws {@link GenerationConflictError}; speculative overlays throw
* {@link SpeculativeOverlayError} (commit them with `brain.transact()`
* first).
*
* @param path - Absolute directory for the snapshot (created; must be
* empty or absent).
* @throws NotYetSupportedAtHistoricalGenerationError when this view is no
* longer the latest generation, or is speculative.
* @throws GenerationConflictError when this view is no longer the latest
* generation.
* @throws SpeculativeOverlayError when this view is a speculative overlay.
*/
async persist(path: string): Promise<void> {
this.assertUsable('persist')
if (this.overlay) {
throw new NotYetSupportedAtHistoricalGenerationError(
'persist of a speculative overlay',
this.gen,
this.host.store.generation()
)
throw new SpeculativeOverlayError('persist', this.gen)
}
await this.host.persistPinned(path, this.gen)
}
@ -679,17 +732,24 @@ export class Db<T = any> {
/**
* @description Release this view's pin (store + versioned index
* providers). Idempotent. After release, every read throws. Views from
* providers) and free its at-generation index materialization, if one was
* built. Idempotent. After release, every read throws. Views from
* `Brainy.load()` / `asOf(path)` also close their underlying read-only
* brain here. A `FinalizationRegistry` backstop releases leaked pins when
* a `Db` is garbage-collected, but explicit release is what makes
* `compactHistory()` deterministic prefer it.
* brain here. A `FinalizationRegistry` backstop releases leaked pins (and
* materializations) when a `Db` is garbage-collected, but explicit
* release is what makes `compactHistory()` deterministic prefer it.
*/
async release(): Promise<void> {
if (this.isReleased) return
this.isReleased = true
this.host.unregisterDbFromFinalization(this)
this.host.releaseGeneration(this.gen)
if (this.materializedHandle) {
// A failed materialization already surfaced to the query that
// triggered it; release() must still succeed.
const handle = await this.materializedHandle.catch(() => null)
if (handle) await handle.close()
}
if (this.closeOnRelease) {
await this.closeOnRelease()
}
@ -704,6 +764,32 @@ export class Db<T = any> {
return this.host.store.hasCommittedAfter(this.gen)
}
/**
* Build (once) and cache the at-generation index materialization. The
* first index-accelerated query at a historical pin pays the O(n at G)
* build; every later one reuses the cached handle until release(). The
* GC-backstop registration is refreshed so a leaked `Db` also closes its
* materialized reader (whose flush timers would otherwise outlive it).
*/
private materialize(): Promise<HistoricalQueryHandle<T>> {
if (!this.materializedHandle) {
this.materializedHandle = this.host.materializeAt(this.gen).then((handle) => {
this.host.unregisterDbFromFinalization(this)
this.host.registerDbForFinalization(this, this.gen, async () => {
await handle.close()
if (this.closeOnRelease) await this.closeOnRelease()
})
return handle
})
// A failed build must not poison the cache — the next query retries.
this.materializedHandle = this.materializedHandle.catch((err) => {
this.materializedHandle = undefined
throw err
})
}
return this.materializedHandle
}
/** Throw on use-after-release. */
private assertUsable(method: string): void {
if (this.isReleased) {
@ -715,36 +801,51 @@ export class Db<T = any> {
}
/**
* Reject find() dimensions that require index machinery the record layer
* cannot answer at a historical generation / for a speculative overlay.
* Reject find() dimensions that require index machinery a speculative
* overlay cannot answer (overlay entities carry no embeddings and are in
* no index see {@link SpeculativeOverlayError}).
*/
private assertMetadataOnlyFind(params: FindParams<T>): void {
const unsupported: Array<[string, boolean]> = [
['semantic query', params.query !== undefined],
['vector search', params.vector !== undefined],
['proximity search (near)', params.near !== undefined],
['graph traversal (connected)', params.connected !== undefined],
['cursor pagination', params.cursor !== undefined],
['aggregation', params.aggregate !== undefined],
['relation expansion (includeRelations)', params.includeRelations === true],
[
`search mode '${params.mode ?? params.searchMode}'`,
(params.mode !== undefined && params.mode !== 'metadata' && params.mode !== 'auto') ||
(params.searchMode !== undefined && params.searchMode !== 'auto')
]
]
for (const [capability, present] of unsupported) {
private assertOverlayCompatibleFind(params: FindParams<T>): void {
for (const [capability, present] of indexOnlyFindDimensions(params)) {
if (present) {
throw new NotYetSupportedAtHistoricalGenerationError(
capability,
this.gen,
this.host.store.generation()
)
throw new SpeculativeOverlayError(capability, this.gen)
}
}
}
}
/**
* @description The find() dimensions that only index machinery can answer
* (everything beyond metadata filters + ordering + offset/limit windows),
* as `[capability, present]` pairs for the given params.
*/
function indexOnlyFindDimensions(params: FindParams): Array<[string, boolean]> {
return [
['semantic query', params.query !== undefined],
['vector search', params.vector !== undefined],
['proximity search (near)', params.near !== undefined],
['graph traversal (connected)', params.connected !== undefined],
['cursor pagination', params.cursor !== undefined],
['aggregation', params.aggregate !== undefined],
['relation expansion (includeRelations)', params.includeRelations === true],
[
`search mode '${params.mode ?? params.searchMode}'`,
(params.mode !== undefined && params.mode !== 'metadata' && params.mode !== 'auto') ||
(params.searchMode !== undefined && params.searchMode !== 'auto')
]
]
}
/**
* @description Whether the find() params include any index-only dimension
* the routing predicate that decides between the record path (cheap,
* metadata-only) and the at-generation index materialization at historical
* generations.
*/
function findRequiresIndexes(params: FindParams): boolean {
return indexOnlyFindDimensions(params).some(([, present]) => present)
}
/**
* @description Build a `Result` row from a record/overlay-resolved entity,
* mirroring the live metadata-only find path (flattened fields, score `1.0`).