/** * @module tests/integration/restore-nondestructive * @description Regression for a consumer-reported recovery hazard: `restore()` * removed the entire live brain directory and THEN `fs.cp`'d the snapshot in, so * a copy failure (most dangerously ENOSPC — `fs.cp` also materializes the holes * of sparse mmap blobs, ballooning a snapshot that would otherwise fit) left the * store destroyed with only a partial copy: the recovery tool could destroy the * brain it was meant to recover. * * Fix: the snapshot is copied into a staging area (sparse-aware) BEFORE any live * data is touched; only after the copy succeeds and a marker is fsync'd does an * atomic per-entry swap move it into place. A copy failure leaves the live store * exactly as it was; a crash mid-swap is resumed forward on the next open. * * These tests exercise the property directly: a forced copy failure leaves live * data intact, a normal restore round-trips correctly, an interrupted-but- * committed staging area is completed on reopen, and the sparse copy preserves * holes byte-for-byte. */ import { describe, it, expect, beforeEach, 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 { NounType } from '../../src/types/graphTypes.js' const STAGING = '_restore_staging' const MARKER = '.restore-manifest.json' describe('non-destructive restore (BRAINY-RESTORE-DESTRUCTIVE)', () => { let root: string let snap: string let brain: any const openBrain = async (dir: string) => { const b = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir }, dimensions: 384, silent: true }) await b.init() return b } beforeEach(async () => { process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true' const base = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-restore-')) root = path.join(base, 'store') snap = path.join(base, 'snapshot') brain = await openBrain(root) }) afterEach(async () => { try { await brain.close() } catch { /* already closed in a test */ } fs.rmSync(path.dirname(root), { recursive: true, force: true }) }) it('a copy failure leaves the LIVE store completely intact (the core property)', async () => { const keep = await brain.add({ id: '00000000-0000-4000-8000-0000000000c1', data: 'in snapshot', type: NounType.Thing }) await brain.flush() await brain['storage'].snapshotToDirectory(snap) // Mutate live AFTER the snapshot — this state must survive a failed restore. const extra = await brain.add({ id: '00000000-0000-4000-8000-0000000000c2', data: 'live only', type: NounType.Thing }) // Force the staging copy to fail on its second entry (partial staging). const storage = brain['storage'] const realCopy = storage.copyTreeSparse.bind(storage) let calls = 0 storage.copyTreeSparse = async (src: string, dest: string) => { if (++calls === 2) throw new Error('simulated ENOSPC') return realCopy(src, dest) } await expect(brain.restore(snap, { confirm: true })).rejects.toThrow(/untouched/) // Live data — BOTH the snapshot entity and the live-only mutation — survives. expect(await brain.get(keep)).not.toBeNull() expect(await brain.get(extra)).not.toBeNull() // The half-written staging area was cleaned up. expect(fs.existsSync(path.join(root, STAGING))).toBe(false) }) it('a normal restore round-trips to the snapshot state', async () => { const a = await brain.add({ id: '00000000-0000-4000-8000-0000000000c3', data: 'A', type: NounType.Thing }) await brain.flush() await brain['storage'].snapshotToDirectory(snap) // Diverge from the snapshot: add B, remove A. const b = await brain.add({ id: '00000000-0000-4000-8000-0000000000c4', data: 'B', type: NounType.Thing }) await brain.remove(a) expect(await brain.get(a)).toBeNull() expect(await brain.get(b)).not.toBeNull() await brain.restore(snap, { confirm: true }) // Back to exactly the snapshot: A present, B gone. expect(await brain.get(a)).not.toBeNull() expect(await brain.get(b)).toBeNull() expect(fs.existsSync(path.join(root, STAGING))).toBe(false) }) it('an interrupted-but-committed restore is completed on the next open (resume forward)', async () => { const a = await brain.add({ id: '00000000-0000-4000-8000-0000000000c5', data: 'A', type: NounType.Thing }) await brain.flush() await brain['storage'].snapshotToDirectory(snap) const b = await brain.add({ id: '00000000-0000-4000-8000-0000000000c6', data: 'B', type: NounType.Thing }) await brain.flush() await brain.close() // Simulate the state right after the staging copy committed but before the // swap ran: a _restore_staging holding the snapshot's entries + the marker. const staging = path.join(root, STAGING) fs.mkdirSync(staging, { recursive: true }) const entries: string[] = [] for (const entry of fs.readdirSync(snap)) { if (entry === 'locks' || entry === STAGING) continue fs.cpSync(path.join(snap, entry), path.join(staging, entry), { recursive: true }) entries.push(entry) } fs.writeFileSync(path.join(staging, MARKER), JSON.stringify({ entries })) // Reopen → init resumes the swap → the store is the snapshot (A, no B). brain = await openBrain(root) expect(await brain.get(a)).not.toBeNull() expect(await brain.get(b)).toBeNull() expect(fs.existsSync(staging)).toBe(false) }) it('an uncommitted staging area (no marker) is discarded on open, live untouched', async () => { const a = await brain.add({ id: '00000000-0000-4000-8000-0000000000c7', data: 'A live', type: NounType.Thing }) await brain.flush() await brain.close() // A staging dir with NO marker = a copy that never committed → debris. const staging = path.join(root, STAGING) fs.mkdirSync(staging, { recursive: true }) fs.writeFileSync(path.join(staging, 'entities'), 'garbage partial copy') brain = await openBrain(root) expect(await brain.get(a)).not.toBeNull() // live authoritative expect(fs.existsSync(staging)).toBe(false) // debris removed }) it('the sparse copy preserves holes: content byte-identical, allocation far below apparent size', async () => { const base = path.dirname(root) const src = path.join(base, 'sparse-src.bin') const dest = path.join(base, 'sparse-dest.bin') const SIZE = 32 * 1024 * 1024 // 32 MiB apparent const DATA = Buffer.from('hello sparse world') // Create a sparse source: 32 MiB of holes with a little real data at the start. const fh = fs.openSync(src, 'w') fs.ftruncateSync(fh, SIZE) fs.writeSync(fh, DATA, 0, DATA.length, 0) fs.closeSync(fh) const storage = brain['storage'] const stat = fs.statSync(src) await storage.copyFileSparse(src, dest, stat.size, stat.mode) // Same apparent size and byte-identical content… const destStat = fs.statSync(dest) expect(destStat.size).toBe(SIZE) expect(fs.readFileSync(dest).equals(fs.readFileSync(src))).toBe(true) // …but the destination is actually sparse (allocated bytes far below size). expect(destStat.blocks * 512).toBeLessThan(SIZE / 2) }) })