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 })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
/** Build a persisted brain with one entity. `staleAfter` overwrites the marker
|
|
|
|
|
* 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()
|
|
|
|
|
})
|
|
|
|
|
})
|