brainy/tests/integration/migration-backup.test.ts

190 lines
7.6 KiB
TypeScript
Raw Normal View History

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