/** * @module tests/unit/db/generationStore-commit-guard * @description Pins the commit-order guard on * `GenerationStore.commitTransaction()` (`src/db/generationStore.ts`). * * `reservedGensAsc()`'s own doc comment states an invariant it never * enforced: pending single-op generations are always greater than every * committed one, because the store's only two sanctioned callers — * `Brainy.transact()` and `Brainy.compactHistory()` — flush the pending tier * before committing. Nothing stopped a caller from invoking * `commitTransaction()` directly while single-ops were still buffered: the * fresh commit would land in `committedRanges` ABOVE those lower, * still-pending generations, so the committed-then-pending concatenation * `reservedGensAsc()` yields is no longer ascending — and `resolveManyAt` * (which walks committed ranges before pending ones) would silently report a * WRONG before-image for a point-in-time read. `commitTransaction()` now * refuses loudly (`PendingSingleOpsUnflushedError`) instead of assuming. * * Four pins: * 1. A direct `commitTransaction()` call while single-ops are pending throws * and commits NOTHING. * 2. The same commit succeeds once the pending tier is flushed first. * 3. `Brainy.transact()` — which already flushes first — is unaffected * (mirrors `tests/unit/db/generation-chain.test.ts`'s `seedX()`/`bumpX()` * transact pin: add, then transact-update, generation advances by one * each time, the update lands). * 4. `reservedGensAsc()` stays ascending across a real add+transact+delete * workload — proven by point-in-time reads (`asOf`) staying correct * throughout, which is exactly what an ordering break would corrupt. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { MemoryStorage } from '../../../src/storage/adapters/memoryStorage.js' import { GenerationStore, GENERATIONS_PREFIX, MANIFEST_PATH } from '../../../src/db/generationStore.js' import { PendingSingleOpsUnflushedError } from '../../../src/db/errors.js' import { Brainy } from '../../../src/index.js' import { NounType } from '../../../src/types/graphTypes.js' import { createTestConfig, generateTestVector } from '../../helpers/test-factory.js' /** Precomputed embedding so Brainy-level adds skip the (slow) embedding model — * these tests exercise the generation layer, not semantics. */ const VEC = generateTestVector() // Entity ids must be UUID-shaped (the sharded storage layout derives the // shard from the UUID hex) — same fixture convention as generationStore.test.ts. const ID_A = '00000000-0000-4000-8000-0000000000aa' const ID_B = '00000000-0000-4000-8000-0000000000bb' /** Stored-metadata fixture in the canonical shape the live write paths use * (matches generationStore.test.ts's fixture exactly). */ function metadataFixture(version: number): Record { return { noun: NounType.Document, subtype: 'note', data: `payload-v${version}`, version, createdAt: 1000, updatedAt: 1000 + version, _rev: version } } describe('db/GenerationStore — commitTransaction pending-tier guard (store level)', () => { let storage: MemoryStorage let store: GenerationStore beforeEach(async () => { storage = new MemoryStorage() await storage.init() store = new GenerationStore(storage) await store.open() }) /** Buffer one single-op generation via commitSingleOp WITHOUT flushing — * the pending tier that must be drained before commitTransaction(). */ async function pendingSingleOp(id: string, version: number): Promise { const { generation } = await store.commitSingleOp({ touched: { nouns: [id] }, execute: async () => { await storage.saveNounMetadata(id, metadataFixture(version)) } }) return generation } /** A direct transact commit — exactly what a caller bypassing * Brainy.transact()'s flush-first step would issue. */ function directCommit(id: string, version: number): Promise<{ generation: number; timestamp: number }> { return store.commitTransaction({ touched: { nouns: [id], verbs: [] }, execute: async () => { await storage.saveNounMetadata(id, metadataFixture(version)) } }) } it('PIN 1: refuses a direct commitTransaction() while single-ops are pending, and commits NOTHING', async () => { const g1 = await pendingSingleOp(ID_A, 1) expect(g1).toBe(1) expect(store.committedGeneration()).toBe(0) // nothing flushed to disk yet let caught: unknown try { await directCommit(ID_B, 1) expect.unreachable('should have thrown PendingSingleOpsUnflushedError') } catch (err) { caught = err } expect(caught).toBeInstanceOf(PendingSingleOpsUnflushedError) expect((caught as PendingSingleOpsUnflushedError).pendingCount).toBe(1) // Nothing committed: the head + committed ranges are unchanged, and the // counter never advanced for the refused attempt (the guard fires before // a generation is even reserved). expect(store.committedGeneration()).toBe(0) expect(store.generation()).toBe(1) // still just the pending single-op's gen expect(await storage.readRawObject(MANIFEST_PATH)).toBeNull() // The guard fires BEFORE a generation is reserved (`gen = ++this.counter` // never runs), so the refused attempt's would-be directory (generation 2, // the next number after the pending single-op's 1) was never created. expect(await storage.listRawObjects(`${GENERATIONS_PREFIX}/2`)).toEqual([]) // The refused write never touched canonical storage. expect((await storage.readNounRaw(ID_B)).metadata).toBeNull() // The pending tier itself is untouched by the refused attempt — flushing // now still commits the ORIGINAL single-op cleanly. await store.flushPendingSingleOps() expect(store.committedGeneration()).toBe(1) const atG0 = await store.resolveAt('noun', ID_A, 0) expect(atG0).toEqual({ source: 'absent' }) // the create sentinel before g1's write }) it('PIN 2: the same commit succeeds once the pending tier is flushed first', async () => { await pendingSingleOp(ID_A, 1) await expect(directCommit(ID_B, 1)).rejects.toBeInstanceOf(PendingSingleOpsUnflushedError) await store.flushPendingSingleOps() expect(store.committedGeneration()).toBe(1) const { generation } = await directCommit(ID_B, 1) expect(generation).toBe(2) expect(store.committedGeneration()).toBe(2) expect((await storage.readNounRaw(ID_B)).metadata).toMatchObject({ version: 1 }) }) }) describe('Brainy public API — commitTransaction pending-tier guard is behavior-neutral', () => { let brain: Brainy beforeEach(async () => { brain = new Brainy(createTestConfig()) await brain.init() }) afterEach(async () => { await brain.close() }) it('PIN 3: Brainy.transact() still commits normally over pending single-ops (mirrors generation-chain.test.ts\'s seedX()/bumpX() transact pin)', async () => { const store = (brain as any).generationStore as GenerationStore // Relative, not absolute: under the adopt-at-open default the open-time // baseline backfill takes a generation of its own (see // bounded-chains.test.ts's identical note), so the first user add is not // necessarily generation 1. const baseGen = brain.generation() const baseCommitted = store.committedGeneration() const id = await brain.add({ data: 'x', type: NounType.Document, subtype: 'note', metadata: { v: 1 }, vector: VEC }) // The add is a pending single-op generation — NOT yet flushed. expect(brain.generation()).toBe(baseGen + 1) expect(store.committedGeneration()).toBe(baseCommitted) // Brainy.transact() flushes the pending tier FIRST (src/brainy.ts: // `await this.generationStore.flushPendingSingleOps()`, immediately // before its `generationStore.commitTransaction()` call), so the guard // never fires on this path — same shape as generation-chain.test.ts's // seedX() (add) → bumpX() (transact update) → generation advances by one. const db = await brain.transact([{ op: 'update', id, metadata: { v: 2 } }]) await db.release() expect(brain.generation()).toBe(baseGen + 2) expect(store.committedGeneration()).toBe(baseGen + 2) // the flushed add + the transact update const entity = (await brain.get(id)) as any expect(entity.metadata.v).toBe(2) }) it('PIN 4: reservedGensAsc() stays ascending across a real add+transact+delete workload — point-in-time reads stay correct', async () => { const store = (brain as any).generationStore as GenerationStore const baseGen = brain.generation() const baseCommitted = store.committedGeneration() const idX = await brain.add({ data: 'x', type: NounType.Document, subtype: 'note', metadata: { v: 1 }, vector: VEC }) expect(brain.generation()).toBe(baseGen + 1) // pending (un-flushed) const idY = await brain.add({ data: 'y', type: NounType.Document, subtype: 'note', metadata: { v: 1 }, vector: VEC }) // Pin right after BOTH adds — before the transact update — so X reads v1 // and Y still exists at this pin, unlike the live head after the rest of // the workload runs. const pinAfterBothAdds = brain.generation() expect(pinAfterBothAdds).toBe(baseGen + 2) // ALSO pending — two un-flushed single-ops expect(store.committedGeneration()).toBe(baseCommitted) // A transact() flushes baseGen+1 and baseGen+2 first, then commits its // own update as baseGen+3. If committed-vs-pending ordering ever broke, // this is exactly the step that would land a commit ABOVE still-pending // generations. const db = await brain.transact([{ op: 'update', id: idX, metadata: { v: 3 } }]) await db.release() expect(brain.generation()).toBe(baseGen + 3) expect(store.committedGeneration()).toBe(baseGen + 3) // A single-op delete, pending again (un-flushed). await brain.remove(idY) expect(brain.generation()).toBe(baseGen + 4) // A point-in-time read pinned right after the two adds (before the // transact update) must see X's PRE-update value and Y still present. // This is precisely what resolveManyAt/resolveAt get WRONG if committed // and pending generations were ever interleaved out of ascending order. const past = await brain.asOf(pinAfterBothAdds) const xAtPin = (await past.get(idX)) as any expect(xAtPin?.metadata?.v).toBe(1) const yAtPin = (await past.get(idY)) as any expect(yAtPin?.metadata?.v).toBe(1) // not yet removed, as of this pin await past.release() // Live state reflects every later write, in the right order. const xNow = (await brain.get(idX)) as any expect(xNow.metadata.v).toBe(3) expect(await brain.get(idY)).toBeNull() }) })