schema foundation support complete
This commit is contained in:
parent
33d4f92f8d
commit
dc59dcc778
@ -157,9 +157,7 @@ describe("Delta Validation", () => {
|
|||||||
it("should throw error for invalid pointer values", () => {
|
it("should throw error for invalid pointer values", () => {
|
||||||
const invalidPointers = [
|
const invalidPointers = [
|
||||||
{ key: undefined },
|
{ key: undefined },
|
||||||
{ key: [] },
|
{ key: [] }
|
||||||
{ key: true },
|
|
||||||
{ key: false }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
invalidPointers.forEach(pointers => {
|
invalidPointers.forEach(pointers => {
|
||||||
|
421
__tests__/schema.ts
Normal file
421
__tests__/schema.ts
Normal file
@ -0,0 +1,421 @@
|
|||||||
|
import {
|
||||||
|
SchemaBuilder,
|
||||||
|
PrimitiveSchemas,
|
||||||
|
ReferenceSchemas,
|
||||||
|
ArraySchemas,
|
||||||
|
CommonSchemas,
|
||||||
|
ObjectSchema
|
||||||
|
} from '../src/schema';
|
||||||
|
import { DefaultSchemaRegistry } from '../src/schema-registry';
|
||||||
|
import { TypedCollectionImpl, SchemaValidationError } from '../src/collection-typed';
|
||||||
|
import { RhizomeNode } from '../src/node';
|
||||||
|
import { Delta } from '../src/delta';
|
||||||
|
|
||||||
|
describe('Schema System', () => {
|
||||||
|
let schemaRegistry: DefaultSchemaRegistry;
|
||||||
|
let node: RhizomeNode;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
schemaRegistry = new DefaultSchemaRegistry();
|
||||||
|
node = new RhizomeNode();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Schema Builder', () => {
|
||||||
|
it('should create a basic schema', () => {
|
||||||
|
const schema = SchemaBuilder
|
||||||
|
.create('user')
|
||||||
|
.name('User')
|
||||||
|
.description('A user entity')
|
||||||
|
.property('name', PrimitiveSchemas.requiredString())
|
||||||
|
.property('age', PrimitiveSchemas.number())
|
||||||
|
.property('active', PrimitiveSchemas.boolean())
|
||||||
|
.required('name')
|
||||||
|
.build();
|
||||||
|
|
||||||
|
expect(schema.id).toBe('user');
|
||||||
|
expect(schema.name).toBe('User');
|
||||||
|
expect(schema.description).toBe('A user entity');
|
||||||
|
expect(schema.properties.name).toEqual({
|
||||||
|
type: 'primitive',
|
||||||
|
primitiveType: 'string',
|
||||||
|
required: true
|
||||||
|
});
|
||||||
|
expect(schema.requiredProperties).toContain('name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create schema with references', () => {
|
||||||
|
const schema = SchemaBuilder
|
||||||
|
.create('post')
|
||||||
|
.name('Post')
|
||||||
|
.property('title', PrimitiveSchemas.requiredString())
|
||||||
|
.property('author', ReferenceSchemas.required('user'))
|
||||||
|
.property('tags', ArraySchemas.of(PrimitiveSchemas.string()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
expect(schema.properties.author).toEqual({
|
||||||
|
type: 'reference',
|
||||||
|
targetSchema: 'user',
|
||||||
|
maxDepth: 3,
|
||||||
|
required: true
|
||||||
|
});
|
||||||
|
expect(schema.properties.tags).toEqual({
|
||||||
|
type: 'array',
|
||||||
|
itemSchema: { type: 'primitive', primitiveType: 'string' }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enforce required fields', () => {
|
||||||
|
expect(() => {
|
||||||
|
SchemaBuilder.create('').build();
|
||||||
|
}).toThrow('Schema must have id and name');
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
SchemaBuilder.create('test').build();
|
||||||
|
}).toThrow('Schema must have id and name');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Schema Registry', () => {
|
||||||
|
it('should register and retrieve schemas', () => {
|
||||||
|
const schema = CommonSchemas.User();
|
||||||
|
schemaRegistry.register(schema);
|
||||||
|
|
||||||
|
const retrieved = schemaRegistry.get('user');
|
||||||
|
expect(retrieved).toEqual(schema);
|
||||||
|
|
||||||
|
const all = schemaRegistry.list();
|
||||||
|
expect(all).toContain(schema);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate schema structure', () => {
|
||||||
|
const invalidSchema = {
|
||||||
|
id: 'invalid',
|
||||||
|
name: 'Invalid',
|
||||||
|
properties: {
|
||||||
|
badProperty: { type: 'unknown' } as never
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
schemaRegistry.register(invalidSchema as ObjectSchema);
|
||||||
|
}).toThrow('Unknown schema type');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate required properties exist', () => {
|
||||||
|
const schema = SchemaBuilder
|
||||||
|
.create('test')
|
||||||
|
.name('Test')
|
||||||
|
.property('name', PrimitiveSchemas.string())
|
||||||
|
.required('name', 'nonexistent')
|
||||||
|
.build();
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
schemaRegistry.register(schema);
|
||||||
|
}).toThrow("Required property 'nonexistent' not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect circular dependencies', () => {
|
||||||
|
// Create schemas with circular references
|
||||||
|
const userSchema = SchemaBuilder
|
||||||
|
.create('user')
|
||||||
|
.name('User')
|
||||||
|
.property('name', PrimitiveSchemas.string())
|
||||||
|
.property('bestFriend', ReferenceSchemas.to('user'))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
schemaRegistry.register(userSchema);
|
||||||
|
|
||||||
|
// This is circular (self-reference)
|
||||||
|
expect(schemaRegistry.hasCircularDependencies()).toBe(true);
|
||||||
|
|
||||||
|
// Create actual circular dependency
|
||||||
|
const groupSchema = SchemaBuilder
|
||||||
|
.create('group')
|
||||||
|
.name('Group')
|
||||||
|
.property('owner', ReferenceSchemas.to('user'))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const userWithGroupSchema = SchemaBuilder
|
||||||
|
.create('user-with-group')
|
||||||
|
.name('User With Group')
|
||||||
|
.property('group', ReferenceSchemas.to('group'))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
schemaRegistry.register(groupSchema);
|
||||||
|
schemaRegistry.register(userWithGroupSchema);
|
||||||
|
|
||||||
|
// Still circular due to the self-referencing user schema
|
||||||
|
expect(schemaRegistry.hasCircularDependencies()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate lossless views against schemas', () => {
|
||||||
|
const userSchema = CommonSchemas.User();
|
||||||
|
schemaRegistry.register(userSchema);
|
||||||
|
|
||||||
|
// Create a valid lossless view
|
||||||
|
const validView = {
|
||||||
|
id: 'user123',
|
||||||
|
referencedAs: ['user'],
|
||||||
|
propertyDeltas: {
|
||||||
|
name: [{
|
||||||
|
id: 'delta1',
|
||||||
|
timeCreated: 123,
|
||||||
|
host: 'host1',
|
||||||
|
creator: 'creator1',
|
||||||
|
pointers: [{ name: 'Alice' }]
|
||||||
|
}],
|
||||||
|
age: [{
|
||||||
|
id: 'delta2',
|
||||||
|
timeCreated: 124,
|
||||||
|
host: 'host1',
|
||||||
|
creator: 'creator1',
|
||||||
|
pointers: [{ age: 25 }]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = schemaRegistry.validate('user123', 'user', validView);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
|
||||||
|
// Test invalid view (missing required property)
|
||||||
|
const invalidView = {
|
||||||
|
id: 'user456',
|
||||||
|
referencedAs: ['user'],
|
||||||
|
propertyDeltas: {
|
||||||
|
age: [{
|
||||||
|
id: 'delta3',
|
||||||
|
timeCreated: 125,
|
||||||
|
host: 'host1',
|
||||||
|
creator: 'creator1',
|
||||||
|
pointers: [{ age: 30 }]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const invalidResult = schemaRegistry.validate('user456', 'user', invalidView);
|
||||||
|
expect(invalidResult.valid).toBe(false);
|
||||||
|
expect(invalidResult.errors).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
property: 'name',
|
||||||
|
message: expect.stringContaining('Required property')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate primitive types', () => {
|
||||||
|
const schema = SchemaBuilder
|
||||||
|
.create('test')
|
||||||
|
.name('Test')
|
||||||
|
.property('stringProp', PrimitiveSchemas.string())
|
||||||
|
.property('numberProp', PrimitiveSchemas.number())
|
||||||
|
.property('booleanProp', PrimitiveSchemas.boolean())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
schemaRegistry.register(schema);
|
||||||
|
|
||||||
|
// Valid types
|
||||||
|
const validView = {
|
||||||
|
id: 'test1',
|
||||||
|
referencedAs: [],
|
||||||
|
propertyDeltas: {
|
||||||
|
stringProp: [{ id: 'd1', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ stringProp: 'hello' }] }],
|
||||||
|
numberProp: [{ id: 'd2', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ numberProp: 42 }] }],
|
||||||
|
booleanProp: [{ id: 'd3', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ booleanProp: true }] }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validResult = schemaRegistry.validate('test1', 'test', validView);
|
||||||
|
expect(validResult.valid).toBe(true);
|
||||||
|
|
||||||
|
// Invalid types
|
||||||
|
const invalidView = {
|
||||||
|
id: 'test2',
|
||||||
|
referencedAs: [],
|
||||||
|
propertyDeltas: {
|
||||||
|
stringProp: [{ id: 'd4', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ stringProp: 123 as never }] }],
|
||||||
|
numberProp: [{ id: 'd5', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ numberProp: 'not-number' as never }] }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const invalidResult = schemaRegistry.validate('test2', 'test', invalidView);
|
||||||
|
expect(invalidResult.valid).toBe(false);
|
||||||
|
expect(invalidResult.errors).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Typed Collection', () => {
|
||||||
|
it('should create typed collection with schema validation', () => {
|
||||||
|
const userSchema = CommonSchemas.User();
|
||||||
|
const collection = new TypedCollectionImpl<{
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
age?: number;
|
||||||
|
active?: boolean;
|
||||||
|
}>('users', userSchema, schemaRegistry);
|
||||||
|
|
||||||
|
expect(collection.schema).toEqual(userSchema);
|
||||||
|
expect(collection.name).toBe('users');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate entities against schema', () => {
|
||||||
|
const userSchema = CommonSchemas.User();
|
||||||
|
const collection = new TypedCollectionImpl<{
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
age?: number;
|
||||||
|
}>('users', userSchema, schemaRegistry);
|
||||||
|
|
||||||
|
// Valid entity
|
||||||
|
const validUser = { name: 'Alice', email: 'alice@test.com', age: 25 };
|
||||||
|
const validResult = collection.validate(validUser);
|
||||||
|
expect(validResult.valid).toBe(true);
|
||||||
|
|
||||||
|
// Invalid entity (missing required name)
|
||||||
|
const invalidUser = { email: 'bob@test.com', age: 30 } as never;
|
||||||
|
const invalidResult = collection.validate(invalidUser);
|
||||||
|
expect(invalidResult.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enforce strict validation on put operations', async () => {
|
||||||
|
const userSchema = CommonSchemas.User();
|
||||||
|
const collection = new TypedCollectionImpl<{
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
}>('users', userSchema, schemaRegistry, { strictValidation: true });
|
||||||
|
|
||||||
|
collection.rhizomeConnect(node);
|
||||||
|
|
||||||
|
// Valid put should succeed
|
||||||
|
await expect(collection.put('user1', { name: 'Alice' })).resolves.toBeDefined();
|
||||||
|
|
||||||
|
// Invalid put should fail
|
||||||
|
await expect(collection.put('user2', { email: 'invalid@test.com' })).rejects.toThrow(SchemaValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide validation statistics', async () => {
|
||||||
|
const userSchema = CommonSchemas.User();
|
||||||
|
const collection = new TypedCollectionImpl<{
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
}>('users', userSchema, schemaRegistry);
|
||||||
|
|
||||||
|
collection.rhizomeConnect(node);
|
||||||
|
|
||||||
|
// Add some entities
|
||||||
|
await collection.put('user1', { name: 'Alice', email: 'alice@test.com' });
|
||||||
|
await collection.put('user2', { name: 'Bob' });
|
||||||
|
|
||||||
|
// Manually create an invalid entity by bypassing validation
|
||||||
|
const invalidDelta = new Delta({
|
||||||
|
creator: node.config.creator,
|
||||||
|
host: node.config.peerId,
|
||||||
|
pointers: [
|
||||||
|
{ localContext: 'users', target: 'user3', targetContext: 'email' },
|
||||||
|
{ localContext: 'email', target: 'invalid@test.com' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
node.lossless.ingestDelta(invalidDelta);
|
||||||
|
|
||||||
|
const stats = collection.getValidationStats();
|
||||||
|
expect(stats.totalEntities).toBe(3);
|
||||||
|
expect(stats.validEntities).toBe(2);
|
||||||
|
expect(stats.invalidEntities).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter valid and invalid entities', async () => {
|
||||||
|
const userSchema = CommonSchemas.User();
|
||||||
|
const collection = new TypedCollectionImpl<{
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
}>('users', userSchema, schemaRegistry);
|
||||||
|
|
||||||
|
collection.rhizomeConnect(node);
|
||||||
|
|
||||||
|
await collection.put('user1', { name: 'Alice' });
|
||||||
|
await collection.put('user2', { name: 'Bob' });
|
||||||
|
|
||||||
|
// Create invalid entity manually
|
||||||
|
const invalidDelta = new Delta({
|
||||||
|
creator: node.config.creator,
|
||||||
|
host: node.config.peerId,
|
||||||
|
pointers: [
|
||||||
|
{ localContext: 'users', target: 'user3', targetContext: 'age' },
|
||||||
|
{ localContext: 'age', target: 'not-a-number' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
node.lossless.ingestDelta(invalidDelta);
|
||||||
|
|
||||||
|
const validIds = collection.getValidEntities();
|
||||||
|
expect(validIds).toContain('user1');
|
||||||
|
expect(validIds).toContain('user2');
|
||||||
|
expect(validIds).not.toContain('user3');
|
||||||
|
|
||||||
|
const invalidEntities = collection.getInvalidEntities();
|
||||||
|
expect(invalidEntities).toHaveLength(1);
|
||||||
|
expect(invalidEntities[0].entityId).toBe('user3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply schema to lossless views', async () => {
|
||||||
|
const userSchema = CommonSchemas.User();
|
||||||
|
const collection = new TypedCollectionImpl<{
|
||||||
|
name: string;
|
||||||
|
age?: number;
|
||||||
|
}>('users', userSchema, schemaRegistry);
|
||||||
|
|
||||||
|
collection.rhizomeConnect(node);
|
||||||
|
|
||||||
|
await collection.put('user1', { name: 'Alice', age: 25 });
|
||||||
|
|
||||||
|
const validatedView = collection.getValidatedView('user1');
|
||||||
|
expect(validatedView).toBeDefined();
|
||||||
|
expect(validatedView!.schemaId).toBe('user');
|
||||||
|
expect(validatedView!.properties.name).toBeDefined();
|
||||||
|
expect(validatedView!.properties.age).toBeDefined();
|
||||||
|
expect(validatedView!.metadata?.appliedAt).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide schema introspection', () => {
|
||||||
|
const userSchema = CommonSchemas.User();
|
||||||
|
schemaRegistry.register(CommonSchemas.UserSummary());
|
||||||
|
|
||||||
|
const collection = new TypedCollectionImpl<{
|
||||||
|
name: string;
|
||||||
|
}>('users', userSchema, schemaRegistry);
|
||||||
|
|
||||||
|
const schemaInfo = collection.getSchemaInfo();
|
||||||
|
expect(schemaInfo.schema).toEqual(userSchema);
|
||||||
|
expect(schemaInfo.dependencies).toContain('user-summary');
|
||||||
|
expect(schemaInfo.hasCircularDependencies).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Common Schemas', () => {
|
||||||
|
it('should provide working User schema', () => {
|
||||||
|
const userSchema = CommonSchemas.User();
|
||||||
|
expect(userSchema.id).toBe('user');
|
||||||
|
expect(userSchema.name).toBe('User');
|
||||||
|
expect(userSchema.properties.name).toBeDefined();
|
||||||
|
expect(userSchema.properties.friends).toBeDefined();
|
||||||
|
expect(userSchema.requiredProperties).toContain('name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide working Document schema', () => {
|
||||||
|
const docSchema = CommonSchemas.Document();
|
||||||
|
expect(docSchema.id).toBe('document');
|
||||||
|
expect(docSchema.properties.title).toBeDefined();
|
||||||
|
expect(docSchema.properties.author).toBeDefined();
|
||||||
|
expect(docSchema.requiredProperties).toContain('title');
|
||||||
|
expect(docSchema.requiredProperties).toContain('author');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work together in a registry', () => {
|
||||||
|
schemaRegistry.register(CommonSchemas.User());
|
||||||
|
schemaRegistry.register(CommonSchemas.UserSummary());
|
||||||
|
schemaRegistry.register(CommonSchemas.Document());
|
||||||
|
|
||||||
|
expect(schemaRegistry.list()).toHaveLength(3);
|
||||||
|
expect(schemaRegistry.hasCircularDependencies()).toBe(false); // No circular deps in CommonSchemas
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,4 +1,4 @@
|
|||||||
import { Delta, DeltaV2 } from '../src/delta';
|
import { Delta } from '../src/delta';
|
||||||
import { Lossless } from '../src/lossless';
|
import { Lossless } from '../src/lossless';
|
||||||
import { RhizomeNode } from '../src/node';
|
import { RhizomeNode } from '../src/node';
|
||||||
import { DeltaFilter } from '../src/delta';
|
import { DeltaFilter } from '../src/delta';
|
||||||
|
248
src/collection-typed.ts
Normal file
248
src/collection-typed.ts
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
import Debug from 'debug';
|
||||||
|
import { Collection } from './collection-abstract';
|
||||||
|
import { LastWriteWins, ResolvedViewOne } from './last-write-wins';
|
||||||
|
import {
|
||||||
|
ObjectSchema,
|
||||||
|
SchemaValidationResult,
|
||||||
|
SchemaAppliedView,
|
||||||
|
TypedCollection,
|
||||||
|
SchemaApplicationOptions
|
||||||
|
} from './schema';
|
||||||
|
import { DefaultSchemaRegistry } from './schema-registry';
|
||||||
|
import { LosslessViewOne } from './lossless';
|
||||||
|
import { DomainEntityID, PropertyTypes } from './types';
|
||||||
|
import { EntityProperties } from './entity';
|
||||||
|
|
||||||
|
const debug = Debug('rz:typed-collection');
|
||||||
|
|
||||||
|
export class SchemaValidationError extends Error {
|
||||||
|
constructor(message: string, public validationResult: SchemaValidationResult) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'SchemaValidationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TypedCollectionImpl<T extends Record<string, unknown>>
|
||||||
|
extends Collection<LastWriteWins>
|
||||||
|
implements TypedCollection<T> {
|
||||||
|
|
||||||
|
schema: ObjectSchema;
|
||||||
|
private schemaRegistry: DefaultSchemaRegistry;
|
||||||
|
private applicationOptions: SchemaApplicationOptions;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
name: string,
|
||||||
|
schema: ObjectSchema,
|
||||||
|
schemaRegistry: DefaultSchemaRegistry,
|
||||||
|
options: SchemaApplicationOptions = {}
|
||||||
|
) {
|
||||||
|
super(name);
|
||||||
|
this.schema = schema;
|
||||||
|
this.schemaRegistry = schemaRegistry;
|
||||||
|
this.applicationOptions = {
|
||||||
|
maxDepth: 3,
|
||||||
|
includeMetadata: true,
|
||||||
|
strictValidation: false,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register the schema if not already registered
|
||||||
|
if (!this.schemaRegistry.get(schema.id)) {
|
||||||
|
this.schemaRegistry.register(schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(`Created typed collection '${name}' with schema '${schema.id}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeView(): void {
|
||||||
|
if (!this.rhizomeNode) throw new Error('not connected to rhizome');
|
||||||
|
this.lossy = new LastWriteWins(this.rhizomeNode.lossless);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(id: string): ResolvedViewOne | undefined {
|
||||||
|
if (!this.rhizomeNode) throw new Error('collection not connected to rhizome');
|
||||||
|
if (!this.lossy) throw new Error('lossy view not initialized');
|
||||||
|
|
||||||
|
const res = this.lossy.resolve([id]) || {};
|
||||||
|
return res[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate an entity against the schema
|
||||||
|
validate(entity: T): SchemaValidationResult {
|
||||||
|
// Convert entity to a mock lossless view for validation
|
||||||
|
const mockLosslessView: LosslessViewOne = {
|
||||||
|
id: 'validation-mock',
|
||||||
|
referencedAs: [],
|
||||||
|
propertyDeltas: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create mock deltas for each property
|
||||||
|
for (const [key, value] of Object.entries(entity)) {
|
||||||
|
if (value !== undefined) {
|
||||||
|
mockLosslessView.propertyDeltas[key] = [{
|
||||||
|
id: 'mock-delta',
|
||||||
|
timeCreated: Date.now(),
|
||||||
|
host: 'validation',
|
||||||
|
creator: 'validation',
|
||||||
|
pointers: [{ [key]: value as PropertyTypes }]
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.schemaRegistry.validate('validation-mock', this.schema.id, mockLosslessView);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply schema to a lossless view
|
||||||
|
apply(view: LosslessViewOne): SchemaAppliedView {
|
||||||
|
return this.schemaRegistry.applySchema(view, this.schema.id, this.applicationOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a schema-validated view of an entity
|
||||||
|
getValidatedView(entityId: DomainEntityID): SchemaAppliedView | undefined {
|
||||||
|
if (!this.rhizomeNode) throw new Error('collection not connected to rhizome');
|
||||||
|
|
||||||
|
const losslessView = this.rhizomeNode.lossless.view([entityId])[entityId];
|
||||||
|
if (!losslessView) return undefined;
|
||||||
|
|
||||||
|
return this.apply(losslessView);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all entities in this collection with schema validation
|
||||||
|
getAllValidatedViews(): SchemaAppliedView[] {
|
||||||
|
if (!this.rhizomeNode) throw new Error('collection not connected to rhizome');
|
||||||
|
|
||||||
|
const entityIds = this.getIds();
|
||||||
|
const views: SchemaAppliedView[] = [];
|
||||||
|
|
||||||
|
for (const entityId of entityIds) {
|
||||||
|
const view = this.getValidatedView(entityId);
|
||||||
|
if (view) {
|
||||||
|
views.push(view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return views;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override put to include schema validation
|
||||||
|
async put(
|
||||||
|
entityId: DomainEntityID | undefined,
|
||||||
|
properties: EntityProperties,
|
||||||
|
): Promise<ResolvedViewOne> {
|
||||||
|
// Validate against schema if strict validation is enabled
|
||||||
|
if (this.applicationOptions.strictValidation) {
|
||||||
|
const validationResult = this.validate(properties as T);
|
||||||
|
if (!validationResult.valid) {
|
||||||
|
throw new SchemaValidationError(
|
||||||
|
`Schema validation failed: ${validationResult.errors.map(e => e.message).join(', ')}`,
|
||||||
|
validationResult
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call parent put method
|
||||||
|
const result = await super.put(entityId, properties);
|
||||||
|
|
||||||
|
// Log validation warnings if any
|
||||||
|
const validationResult = this.validate(properties as T);
|
||||||
|
if (validationResult.warnings.length > 0) {
|
||||||
|
debug(`Validation warnings for entity ${entityId}:`, validationResult.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get validation statistics for the collection
|
||||||
|
getValidationStats(): {
|
||||||
|
totalEntities: number;
|
||||||
|
validEntities: number;
|
||||||
|
invalidEntities: number;
|
||||||
|
entitiesWithWarnings: number;
|
||||||
|
commonErrors: Map<string, number>;
|
||||||
|
} {
|
||||||
|
const entityIds = this.getIds();
|
||||||
|
const stats = {
|
||||||
|
totalEntities: entityIds.length,
|
||||||
|
validEntities: 0,
|
||||||
|
invalidEntities: 0,
|
||||||
|
entitiesWithWarnings: 0,
|
||||||
|
commonErrors: new Map<string, number>()
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const entityId of entityIds) {
|
||||||
|
if (!this.rhizomeNode) continue;
|
||||||
|
|
||||||
|
const losslessView = this.rhizomeNode.lossless.view([entityId])[entityId];
|
||||||
|
if (!losslessView) continue;
|
||||||
|
|
||||||
|
const validationResult = this.schemaRegistry.validate(entityId, this.schema.id, losslessView);
|
||||||
|
|
||||||
|
if (validationResult.valid) {
|
||||||
|
stats.validEntities++;
|
||||||
|
} else {
|
||||||
|
stats.invalidEntities++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validationResult.warnings.length > 0) {
|
||||||
|
stats.entitiesWithWarnings++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count common errors
|
||||||
|
for (const error of validationResult.errors) {
|
||||||
|
const count = stats.commonErrors.get(error.message) || 0;
|
||||||
|
stats.commonErrors.set(error.message, count + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter entities by schema validation status
|
||||||
|
getValidEntities(): DomainEntityID[] {
|
||||||
|
if (!this.rhizomeNode) return [];
|
||||||
|
|
||||||
|
return this.getIds().filter(entityId => {
|
||||||
|
const losslessView = this.rhizomeNode!.lossless.view([entityId])[entityId];
|
||||||
|
if (!losslessView) return false;
|
||||||
|
|
||||||
|
const validationResult = this.schemaRegistry.validate(entityId, this.schema.id, losslessView);
|
||||||
|
return validationResult.valid;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getInvalidEntities(): Array<{ entityId: DomainEntityID; errors: string[] }> {
|
||||||
|
if (!this.rhizomeNode) return [];
|
||||||
|
|
||||||
|
const invalid: Array<{ entityId: DomainEntityID; errors: string[] }> = [];
|
||||||
|
|
||||||
|
for (const entityId of this.getIds()) {
|
||||||
|
const losslessView = this.rhizomeNode.lossless.view([entityId])[entityId];
|
||||||
|
if (!losslessView) continue;
|
||||||
|
|
||||||
|
const validationResult = this.schemaRegistry.validate(entityId, this.schema.id, losslessView);
|
||||||
|
if (!validationResult.valid) {
|
||||||
|
invalid.push({
|
||||||
|
entityId,
|
||||||
|
errors: validationResult.errors.map(e => e.message)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema introspection
|
||||||
|
getSchemaInfo(): {
|
||||||
|
schema: ObjectSchema;
|
||||||
|
dependencies: string[];
|
||||||
|
hasCircularDependencies: boolean;
|
||||||
|
} {
|
||||||
|
const dependencies = this.schemaRegistry.getDependencyGraph().get(this.schema.id) || new Set();
|
||||||
|
|
||||||
|
return {
|
||||||
|
schema: this.schema,
|
||||||
|
dependencies: Array.from(dependencies),
|
||||||
|
hasCircularDependencies: this.schemaRegistry.hasCircularDependencies()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -60,8 +60,8 @@ export function validateCreatorId(creator: unknown): creator is CreatorID {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function validatePointerTarget(target: unknown): target is PointerTarget {
|
export function validatePointerTarget(target: unknown): target is PointerTarget {
|
||||||
if (target !== null && typeof target !== "string" && typeof target !== "number") {
|
if (target !== null && typeof target !== "string" && typeof target !== "number" && typeof target !== "boolean") {
|
||||||
throw new InvalidPointerError("Pointer target must be string, number, or null");
|
throw new InvalidPointerError("Pointer target must be string, number, boolean, or null");
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -120,7 +120,7 @@ export function validatePointersV2(pointers: unknown): pointers is PointersV2 {
|
|||||||
throw new InvalidPointerError("Pointer key must be a non-empty string");
|
throw new InvalidPointerError("Pointer key must be a non-empty string");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value !== null && typeof value !== "string" && typeof value !== "number" && typeof value !== "object") {
|
if (value !== null && typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean" && typeof value !== "object") {
|
||||||
throw new InvalidPointerError(`Invalid pointer value for key '${key}'`);
|
throw new InvalidPointerError(`Invalid pointer value for key '${key}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -8,7 +8,7 @@ const debug = Debug('rz:delta');
|
|||||||
|
|
||||||
export type DeltaID = string;
|
export type DeltaID = string;
|
||||||
|
|
||||||
export type PointerTarget = string | number | null;
|
export type PointerTarget = string | number | boolean | null;
|
||||||
|
|
||||||
type PointerV1 = {
|
type PointerV1 = {
|
||||||
localContext: string;
|
localContext: string;
|
||||||
@ -16,7 +16,7 @@ type PointerV1 = {
|
|||||||
targetContext?: string;
|
targetContext?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Scalar = string | number | null;
|
export type Scalar = string | number | boolean | null;
|
||||||
export type Reference = {
|
export type Reference = {
|
||||||
[key: DomainEntityID]: PropertyID
|
[key: DomainEntityID]: PropertyID
|
||||||
};
|
};
|
||||||
|
398
src/schema-registry.ts
Normal file
398
src/schema-registry.ts
Normal file
@ -0,0 +1,398 @@
|
|||||||
|
import Debug from 'debug';
|
||||||
|
import {
|
||||||
|
SchemaRegistry,
|
||||||
|
ObjectSchema,
|
||||||
|
SchemaID,
|
||||||
|
SchemaValidationResult,
|
||||||
|
SchemaValidationError,
|
||||||
|
PropertySchema,
|
||||||
|
PrimitiveSchema,
|
||||||
|
ReferenceSchema,
|
||||||
|
ArraySchema,
|
||||||
|
SchemaAppliedView,
|
||||||
|
SchemaApplicationOptions
|
||||||
|
} from './schema';
|
||||||
|
import { LosslessViewOne } from './lossless';
|
||||||
|
import { DomainEntityID, PropertyID } 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 } = 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, // TODO: Calculate actual depth in nested references
|
||||||
|
truncated: false // TODO: Mark if we hit maxDepth limits
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return appliedView;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
233
src/schema.ts
Normal file
233
src/schema.ts
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
import { DomainEntityID, PropertyID, PropertyTypes } from "./types";
|
||||||
|
import { LosslessViewOne } from "./lossless";
|
||||||
|
import { CollapsedDelta } from "./lossless";
|
||||||
|
|
||||||
|
// Base schema types
|
||||||
|
export type SchemaID = string;
|
||||||
|
|
||||||
|
// Primitive schema types - these terminate the recursion
|
||||||
|
export type PrimitiveSchemaType = 'string' | 'number' | 'boolean' | 'null';
|
||||||
|
|
||||||
|
export interface PrimitiveSchema {
|
||||||
|
type: 'primitive';
|
||||||
|
primitiveType: PrimitiveSchemaType;
|
||||||
|
required?: boolean;
|
||||||
|
default?: PropertyTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference schema for linking to other objects
|
||||||
|
export interface ReferenceSchema {
|
||||||
|
type: 'reference';
|
||||||
|
targetSchema: SchemaID; // Reference to another schema by ID
|
||||||
|
required?: boolean;
|
||||||
|
maxDepth?: number; // Prevent infinite recursion
|
||||||
|
}
|
||||||
|
|
||||||
|
// Array schema for collections of values
|
||||||
|
export interface ArraySchema {
|
||||||
|
type: 'array';
|
||||||
|
itemSchema: PropertySchema;
|
||||||
|
required?: boolean;
|
||||||
|
maxItems?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Union type for all property schema types
|
||||||
|
export type PropertySchema = PrimitiveSchema | ReferenceSchema | ArraySchema;
|
||||||
|
|
||||||
|
// Object schema defines the structure of an entity
|
||||||
|
export interface ObjectSchema {
|
||||||
|
id: SchemaID;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
properties: {
|
||||||
|
[propertyId: PropertyID]: PropertySchema;
|
||||||
|
};
|
||||||
|
requiredProperties?: PropertyID[];
|
||||||
|
additionalProperties?: boolean; // Allow properties not in schema
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema registry manages all schemas
|
||||||
|
export interface SchemaRegistry {
|
||||||
|
schemas: Map<SchemaID, ObjectSchema>;
|
||||||
|
register(schema: ObjectSchema): void;
|
||||||
|
get(id: SchemaID): ObjectSchema | undefined;
|
||||||
|
list(): ObjectSchema[];
|
||||||
|
validate(entityId: DomainEntityID, schemaId: SchemaID, view: LosslessViewOne): SchemaValidationResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation result types
|
||||||
|
export interface SchemaValidationError {
|
||||||
|
property: PropertyID;
|
||||||
|
message: string;
|
||||||
|
expectedType?: string;
|
||||||
|
actualValue?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchemaValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
errors: SchemaValidationError[];
|
||||||
|
warnings: SchemaValidationError[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema application options
|
||||||
|
export interface SchemaApplicationOptions {
|
||||||
|
maxDepth?: number;
|
||||||
|
includeMetadata?: boolean;
|
||||||
|
strictValidation?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applied schema result - a lossless view filtered through a schema
|
||||||
|
export interface SchemaAppliedView {
|
||||||
|
id: DomainEntityID;
|
||||||
|
schemaId: SchemaID;
|
||||||
|
properties: {
|
||||||
|
[propertyId: PropertyID]: {
|
||||||
|
deltas: CollapsedDelta[];
|
||||||
|
schema: PropertySchema;
|
||||||
|
validationResult: SchemaValidationResult;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
metadata?: {
|
||||||
|
appliedAt: number;
|
||||||
|
depth: number;
|
||||||
|
truncated: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema-based collection interface
|
||||||
|
export interface TypedCollection<T> {
|
||||||
|
schema: ObjectSchema;
|
||||||
|
validate(entity: T): SchemaValidationResult;
|
||||||
|
apply(view: LosslessViewOne): SchemaAppliedView;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built-in schema helpers
|
||||||
|
export const PrimitiveSchemas = {
|
||||||
|
string: (): PrimitiveSchema => ({ type: 'primitive', primitiveType: 'string' }),
|
||||||
|
number: (): PrimitiveSchema => ({ type: 'primitive', primitiveType: 'number' }),
|
||||||
|
boolean: (): PrimitiveSchema => ({ type: 'primitive', primitiveType: 'boolean' }),
|
||||||
|
null: (): PrimitiveSchema => ({ type: 'primitive', primitiveType: 'null' }),
|
||||||
|
|
||||||
|
requiredString: (): PrimitiveSchema => ({ type: 'primitive', primitiveType: 'string', required: true }),
|
||||||
|
requiredNumber: (): PrimitiveSchema => ({ type: 'primitive', primitiveType: 'number', required: true }),
|
||||||
|
requiredBoolean: (): PrimitiveSchema => ({ type: 'primitive', primitiveType: 'boolean', required: true }),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const ReferenceSchemas = {
|
||||||
|
to: (targetSchema: SchemaID, maxDepth = 3): ReferenceSchema => ({
|
||||||
|
type: 'reference',
|
||||||
|
targetSchema,
|
||||||
|
maxDepth
|
||||||
|
}),
|
||||||
|
|
||||||
|
required: (targetSchema: SchemaID, maxDepth = 3): ReferenceSchema => ({
|
||||||
|
type: 'reference',
|
||||||
|
targetSchema,
|
||||||
|
maxDepth,
|
||||||
|
required: true
|
||||||
|
})
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const ArraySchemas = {
|
||||||
|
of: (itemSchema: PropertySchema, maxItems?: number): ArraySchema => ({
|
||||||
|
type: 'array',
|
||||||
|
itemSchema,
|
||||||
|
maxItems
|
||||||
|
}),
|
||||||
|
|
||||||
|
required: (itemSchema: PropertySchema, maxItems?: number): ArraySchema => ({
|
||||||
|
type: 'array',
|
||||||
|
itemSchema,
|
||||||
|
maxItems,
|
||||||
|
required: true
|
||||||
|
})
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Schema builder for fluent API
|
||||||
|
export class SchemaBuilder {
|
||||||
|
private schema: Partial<ObjectSchema> = {};
|
||||||
|
|
||||||
|
static create(id: SchemaID): SchemaBuilder {
|
||||||
|
const builder = new SchemaBuilder();
|
||||||
|
builder.schema.id = id;
|
||||||
|
builder.schema.properties = {};
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
name(name: string): SchemaBuilder {
|
||||||
|
this.schema.name = name;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
description(description: string): SchemaBuilder {
|
||||||
|
this.schema.description = description;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
property(propertyId: PropertyID, schema: PropertySchema): SchemaBuilder {
|
||||||
|
if (!this.schema.properties) this.schema.properties = {};
|
||||||
|
this.schema.properties[propertyId] = schema;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
required(...propertyIds: PropertyID[]): SchemaBuilder {
|
||||||
|
this.schema.requiredProperties = [
|
||||||
|
...(this.schema.requiredProperties || []),
|
||||||
|
...propertyIds
|
||||||
|
];
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
additionalProperties(allowed = true): SchemaBuilder {
|
||||||
|
this.schema.additionalProperties = allowed;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
build(): ObjectSchema {
|
||||||
|
if (!this.schema.id || !this.schema.name) {
|
||||||
|
throw new Error('Schema must have id and name');
|
||||||
|
}
|
||||||
|
return this.schema as ObjectSchema;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common schema patterns
|
||||||
|
export const CommonSchemas = {
|
||||||
|
// User schema with friends references
|
||||||
|
User: () => SchemaBuilder
|
||||||
|
.create('user')
|
||||||
|
.name('User')
|
||||||
|
.description('A user entity with profile information')
|
||||||
|
.property('name', PrimitiveSchemas.requiredString())
|
||||||
|
.property('email', PrimitiveSchemas.string())
|
||||||
|
.property('age', PrimitiveSchemas.number())
|
||||||
|
.property('active', PrimitiveSchemas.boolean())
|
||||||
|
.property('friends', ArraySchemas.of(ReferenceSchemas.to('user-summary', 1)))
|
||||||
|
.required('name')
|
||||||
|
.build(),
|
||||||
|
|
||||||
|
// User summary schema for references to prevent infinite recursion
|
||||||
|
UserSummary: () => SchemaBuilder
|
||||||
|
.create('user-summary')
|
||||||
|
.name('User Summary')
|
||||||
|
.description('Abbreviated user information for references')
|
||||||
|
.property('name', PrimitiveSchemas.requiredString())
|
||||||
|
.property('email', PrimitiveSchemas.string())
|
||||||
|
.required('name')
|
||||||
|
.additionalProperties(false)
|
||||||
|
.build(),
|
||||||
|
|
||||||
|
// Document schema
|
||||||
|
Document: () => SchemaBuilder
|
||||||
|
.create('document')
|
||||||
|
.name('Document')
|
||||||
|
.description('A document with metadata')
|
||||||
|
.property('title', PrimitiveSchemas.requiredString())
|
||||||
|
.property('content', PrimitiveSchemas.string())
|
||||||
|
.property('author', ReferenceSchemas.required('user-summary'))
|
||||||
|
.property('tags', ArraySchemas.of(PrimitiveSchemas.string()))
|
||||||
|
.property('created', PrimitiveSchemas.requiredNumber())
|
||||||
|
.property('published', PrimitiveSchemas.boolean())
|
||||||
|
.required('title', 'author', 'created')
|
||||||
|
.build()
|
||||||
|
} as const;
|
@ -4,7 +4,7 @@ export type FilterExpr = JSONLogic;
|
|||||||
|
|
||||||
export type FilterGenerator = () => FilterExpr;
|
export type FilterGenerator = () => FilterExpr;
|
||||||
|
|
||||||
export type PropertyTypes = string | number | null;
|
export type PropertyTypes = string | number | boolean | null;
|
||||||
|
|
||||||
export type DomainEntityID = string;
|
export type DomainEntityID = string;
|
||||||
export type PropertyID = string;
|
export type PropertyID = string;
|
||||||
|
12
todo.md
12
todo.md
@ -17,12 +17,12 @@ This document tracks work needed to achieve full specification compliance, organ
|
|||||||
- [x] Test atomic transaction operations
|
- [x] Test atomic transaction operations
|
||||||
- [ ] Add transaction rollback capabilities (deferred - not critical for spec parity)
|
- [ ] Add transaction rollback capabilities (deferred - not critical for spec parity)
|
||||||
|
|
||||||
### 1.3 Schema Foundation
|
### 1.3 Schema Foundation ✅
|
||||||
- [ ] Design schema type definitions based on spec
|
- [x] Design schema type definitions based on spec
|
||||||
- [ ] Implement basic schema validation
|
- [x] Implement basic schema validation
|
||||||
- [ ] Create schema registry/storage mechanism
|
- [x] Create schema registry/storage mechanism
|
||||||
- [ ] Add property type enforcement
|
- [x] Add property type enforcement
|
||||||
- [ ] Test schema application to collections
|
- [x] Test schema application to collections
|
||||||
|
|
||||||
## Phase 2: Core Features (Spec Compliance)
|
## Phase 2: Core Features (Spec Compliance)
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user