open-brainy/tests/unit/db/generationStore-commit-guard.test.ts
David Snelling a79db434ac
Some checks are pending
CI / Node 22 (push) Waiting to run
CI / Node 24 (push) Waiting to run
CI / Integration + conformance (Node 22) (push) Waiting to run
CI / Bun (latest) (push) Waiting to run
Delta Gate / Delta gate — candidate vs control (push) Waiting to run
fix(generation-store): commitTransaction refuses while single-ops are pending — the order invariant is enforced, not assumed
reservedGensAsc() documented but never enforced that pending single-op
generations must sort above every committed one. A direct
commitTransaction() call bypassing Brainy.transact()'s flush-first step
could commit a fresh generation into committedRanges above lower,
still-pending ones, unsorting the committed-then-pending walk
resolveManyAt relies on and returning a wrong before-image for a
point-in-time read — silently. commitTransaction() now refuses via a new
PendingSingleOpsUnflushedError when the pending tier is non-empty, before
any staging I/O. Behavior-neutral: both sanctioned callers
(Brainy.transact(), Brainy.compactHistory()) already flush first.
2026-09-02 10:55:40 -07:00

254 lines
11 KiB
TypeScript

/**
* @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<string, unknown> {
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<number> {
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()
})
})