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.
223 lines
8.9 KiB
TypeScript
223 lines
8.9 KiB
TypeScript
/**
|
|
* @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)
|
|
})
|
|
})
|