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", () => {
|
||||
const invalidPointers = [
|
||||
{ key: undefined },
|
||||
{ key: [] },
|
||||
{ key: true },
|
||||
{ key: false }
|
||||
{ key: [] }
|
||||
];
|
||||
|
||||
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 { RhizomeNode } from '../src/node';
|
||||
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 {
|
||||
if (target !== null && typeof target !== "string" && typeof target !== "number") {
|
||||
throw new InvalidPointerError("Pointer target must be string, number, or null");
|
||||
if (target !== null && typeof target !== "string" && typeof target !== "number" && typeof target !== "boolean") {
|
||||
throw new InvalidPointerError("Pointer target must be string, number, boolean, or null");
|
||||
}
|
||||
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");
|
||||
}
|
||||
|
||||
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}'`);
|
||||
}
|
||||
|
||||
|
@ -8,7 +8,7 @@ const debug = Debug('rz:delta');
|
||||
|
||||
export type DeltaID = string;
|
||||
|
||||
export type PointerTarget = string | number | null;
|
||||
export type PointerTarget = string | number | boolean | null;
|
||||
|
||||
type PointerV1 = {
|
||||
localContext: string;
|
||||
@ -16,7 +16,7 @@ type PointerV1 = {
|
||||
targetContext?: string;
|
||||
};
|
||||
|
||||
export type Scalar = string | number | null;
|
||||
export type Scalar = string | number | boolean | null;
|
||||
export type Reference = {
|
||||
[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 PropertyTypes = string | number | null;
|
||||
export type PropertyTypes = string | number | boolean | null;
|
||||
|
||||
export type DomainEntityID = 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
|
||||
- [ ] Add transaction rollback capabilities (deferred - not critical for spec parity)
|
||||
|
||||
### 1.3 Schema Foundation
|
||||
- [ ] Design schema type definitions based on spec
|
||||
- [ ] Implement basic schema validation
|
||||
- [ ] Create schema registry/storage mechanism
|
||||
- [ ] Add property type enforcement
|
||||
- [ ] Test schema application to collections
|
||||
### 1.3 Schema Foundation ✅
|
||||
- [x] Design schema type definitions based on spec
|
||||
- [x] Implement basic schema validation
|
||||
- [x] Create schema registry/storage mechanism
|
||||
- [x] Add property type enforcement
|
||||
- [x] Test schema application to collections
|
||||
|
||||
## Phase 2: Core Features (Spec Compliance)
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user