diff --git a/src/brainy.ts b/src/brainy.ts index fb1c1614..38980a4d 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -3907,6 +3907,12 @@ export class Brainy implements BrainyInterface { uuid: string, options?: { direction?: 'in' | 'out' | 'both'; limit?: number; offset?: number } ): Promise { + // READ-SURFACE READINESS GATE (the 4.2.4 blackout's brainy half): every + // index read funnels through this helper, so the gate here makes + // serve-while-not-ready UNREPRESENTABLE — a production store once acked + // writes while every non-find() read served empty from a not-ready + // provider for 15 minutes. Fast path after the latch is one boolean. + await this.ensureIndexesLoaded() const entityInt = this.graphEntityInt(uuid) if (entityInt === undefined) return [] const neighborInts = await this.graphIndex.getNeighbors(entityInt, options) @@ -11807,6 +11813,12 @@ export class Brainy implements BrainyInterface { filter: unknown, opts?: { limit?: number; offset?: number } ): Promise { + // READ-SURFACE READINESS GATE (the 4.2.4 blackout's brainy half): every + // index read funnels through this helper, so the gate here makes + // serve-while-not-ready UNREPRESENTABLE — a production store once acked + // writes while every non-find() read served empty from a not-ready + // provider for 15 minutes. Fast path after the latch is one boolean. + await this.ensureIndexesLoaded() try { return await this.metadataIndex.getIdsForFilter(filter, opts) } catch (err) { @@ -14279,6 +14291,12 @@ export class Brainy implements BrainyInterface { verbTypes?: Set, limit?: number ): Promise { + // READ-SURFACE READINESS GATE (the 4.2.4 blackout's brainy half): every + // index read funnels through this helper, so the gate here makes + // serve-while-not-ready UNREPRESENTABLE — a production store once acked + // writes while every non-find() read served empty from a not-ready + // provider for 15 minutes. Fast path after the latch is one boolean. + await this.ensureIndexesLoaded() // 8.0 BigInt boundary: unmapped node → no relations. const nodeInt = this.graphEntityInt(nodeId) if (nodeInt === undefined) return [] @@ -16217,7 +16235,15 @@ export class Brainy implements BrainyInterface { return } - // Start lazy rebuild (with mutex to prevent concurrent rebuilds) + // Start lazy rebuild (with mutex to prevent concurrent rebuilds). + // ALWAYS narrated (prodLog, never the silent-suppressible console): a + // read that triggers an index build must be visible to the operator — + // fifteen silent minutes of a production blackout taught this line. + prodLog.warn( + `[Brainy] first read on this instance is building the derived indexes ` + + `(deferred at open by disableAutoRebuild) — reads WAIT and then serve; ` + + `nothing serves empty. Bounded by store size; progress under [MetadataIndex]/[GraphIndex].` + ) this.lazyRebuildInProgress = true this.lazyRebuildPromise = this.rebuildIndexesIfNeeded(true) .then(() => { diff --git a/tests/integration/read-surface-readiness.test.ts b/tests/integration/read-surface-readiness.test.ts new file mode 100644 index 00000000..0215d81f --- /dev/null +++ b/tests/integration/read-surface-readiness.test.ts @@ -0,0 +1,68 @@ +/** + * @module tests/integration/read-surface-readiness + * @description THE READ-SURFACE READINESS GATE (a production blackout's + * brainy half): with `disableAutoRebuild: true`, init defers index builds — + * and before this gate, only find() waited for the lazy rebuild while + * related() and every VFS path served EMPTY from the not-ready providers + * (writes acked into canonical, readback empty — fifteen live minutes). + * The pins: on a fresh instance over a populated store, the FIRST read on + * every surface serves truth (it waits for the build), never empty. + */ +import { describe, it, expect, afterEach } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Brainy } from '../../src/index.js' +import { NounType, VerbType } from '../../src/types/graphTypes.js' + +const dirs: string[] = [] +const brains: Brainy[] = [] +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 }) +}) + +async function openLazy(dir: string): Promise { + const brain = new Brainy({ + storage: { type: 'filesystem', path: dir }, + requireSubtype: false, + silent: true, + disableAutoRebuild: true + }) + await brain.init() + brains.push(brain) + return brain +} + +describe('read-surface readiness gate', () => { + it('related() as the FIRST read on a fresh lazy instance serves truth, never empty', async () => { + const dir = mkdtempSync(join(tmpdir(), 'brainy-readgate-')) + dirs.push(dir) + const writer = await openLazy(dir) + const a = await writer.add({ data: 'hub row', type: NounType.Document, metadata: { n: 1 } }) + const b = await writer.add({ data: 'leaf row', type: NounType.Document, metadata: { n: 2 } }) + await writer.relate({ from: a, to: b, type: VerbType.RelatedTo }) + await writer.flush() + await brains.pop()!.close() + + // Fresh instance: indexes deferred at open. The production shape called + // related() FIRST (no find() to trigger the old, only gate). + const reader = await openLazy(dir) + const rels = await reader.related({ from: a }) + expect(rels.length, 'the FIRST related() read waits for the build and serves').toBeGreaterThan(0) + expect(rels.some((r) => r.to === b || (r as { target?: string }).target === b)).toBe(true) + }, 120000) + + it('a metadata-filtered read as the FIRST read serves truth, never empty', async () => { + const dir = mkdtempSync(join(tmpdir(), 'brainy-readgate2-')) + dirs.push(dir) + const writer = await openLazy(dir) + await writer.add({ data: 'tagged row', type: NounType.Document, metadata: { team: 'atlas' } }) + await writer.flush() + await brains.pop()!.close() + + const reader = await openLazy(dir) + const rows = await reader.find({ where: { team: 'atlas' } }) + expect(rows.length, 'filtered find on a cold lazy instance serves').toBe(1) + }, 120000) +})