open-brainy/src/db/whereMatcher.ts
David Snelling ddcc0c723d refactor(8.0): remove the 4 deprecated query-operator aliases (clean break)
8.0 keeps the canonical operators (eq/ne/gt/gte/lt/lte) and their clean
long-form aliases (equals/notEquals/greaterThan/greaterThanOrEqual/lessThan/
lessThanOrEqual), and drops the four redundant deprecated spellings:
  is → eq,  isNot → ne,  greaterEqual → gte,  lessEqual → lte

Removed from every evaluator (metadataIndex criteria + range switches,
metadataFilter, the db whereMatcher egress path) and from the
BrainyFieldOperators type, the unsupported-operator error message, the docs
(QUERY_OPERATORS / api README / VFS projection + semantic guides), and the
whereMatcher alias tests. Also migrated Brainy's own internal use — the VFS
TemporalProjection queried with greaterEqual/lessEqual, which would have
silently broken — to gte/lte.

Full gate green: build, unit 1512, integration 607.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 10:29:20 -07:00

291 lines
11 KiB
TypeScript

/**
* @module db/whereMatcher
* @description In-memory evaluation of `find()` metadata filters against a
* single resolved entity — the overlay half of historical and speculative
* reads on a `Db` value.
*
* A `Db` pinned at a past generation answers metadata-level `find()` by
* combining the live index's results (for entities untouched since the pin)
* with per-entity evaluation of the same filter against generation records
* (for entities that DID change). The same evaluator backs `db.with()`
* speculative overlays. The semantics here deliberately mirror the index
* evaluator in `src/utils/metadataIndex.ts` (`getIdsForFilter`) — operator
* aliases, array-membership equality, `exists`/`missing`, and the
* `allOf`/`anyOf`/`not` logical forms — so an entity matches in-memory if and
* only if it would have matched through the index.
*
* **Honesty contract:** an operator this module does not recognize throws
* {@link UnsupportedWhereOperatorError} instead of guessing — a historical
* read must never silently return wrong results.
*/
import type { Entity, FindParams } from '../types/brainy.types.js'
/**
* @description Thrown when a `where` clause uses an operator this in-memory
* evaluator does not implement. Callers (historical/speculative `find()` on
* a `Db`) never let it surface as silently-wrong results: at a historical
* generation the query is rerouted through the at-generation index
* materialization (which evaluates the full live operator surface); on a
* speculative overlay it becomes a `SpeculativeOverlayError`.
*/
export class UnsupportedWhereOperatorError extends Error {
/** The unrecognized operator name. */
public readonly operator: string
/**
* @param operator - The operator name that could not be evaluated.
*/
constructor(operator: string) {
super(
`The where-operator '${operator}' is not supported for historical/speculative ` +
`in-memory evaluation. Supported: eq/equals, ne/notEquals, in/oneOf, ` +
`gt/greaterThan, gte/greaterThanOrEqual, lt/lessThan, ` +
`lte/lessThanOrEqual, between, contains, exists, missing, ` +
`plus allOf/anyOf/not.`
)
this.name = 'UnsupportedWhereOperatorError'
this.operator = operator
}
}
/**
* @description Resolve a filter field name to its value on an entity,
* mirroring how `extractIndexableFields` lays entities out for the metadata
* index: standard fields live at the top level (with the `noun` ↔ `type`
* alias), everything else is a custom field inside the `metadata` bag.
* Dotted paths (`metadata.priority`, `address.city`) traverse nested objects.
*
* @param entity - The resolved entity to read from.
* @param field - The filter field name.
* @returns The field's value, or `undefined` when absent.
*/
export function resolveEntityField(entity: Entity, field: string): unknown {
switch (field) {
case 'noun':
case 'type':
return entity.type
case 'subtype':
return entity.subtype
case 'id':
return entity.id
case 'createdAt':
return entity.createdAt
case 'updatedAt':
return entity.updatedAt
case 'service':
return entity.service
case 'createdBy':
return entity.createdBy
case 'confidence':
return entity.confidence
case 'weight':
return entity.weight
case '_rev':
return entity._rev
case 'data':
return entity.data
}
if (field.includes('.')) {
// Dotted path: resolve against the whole entity first (`metadata.x`),
// then against the metadata bag (`address.city` on nested metadata).
const fromEntity = resolvePath(entity as unknown as Record<string, unknown>, field)
if (fromEntity !== undefined) return fromEntity
return resolvePath((entity.metadata ?? {}) as Record<string, unknown>, field)
}
return ((entity.metadata ?? {}) as Record<string, unknown>)[field]
}
/** Walk a dotted path through nested plain objects. */
function resolvePath(obj: Record<string, unknown>, path: string): unknown {
let current: unknown = obj
for (const segment of path.split('.')) {
if (current === null || typeof current !== 'object') return undefined
current = (current as Record<string, unknown>)[segment]
}
return current
}
/**
* @description Index-equality semantics: scalar strict equality, with
* array-valued fields matching by membership (the index stores one posting
* per array element, so `eq` on an array field means "contains").
*/
function eqMatches(value: unknown, operand: unknown): boolean {
if (Array.isArray(value)) {
return value.some((element) => element === operand)
}
return value === operand
}
/** Ordered comparison for range operators (numbers, or both-strings lexicographic). */
function compare(value: unknown, operand: unknown): number | null {
if (typeof value === 'number' && typeof operand === 'number') {
return value - operand
}
if (typeof value === 'string' && typeof operand === 'string') {
return value < operand ? -1 : value > operand ? 1 : 0
}
return null
}
/**
* @description Evaluate one field condition (shorthand value or operator
* object) against a resolved field value, mirroring the operator table in
* `metadataIndex.getIdsForFilter`.
*
* @param value - The entity's field value (possibly `undefined`).
* @param condition - The filter condition for this field.
* @returns Whether the value satisfies the condition.
* @throws UnsupportedWhereOperatorError for unrecognized operators.
*/
function fieldConditionMatches(value: unknown, condition: unknown): boolean {
if (condition === null || typeof condition !== 'object' || Array.isArray(condition)) {
// Shorthand for 'eq'.
return eqMatches(value, condition)
}
for (const [op, operand] of Object.entries(condition as Record<string, unknown>)) {
let matches: boolean
switch (op) {
case 'equals':
case 'eq':
matches = eqMatches(value, operand)
break
case 'notEquals':
case 'ne':
matches = !eqMatches(value, operand)
break
case 'oneOf':
case 'in':
matches = Array.isArray(operand) && operand.some((candidate) => eqMatches(value, candidate))
break
case 'greaterThan':
case 'gt': {
const cmp = compare(value, operand)
matches = cmp !== null && cmp > 0
break
}
case 'greaterThanOrEqual':
case 'gte': {
const cmp = compare(value, operand)
matches = cmp !== null && cmp >= 0
break
}
case 'lessThan':
case 'lt': {
const cmp = compare(value, operand)
matches = cmp !== null && cmp < 0
break
}
case 'lessThanOrEqual':
case 'lte': {
const cmp = compare(value, operand)
matches = cmp !== null && cmp <= 0
break
}
case 'between': {
if (!Array.isArray(operand) || operand.length !== 2) {
matches = false
break
}
const lower = compare(value, operand[0])
const upper = compare(value, operand[1])
matches = lower !== null && upper !== null && lower >= 0 && upper <= 0
break
}
case 'contains':
// Index semantics: array fields post one entry per element, so
// 'contains' is the same lookup as 'eq' (membership on arrays).
matches = eqMatches(value, operand)
break
case 'exists':
matches = operand ? value !== undefined : value === undefined
break
case 'missing':
matches = operand ? value === undefined : value !== undefined
break
default:
throw new UnsupportedWhereOperatorError(op)
}
if (!matches) return false // Multiple operators on one field AND together.
}
return true
}
/**
* @description Evaluate a full `where` filter (field conditions plus
* `allOf`/`anyOf`/`not` logical composition) against one entity.
*
* @param entity - The resolved entity (historical record or speculative overlay).
* @param where - The `where` clause from `FindParams`.
* @returns Whether the entity satisfies every clause.
* @throws UnsupportedWhereOperatorError for unrecognized operators.
*/
export function whereMatches(entity: Entity, where: Record<string, unknown>): boolean {
for (const [field, condition] of Object.entries(where)) {
if (field === 'allOf') {
if (!Array.isArray(condition)) return false
if (!condition.every((sub) => whereMatches(entity, sub as Record<string, unknown>))) return false
continue
}
if (field === 'anyOf') {
if (!Array.isArray(condition)) return false
if (!condition.some((sub) => whereMatches(entity, sub as Record<string, unknown>))) return false
continue
}
if (field === 'not') {
if (whereMatches(entity, condition as Record<string, unknown>)) return false
continue
}
if (!fieldConditionMatches(resolveEntityField(entity, field), condition)) return false
}
return true
}
/**
* @description Evaluate the metadata-level portions of a `find()` query
* (`type`, `subtype`, `where`, `service`, `excludeVFS`) against one resolved
* entity. Used by historical and speculative `find()` to decide whether a
* changed/overlaid entity belongs in the result set. The caller guarantees
* the query carries no index-only dimensions (semantic `query`/`vector`,
* `connected` traversal, …) — those are routed to the at-generation index
* materialization (historical) or rejected with `SpeculativeOverlayError`
* (overlays) before evaluation starts.
*
* @param entity - The resolved entity.
* @param params - The metadata-level find parameters.
* @returns Whether the entity matches every requested filter.
* @throws UnsupportedWhereOperatorError for unrecognized `where` operators.
*/
export function entityMatchesFind(entity: Entity, params: FindParams): boolean {
if (params.type !== undefined) {
const types = Array.isArray(params.type) ? params.type : [params.type]
if (!types.includes(entity.type)) return false
}
if (params.subtype !== undefined) {
const subtypes = Array.isArray(params.subtype) ? params.subtype : [params.subtype]
if (entity.subtype === undefined || !subtypes.includes(entity.subtype)) return false
}
if (params.service !== undefined && entity.service !== params.service) {
return false
}
if (params.excludeVFS === true) {
// Mirror of find()'s exclusion filter (`vfsType: { exists: false }`,
// `isVFSEntity: { ne: true }`) and Brainy's VFS-marker helper.
const metadata = (entity.metadata ?? {}) as Record<string, unknown>
if (metadata.vfsType !== undefined) return false
if (metadata.isVFSEntity === true || metadata.isVFS === true) return false
}
if (params.where !== undefined) {
if (!whereMatches(entity, params.where as Record<string, unknown>)) return false
}
return true
}