/** * @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 { 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)', () => { 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) }) 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() }) })