294 lines
11 KiB
TypeScript
294 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 {
|
|
// THE ONE ADDRESSING LAW (sealed 2026-08-03): `system.<field>` reads the
|
|
// entity scalar; bare and `metadata.`-prefixed names read the user's
|
|
// metadata bag (dotted paths traverse INSIDE the bag). The old bare-name
|
|
// switch over system fields is dead — bare `createdAt` is the user's own
|
|
// field now; the engine scalar is `system.createdAt`. Plumbing (vector,
|
|
// connections, level, data, _rev) is invisible: no spelling reaches it.
|
|
if (field.startsWith('system.')) {
|
|
switch (field.slice('system.'.length)) {
|
|
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 'visibility':
|
|
return (entity as unknown as Record<string, unknown>).visibility
|
|
}
|
|
// Out-of-map system spelling: parse refuses these upstream with a typed
|
|
// error; reaching here (internal callers only) reads as absent.
|
|
return undefined
|
|
}
|
|
|
|
const path = field.startsWith('metadata.') ? field.slice('metadata.'.length) : field
|
|
const bag = (entity.metadata ?? {}) as Record<string, unknown>
|
|
if (!path.includes('.')) return bag[path]
|
|
return resolvePath(bag, path)
|
|
}
|
|
|
|
/** 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
|
|
}
|