/** * Migration LOCK (#18) — coordinated, automatic 7.x → 8.0 auto-upgrade. * * Exercises the real block-and-queue behavior of `awaitMigrationLock` at the * `ensureInitialized` choke point: while ANY index provider reports * `isMigrating() === true`, data-plane reads and writes WAIT (no operation * touches a half-built index); observability (`getIndexStatus`, `health`) and * the lock-clearing `stampBrainFormat` stay exempt; and a lock that outlives the * configured window surfaces a retryable `MigrationInProgressError`. * * A native (cortex) provider owns the real migration; here we simulate the lock * by feature-detect-injecting `isMigrating()` onto a live provider, exactly as * the production feature-detection reads it. */ import { describe, it, expect, beforeEach } from 'vitest' import { Brainy, NounType, MigrationInProgressError } from '../../src/index.js' import { GraphAdjacencyIndex } from '../../src/graph/graphAdjacencyIndex.js' /** Force the graph provider to hold (or release) the migration lock. */ function setMigrating(brain: any, migrating: boolean): void { brain.graphIndex.isMigrating = () => migrating } describe('Migration LOCK (#18) — coordinated 7.x→8.0 auto-upgrade', () => { let brain: any beforeEach(async () => { brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, migrationWaitTimeoutMs: 5000 // generous; timing tests clear well within it }) await brain.init() }) it('does not gate operations when no provider is migrating (fast path)', async () => { const id = await brain.add({ data: 'hello', type: NounType.Concept }) expect(id).toBeTruthy() const status = await brain.getIndexStatus() expect(status.migrating).toBe(false) expect(status.migration).toBeUndefined() }) it('getIndexStatus() reports migrating WITHOUT blocking (lock-exempt observability)', async () => { setMigrating(brain, true) const status = await brain.getIndexStatus() // must resolve, never hang expect(status.migrating).toBe(true) expect(status.migration).toBeDefined() expect(typeof status.migration.elapsedMs).toBe('number') }) it('health() surfaces the migration as a warn check without blocking', async () => { setMigrating(brain, true) const h = await brain.health() // lock-exempt expect(h.overall).toBe('warn') expect(h.checks.some((c: any) => c.name === 'migration')).toBe(true) }) it('checkHealth() answers during a migration (lock-exempt diagnostics)', async () => { setMigrating(brain, true) const r = await brain.checkHealth() expect(r).toHaveProperty('healthy') }) it('blocks a write while migrating (poll path), then completes when the lock clears', async () => { setMigrating(brain, true) let resolved = false const p = brain .add({ data: 'queued write', type: NounType.Concept }) .then((id: string) => { resolved = true return id }) // Still blocked shortly after issue. await new Promise((r) => setTimeout(r, 40)) expect(resolved).toBe(false) // Clearing the lock releases the queued write against the good indexes. setMigrating(brain, false) const id = await p expect(resolved).toBe(true) expect(id).toBeTruthy() }) it('blocks a read while migrating, then releases when the flag clears (poll path)', async () => { let migrating = true brain.graphIndex.isMigrating = () => migrating const p = brain.find({ type: NounType.Concept }) // a read → gated let resolved = false p.then(() => { resolved = true }) await new Promise((r) => setTimeout(r, 40)) expect(resolved).toBe(false) // Clearing the flag lets the next poll release the read against good indexes. migrating = false await expect(p).resolves.toEqual([]) }) it('throws a retryable MigrationInProgressError when the lock outlives the timeout', async () => { const shortBrain: any = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, migrationWaitTimeoutMs: 120 }) await shortBrain.init() setMigrating(shortBrain, true) // never clears await expect(shortBrain.find({ type: NounType.Concept })).rejects.toBeInstanceOf( MigrationInProgressError ) try { await shortBrain.add({ data: 'x', type: NounType.Concept }) throw new Error('expected MigrationInProgressError') } catch (e: any) { expect(e).toBeInstanceOf(MigrationInProgressError) expect(e.retryable).toBe(true) expect(typeof e.elapsedMs).toBe('number') } }) it('stampBrainFormat() is NOT gated — cor clears the lock through it (no deadlock)', async () => { setMigrating(brain, true) // Must resolve, not hang, even while a migration is "in progress". await expect(brain.stampBrainFormat()).resolves.toBeUndefined() }) it('close() is NOT gated — an instance can shut down mid-migration', async () => { setMigrating(brain, true) await expect(brain.close()).resolves.toBeUndefined() }) it('C1: init() waits out a migration BEFORE VFS bootstrap (no deadlock, no failure)', async () => { // The critical regression: init()'s own VFS bootstrap (get/add/find on the // root) routes through the gated ensureInitialized. If a provider migrates // during init, init must WAIT for the lock to clear BEFORE bootstrapping VFS — // otherwise it deadlocks (< timeout) or throws MigrationInProgressError past // the timeout. We make the graph provider report migrating for a short window // spanning init, then clear it, and assert init completes and the brain works. const orig = (GraphAdjacencyIndex.prototype as any).isMigrating let migrating = true ;(GraphAdjacencyIndex.prototype as any).isMigrating = () => migrating const clearMigration = setTimeout(() => { migrating = false }, 60) try { const b: any = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, migrationWaitTimeoutMs: 5000 }) await b.init() // must resolve once the migration clears — not hang, not throw expect(b.isInitialized).toBe(true) // VFS bootstrapped post-migration → normal write/read work end-to-end. const id = await b.add({ data: 'after upgrade', type: NounType.Concept }) expect(id).toBeTruthy() expect(await b.get(id)).not.toBeNull() await b.close() } finally { clearTimeout(clearMigration) if (orig) (GraphAdjacencyIndex.prototype as any).isMigrating = orig else delete (GraphAdjacencyIndex.prototype as any).isMigrating } }) })