import jsonLogic from 'json-logic-js'; const { apply, is_logic } = jsonLogic; import Debug from 'debug'; import { SchemaRegistry, SchemaID, ObjectSchema } from '../schema/schema'; import { Lossless, LosslessViewOne, LosslessViewMany, CollapsedDelta } from '../views/lossless'; import { DomainEntityID } from '../core/types'; import { DeltaFilter } from '../core/delta'; const debug = Debug('rz:query'); // List of valid JSON Logic operators const VALID_OPERATORS = new Set([ '==', '===', '!=', '!==', '>', '>=', '<', '<=', '!', '!!', 'and', 'or', 'if', '?:', '??', '!!', '!', '!!', '!!', '!', 'var', 'missing', 'missing_some', 'in', 'cat', 'log', 'method', 'merge', '+', '-', '*', '/', '%', 'min', 'max', 'map', 'reduce', 'filter', 'all', 'some', 'none' ]); class InvalidQueryOperatorError extends Error { constructor(operator: string) { super(`Invalid query operator: ${operator}`); this.name = 'InvalidQueryOperatorError'; } } export type JsonLogic = Record; export interface QueryOptions { maxResults?: number; deltaFilter?: DeltaFilter; } export interface QueryResult { entities: LosslessViewMany; totalFound: number; limited: boolean; } export class QueryEngine { constructor( private lossless: Lossless, private schemaRegistry: SchemaRegistry ) {} /** * Query entities by schema type with optional JSON Logic filter */ /** * Validate JSON Logic operators in a filter * @throws {InvalidQueryOperatorError} If an invalid operator is found */ private validateJsonLogicOperators(logic: unknown): void { if (!logic || typeof logic !== 'object') { return; } const logicObj = logic as Record; const operator = Object.keys(logicObj)[0]; // Check if this is an operator if (is_logic(logic) && operator && !VALID_OPERATORS.has(operator)) { throw new InvalidQueryOperatorError(operator); } // Recursively check nested logic for (const value of Object.values(logicObj)) { if (Array.isArray(value)) { value.forEach(item => this.validateJsonLogicOperators(item)); } else if (value && typeof value === 'object') { this.validateJsonLogicOperators(value); } } } async query( schemaId: SchemaID, filter?: JsonLogic, options: QueryOptions = {} ): Promise { debug(`Querying schema ${schemaId} with filter:`, filter); // Validate filter operators if provided if (filter) { try { this.validateJsonLogicOperators(filter); } catch (error) { if (error instanceof InvalidQueryOperatorError) { debug(`Invalid query operator: ${error.message}`); throw error; // Re-throw to let the caller handle it } throw error; } } // 1. Find all entities that could match this schema const candidateEntityIds = this.discoverEntitiesBySchema(schemaId); debug(`Found ${candidateEntityIds.length} candidate entities for schema ${schemaId}`); // 2. Compose lossless views for all candidates const allViews = this.lossless.compose(candidateEntityIds, options.deltaFilter); debug(`Composed ${Object.keys(allViews).length} lossless views`); // 3. Apply JSON Logic filter if provided let filteredViews: LosslessViewMany = allViews; if (filter) { filteredViews = this.applyJsonLogicFilter(allViews, filter, schemaId); debug(`After filtering: ${Object.keys(filteredViews).length} entities match`); } // 4. Apply result limits if specified const totalFound = Object.keys(filteredViews).length; let limited = false; if (options.maxResults && totalFound > options.maxResults) { const entityIds = Object.keys(filteredViews).slice(0, options.maxResults); filteredViews = {}; for (const entityId of entityIds) { filteredViews[entityId] = allViews[entityId]; } limited = true; debug(`Limited results to ${options.maxResults} entities`); } return { entities: filteredViews, totalFound, limited }; } /** * Query for a single entity by ID with schema validation */ async queryOne(schemaId: SchemaID, entityId: DomainEntityID): Promise { debug(`Querying single entity ${entityId} with schema ${schemaId}`); const views = this.lossless.compose([entityId]); const view = views[entityId]; if (!view) { debug(`Entity ${entityId} not found`); return null; } // Validate that the entity matches the schema if (!this.entityMatchesSchema(view, schemaId)) { debug(`Entity ${entityId} does not match schema ${schemaId}`); return null; } return view; } /** * Discover all entities that could potentially match a given schema * This is a heuristic based on the schema's required properties */ private discoverEntitiesBySchema(schemaId: SchemaID): DomainEntityID[] { const schema = this.schemaRegistry.get(schemaId); if (!schema) { debug(`Schema ${schemaId} not found in registry`); return []; } // Strategy: Find entities that have deltas for the schema's required properties const requiredProperties = schema.requiredProperties || []; const allEntityIds = Array.from(this.lossless.domainEntities.keys()); if (requiredProperties.length === 0) { // No required properties - return all entities debug(`Schema ${schemaId} has no required properties, returning all entities`); return allEntityIds; } // Find entities that have at least one required property const candidateEntities: DomainEntityID[] = []; for (const entityId of allEntityIds) { const entity = this.lossless.domainEntities.get(entityId); if (!entity) continue; // Check if entity has deltas for all required property const hasRequiredProperties = requiredProperties.every(propertyId => entity.properties.has(propertyId) ); if (hasRequiredProperties) { candidateEntities.push(entityId); } } debug(`Found ${candidateEntities.length} entities with required properties for schema ${schemaId}`); return candidateEntities; } /** * Apply JSON Logic filter to lossless views * This requires converting each lossless view to a queryable object */ private applyJsonLogicFilter( views: LosslessViewMany, filter: JsonLogic, schemaId: SchemaID ): LosslessViewMany { const schema = this.schemaRegistry.get(schemaId); if (!schema) { debug(`Cannot filter without schema ${schemaId}`); return views; } const filteredViews: LosslessViewMany = {}; let hasFilterErrors = false; const filterErrors: string[] = []; for (const [entityId, view] of Object.entries(views)) { try { // Convert lossless view to queryable object using schema const queryableObject = this.losslessViewToQueryableObject(view, schema); // Apply JSON Logic filter const matches = apply(filter, queryableObject); if (matches) { filteredViews[entityId] = view; debug(`Entity ${entityId} matches filter`); } else { debug(`Entity ${entityId} does not match filter`); } } catch (error) { hasFilterErrors = true; const errorMsg = `Error applying filter to entity ${entityId}: ${error instanceof Error ? error.message : String(error)}`; filterErrors.push(errorMsg); debug(errorMsg, error); // Continue processing other entities } } // If we had any filter errors, log them as a warning if (hasFilterErrors) { console.warn(`Encountered ${filterErrors.length} filter errors. First error: ${filterErrors[0]}`); debug('All filter errors:', filterErrors); } return filteredViews; } /** * Convert a lossless view to a queryable object based on schema * Uses simple resolution strategies for now */ private losslessViewToQueryableObject(view: LosslessViewOne, schema: ObjectSchema): Record { const obj: Record = { id: view.id, _referencedAs: view.referencedAs }; // Convert each schema property from lossless view deltas for (const [propertyId, propertySchema] of Object.entries(schema.properties)) { const deltas = view.propertyDeltas[propertyId] || []; if (deltas.length === 0) { obj[propertyId] = null; continue; } // Apply simple resolution strategy based on property schema type switch (propertySchema.type) { case 'primitive': { // Use last-write-wins for primitives const deltasSorted = deltas.sort((a, b) => b.timeCreated - a.timeCreated); for (const delta of deltasSorted) { const primitiveValue = this.extractPrimitiveValue(delta, propertyId); if (primitiveValue !== null) { obj[propertyId] = primitiveValue; } } break; } case 'array': { // Collect all values as array const arrayValues = deltas .map(delta => this.extractPrimitiveValue(delta, propertyId)) .filter(value => value !== null); obj[propertyId] = arrayValues; break; } case 'reference': { // For references, include the target IDs const refValues = deltas .map(delta => this.extractReferenceValue(delta, propertyId)) .filter(value => value !== null); obj[propertyId] = refValues; break; } default: obj[propertyId] = deltas.length; } } debug(`Converted entity ${view.id} to queryable object:`, obj); return obj; } /** * Extract primitive value from a delta for a given property */ private extractPrimitiveValue(delta: CollapsedDelta, propertyId: string): unknown { // Look for the value in collapsed pointers // CollapsedPointer is {[key: PropertyID]: PropertyTypes} for (const pointer of delta.pointers) { if (pointer[propertyId] !== undefined) { return pointer[propertyId]; } } return null; } /** * Extract reference value (target ID) from a delta for a given property */ private extractReferenceValue(delta: CollapsedDelta, _propertyId: string): string | null { // For references, we want the value pointer that contains the reference ID for (const pointer of delta.pointers) { if (pointer.value !== undefined && typeof pointer.value === 'string') { return pointer.value; } } return null; } /** * Check if an entity matches a schema (basic validation) */ private entityMatchesSchema(view: LosslessViewOne, schemaId: SchemaID): boolean { const schema = this.schemaRegistry.get(schemaId); if (!schema) return false; // Check that all required properties have at least one delta const requiredProperties = schema.requiredProperties || []; for (const propertyId of requiredProperties) { const deltas = view.propertyDeltas[propertyId]; if (!deltas || deltas.length === 0) { debug(`Entity ${view.id} missing required property ${propertyId} for schema ${schemaId}`); return false; } } return true; } /** * Get statistics about queryable entities */ getStats() { const totalEntities = this.lossless.domainEntities.size; const registeredSchemas = this.schemaRegistry.list().length; return { totalEntities, registeredSchemas, schemasById: this.schemaRegistry.list().reduce((acc, schema) => { acc[schema.id] = this.discoverEntitiesBySchema(schema.id).length; return acc; }, {} as Record) }; } }