import Debug from 'debug'; import { SchemaRegistry, ObjectSchema, SchemaID, SchemaValidationResult, SchemaValidationError, PropertySchema, PrimitiveSchema, ReferenceSchema, ArraySchema, SchemaAppliedView, SchemaAppliedViewWithNesting, SchemaApplicationOptions, ResolutionContext } from '../schema/schema'; import { LosslessViewOne, Lossless } from '../views/lossless'; import { DomainEntityID, PropertyID, PropertyTypes } from '../core/types'; import { CollapsedDelta } from '../views/lossless'; const debug = Debug('rz:schema-registry'); export class DefaultSchemaRegistry implements SchemaRegistry { schemas = new Map(); register(schema: ObjectSchema): void { this.validateSchemaStructure(schema); this.schemas.set(schema.id, schema); debug(`Registered schema: ${schema.id} (${schema.name})`); } get(id: SchemaID): ObjectSchema | undefined { return this.schemas.get(id); } list(): ObjectSchema[] { return Array.from(this.schemas.values()); } private validateSchemaStructure(schema: ObjectSchema): void { if (!schema.id || typeof schema.id !== 'string') { throw new Error('Schema must have a valid string id'); } if (!schema.name || typeof schema.name !== 'string') { throw new Error('Schema must have a valid string name'); } if (!schema.properties || typeof schema.properties !== 'object') { throw new Error('Schema must have properties object'); } // Validate property schemas for (const [propertyId, propertySchema] of Object.entries(schema.properties)) { this.validatePropertySchema(propertySchema, `${schema.id}.${propertyId}`); } // Validate required properties exist if (schema.requiredProperties) { for (const required of schema.requiredProperties) { if (!(required in schema.properties)) { throw new Error(`Required property '${required}' not found in schema '${schema.id}'`); } } } } private validatePropertySchema(schema: PropertySchema, path: string): void { switch (schema.type) { case 'primitive': this.validatePrimitiveSchema(schema, path); break; case 'reference': this.validateReferenceSchema(schema, path); break; case 'array': this.validateArraySchema(schema, path); break; default: throw new Error(`Unknown schema type at ${path}`); } } private validatePrimitiveSchema(schema: PrimitiveSchema, path: string): void { const validTypes = ['string', 'number', 'boolean', 'null']; if (!validTypes.includes(schema.primitiveType)) { throw new Error(`Invalid primitive type '${schema.primitiveType}' at ${path}`); } } private validateReferenceSchema(schema: ReferenceSchema, path: string): void { if (!schema.targetSchema || typeof schema.targetSchema !== 'string') { throw new Error(`Reference schema must have valid targetSchema at ${path}`); } if (schema.maxDepth !== undefined && schema.maxDepth < 0) { throw new Error(`Reference maxDepth must be non-negative at ${path}`); } } private validateArraySchema(schema: ArraySchema, path: string): void { this.validatePropertySchema(schema.itemSchema, `${path}[]`); if (schema.maxItems !== undefined && schema.maxItems < 0) { throw new Error(`Array maxItems must be non-negative at ${path}`); } } validate(entityId: DomainEntityID, schemaId: SchemaID, view: LosslessViewOne): SchemaValidationResult { const schema = this.get(schemaId); if (!schema) { return { valid: false, errors: [{ property: '', message: `Schema '${schemaId}' not found` }], warnings: [] }; } const errors: SchemaValidationError[] = []; const warnings: SchemaValidationError[] = []; // Check required properties if (schema.requiredProperties) { for (const required of schema.requiredProperties) { if (!(required in view.propertyDeltas) || view.propertyDeltas[required].length === 0) { errors.push({ property: required, message: `Required property '${required}' is missing or has no deltas` }); } } } // Validate each property in the view for (const [propertyId, deltas] of Object.entries(view.propertyDeltas)) { const propertySchema = schema.properties[propertyId]; if (!propertySchema) { if (schema.additionalProperties === false) { warnings.push({ property: propertyId, message: `Property '${propertyId}' not defined in schema and additionalProperties is false` }); } continue; } // Validate each delta for this property for (const delta of deltas) { const validationResult = this.validateDeltaAgainstPropertySchema( delta, propertySchema, propertyId ); errors.push(...validationResult.errors); warnings.push(...validationResult.warnings); } } // Validate properties defined in schema but missing from view for (const [propertyId, propertySchema] of Object.entries(schema.properties)) { if (!(propertyId in view.propertyDeltas)) { if (propertySchema.required) { errors.push({ property: propertyId, message: `Required property '${propertyId}' is missing from view` }); } } } return { valid: errors.length === 0, errors, warnings }; } private validateDeltaAgainstPropertySchema( delta: CollapsedDelta, schema: PropertySchema, propertyId: PropertyID ): SchemaValidationResult { const errors: SchemaValidationError[] = []; const warnings: SchemaValidationError[] = []; // Extract the value from the delta const valuePointer = delta.pointers.find(p => p[propertyId] !== undefined); if (!valuePointer) { errors.push({ property: propertyId, message: `Delta does not contain expected property '${propertyId}'` }); return { valid: false, errors, warnings }; } const value = valuePointer[propertyId]; switch (schema.type) { case 'primitive': this.validatePrimitiveValue(value, schema, propertyId, errors); break; case 'reference': this.validateReferenceValue(value, schema, propertyId, errors, warnings); break; case 'array': // Arrays are complex - for now just warn that we don't fully validate them warnings.push({ property: propertyId, message: `Array validation not fully implemented for property '${propertyId}'` }); break; } return { valid: errors.length === 0, errors, warnings }; } private validatePrimitiveValue( value: unknown, schema: PrimitiveSchema, propertyId: PropertyID, errors: SchemaValidationError[] ): void { let valid = false; switch (schema.primitiveType) { case 'string': valid = typeof value === 'string'; break; case 'number': valid = typeof value === 'number'; break; case 'boolean': valid = typeof value === 'boolean'; break; case 'null': valid = value === null; break; } if (!valid) { errors.push({ property: propertyId, message: `Expected ${schema.primitiveType} but got ${typeof value}`, expectedType: schema.primitiveType, actualValue: value }); } } private validateReferenceValue( value: unknown, schema: ReferenceSchema, propertyId: PropertyID, errors: SchemaValidationError[], warnings: SchemaValidationError[] ): void { if (typeof value !== 'string') { errors.push({ property: propertyId, message: `Reference value must be a string (entity ID), got ${typeof value}`, expectedType: 'string (entity ID)', actualValue: value }); return; } // Check if target schema exists const targetSchema = this.get(schema.targetSchema); if (!targetSchema) { warnings.push({ property: propertyId, message: `Target schema '${schema.targetSchema}' not found for reference` }); } } applySchema( view: LosslessViewOne, schemaId: SchemaID, options: SchemaApplicationOptions = {} ): SchemaAppliedView { const schema = this.get(schemaId); if (!schema) { throw new Error(`Schema '${schemaId}' not found`); } const { includeMetadata = true, strictValidation = false, maxDepth: _maxDepth = 3 } = options; const appliedView: SchemaAppliedView = { id: view.id, schemaId, properties: {} }; // Apply schema to each property for (const [propertyId, propertySchema] of Object.entries(schema.properties)) { const deltas = view.propertyDeltas[propertyId] || []; const validationResult = this.validate(view.id, schemaId, view); appliedView.properties[propertyId] = { deltas, schema: propertySchema, validationResult }; // If strict validation is enabled and there are errors, throw if (strictValidation && !validationResult.valid) { throw new Error(`Schema validation failed for property '${propertyId}': ${validationResult.errors[0]?.message}`); } } // Add metadata if requested if (includeMetadata) { appliedView.metadata = { appliedAt: Date.now(), depth: 1, truncated: false }; } return appliedView; } /** * Apply schema with nested object resolution * Resolves references to other entities according to schema specifications */ applySchemaWithNesting( view: LosslessViewOne, schemaId: SchemaID, losslessView: Lossless, options: SchemaApplicationOptions = {} ): SchemaAppliedViewWithNesting { const { maxDepth = 3, includeMetadata = true, strictValidation = false } = options; const resolutionContext = new ResolutionContext(maxDepth); return this.resolveNestedView( view, schemaId, losslessView, resolutionContext, { includeMetadata, strictValidation } ); } private resolveNestedView( view: LosslessViewOne, schemaId: SchemaID, losslessView: Lossless, context: ResolutionContext, options: { includeMetadata: boolean; strictValidation: boolean } ): SchemaAppliedViewWithNesting { const schema = this.get(schemaId); if (!schema) { throw new Error(`Schema '${schemaId}' not found`); } // Check for circular reference if (context.hasVisited(view.id, schemaId)) { return this.createTruncatedView(view.id, schemaId, context.currentDepth, true); } // Check depth limit if (context.currentDepth >= context.maxDepth) { return this.createTruncatedView(view.id, schemaId, context.currentDepth, true); } // Mark this entity/schema combination as visited context.visit(view.id, schemaId); const appliedView: SchemaAppliedViewWithNesting = { id: view.id, schemaId, properties: {}, nestedObjects: {} }; // Validate the view once const overallValidationResult = this.validate(view.id, schemaId, view); // Process each property for (const [propertyId, propertySchema] of Object.entries(schema.properties)) { const deltas = view.propertyDeltas[propertyId] || []; appliedView.properties[propertyId] = { deltas, schema: propertySchema, validationResult: overallValidationResult }; // Handle reference resolution if (propertySchema.type === 'reference') { const referenceSchema = propertySchema as ReferenceSchema; const nestedViews = this.resolveReferenceProperty( deltas, referenceSchema, losslessView, context.withDepth(context.currentDepth + 1), options, view.id ); if (nestedViews.length > 0) { appliedView.nestedObjects[propertyId] = nestedViews; } } else if (propertySchema.type === 'array' && propertySchema.itemSchema?.type === 'reference') { const arraySchema = propertySchema as ArraySchema; const referenceSchema = arraySchema.itemSchema as ReferenceSchema; const nestedViews = this.resolveReferenceProperty( deltas, referenceSchema, losslessView, context.withDepth(context.currentDepth + 1), options, view.id ); if (nestedViews.length > 0) { appliedView.nestedObjects[propertyId] = nestedViews; } } // Validation error handling if (options.strictValidation && !overallValidationResult.valid) { throw new Error(`Schema validation failed for property '${propertyId}': ${overallValidationResult.errors[0]?.message}`); } } // Add metadata if (options.includeMetadata) { appliedView.metadata = { appliedAt: Date.now(), depth: context.currentDepth, truncated: context.currentDepth >= context.maxDepth }; } // Mark as unvisited when leaving this path context.unvisit(view.id, schemaId); return appliedView; } private resolveReferenceProperty( deltas: CollapsedDelta[], referenceSchema: ReferenceSchema, losslessView: Lossless, context: ResolutionContext, options: { includeMetadata: boolean; strictValidation: boolean }, parentEntityId: string ): SchemaAppliedViewWithNesting[] { const resolvedViews: SchemaAppliedViewWithNesting[] = []; const referenceDepthLimit = referenceSchema.maxDepth || context.maxDepth; // Check if we're at the reference's specific depth limit if (context.currentDepth >= referenceDepthLimit) { return []; } // Create composite objects from deltas - one per delta for (const delta of deltas) { try { const compositeObject = this.createCompositeObjectFromDelta( delta, parentEntityId, referenceSchema.targetSchema, losslessView, context, options ); if (compositeObject) { resolvedViews.push(compositeObject); } else { // Fall back to original logic for single entity references const referenceIds = this.extractReferenceIdsFromDelta(delta, parentEntityId); for (const referenceId of referenceIds) { try { // Get the referenced entity's lossless view const referencedViews = losslessView.view([referenceId]); const referencedView = referencedViews[referenceId]; if (referencedView) { // Recursively resolve the referenced entity with its target schema const nestedView = this.resolveNestedView( referencedView, referenceSchema.targetSchema, losslessView, context, options ); resolvedViews.push(nestedView); } } catch (error) { // Handle resolution errors gracefully console.warn(`Failed to resolve reference ${referenceId}:`, error); } } } } catch (error) { // Handle resolution errors gracefully console.warn(`Failed to resolve composite object from delta ${delta.id}:`, error); } } return resolvedViews; } private createCompositeObjectFromDelta( delta: CollapsedDelta, parentEntityId: string, targetSchema: SchemaID, losslessView: Lossless, context: ResolutionContext, options: { includeMetadata: boolean; strictValidation: boolean } ): SchemaAppliedViewWithNesting | null { // Group pointers by localContext, excluding the parent pointer const pointersByContext: { [localContext: string]: PropertyTypes[] } = {}; let entityReferenceCount = 0; let scalarCount = 0; for (const pointer of delta.pointers) { for (const [localContext, target] of Object.entries(pointer)) { // Skip the pointer that references the parent entity (the "up" pointer) if (typeof target === 'string' && target === parentEntityId) { continue; } if (!pointersByContext[localContext]) { pointersByContext[localContext] = []; } pointersByContext[localContext].push(target); // Count entity references vs scalars if (typeof target === 'string') { const referencedViews = losslessView.view([target]); if (referencedViews[target]) { entityReferenceCount++; } else { scalarCount++; } } else { scalarCount++; } } } // If no non-parent pointers found, return null if (Object.keys(pointersByContext).length === 0) { return null; } // Only create composite objects for deltas with multiple entity references or mixed entity/scalar // Single entity reference should use the original behavior if (entityReferenceCount === 1 && scalarCount === 0) { return null; // Let the original logic handle single entity references } // Create the composite object const nestedObjects: { [propertyId: string]: SchemaAppliedViewWithNesting[] } = {}; const scalarProperties: { [key: string]: PropertyTypes | PropertyTypes[] } = {}; for (const [localContext, targets] of Object.entries(pointersByContext)) { if (targets.length === 1) { const target = targets[0]; if (typeof target === 'string') { // Try to resolve as entity reference try { const referencedViews = losslessView.view([target]); const referencedView = referencedViews[target]; if (referencedView) { // Recursively resolve the referenced entity const nestedView = this.resolveNestedView( referencedView, targetSchema, losslessView, context, options ); nestedObjects[localContext] = [nestedView]; } else { // Not a valid entity reference, treat as scalar scalarProperties[localContext] = target; } } catch (_error) { // Failed to resolve as entity, treat as scalar scalarProperties[localContext] = target; } } else { // Scalar value scalarProperties[localContext] = target; } } else { // Multiple values for same localContext - create array const resolvedArray: (PropertyTypes | SchemaAppliedViewWithNesting)[] = []; for (const target of targets) { if (typeof target === 'string') { // Try to resolve as entity reference try { const referencedViews = losslessView.view([target]); const referencedView = referencedViews[target]; if (referencedView) { const nestedView = this.resolveNestedView( referencedView, targetSchema, losslessView, context, options ); resolvedArray.push(nestedView); } else { // Not a valid entity reference, treat as scalar resolvedArray.push(target); } } catch (_error) { // Failed to resolve as entity, treat as scalar resolvedArray.push(target); } } else { // Scalar value resolvedArray.push(target); } } // Separate entities from scalars in the array const entities: SchemaAppliedViewWithNesting[] = []; const scalars: PropertyTypes[] = []; for (const item of resolvedArray) { if (typeof item === 'object' && item !== null && 'schemaId' in item) { entities.push(item as SchemaAppliedViewWithNesting); } else { scalars.push(item as PropertyTypes); } } if (entities.length > 0) { nestedObjects[localContext] = entities; } if (scalars.length > 0) { scalarProperties[localContext] = scalars.length === 1 ? scalars[0] : scalars; } } } // Create a synthetic composite object const compositeObject = { id: `composite-${delta.id}`, // Synthetic ID for the composite object schemaId: targetSchema, properties: scalarProperties, // Custom field for scalar values nestedObjects, metadata: { appliedAt: Date.now(), depth: context.currentDepth, truncated: false } }; return compositeObject as unknown as SchemaAppliedViewWithNesting; } private extractReferenceIdsFromDelta(delta: CollapsedDelta, parentEntityId: string): string[] { const referenceIds = new Set(); // For each pointer in the delta, collect all values that aren't the parent entity for (const pointer of delta.pointers) { for (const [_key, value] of Object.entries(pointer)) { if (typeof value === 'string' && value !== parentEntityId) { // This is a potential reference - any string value that's not the parent referenceIds.add(value); } else if (typeof value === 'object' && value !== null) { // For object values, collect the entity IDs (keys) that aren't the parent for (const entityId of Object.keys(value)) { if (typeof entityId === 'string' && entityId !== parentEntityId) { referenceIds.add(entityId); } } } } } return Array.from(referenceIds); } private extractReferenceIds(deltas: CollapsedDelta[], parentEntityId: string): string[] { const referenceIds = new Set(); for (const delta of deltas) { // For each pointer in the delta, collect all values that aren't the parent entity for (const pointer of delta.pointers) { for (const [_key, value] of Object.entries(pointer)) { if (typeof value === 'string' && value !== parentEntityId) { // This is a potential reference - any string value that's not the parent referenceIds.add(value); } else if (typeof value === 'object' && value !== null) { // For object values, collect the entity IDs (keys) that aren't the parent for (const entityId of Object.keys(value)) { if (typeof entityId === 'string' && entityId !== parentEntityId) { referenceIds.add(entityId); } } } } } } return Array.from(referenceIds); } private createTruncatedView( entityId: string, schemaId: SchemaID, depth: number, truncated: boolean ): SchemaAppliedViewWithNesting { return { id: entityId, schemaId, properties: {}, nestedObjects: {}, metadata: { appliedAt: Date.now(), depth, truncated } }; } // Helper method to resolve circular dependencies getDependencyGraph(): Map> { const dependencies = new Map>(); for (const schema of this.schemas.values()) { const deps = new Set(); this.collectSchemaDependencies(schema, deps); dependencies.set(schema.id, deps); } return dependencies; } private collectSchemaDependencies(schema: ObjectSchema, deps: Set): void { for (const propertySchema of Object.values(schema.properties)) { this.collectPropertySchemaDependencies(propertySchema, deps); } } private collectPropertySchemaDependencies(schema: PropertySchema, deps: Set): void { switch (schema.type) { case 'reference': deps.add(schema.targetSchema); break; case 'array': this.collectPropertySchemaDependencies(schema.itemSchema, deps); break; } } // Check for circular dependencies hasCircularDependencies(): boolean { const dependencies = this.getDependencyGraph(); const visited = new Set(); const recursionStack = new Set(); for (const schemaId of dependencies.keys()) { if (this.hasCircularDependencyDFS(schemaId, dependencies, visited, recursionStack)) { return true; } } return false; } private hasCircularDependencyDFS( schemaId: SchemaID, dependencies: Map>, visited: Set, recursionStack: Set ): boolean { if (recursionStack.has(schemaId)) { return true; // Circular dependency found } if (visited.has(schemaId)) { return false; // Already processed } visited.add(schemaId); recursionStack.add(schemaId); const deps = dependencies.get(schemaId) || new Set(); for (const dep of deps) { if (this.hasCircularDependencyDFS(dep, dependencies, visited, recursionStack)) { return true; } } recursionStack.delete(schemaId); return false; } }