/** * @module tests/integration/vfs-cow-blob-adoption * @description P0 recovery: a 7→8 upgrade left VFS content blobs in the removed * branch system's copy-on-write area (`_cow/`) instead of the 8.0 * content-addressed store (`_cas/`), so a VFS read of a stranded blob throws * "Blob metadata not found" and every page backed by it 500s. * * Proves the cure end to end: * - `storage.adoptLegacyCowBlobs()` copies each orphaned `_cow/` blob (bytes + * metadata) into `_cas/` verbatim, idempotently, non-destructively, and * reports an `incomplete` blob (bytes without metadata) rather than * half-adopting it. * - a real brain that upgraded into this state SELF-HEALS on its next open * (auto-adoption) — the stranded VFS file reads correctly again with no * operator action. * - `brain.vfs.adoptOrphanedBlobs()` is the explicit equivalent. */ 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' const tmpDirs: string[] = [] function mkTmp(): string { const d = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-cow-adopt-')) tmpDirs.push(d) return d } /** Release a raw FileSystemStorage: flush, stop its timer, drop the writer lock * so a subsequent open of the same directory is not blocked. */ async function teardownRawStorage(s: any): Promise { try { await s.flush?.() } catch { /* best effort */ } try { s.stopFlushRequestWatcher?.() } catch { /* best effort */ } try { await s.releaseWriterLock?.() } catch { /* best effort */ } } afterEach(() => { for (const d of tmpDirs.splice(0)) fs.rmSync(d, { recursive: true, force: true }) }) /** Move every `_cas/blob*` object into `_cow/` and delete the `_cas/` copy — * reproducing the exact on-disk state a 7.x brain leaves after the 8.0 layout * migration collapses entities but does not adopt the copy-on-write blobs. */ async function strandCasBlobsIntoCow(dir: string): Promise { const s: any = new FileSystemStorage(dir) await s.init() const casKeys = (await s.listRawObjects('_cas/')).filter((k: string) => /(^|\/)(blob:|blob-meta:)/.test(k) ) let moved = 0 for (const full of casKeys) { const key = full.replace(/^_cas\//, '') const obj = await s.readRawObject(`_cas/${key}`) if (obj === null) continue await s.writeRawObject(`_cow/${key}`, obj) await s.deleteRawObject(`_cas/${key}`) moved++ } await teardownRawStorage(s) return moved } describe('VFS _cow/ blob adoption (P0 7→8 recovery)', () => { it('adoptLegacyCowBlobs copies bytes + metadata into _cas/, verbatim, idempotent, non-destructive', async () => { const dir = mkTmp() const storage: any = new FileSystemStorage(dir) await storage.init() const hash = 'a'.repeat(64) const bytes = Buffer.from('the homepage payload') // 7.x layout: the pair lives ONLY under _cow/ (blob-meta carries the refCount). await storage.writeRawObject(`_cow/blob:${hash}`, { _binary: true, data: bytes.toString('base64') }) await storage.writeRawObject(`_cow/blob-meta:${hash}`, { hash, size: bytes.length, refCount: 3, mimeType: 'application/json' }) // Pre-adoption: _cas/ has neither → this is the "Blob metadata not found" state. expect(await storage.readRawObject(`_cas/blob-meta:${hash}`)).toBeNull() const r = await storage.adoptLegacyCowBlobs() expect(r).toMatchObject({ cowBlobs: 1, adopted: 1, alreadyPresent: 0, incomplete: 0 }) // Post: _cas/ has both, metadata verbatim (refCount preserved), bytes intact. const casMeta = await storage.readRawObject(`_cas/blob-meta:${hash}`) expect(casMeta).toMatchObject({ hash, refCount: 3, size: bytes.length }) const casBlob = await storage.readRawObject(`_cas/blob:${hash}`) expect(Buffer.from(casBlob.data, 'base64').toString()).toBe('the homepage payload') // Idempotent: a second run adopts nothing, counts the pair present. expect(await storage.adoptLegacyCowBlobs()).toMatchObject({ adopted: 0, alreadyPresent: 1 }) // Non-destructive: the _cow/ original is still there (rollback stays possible). expect(await storage.readRawObject(`_cow/blob:${hash}`)).not.toBeNull() // Incomplete: bytes without metadata are reported, not half-adopted. await storage.writeRawObject(`_cow/blob:${'b'.repeat(64)}`, { _binary: true, data: 'x' }) const r3 = await storage.adoptLegacyCowBlobs() expect(r3.incomplete).toBe(1) expect(await storage.readRawObject(`_cas/blob:${'b'.repeat(64)}`)).toBeNull() // not adopted await teardownRawStorage(storage) }) it('a brain that upgraded into the stranded state self-heals on next open — the VFS page reads again', async () => { const dir = mkTmp() // 1. Author a VFS page (its content blob lands in _cas/). let brain: any = new Brainy({ storage: { type: 'filesystem', path: dir }, plugins: [], silent: true }) await brain.init() await brain.vfs.writeFile('/pages/homepage/page.json', '{"title":"Homepage"}') expect((await brain.vfs.readFile('/pages/homepage/page.json')).toString()).toContain('Homepage') await brain.close() // 2. Reproduce the post-7→8 stranding: the blob is now only under _cow/. const moved = await strandCasBlobsIntoCow(dir) expect(moved).toBeGreaterThan(0) // 3. Reopen: the on-open auto-adoption heals it with no operator action. brain = new Brainy({ storage: { type: 'filesystem', path: dir }, plugins: [], silent: true }) await brain.init() const healed = await brain.vfs.readFile('/pages/homepage/page.json') expect(healed.toString()).toContain('Homepage') // A subsequent open records the adoption marker and does not re-scan-heal. await brain.close() brain = new Brainy({ storage: { type: 'filesystem', path: dir }, plugins: [], silent: true }) await brain.init() expect((await brain.vfs.readFile('/pages/homepage/page.json')).toString()).toContain('Homepage') await brain.close() }) it('brain.vfs.adoptOrphanedBlobs() is the explicit recovery equivalent', async () => { const dir = mkTmp() const body = JSON.stringify({ page: 'about', body: 'x'.repeat(400) }) let brain: any = new Brainy({ storage: { type: 'filesystem', path: dir }, plugins: [], silent: true }) await brain.init() await brain.vfs.writeFile('/about.json', body) await brain.close() await strandCasBlobsIntoCow(dir) // The explicit API self-initializes (like other VFS methods); init's // auto-heal adopts, so the explicit call sees the pair present. Either way // it reports the _cow/ blobs it accounted for, and the read is healed. brain = new Brainy({ storage: { type: 'filesystem', path: dir }, plugins: [], silent: true }) const r = await brain.vfs.adoptOrphanedBlobs() expect(r.cowBlobs).toBeGreaterThan(0) expect(r.adopted + r.alreadyPresent).toBe(r.cowBlobs) expect((await brain.vfs.readFile('/about.json')).toString()).toContain('about') await brain.close() }) })