feat(8.0): auto pre-upgrade backup — hard-link snapshot before the 7.x→8.0 migration
David's ask: back up the brain before the one-time upgrade, drop it after. On
open, when the on-disk format is stale (a 7.x→8.0 rebuild will run) and the store
holds data, brainy hard-link-snapshots the brain dir to a sibling
`<brainDir>.migration-backup` BEFORE any provider rebuilds — near-zero cost/space
(shared inodes; the store is immutable-by-rename), instant even at scale. Removed
automatically once the upgrade verifies + stamps the marker; retained on failure
for rollback. Default-on; opt out with `migrationBackup: false`.
- Reuses the existing snapshotToDirectory() hard-link farm via new optional
adapter methods createMigrationBackup()/removeMigrationBackup() (filesystem
only — feature-detected; memory + non-fs are a graceful no-op).
- Taken before the native provider inits, so it captures true pre-migration
state; a prior failed upgrade's backup is reused, not overwritten.
- Best-effort: a backup failure never blocks the upgrade (the migration is itself
safe — reads canonical, reconstructable, self-heals). Catastrophe-insurance
against a migration bug, not a data-loss guard or an off-device backup.
4 lifecycle tests (adapter hard-link / reuse / remove-without-touching-live /
empty→null; stale-epoch reopen create+remove; opt-out; memory no-op). Gates:
typecheck 0, build 0, test:unit 1753/1753, layout-migration suite 5/5.
2026-07-01 15:04:01 -07:00
/ * *
* @module tests / integration / migration - backup
* @description Proof suite for the pre - upgrade backup ( # 42 ) : before a one - time
* 7 . x → 8.0 rebuild mutates the derived indexes , brainy hard - link - snapshots the
* brain directory ( default - on , opt - out via ` migrationBackup: false ` ) , removes it
* once the upgrade verifies + stamps , and retains it on failure for rollback .
*
* Covers : the FileSystemStorage machinery ( hard - link snapshot , reuse , remove
* without touching live data , empty → null ) ; the brainy lifecycle on a stale - epoch
* reopen ( backup created then removed on success ) ; the opt - out ; and the memory
* feature - detect ( no machinery → graceful no - op ) .
*
* Staleness is forced by stamping a marker with a mismatched ` indexEpoch ` ( what a
* pre - 8.0 brain looks like ) , via the same ` writeRawObject ` surface the marker is
* stored through — not by deleting an on - disk file ( the marker path is encoded ) .
* /
import { describe , it , expect , 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 } from '../../src/types/graphTypes.js'
const tmpDirs : string [ ] = [ ]
function mkTmp ( ) : string {
const d = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'brainy-mig-backup-' ) )
tmpDirs . push ( d )
return d
}
afterEach ( ( ) = > {
for ( const d of tmpDirs . splice ( 0 ) ) {
fs . rmSync ( d , { recursive : true , force : true } )
fs . rmSync ( ` ${ d } .migration-backup ` , { recursive : true , force : true } )
}
} )
/ * * B u i l d a p e r s i s t e d b r a i n w i t h o n e e n t i t y . ` s t a l e A f t e r ` o v e r w r i t e s t h e m a r k e r
* with a mismatched epoch so the NEXT open detects a 7 . x → 8.0 upgrade . * /
async function buildBrainWithData ( dir : string , staleAfter = false ) : Promise < string > {
const brain : any = new Brainy ( {
requireSubtype : false ,
storage : { type : 'filesystem' , path : dir } ,
silent : true
} )
await brain . init ( )
const id = await brain . add ( { data : 'protect me during the upgrade' , type : NounType . Concept } )
await brain . flush ( )
if ( staleAfter ) {
await brain . storage . writeRawObject ( '_system/brain-format.json' , { dataFormat : '7.0' , indexEpoch : 0 } )
}
await brain . close ( )
return id
}
describe ( 'Pre-migration backup (#42)' , ( ) = > {
2026-07-02 13:35:43 -07:00
it ( 'byte-copies _id_mapper/* (mmap, mutated in place) but hard-links the rest' , async ( ) = > {
// cor confirmed the native _id_mapper mmap is msync/truncate-in-place, so a
// hard-linked snapshot of it would be corrupted by a live mutation reaching
// through the shared inode — it must be byte-copied. Everything else (written
// tmp+rename) hard-links safely.
const dir = mkTmp ( )
await buildBrainWithData ( dir )
fs . mkdirSync ( path . join ( dir , '_id_mapper' ) , { recursive : true } )
fs . writeFileSync ( path . join ( dir , '_id_mapper' , 'map.bin' ) , Buffer . from ( [ 1 , 2 , 3 , 4 ] ) )
fs . writeFileSync ( path . join ( dir , 'regular.bin' ) , Buffer . from ( [ 9 , 9 , 9 , 9 ] ) )
const storage : any = new FileSystemStorage ( dir )
await storage . init ( )
const backup = await storage . createMigrationBackup ( )
// _id_mapper file → byte-copied: DIFFERENT inode (a live msync can't reach it) + faithful content
const mapperLive = fs . statSync ( path . join ( dir , '_id_mapper' , 'map.bin' ) ) . ino
const mapperBackup = fs . statSync ( path . join ( backup , '_id_mapper' , 'map.bin' ) ) . ino
expect ( mapperBackup ) . not . toBe ( mapperLive )
expect ( fs . readFileSync ( path . join ( backup , '_id_mapper' , 'map.bin' ) ) ) . toEqual ( Buffer . from ( [ 1 , 2 , 3 , 4 ] ) )
// a regular (tmp+rename) file → hard-linked: SAME inode (zero-copy)
const regLive = fs . statSync ( path . join ( dir , 'regular.bin' ) ) . ino
const regBackup = fs . statSync ( path . join ( backup , 'regular.bin' ) ) . ino
expect ( regBackup ) . toBe ( regLive )
} )
feat(8.0): auto pre-upgrade backup — hard-link snapshot before the 7.x→8.0 migration
David's ask: back up the brain before the one-time upgrade, drop it after. On
open, when the on-disk format is stale (a 7.x→8.0 rebuild will run) and the store
holds data, brainy hard-link-snapshots the brain dir to a sibling
`<brainDir>.migration-backup` BEFORE any provider rebuilds — near-zero cost/space
(shared inodes; the store is immutable-by-rename), instant even at scale. Removed
automatically once the upgrade verifies + stamps the marker; retained on failure
for rollback. Default-on; opt out with `migrationBackup: false`.
- Reuses the existing snapshotToDirectory() hard-link farm via new optional
adapter methods createMigrationBackup()/removeMigrationBackup() (filesystem
only — feature-detected; memory + non-fs are a graceful no-op).
- Taken before the native provider inits, so it captures true pre-migration
state; a prior failed upgrade's backup is reused, not overwritten.
- Best-effort: a backup failure never blocks the upgrade (the migration is itself
safe — reads canonical, reconstructable, self-heals). Catastrophe-insurance
against a migration bug, not a data-loss guard or an off-device backup.
4 lifecycle tests (adapter hard-link / reuse / remove-without-touching-live /
empty→null; stale-epoch reopen create+remove; opt-out; memory no-op). Gates:
typecheck 0, build 0, test:unit 1753/1753, layout-migration suite 5/5.
2026-07-01 15:04:01 -07:00
it ( 'FileSystemStorage.createMigrationBackup hard-links the store; removeMigrationBackup deletes it without touching live data' , async ( ) = > {
const dir = mkTmp ( )
const id = await buildBrainWithData ( dir )
const storage : any = new FileSystemStorage ( dir )
await storage . init ( )
const backupPath = await storage . createMigrationBackup ( )
expect ( backupPath ) . toBe ( ` ${ dir } .migration-backup ` )
expect ( fs . existsSync ( backupPath ) ) . toBe ( true )
expect ( fs . readdirSync ( backupPath ) . length ) . toBeGreaterThan ( 0 ) // snapshot has content
// Reuse: a second call returns the same path (a prior failed upgrade's
// pre-state is preserved, not overwritten) and does not throw.
expect ( await storage . createMigrationBackup ( ) ) . toBe ( backupPath )
// Remove: gone from disk, and the LIVE store is untouched (shared inodes).
await storage . removeMigrationBackup ( backupPath )
expect ( fs . existsSync ( backupPath ) ) . toBe ( false )
const reopened : any = new Brainy ( {
requireSubtype : false ,
storage : { type : 'filesystem' , path : dir } ,
silent : true
} )
await reopened . init ( )
expect ( await reopened . get ( id ) ) . not . toBeNull ( ) // live data survived the backup+remove
await reopened . close ( )
} )
it ( 'returns null for an empty store — nothing to protect' , async ( ) = > {
const dir = mkTmp ( )
const storage : any = new FileSystemStorage ( dir ) // raw store, no brain → no VFS root
await storage . init ( )
expect ( await storage . createMigrationBackup ( ) ) . toBeNull ( )
} )
it ( 'takes a backup on a stale-epoch reopen (default-on) and removes it once the upgrade verifies' , async ( ) = > {
const dir = mkTmp ( )
await buildBrainWithData ( dir , /* staleAfter */ true )
const created : string [ ] = [ ]
const removed : string [ ] = [ ]
const proto = FileSystemStorage . prototype as any
const origCreate = proto . createMigrationBackup
const origRemove = proto . removeMigrationBackup
proto . createMigrationBackup = async function ( ) {
const p = await origCreate . call ( this )
if ( p ) created . push ( p )
return p
}
proto . removeMigrationBackup = async function ( p : string ) {
removed . push ( p )
return origRemove . call ( this , p )
}
try {
const brain : any = new Brainy ( {
requireSubtype : false ,
storage : { type : 'filesystem' , path : dir } ,
silent : true
} )
await brain . init ( ) // epochStale → backup created; inline rebuild stamps → backup removed
await brain . close ( )
expect ( created . length ) . toBe ( 1 ) // a backup WAS taken before the rebuild
expect ( removed ) . toContain ( created [ 0 ] ) // and removed once the upgrade stamped
expect ( fs . existsSync ( created [ 0 ] ) ) . toBe ( false ) // gone from disk
} finally {
proto . createMigrationBackup = origCreate
proto . removeMigrationBackup = origRemove
}
} )
it ( 'opt-out (migrationBackup:false) takes no backup; memory storage is a graceful no-op' , async ( ) = > {
const dir = mkTmp ( )
await buildBrainWithData ( dir , /* staleAfter */ true )
const proto = FileSystemStorage . prototype as any
const origCreate = proto . createMigrationBackup
let calls = 0
proto . createMigrationBackup = async function ( ) {
calls ++
return origCreate . call ( this )
}
try {
const brain : any = new Brainy ( {
requireSubtype : false ,
storage : { type : 'filesystem' , path : dir } ,
migrationBackup : false ,
silent : true
} )
await brain . init ( )
await brain . close ( )
expect ( calls ) . toBe ( 0 )
} finally {
proto . createMigrationBackup = origCreate
}
// Memory storage has no backup machinery — default-on must not crash.
const mem : any = new Brainy ( { requireSubtype : false , storage : { type : 'memory' } } )
await mem . init ( )
expect ( mem . isInitialized ) . toBe ( true )
expect ( mem . config . migrationBackup ) . toBe ( true ) // default resolved
await mem . close ( )
} )
} )