feat(health): the gate reads the named report — reads refuse loudly, never rebuild; open serves before it returns; the ceremony door
All checks were successful
CI / Node 22 (push) Successful in 12m19s
CI / Node 24 (push) Successful in 12m16s
CI / Integration + conformance (Node 22) (push) Successful in 18m41s
CI / Bun (latest) (push) Successful in 12m20s

The read gate stops consulting the unnamed isReady() boolean: every provider
may expose healthReport() (sync, O(1), composed from exact ledgers —
HealthReport with a monotonic generation, per-invariant source
ledger|deep|unledgered, missing {count, sample}), and one readiness
authority (assessProviderHealth) derives the verdict. Unledgered families
are UNKNOWN — never healthy, never broken; a report that throws is a loud
not-ready, never a shrug. Reads at the four index choke points refuse with
the typed NotReady errors, narrated once per (provider, generation) — a
read NEVER starts a store walk:

- the first-read lazy build retires (open builds instead, regardless of
  size — the ≥10k deferral and the "lazy loading on first query" branch go;
  disableAutoRebuild is re-meant honestly in its docs);
- the verify*Live read-path rebuild triggers retire (refuse-or-serve);
- the read-time consistency probe that could launch a dark rebuild from an
  ordinary find() retires;
- repairIndex({ rebuild: ['metadata'|'graph'|'vector'] | 'all' }) is the
  one explicit door: rebuilds the named leg unconditionally and reports
  rebuilt per family; bare repairIndex() stays report-driven.

test(lifecycle): the biography lane — a store's whole life, refereed

tests/lifecycle/: an independent shadow model referees every read after
every chapter (founding, a working day, clean restart, crash, repair,
second life). Chapters 1-3 green. Chapters 4-6 assert the true contract and
are marked .fails as a release-blocking finding (the kill-matrix
convention): after a crash + adopt reopen the metadata index computes its
'catchup' watermark verdict and nothing consumes it — find() serves the
pre-crash index while canonical and counts recover. The catchup wiring is
the cure; a passing .fails will force the marker's removal. The lane runs
in the integration gate (config + coverage guard).
This commit is contained in:
David Snelling 2026-08-24 12:45:51 -07:00
parent a8b5ca0c8f
commit f8f64780b1
19 changed files with 2160 additions and 652 deletions

View file

@ -1,38 +1,49 @@
/**
* @module tests/unit/brainy/lazy-notready-honor
* @description THE SILENT-EMPTY TRAP pin (found during a fleet adoption,
* SELF-ENGINE-PAIR-STANDARD): under `disableAutoRebuild: true`, the lazy
* SELF-ENGINE-PAIR-STANDARD): under `disableAutoRebuild: true`, the OLD lazy
* first-query path (`ensureIndexesLoaded`) assessed ONLY the vector index's
* readiness a native METADATA provider reporting not-ready (its strand
* report) never blocked the completion latch, so the promised lazy rebuild
* never fired and every `find()` silently returned `[]` on a populated
* store (measured: 52 entities durable-but-unqueryable, first query
* 0ms/0 rows). The law: a not-ready report from ANY provider falls through
* to the rebuild never a silent empty.
* never fired and every `find()` silently returned `[]` on a populated store
* (measured: 52 entities durable-but-unqueryable, first query 0ms/0 rows).
*
* RE-POINTED to the health-gate law (a read never builds; a rebuild runs
* entirely at open): `ensureIndexesLoaded()` is now a pure CHECK. A not-ready
* report from ANY provider metadata, vector, or graph makes it THROW the
* matching typed `*NotReadyError` rather than silently letting the read
* proceed, and it NEVER calls `rebuildIndexesIfNeeded` (that is entirely
* open()'s job now see the second describe block below). The spirit is
* unchanged: a not-ready report from any single provider can never be
* shadowed into a silent empty result.
*
* White-box provider-double pattern per tests/unit/brainy/migration-deference.
*/
import { describe, it, expect, afterEach, vi } from 'vitest'
import { Brainy } from '../../../src/index.js'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Brainy, MetadataIndexNotReadyError } from '../../../src/index.js'
import { NounType } from '../../../src/types/graphTypes.js'
import { createTestConfig } from '../../helpers/test-factory.js'
interface BrainInternals {
index: { size(): number }
metadataIndex: { isReady?: () => boolean }
lazyRebuildCompleted: boolean
ensureIndexesLoaded(): Promise<void>
ensureIndexesLoaded(): void
rebuildIndexesIfNeeded(force?: boolean): Promise<void>
}
const brains: Brainy[] = []
const dirs: string[] = []
afterEach(async () => {
for (const b of brains.splice(0)) await b.close().catch(() => {})
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
vi.restoreAllMocks()
})
async function warmLazyBrain(): Promise<{ brain: Brainy; internals: BrainInternals }> {
async function warmBrain(): Promise<{ brain: Brainy; internals: BrainInternals }> {
const brain = new Brainy(createTestConfig({ disableAutoRebuild: true }))
await brain.init()
brains.push(brain)
@ -40,36 +51,59 @@ async function warmLazyBrain(): Promise<{ brain: Brainy; internals: BrainInterna
await brain.add({ data: `row ${i}`, type: NounType.Document, metadata: { i } })
}
const internals = brain as unknown as BrainInternals
internals.lazyRebuildCompleted = false // simulate the cold first query
return { brain, internals }
}
describe('lazy path honors EVERY providers not-ready report', () => {
it('a not-ready METADATA provider blocks the completion latch and fires the rebuild', async () => {
const { internals } = await warmLazyBrain()
describe('the read gate honors EVERY providers not-ready report', () => {
it('a not-ready METADATA provider refuses loudly — it never lets a read proceed, and it never rebuilds', async () => {
const { internals } = await warmBrain()
// The trap's shape: vector side looks fine (populated), metadata
// provider says NOT ready — the old gate latched complete here.
;(internals.metadataIndex as { isReady?: () => boolean }).isReady = () => false
const rebuildSpy = vi
.spyOn(internals, 'rebuildIndexesIfNeeded')
.mockResolvedValue(undefined)
// provider says NOT ready — the OLD gate silently latched complete here.
// The new gate refuses loudly instead; a read never triggers a rebuild.
internals.metadataIndex.isReady = () => false
const rebuildSpy = vi.spyOn(internals, 'rebuildIndexesIfNeeded').mockResolvedValue(undefined)
await internals.ensureIndexesLoaded()
expect(rebuildSpy, 'not-ready metadata provider must fire the lazy rebuild').toHaveBeenCalledWith(true)
expect(() => internals.ensureIndexesLoaded()).toThrow(MetadataIndexNotReadyError)
expect(rebuildSpy, 'a read NEVER triggers a rebuild — building is entirely open()\'s job now').not.toHaveBeenCalled()
})
it('control: all providers ready/unknown+populated → latch completes, no rebuild', async () => {
const { internals } = await warmLazyBrain()
;(internals.metadataIndex as { isReady?: () => boolean }).isReady = () => true
const rebuildSpy = vi
.spyOn(internals, 'rebuildIndexesIfNeeded')
.mockResolvedValue(undefined)
await internals.ensureIndexesLoaded()
it('control: all providers ready/unknown+populated → the gate lets the read through, no rebuild', async () => {
const { internals } = await warmBrain()
internals.metadataIndex.isReady = () => true
const rebuildSpy = vi.spyOn(internals, 'rebuildIndexesIfNeeded').mockResolvedValue(undefined)
expect(() => internals.ensureIndexesLoaded()).not.toThrow()
expect(rebuildSpy).not.toHaveBeenCalled()
expect(internals.lazyRebuildCompleted).toBe(true)
})
})
describe('the open-time build honors the same law: a needed rebuild runs at open, never deferred to a read', () => {
it('disableAutoRebuild:true does not defer a needed rebuild past open() on a reopened, populated store', async () => {
const dir = mkdtempSync(join(tmpdir(), 'brainy-lazy-notready-honor-'))
dirs.push(dir)
const writer = new Brainy(createTestConfig({ disableAutoRebuild: true, storage: { type: 'filesystem', path: dir } }))
await writer.init()
for (let i = 0; i < 3; i++) {
await writer.add({ data: `row ${i}`, type: NounType.Document, metadata: { i } })
}
await writer.flush()
await writer.close()
// Fresh instance over the same store: its derived indexes start empty in
// memory, so open()'s rebuildIndexesIfNeeded MUST fire (and complete)
// before init() returns — even though disableAutoRebuild is true, there
// is no first-query lazy path left to defer to.
const reader = new Brainy(createTestConfig({ disableAutoRebuild: true, storage: { type: 'filesystem', path: dir } }))
const internals = reader as unknown as { rebuildIndexesIfNeeded(force?: boolean): Promise<void> }
const rebuildSpy = vi.spyOn(internals, 'rebuildIndexesIfNeeded')
await reader.init()
brains.push(reader)
expect(rebuildSpy).toHaveBeenCalledTimes(1)
const rows = await reader.find({ where: { i: 1 } })
expect(rows.length).toBe(1)
}, 30000)
})