feat: add migration system with error handling, validation, and enterprise hardening
- MigrationRunner: per-entity error tracking (non-fatal), maxErrors bail-out,
static validateMigrations() called in constructor, branch error propagation
- RefManager: updateRefMetadata() method for clean metadata updates
- brainy.ts: eliminate as-any casts in migration methods, use updateRefMetadata,
forward maxErrors through full chain including branch migrations
- Types: MigrationError interface, errors field on MigrationResult, maxErrors on MigrateOptions
- Package exports: MigrationError type, migrate() on BrainyInterface, autoMigrate config
- 31 integration tests covering error handling, validation, branch error propagation
- Documentation: docs/guides/schema-migrations.md
2026-02-09 16:13:14 -08:00
/ * *
* Migration System Integration Tests
*
* Verifies :
* - MigrationRunner with in - memory storage
* - brain . migrate ( ) public API ( dry - run and apply )
* - Backup branch creation with metadata tagging
* - 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 { 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 < void > ) : Promise < void > {
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 ( ) = > {
feat(8.0)!: flip requireSubtype default to true (BRAINY-8.0-SUBTYPE-CONTRACT § C-1)
Brainy 8.0 makes subtype required by default on every public write path
(`add`, `addMany`, `update`, `relate`, `relateMany`, `updateRelation`,
import). Per the locked C-1 contract, every entity and relation gets a
non-empty subtype string by the time the storage layer sees it.
OPT-OUT REMAINS FULLY SUPPORTED
The runtime flag is still consumer-controlled. Three opt-out paths
cover migration / legacy fixtures / typed escape:
- `new Brainy({ requireSubtype: false })` — last-resort: turn off the
contract entirely. Recommended only for migration windows or test
fixtures that legitimately can't supply a subtype.
- `new Brainy({ requireSubtype: { except: [NounType.Thing, ...] } })` —
per-type allowlist: strict everywhere except the listed types.
- `brain.requireSubtype(type, options)` — per-type registration with
optional vocabulary. Composes with the brain-wide flag.
Default is now `true`. Opt-out is explicit and documented; nothing
silently degrades.
TEST SWEEP
Bulk-applied `requireSubtype: false` to every `new Brainy({...})` call
site across 120 test files. Three sed patterns covered the shapes:
- `new Brainy({` → `new Brainy({ requireSubtype: false,`
- `new Brainy<T>({` → `new Brainy<T>({ requireSubtype: false,`
- `new Brainy()` → `new Brainy({ requireSubtype: false })`
tests/helpers/test-factory.ts → createTestConfig() defaults
`requireSubtype: false` so test files using the helper inherit the
opt-out without per-site edits.
The test sites that DO exercise subtype semantics (the
subtype-and-facets suite, the strict-mode-self-test suite, the verb-
subtype-and-enforcement suite, etc.) already pass real subtypes — they
were the 7.30.x acceptance tests for this contract. Those tests
continue to pass unchanged.
CHANGES
src/brainy.ts
- normalizeConfig() — `requireSubtype` default `false` → `true`.
Comment refreshed to document the three opt-out paths.
tests/* (120 files)
- Bulk-edited brain construction sites. No functional test changes; the
opt-out preserves the test author's original intent.
tests/helpers/test-factory.ts
- createTestConfig() base config gains `requireSubtype: false`.
NO-OP for consumers who were already passing subtype on every write.
For consumers who weren't, the upgrade path is one of the three opt-out
forms above. Migration recipe documented in 8.0 release notes (next
commit).
VERIFICATION
- npx tsc --noEmit: clean
- npm test: 1408 / 1409 (same pre-existing race-condition outstanding;
no other regressions from the flip)
2026-06-09 14:58:25 -07:00
brain = new Brainy ( { requireSubtype : false , storage : { type : 'memory' } , silent : true } )
feat: add migration system with error handling, validation, and enterprise hardening
- MigrationRunner: per-entity error tracking (non-fatal), maxErrors bail-out,
static validateMigrations() called in constructor, branch error propagation
- RefManager: updateRefMetadata() method for clean metadata updates
- brainy.ts: eliminate as-any casts in migration methods, use updateRefMetadata,
forward maxErrors through full chain including branch migrations
- Types: MigrationError interface, errors field on MigrationResult, maxErrors on MigrateOptions
- Package exports: MigrationError type, migrate() on BrainyInterface, autoMigrate config
- 31 integration tests covering error handling, validation, branch error propagation
- Documentation: docs/guides/schema-migrations.md
2026-02-09 16:13:14 -08:00
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 )
} )
} )
} )
// ─── Backup branch tests ──────────────────────────────────────
describe ( 'Backup branches' , ( ) = > {
it ( 'should create a backup branch before migrating' , async ( ) = > {
await brain . add ( { type : NounType . Concept , data : { name : 'Test' } , metadata : { x : 1 } } )
// Need a commit for fork to work
await brain . commit ( { message : 'test' , author : 'test' } )
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 ( )
const r = result as any
expect ( r . backupBranch ) . toBe ( 'pre-migration-2.0.0' )
const branches = await brain . listBranches ( )
expect ( branches ) . toContain ( 'pre-migration-2.0.0' )
} )
} )
it ( 'should tag backup branch with system:backup metadata' , async ( ) = > {
await brain . add ( { type : NounType . Concept , data : { name : 'Test' } , metadata : { z : 1 } } )
await brain . commit ( { message : 'test' , author : 'test' } )
const migration : Migration = {
id : 'test-tag' ,
version : '4.0.0' ,
description : 'Tag test' ,
applies : 'nouns' ,
transform : ( m ) = > 'z' in m ? { . . . m , tagged : true } : null
}
await withMigrations ( [ migration ] , async ( ) = > {
await brain . migrate ( )
// Verify metadata tag on the backup branch ref
const refManager = ( brain as any ) . storage . refManager
if ( refManager ) {
const ref = await refManager . getRef ( 'pre-migration-4.0.0' )
expect ( ref ) . toBeDefined ( )
expect ( ref ? . metadata ? . type ) . toBe ( 'system:backup' )
expect ( ref ? . metadata ? . migrationVersion ) . toBe ( '4.0.0' )
expect ( ref ? . metadata ? . author ) . toBe ( 'brainy-migration' )
}
} )
} )
it ( 'should keep backup branch as a named restore point' , async ( ) = > {
const id = await brain . add ( { type : NounType . Concept , data : { name : 'Restore Test' } , metadata : { original : true } } )
await brain . commit ( { message : 'before migration' , author : 'test' } )
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 ( )
const r = result as any
// Verify migration was applied
const migrated = await brain . get ( id )
expect ( migrated ? . metadata ? . migrated ) . toBe ( true )
// Verify backup branch exists as a named restore point
expect ( r . backupBranch ) . toBe ( 'pre-migration-3.0.0' )
const branches = await brain . listBranches ( )
expect ( branches ) . toContain ( 'pre-migration-3.0.0' )
// Verify the backup ref points to the pre-migration commit
const refManager = ( brain as any ) . storage . refManager
if ( refManager ) {
const mainRef = await refManager . getRef ( 'main' )
const backupRef = await refManager . getRef ( 'pre-migration-3.0.0' )
expect ( backupRef ) . toBeDefined ( )
// Backup was forked before migration, so both share the same commit
// (the pre-migration commit). After migration, main's overlay has new data,
// but the commit pointer is unchanged.
expect ( backupRef . commitHash ) . toBe ( mainRef . commitHash )
}
} )
} )
} )
// ─── Warning log tests ────────────────────────────────────────
describe ( 'autoMigrate: false (default)' , ( ) = > {
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 ( ) = > {
feat(8.0)!: flip requireSubtype default to true (BRAINY-8.0-SUBTYPE-CONTRACT § C-1)
Brainy 8.0 makes subtype required by default on every public write path
(`add`, `addMany`, `update`, `relate`, `relateMany`, `updateRelation`,
import). Per the locked C-1 contract, every entity and relation gets a
non-empty subtype string by the time the storage layer sees it.
OPT-OUT REMAINS FULLY SUPPORTED
The runtime flag is still consumer-controlled. Three opt-out paths
cover migration / legacy fixtures / typed escape:
- `new Brainy({ requireSubtype: false })` — last-resort: turn off the
contract entirely. Recommended only for migration windows or test
fixtures that legitimately can't supply a subtype.
- `new Brainy({ requireSubtype: { except: [NounType.Thing, ...] } })` —
per-type allowlist: strict everywhere except the listed types.
- `brain.requireSubtype(type, options)` — per-type registration with
optional vocabulary. Composes with the brain-wide flag.
Default is now `true`. Opt-out is explicit and documented; nothing
silently degrades.
TEST SWEEP
Bulk-applied `requireSubtype: false` to every `new Brainy({...})` call
site across 120 test files. Three sed patterns covered the shapes:
- `new Brainy({` → `new Brainy({ requireSubtype: false,`
- `new Brainy<T>({` → `new Brainy<T>({ requireSubtype: false,`
- `new Brainy()` → `new Brainy({ requireSubtype: false })`
tests/helpers/test-factory.ts → createTestConfig() defaults
`requireSubtype: false` so test files using the helper inherit the
opt-out without per-site edits.
The test sites that DO exercise subtype semantics (the
subtype-and-facets suite, the strict-mode-self-test suite, the verb-
subtype-and-enforcement suite, etc.) already pass real subtypes — they
were the 7.30.x acceptance tests for this contract. Those tests
continue to pass unchanged.
CHANGES
src/brainy.ts
- normalizeConfig() — `requireSubtype` default `false` → `true`.
Comment refreshed to document the three opt-out paths.
tests/* (120 files)
- Bulk-edited brain construction sites. No functional test changes; the
opt-out preserves the test author's original intent.
tests/helpers/test-factory.ts
- createTestConfig() base config gains `requireSubtype: false`.
NO-OP for consumers who were already passing subtype on every write.
For consumers who weren't, the upgrade path is one of the three opt-out
forms above. Migration recipe documented in 8.0 release notes (next
commit).
VERIFICATION
- npx tsc --noEmit: clean
- npm test: 1408 / 1409 (same pre-existing race-condition outstanding;
no other regressions from the flip)
2026-06-09 14:58:25 -07:00
const warnBrain = new Brainy ( { requireSubtype : false , storage : { type : 'memory' } } )
feat: add migration system with error handling, validation, and enterprise hardening
- MigrationRunner: per-entity error tracking (non-fatal), maxErrors bail-out,
static validateMigrations() called in constructor, branch error propagation
- RefManager: updateRefMetadata() method for clean metadata updates
- brainy.ts: eliminate as-any casts in migration methods, use updateRefMetadata,
forward maxErrors through full chain including branch migrations
- Types: MigrationError interface, errors field on MigrationResult, maxErrors on MigrateOptions
- Package exports: MigrationError type, migrate() on BrainyInterface, autoMigrate config
- 31 integration tests covering error handling, validation, branch error propagation
- Documentation: docs/guides/schema-migrations.md
2026-02-09 16:13:14 -08:00
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
feat(8.0)!: flip requireSubtype default to true (BRAINY-8.0-SUBTYPE-CONTRACT § C-1)
Brainy 8.0 makes subtype required by default on every public write path
(`add`, `addMany`, `update`, `relate`, `relateMany`, `updateRelation`,
import). Per the locked C-1 contract, every entity and relation gets a
non-empty subtype string by the time the storage layer sees it.
OPT-OUT REMAINS FULLY SUPPORTED
The runtime flag is still consumer-controlled. Three opt-out paths
cover migration / legacy fixtures / typed escape:
- `new Brainy({ requireSubtype: false })` — last-resort: turn off the
contract entirely. Recommended only for migration windows or test
fixtures that legitimately can't supply a subtype.
- `new Brainy({ requireSubtype: { except: [NounType.Thing, ...] } })` —
per-type allowlist: strict everywhere except the listed types.
- `brain.requireSubtype(type, options)` — per-type registration with
optional vocabulary. Composes with the brain-wide flag.
Default is now `true`. Opt-out is explicit and documented; nothing
silently degrades.
TEST SWEEP
Bulk-applied `requireSubtype: false` to every `new Brainy({...})` call
site across 120 test files. Three sed patterns covered the shapes:
- `new Brainy({` → `new Brainy({ requireSubtype: false,`
- `new Brainy<T>({` → `new Brainy<T>({ requireSubtype: false,`
- `new Brainy()` → `new Brainy({ requireSubtype: false })`
tests/helpers/test-factory.ts → createTestConfig() defaults
`requireSubtype: false` so test files using the helper inherit the
opt-out without per-site edits.
The test sites that DO exercise subtype semantics (the
subtype-and-facets suite, the strict-mode-self-test suite, the verb-
subtype-and-enforcement suite, etc.) already pass real subtypes — they
were the 7.30.x acceptance tests for this contract. Those tests
continue to pass unchanged.
CHANGES
src/brainy.ts
- normalizeConfig() — `requireSubtype` default `false` → `true`.
Comment refreshed to document the three opt-out paths.
tests/* (120 files)
- Bulk-edited brain construction sites. No functional test changes; the
opt-out preserves the test author's original intent.
tests/helpers/test-factory.ts
- createTestConfig() base config gains `requireSubtype: false`.
NO-OP for consumers who were already passing subtype on every write.
For consumers who weren't, the upgrade path is one of the three opt-out
forms above. Migration recipe documented in 8.0 release notes (next
commit).
VERIFICATION
- npx tsc --noEmit: clean
- npm test: 1408 / 1409 (same pre-existing race-condition outstanding;
no other regressions from the flip)
2026-06-09 14:58:25 -07:00
const setupBrain = new Brainy ( { requireSubtype : false , storage : { type : 'memory' } , silent : true } )
feat: add migration system with error handling, validation, and enterprise hardening
- MigrationRunner: per-entity error tracking (non-fatal), maxErrors bail-out,
static validateMigrations() called in constructor, branch error propagation
- RefManager: updateRefMetadata() method for clean metadata updates
- brainy.ts: eliminate as-any casts in migration methods, use updateRefMetadata,
forward maxErrors through full chain including branch migrations
- Types: MigrationError interface, errors field on MigrationResult, maxErrors on MigrateOptions
- Package exports: MigrationError type, migrate() on BrainyInterface, autoMigrate config
- 31 integration tests covering error handling, validation, branch error propagation
- Documentation: docs/guides/schema-migrations.md
2026-02-09 16:13:14 -08:00
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 ( ) = > {
feat(8.0)!: flip requireSubtype default to true (BRAINY-8.0-SUBTYPE-CONTRACT § C-1)
Brainy 8.0 makes subtype required by default on every public write path
(`add`, `addMany`, `update`, `relate`, `relateMany`, `updateRelation`,
import). Per the locked C-1 contract, every entity and relation gets a
non-empty subtype string by the time the storage layer sees it.
OPT-OUT REMAINS FULLY SUPPORTED
The runtime flag is still consumer-controlled. Three opt-out paths
cover migration / legacy fixtures / typed escape:
- `new Brainy({ requireSubtype: false })` — last-resort: turn off the
contract entirely. Recommended only for migration windows or test
fixtures that legitimately can't supply a subtype.
- `new Brainy({ requireSubtype: { except: [NounType.Thing, ...] } })` —
per-type allowlist: strict everywhere except the listed types.
- `brain.requireSubtype(type, options)` — per-type registration with
optional vocabulary. Composes with the brain-wide flag.
Default is now `true`. Opt-out is explicit and documented; nothing
silently degrades.
TEST SWEEP
Bulk-applied `requireSubtype: false` to every `new Brainy({...})` call
site across 120 test files. Three sed patterns covered the shapes:
- `new Brainy({` → `new Brainy({ requireSubtype: false,`
- `new Brainy<T>({` → `new Brainy<T>({ requireSubtype: false,`
- `new Brainy()` → `new Brainy({ requireSubtype: false })`
tests/helpers/test-factory.ts → createTestConfig() defaults
`requireSubtype: false` so test files using the helper inherit the
opt-out without per-site edits.
The test sites that DO exercise subtype semantics (the
subtype-and-facets suite, the strict-mode-self-test suite, the verb-
subtype-and-enforcement suite, etc.) already pass real subtypes — they
were the 7.30.x acceptance tests for this contract. Those tests
continue to pass unchanged.
CHANGES
src/brainy.ts
- normalizeConfig() — `requireSubtype` default `false` → `true`.
Comment refreshed to document the three opt-out paths.
tests/* (120 files)
- Bulk-edited brain construction sites. No functional test changes; the
opt-out preserves the test author's original intent.
tests/helpers/test-factory.ts
- createTestConfig() base config gains `requireSubtype: false`.
NO-OP for consumers who were already passing subtype on every write.
For consumers who weren't, the upgrade path is one of the three opt-out
forms above. Migration recipe documented in 8.0 release notes (next
commit).
VERIFICATION
- npx tsc --noEmit: clean
- npm test: 1408 / 1409 (same pre-existing race-condition outstanding;
no other regressions from the flip)
2026-06-09 14:58:25 -07:00
const brain1 = new Brainy ( { requireSubtype : false , storage : { type : 'memory' } , silent : true } )
const brain2 = new Brainy ( { requireSubtype : false , storage : { type : 'memory' } , silent : true } )
feat: add migration system with error handling, validation, and enterprise hardening
- MigrationRunner: per-entity error tracking (non-fatal), maxErrors bail-out,
static validateMigrations() called in constructor, branch error propagation
- RefManager: updateRefMetadata() method for clean metadata updates
- brainy.ts: eliminate as-any casts in migration methods, use updateRefMetadata,
forward maxErrors through full chain including branch migrations
- Types: MigrationError interface, errors field on MigrationResult, maxErrors on MigrateOptions
- Package exports: MigrationError type, migrate() on BrainyInterface, autoMigrate config
- 31 integration tests covering error handling, validation, branch error propagation
- Documentation: docs/guides/schema-migrations.md
2026-02-09 16:13:14 -08:00
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 )
} )
} )
} )
// ─── Multi-branch migration ─────────────────────────────────
describe ( 'Multi-branch migration' , ( ) = > {
it ( 'should migrate entities on all branches including feature branches' , async ( ) = > {
// Setup: create entities on main, fork a branch, add entities on the branch
const mainId = await brain . add ( {
type : NounType . Concept ,
data : { name : 'Main Entity' } ,
metadata : { legacy : true , source : 'main' }
} )
await brain . commit ( { message : 'initial' , author : 'test' } )
// Fork a feature branch and add branch-local entity
const fork = await brain . fork ( 'feature-x' )
const branchId = await fork . add ( {
type : NounType . Concept ,
data : { name : 'Branch Entity' } ,
metadata : { legacy : true , source : 'branch' }
} )
// Define migration that transforms the 'legacy' field
const migration : Migration = {
id : 'test-multi-branch' ,
version : '1.0.0' ,
description : 'Mark legacy entities as upgraded' ,
applies : 'nouns' ,
transform : ( m ) = > {
if ( m . legacy === true ) {
return { . . . m , legacy : false , upgraded : true }
}
return null // Already migrated or not applicable
}
}
await withMigrations ( [ migration ] , async ( ) = > {
// Migrate from main — should reach all branches
const result = await brain . migrate ( )
const r = result as any
expect ( r . migrationsApplied ) . toContain ( 'test-multi-branch' )
// Main entity + branch-local entity should both be modified
expect ( r . entitiesModified ) . toBeGreaterThanOrEqual ( 2 )
// Verify main entity was transformed
const mainEntity = await brain . get ( mainId )
expect ( mainEntity ? . metadata ? . upgraded ) . toBe ( true )
expect ( mainEntity ? . metadata ? . legacy ) . toBe ( false )
} )
await fork . close ( )
} )
it ( 'should skip system:backup branches during multi-branch migration' , async ( ) = > {
await brain . add ( { type : NounType . Concept , data : { name : 'Test' } , metadata : { v : 1 } } )
await brain . commit ( { message : 'test' , author : 'test' } )
// Create two migrations — first creates a backup branch, second should skip it
const migration1 : Migration = {
id : 'test-skip-backup-1' ,
version : '5.0.0' ,
description : 'First migration' ,
applies : 'nouns' ,
transform : ( m ) = > typeof m . v === 'number' && ! ( 'm1' in m ) ? { . . . m , m1 : true } : null
}
await withMigrations ( [ migration1 ] , async ( ) = > {
const result = await brain . migrate ( )
const r = result as any
expect ( r . backupBranch ) . toBe ( 'pre-migration-5.0.0' )
// Verify backup branch exists
const branches = await brain . listBranches ( )
expect ( branches ) . toContain ( 'pre-migration-5.0.0' )
// Migration should not have errored from trying to migrate the backup
expect ( r . migrationsApplied ) . toContain ( 'test-skip-backup-1' )
} )
} )
} )
// ─── 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 propagate errors from branch migrations into the result' , async ( ) = > {
// Create entity on main, commit, fork a branch, add a branch-local entity that will fail
await brain . add ( { type : NounType . Concept , data : { name : 'MainOk' } , metadata : { branchTest : 1 } } )
await brain . commit ( { message : 'initial' , author : 'test' } )
const fork = await brain . fork ( 'branch-with-error' )
await fork . add ( { type : NounType . Concept , data : { name : 'BranchBad' } , metadata : { branchTest : 'crash' } } )
const migration : Migration = {
id : 'test-branch-error-prop' ,
version : '1.0.0' ,
description : 'Throws on non-number branchTest' ,
applies : 'nouns' ,
transform : ( m ) = > {
if ( 'branchTest' in m ) {
if ( typeof m . branchTest !== 'number' ) {
throw new Error ( 'branchTest must be a number' )
}
return { . . . m , branchTest : ( m . branchTest as number ) * 10 }
}
return null
}
}
await withMigrations ( [ migration ] , async ( ) = > {
const result = await brain . migrate ( )
const r = result as any
// Main entity should have migrated successfully
expect ( r . migrationsApplied ) . toContain ( 'test-branch-error-prop' )
expect ( r . entitiesModified ) . toBeGreaterThanOrEqual ( 1 )
// Branch error should appear in the combined result
expect ( r . errors . length ) . toBeGreaterThanOrEqual ( 1 )
const branchError = r . errors . find ( ( e : any ) = > e . error . includes ( 'branchTest must be a number' ) )
expect ( branchError ) . toBeDefined ( )
} )
await fork . close ( )
} )
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/ )
} )
} )
} )