/** * @module tests/integration/rollback-trapdoor * @description Regression for a consumer-reported data-integrity bug: a failed * CANONICAL rollback undo left the record durable while the request errored (a * post-commit response lie). Reproduced by a failure-injection coherence harness: * a late index op throws → rollback runs → an earlier op's canonical undo * (storage.deleteNounMetadata) fails persistently → the record stays on disk * while Transaction.rollback threw TransactionRollbackError AND lied with * state='rolled_back'. * * Fix (David's ruling — adopt-forward when safe, else fail loud): * - SINGLE-op add whose only damage is a durably-present orphan → adopt it * forward: commit the generation (the record IS what add() wanted), return * success + a loud warn; the derived index heals on rebuild/repairIndex. * - MULTI-op batch, OR any restorative LOSS → StoreInconsistentError naming the * unreconciled ids; the brain enters write-quarantine (reads OK, writes * refused) until repairIndex() reconciles and lifts it. Transaction state is * 'inconsistent', never the 'rolled_back' lie. * * The injection mirrors that harness: fault index.addToIndex (trigger rollback) and * storage.deleteNounMetadata (fail the canonical undo). */ 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' import { StoreInconsistentError } from '../../src/db/errors.js' let seq = 0 const freshId = (): string => `00000000-0000-4000-8000-${(++seq).toString(16).padStart(12, '0')}` describe('rollback trapdoor — failed canonical undo (data integrity)', () => { let dir: string let brain: any let restore: Array<() => void> beforeEach(async () => { process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true' dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-trapdoor-')) brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir }, dimensions: 384, silent: true }) await brain.init() restore = [] }) afterEach(async () => { for (const r of restore) r() try { await brain.close() } catch { /* ignore */ } fs.rmSync(dir, { recursive: true, force: true }) }) /** Fault the metadata-index add so a write's rollback is triggered. */ const faultIndexAdd = () => { const idx = brain['metadataIndex'] const orig = idx.addToIndex.bind(idx) idx.addToIndex = async () => { throw new Error('injected: index add failed') } restore.push(() => { idx.addToIndex = orig }) } /** Fault the canonical additive undo (delete of a just-created record). */ const faultCanonicalDelete = () => { const storage = brain['storage'] const orig = storage.deleteNounMetadata.bind(storage) storage.deleteNounMetadata = async () => { throw new Error('injected: canonical undo failed') } restore.push(() => { storage.deleteNounMetadata = orig }) } it('ADOPT-FORWARD: a single-op add whose canonical undo fails commits as a durable, get-able record (no throw, no quarantine)', async () => { const id = freshId() const genBefore = brain.generationStore.generation() faultIndexAdd() // triggers rollback faultCanonicalDelete() // the trapdoor: the metadata record can't be un-written // add() must NOT throw — the record it wanted is durable, so it is adopted. const returned = await brain.add({ id, data: 'orphan adopted', type: NounType.Thing }) expect(returned).toBe(id) // Un-fault so reads/subsequent writes use the real methods. for (const r of restore) r() restore = [] // The record is durable and get-able, and the generation advanced. expect(await brain.get(id)).not.toBeNull() expect(brain.generationStore.generation()).toBe(genBefore + 1) // The store is NOT quarantined — a subsequent write succeeds. const ok = await brain.add({ id: freshId(), data: 'still writable', type: NounType.Thing }) expect(await brain.get(ok)).not.toBeNull() }) it('FAIL-LOUD: a multi-op transact whose canonical undo fails throws StoreInconsistentError and write-quarantines the brain', async () => { // A pre-existing record to prove reads keep working under quarantine. const anchor = await brain.add({ id: freshId(), data: 'anchor', type: NounType.Thing }) await brain.flush() faultIndexAdd() faultCanonicalDelete() const a = freshId() const b = freshId() await expect( brain.transact([ { op: 'add', id: a, data: 'batch A', type: NounType.Thing }, { op: 'add', id: b, data: 'batch B', type: NounType.Thing } ]) ).rejects.toBeInstanceOf(StoreInconsistentError) // Writes are refused (quarantine); reads still work. await expect( brain.add({ id: freshId(), data: 'blocked', type: NounType.Thing }) ).rejects.toThrow(/WRITE-QUARANTINED/) expect(await brain.get(anchor)).not.toBeNull() // repairIndex() reconciles and lifts the quarantine → writes resume. for (const r of restore) r() restore = [] await brain.repairIndex() const ok = await brain.add({ id: freshId(), data: 'writable again', type: NounType.Thing }) expect(await brain.get(ok)).not.toBeNull() }) it('the StoreInconsistentError names the unreconciled records with dispositions', async () => { faultIndexAdd() faultCanonicalDelete() const a = freshId() const b = freshId() let caught: unknown await brain .transact([ { op: 'add', id: a, data: 'A', type: NounType.Thing }, { op: 'add', id: b, data: 'B', type: NounType.Thing } ]) .catch((e: unknown) => (caught = e)) expect(caught).toBeInstanceOf(StoreInconsistentError) const err = caught as StoreInconsistentError expect(err.records.length).toBeGreaterThan(0) // Every unreconciled record here is a durably-present orphan. expect(err.records.every((r) => r.disposition === 'orphan')).toBe(true) expect(err.message).toMatch(/WRITE-QUARANTINED/) // Recover so afterEach can close cleanly. for (const r of restore) r() restore = [] await brain.repairIndex() }) })