/** * @module tests/unit/brainy/migration-deference * @description rc.8 no-freeze auto-upgrade hooks — the deference seam that lets a * native provider run an ONLINE, background build-new→verify→swap index migration * while brainy stays out of the way (never a minutes-long blocking * rebuild-on-open / first-query on a large-brain epoch-drift upgrade). * * Three hooks, locked with the cor team: * * - Hook 1 (deference): an OPTIONAL sync `isMigrating(): boolean` on each index * provider (metadata / vector / graph). While it returns true, brainy SKIPS its * own rebuild of that index — both in {@link Brainy.rebuildIndexesIfNeeded} * (even under epoch-drift or `size()===0`) and on the large-path first-query * lazy force-rebuild. A NON-migrating sibling still rebuilds when it needs to. * - Hook 2: the public `brain.stampBrainFormat()` the provider calls once its * background migration has verified-and-swapped, authoring the shared * `_system/brain-format.json` marker. * - Hook 3: the marker module is re-exported at `@soulcraft/brainy/brain-format` * so cor reads the SAME `EXPECTED_INDEX_EPOCH` / `CURRENT_DATA_FORMAT` constants * (single source of truth, no duplicated value). * * These paths are never exercised by brainy CI (cor registers no provider there), * so the deference gates and the public stamp are pinned here with a white-box * test-double provider — the same pattern as the cold-graph / handshake tests. */ import { describe, it, expect, afterEach, vi } from 'vitest' import { Brainy } from '../../../src/index.js' import { NounType } from '../../../src/types/graphTypes.js' import { createTestConfig } from '../../helpers/test-factory.js' import { BaseStorage } from '../../../src/storage/baseStorage.js' import { BRAIN_FORMAT_PATH, CURRENT_DATA_FORMAT, EXPECTED_INDEX_EPOCH } from '../../../src/storage/brainFormat.js' const CURRENT_MARKER = { dataFormat: CURRENT_DATA_FORMAT, indexEpoch: EXPECTED_INDEX_EPOCH } /** The white-box surface this suite drives on a live brain instance. */ interface BrainInternals { index: { size(): number; rebuild(...a: unknown[]): Promise } metadataIndex: { rebuild(...a: unknown[]): Promise } graphIndex: { size(): number; rebuild(...a: unknown[]): Promise } _indexEpochStale: boolean lazyRebuildCompleted: boolean rebuildIndexesIfNeeded(force?: boolean): Promise ensureIndexesLoaded(): Promise storage: { readRawObject(p: string): Promise } } const brains: Brainy[] = [] /** Open (and track) a warmed memory brain holding `count` document entities. */ async function makeWarmBrain(count = 2, extraConfig: Record = {}): Promise { const brain = new Brainy(createTestConfig(extraConfig)) await brain.init() brains.push(brain) for (let i = 0; i < count; i++) { await brain.add({ data: `doc-${i}`, type: NounType.Document, metadata: { k: i } }) } return brain } /** Cast a brain to its white-box internals. */ function internalsOf(brain: Brainy): BrainInternals { return brain as unknown as BrainInternals } /** Force `provider.isMigrating()` to a fixed value (the native provider's deference flag). */ function setMigrating(provider: object, value: boolean): void { ;(provider as { isMigrating?: () => boolean }).isMigrating = () => value } afterEach(async () => { for (const brain of brains.splice(0)) { try { await brain.close() } catch { // already closed by the test } } vi.restoreAllMocks() }) describe('rc.8 no-freeze migration deference (isMigrating / stampBrainFormat / brain-format export)', () => { // --- Hook 1: per-index deference in rebuildIndexesIfNeeded ---------------- it('metadata provider isMigrating(): its rebuild is skipped under epoch-drift; vector + graph siblings still rebuild', async () => { const brain = await makeWarmBrain() const internals = internalsOf(brain) const miSpy = vi.spyOn(internals.metadataIndex, 'rebuild').mockResolvedValue(undefined) const idxSpy = vi.spyOn(internals.index, 'rebuild').mockResolvedValue(undefined) const giSpy = vi.spyOn(internals.graphIndex, 'rebuild').mockResolvedValue(undefined) setMigrating(internals.metadataIndex, true) // Epoch-drift would normally force ALL three to rebuild past the warm fast path. internals._indexEpochStale = true await internals.rebuildIndexesIfNeeded() // The migrating provider owns its index — brainy does NOT rebuild it. expect(miSpy).toHaveBeenCalledTimes(0) // Non-migrating siblings still rebuild under the drift. expect(idxSpy).toHaveBeenCalledTimes(1) expect(giSpy).toHaveBeenCalledTimes(1) // The marker is NOT advanced while a migration is in flight — cor stamps it // when its build-new→verify→swap completes, so the stale flag stays set. expect(internals._indexEpochStale).toBe(true) }) it('vector provider isMigrating(): its rebuild is skipped under epoch-drift; metadata + graph siblings still rebuild', async () => { const brain = await makeWarmBrain() const internals = internalsOf(brain) const miSpy = vi.spyOn(internals.metadataIndex, 'rebuild').mockResolvedValue(undefined) const idxSpy = vi.spyOn(internals.index, 'rebuild').mockResolvedValue(undefined) const giSpy = vi.spyOn(internals.graphIndex, 'rebuild').mockResolvedValue(undefined) setMigrating(internals.index, true) internals._indexEpochStale = true await internals.rebuildIndexesIfNeeded() expect(idxSpy).toHaveBeenCalledTimes(0) expect(miSpy).toHaveBeenCalledTimes(1) expect(giSpy).toHaveBeenCalledTimes(1) expect(internals._indexEpochStale).toBe(true) }) it('graph provider isMigrating(): its rebuild is skipped under epoch-drift; metadata + vector siblings still rebuild', async () => { const brain = await makeWarmBrain() const internals = internalsOf(brain) const miSpy = vi.spyOn(internals.metadataIndex, 'rebuild').mockResolvedValue(undefined) const idxSpy = vi.spyOn(internals.index, 'rebuild').mockResolvedValue(undefined) const giSpy = vi.spyOn(internals.graphIndex, 'rebuild').mockResolvedValue(undefined) setMigrating(internals.graphIndex, true) internals._indexEpochStale = true await internals.rebuildIndexesIfNeeded() expect(giSpy).toHaveBeenCalledTimes(0) expect(miSpy).toHaveBeenCalledTimes(1) expect(idxSpy).toHaveBeenCalledTimes(1) expect(internals._indexEpochStale).toBe(true) }) 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) const miSpy = vi.spyOn(internals.metadataIndex, 'rebuild').mockResolvedValue(undefined) const idxSpy = vi.spyOn(internals.index, 'rebuild').mockResolvedValue(undefined) const giSpy = vi.spyOn(internals.graphIndex, 'rebuild').mockResolvedValue(undefined) // 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 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) await internals.rebuildIndexesIfNeeded() expect(idxSpy).toHaveBeenCalledTimes(0) // 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 ---------- it('lazy first-query force-rebuild is SKIPPED when the vector provider isMigrating()', async () => { // disableAutoRebuild routes first queries through ensureIndexesLoaded() (the // large-brain lazy path that would otherwise force a blocking rebuild). const brain = await makeWarmBrain(2, { disableAutoRebuild: true }) const internals = internalsOf(brain) const rebuildSpy = vi.spyOn(internals, 'rebuildIndexesIfNeeded').mockResolvedValue(undefined) // Simulate a cold/empty live vector index (cor is mid-swap, serving canonical). vi.spyOn(internals.index, 'size').mockReturnValue(0) internals.lazyRebuildCompleted = false setMigrating(internals.index, true) await internals.ensureIndexesLoaded() // A query during cor's background swap must not trigger brainy's blocking rebuild. expect(rebuildSpy).toHaveBeenCalledTimes(0) }) it('lazy first-query force-rebuild STILL fires when the vector provider is not migrating (control)', async () => { const brain = await makeWarmBrain(2, { disableAutoRebuild: true }) const internals = internalsOf(brain) const rebuildSpy = vi.spyOn(internals, 'rebuildIndexesIfNeeded').mockResolvedValue(undefined) vi.spyOn(internals.index, 'size').mockReturnValue(0) internals.lazyRebuildCompleted = false // No isMigrating → not deferring. await internals.ensureIndexesLoaded() // Without deference, the cold empty index drives the lazy force-rebuild. expect(rebuildSpy).toHaveBeenCalledTimes(1) expect(rebuildSpy).toHaveBeenCalledWith(true) }) // --- Hook 2: public stampBrainFormat() ----------------------------------- it('brain.stampBrainFormat() writes _system/brain-format.json with the current {dataFormat, indexEpoch}', async () => { const brain = new Brainy(createTestConfig()) await brain.init() brains.push(brain) const internals = internalsOf(brain) // Spy AFTER init so only the stamp call's write is observed (init already // stamped the fresh brain). const writeSpy = vi.spyOn(BaseStorage.prototype, 'writeRawObject') await brain.stampBrainFormat() const stampWrites = writeSpy.mock.calls.filter(([p]) => p === BRAIN_FORMAT_PATH) expect(stampWrites.length).toBe(1) expect(stampWrites[0][1]).toEqual(CURRENT_MARKER) // And the marker is durable on disk at the current epoch. const onDisk = await internals.storage.readRawObject(BRAIN_FORMAT_PATH) expect(onDisk).toEqual(CURRENT_MARKER) }) // --- Hook 3: marker module export ---------------------------------------- it('the brain-format marker module exports the compiled epoch + data-format constants', () => { // cor imports these from '@soulcraft/brainy/brain-format' (Hook 3) so both // sides share ONE source of truth — no duplicated constant to drift. // Epoch 3: the namespace-law key split (bare user keys · literal // 'system.' scalars, 2026-08-03) — every brain rebuilds onto the // frozen keys at first open. (Epoch 2 same day: `level` indexability.) expect(EXPECTED_INDEX_EPOCH).toBe(3) expect(CURRENT_DATA_FORMAT).toBe('8.0') }) })