feat(8.0): no-freeze auto-upgrade hooks — isMigrating() deference + stampBrainFormat() + brain-format export

Three hooks so a native provider can run a non-blocking, online, background
build-new→verify→swap index migration on a large-brain upgrade while brainy stays
out of the way — no minutes-long blocking rebuild-on-open/first-query.

- isMigrating?(): boolean — OPTIONAL on MetadataIndexProvider / GraphIndexProvider /
  VectorIndexProvider (mirrors isReady?()/init?(), feature-detected). While a
  provider reports migrating, brainy SKIPS its rebuild for that index — per-index,
  so a non-migrating sibling still rebuilds; skipped even under epoch-drift or
  size()===0 — in both rebuildIndexesIfNeeded and the lazy first-query force path.
  The provider serves correct reads from canonical until it verifies-and-swaps.
- brain.stampBrainFormat() — public; the provider calls it once its background swap
  verifies, so brainy authors dataFormat (the provider never does). brainy withholds
  its own marker stamp while any provider is migrating, so the shared epoch is never
  advanced ahead of a deferred index.
- ./brain-format export — the marker module's EXPECTED_INDEX_EPOCH / CURRENT_DATA_FORMAT
  are importable so a native provider shares the constant (no duplicate = no
  lockstep-drift). 8-case test; unit 1743 green.
This commit is contained in:
David Snelling 2026-06-30 13:37:23 -07:00
parent bd6faf7499
commit b6b919890c
4 changed files with 378 additions and 18 deletions

View file

@ -0,0 +1,244 @@
/**
* @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-newverifyswap 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<unknown> }
metadataIndex: { rebuild(...a: unknown[]): Promise<unknown> }
graphIndex: { size(): number; rebuild(...a: unknown[]): Promise<unknown> }
_indexEpochStale: boolean
lazyRebuildCompleted: boolean
rebuildIndexesIfNeeded(force?: boolean): Promise<void>
ensureIndexesLoaded(): Promise<void>
storage: { readRawObject(p: string): Promise<unknown> }
}
const brains: Brainy[] = []
/** Open (and track) a warmed memory brain holding `count` document entities. */
async function makeWarmBrain(count = 2, extraConfig: Record<string, unknown> = {}): Promise<Brainy> {
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 when its size()===0; a non-migrating size()===0 sibling still rebuilds', 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 this time: the rebuild trigger is purely "index is empty".
internals._indexEpochStale = false
// Vector index reports empty AND is migrating → its background swap owns it.
vi.spyOn(internals.index, 'size').mockReturnValue(0)
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.
expect(miSpy).toHaveBeenCalledTimes(0)
})
// --- 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.
expect(EXPECTED_INDEX_EPOCH).toBe(1)
expect(CURRENT_DATA_FORMAT).toBe('8.0')
})
})