rhizome-node/src/schema-registry.ts

805 lines
25 KiB
TypeScript

import Debug from 'debug';
import {
SchemaRegistry,
ObjectSchema,
SchemaID,
SchemaValidationResult,
SchemaValidationError,
PropertySchema,
PrimitiveSchema,
ReferenceSchema,
ArraySchema,
SchemaAppliedView,
SchemaAppliedViewWithNesting,
SchemaApplicationOptions,
ResolutionContext
} from './schema';
import { LosslessViewOne, Lossless } from './lossless';
import { DomainEntityID, PropertyID, PropertyTypes } from './types';
import { CollapsedDelta } from './lossless';
const debug = Debug('rz:schema-registry');
export class DefaultSchemaRegistry implements SchemaRegistry {
schemas = new Map<SchemaID, ObjectSchema>();
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<string>();
// 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<string>();
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<SchemaID, Set<SchemaID>> {
const dependencies = new Map<SchemaID, Set<SchemaID>>();
for (const schema of this.schemas.values()) {
const deps = new Set<SchemaID>();
this.collectSchemaDependencies(schema, deps);
dependencies.set(schema.id, deps);
}
return dependencies;
}
private collectSchemaDependencies(schema: ObjectSchema, deps: Set<SchemaID>): void {
for (const propertySchema of Object.values(schema.properties)) {
this.collectPropertySchemaDependencies(propertySchema, deps);
}
}
private collectPropertySchemaDependencies(schema: PropertySchema, deps: Set<SchemaID>): 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<SchemaID>();
const recursionStack = new Set<SchemaID>();
for (const schemaId of dependencies.keys()) {
if (this.hasCircularDependencyDFS(schemaId, dependencies, visited, recursionStack)) {
return true;
}
}
return false;
}
private hasCircularDependencyDFS(
schemaId: SchemaID,
dependencies: Map<SchemaID, Set<SchemaID>>,
visited: Set<SchemaID>,
recursionStack: Set<SchemaID>
): 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;
}
}