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.
This commit is contained in:
parent
ed178e2ce9
commit
1aad1f67de
5 changed files with 319 additions and 0 deletions
162
tests/integration/migration-backup.test.ts
Normal file
162
tests/integration/migration-backup.test.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
/**
|
||||
* @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)', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue