/** * @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, field) if (fromEntity !== undefined) return fromEntity return resolvePath((entity.metadata ?? {}) as Record, field) } return ((entity.metadata ?? {}) as Record)[field] } /** Walk a dotted path through nested plain objects. */ function resolvePath(obj: Record, 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)[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)) { 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): 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))) return false continue } if (field === 'anyOf') { if (!Array.isArray(condition)) return false if (!condition.some((sub) => whereMatches(entity, sub as Record))) return false continue } if (field === 'not') { if (whereMatches(entity, condition as Record)) 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 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)) return false } return true }