fix: cold-open no longer re-derives durable indexes — complete the readiness contract for all three providers

A production deployment measured ~48 seconds on EVERY reopen of an
11k-entity brain. Root cause: brainy's rebuild gate decided from in-memory
size()/count, which read 0 for a durable-but-not-resident index, so it
re-read every entity file to rebuild from scratch. At GA we gave only the
GRAPH provider a readiness contract (init() eager cold-load + isReady()
honest signal) so it would never eat that spurious rebuild; the vector and
metadata providers never got it, and brainy never even eager-inited the
vector provider.

Complete the contract symmetrically:

- plugin.ts: VectorIndexProvider gains optional init()+isReady();
  MetadataIndexProvider gains isReady() — mirroring GraphIndexProvider.
  Additive and optional; a provider that exposes nothing keeps today's
  behavior.
- brainy.ts: eager-init every provider that exposes init() (after metadata
  init() so the id-mapper is hydrated first), then decide per leg in
  precedence order — migrating (skip) -> epoch drift (rebuild) -> isReady()
  -> a per-leg empty fallback. The old instant fast-path keyed off
  this.index.size()>0, a dishonest proxy that skipped the metadata/graph
  checks whenever the vector was warm and never fired on a real cold process
  anyway; removed.

The per-leg fallbacks differ because "empty" means different things: the JS
vector's rebuild() IS its load, so size()===0 correctly triggers it; the
id-mapper backs metadata, so totalEntries===0 (past the empty-store return)
is a real load failure; but entities do not imply edges, so a graph
size()===0 is a valid empty state, not a load failure.

- The JS graph now COLD-LOADS its durable LSM instead of re-deriving from a
  full canonical verb scan on every boot (baseStorage._initializeGraphIndex
  loads the persisted SSTables via a new GraphAdjacencyIndex.init(); it
  self-heals from canonical only when the durable state is genuinely missing).
  This removes an O(E)-per-open cost every filesystem consumer paid.
- LSMTree.loadManifest loads its SSTables BEFORE publishing the relationship
  count, and resets to an honest-empty state on load failure — a tree can no
  longer claim persisted relationships while holding none (the silent-empty
  cold-load class the query-time guards exist to prevent).

Verified end-to-end against a built brain: a warm reopen (with edges and
edgeless) reloads only the JS vector; the graph and metadata cold-load with
no rebuild, and queries return correct results. New tests in
cold-open-rebuild-gate.test.ts pin the contract (isReady() defers, self-heal
still fires); migration-deference updated to drive size-based deference
through the vector, the leg where empty->rebuild remains correct.

Pairs with the native provider's isReady()/init() implementation — brainy's
gate defers only to a signal the provider exposes.
This commit is contained in:
David Snelling 2026-07-07 10:39:00 -07:00
parent 4fde94bc2f
commit 61c247c923
8 changed files with 425 additions and 57 deletions

View file

@ -181,6 +181,27 @@ context.registerProvider('vector', (config, distanceFn, options) => {
})
```
#### The readiness contract (all three index providers)
A provider that **persists its derived index** should implement the optional readiness
members so a warm reopen never pays a redundant rebuild-from-canonical:
- **`init?(): Promise<void>`** — eager cold-load. Brainy awaits it once during
`brain.init()`, after the metadata provider's `init()` (the id-mapper hydrates first)
and **before the rebuild gate**.
- **`isReady?(): boolean`** — honest durability signal. `true` ⇔ the persisted index is
loaded (or cheaply demand-loadable) and consistent with what was last persisted. When
exposed, the rebuild gate defers to this signal **instead of** the `size() === 0` /
`totalEntries === 0` heuristics — a disk-native index may report 0 resident entries
while fully durable. Never return `true` if the durable state failed to load: the
signal is honest in both directions, and a not-ready provider gets its rebuild even
when `size() > 0`.
- **`isMigrating?(): boolean`** — while `true`, the provider owns its index (background
migration); brainy skips its rebuild entirely.
Providers that implement none of these keep the size/count heuristics — correct for
engines whose `rebuild()` *is* their load path (like brainy's built-in JS vector index).
#### `metadataIndex`
**Type:** `(storage: StorageAdapter) => MetadataIndexManager-compatible`

View file

@ -961,14 +961,21 @@ export class Brainy<T = any> implements BrainyInterface<T> {
this.graphIndex = graphIndex
}
// Eager graph cold-load (cor contract). The native graph provider lazily
// defers loading its source→target adjacency, so isReady() would report
// false at the rebuild gate below and force a spurious rebuild (failing the
// §7.1 rebuild()==0 acceptance). Trigger the eager cold-load now — AFTER
// metadataIndex.init() above (the id-mapper is hydrated, so a native int
// adjacency resolves endpoints through it: the CTX-BR-RESTORE-REBUILD order)
// and BEFORE rebuildIndexesIfNeeded. Optional: the JS graph has no init()
// and self-loads its adjacency on demand.
// Eager cold-load (readiness contract). A provider that persists its
// derived state exposes init?(): trigger the load NOW — AFTER
// metadataIndex.init() above (the id-mapper is hydrated first, so a
// native int-keyed index resolves endpoints/slots through it — the
// CTX-BR-RESTORE-REBUILD order) and BEFORE the rebuild gate — so a
// durable index reports its real size()/isReady() at the gate instead
// of eating a spurious rebuild-from-canonical on every open (§7.1
// rebuild()==0). Vector first, then graph. JS engines: the JS vector
// index has no init() (rebuild() IS its load path); the JS graph's
// init() already cold-loaded its LSM inside storage.getGraphIndex()
// (idempotent here).
const vectorWithInit = this.index as { init?: () => Promise<void> }
if (typeof vectorWithInit.init === 'function') {
await vectorWithInit.init()
}
const graphWithInit = this.graphIndex as { init?: () => Promise<void> }
if (typeof graphWithInit.init === 'function') {
await graphWithInit.init()
@ -13398,20 +13405,13 @@ export class Brainy<T = any> implements BrainyInterface<T> {
return
}
// OPTIMIZATION: Instant check - if index already has data, skip immediately
// This gives 0s startup for warm restarts (vs 50-100ms of async checks).
// The epoch-drift guard suppresses this fast path: an index that loaded
// eagerly (size()>0) but whose on-disk format predates this build
// (`_indexEpochStale`) must still rebuild — that is the exact gap this
// handshake closes (rebuild on a format change, not only on size()===0).
if (this.index.size() > 0 && !force && !this._indexEpochStale) {
if (!this.config.silent) {
console.log(
`✅ Index already populated (${this.index.size().toLocaleString()} entities) - 0s startup!`
)
}
return
}
// No instant fast-path here: the honest per-leg readiness checks below
// are all O(1) (one bounded storage sample + each provider's size()/
// isReady()), and this method runs exactly once per open (init calls it;
// the lazy path passes force=true). The removed shortcut keyed off
// `this.index.size() > 0`, a dishonest proxy — it skipped the metadata
// and graph checks whenever the vector happened to be warm, and it never
// fired on a real cold process (the JS vector size is 0 until it loads).
// BUG #2 FIX: Don't trust counts - check actual storage instead
// Counts can be lost/corrupted in container restarts
@ -13437,17 +13437,23 @@ export class Brainy<T = any> implements BrainyInterface<T> {
// Check if indexes need rebuilding
const metadataStats = await this.metadataIndex.getStats()
const hnswIndexSize = this.index.size()
const graphIndexSize = await this.graphIndex.size()
// Graph rebuild fires when the adjacency is empty OR when the provider's
// honest readiness signal says the edges did not load on a cold open — the
// native int adjacency can report size()>0 (count/manifest loaded) yet have
// NO source→target edges, the silent-empty cold-load failure. Providers
// without isReady() keep the exact prior behavior (size()===0 only).
const graphIsReady = this.graphIndex as GraphAdjacencyIndex & { isReady?: () => boolean }
const needsGraphRebuild =
graphIndexSize === 0 ||
(typeof graphIsReady.isReady === 'function' && !graphIsReady.isReady())
// Readiness contract: when a provider exposes isReady(), that honest
// signal REPLACES the size/count heuristic below — an mmap/disk-native
// index legitimately reports 0 resident entries while fully durable on
// disk, and rebuilding it from canonical re-reads every entity file on
// every boot (the 48-seconds-per-restart class a production deployment
// hit). The signal is honest in BOTH directions: a provider whose
// durable state failed to load returns false and gets its rebuild even
// when size() > 0 (the silent-empty cold-load failure). Providers
// without isReady() keep the exact prior empty-heuristics.
const providerReady = (leg: unknown): boolean | undefined => {
const candidate = leg as { isReady?: () => boolean }
return typeof candidate.isReady === 'function' ? candidate.isReady() : undefined
}
const metadataReady = providerReady(this.metadataIndex)
const vectorReady = providerReady(this.index)
const graphReady = providerReady(this.graphIndex)
// Epoch-drift trigger: a format-version change makes EVERY derived index
// suspect even when each is non-empty, so it forces a rebuild of all
@ -13466,16 +13472,38 @@ export class Brainy<T = any> implements BrainyInterface<T> {
const graphMigrating = this.providerIsMigrating(this.graphIndex)
const anyMigrating = metadataMigrating || vectorMigrating || graphMigrating
// Per-leg decision, in precedence order: a migrating provider owns its
// index (skip) → epoch drift forces a rebuild → an exposed isReady()
// decides → otherwise a per-leg fallback. The fallbacks differ by leg
// because "empty" means different things:
// - METADATA: past the empty-store early-return, entities exist, so the
// id-mapper SHOULD have loaded entries — totalEntries===0 is a real
// load-failure signal, so rebuild (self-heal from canonical).
// - VECTOR: the JS vector has no passive cold-load — rebuild() IS its
// load path — so size()===0 correctly triggers the load.
// - GRAPH: entities do NOT imply edges, so size()===0 is a VALID empty
// state, not a load failure. The JS graph cold-loads (and self-heals
// against canonical) inside storage.getGraphIndex() BEFORE this gate,
// so it is already authoritative here; re-deriving would be spurious
// (a full O(E) verb scan on every open of an edgeless brain). It
// therefore rebuilds only on epoch drift or a native !isReady().
// (verifyGraphAdjacencyLive is the query-time backstop.)
const shouldRebuildMetadata =
(metadataStats.totalEntries === 0 || epochStale) && !metadataMigrating
const shouldRebuildVector = (hnswIndexSize === 0 || epochStale) && !vectorMigrating
const shouldRebuildGraph = (needsGraphRebuild || epochStale) && !graphMigrating
!metadataMigrating &&
(epochStale ||
(metadataReady !== undefined ? !metadataReady : metadataStats.totalEntries === 0))
const shouldRebuildVector =
!vectorMigrating &&
(epochStale || (vectorReady !== undefined ? !vectorReady : hnswIndexSize === 0))
const shouldRebuildGraph =
!graphMigrating &&
(epochStale || (graphReady !== undefined ? !graphReady : false))
const needsRebuild = shouldRebuildMetadata || shouldRebuildVector || shouldRebuildGraph
if (!needsRebuild && !force) {
// All indexes already populated (or owned by a background migration), no
// rebuild needed.
// All indexes report current — durably loaded (isReady/size), or owned
// by a background migration. No rebuild needed.
return
}
@ -13501,7 +13529,18 @@ export class Brainy<T = any> implements BrainyInterface<T> {
: '🔄 Auto-rebuild explicitly enabled'
if (!this.config.silent) {
console.log(`${rebuildReason} - rebuilding all indexes from persisted data...`)
// Name exactly which legs rebuild — "all indexes" was a lie whenever
// the durable legs were skipped (e.g. only the JS vector index loads
// here on a warm reopen), and it misread as a whole-brain rebuild in
// consumer boot logs.
const rebuildingLegs = [
shouldRebuildMetadata && 'metadata',
shouldRebuildVector && 'vector',
shouldRebuildGraph && 'graph'
]
.filter(Boolean)
.join(' + ')
console.log(`${rebuildReason} - loading/rebuilding ${rebuildingLegs || 'no'} index(es) from persisted data...`)
}
// Before the graph rebuild, hydrate the entity id-mapper from the persisted

View file

@ -195,6 +195,25 @@ export class GraphAdjacencyIndex implements GraphIndexProvider {
return next
}
/**
* Eager cold-load of the persisted adjacency (readiness contract).
*
* Loads the LSM manifests + SSTables so `size()` reports the durable edge
* count at the rebuild gate a warm reopen must load the persisted index,
* never re-derive it from a full canonical verb scan (the every-boot O(E)
* cost this method exists to eliminate). Idempotent; the lazy read paths
* call the same `ensureInitialized()` on demand.
*
* NOTE: the JS index deliberately does NOT expose `isReady()`. That signal
* (see `GraphIndexProvider.isReady`) asserts "traversals are trustworthy",
* which this side cannot honestly promise without comparing against the
* canonical store the query-time known-edge probe
* (`verifyGraphAdjacencyLive` Strategy 2) remains the JS trust check.
*/
async init(): Promise<void> {
await this.ensureInitialized()
}
/**
* Initialize the graph index (lazy initialization)
* Added defensive auto-rebuild check for verbIdSet consistency

View file

@ -547,13 +547,26 @@ export class LSMTree {
const data = metadata.data as PersistedManifestData
this.manifest.sstables = new Map(Object.entries(data.sstables || {}))
this.manifest.lastCompaction = data.lastCompaction || Date.now()
this.manifest.totalRelationships = data.totalRelationships || 0
// Load SSTables from storage
// Load SSTables from storage BEFORE publishing the persisted count.
// If the SSTable load throws, `size()` must keep reporting 0 — a tree
// that claims its persisted relationships while holding none serves
// silent-empty traversals as truth (the cold-load swallow class), and
// downstream self-heal keys off the honest 0.
await this.loadSSTables()
this.manifest.totalRelationships = data.totalRelationships || 0
}
} catch (error) {
prodLog.debug('LSMTree: No existing manifest found, starting fresh')
// Reset anything partially loaded — an honest empty tree triggers the
// rebuild/self-heal paths; a half-loaded one masks them. (An absent
// manifest on a fresh store also lands here: empty is correct.)
this.manifest.sstables = new Map()
this.manifest.totalRelationships = 0
this.sstablesByLevel.clear()
prodLog.debug(
`LSMTree(${this.config.storagePrefix}): no loadable manifest/SSTables — starting empty ` +
`(${error instanceof Error ? error.message : String(error)})`
)
}
}

View file

@ -125,6 +125,20 @@ export interface MetadataIndexProvider {
flush(): Promise<void>
rebuild(): Promise<void>
/**
* @description OPTIONAL honest durability signal (readiness contract,
* mirrors `isReady?()` on the graph and vector providers). `true` the
* persisted field postings are loaded (or cheaply demand-loadable) and
* consistent with what the provider last persisted a rebuild from the
* canonical records would be redundant. When exposed, the rebuild gate
* defers to this signal INSTEAD of the `getStats().totalEntries === 0`
* heuristic (a durable provider may legitimately report 0 resident entries
* on a cold open while its postings sit loadable on disk). Absent the
* gate keeps the count heuristic. Never return `true` when the durable
* state failed to load.
*/
isReady?(): boolean
/**
* @description OPTIONAL. A native provider returns true from the moment its
* `init()` detects a large epoch-drift until its background
@ -902,6 +916,33 @@ export interface VectorIndexProvider {
flush(): Promise<number>
getPersistMode(): 'immediate' | 'deferred'
/**
* @description OPTIONAL eager cold-load (readiness contract, mirrors
* {@link GraphIndexProvider.init}). Called once during brain init AFTER the
* metadata provider's `init()` (the id-mapper is hydrated first, so a
* provider whose vector slots resolve through interned ints reads a complete
* mapping) and BEFORE the rebuild gate so a durable provider loads (or
* verifies it can demand-load) its persisted index and reports
* `isReady() === true` at the gate instead of eating a spurious
* rebuild-from-canonical on every open. The built-in JS index omits it:
* `rebuild()` IS its load path.
*/
init?(): Promise<void>
/**
* @description OPTIONAL honest durability signal (readiness contract,
* mirrors {@link GraphIndexProvider.isReady}). `true` the persisted
* derived index is loaded (or cheaply demand-loadable) and consistent with
* what the provider last persisted a rebuild from the canonical records
* would be redundant work. When exposed, the rebuild gate defers to this
* signal INSTEAD of the `size() === 0` heuristic (an mmap/disk-native index
* may legitimately report 0 resident entries while fully durable). Absent
* the gate keeps the size heuristic. Never return `true` when the durable
* state failed to load that converts a recoverable rebuild into silent
* empty results.
*/
isReady?(): boolean
/**
* @description OPTIONAL. A native provider returns true from the moment its
* `init()` detects a large epoch-drift until its background

View file

@ -2429,12 +2429,27 @@ export abstract class BaseStorage extends BaseStorageAdapter {
// invalidateGraphIndex); on first init Brainy wires it right after.
this.graphIndex = new GraphAdjacencyIndex(this, {}, this.graphEntityIdResolver)
// Check if we need to rebuild from existing data
// Load the PERSISTED adjacency first (LSM manifests + SSTables). A warm
// reopen must load the durable index it already built — the previous
// "any verb exists → rebuild()" check here re-derived the whole graph
// from a full canonical verb scan on EVERY boot, an O(E) cost that
// dominated real deployments' startup.
await this.graphIndex.init()
// Self-heal only when the durable state is genuinely missing: canonical
// records exist but the loaded index is empty (first open on pre-index
// data, a deleted/corrupt _graph dir, or the LSM load failing loud).
// One O(1) probe replaces the unconditional O(E) re-derive.
if (this.graphIndex.size() === 0) {
const sampleVerbs = await this.getVerbs({ pagination: { limit: 1 } })
if (sampleVerbs.items.length > 0) {
prodLog.info('Found existing verbs, rebuilding graph index...')
prodLog.warn(
'GraphAdjacencyIndex: canonical verbs exist but the persisted adjacency is empty — ' +
'rebuilding from storage (one-time self-heal).'
)
await this.graphIndex.rebuild()
}
}
return this.graphIndex
}

View file

@ -148,7 +148,7 @@ describe('rc.8 no-freeze migration deference (isMigrating / stampBrainFormat / b
expect(internals._indexEpochStale).toBe(true)
})
it('a migrating provider is skipped even when its size()===0; a non-migrating size()===0 sibling still rebuilds', async () => {
it('a migrating provider is skipped even though its empty-signal would trigger a rebuild; clearing the flag lets the empty leg rebuild', async () => {
const brain = await makeWarmBrain()
const internals = internalsOf(brain)
@ -156,22 +156,29 @@ describe('rc.8 no-freeze migration deference (isMigrating / stampBrainFormat / b
const idxSpy = vi.spyOn(internals.index, 'rebuild').mockResolvedValue(undefined)
const giSpy = vi.spyOn(internals.graphIndex, 'rebuild').mockResolvedValue(undefined)
// No epoch drift this time: the rebuild trigger is purely "index is empty".
// No epoch drift: the only rebuild trigger is a leg's own empty signal. The
// JS vector's rebuild() IS its load path, so size()===0 is its trigger —
// the leg where "empty → rebuild" is architecturally correct — so we drive
// deference through it. (The JS graph cold-loads before this gate, so its
// size()===0 is a valid empty state, not a rebuild trigger; graph deference
// is covered by the epoch-drift case above.)
internals._indexEpochStale = false
// Vector index reports empty AND is migrating → its background swap owns it.
vi.spyOn(internals.index, 'size').mockReturnValue(0)
// Migrating: the provider's background swap owns the index, so the
// size()===0 load trigger is suppressed.
setMigrating(internals.index, true)
// Graph index reports empty and is NOT migrating → brainy must rebuild it.
vi.spyOn(internals.graphIndex, 'size').mockReturnValue(0)
await internals.rebuildIndexesIfNeeded()
// size()===0 would normally force the vector rebuild — deference suppresses it.
expect(idxSpy).toHaveBeenCalledTimes(0)
// The empty, non-migrating graph sibling still rebuilds.
expect(giSpy).toHaveBeenCalledTimes(1)
// Metadata has entries and no drift → no rebuild needed.
// Metadata has entries; the graph has no edges; neither drifted → no rebuild.
expect(miSpy).toHaveBeenCalledTimes(0)
expect(giSpy).toHaveBeenCalledTimes(0)
// Clearing the flag: the same empty, non-migrating vector now rebuilds — and
// this second call re-evaluating at all confirms the gate is not latched.
setMigrating(internals.index, false)
await internals.rebuildIndexesIfNeeded()
expect(idxSpy).toHaveBeenCalledTimes(1)
})
// --- Hook 1: large-path first-query lazy force-rebuild deference ----------

View file

@ -0,0 +1,213 @@
/**
* Cold-open rebuild gate the readiness contract (8.0.12).
*
* A production deployment measured 48 seconds on EVERY boot because the
* rebuild gate keyed off in-memory size()/count heuristics that are dishonest
* for durable indexes: a disk-native provider legitimately reports 0 resident
* entries while fully durable, and the JS graph loaded nothing at gate time
* (its LSM initialized lazily) so `rebuildIndexesIfNeeded()` re-derived
* indexes from a full canonical scan on every open.
*
* The contract pinned here:
* - JS reopen: the graph must COLD-LOAD its persisted LSM (no re-derive; the
* old `_initializeGraphIndex` rebuilt from a full verb scan every boot),
* the metadata leg must not rebuild (id-mapper signal is honest), and
* queries stay correct. The JS vector leg still runs `rebuild()` that IS
* its load path.
* - A vector provider exposing `isReady() === true` is never rebuilt, even
* at `size() === 0`; `isReady() === false` gets its rebuild. `init?()` is
* eagerly awaited before the gate.
*/
import { describe, it, expect, afterEach } from 'vitest'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { Brainy, NounType, VerbType } from '../../src/index.js'
import { GraphAdjacencyIndex } from '../../src/graph/graphAdjacencyIndex.js'
import { MetadataIndexManager } from '../../src/utils/metadataIndex.js'
const tmpDirs: string[] = []
function mkTmp(): string {
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-cold-open-'))
tmpDirs.push(d)
return d
}
afterEach(() => {
for (const d of tmpDirs.splice(0)) fs.rmSync(d, { recursive: true, force: true })
})
const V = () => Array.from({ length: 384 }, () => Math.random())
/** Populate a brain with nouns + edges and close it. */
async function buildBrain(dir: string, n = 12): Promise<string[]> {
const brain: any = new Brainy({
requireSubtype: false,
storage: { type: 'filesystem', path: dir },
plugins: [],
silent: true
})
await brain.init()
const ids: string[] = []
for (let i = 0; i < n; i++) {
ids.push(
await brain.add({
data: `entity ${i}`,
type: NounType.Concept,
subtype: 's',
metadata: { wave: i % 3 },
vector: V()
})
)
}
for (let i = 0; i + 1 < ids.length; i++) {
await brain.relate({ from: ids[i], to: ids[i + 1], type: VerbType.RelatedTo, subtype: 's' })
}
await brain.close()
return ids
}
describe('Cold-open rebuild gate (readiness contract)', () => {
it('JS reopen: graph cold-loads its LSM (no re-derive), metadata skips, queries correct', async () => {
const dir = mkTmp()
const ids = await buildBrain(dir)
// Spy on the two rebuilds that must NOT run on a healthy reopen.
const graphRebuilds: number[] = []
const metadataRebuilds: number[] = []
const origGraphRebuild = GraphAdjacencyIndex.prototype.rebuild
const origMetaRebuild = MetadataIndexManager.prototype.rebuild
GraphAdjacencyIndex.prototype.rebuild = async function (...args: any[]) {
graphRebuilds.push(1)
return origGraphRebuild.apply(this, args as any)
}
MetadataIndexManager.prototype.rebuild = async function (...args: any[]) {
metadataRebuilds.push(1)
return origMetaRebuild.apply(this, args as any)
}
try {
const brain: any = new Brainy({
requireSubtype: false,
storage: { type: 'filesystem', path: dir },
plugins: [],
silent: true
})
await brain.init()
expect(graphRebuilds.length).toBe(0) // persisted LSM loaded — no O(E) re-derive
expect(metadataRebuilds.length).toBe(0) // id-mapper signal honest — no rebuild
expect(await brain.graphIndex.size()).toBeGreaterThan(0) // durable edges COLD-LOADED at boot
// Correctness after the load — the whole point of not rebuilding.
const byWhere = await brain.find({ type: NounType.Concept, where: { wave: 1 } })
expect(byWhere.length).toBe(4) // waves 1,4,7,10 of 12
const connected = await brain.find({ connected: { from: ids[0], depth: 1 } })
expect(connected.length).toBe(1)
await brain.close()
} finally {
GraphAdjacencyIndex.prototype.rebuild = origGraphRebuild
MetadataIndexManager.prototype.rebuild = origMetaRebuild
}
})
it('a vector provider with isReady()===true is never rebuilt, even at size()===0 (init eagerly awaited)', async () => {
const dir = mkTmp()
await buildBrain(dir, 6)
const calls = { init: 0, rebuild: 0 }
const stubVector = {
addItem: async (item: any) => item?.id ?? 'stub-id',
removeItem: async () => true,
search: async () => [] as Array<[string, number]>,
size: () => 0, // disk-native posture: durable, zero resident
clear: () => {},
rebuild: async () => {
calls.rebuild++
},
flush: async () => 0,
getPersistMode: () => 'deferred' as const,
init: async () => {
calls.init++
},
isReady: () => true
}
const brain: any = new Brainy({
requireSubtype: false,
storage: { type: 'filesystem', path: dir },
plugins: [],
silent: true
})
brain.use({ name: 'fake-native-vector', activate: async (ctx: any) => { ctx.registerProvider('vector', () => stubVector); return true } })
await brain.init()
expect(calls.init).toBe(1) // the eager cold-load ran before the gate
expect(calls.rebuild).toBe(0) // isReady()===true replaced the size()===0 heuristic
await brain.close()
})
it('a vector provider with isReady()===false gets its rebuild (honest in both directions)', async () => {
const dir = mkTmp()
await buildBrain(dir, 6)
const calls = { rebuild: 0 }
let ready = false
const stubVector = {
addItem: async (item: any) => item?.id ?? 'stub-id',
removeItem: async () => true,
search: async () => [] as Array<[string, number]>,
size: () => 999, // even a non-zero size must NOT mask a not-ready provider
clear: () => {},
rebuild: async () => {
calls.rebuild++
ready = true // rebuild restores readiness
},
flush: async () => 0,
getPersistMode: () => 'deferred' as const,
isReady: () => ready
}
const brain: any = new Brainy({
requireSubtype: false,
storage: { type: 'filesystem', path: dir },
plugins: [],
silent: true
})
brain.use({ name: 'fake-native-vector', activate: async (ctx: any) => { ctx.registerProvider('vector', () => stubVector); return true } })
await brain.init()
expect(calls.rebuild).toBe(1)
await brain.close()
})
it('self-heal survives: durable graph state deleted → one rebuild from canonical on reopen', async () => {
const dir = mkTmp()
const ids = await buildBrain(dir, 6)
// Simulate lost durable graph state: remove the persisted LSM artifacts
// (metadata channel keys live under _system hash buckets — nuke the graph
// manifests via the storage API instead of guessing paths).
const wipe: any = new Brainy({
requireSubtype: false,
storage: { type: 'filesystem', path: dir },
plugins: [],
silent: true
})
await wipe.init()
// Clear both LSM trees' persisted manifests through the live index, then
// close WITHOUT letting them re-flush a fresh manifest state.
const gi: any = wipe.graphIndex
await gi.lsmTreeVerbsBySource.clear?.()
await gi.lsmTreeVerbsByTarget.clear?.()
await wipe.close()
const brain: any = new Brainy({
requireSubtype: false,
storage: { type: 'filesystem', path: dir },
plugins: [],
silent: true
})
await brain.init()
// Canonical verbs still exist → the boot self-heal must have restored the adjacency.
const connected = await brain.find({ connected: { from: ids[0], depth: 1 } })
expect(connected.length).toBe(1)
await brain.close()
})
})