feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)

One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.

Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
  transact() commit and once per single-operation write (storage hook), so
  brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
  batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
  (the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
  and forces an index rebuild; refcounted pins gate compactHistory(), which
  records a horizon (asOf below it throws GenerationCompactedError).

Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
  overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
  generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
  (GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
  {confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
  generation; index-accelerated queries at historical generations throw
  NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
  plugin.ts: feature-detected, balanced pin/release in lockstep with Db
  lifecycle, post-commit applier + replay-gap model documented.

Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.

Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
This commit is contained in:
David Snelling 2026-06-10 14:14:07 -07:00
parent 49e49483d1
commit 431cd64406
16 changed files with 6244 additions and 5 deletions

View file

@ -0,0 +1,955 @@
/**
* @module tests/integration/db-mvcc
* @description Credibility-bar proof suite for Brainy 8.0's generational MVCC
* storage core + Datomic-style Db API. Each test PROVES a stated guarantee
* (not merely exercises the API):
*
* 1. Isolation a pinned Db reads its pinned state exactly, across 200
* subsequent transact mutations (updates + removes).
* 2. Atomicity a batch with a failing later operation applies NOTHING
* (generation unchanged, no partial records), for both plan-time and
* execution-phase failures (the latter via real fault injection into the
* storage adapter, exercising the production rollback path).
* 3. CAS `ifAtGeneration` commits when fresh, throws
* `GenerationConflictError` with correct expected/actual when stale.
* 4. Snapshot integrity persist mutate source heavily `Brainy.load()`
* is byte-identical to the pre-mutation state (hard-link safety), and an
* in-memory brain persists to a loadable directory.
* 5. Compaction safety pinned reads stay correct across `compactHistory()`;
* after release + compact the superseded record-sets are reclaimed.
* 6. with() speculative overlays are visible on the overlay Db only; the
* underlying brain and the original Db are untouched.
* 7. Generation monotonicity across close/reopen.
* 8. Crash consistency a simulated crash between record staging and the
* manifest rename (the commit point) recovers to the exact pre-transaction
* state on reopen, through the REAL production recovery path.
* 9. Versioned provider wiring a provider implementing the 4-method
* `VersionedIndexProvider` contract receives balanced pin/release (and a
* visibility consult per pin) as Db values are created and released.
*
* All entities carry explicit vectors so the suite never invokes the
* embedding engine (semantic search is exercised only for its documented
* historical-generation error).
*/
import { describe, it, expect, afterEach, vi } 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 { Db } from '../../src/db/db.js'
import {
GenerationCompactedError,
GenerationConflictError,
NotYetSupportedAtHistoricalGenerationError
} from '../../src/db/errors.js'
import type { GenerationStore } from '../../src/db/generationStore.js'
import { GraphAdjacencyIndex } from '../../src/graph/graphAdjacencyIndex.js'
import { RevisionConflictError } from '../../src/transaction/RevisionConflictError.js'
import type { StorageAdapter } from '../../src/coreTypes.js'
import { NounType, VerbType } from '../../src/types/graphTypes.js'
/** Deterministic 384-dim vector so no test ever invokes the embedder. */
function vec(seed: number): number[] {
return Array.from({ length: 384 }, (_, i) => ((seed * 31 + i * 7) % 100) / 100)
}
/**
* Map a readable label to a deterministic UUID-shaped id (entity ids must be
* UUIDs the sharded storage layout derives the shard from the UUID hex).
* Two independent FNV-style mixes keep 7+ distinct labels collision-free.
*/
function uid(label: string): string {
let h1 = 0x811c9dc5
for (let i = 0; i < label.length; i++) {
h1 = Math.imul(h1 ^ label.charCodeAt(i), 0x01000193) >>> 0
}
let h2 = 0xdeadbeef
for (let i = label.length - 1; i >= 0; i--) {
h2 = Math.imul(h2 ^ label.charCodeAt(i), 0x85ebca6b) >>> 0
}
const hex = h1.toString(16).padStart(8, '0') + h2.toString(16).padStart(8, '0')
return `00000000-0000-4000-8000-${hex.slice(0, 12)}`
}
/** Typed access to the brain's private generation store (test injection point). */
function generationStoreOf(brain: Brainy): GenerationStore {
return (brain as unknown as { generationStore: GenerationStore }).generationStore
}
describe('8.0 Db API — generational MVCC', () => {
const brains: Brainy[] = []
const tempDirs: string[] = []
/** Track a brain for afterEach teardown. */
async function openMemoryBrain(): Promise<Brainy> {
const brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } })
await brain.init()
brains.push(brain)
return brain
}
/** Open (and track) a filesystem brain rooted at a fresh temp directory. */
async function openFsBrain(dir?: string): Promise<{ brain: Brainy; dir: string }> {
const rootDirectory = dir ?? makeTempDir()
const brain = new Brainy({
requireSubtype: false,
storage: { type: 'filesystem', rootDirectory }
})
await brain.init()
brains.push(brain)
return { brain, dir: rootDirectory }
}
function makeTempDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-db-mvcc-'))
tempDirs.push(dir)
return dir
}
afterEach(async () => {
for (const brain of brains.splice(0)) {
try {
await brain.close()
} catch {
// already closed by the test
}
}
for (const dir of tempDirs.splice(0)) {
await fs.promises.rm(dir, { recursive: true, force: true })
}
})
// ==========================================================================
// 1. Isolation
// ==========================================================================
it('proof 1 — a pinned Db reads its pinned state exactly across 200 transact mutations', async () => {
const brain = await openMemoryBrain()
const ids = Array.from({ length: 20 }, (_, i) => uid(`iso-e${i}`))
await brain.transact(
ids.map((id, i) => ({
op: 'add' as const,
id,
type: NounType.Document,
data: `doc-${i}`,
vector: vec(i),
metadata: { v: 0, slot: i }
}))
)
const db = brain.now()
const pinned = new Map<string, unknown>()
for (const id of ids) {
pinned.set(id, await db.get(id))
expect(pinned.get(id)).not.toBeNull()
}
const pinnedFind = await db.find({ type: NounType.Document, limit: 100 })
expect(pinnedFind.length).toBe(20)
// 200 mutations: 198 updates over the first 18 ids + 2 removes.
for (let i = 0; i < 200; i++) {
if (i === 100) {
await brain.transact([{ op: 'remove', id: uid('iso-e18') }])
} else if (i === 150) {
await brain.transact([{ op: 'remove', id: uid('iso-e19') }])
} else {
await brain.transact([
{ op: 'update', id: ids[i % 18], metadata: { v: i + 1, touchedAt: i } }
])
}
}
// Live state moved…
const liveUpdated = await brain.get(ids[0])
expect((liveUpdated?.metadata as { v: number }).v).toBeGreaterThan(0)
expect(await brain.get(uid('iso-e18'))).toBeNull()
expect(await brain.get(uid('iso-e19'))).toBeNull()
// …but EVERY read through the pinned Db is exactly the pinned state,
// including the two entities removed after the pin.
for (const id of ids) {
expect(await db.get(id)).toEqual(pinned.get(id))
}
// Set-level isolation: metadata find() at the pin still sees all 20.
const histFind = await db.find({ type: NounType.Document, limit: 100 })
expect(histFind.length).toBe(20)
for (const row of histFind) {
expect((row.metadata as { v: number }).v).toBe(0)
}
await db.release()
})
// ==========================================================================
// 2. Atomicity
// ==========================================================================
it('proof 2a — a plan-time failure in a later op applies nothing', async () => {
const brain = await openMemoryBrain()
const g0 = brain.generation()
await expect(
brain.transact([
{
op: 'add',
id: uid('atomic-a'),
type: NounType.Document,
data: 'a',
vector: vec(1),
metadata: { ok: true }
},
{ op: 'update', id: uid('never-created'), metadata: { boom: true } }
])
).rejects.toThrow(`Entity ${uid('never-created')} not found`)
expect(brain.generation()).toBe(g0)
expect(await brain.get(uid('atomic-a'))).toBeNull()
expect((await brain.find({ type: NounType.Document, limit: 10 })).length).toBe(0)
})
it('proof 2b — an execution-phase failure rolls back every applied op (generation unchanged, zero partial records)', async () => {
const brain = await openMemoryBrain()
await brain.transact([
{
op: 'add',
id: uid('atomic-base'),
type: NounType.Document,
data: 'base',
vector: vec(0),
metadata: { base: true }
}
])
const g0 = brain.generation()
const storage = brain.storageAdapter
// Real fault injection: the verb-metadata write of the LAST operation
// fails once, after the earlier add operations have already executed —
// exercising the production TransactionManager rollback path.
const spy = vi
.spyOn(storage, 'saveVerbMetadata')
.mockImplementationOnce(async () => {
throw new Error('disk full (injected)')
})
await expect(
brain.transact([
{
op: 'add',
id: uid('atomic-b'),
type: NounType.Person,
data: 'b',
vector: vec(2),
metadata: { name: 'b' }
},
{
op: 'relate',
from: uid('atomic-b'),
to: uid('atomic-base'),
type: VerbType.RelatedTo
}
])
).rejects.toThrow('disk full (injected)')
spy.mockRestore()
// ZERO ops applied: generation unchanged, the add rolled back, no edge.
expect(brain.generation()).toBe(g0)
expect(await brain.get(uid('atomic-b'))).toBeNull()
expect((await brain.getRelations({ from: uid('atomic-b') })).length).toBe(0)
expect((await brain.find({ type: NounType.Person, limit: 10 })).length).toBe(0)
// No partial generation records: the staging directory was removed.
const records = await storage.listRawObjects(`_generations/${g0 + 1}`)
expect(records).toEqual([])
// The store remains fully writable and consistent after the rollback.
const db = await brain.transact([
{
op: 'add',
id: uid('atomic-c'),
type: NounType.Person,
data: 'c',
vector: vec(3),
metadata: { name: 'c' }
}
])
expect(db.generation).toBe(g0 + 1)
await db.release()
})
it('proof 2c — an ifRev conflict on a later update rejects the whole batch', async () => {
const brain = await openMemoryBrain()
await brain.transact([
{
op: 'add',
id: uid('rev-e'),
type: NounType.Document,
data: 'rev',
vector: vec(4),
metadata: { v: 1 }
}
])
await brain.update({ id: uid('rev-e'), metadata: { v: 2 } }) // _rev is now 2
const g0 = brain.generation()
await expect(
brain.transact([
{
op: 'add',
id: uid('rev-new'),
type: NounType.Document,
data: 'new',
vector: vec(5),
metadata: {}
},
{ op: 'update', id: uid('rev-e'), metadata: { v: 3 }, ifRev: 1 } // stale
])
).rejects.toThrow(RevisionConflictError)
expect(brain.generation()).toBe(g0)
expect(await brain.get(uid('rev-new'))).toBeNull()
expect(((await brain.get(uid('rev-e')))?.metadata as { v: number }).v).toBe(2)
})
// ==========================================================================
// 3. CAS (ifAtGeneration)
// ==========================================================================
it('proof 3 — ifAtGeneration commits when fresh and conflicts when stale', async () => {
const brain = await openMemoryBrain()
const db = brain.now()
const committed = await brain.transact(
[
{
op: 'add',
id: uid('cas-e'),
type: NounType.Document,
data: 'cas',
vector: vec(6),
metadata: { n: 1 }
}
],
{ ifAtGeneration: db.generation }
)
expect(committed.generation).toBe(db.generation + 1)
try {
await brain.transact(
[{ op: 'update', id: uid('cas-e'), metadata: { n: 2 } }],
{ ifAtGeneration: db.generation } // stale — committed moved past it
)
expect.unreachable('should have thrown GenerationConflictError')
} catch (err) {
expect(err).toBeInstanceOf(GenerationConflictError)
expect((err as GenerationConflictError).expected).toBe(db.generation)
expect((err as GenerationConflictError).actual).toBe(committed.generation)
}
// Nothing committed by the conflicting attempt.
expect(((await brain.get(uid('cas-e')))?.metadata as { n: number }).n).toBe(1)
expect(brain.generation()).toBe(committed.generation)
await db.release()
await committed.release()
})
// ==========================================================================
// 4. Snapshot integrity (persist → mutate → load)
// ==========================================================================
it('proof 4a — a persisted snapshot is immune to heavy source mutation (hard-link safety)', async () => {
const { brain } = await openFsBrain()
const ids = Array.from({ length: 10 }, (_, i) => uid(`snap-e${i}`))
await brain.transact(
ids.map((id, i) => ({
op: 'add' as const,
id,
type: NounType.Document,
data: `snapshot-doc-${i}`,
vector: vec(i + 10),
metadata: { generation: 'pre', slot: i }
}))
)
const relDb = await brain.transact([
{ op: 'relate', from: ids[0], to: ids[1], type: VerbType.References },
{ op: 'relate', from: ids[1], to: ids[2], type: VerbType.References }
])
await relDb.release()
// Capture pre-mutation state, then snapshot.
const preState = new Map<string, unknown>()
for (const id of ids) {
preState.set(id, await brain.get(id))
}
const preRelations = await brain.getRelations({ from: ids[0] })
expect(preRelations.length).toBe(1)
const snapDir = path.join(makeTempDir(), 'snapshot')
const db = brain.now()
await db.persist(snapDir)
await db.release()
// Mutate the source heavily: rewrite every entity, delete some, add new.
for (const [i, id] of ids.entries()) {
await brain.transact([
{ op: 'update', id, data: `mutated-${i}`, metadata: { generation: 'post' } }
])
}
await brain.transact([{ op: 'remove', id: ids[0] }])
await brain.transact([
{
op: 'add',
id: uid('snap-after'),
type: NounType.Document,
data: 'added after snapshot',
vector: vec(99),
metadata: { generation: 'post' }
}
])
await brain.flush()
// The snapshot opens as a self-contained store with the PRE state.
const loaded = await Brainy.load(snapDir)
for (const id of ids) {
const entity = await loaded.get(id)
expect(entity).toEqual(preState.get(id))
}
expect(await loaded.get(uid('snap-after'))).toBeNull()
const loadedFind = await loaded.find({ type: NounType.Document, limit: 100 })
expect(loadedFind.length).toBe(10)
for (const row of loadedFind) {
expect((row.metadata as { generation: string }).generation).toBe('pre')
}
const loadedRelations = await loaded.related({ from: ids[0] })
expect(loadedRelations.length).toBe(1)
expect(loadedRelations[0].to).toBe(ids[1])
await loaded.release()
// And asOf(path) is the instance-method route to the same snapshot.
const viaAsOf = await brain.asOf(snapDir)
expect(((await viaAsOf.get(ids[3])) as { metadata: { generation: string } }).metadata.generation).toBe('pre')
await viaAsOf.release()
})
it('proof 4b — an in-memory brain persists to a directory loadable as a real store', async () => {
const brain = await openMemoryBrain()
await brain.transact([
{
op: 'add',
id: uid('mem-e1'),
type: NounType.Document,
data: 'memory-persisted',
vector: vec(21),
metadata: { from: 'memory' }
}
])
const snapDir = path.join(makeTempDir(), 'memory-snapshot')
const db = brain.now()
await db.persist(snapDir)
await db.release()
const loaded = await Brainy.load(snapDir)
const entity = await loaded.get(uid('mem-e1'))
expect(entity).not.toBeNull()
expect((entity?.metadata as { from: string }).from).toBe('memory')
await loaded.release()
})
it('proof 4c — persist() refuses a view that history has moved past (honest boundary)', async () => {
const brain = await openMemoryBrain()
const db = brain.now()
await brain.transact([
{
op: 'add',
id: uid('stale-persist'),
type: NounType.Document,
data: 'x',
vector: vec(30),
metadata: {}
}
])
const snapDir = path.join(makeTempDir(), 'stale-snapshot')
await expect(db.persist(snapDir)).rejects.toThrow(
NotYetSupportedAtHistoricalGenerationError
)
await db.release()
})
// ==========================================================================
// 5. Compaction safety
// ==========================================================================
it('proof 5 — compaction never breaks pinned reads; after release the records are reclaimed', async () => {
const { brain } = await openFsBrain()
const storage = brain.storageAdapter
// Every transact() returns a PINNED Db — release the ones this test
// does not keep, so compaction eligibility is determined solely by `db`.
await (
await brain.transact([
{
op: 'add',
id: uid('compact-e'),
type: NounType.Document,
data: 'v1',
vector: vec(40),
metadata: { v: 1 }
}
])
).release()
await (
await brain.transact([{ op: 'update', id: uid('compact-e'), metadata: { v: 2 } }])
).release()
const db = brain.now() // pinned at v2
const pinnedEntity = await db.get(uid('compact-e'))
await (
await brain.transact([{ op: 'update', id: uid('compact-e'), metadata: { v: 3 } }])
).release()
await (
await brain.transact([{ op: 'update', id: uid('compact-e'), metadata: { v: 4 } }])
).release()
const recordsBefore = (await storage.listRawObjects('_generations')).length
expect(recordsBefore).toBeGreaterThan(0)
// Compact while pinned: record-sets above the pin survive, pinned reads stay correct.
const first = await brain.compactHistory()
expect(await db.get(uid('compact-e'))).toEqual(pinnedEntity)
expect(((await db.get(uid('compact-e')))?.metadata as { v: number }).v).toBe(2)
// After release, everything is reclaimable.
await db.release()
const second = await brain.compactHistory()
expect(first.removedGenerations + second.removedGenerations).toBeGreaterThan(0)
const recordsAfter = (await storage.listRawObjects('_generations')).length
expect(recordsAfter).toBeLessThan(recordsBefore)
expect(recordsAfter).toBe(0)
// Compacted generations are honestly unreachable.
await expect(brain.asOf(1)).rejects.toThrow(GenerationCompactedError)
})
// ==========================================================================
// 6. with() — speculative overlays
// ==========================================================================
it('proof 6 — with() overlays are visible only on the overlay Db; brain and base Db untouched', async () => {
const brain = await openMemoryBrain()
await brain.transact([
{
op: 'add',
id: uid('with-a'),
type: NounType.Person,
data: 'Ada',
vector: vec(50),
metadata: { v: 1 }
},
{
op: 'add',
id: uid('with-b'),
type: NounType.Person,
data: 'Grace',
vector: vec(51),
metadata: { v: 1 }
}
])
const g0 = brain.generation()
const db = brain.now()
const spec = await db.with([
{ op: 'update', id: uid('with-a'), metadata: { v: 2 } },
{
op: 'add',
id: uid('with-new'),
type: NounType.Project,
data: 'Apollo',
metadata: { speculative: true }
},
{ op: 'remove', id: uid('with-b') },
{ op: 'relate', from: uid('with-a'), to: uid('with-new'), type: VerbType.WorksOn }
])
// The overlay view sees everything…
expect(spec.speculative).toBe(true)
expect(((await spec.get(uid('with-a')))?.metadata as { v: number }).v).toBe(2)
expect(await spec.get(uid('with-new'))).not.toBeNull()
expect(await spec.get(uid('with-b'))).toBeNull()
const specEdges = await spec.related({ from: uid('with-a') })
expect(specEdges.length).toBe(1)
expect(specEdges[0].to).toBe(uid('with-new'))
const specFind = await spec.find({ type: NounType.Project, limit: 10 })
expect(specFind.length).toBe(1)
// …the base Db sees none of it…
expect(db.speculative).toBe(false)
expect(((await db.get(uid('with-a')))?.metadata as { v: number }).v).toBe(1)
expect(await db.get(uid('with-new'))).toBeNull()
expect(await db.get(uid('with-b'))).not.toBeNull()
// …and the brain (disk, indexes, generation counter) is untouched.
expect(brain.generation()).toBe(g0)
expect(((await brain.get(uid('with-a')))?.metadata as { v: number }).v).toBe(1)
expect(await brain.get(uid('with-new'))).toBeNull()
expect((await brain.getRelations({ from: uid('with-a') })).length).toBe(0)
await spec.release()
await db.release()
})
// ==========================================================================
// 7. Generation monotonicity across close/reopen
// ==========================================================================
it('proof 7 — the generation counter never moves backwards across close/reopen', async () => {
const dir = makeTempDir()
const { brain: first } = await openFsBrain(dir)
await first.transact([
{
op: 'add',
id: uid('mono-e'),
type: NounType.Document,
data: 'v1',
vector: vec(60),
metadata: { v: 1 }
}
])
await first.transact([{ op: 'update', id: uid('mono-e'), metadata: { v: 2 } }])
// Single-operation write — bumps the counter outside transact.
await first.update({ id: uid('mono-e'), metadata: { v: 3 } })
const beforeClose = first.generation()
expect(beforeClose).toBeGreaterThanOrEqual(3)
await first.close()
const { brain: second } = await openFsBrain(dir)
expect(second.generation()).toBeGreaterThanOrEqual(beforeClose)
const db = await second.transact([{ op: 'update', id: uid('mono-e'), metadata: { v: 4 } }])
expect(db.generation).toBeGreaterThan(beforeClose)
await db.release()
})
// ==========================================================================
// 8. Crash consistency
// ==========================================================================
it('proof 8 — a crash before the manifest rename recovers to the exact pre-transaction state', async () => {
const dir = makeTempDir()
const { brain: first } = await openFsBrain(dir)
await first.transact([
{
op: 'add',
id: uid('crash-e'),
type: NounType.Document,
data: 'stable',
vector: vec(70),
metadata: { v: 1 }
}
])
const committedGen = first.generation()
// Simulated crash at the worst point of the REAL commit protocol: every
// record is staged and the batch has executed, but the atomic manifest
// rename — the commit point — never happens. The throwing injector skips
// the abort cleanup exactly as a dead process would.
generationStoreOf(first).setCommitFaultInjector((phase) => {
if (phase === 'before-manifest-rename') {
throw new Error('simulated process crash')
}
})
await expect(
first.transact([
{ op: 'update', id: uid('crash-e'), metadata: { v: 2 } },
{
op: 'add',
id: uid('crash-new'),
type: NounType.Document,
data: 'uncommitted',
vector: vec(71),
metadata: {}
}
])
).rejects.toThrow('simulated process crash')
generationStoreOf(first).setCommitFaultInjector(undefined)
// The in-process state is dirty (batch executed) — close flushes it all,
// the realistic worst case for the recovery path.
await first.close()
// Reopen: recovery rolls the uncommitted generation back and rebuilds
// the indexes from the repaired records.
const { brain: second } = await openFsBrain(dir)
const recovered = await second.get(uid('crash-e'))
expect((recovered?.metadata as { v: number }).v).toBe(1)
expect(await second.get(uid('crash-new'))).toBeNull()
const found = await second.find({ type: NounType.Document, limit: 10 })
expect(found.length).toBe(1)
expect((found[0].metadata as { v: number }).v).toBe(1)
// The crashed generation number is never reissued.
expect(second.generation()).toBeGreaterThanOrEqual(committedGen + 1)
const db = await second.transact([{ op: 'update', id: uid('crash-e'), metadata: { v: 5 } }])
expect(db.generation).toBeGreaterThan(committedGen + 1)
await db.release()
})
// ==========================================================================
// 9. Versioned provider wiring
// ==========================================================================
it('proof 9 — a VersionedIndexProvider receives balanced pin/release as Db values live and die', async () => {
const calls = {
pins: [] as bigint[],
releases: [] as bigint[],
visibility: [] as bigint[]
}
const brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } })
brain.use({
name: 'test-versioned-graph-index',
activate: async (ctx) => {
ctx.registerProvider('graphIndex', (storage: StorageAdapter) =>
Object.assign(new GraphAdjacencyIndex(storage), {
generation: () => 0n,
isGenerationVisible: (g: bigint) => {
calls.visibility.push(g)
return true
},
pin: (g: bigint) => {
calls.pins.push(g)
},
release: (g: bigint) => {
calls.releases.push(g)
}
})
)
return true
}
})
await brain.init()
brains.push(brain)
const base = brain.generation()
const db1 = brain.now()
expect(calls.pins).toEqual([BigInt(base)])
expect(calls.visibility).toEqual([BigInt(base)]) // consulted at pin time
const db2 = await brain.transact([
{
op: 'add',
id: uid('vp-e'),
type: NounType.Document,
data: 'pinned',
vector: vec(80),
metadata: {}
}
])
expect(calls.pins).toEqual([BigInt(base), BigInt(base + 1)])
const db3 = await brain.asOf(base)
expect(calls.pins).toEqual([BigInt(base), BigInt(base + 1), BigInt(base)])
// A with() overlay takes its own pin on the same generation.
const spec = await db2.with([{ op: 'update', id: uid('vp-e'), metadata: { spec: true } }])
expect(calls.pins.length).toBe(4)
await db1.release()
await db2.release()
await db3.release()
await spec.release()
await spec.release() // idempotent — must NOT double-release the pin
expect(calls.releases.length).toBe(calls.pins.length)
expect([...calls.releases].sort()).toEqual([...calls.pins].sort())
})
// ==========================================================================
// Supporting guarantees: asOf, since, receipts, tx-log, honest errors
// ==========================================================================
it('asOf(generation) and asOf(Date) resolve historical state; future/negative are rejected', async () => {
const brain = await openMemoryBrain()
const tx1 = await brain.transact(
[
{
op: 'add',
id: uid('asof-e'),
type: NounType.Document,
data: 'v1',
vector: vec(90),
metadata: { v: 1 }
}
],
{ meta: { reason: 'first commit' } }
)
await new Promise((resolve) => setTimeout(resolve, 5))
const betweenCommits = new Date()
await new Promise((resolve) => setTimeout(resolve, 5))
await brain.transact([{ op: 'update', id: uid('asof-e'), metadata: { v: 2 } }])
// By generation.
const atFirst = await brain.asOf(tx1.generation)
expect(((await atFirst.get(uid('asof-e')))?.metadata as { v: number }).v).toBe(1)
expect(atFirst.timestamp).toBe(tx1.timestamp)
await atFirst.release()
// By timestamp: resolves to the newest commit at-or-before the instant.
const atInstant = await brain.asOf(betweenCommits)
expect(atInstant.generation).toBe(tx1.generation)
expect(((await atInstant.get(uid('asof-e')))?.metadata as { v: number }).v).toBe(1)
await atInstant.release()
// Honest range errors.
await expect(brain.asOf(brain.generation() + 100)).rejects.toThrow(RangeError)
await expect(brain.asOf(-1)).rejects.toThrow(RangeError)
await expect(brain.asOf('/no/such/snapshot/dir')).rejects.toThrow('not a snapshot directory')
// The transact meta was reified into the tx-log.
const lines = await brain.storageAdapter.readTxLogLines()
const entries = lines.map((line) => JSON.parse(line))
const first = entries.find((entry) => entry.generation === tx1.generation)
expect(first?.meta).toEqual({ reason: 'first commit' })
await tx1.release()
})
it('receipts resolve ids in input order, including intra-batch references and relate dedupe', async () => {
const brain = await openMemoryBrain()
const db = await brain.transact([
{ op: 'add', id: uid('rcpt-aa'), type: NounType.Person, data: 'a', vector: vec(91), metadata: {} },
{ op: 'add', id: uid('rcpt-bb'), type: NounType.Person, data: 'b', vector: vec(92), metadata: {} },
{ op: 'relate', from: uid('rcpt-aa'), to: uid('rcpt-bb'), type: VerbType.Knows },
{ op: 'update', id: uid('rcpt-bb'), metadata: { seen: true } }
])
expect(db.receipt).toBeDefined()
expect(db.receipt!.generation).toBe(db.generation)
expect(db.receipt!.ids.length).toBe(4)
expect(db.receipt!.ids[0]).toBe(uid('rcpt-aa'))
expect(db.receipt!.ids[1]).toBe(uid('rcpt-bb'))
expect(db.receipt!.ids[3]).toBe(uid('rcpt-bb'))
const relationId = db.receipt!.ids[2]
expect((await brain.getRelations({ from: uid('rcpt-aa') }))[0].id).toBe(relationId)
// Duplicate relate (same from/to/type) dedupes to the SAME id — both
// within a batch and against committed state, mirroring relate().
const dup = await brain.transact([
{ op: 'relate', from: uid('rcpt-aa'), to: uid('rcpt-bb'), type: VerbType.Knows }
])
expect(dup.receipt!.ids[0]).toBe(relationId)
expect((await brain.getRelations({ from: uid('rcpt-aa') })).length).toBe(1)
await db.release()
await dup.release()
})
it('since() reports the ids changed between two pinned views', async () => {
const brain = await openMemoryBrain()
await brain.transact([
{ op: 'add', id: uid('since-a'), type: NounType.Document, data: 'a', vector: vec(93), metadata: {} }
])
const before = brain.now()
await brain.transact([
{ op: 'add', id: uid('since-b'), type: NounType.Document, data: 'b', vector: vec(94), metadata: {} },
{ op: 'relate', from: uid('since-a'), to: uid('since-b'), type: VerbType.References }
])
await brain.transact([{ op: 'update', id: uid('since-a'), metadata: { touched: true } }])
const after = brain.now()
const changed = await after.since(before)
expect([...changed.nouns].sort()).toEqual([uid('since-a'), uid('since-b')].sort())
expect(changed.verbs.length).toBe(1)
expect(changed.fromGeneration).toBe(before.generation)
expect(changed.toGeneration).toBe(after.generation)
// Direction is enforced.
await expect(before.since(after)).rejects.toThrow(RangeError)
await before.release()
await after.release()
})
it('index-accelerated queries at a historical generation throw the documented error — never silently-wrong results', async () => {
const brain = await openMemoryBrain()
await brain.transact([
{ op: 'add', id: uid('hist-e'), type: NounType.Document, data: 'x', vector: vec(95), metadata: { v: 1 } }
])
const db = brain.now()
await brain.transact([{ op: 'update', id: uid('hist-e'), metadata: { v: 2 } }])
// Storage-record reads remain fully supported…
expect(((await db.get(uid('hist-e')))?.metadata as { v: number }).v).toBe(1)
expect((await db.find({ type: NounType.Document, where: { v: 1 } })).length).toBe(1)
// …index-accelerated dimensions throw the documented, named error.
await expect(db.search('anything')).rejects.toThrow(
NotYetSupportedAtHistoricalGenerationError
)
await expect(db.find({ vector: vec(95) })).rejects.toThrow(
NotYetSupportedAtHistoricalGenerationError
)
await expect(db.find({ connected: { from: uid('hist-e') } })).rejects.toThrow(
NotYetSupportedAtHistoricalGenerationError
)
// Released views refuse all reads.
await db.release()
await expect(db.get(uid('hist-e'))).rejects.toThrow('released Db')
})
it('restore() requires confirmation and replaces state from a snapshot (generation floor preserved)', async () => {
const { brain } = await openFsBrain()
await brain.transact([
{ op: 'add', id: uid('restore-e'), type: NounType.Document, data: 'keep', vector: vec(96), metadata: { v: 1 } }
])
const snapDir = path.join(makeTempDir(), 'restore-snapshot')
const db = brain.now()
await db.persist(snapDir)
await db.release()
await brain.transact([{ op: 'update', id: uid('restore-e'), metadata: { v: 2 } }])
await brain.transact([
{ op: 'add', id: uid('restore-extra'), type: NounType.Document, data: 'extra', vector: vec(97), metadata: {} }
])
const beforeRestore = brain.generation()
await expect(
brain.restore(snapDir, { confirm: false })
).rejects.toThrow('confirm')
await brain.restore(snapDir, { confirm: true })
expect(((await brain.get(uid('restore-e')))?.metadata as { v: number }).v).toBe(1)
expect(await brain.get(uid('restore-extra'))).toBeNull()
expect((await brain.find({ type: NounType.Document, limit: 10 })).length).toBe(1)
// The counter never moves backwards — pre-restore generations are not reissued.
expect(brain.generation()).toBeGreaterThanOrEqual(beforeRestore)
const after = await brain.transact([{ op: 'update', id: uid('restore-e'), metadata: { v: 9 } }])
expect(after.generation).toBeGreaterThan(beforeRestore)
await after.release()
})
it('Db values are first-class: Brainy.open() + chained with() overlays compose', async () => {
const brain = await Brainy.open({ requireSubtype: false, storage: { type: 'memory' } })
brains.push(brain)
expect(brain.isInitialized).toBe(true)
await brain.transact([
{ op: 'add', id: uid('chain-e'), type: NounType.Document, data: 'x', vector: vec(98), metadata: { v: 1 } }
])
const db = brain.now()
const spec1 = await db.with([{ op: 'update', id: uid('chain-e'), metadata: { v: 2 } }])
const spec2 = await spec1.with([{ op: 'update', id: uid('chain-e'), metadata: { v: 3 } }])
expect(((await db.get(uid('chain-e')))?.metadata as { v: number }).v).toBe(1)
expect(((await spec1.get(uid('chain-e')))?.metadata as { v: number }).v).toBe(2)
expect(((await spec2.get(uid('chain-e')))?.metadata as { v: number }).v).toBe(3)
expect(spec2).toBeInstanceOf(Db)
await spec2.release()
await spec1.release()
await db.release()
})
})

View file

@ -0,0 +1,333 @@
/**
* @module tests/unit/db/generationStore
* @description Unit tests for the generational MVCC record layer
* (`src/db/generationStore.ts`) against a real `MemoryStorage` adapter
* counter monotonicity, the commit protocol (staging execute manifest
* rename tx-log), point-in-time resolution from before-images, pin
* refcounting, compaction retention rules, CAS conflicts, timestamp
* resolution, and crash rollback of uncommitted generations.
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { MemoryStorage } from '../../../src/storage/adapters/memoryStorage.js'
import {
GenerationStore,
GENERATIONS_PREFIX,
MANIFEST_PATH
} from '../../../src/db/generationStore.js'
import {
GenerationCompactedError,
GenerationConflictError
} from '../../../src/db/errors.js'
import { NounType } from '../../../src/types/graphTypes.js'
/** Stored-metadata fixture in the canonical shape the live write paths use. */
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
}
}
// Entity ids must be UUID-shaped (the sharded storage layout derives the
// shard from the UUID hex). Fixed values keep assertions deterministic;
// suffix ordering (…aa < …bb < …cc) matches `changedBetween`'s sorted output.
const ID_A = '00000000-0000-4000-8000-0000000000aa'
const ID_B = '00000000-0000-4000-8000-0000000000bb'
const ID_C = '00000000-0000-4000-8000-0000000000cc'
const ID_SOLO = '00000000-0000-4000-8000-0000000000d0'
const ID_LATER = '00000000-0000-4000-8000-0000000000e0'
describe('db/GenerationStore', () => {
let storage: MemoryStorage
let store: GenerationStore
beforeEach(async () => {
storage = new MemoryStorage()
await storage.init()
store = new GenerationStore(storage)
await store.open()
})
/** Commit one transaction that writes `metadata` for `id`. */
async function commitWrite(
id: string,
version: number,
options?: { meta?: Record<string, unknown>; ifAtGeneration?: number }
): Promise<{ generation: number; timestamp: number }> {
return store.commitTransaction({
touched: { nouns: [id], verbs: [] },
meta: options?.meta,
ifAtGeneration: options?.ifAtGeneration,
execute: async () => {
await storage.saveNounMetadata(id, metadataFixture(version))
}
})
}
describe('generation counter', () => {
it('starts at 0 on a fresh store', () => {
expect(store.generation()).toBe(0)
expect(store.committedGeneration()).toBe(0)
})
it('advances by exactly one per committed transaction', async () => {
await commitWrite(ID_A, 1)
expect(store.generation()).toBe(1)
await commitWrite(ID_A, 2)
expect(store.generation()).toBe(2)
expect(store.committedGeneration()).toBe(2)
})
it('bumps on single-operation writes via the storage hook (suppressed inside transact)', async () => {
// Single-op write outside any transaction → hook bumps.
await storage.saveNounMetadata(ID_SOLO, metadataFixture(1))
expect(store.generation()).toBe(1)
expect(store.committedGeneration()).toBe(0) // no transact committed
// A transact batch writing twice bumps ONCE (the batch is one generation).
await store.commitTransaction({
touched: { nouns: [ID_A, ID_B], verbs: [] },
execute: async () => {
await storage.saveNounMetadata(ID_A, metadataFixture(1))
await storage.saveNounMetadata(ID_B, metadataFixture(1))
}
})
expect(store.generation()).toBe(2)
})
it('survives close + reopen monotonically', async () => {
await commitWrite(ID_A, 1)
await storage.saveNounMetadata(ID_SOLO, metadataFixture(1)) // single-op bump
const before = store.generation()
await store.close()
const reopened = new GenerationStore(storage)
await reopened.open()
expect(reopened.generation()).toBeGreaterThanOrEqual(before)
expect(reopened.committedGeneration()).toBe(1)
})
})
describe('CAS (ifAtGeneration)', () => {
it('commits when the expectation matches and rejects with expected/actual when stale', async () => {
await commitWrite(ID_A, 1)
const pinned = store.generation()
await commitWrite(ID_A, 2, { ifAtGeneration: pinned }) // fresh → commits
expect(store.generation()).toBe(pinned + 1)
try {
await commitWrite(ID_A, 3, { ifAtGeneration: pinned }) // stale → rejects
expect.unreachable('should have thrown GenerationConflictError')
} catch (err) {
expect(err).toBeInstanceOf(GenerationConflictError)
expect((err as GenerationConflictError).expected).toBe(pinned)
expect((err as GenerationConflictError).actual).toBe(pinned + 1)
}
expect(store.generation()).toBe(pinned + 1) // nothing moved
})
})
describe('failed transactions', () => {
it('removes the staging directory and returns the generation reservation', async () => {
await commitWrite(ID_A, 1)
const before = store.generation()
await expect(
store.commitTransaction({
touched: { nouns: [ID_A], verbs: [] },
execute: async () => {
throw new Error('boom mid-batch')
}
})
).rejects.toThrow('boom mid-batch')
expect(store.generation()).toBe(before)
const leftovers = await storage.listRawObjects(`${GENERATIONS_PREFIX}/${before + 1}`)
expect(leftovers).toEqual([])
})
})
describe('point-in-time resolution', () => {
it('resolves untouched ids to the live fast path', async () => {
await commitWrite(ID_A, 1)
const pinned = store.generation()
await commitWrite(ID_B, 1) // later commit touches a DIFFERENT id
expect(await store.resolveAt('noun', ID_A, pinned)).toEqual({ source: 'current' })
})
it('resolves changed ids from the first before-image after the pin', async () => {
await commitWrite(ID_A, 1)
const pinned = store.generation()
await commitWrite(ID_A, 2)
await commitWrite(ID_A, 3)
const resolved = await store.resolveAt('noun', ID_A, pinned)
expect(resolved.source).toBe('record')
if (resolved.source === 'record') {
expect(resolved.metadata.version).toBe(1) // state as of the pin
}
})
it('resolves ids created after the pin as absent', async () => {
await commitWrite(ID_A, 1)
const pinned = store.generation()
await commitWrite(ID_LATER, 1)
expect(await store.resolveAt('noun', ID_LATER, pinned)).toEqual({ source: 'absent' })
})
it('changedBetween reports the ids touched in the interval', async () => {
await commitWrite(ID_A, 1)
const from = store.generation()
await commitWrite(ID_B, 1)
await commitWrite(ID_C, 1)
const to = store.generation()
const changed = await store.changedBetween(from, to)
expect(changed.nouns).toEqual([ID_B, ID_C])
expect(changed.verbs).toEqual([])
expect(changed.fromGeneration).toBe(from)
expect(changed.toGeneration).toBe(to)
})
})
describe('timestamp resolution + tx-log', () => {
it('records meta in the tx-log and resolves timestamps to generations', async () => {
const t0 = Date.now() - 10
const first = await commitWrite(ID_A, 1, { meta: { author: 'unit-test' } })
// Distinct commit timestamps: resolution picks the NEWEST generation
// committed at-or-before the instant, so same-millisecond commits tie.
await new Promise((resolve) => setTimeout(resolve, 5))
const second = await commitWrite(ID_A, 2)
const lines = await storage.readTxLogLines()
expect(lines.length).toBe(2)
const firstEntry = JSON.parse(lines[0])
expect(firstEntry.generation).toBe(first.generation)
expect(firstEntry.meta).toEqual({ author: 'unit-test' })
expect((await store.resolveTimestamp(t0)).generation).toBe(0)
expect((await store.resolveTimestamp(first.timestamp)).generation).toBe(first.generation)
expect((await store.resolveTimestamp(Date.now() + 1000)).generation).toBe(
second.generation
)
})
})
describe('pins + compaction', () => {
it('refcounts pins and never reclaims records a live pin needs', async () => {
await commitWrite(ID_A, 1)
const pinned = store.generation()
store.pin(pinned)
store.pin(pinned) // refcount 2
await commitWrite(ID_A, 2)
await commitWrite(ID_A, 3)
// Pin at G protects every record-set ABOVE G (resolution reads
// before-images from generations strictly greater than the pin).
await store.compact()
const resolved = await store.resolveAt('noun', ID_A, pinned)
expect(resolved.source).toBe('record')
if (resolved.source === 'record') {
expect(resolved.metadata.version).toBe(1)
}
store.release(pinned)
await store.compact()
// Still one live pin — records above it still resolve correctly.
const stillResolved = await store.resolveAt('noun', ID_A, pinned)
expect(stillResolved.source).toBe('record')
store.release(pinned)
const result = await store.compact()
expect(result.removedGenerations).toBeGreaterThan(0)
const remaining = await storage.listRawObjects(GENERATIONS_PREFIX)
expect(remaining).toEqual([])
})
it('retainGenerations keeps the most recent record-sets', async () => {
await commitWrite(ID_A, 1)
await commitWrite(ID_A, 2)
await commitWrite(ID_A, 3)
const result = await store.compact({ retainGenerations: 2 })
expect(result.removedGenerations).toBe(1)
expect(result.horizon).toBe(1)
// Removing record-set N breaks pins BELOW N (their reads need N's
// before-images) — the horizon itself stays reachable: state at the
// horizon resolves from the retained record-sets above it.
expect(() => store.assertReachable(0)).toThrow(GenerationCompactedError)
expect(store.assertReachable(1)).toBe(1)
expect(store.assertReachable(2)).toBe(2)
})
it('rejects future and negative generations', async () => {
await commitWrite(ID_A, 1)
expect(() => store.assertReachable(99)).toThrow(RangeError)
expect(() => store.assertReachable(-1)).toThrow(RangeError)
expect(() => store.assertReachable(1.5)).toThrow(RangeError)
})
})
describe('crash recovery', () => {
it('rolls back an uncommitted generation on open (manifest rename is the commit point)', async () => {
await commitWrite(ID_A, 1)
// Simulate a crash between record staging and the manifest rename: a
// throwing fault injector skips the abort cleanup, exactly as a dead
// process would, leaving canonical files mutated and the staging
// directory present — but the manifest still at the prior generation.
store.setCommitFaultInjector((phase) => {
if (phase === 'before-manifest-rename') {
throw new Error('simulated crash')
}
})
await expect(commitWrite(ID_A, 2)).rejects.toThrow('simulated crash')
store.setCommitFaultInjector(undefined)
// The canonical file currently holds the EXECUTED (uncommitted) state.
const dirty = await storage.readNounRaw(ID_A)
expect((dirty.metadata as { version: number }).version).toBe(2)
// Recovery on the next open restores the before-image byte-identically.
const reopened = new GenerationStore(storage)
const openResult = await reopened.open()
expect(openResult.rolledBackGenerations).toBe(1)
const repaired = await storage.readNounRaw(ID_A)
expect((repaired.metadata as { version: number }).version).toBe(1)
expect(reopened.committedGeneration()).toBe(1)
// The crashed generation number is never reused.
expect(reopened.generation()).toBeGreaterThanOrEqual(2)
})
it('keeps a transaction whose manifest rename landed (tx-log append is advisory)', async () => {
await commitWrite(ID_A, 1)
store.setCommitFaultInjector((phase) => {
if (phase === 'after-manifest-rename') {
throw new Error('simulated crash after commit point')
}
})
await expect(commitWrite(ID_A, 2)).rejects.toThrow('simulated crash after commit point')
store.setCommitFaultInjector(undefined)
const reopened = new GenerationStore(storage)
const openResult = await reopened.open()
expect(openResult.rolledBackGenerations).toBe(0)
expect(reopened.committedGeneration()).toBe(2)
const kept = await storage.readNounRaw(ID_A)
expect((kept.metadata as { version: number }).version).toBe(2)
const manifest = await storage.readRawObject(MANIFEST_PATH)
expect(manifest.generation).toBe(2)
})
})
})

View file

@ -0,0 +1,223 @@
/**
* @module tests/unit/db/whereMatcher
* @description Unit tests for the in-memory `find()` filter evaluator behind
* historical and speculative `Db` reads (`src/db/whereMatcher.ts`). The
* evaluator must mirror the metadata index's operator semantics exactly an
* entity matches in-memory if and only if it would have matched through the
* index and must throw `UnsupportedWhereOperatorError` for anything it
* does not recognize (never guess on a historical read).
*/
import { describe, it, expect } from 'vitest'
import {
entityMatchesFind,
resolveEntityField,
whereMatches,
UnsupportedWhereOperatorError
} from '../../../src/db/whereMatcher.js'
import type { Entity } from '../../../src/types/brainy.types.js'
import { NounType } from '../../../src/types/graphTypes.js'
/** Build a minimal entity for matcher tests. */
function entity(overrides: Partial<Entity> = {}): Entity {
return {
id: 'e-1',
vector: [],
type: NounType.Document,
createdAt: 1000,
updatedAt: 2000,
metadata: {},
...overrides
}
}
describe('db/whereMatcher — resolveEntityField', () => {
it('resolves standard top-level fields', () => {
const e = entity({
subtype: 'invoice',
service: 'billing',
confidence: 0.9,
weight: 0.5,
_rev: 3,
data: 'payload'
})
expect(resolveEntityField(e, 'id')).toBe('e-1')
expect(resolveEntityField(e, 'type')).toBe(NounType.Document)
expect(resolveEntityField(e, 'noun')).toBe(NounType.Document) // alias
expect(resolveEntityField(e, 'subtype')).toBe('invoice')
expect(resolveEntityField(e, 'service')).toBe('billing')
expect(resolveEntityField(e, 'confidence')).toBe(0.9)
expect(resolveEntityField(e, 'weight')).toBe(0.5)
expect(resolveEntityField(e, '_rev')).toBe(3)
expect(resolveEntityField(e, 'createdAt')).toBe(1000)
expect(resolveEntityField(e, 'updatedAt')).toBe(2000)
expect(resolveEntityField(e, 'data')).toBe('payload')
})
it('resolves custom fields from the metadata bag', () => {
const e = entity({ metadata: { status: 'open', priority: 2 } })
expect(resolveEntityField(e, 'status')).toBe('open')
expect(resolveEntityField(e, 'priority')).toBe(2)
expect(resolveEntityField(e, 'absent')).toBeUndefined()
})
it('resolves dotted paths against the entity and the metadata bag', () => {
const e = entity({ metadata: { address: { city: 'Lyon' }, priority: 7 } })
expect(resolveEntityField(e, 'metadata.priority')).toBe(7)
expect(resolveEntityField(e, 'address.city')).toBe('Lyon')
expect(resolveEntityField(e, 'address.zip')).toBeUndefined()
})
})
describe('db/whereMatcher — operators', () => {
const e = entity({
subtype: 'invoice',
metadata: { amount: 250, status: 'open', tags: ['urgent', 'q3'], city: 'Lyon' }
})
it('shorthand equality', () => {
expect(whereMatches(e, { status: 'open' })).toBe(true)
expect(whereMatches(e, { status: 'closed' })).toBe(false)
})
it('eq / equals / is aliases', () => {
expect(whereMatches(e, { amount: { eq: 250 } })).toBe(true)
expect(whereMatches(e, { amount: { equals: 250 } })).toBe(true)
expect(whereMatches(e, { amount: { is: 250 } })).toBe(true)
expect(whereMatches(e, { amount: { eq: 99 } })).toBe(false)
})
it('array-membership equality (index posting semantics)', () => {
expect(whereMatches(e, { tags: 'urgent' })).toBe(true)
expect(whereMatches(e, { tags: { contains: 'q3' } })).toBe(true)
expect(whereMatches(e, { tags: 'missing-tag' })).toBe(false)
})
it('ne / notEquals / isNot aliases', () => {
expect(whereMatches(e, { status: { ne: 'closed' } })).toBe(true)
expect(whereMatches(e, { status: { notEquals: 'open' } })).toBe(false)
expect(whereMatches(e, { status: { isNot: 'open' } })).toBe(false)
})
it('in / oneOf set membership', () => {
expect(whereMatches(e, { status: { in: ['open', 'closed'] } })).toBe(true)
expect(whereMatches(e, { status: { oneOf: ['archived'] } })).toBe(false)
})
it('numeric range operators with aliases', () => {
expect(whereMatches(e, { amount: { gt: 200 } })).toBe(true)
expect(whereMatches(e, { amount: { greaterThan: 250 } })).toBe(false)
expect(whereMatches(e, { amount: { gte: 250 } })).toBe(true)
expect(whereMatches(e, { amount: { greaterThanOrEqual: 251 } })).toBe(false)
expect(whereMatches(e, { amount: { greaterEqual: 250 } })).toBe(true)
expect(whereMatches(e, { amount: { lt: 251 } })).toBe(true)
expect(whereMatches(e, { amount: { lessThan: 250 } })).toBe(false)
expect(whereMatches(e, { amount: { lte: 250 } })).toBe(true)
expect(whereMatches(e, { amount: { lessThanOrEqual: 249 } })).toBe(false)
expect(whereMatches(e, { amount: { lessEqual: 250 } })).toBe(true)
})
it('string range comparison is lexicographic', () => {
expect(whereMatches(e, { city: { gt: 'Aix' } })).toBe(true)
expect(whereMatches(e, { city: { lt: 'Aix' } })).toBe(false)
})
it('mixed-type range comparison never matches', () => {
expect(whereMatches(e, { city: { gt: 5 } })).toBe(false)
expect(whereMatches(e, { amount: { lt: 'zzz' } })).toBe(false)
})
it('between', () => {
expect(whereMatches(e, { amount: { between: [200, 300] } })).toBe(true)
expect(whereMatches(e, { amount: { between: [251, 300] } })).toBe(false)
expect(whereMatches(e, { amount: { between: [200] } })).toBe(false) // malformed operand
})
it('exists / missing', () => {
expect(whereMatches(e, { status: { exists: true } })).toBe(true)
expect(whereMatches(e, { nope: { exists: true } })).toBe(false)
expect(whereMatches(e, { nope: { exists: false } })).toBe(true)
expect(whereMatches(e, { nope: { missing: true } })).toBe(true)
expect(whereMatches(e, { status: { missing: true } })).toBe(false)
expect(whereMatches(e, { status: { missing: false } })).toBe(true)
})
it('multiple operators on one field AND together', () => {
expect(whereMatches(e, { amount: { gte: 200, lte: 300 } })).toBe(true)
expect(whereMatches(e, { amount: { gte: 200, lte: 249 } })).toBe(false)
})
it('allOf / anyOf / not logical composition', () => {
expect(whereMatches(e, { allOf: [{ status: 'open' }, { amount: { gt: 100 } }] })).toBe(true)
expect(whereMatches(e, { allOf: [{ status: 'open' }, { amount: { gt: 999 } }] })).toBe(false)
expect(whereMatches(e, { anyOf: [{ status: 'closed' }, { amount: 250 }] })).toBe(true)
expect(whereMatches(e, { anyOf: [{ status: 'closed' }, { amount: 9 }] })).toBe(false)
expect(whereMatches(e, { not: { status: 'closed' } })).toBe(true)
expect(whereMatches(e, { not: { status: 'open' } })).toBe(false)
})
it('throws UnsupportedWhereOperatorError for unknown operators — never guesses', () => {
expect(() => whereMatches(e, { amount: { approximately: 250 } })).toThrow(
UnsupportedWhereOperatorError
)
try {
whereMatches(e, { amount: { approximately: 250 } })
expect.unreachable('should have thrown')
} catch (err) {
expect(err).toBeInstanceOf(UnsupportedWhereOperatorError)
expect((err as UnsupportedWhereOperatorError).operator).toBe('approximately')
}
})
})
describe('db/whereMatcher — entityMatchesFind', () => {
const e = entity({
subtype: 'invoice',
service: 'billing',
metadata: { amount: 250 }
})
it('filters by type (single + array)', () => {
expect(entityMatchesFind(e, { type: NounType.Document })).toBe(true)
expect(entityMatchesFind(e, { type: [NounType.Person, NounType.Document] })).toBe(true)
expect(entityMatchesFind(e, { type: NounType.Person })).toBe(false)
})
it('filters by subtype (single + array; entities without subtype excluded)', () => {
expect(entityMatchesFind(e, { subtype: 'invoice' })).toBe(true)
expect(entityMatchesFind(e, { subtype: ['invoice', 'receipt'] })).toBe(true)
expect(entityMatchesFind(e, { subtype: 'receipt' })).toBe(false)
expect(entityMatchesFind(entity(), { subtype: 'invoice' })).toBe(false)
})
it('filters by service', () => {
expect(entityMatchesFind(e, { service: 'billing' })).toBe(true)
expect(entityMatchesFind(e, { service: 'crm' })).toBe(false)
})
it('excludeVFS drops VFS-marked entities', () => {
const vfsEntity = entity({ metadata: { vfsType: 'file' } })
const markedEntity = entity({ metadata: { isVFSEntity: true } })
expect(entityMatchesFind(vfsEntity, { excludeVFS: true })).toBe(false)
expect(entityMatchesFind(markedEntity, { excludeVFS: true })).toBe(false)
expect(entityMatchesFind(e, { excludeVFS: true })).toBe(true)
expect(entityMatchesFind(vfsEntity, {})).toBe(true)
})
it('composes type + subtype + where', () => {
expect(
entityMatchesFind(e, {
type: NounType.Document,
subtype: 'invoice',
where: { amount: { gte: 200 } }
})
).toBe(true)
expect(
entityMatchesFind(e, {
type: NounType.Document,
subtype: 'invoice',
where: { amount: { gte: 999 } }
})
).toBe(false)
})
})