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).
234 lines
9.6 KiB
TypeScript
234 lines
9.6 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('system.<field> resolves the entity scalar; bare/metadata. reads the metadata bag only (sealed 2026-08-03)', () => {
|
|
const e = entity({
|
|
subtype: 'invoice',
|
|
service: 'billing',
|
|
confidence: 0.9,
|
|
weight: 0.5,
|
|
_rev: 3,
|
|
data: 'payload'
|
|
})
|
|
|
|
// 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()
|
|
})
|
|
|
|
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', () => {
|
|
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', () => {
|
|
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)
|
|
})
|
|
})
|