feat(8.0): 7.x→8.0 layout migration — fix silent total data loss on first open
7.x stored every object branch-scoped under branches/<branch>/<basePath>; 8.0
stores the IDENTICAL entity structure at the storage ROOT plus the generational
_system/_generations layer. GenerationStore.open tolerates a missing manifest
(opens at gen 0), so a naive 8.0 open of a 7.x directory reported ZERO entities
and SILENTLY LOST ALL DATA. The fix is blocked in the normal init order — cor 3.0's
storage-factory legacy guard fires inside setupStorage(), five steps before the
old migration check — so a new phase runs BEFORE setupStorage().
legacyLayoutMigrationPhase() (init, between loadPlugins and setupStorage):
- Fast-path: returns immediately when there's no top-level branches/ dir (every
native 8.0 brain + fresh dir pays only one fs.existsSync on the init hot path).
- Runs on a temporary BUILT-IN FileSystemStorage (never the plugin adapter, whose
guard would throw). Memory/cloud/pre-built-adapter stores are a strict no-op.
- Detect via _system/migration-layout.json marker + branches/<head>/entities/.
- autoMigrate:false on a legacy layout THROWS explicit guidance (no silent loss).
- Acquire the writer lock, then COLLAPSE branches/<head>/entities/* → entities/*
via the .gz-transparent raw primitives (read→write→delete, idempotent/resume-safe;
NOT fs.rename — logical paths + memory-adapter incompatibility).
- Rebuild persisted counts (rebuildCounts → totalNounCount/counts.json;
rebuildTypeCounts/rebuildSubtypeCounts → per-type stats) — the three indexes are
rebuilt by the normal init's rebuildIndexesIfNeeded afterward.
- Finalize: stamp the flat-v8 marker (re-open no-op), drain the head branch
(non-head branches = 7.x version history 8.0's MVCC does not import; left as-is).
Tests (tests/integration/migration-7x-to-8x.test.ts): the data-loss LOCK (a
built-in FileSystemStorage on a reshaped 7.x dir reports 0 nouns), a byte-equal
ROUND TRIP (find/related/counts equal the pre-reshape reference), IDEMPOTENCY,
the autoMigrate:false GUARD throw, and the native-flat NO-OP. RELEASES upgrade
checklist rewritten (the old note documented the opposite, lossy behavior).
1477 unit + migration 5 + db-mvcc 25 green; no init-path regression.
Follow-ups (hardening, not correctness for the common case): backupTo config
plumbing (currently a loud warning), a crash-mid-collapse resume test, and a
boundary-safe fake-plugin ordering test.
2026-06-19 13:44:03 -07:00
/ * *
* @module tests / integration / migration - 7 x - to - 8 x
* @description Proof suite for the 7 . x → 8.0 on - disk layout migration . 7 . x stored
* every object branch - scoped under ` branches/<branch>/<basePath> ` ; 8.0 stores the
* identical entity structure at the ROOT ( ` entities/<kind>/<shard>/<id>/… ` ) plus
* the generational ` _system/manifest.json ` + ` _generations/ ` . GenerationStore . open
* is TOLERANT of a missing manifest ( opens at generation 0 ) , so a naive 8.0 open of
* a 7 . x directory reports zero entities and SILENTLY LOSES ALL DATA . The migration
* collapses the HEAD branch ' s entities to the root before storage is set up .
*
* Tests :
* 1 . DATA - LOSS LOCK — a built - in FileSystemStorage opened on the reshaped 7 . x dir
* reports zero nouns at the root layout ( the mode the migration fixes ; durable
* because storage never migrates ) .
* 2 . ROUND TRIP — opening a brain with autoMigrate ( default ) restores
* find / related / counts byte - equal to the reference captured before reshaping .
* 3 . IDEMPOTENCY — a second open is a no - op ( marker short - circuits ; branch drained ) .
* 4 . GUARD — autoMigrate :false on a legacy layout throws explicit guidance .
* 5 . NO - OP — a native flat 8.0 brain opens untouched ( no marker churn , no data move ) .
* /
import { describe , it , expect , beforeAll , afterAll , afterEach } 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 { FileSystemStorage } from '../../src/storage/adapters/fileSystemStorage.js'
import { NounType , VerbType } from '../../src/types/graphTypes.js'
interface Reference {
nounCount : number
verbCount : number
docIds : string [ ]
activeIds : string [ ]
relatedFrom0 : string [ ]
}
const docId = ( i : number ) : string = > ` 00000000-0000-4000-8000-00000000000 ${ i } `
async function buildReferenceBrain ( dir : string ) : Promise < Reference > {
const brain = new Brainy ( {
requireSubtype : false ,
feat(8.0): API simplification — remove neural()/Db.search, one storage `path` key, integration→0
8.0 RC cleanup toward "one place per thing, zero-config, no deprecation":
- Remove the `brain.neural()` clustering namespace (ImprovedNeuralAPI + the dead
legacy NeuralAPI + the neural CLI + neural-only types). Similarity is `find({vector})`
/ `similar({to})`; attribute grouping is the aggregation `GROUP BY` engine. The separate
entity-extraction / smart-import feature (NeuralImport, NeuralEntityExtractor, SmartExtractor,
NaturalLanguageProcessor, `brain.extract()`/`brain.nlp()`) is kept.
- Remove `Db.search()`; `find()` is the one query verb (accepts a bare string or FindParams).
Fix the bundled MCP client, which called a non-existent `brain.search(query, limit)` →
now `find({ query, limit })`.
- Storage config: collapse to one canonical top-level `path` key. The pre-8.0 aliases
(`rootDirectory`, `options.*`, `fileSystemStorage.*`) are removed and now THROW with the
exact rename instead of silently defaulting to `./brainy-data` on upgrade. A single resolver
feeds createStorage, the 7.x→8.0 migration probe, and the plugin-factory handoff, so a native
storage provider resolves the identical root (no split-brain).
- Fix `similar({ threshold })`: the min-similarity filter was silently dropped; it is now
applied as a post-filter on `result.score` (the documented way to bound semantic results).
- Fix `vfs.rename()` on a directory: child path updates spread the entity vector into `update()`
and failed dimension validation; they are metadata-only updates now.
- Fix `vfs.move()`: copy+delete orphaned the content-addressed content blob (the destination
shared the source hash, then unlink removed it). `move()` now delegates to `rename()` — an
in-place path change that preserves the blob and the entity id, for files and directories.
- Fix streaming import: the bulk fast path never flushed mid-import nor signalled queryability.
Entity writes are now chunked by a progressive flush interval (100 → 1000 → 5000); each chunk
flushes and emits `progress.queryable`, so imported data is queryable during the import.
- Sweep all docs, comments, and JSDoc for the removed/changed APIs.
Integration suite: 49 files / 588 passed / 0 failed. Unit: 80 files / 1456 passed, no type errors.
2026-06-20 13:31:11 -07:00
storage : { type : 'filesystem' , path : dir } ,
feat(8.0): 7.x→8.0 layout migration — fix silent total data loss on first open
7.x stored every object branch-scoped under branches/<branch>/<basePath>; 8.0
stores the IDENTICAL entity structure at the storage ROOT plus the generational
_system/_generations layer. GenerationStore.open tolerates a missing manifest
(opens at gen 0), so a naive 8.0 open of a 7.x directory reported ZERO entities
and SILENTLY LOST ALL DATA. The fix is blocked in the normal init order — cor 3.0's
storage-factory legacy guard fires inside setupStorage(), five steps before the
old migration check — so a new phase runs BEFORE setupStorage().
legacyLayoutMigrationPhase() (init, between loadPlugins and setupStorage):
- Fast-path: returns immediately when there's no top-level branches/ dir (every
native 8.0 brain + fresh dir pays only one fs.existsSync on the init hot path).
- Runs on a temporary BUILT-IN FileSystemStorage (never the plugin adapter, whose
guard would throw). Memory/cloud/pre-built-adapter stores are a strict no-op.
- Detect via _system/migration-layout.json marker + branches/<head>/entities/.
- autoMigrate:false on a legacy layout THROWS explicit guidance (no silent loss).
- Acquire the writer lock, then COLLAPSE branches/<head>/entities/* → entities/*
via the .gz-transparent raw primitives (read→write→delete, idempotent/resume-safe;
NOT fs.rename — logical paths + memory-adapter incompatibility).
- Rebuild persisted counts (rebuildCounts → totalNounCount/counts.json;
rebuildTypeCounts/rebuildSubtypeCounts → per-type stats) — the three indexes are
rebuilt by the normal init's rebuildIndexesIfNeeded afterward.
- Finalize: stamp the flat-v8 marker (re-open no-op), drain the head branch
(non-head branches = 7.x version history 8.0's MVCC does not import; left as-is).
Tests (tests/integration/migration-7x-to-8x.test.ts): the data-loss LOCK (a
built-in FileSystemStorage on a reshaped 7.x dir reports 0 nouns), a byte-equal
ROUND TRIP (find/related/counts equal the pre-reshape reference), IDEMPOTENCY,
the autoMigrate:false GUARD throw, and the native-flat NO-OP. RELEASES upgrade
checklist rewritten (the old note documented the opposite, lossy behavior).
1477 unit + migration 5 + db-mvcc 25 green; no init-path regression.
Follow-ups (hardening, not correctness for the common case): backupTo config
plumbing (currently a loud warning), a crash-mid-collapse resume test, and a
boundary-safe fake-plugin ordering test.
2026-06-19 13:44:03 -07:00
dimensions : 384 ,
silent : true
} )
await brain . init ( )
for ( let i = 0 ; i < 5 ; i ++ ) {
await brain . add ( {
id : docId ( i ) ,
data : ` Document number ${ i } about distributed systems ` ,
type : NounType . Document ,
metadata : { title : ` Doc ${ i } ` , status : i % 2 === 0 ? 'active' : 'archived' }
} )
}
await brain . relate ( { from : docId ( 0 ) , to : docId ( 1 ) , type : VerbType . RelatedTo } )
await brain . relate ( { from : docId ( 0 ) , to : docId ( 2 ) , type : VerbType . RelatedTo } )
await brain . flush ( )
const ref = await captureReference ( brain )
await brain . close ( )
return ref
}
async function captureReference ( brain : Brainy ) : Promise < Reference > {
const docs = await brain . find ( { type : NounType . Document , limit : 100 } )
const active = await brain . find ( { type : NounType . Document , where : { status : 'active' } , limit : 100 } )
const related = await brain . related ( docId ( 0 ) )
return {
nounCount : await brain . getNounCount ( ) ,
verbCount : await brain . getVerbCount ( ) ,
docIds : docs.map ( ( r : any ) = > r . id ) . sort ( ) ,
activeIds : active.map ( ( r : any ) = > r . id ) . sort ( ) ,
relatedFrom0 : related.map ( ( r : any ) = > r . to ) . sort ( )
}
}
/ * *
* Reshape a flat 8.0 directory into the 7 . x branch layout : move ` entities/ ` under
* ` branches/main/entities/ ` , and delete the 8.0 - only derived + MVCC state ( 8.0
* rebuilds all of it from the canonical entities ) . The result is what a 7 . x store
* looks like to 8.0 : canonical entities present , but invisible at the root .
* /
function reshapeToLegacy ( dir : string ) : void {
const branchEntities = path . join ( dir , 'branches' , 'main' , 'entities' )
fs . mkdirSync ( path . dirname ( branchEntities ) , { recursive : true } )
fs . renameSync ( path . join ( dir , 'entities' ) , branchEntities )
for ( const derived of [ '_system' , '_column_index' , '_blobs' , '_generations' , 'indexes' ] ) {
fs . rmSync ( path . join ( dir , derived ) , { recursive : true , force : true } )
}
}
const makeTempDir = ( ) : string = > fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'brainy-migrate-' ) )
async function openBrain ( dir : string , extra : Record < string , unknown > = { } ) : Promise < Brainy > {
return new Brainy ( {
requireSubtype : false ,
feat(8.0): API simplification — remove neural()/Db.search, one storage `path` key, integration→0
8.0 RC cleanup toward "one place per thing, zero-config, no deprecation":
- Remove the `brain.neural()` clustering namespace (ImprovedNeuralAPI + the dead
legacy NeuralAPI + the neural CLI + neural-only types). Similarity is `find({vector})`
/ `similar({to})`; attribute grouping is the aggregation `GROUP BY` engine. The separate
entity-extraction / smart-import feature (NeuralImport, NeuralEntityExtractor, SmartExtractor,
NaturalLanguageProcessor, `brain.extract()`/`brain.nlp()`) is kept.
- Remove `Db.search()`; `find()` is the one query verb (accepts a bare string or FindParams).
Fix the bundled MCP client, which called a non-existent `brain.search(query, limit)` →
now `find({ query, limit })`.
- Storage config: collapse to one canonical top-level `path` key. The pre-8.0 aliases
(`rootDirectory`, `options.*`, `fileSystemStorage.*`) are removed and now THROW with the
exact rename instead of silently defaulting to `./brainy-data` on upgrade. A single resolver
feeds createStorage, the 7.x→8.0 migration probe, and the plugin-factory handoff, so a native
storage provider resolves the identical root (no split-brain).
- Fix `similar({ threshold })`: the min-similarity filter was silently dropped; it is now
applied as a post-filter on `result.score` (the documented way to bound semantic results).
- Fix `vfs.rename()` on a directory: child path updates spread the entity vector into `update()`
and failed dimension validation; they are metadata-only updates now.
- Fix `vfs.move()`: copy+delete orphaned the content-addressed content blob (the destination
shared the source hash, then unlink removed it). `move()` now delegates to `rename()` — an
in-place path change that preserves the blob and the entity id, for files and directories.
- Fix streaming import: the bulk fast path never flushed mid-import nor signalled queryability.
Entity writes are now chunked by a progressive flush interval (100 → 1000 → 5000); each chunk
flushes and emits `progress.queryable`, so imported data is queryable during the import.
- Sweep all docs, comments, and JSDoc for the removed/changed APIs.
Integration suite: 49 files / 588 passed / 0 failed. Unit: 80 files / 1456 passed, no type errors.
2026-06-20 13:31:11 -07:00
storage : { type : 'filesystem' , path : dir } ,
feat(8.0): 7.x→8.0 layout migration — fix silent total data loss on first open
7.x stored every object branch-scoped under branches/<branch>/<basePath>; 8.0
stores the IDENTICAL entity structure at the storage ROOT plus the generational
_system/_generations layer. GenerationStore.open tolerates a missing manifest
(opens at gen 0), so a naive 8.0 open of a 7.x directory reported ZERO entities
and SILENTLY LOST ALL DATA. The fix is blocked in the normal init order — cor 3.0's
storage-factory legacy guard fires inside setupStorage(), five steps before the
old migration check — so a new phase runs BEFORE setupStorage().
legacyLayoutMigrationPhase() (init, between loadPlugins and setupStorage):
- Fast-path: returns immediately when there's no top-level branches/ dir (every
native 8.0 brain + fresh dir pays only one fs.existsSync on the init hot path).
- Runs on a temporary BUILT-IN FileSystemStorage (never the plugin adapter, whose
guard would throw). Memory/cloud/pre-built-adapter stores are a strict no-op.
- Detect via _system/migration-layout.json marker + branches/<head>/entities/.
- autoMigrate:false on a legacy layout THROWS explicit guidance (no silent loss).
- Acquire the writer lock, then COLLAPSE branches/<head>/entities/* → entities/*
via the .gz-transparent raw primitives (read→write→delete, idempotent/resume-safe;
NOT fs.rename — logical paths + memory-adapter incompatibility).
- Rebuild persisted counts (rebuildCounts → totalNounCount/counts.json;
rebuildTypeCounts/rebuildSubtypeCounts → per-type stats) — the three indexes are
rebuilt by the normal init's rebuildIndexesIfNeeded afterward.
- Finalize: stamp the flat-v8 marker (re-open no-op), drain the head branch
(non-head branches = 7.x version history 8.0's MVCC does not import; left as-is).
Tests (tests/integration/migration-7x-to-8x.test.ts): the data-loss LOCK (a
built-in FileSystemStorage on a reshaped 7.x dir reports 0 nouns), a byte-equal
ROUND TRIP (find/related/counts equal the pre-reshape reference), IDEMPOTENCY,
the autoMigrate:false GUARD throw, and the native-flat NO-OP. RELEASES upgrade
checklist rewritten (the old note documented the opposite, lossy behavior).
1477 unit + migration 5 + db-mvcc 25 green; no init-path regression.
Follow-ups (hardening, not correctness for the common case): backupTo config
plumbing (currently a loud warning), a crash-mid-collapse resume test, and a
boundary-safe fake-plugin ordering test.
2026-06-19 13:44:03 -07:00
dimensions : 384 ,
silent : true ,
. . . extra
} )
}
describe ( '7.x → 8.0 layout migration' , ( ) = > {
const dirs : string [ ] = [ ]
const brains : Brainy [ ] = [ ]
let reference : Reference
let legacyTemplate : string
beforeAll ( async ( ) = > {
process . env . BRAINY_DETERMINISTIC_EMBEDDINGS = 'true'
legacyTemplate = makeTempDir ( )
reference = await buildReferenceBrain ( legacyTemplate )
reshapeToLegacy ( legacyTemplate )
} )
afterAll ( ( ) = > fs . rmSync ( legacyTemplate , { recursive : true , force : true } ) )
afterEach ( async ( ) = > {
for ( const b of brains . splice ( 0 ) ) {
try {
await b . close ( )
} catch {
/* already closed */
}
}
for ( const d of dirs . splice ( 0 ) ) fs . rmSync ( d , { recursive : true , force : true } )
} )
/** A fresh copy of the reshaped legacy directory for one test to mutate. */
function freshLegacyDir ( ) : string {
const dir = makeTempDir ( )
dirs . push ( dir )
fs . cpSync ( legacyTemplate , dir , { recursive : true } )
return dir
}
it ( 'DATA-LOSS LOCK: a built-in FileSystemStorage on a 7.x layout reports zero nouns at the root' , async ( ) = > {
const dir = freshLegacyDir ( )
const storage = new FileSystemStorage ( dir )
await storage . init ( )
// The canonical entities exist on disk (under branches/main/) but are invisible
// at the 8.0 root layout — exactly the silent data loss the migration prevents.
expect ( await storage . getNounCount ( ) ) . toBe ( 0 )
} )
it ( 'ROUND TRIP: opening with autoMigrate (default) restores all three intelligences byte-equal' , async ( ) = > {
const dir = freshLegacyDir ( )
const brain = await openBrain ( dir )
brains . push ( brain )
await brain . init ( )
const after = await captureReference ( brain )
expect ( after ) . toEqual ( reference )
// The branch layout is drained and the marker stamped.
expect ( fs . existsSync ( path . join ( dir , 'branches' , 'main' , 'entities' ) ) ) . toBe ( false )
expect ( fs . existsSync ( path . join ( dir , 'entities' ) ) ) . toBe ( true )
} )
it ( 'IDEMPOTENCY: a second open is a no-op and still reads the migrated data' , async ( ) = > {
const dir = freshLegacyDir ( )
const first = await openBrain ( dir )
brains . push ( first )
await first . init ( )
await first . close ( )
brains . splice ( brains . indexOf ( first ) , 1 )
// Mtime of the marker must not change on the second open (no re-migration).
const markerPath = path . join ( dir , '_system' , 'migration-layout.json' )
const markerExists = fs . existsSync ( markerPath ) || fs . existsSync ( markerPath + '.gz' )
const second = await openBrain ( dir )
brains . push ( second )
await second . init ( )
expect ( markerExists ) . toBe ( true )
expect ( await captureReference ( second ) ) . toEqual ( reference )
} )
it ( 'GUARD: autoMigrate:false on a 7.x layout throws explicit guidance instead of losing data' , async ( ) = > {
const dir = freshLegacyDir ( )
const brain = await openBrain ( dir , { autoMigrate : false } )
brains . push ( brain )
await expect ( brain . init ( ) ) . rejects . toThrow ( /7\.x branch layout/ )
} )
it ( 'NO-OP: a native flat 8.0 brain opens untouched' , async ( ) = > {
const dir = makeTempDir ( )
dirs . push ( dir )
const ref = await buildReferenceBrain ( dir ) // builds + closes a normal flat brain
const reopened = await openBrain ( dir )
brains . push ( reopened )
await reopened . init ( ) // migration phase is a strict no-op here
expect ( await captureReference ( reopened ) ) . toEqual ( ref )
expect ( fs . existsSync ( path . join ( dir , 'branches' ) ) ) . toBe ( false )
} )
2026-07-07 16:07:10 -07:00
it ( 'PARITY GUARD: branch-scoped non-entity state survives migration (not silently drained)' , async ( ) = > {
const dir = freshLegacyDir ( )
// Simulate a 7.x engine that wrote durable state under the HEAD branch
// OUTSIDE entities/ (a branch-scoped index/blob/registry). The migration
// rescues entities/ only; the guard must PRESERVE the rest rather than let
// removeRawPrefix silently delete it — the VFS `_cow/` stranding lesson.
const raw : any = new FileSystemStorage ( dir )
await raw . init ( )
await raw . writeRawObject ( 'branches/main/_legacy_engine/state' , { keep : 'me' , n : 42 } )
await raw . flush ? . ( )
raw . stopFlushRequestWatcher ? . ( )
await raw . releaseWriterLock ? . ( )
// Migrate (entities collapse to the root as usual).
const brain = await openBrain ( dir )
brains . push ( brain )
await brain . init ( )
expect ( await captureReference ( brain ) ) . toEqual ( reference )
await brain . close ( )
brains . splice ( brains . indexOf ( brain ) , 1 )
// The non-entity branch-scoped state is PRESERVED (branch NOT drained) —
// recoverable, not lost.
const check : any = new FileSystemStorage ( dir )
await check . init ( )
expect ( await check . readRawObject ( 'branches/main/_legacy_engine/state' ) ) . toMatchObject ( {
keep : 'me' ,
n : 42
} )
await check . flush ? . ( )
check . stopFlushRequestWatcher ? . ( )
await check . releaseWriterLock ? . ( )
} )
feat(8.0): 7.x→8.0 layout migration — fix silent total data loss on first open
7.x stored every object branch-scoped under branches/<branch>/<basePath>; 8.0
stores the IDENTICAL entity structure at the storage ROOT plus the generational
_system/_generations layer. GenerationStore.open tolerates a missing manifest
(opens at gen 0), so a naive 8.0 open of a 7.x directory reported ZERO entities
and SILENTLY LOST ALL DATA. The fix is blocked in the normal init order — cor 3.0's
storage-factory legacy guard fires inside setupStorage(), five steps before the
old migration check — so a new phase runs BEFORE setupStorage().
legacyLayoutMigrationPhase() (init, between loadPlugins and setupStorage):
- Fast-path: returns immediately when there's no top-level branches/ dir (every
native 8.0 brain + fresh dir pays only one fs.existsSync on the init hot path).
- Runs on a temporary BUILT-IN FileSystemStorage (never the plugin adapter, whose
guard would throw). Memory/cloud/pre-built-adapter stores are a strict no-op.
- Detect via _system/migration-layout.json marker + branches/<head>/entities/.
- autoMigrate:false on a legacy layout THROWS explicit guidance (no silent loss).
- Acquire the writer lock, then COLLAPSE branches/<head>/entities/* → entities/*
via the .gz-transparent raw primitives (read→write→delete, idempotent/resume-safe;
NOT fs.rename — logical paths + memory-adapter incompatibility).
- Rebuild persisted counts (rebuildCounts → totalNounCount/counts.json;
rebuildTypeCounts/rebuildSubtypeCounts → per-type stats) — the three indexes are
rebuilt by the normal init's rebuildIndexesIfNeeded afterward.
- Finalize: stamp the flat-v8 marker (re-open no-op), drain the head branch
(non-head branches = 7.x version history 8.0's MVCC does not import; left as-is).
Tests (tests/integration/migration-7x-to-8x.test.ts): the data-loss LOCK (a
built-in FileSystemStorage on a reshaped 7.x dir reports 0 nouns), a byte-equal
ROUND TRIP (find/related/counts equal the pre-reshape reference), IDEMPOTENCY,
the autoMigrate:false GUARD throw, and the native-flat NO-OP. RELEASES upgrade
checklist rewritten (the old note documented the opposite, lossy behavior).
1477 unit + migration 5 + db-mvcc 25 green; no init-path regression.
Follow-ups (hardening, not correctness for the common case): backupTo config
plumbing (currently a loud warning), a crash-mid-collapse resume test, and a
boundary-safe fake-plugin ordering test.
2026-06-19 13:44:03 -07:00
} )