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

235 lines
9.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', () => {
feat(namespace): NO SPECIAL NAMES + storage fidelity — the ruled completion of the field-addressing law The write side of the law, ruled 2026-08-03: data is either in main space where developers can use anything, or it is in system.*. - The reserved-name write door DIES: add/update/relate/updateRelation metadata bags accept EVERY name (confidence, type, id, data, level, content, ...) as ordinary user fields — indexed, filterable, sortable, aggregatable, identical to any other field. The remap/enforce/warn machinery, the reservedFieldPolicy config (now a typed init refusal), and the compile-time metadata key bans are all removed. The one write refusal left: keys spelled 'system.*' (namespace forgery), now enforced on all four write doors. - STORED RECORDS GO NESTED (v2): engine fields top-level, the user bag nested verbatim under 'metadata', sealed by a format stamp — by-name storage discrimination is unsound once colliders are admitted. Legacy flat records stay readable forever through the shape-aware splitters (sound for them: the old door refused colliders). Time travel rides the same split (generation store snapshots whole records). - Name-based index exclusions DIE: user frame indexes every name; the excludeFields/indexedFields knobs and their silent-[] holes are gone; bulk-payload protection is value-shape only, uniform across names. - Consumer-sweep findings fixed in the same wave: per-type counts read the frozen 'system.type' column (addToIndex sort, affinity tracking, cold-count rehydration, VFS type bitmaps — legacy 'noun' fallback for pre-rebuild reads); resolveHiddenIds addresses 'system.visibility' (bare 'visibility' was a silent no-op under the law — VFS/system entities leaked into default reads). - Fidelity fallout fixed in the owning layers: readEntityFieldAddress reads the bag first (colliders were absent-shadowed by its own guard) and never serves system addresses from the bag; blob history refs read the bag shape-aware; migration transforms now receive ONE normalized view (engine fields + nested bag) regardless of stored era, and stray flat-habit keys refuse with the fix in the message. - THE REOPEN-COLLIDER CONFORMANCE CASE (required before any RC counts as gates-green): all ten collider names + plumbing names written as user fields, verified verbatim + queryable across live reads, flush+reopen, a forced epoch rebuild, and asOf time travel; relation mirror; forgery refusals; legacy flat-record compat. 8/8 green. Gates: unit 1901/1901 (exit 0) · integration 758 (exit 0) · conformance 27/27 (exit 0) · consumer test sweep migrated (10 files).
2026-08-03 16:59:13 -07:00
it('system.<field> resolves the entity scalar; bare/metadata. reads the metadata bag only (sealed 2026-08-03)', () => {
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
const e = entity({
subtype: 'invoice',
service: 'billing',
confidence: 0.9,
weight: 0.5,
_rev: 3,
data: 'payload'
})
feat(namespace): NO SPECIAL NAMES + storage fidelity — the ruled completion of the field-addressing law The write side of the law, ruled 2026-08-03: data is either in main space where developers can use anything, or it is in system.*. - The reserved-name write door DIES: add/update/relate/updateRelation metadata bags accept EVERY name (confidence, type, id, data, level, content, ...) as ordinary user fields — indexed, filterable, sortable, aggregatable, identical to any other field. The remap/enforce/warn machinery, the reservedFieldPolicy config (now a typed init refusal), and the compile-time metadata key bans are all removed. The one write refusal left: keys spelled 'system.*' (namespace forgery), now enforced on all four write doors. - STORED RECORDS GO NESTED (v2): engine fields top-level, the user bag nested verbatim under 'metadata', sealed by a format stamp — by-name storage discrimination is unsound once colliders are admitted. Legacy flat records stay readable forever through the shape-aware splitters (sound for them: the old door refused colliders). Time travel rides the same split (generation store snapshots whole records). - Name-based index exclusions DIE: user frame indexes every name; the excludeFields/indexedFields knobs and their silent-[] holes are gone; bulk-payload protection is value-shape only, uniform across names. - Consumer-sweep findings fixed in the same wave: per-type counts read the frozen 'system.type' column (addToIndex sort, affinity tracking, cold-count rehydration, VFS type bitmaps — legacy 'noun' fallback for pre-rebuild reads); resolveHiddenIds addresses 'system.visibility' (bare 'visibility' was a silent no-op under the law — VFS/system entities leaked into default reads). - Fidelity fallout fixed in the owning layers: readEntityFieldAddress reads the bag first (colliders were absent-shadowed by its own guard) and never serves system addresses from the bag; blob history refs read the bag shape-aware; migration transforms now receive ONE normalized view (engine fields + nested bag) regardless of stored era, and stray flat-habit keys refuse with the fix in the message. - THE REOPEN-COLLIDER CONFORMANCE CASE (required before any RC counts as gates-green): all ten collider names + plumbing names written as user fields, verified verbatim + queryable across live reads, flush+reopen, a forced epoch rebuild, and asOf time travel; relation mirror; forgery refusals; legacy flat-record compat. 8/8 green. Gates: unit 1901/1901 (exit 0) · integration 758 (exit 0) · conformance 27/27 (exit 0) · consumer test sweep migrated (10 files).
2026-08-03 16:59:13 -07:00
// system.<field> is the ONLY spelling that reaches an entity scalar.
expect(resolveEntityField(e, 'system.id')).toBe('e-1')
expect(resolveEntityField(e, 'system.type')).toBe(NounType.Document)
expect(resolveEntityField(e, 'system.subtype')).toBe('invoice')
expect(resolveEntityField(e, 'system.service')).toBe('billing')
expect(resolveEntityField(e, 'system.confidence')).toBe(0.9)
expect(resolveEntityField(e, 'system.weight')).toBe(0.5)
expect(resolveEntityField(e, 'system.createdAt')).toBe(1000)
expect(resolveEntityField(e, 'system.updatedAt')).toBe(2000)
// Plumbing (_rev, data) is invisible even via system. — not in the
// ten-scalar map, so this internal resolver reads it as absent (the typed
// refusal for these lives one layer up, at the query-surface parser).
expect(resolveEntityField(e, 'system._rev')).toBeUndefined()
expect(resolveEntityField(e, 'system.data')).toBeUndefined()
// Bare names are ALWAYS the user's metadata field — even when they share
// a spelling with an engine scalar, or with the now-dead 'noun' alias.
// This entity's metadata bag is empty, so every bare name below reads
// absent rather than silently falling back to the entity scalar.
expect(resolveEntityField(e, 'id')).toBeUndefined()
expect(resolveEntityField(e, 'type')).toBeUndefined()
expect(resolveEntityField(e, 'noun')).toBeUndefined() // legacy alias is dead
expect(resolveEntityField(e, 'subtype')).toBeUndefined()
expect(resolveEntityField(e, 'createdAt')).toBeUndefined()
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
})
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)
})
})