brainy/tests/unit/db/whereMatcher.test.ts

220 lines
8.6 KiB
TypeScript
Raw Normal View History

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.
2026-06-10 14:14:07 -07:00
/**
* @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 aliases', () => {
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.
2026-06-10 14:14:07 -07:00
expect(whereMatches(e, { amount: { eq: 250 } })).toBe(true)
expect(whereMatches(e, { amount: { equals: 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 aliases', () => {
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.
2026-06-10 14:14:07 -07:00
expect(whereMatches(e, { status: { ne: 'closed' } })).toBe(true)
expect(whereMatches(e, { status: { notEquals: '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: { 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)
})
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)
})
})