/** * Migration System Integration Tests * * Verifies: * - MigrationRunner with in-memory storage * - brain.migrate() public API (dry-run and apply) * - Pre-migration snapshots via `backupTo` (persist-before-migrate + restore) * - No-op when MIGRATIONS array is empty * - Warning log when autoMigrate is false * - Multi-tenant independent migration state * - Verb and noun transforms */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import { Brainy } from '../../src/brainy.js' import { MigrationRunner, MIGRATIONS } from '../../src/migration/index.js' import type { Migration } from '../../src/migration/index.js' import { NounType, VerbType } from '../../src/types/graphTypes.js' // Helper to temporarily inject migrations into the MIGRATIONS array function withMigrations(migrations: Migration[], fn: () => Promise): Promise { const original = MIGRATIONS.splice(0, MIGRATIONS.length) MIGRATIONS.push(...migrations) return fn().finally(() => { MIGRATIONS.splice(0, MIGRATIONS.length) MIGRATIONS.push(...original) }) } describe('Migration System', () => { let brain: Brainy beforeEach(async () => { brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true }) await brain.init() }) afterEach(async () => { await brain.close() }) // ─── No-op tests ─────────────────────────────────────────────── describe('No-op with empty MIGRATIONS', () => { it('should have zero pending migrations by default', async () => { // MIGRATIONS is empty by default — init() should add ~0ms overhead const preview = await brain.migrate({ dryRun: true }) expect(preview).toHaveProperty('pendingMigrations') const p = preview as any expect(p.pendingMigrations).toHaveLength(0) expect(p.affectedEntities).toBe(0) }) it('should return no-op result when applying with no migrations', async () => { const result = await brain.migrate() expect(result).toHaveProperty('migrationsApplied') const r = result as any expect(r.migrationsApplied).toHaveLength(0) expect(r.entitiesModified).toBe(0) }) }) // ─── Dry-run preview ────────────────────────────────────────── describe('Dry-run preview', () => { it('should preview affected entities without writing', async () => { // Add some entities with a unique marker field const id1 = await brain.add({ type: NounType.Concept, data: { name: 'Alpha' }, metadata: { status: 'active' } }) const id2 = await brain.add({ type: NounType.Concept, data: { name: 'Beta' }, metadata: { status: 'inactive' } }) const id3 = await brain.add({ type: NounType.Concept, data: { name: 'Gamma' }, metadata: { status: 'pending' } }) const testMigration: Migration = { id: 'test-1.0.0-add-version', version: '1.0.0', description: 'Add version field to entities with status', applies: 'nouns', transform: (m) => { // Only transform entities that have our specific 'status' field if ('status' in m && !('version' in m)) { return { ...m, version: 1 } } return null } } await withMigrations([testMigration], async () => { const preview = await brain.migrate({ dryRun: true }) const p = preview as any expect(p.pendingMigrations).toHaveLength(1) expect(p.pendingMigrations[0].id).toBe('test-1.0.0-add-version') // All 3 entities have 'status' metadata expect(p.affectedEntities).toBeGreaterThanOrEqual(3) expect(p.sampleChanges.length).toBeGreaterThan(0) expect(p.sampleChanges[0].after.version).toBe(1) // Verify no data was modified (dry-run) const entity = await brain.get(id1) expect(entity?.metadata?.version).toBeUndefined() }) }) it('should show correct sample before/after', async () => { await brain.add({ type: NounType.Concept, data: { name: 'Test' }, metadata: { state: 'draft' } }) const renameMigration: Migration = { id: 'test-rename', version: '1.0.0', description: 'Rename state to status', applies: 'nouns', transform: (m) => { if ('state' in m) { const { state, ...rest } = m return { ...rest, status: state } } return null } } await withMigrations([renameMigration], async () => { const preview = await brain.migrate({ dryRun: true }) const p = preview as any expect(p.sampleChanges.length).toBeGreaterThanOrEqual(1) // Find the sample for our entity (it has the 'state' field) const sample = p.sampleChanges.find((s: any) => s.before.state === 'draft') expect(sample).toBeDefined() expect(sample.after.status).toBe('draft') expect(sample.after.state).toBeUndefined() }) }) }) // ─── Apply migrations ───────────────────────────────────────── describe('Apply migrations', () => { it('should transform noun metadata and return result', async () => { const id = await brain.add({ type: NounType.Concept, data: { name: 'Alice' }, metadata: { priority: 'low' } }) const addFieldMigration: Migration = { id: 'test-add-field', version: '1.0.0', description: 'Add migrated flag to entities with priority', applies: 'nouns', transform: (m) => { if ('priority' in m && !('migrated' in m)) { return { ...m, migrated: true } } return null } } await withMigrations([addFieldMigration], async () => { const result = await brain.migrate() const r = result as any expect(r.migrationsApplied).toContain('test-add-field') expect(r.entitiesModified).toBeGreaterThanOrEqual(1) // Verify data was actually transformed const entity = await brain.get(id) expect(entity?.metadata?.migrated).toBe(true) expect(entity?.metadata?.priority).toBe('low') }) }) it('should skip entities that return null from transform', async () => { await brain.add({ type: NounType.Concept, data: { name: 'Has status' }, metadata: { status: 'active' } }) await brain.add({ type: NounType.Concept, data: { name: 'No status' }, metadata: { priority: 'high' } }) const conditionalMigration: Migration = { id: 'test-conditional', version: '1.0.0', description: 'Uppercase status field only when present', applies: 'nouns', transform: (m) => { if (typeof m.status === 'string') { return { ...m, status: (m.status as string).toUpperCase() } } return null } } await withMigrations([conditionalMigration], async () => { const result = await brain.migrate() const r = result as any // Only 1 entity has 'status', the other has 'priority' expect(r.entitiesModified).toBeGreaterThanOrEqual(1) expect(r.entitiesProcessed).toBeGreaterThanOrEqual(2) }) }) it('should run multiple migrations in order', async () => { await brain.add({ type: NounType.Concept, data: { name: 'Test' }, metadata: { count: 1 } }) const migration1: Migration = { id: 'test-double', version: '1.0.0', description: 'Double count', applies: 'nouns', transform: (m) => typeof m.count === 'number' ? { ...m, count: (m.count as number) * 2 } : null } const migration2: Migration = { id: 'test-add-ten', version: '1.1.0', description: 'Add 10 to count', applies: 'nouns', transform: (m) => typeof m.count === 'number' ? { ...m, count: (m.count as number) + 10 } : null } await withMigrations([migration1, migration2], async () => { const result = await brain.migrate() const r = result as any expect(r.migrationsApplied).toEqual(['test-double', 'test-add-ten']) }) }) it('should not re-run completed migrations', async () => { await brain.add({ type: NounType.Concept, data: { name: 'Test' }, metadata: { v: 1 } }) const migration: Migration = { id: 'test-increment', version: '1.0.0', description: 'Increment v', applies: 'nouns', transform: (m) => typeof m.v === 'number' ? { ...m, v: (m.v as number) + 1 } : null } await withMigrations([migration], async () => { // Run once const result1 = await brain.migrate() expect((result1 as any).migrationsApplied).toHaveLength(1) // Run again — should be no-op const result2 = await brain.migrate() expect((result2 as any).migrationsApplied).toHaveLength(0) expect((result2 as any).entitiesModified).toBe(0) }) }) }) // ─── Pre-migration snapshot tests ───────────────────────────── describe('Pre-migration snapshots (backupTo)', () => { let backupDir: string beforeEach(() => { backupDir = path.join(os.tmpdir(), `brainy-migration-backup-${Date.now()}-${Math.random().toString(36).slice(2)}`) }) afterEach(() => { fs.rmSync(backupDir, { recursive: true, force: true }) }) it('should persist a snapshot before migrating and report its path', async () => { await brain.add({ type: NounType.Concept, data: { name: 'Test' }, metadata: { x: 1 } }) const migration: Migration = { id: 'test-backup', version: '2.0.0', description: 'Add y field to entities with x', applies: 'nouns', transform: (m) => 'x' in m && !('y' in m) ? { ...m, y: 2 } : null } await withMigrations([migration], async () => { const result = await brain.migrate({ backupTo: backupDir }) const r = result as any expect(r.backupPath).toBe(backupDir) expect(r.migrationsApplied).toContain('test-backup') // The snapshot is a self-contained store directory expect(fs.existsSync(backupDir)).toBe(true) expect(fs.readdirSync(backupDir).length).toBeGreaterThan(0) }) }) it('should restore the pre-migration state wholesale from the snapshot', async () => { const id = await brain.add({ type: NounType.Concept, data: { name: 'Restore Test' }, metadata: { original: true } }) const migration: Migration = { id: 'test-restore', version: '3.0.0', description: 'Replace original with migrated', applies: 'nouns', transform: (m) => { if (m.original === true) { return { ...m, original: false, migrated: true } } return null } } await withMigrations([migration], async () => { const result = await brain.migrate({ backupTo: backupDir }) const r = result as any expect(r.backupPath).toBe(backupDir) // Verify migration was applied const migrated = await brain.get(id) expect(migrated?.metadata?.migrated).toBe(true) expect(migrated?.metadata?.original).toBe(false) // Restore the snapshot — the store returns to its pre-migration state await brain.restore(backupDir, { confirm: true }) const restored = await brain.get(id) expect(restored?.metadata?.original).toBe(true) expect(restored?.metadata?.migrated).toBeUndefined() }) }) it('should report backupPath null when no backupTo is supplied', async () => { await brain.add({ type: NounType.Concept, data: { name: 'NoBackup' }, metadata: { q: 1 } }) const migration: Migration = { id: 'test-no-backup', version: '4.0.0', description: 'Add field', applies: 'nouns', transform: (m) => 'q' in m && !('r' in m) ? { ...m, r: 2 } : null } await withMigrations([migration], async () => { const result = await brain.migrate() const r = result as any expect(r.backupPath).toBeNull() expect(r.migrationsApplied).toContain('test-no-backup') }) }) }) // ─── Warning log tests ──────────────────────────────────────── describe('autoMigrate: false', () => { it('should log warning when pending migrations exist', async () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const migration: Migration = { id: 'test-warning', version: '1.0.0', description: 'Test warning', applies: 'nouns', transform: () => null } await withMigrations([migration], async () => { // autoMigrate defaults to TRUE in 8.0, which would run the migration // inline during init() (small dataset) instead of logging. Opt out so // the deferred-notice path is exercised. silent stays false (default) // so the notice is actually emitted to console.log. const warnBrain = new Brainy({ requireSubtype: false, autoMigrate: false, storage: { type: 'memory' } }) await warnBrain.init() const migrationLogs = consoleSpy.mock.calls .filter(call => typeof call[0] === 'string' && call[0].includes('pending migration')) expect(migrationLogs.length).toBeGreaterThan(0) await warnBrain.close() }) consoleSpy.mockRestore() }) }) // ─── autoMigrate: true inline ───────────────────────────────── describe('autoMigrate: true', () => { it('should auto-migrate small datasets during init', async () => { // Create a brain with data, close it const setupBrain = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true }) await setupBrain.init() await setupBrain.add({ type: NounType.Concept, data: { name: 'AutoTest' }, metadata: { legacy: true } }) // We can't easily share memory storage between instances, // so test the migrate() path directly instead const migration: Migration = { id: 'test-auto', version: '1.0.0', description: 'Auto migrate test', applies: 'nouns', transform: (m) => 'legacy' in m ? { ...m, legacy: false, upgraded: true } : null } await withMigrations([migration], async () => { const result = await setupBrain.migrate() const r = result as any expect(r.migrationsApplied).toContain('test-auto') expect(r.entitiesModified).toBeGreaterThanOrEqual(1) }) await setupBrain.close() }) }) // ─── Progress callback ──────────────────────────────────────── describe('Progress callback', () => { it('should call onProgress during migration', async () => { await brain.add({ type: NounType.Concept, data: { name: 'A' }, metadata: { x: 1 } }) await brain.add({ type: NounType.Concept, data: { name: 'B' }, metadata: { x: 2 } }) const migration: Migration = { id: 'test-progress', version: '1.0.0', description: 'Add y to entities with x', applies: 'nouns', transform: (m) => 'x' in m ? { ...m, y: true } : null } const progressCalls: any[] = [] await withMigrations([migration], async () => { await brain.migrate({ onProgress: (p) => progressCalls.push(p) }) }) expect(progressCalls.length).toBeGreaterThan(0) expect(progressCalls[0].migrationId).toBe('test-progress') expect(progressCalls[0].processed).toBeGreaterThan(0) }) }) // ─── Multi-tenant independence ───────────────────────────────── describe('Multi-tenant independence', () => { it('should have independent migration state per instance', async () => { const brain1 = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true }) const brain2 = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true }) await brain1.init() await brain2.init() await brain1.add({ type: NounType.Concept, data: { name: 'User1 data' }, metadata: { v: 1 } }) await brain2.add({ type: NounType.Concept, data: { name: 'User2 data' }, metadata: { v: 1 } }) const migration: Migration = { id: 'test-tenant', version: '1.0.0', description: 'Increment v on entities that have it', applies: 'nouns', transform: (m) => typeof m.v === 'number' ? { ...m, v: (m.v as number) + 1 } : null } await withMigrations([migration], async () => { // Migrate only brain1 const r1 = await brain1.migrate() expect((r1 as any).entitiesModified).toBeGreaterThanOrEqual(1) // brain2 should still have pending migrations const preview = await brain2.migrate({ dryRun: true }) expect((preview as any).pendingMigrations).toHaveLength(1) expect((preview as any).affectedEntities).toBeGreaterThanOrEqual(1) }) await brain1.close() await brain2.close() }) }) // ─── Verb migration ─────────────────────────────────────────── describe('Verb migration', () => { it('should transform verb metadata when applies is verbs', async () => { const id1 = await brain.add({ type: NounType.Concept, data: { name: 'A' } }) const id2 = await brain.add({ type: NounType.Concept, data: { name: 'B' } }) await brain.relate({ from: id1, to: id2, type: VerbType.RelatedTo, metadata: { strength: 'weak' } }) const migration: Migration = { id: 'test-verb-migration', version: '1.0.0', description: 'Rename strength to intensity', applies: 'verbs', transform: (m) => { if ('strength' in m) { const { strength, ...rest } = m return { ...rest, intensity: strength } } return null } } await withMigrations([migration], async () => { const result = await brain.migrate() expect((result as any).migrationsApplied).toContain('test-verb-migration') // At least the one verb with 'strength' should be modified expect((result as any).entitiesModified).toBeGreaterThanOrEqual(1) }) }) }) // ─── Both nouns and verbs ───────────────────────────────────── describe('applies: both', () => { it('should transform both nouns and verbs when applies is both', async () => { const id1 = await brain.add({ type: NounType.Concept, data: { name: 'A' }, metadata: { tag: 'old' } }) const id2 = await brain.add({ type: NounType.Concept, data: { name: 'B' }, metadata: { tag: 'old' } }) await brain.relate({ from: id1, to: id2, type: VerbType.RelatedTo, metadata: { tag: 'old' } }) const migration: Migration = { id: 'test-both', version: '1.0.0', description: 'Update tag from old to new', applies: 'both', transform: (m) => m.tag === 'old' ? { ...m, tag: 'new' } : null } await withMigrations([migration], async () => { const result = await brain.migrate() const r = result as any // At least 2 nouns + 1 verb should be modified expect(r.entitiesModified).toBeGreaterThanOrEqual(3) }) }) }) // ─── MigrationRunner unit-level tests ───────────────────────── describe('MigrationRunner', () => { it('should report no pending migrations when array is empty', async () => { const runner = new MigrationRunner((brain as any).storage) expect(await runner.hasPendingMigrations()).toBe(false) }) it('should report pending migrations when array has entries', async () => { const migration: Migration = { id: 'runner-test', version: '1.0.0', description: 'Test', applies: 'nouns', transform: () => null } await withMigrations([migration], async () => { const runner = new MigrationRunner((brain as any).storage) expect(await runner.hasPendingMigrations()).toBe(true) expect(await runner.pendingCount()).toBe(1) expect(runner.nextMigrationVersion()).toBe('1.0.0') }) }) it('should estimate time correctly', async () => { const migration: Migration = { id: 'estimate-test', version: '1.0.0', description: 'Test', applies: 'nouns', transform: () => null } await withMigrations([migration], async () => { const runner = new MigrationRunner((brain as any).storage) const preview = await runner.preview() // With 0 entities, estimatedTime should be '0ms' or '<1s' expect(preview.estimatedTime).toBeDefined() }) }) }) // ─── Error handling ────────────────────────────────────────────── describe('Error handling', () => { it('should track transform errors without crashing the whole migration', async () => { // Add 3 entities — one will trigger a throw await brain.add({ type: NounType.Concept, data: { name: 'Good1' }, metadata: { value: 1 } }) await brain.add({ type: NounType.Concept, data: { name: 'Bad' }, metadata: { value: 'crash-me' } }) await brain.add({ type: NounType.Concept, data: { name: 'Good2' }, metadata: { value: 3 } }) const migration: Migration = { id: 'test-error-handling', version: '1.0.0', description: 'Transform that throws on non-number values', applies: 'nouns', transform: (m) => { if ('value' in m) { if (typeof m.value !== 'number') { throw new Error('value must be a number') } return { ...m, value: (m.value as number) * 10 } } return null } } await withMigrations([migration], async () => { const result = await brain.migrate() const r = result as any // Migration should still complete for the good entities expect(r.migrationsApplied).toContain('test-error-handling') expect(r.entitiesModified).toBeGreaterThanOrEqual(2) // Errors should be tracked expect(r.errors.length).toBeGreaterThanOrEqual(1) expect(r.errors[0].migrationId).toBe('test-error-handling') expect(r.errors[0].error).toContain('value must be a number') expect(r.errors[0].entityId).toBeDefined() }) }) it('should stop early when maxErrors is exceeded', async () => { // Add many entities that will all fail for (let i = 0; i < 5; i++) { await brain.add({ type: NounType.Concept, data: { name: `Fail${i}` }, metadata: { boom: true } }) } const migration: Migration = { id: 'test-max-errors', version: '1.0.0', description: 'Always throws', applies: 'nouns', transform: (m) => { if ('boom' in m) { throw new Error('deliberate failure') } return null } } await withMigrations([migration], async () => { const result = await brain.migrate({ maxErrors: 2 }) const r = result as any // Should have stopped at 2 errors expect(r.errors.length).toBe(2) // Not all entities should have been processed expect(r.entitiesProcessed).toBeLessThanOrEqual(5) }) }) }) // ─── Validation ───────────────────────────────────────────────── describe('Validation (MigrationRunner.validateMigrations)', () => { it('should accept empty migrations array', () => { expect(() => MigrationRunner.validateMigrations([])).not.toThrow() }) it('should accept valid migrations', () => { const valid: Migration[] = [ { id: 'v1', version: '1.0.0', description: 'Test', applies: 'nouns', transform: () => null }, { id: 'v2', version: '1.1.0', description: 'Test 2', applies: 'verbs', transform: () => null } ] expect(() => MigrationRunner.validateMigrations(valid)).not.toThrow() }) it('should reject duplicate migration IDs', () => { const dupes: Migration[] = [ { id: 'same-id', version: '1.0.0', description: 'First', applies: 'nouns', transform: () => null }, { id: 'same-id', version: '1.1.0', description: 'Second', applies: 'nouns', transform: () => null } ] expect(() => MigrationRunner.validateMigrations(dupes)).toThrow(/Duplicate migration id.*same-id/) }) it('should reject invalid applies value', () => { const bad = [ { id: 'bad-applies', version: '1.0.0', description: 'Bad', applies: 'widgets' as any, transform: () => null } ] expect(() => MigrationRunner.validateMigrations(bad)).toThrow(/invalid applies value.*widgets/) }) it('should reject non-function transform', () => { const bad = [ { id: 'bad-transform', version: '1.0.0', description: 'Bad', applies: 'nouns' as const, transform: 'not a function' as any } ] expect(() => MigrationRunner.validateMigrations(bad)).toThrow(/non-function transform/) }) it('should reject missing required fields', () => { const noId = [ { id: '', version: '1.0.0', description: 'Bad', applies: 'nouns' as const, transform: () => null } ] expect(() => MigrationRunner.validateMigrations(noId)).toThrow(/missing or invalid id/) const noVersion = [ { id: 'ok', version: '', description: 'Bad', applies: 'nouns' as const, transform: () => null } ] expect(() => MigrationRunner.validateMigrations(noVersion)).toThrow(/missing or invalid version/) const noDescription = [ { id: 'ok', version: '1.0.0', description: '', applies: 'nouns' as const, transform: () => null } ] expect(() => MigrationRunner.validateMigrations(noDescription)).toThrow(/missing or invalid description/) }) }) })