major changes and feature additions #1
246
__tests__/compose-decompose.ts
Normal file
246
__tests__/compose-decompose.ts
Normal file
@ -0,0 +1,246 @@
|
||||
/**
|
||||
|
||||
* Tests for lossless view compose() and decompose() bidirectional conversion
|
||||
* Ensures that deltas can be composed into lossless views and decomposed back
|
||||
* to the original deltas with all pointer relationships preserved.
|
||||
*/
|
||||
|
||||
import { RhizomeNode } from '../src/node';
|
||||
import { Delta } from '../src/delta';
|
||||
|
||||
describe('Lossless View Compose/Decompose', () => {
|
||||
let node: RhizomeNode;
|
||||
|
||||
beforeEach(() => {
|
||||
node = new RhizomeNode();
|
||||
});
|
||||
|
||||
describe('Bidirectional Conversion', () => {
|
||||
it('should compose and decompose simple entity deltas correctly', () => {
|
||||
// Create simple entity deltas
|
||||
const nameDeltas = [
|
||||
new Delta({
|
||||
creator: 'test-creator',
|
||||
host: 'test-host',
|
||||
pointers: [
|
||||
{ localContext: 'users', target: 'alice', targetContext: 'name' },
|
||||
{ localContext: 'name', target: 'Alice Smith' }
|
||||
]
|
||||
}),
|
||||
new Delta({
|
||||
creator: 'test-creator',
|
||||
host: 'test-host',
|
||||
pointers: [
|
||||
{ localContext: 'users', target: 'alice', targetContext: 'email' },
|
||||
{ localContext: 'email', target: 'alice@example.com' }
|
||||
]
|
||||
})
|
||||
];
|
||||
|
||||
// Ingest the deltas
|
||||
nameDeltas.forEach(delta => node.lossless.ingestDelta(delta));
|
||||
|
||||
// Compose lossless view
|
||||
const composed = node.lossless.compose(['alice']);
|
||||
const aliceView = composed['alice'];
|
||||
|
||||
expect(aliceView).toBeDefined();
|
||||
expect(aliceView.id).toBe('alice');
|
||||
expect(aliceView.propertyDeltas.name).toHaveLength(1);
|
||||
expect(aliceView.propertyDeltas.email).toHaveLength(1);
|
||||
|
||||
// Decompose back to deltas
|
||||
const decomposed = node.lossless.decompose(aliceView);
|
||||
|
||||
expect(decomposed).toHaveLength(2);
|
||||
|
||||
// Check that original deltas are preserved
|
||||
const originalIds = nameDeltas.map(d => d.id).sort();
|
||||
const decomposedIds = decomposed.map(d => d.id).sort();
|
||||
expect(decomposedIds).toEqual(originalIds);
|
||||
|
||||
// Verify pointer structure is preserved
|
||||
const nameDataDelta = decomposed.find(d =>
|
||||
d.pointers.some(p => p.localContext === 'name' && p.target === 'Alice Smith')
|
||||
);
|
||||
expect(nameDataDelta).toBeDefined();
|
||||
expect(nameDataDelta?.pointers).toHaveLength(2);
|
||||
|
||||
const upPointer = nameDataDelta?.pointers.find(p => p.targetContext === 'name');
|
||||
expect(upPointer).toBeDefined();
|
||||
expect(upPointer?.target).toBe('alice');
|
||||
expect(upPointer?.localContext).toBe('users');
|
||||
});
|
||||
|
||||
it('should handle multi-pointer relationship deltas correctly', () => {
|
||||
// Create a complex relationship delta
|
||||
const relationshipDelta = new Delta({
|
||||
creator: 'test-creator',
|
||||
host: 'test-host',
|
||||
pointers: [
|
||||
{ localContext: 'users', target: 'alice', targetContext: 'relationships' },
|
||||
{ localContext: 'partner', target: 'bob' },
|
||||
{ localContext: 'type', target: 'friendship' },
|
||||
{ localContext: 'since', target: '2020-01-15' },
|
||||
{ localContext: 'intensity', target: 8 }
|
||||
]
|
||||
});
|
||||
|
||||
node.lossless.ingestDelta(relationshipDelta);
|
||||
|
||||
// Compose and decompose
|
||||
const composed = node.lossless.compose(['alice']);
|
||||
const aliceView = composed['alice'];
|
||||
const decomposed = node.lossless.decompose(aliceView);
|
||||
|
||||
expect(decomposed).toHaveLength(1);
|
||||
const reconstituted = decomposed[0];
|
||||
|
||||
// Should have all 5 pointers
|
||||
expect(reconstituted.pointers).toHaveLength(5);
|
||||
|
||||
// Check that all pointer types are preserved
|
||||
const contexts = reconstituted.pointers.map(p => p.localContext).sort();
|
||||
expect(contexts).toEqual(['users', 'partner', 'type', 'since', 'intensity'].sort());
|
||||
|
||||
// Check that the "up" pointer to alice is correctly reconstructed
|
||||
const upPointer = reconstituted.pointers.find(p => p.targetContext === 'relationships');
|
||||
expect(upPointer).toBeDefined();
|
||||
expect(upPointer?.target).toBe('alice');
|
||||
expect(upPointer?.localContext).toBe('users');
|
||||
|
||||
// Check scalar values are preserved
|
||||
const intensityPointer = reconstituted.pointers.find(p => p.localContext === 'intensity');
|
||||
expect(intensityPointer?.target).toBe(8);
|
||||
});
|
||||
|
||||
it('should handle reference relationships correctly', () => {
|
||||
// Create entities first
|
||||
const aliceDelta = new Delta({
|
||||
creator: 'test-creator',
|
||||
host: 'test-host',
|
||||
pointers: [
|
||||
{ localContext: 'users', target: 'alice', targetContext: 'name' },
|
||||
{ localContext: 'name', target: 'Alice' }
|
||||
]
|
||||
});
|
||||
|
||||
const bobDelta = new Delta({
|
||||
creator: 'test-creator',
|
||||
host: 'test-host',
|
||||
pointers: [
|
||||
{ localContext: 'users', target: 'bob', targetContext: 'name' },
|
||||
{ localContext: 'name', target: 'Bob' }
|
||||
]
|
||||
});
|
||||
|
||||
// Create friendship relationship
|
||||
const friendshipDelta = new Delta({
|
||||
creator: 'test-creator',
|
||||
host: 'test-host',
|
||||
pointers: [
|
||||
{ localContext: 'users', target: 'alice', targetContext: 'friends' },
|
||||
{ localContext: 'friend', target: 'bob', targetContext: 'friends' }
|
||||
]
|
||||
});
|
||||
|
||||
[aliceDelta, bobDelta, friendshipDelta].forEach(d => node.lossless.ingestDelta(d));
|
||||
|
||||
// Compose Alice's view
|
||||
const composed = node.lossless.compose(['alice']);
|
||||
const aliceView = composed['alice'];
|
||||
|
||||
expect(aliceView.propertyDeltas.friends).toHaveLength(1);
|
||||
|
||||
// Decompose and verify the friendship delta is correctly reconstructed
|
||||
const decomposed = node.lossless.decompose(aliceView);
|
||||
const friendshipReconstituted = decomposed.find(d =>
|
||||
d.pointers.some(p => p.localContext === 'friend')
|
||||
);
|
||||
|
||||
expect(friendshipReconstituted).toBeDefined();
|
||||
expect(friendshipReconstituted?.pointers).toHaveLength(2);
|
||||
|
||||
// Check both reference pointers are preserved
|
||||
const alicePointer = friendshipReconstituted?.pointers.find(p => p.target === 'alice');
|
||||
const bobPointer = friendshipReconstituted?.pointers.find(p => p.target === 'bob');
|
||||
|
||||
expect(alicePointer).toBeDefined();
|
||||
expect(alicePointer?.targetContext).toBe('friends');
|
||||
expect(bobPointer).toBeDefined();
|
||||
expect(bobPointer?.targetContext).toBe('friends');
|
||||
});
|
||||
|
||||
it('should preserve delta metadata correctly', () => {
|
||||
const originalDelta = new Delta({
|
||||
creator: 'test-creator',
|
||||
host: 'test-host',
|
||||
pointers: [
|
||||
{ localContext: 'users', target: 'alice', targetContext: 'name' },
|
||||
{ localContext: 'name', target: 'Alice' }
|
||||
]
|
||||
});
|
||||
|
||||
node.lossless.ingestDelta(originalDelta);
|
||||
|
||||
const composed = node.lossless.compose(['alice']);
|
||||
const decomposed = node.lossless.decompose(composed['alice']);
|
||||
|
||||
expect(decomposed).toHaveLength(1);
|
||||
const reconstituted = decomposed[0];
|
||||
|
||||
// Check metadata preservation
|
||||
expect(reconstituted.id).toBe(originalDelta.id);
|
||||
expect(reconstituted.creator).toBe(originalDelta.creator);
|
||||
expect(reconstituted.host).toBe(originalDelta.host);
|
||||
expect(reconstituted.timeCreated).toBe(originalDelta.timeCreated);
|
||||
});
|
||||
|
||||
it('should handle multiple deltas for the same property', () => {
|
||||
// Create multiple name changes for alice
|
||||
const nameDeltas = [
|
||||
new Delta({
|
||||
creator: 'test-creator',
|
||||
host: 'test-host',
|
||||
pointers: [
|
||||
{ localContext: 'users', target: 'alice', targetContext: 'name' },
|
||||
{ localContext: 'name', target: 'Alice' }
|
||||
]
|
||||
}),
|
||||
new Delta({
|
||||
creator: 'test-creator',
|
||||
host: 'test-host',
|
||||
pointers: [
|
||||
{ localContext: 'users', target: 'alice', targetContext: 'name' },
|
||||
{ localContext: 'name', target: 'Alice Smith' }
|
||||
]
|
||||
}),
|
||||
new Delta({
|
||||
creator: 'test-creator',
|
||||
host: 'test-host',
|
||||
pointers: [
|
||||
{ localContext: 'users', target: 'alice', targetContext: 'name' },
|
||||
{ localContext: 'name', target: 'Alice Johnson' }
|
||||
]
|
||||
})
|
||||
];
|
||||
|
||||
nameDeltas.forEach(d => node.lossless.ingestDelta(d));
|
||||
|
||||
const composed = node.lossless.compose(['alice']);
|
||||
const aliceView = composed['alice'];
|
||||
|
||||
// Should have 3 deltas for the name property
|
||||
expect(aliceView.propertyDeltas.name).toHaveLength(3);
|
||||
|
||||
const decomposed = node.lossless.decompose(aliceView);
|
||||
|
||||
// Should decompose back to 3 separate deltas
|
||||
expect(decomposed).toHaveLength(3);
|
||||
|
||||
// All original delta IDs should be preserved
|
||||
const originalIds = nameDeltas.map(d => d.id).sort();
|
||||
const decomposedIds = decomposed.map(d => d.id).sort();
|
||||
expect(decomposedIds).toEqual(originalIds);
|
||||
});
|
||||
});
|
||||
});
|
307
__tests__/multi-pointer-resolution.ts
Normal file
307
__tests__/multi-pointer-resolution.ts
Normal file
@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Tests for nested object resolution with deltas containing 3+ pointers
|
||||
* This tests the complex case where a single delta establishes relationships
|
||||
* between multiple entities and includes scalar values.
|
||||
*/
|
||||
|
||||
import { RhizomeNode } from '../src/node';
|
||||
import { Delta } from '../src/delta';
|
||||
import { DefaultSchemaRegistry } from '../src/schema-registry';
|
||||
import { SchemaBuilder, PrimitiveSchemas, ReferenceSchemas, SchemaAppliedViewWithNesting } from '../src/schema';
|
||||
import { TypedCollectionImpl } from '../src/collection-typed';
|
||||
|
||||
describe('Multi-Pointer Delta Resolution', () => {
|
||||
let node: RhizomeNode;
|
||||
let schemaRegistry: DefaultSchemaRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
node = new RhizomeNode();
|
||||
schemaRegistry = new DefaultSchemaRegistry();
|
||||
});
|
||||
|
||||
describe('Three-Entity Relationship Deltas', () => {
|
||||
it('should handle movie casting deltas with actor, movie, role, and scalars', async () => {
|
||||
// Create schemas for a movie casting scenario
|
||||
const actorSchema = SchemaBuilder
|
||||
.create('actor')
|
||||
.name('Actor')
|
||||
.property('name', PrimitiveSchemas.requiredString())
|
||||
.property('filmography', ReferenceSchemas.to('casting-summary', 3))
|
||||
.required('name')
|
||||
.build();
|
||||
|
||||
const movieSchema = SchemaBuilder
|
||||
.create('movie')
|
||||
.name('Movie')
|
||||
.property('title', PrimitiveSchemas.requiredString())
|
||||
.property('cast', ReferenceSchemas.to('casting-summary', 3))
|
||||
.required('title')
|
||||
.build();
|
||||
|
||||
const roleSchema = SchemaBuilder
|
||||
.create('role')
|
||||
.name('Role')
|
||||
.property('name', PrimitiveSchemas.requiredString())
|
||||
.property('portrayals', ReferenceSchemas.to('casting-summary', 3))
|
||||
.required('name')
|
||||
.build();
|
||||
|
||||
const castingSummarySchema = SchemaBuilder
|
||||
.create('casting-summary')
|
||||
.name('Casting Summary')
|
||||
.property('name', PrimitiveSchemas.string())
|
||||
.property('title', PrimitiveSchemas.string())
|
||||
.property('salary', PrimitiveSchemas.number())
|
||||
.property('contract_date', PrimitiveSchemas.string())
|
||||
.additionalProperties(false)
|
||||
.build();
|
||||
|
||||
schemaRegistry.register(actorSchema);
|
||||
schemaRegistry.register(movieSchema);
|
||||
schemaRegistry.register(roleSchema);
|
||||
schemaRegistry.register(castingSummarySchema);
|
||||
|
||||
// Create collections
|
||||
const actorCollection = new TypedCollectionImpl<{ name: string }>('actors', actorSchema, schemaRegistry);
|
||||
const movieCollection = new TypedCollectionImpl<{ title: string }>('movies', movieSchema, schemaRegistry);
|
||||
const roleCollection = new TypedCollectionImpl<{ name: string }>('roles', roleSchema, schemaRegistry);
|
||||
|
||||
actorCollection.rhizomeConnect(node);
|
||||
movieCollection.rhizomeConnect(node);
|
||||
roleCollection.rhizomeConnect(node);
|
||||
|
||||
// Create entities
|
||||
await actorCollection.put('keanu', { name: 'Keanu Reeves' });
|
||||
await movieCollection.put('matrix', { title: 'The Matrix' });
|
||||
await roleCollection.put('neo', { name: 'Neo' });
|
||||
|
||||
// Create a complex casting delta with multiple entity references and scalar values
|
||||
const castingDelta = new Delta({
|
||||
creator: node.config.creator,
|
||||
host: node.config.peerId,
|
||||
pointers: [
|
||||
{ localContext: 'actors', target: 'keanu', targetContext: 'filmography' },
|
||||
{ localContext: 'movies', target: 'matrix', targetContext: 'cast' },
|
||||
{ localContext: 'roles', target: 'neo', targetContext: 'portrayals' },
|
||||
{ localContext: 'salary', target: 15000000 },
|
||||
{ localContext: 'contract_date', target: '1999-03-31' }
|
||||
]
|
||||
});
|
||||
node.lossless.ingestDelta(castingDelta);
|
||||
|
||||
// Test from Keanu's perspective
|
||||
const keanuViews = node.lossless.view(['keanu']);
|
||||
const keanuView = keanuViews['keanu'];
|
||||
|
||||
expect(keanuView.propertyDeltas.filmography).toBeDefined();
|
||||
expect(keanuView.propertyDeltas.filmography.length).toBe(1);
|
||||
|
||||
const nestedKeanuView = schemaRegistry.applySchemaWithNesting(
|
||||
keanuView,
|
||||
'actor',
|
||||
node.lossless,
|
||||
{ maxDepth: 2 }
|
||||
);
|
||||
|
||||
expect(nestedKeanuView.id).toBe('keanu');
|
||||
|
||||
// Should resolve references to matrix and neo, but not keanu (self)
|
||||
expect(nestedKeanuView.nestedObjects.filmography).toBeDefined();
|
||||
if (nestedKeanuView.nestedObjects.filmography) {
|
||||
const castingEntry = nestedKeanuView.nestedObjects.filmography[0];
|
||||
expect(castingEntry).toBeDefined();
|
||||
|
||||
// The casting entry should be resolved with casting-summary schema
|
||||
expect(castingEntry.schemaId).toBe('casting-summary');
|
||||
|
||||
// Should not contain a reference to keanu (the parent)
|
||||
expect(castingEntry.id).not.toBe('keanu');
|
||||
}
|
||||
|
||||
// Test from Matrix's perspective
|
||||
const matrixViews = node.lossless.view(['matrix']);
|
||||
const matrixView = matrixViews['matrix'];
|
||||
|
||||
const nestedMatrixView = schemaRegistry.applySchemaWithNesting(
|
||||
matrixView,
|
||||
'movie',
|
||||
node.lossless,
|
||||
{ maxDepth: 2 }
|
||||
);
|
||||
|
||||
expect(nestedMatrixView.id).toBe('matrix');
|
||||
expect(nestedMatrixView.nestedObjects.cast).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle deltas with mixed scalar and reference values correctly', async () => {
|
||||
// Create a simpler schema for testing mixed values
|
||||
const personSchema = SchemaBuilder
|
||||
.create('person')
|
||||
.name('Person')
|
||||
.property('name', PrimitiveSchemas.requiredString())
|
||||
.property('relationships', ReferenceSchemas.to('relationship-summary', 3))
|
||||
.required('name')
|
||||
.build();
|
||||
|
||||
const relationshipSummarySchema = SchemaBuilder
|
||||
.create('relationship-summary')
|
||||
.name('Relationship Summary')
|
||||
.property('partner_name', PrimitiveSchemas.string())
|
||||
.property('type', PrimitiveSchemas.string())
|
||||
.property('since', PrimitiveSchemas.string())
|
||||
.property('intensity', PrimitiveSchemas.number())
|
||||
.additionalProperties(false)
|
||||
.build();
|
||||
|
||||
schemaRegistry.register(personSchema);
|
||||
schemaRegistry.register(relationshipSummarySchema);
|
||||
|
||||
const personCollection = new TypedCollectionImpl<{ name: string }>('people', personSchema, schemaRegistry);
|
||||
personCollection.rhizomeConnect(node);
|
||||
|
||||
// Create people
|
||||
await personCollection.put('alice', { name: 'Alice' });
|
||||
await personCollection.put('bob', { name: 'Bob' });
|
||||
|
||||
// Create a relationship delta with one entity reference and multiple scalars
|
||||
const relationshipDelta = new Delta({
|
||||
creator: node.config.creator,
|
||||
host: node.config.peerId,
|
||||
pointers: [
|
||||
{ localContext: 'people', target: 'alice', targetContext: 'relationships' },
|
||||
{ localContext: 'partner', target: 'bob' }, // Entity reference
|
||||
{ localContext: 'type', target: 'friendship' }, // Scalar
|
||||
{ localContext: 'since', target: '2020-01-15' }, // Scalar
|
||||
{ localContext: 'intensity', target: 8 } // Scalar number
|
||||
]
|
||||
});
|
||||
node.lossless.ingestDelta(relationshipDelta);
|
||||
|
||||
// Test from Alice's perspective
|
||||
const aliceViews = node.lossless.view(['alice']);
|
||||
const aliceView = aliceViews['alice'];
|
||||
|
||||
const nestedAliceView = schemaRegistry.applySchemaWithNesting(
|
||||
aliceView,
|
||||
'person',
|
||||
node.lossless,
|
||||
{ maxDepth: 2 }
|
||||
);
|
||||
|
||||
expect(nestedAliceView.id).toBe('alice');
|
||||
expect(nestedAliceView.nestedObjects.relationships).toBeDefined();
|
||||
|
||||
if (nestedAliceView.nestedObjects.relationships) {
|
||||
expect(nestedAliceView.nestedObjects.relationships.length).toBe(1);
|
||||
const relationshipEntry = nestedAliceView.nestedObjects.relationships[0];
|
||||
|
||||
// Should be resolved with relationship-summary schema
|
||||
expect(relationshipEntry.schemaId).toBe('relationship-summary');
|
||||
|
||||
// Should contain scalar values and reference to bob but not alice
|
||||
expect(relationshipEntry.id).not.toBe('alice');
|
||||
|
||||
// The relationship should contain the scalar values from the delta
|
||||
// Note: The exact structure depends on how the resolution logic handles mixed values
|
||||
}
|
||||
});
|
||||
|
||||
it('should correctly identify multiple entity references within a single delta', async () => {
|
||||
// Test a scenario with multiple entity references that should all be resolved
|
||||
const projectSchema = SchemaBuilder
|
||||
.create('project')
|
||||
.name('Project')
|
||||
.property('name', PrimitiveSchemas.requiredString())
|
||||
.property('collaborations', ReferenceSchemas.to('collaboration-summary', 3))
|
||||
.required('name')
|
||||
.build();
|
||||
|
||||
const collaborationSummarySchema = SchemaBuilder
|
||||
.create('collaboration-summary')
|
||||
.name('Collaboration Summary')
|
||||
.property('project_name', PrimitiveSchemas.string())
|
||||
.property('developer_name', PrimitiveSchemas.string())
|
||||
.property('designer_name', PrimitiveSchemas.string())
|
||||
.property('budget', PrimitiveSchemas.number())
|
||||
.additionalProperties(false)
|
||||
.build();
|
||||
|
||||
schemaRegistry.register(projectSchema);
|
||||
schemaRegistry.register(collaborationSummarySchema);
|
||||
|
||||
const projectCollection = new TypedCollectionImpl<{ name: string }>('projects', projectSchema, schemaRegistry);
|
||||
const developerCollection = new TypedCollectionImpl<{ name: string }>('developers', projectSchema, schemaRegistry);
|
||||
const designerCollection = new TypedCollectionImpl<{ name: string }>('designers', projectSchema, schemaRegistry);
|
||||
|
||||
projectCollection.rhizomeConnect(node);
|
||||
developerCollection.rhizomeConnect(node);
|
||||
designerCollection.rhizomeConnect(node);
|
||||
|
||||
// Create entities
|
||||
await projectCollection.put('website', { name: 'Company Website' });
|
||||
await developerCollection.put('alice', { name: 'Alice Developer' });
|
||||
await designerCollection.put('bob', { name: 'Bob Designer' });
|
||||
|
||||
// Create a collaboration delta with multiple entity references
|
||||
const collaborationDelta = new Delta({
|
||||
creator: node.config.creator,
|
||||
host: node.config.peerId,
|
||||
pointers: [
|
||||
{ localContext: 'projects', target: 'website', targetContext: 'collaborations' },
|
||||
{ localContext: 'developer', target: 'alice' }, // Entity reference
|
||||
{ localContext: 'designer', target: 'bob' }, // Entity reference
|
||||
{ localContext: 'budget', target: 50000 }, // Scalar
|
||||
{ localContext: 'deadline', target: '2024-06-01' } // Scalar
|
||||
]
|
||||
});
|
||||
node.lossless.ingestDelta(collaborationDelta);
|
||||
|
||||
// Test from project's perspective
|
||||
const projectViews = node.lossless.view(['website']);
|
||||
const projectView = projectViews['website'];
|
||||
|
||||
const nestedProjectView = schemaRegistry.applySchemaWithNesting(
|
||||
projectView,
|
||||
'project',
|
||||
node.lossless,
|
||||
{ maxDepth: 2 }
|
||||
);
|
||||
|
||||
expect(nestedProjectView.id).toBe('website');
|
||||
expect(nestedProjectView.nestedObjects.collaborations).toBeDefined();
|
||||
|
||||
if (nestedProjectView.nestedObjects.collaborations) {
|
||||
|
||||
// Verify we get exactly 1 composite object (not 2 separate objects)
|
||||
expect(nestedProjectView.nestedObjects.collaborations.length).toBe(1);
|
||||
const collaboration = nestedProjectView.nestedObjects.collaborations[0];
|
||||
|
||||
expect(collaboration.schemaId).toBe('collaboration-summary');
|
||||
expect(collaboration.id).toMatch(/^composite-/); // Should be a synthetic composite ID
|
||||
|
||||
// Verify the composite object contains scalar properties
|
||||
expect(collaboration.properties.budget).toBe(50000);
|
||||
expect(collaboration.properties.deadline).toBe('2024-06-01');
|
||||
|
||||
// Verify the composite object contains nested entity references
|
||||
expect(collaboration.nestedObjects.developer).toBeDefined();
|
||||
expect(collaboration.nestedObjects.designer).toBeDefined();
|
||||
|
||||
// The nested entities should be resolved as arrays with single objects
|
||||
const developers = collaboration.nestedObjects.developer as SchemaAppliedViewWithNesting[];
|
||||
const designers = collaboration.nestedObjects.designer as SchemaAppliedViewWithNesting[];
|
||||
|
||||
// Each should have exactly one entity
|
||||
expect(developers.length).toBe(1);
|
||||
expect(designers.length).toBe(1);
|
||||
|
||||
// Verify each entity reference resolves to the correct entity
|
||||
expect(developers[0].id).toBe('alice');
|
||||
expect(developers[0].schemaId).toBe('collaboration-summary');
|
||||
|
||||
expect(designers[0].id).toBe('bob');
|
||||
expect(designers[0].schemaId).toBe('collaboration-summary');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
358
__tests__/nested-resolution-performance.ts
Normal file
358
__tests__/nested-resolution-performance.ts
Normal file
@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Performance tests for nested object resolution with large object graphs
|
||||
*
|
||||
* Tests performance characteristics of:
|
||||
* - Large networks of interconnected entities
|
||||
* - Deep nesting chains
|
||||
* - Wide arrays of references
|
||||
* - Circular reference handling at scale
|
||||
*/
|
||||
|
||||
import { RhizomeNode } from '../src/node';
|
||||
import { Delta } from '../src/delta';
|
||||
import { DefaultSchemaRegistry } from '../src/schema-registry';
|
||||
import { SchemaBuilder, PrimitiveSchemas, ReferenceSchemas, ArraySchemas } from '../src/schema';
|
||||
import { TypedCollectionImpl } from '../src/collection-typed';
|
||||
|
||||
describe('Nested Object Resolution Performance', () => {
|
||||
let node: RhizomeNode;
|
||||
let schemaRegistry: DefaultSchemaRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
node = new RhizomeNode();
|
||||
schemaRegistry = new DefaultSchemaRegistry();
|
||||
});
|
||||
|
||||
describe('Large Network Performance', () => {
|
||||
it('should handle large networks of interconnected users efficiently', async () => {
|
||||
// Create a schema for users with multiple relationship types
|
||||
const networkUserSchema = SchemaBuilder
|
||||
.create('network-user')
|
||||
.name('Network User')
|
||||
.property('name', PrimitiveSchemas.requiredString())
|
||||
.property('friends', ArraySchemas.of(ReferenceSchemas.to('network-user-summary', 2)))
|
||||
.property('followers', ArraySchemas.of(ReferenceSchemas.to('network-user-summary', 2)))
|
||||
.property('mentor', ReferenceSchemas.to('network-user-summary', 2))
|
||||
.required('name')
|
||||
.build();
|
||||
|
||||
const networkUserSummarySchema = SchemaBuilder
|
||||
.create('network-user-summary')
|
||||
.name('Network User Summary')
|
||||
.property('name', PrimitiveSchemas.requiredString())
|
||||
.required('name')
|
||||
.additionalProperties(false)
|
||||
.build();
|
||||
|
||||
schemaRegistry.register(networkUserSchema);
|
||||
schemaRegistry.register(networkUserSummarySchema);
|
||||
|
||||
const userCollection = new TypedCollectionImpl<{
|
||||
name: string;
|
||||
friends?: string[];
|
||||
followers?: string[];
|
||||
mentor?: string;
|
||||
}>('users', networkUserSchema, schemaRegistry);
|
||||
|
||||
userCollection.rhizomeConnect(node);
|
||||
|
||||
const startSetup = performance.now();
|
||||
|
||||
// Create 100 users
|
||||
const userCount = 100;
|
||||
const userIds: string[] = [];
|
||||
for (let i = 0; i < userCount; i++) {
|
||||
const userId = `user${i}`;
|
||||
userIds.push(userId);
|
||||
await userCollection.put(userId, { name: `User ${i}` });
|
||||
}
|
||||
|
||||
// Create a network where each user has 5-10 friends, 10-20 followers, and 1 mentor
|
||||
for (let i = 0; i < userCount; i++) {
|
||||
const userId = userIds[i];
|
||||
|
||||
// Add friends (5-10 random connections)
|
||||
const friendCount = 5 + Math.floor(Math.random() * 6);
|
||||
for (let j = 0; j < friendCount; j++) {
|
||||
const friendIndex = Math.floor(Math.random() * userCount);
|
||||
if (friendIndex !== i) {
|
||||
const friendId = userIds[friendIndex];
|
||||
const friendshipDelta = new Delta({
|
||||
creator: node.config.creator,
|
||||
host: node.config.peerId,
|
||||
pointers: [
|
||||
{ localContext: 'users', target: userId, targetContext: 'friends' },
|
||||
{ localContext: 'friends', target: friendId }
|
||||
]
|
||||
});
|
||||
node.lossless.ingestDelta(friendshipDelta);
|
||||
}
|
||||
}
|
||||
|
||||
// Add followers (10-20 random connections)
|
||||
const followerCount = 10 + Math.floor(Math.random() * 11);
|
||||
for (let j = 0; j < followerCount; j++) {
|
||||
const followerIndex = Math.floor(Math.random() * userCount);
|
||||
if (followerIndex !== i) {
|
||||
const followerId = userIds[followerIndex];
|
||||
const followDelta = new Delta({
|
||||
creator: node.config.creator,
|
||||
host: node.config.peerId,
|
||||
pointers: [
|
||||
{ localContext: 'users', target: userId, targetContext: 'followers' },
|
||||
{ localContext: 'followers', target: followerId }
|
||||
]
|
||||
});
|
||||
node.lossless.ingestDelta(followDelta);
|
||||
}
|
||||
}
|
||||
|
||||
// Add mentor (one per user, creating a hierarchy)
|
||||
if (i > 0) {
|
||||
const mentorIndex = Math.floor(i / 2); // Create a tree-like mentor structure
|
||||
const mentorId = userIds[mentorIndex];
|
||||
const mentorshipDelta = new Delta({
|
||||
creator: node.config.creator,
|
||||
host: node.config.peerId,
|
||||
pointers: [
|
||||
{ localContext: 'users', target: userId, targetContext: 'mentor' },
|
||||
{ localContext: 'mentor', target: mentorId }
|
||||
]
|
||||
});
|
||||
node.lossless.ingestDelta(mentorshipDelta);
|
||||
}
|
||||
}
|
||||
|
||||
const setupTime = performance.now() - startSetup;
|
||||
console.log(`Setup time for ${userCount} users with relationships: ${setupTime.toFixed(2)}ms`);
|
||||
|
||||
// Test resolution performance for a user with many connections
|
||||
const testUserId = userIds[50]; // Pick a user in the middle
|
||||
const userViews = node.lossless.view([testUserId]);
|
||||
const userView = userViews[testUserId];
|
||||
|
||||
const startResolution = performance.now();
|
||||
|
||||
const nestedView = schemaRegistry.applySchemaWithNesting(
|
||||
userView,
|
||||
'network-user',
|
||||
node.lossless,
|
||||
{ maxDepth: 2 }
|
||||
);
|
||||
|
||||
const resolutionTime = performance.now() - startResolution;
|
||||
console.log(`Resolution time for user with many connections: ${resolutionTime.toFixed(2)}ms`);
|
||||
|
||||
// Verify the resolution worked
|
||||
expect(nestedView.id).toBe(testUserId);
|
||||
expect(nestedView.schemaId).toBe('network-user');
|
||||
|
||||
// Performance assertions (adjust thresholds based on acceptable performance)
|
||||
expect(setupTime).toBeLessThan(5000); // Setup should take less than 5 seconds
|
||||
expect(resolutionTime).toBeLessThan(1000); // Resolution should take less than 1 second
|
||||
|
||||
// Verify we got some nested objects
|
||||
const totalNestedObjects = Object.values(nestedView.nestedObjects).reduce(
|
||||
(total, arr) => total + (arr?.length || 0), 0
|
||||
);
|
||||
console.log('Total nested objects resolved:', totalNestedObjects);
|
||||
|
||||
// The test user should have friends, followers, and possibly a mentor
|
||||
expect(Object.keys(nestedView.nestedObjects).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle deep nesting chains efficiently', async () => {
|
||||
// Create a simple schema for chain testing
|
||||
const chainUserSchema = SchemaBuilder
|
||||
.create('chain-user')
|
||||
.name('Chain User')
|
||||
.property('name', PrimitiveSchemas.requiredString())
|
||||
.property('next', ReferenceSchemas.to('chain-user-summary', 3))
|
||||
.required('name')
|
||||
.build();
|
||||
|
||||
const chainUserSummarySchema = SchemaBuilder
|
||||
.create('chain-user-summary')
|
||||
.name('Chain User Summary')
|
||||
.property('name', PrimitiveSchemas.requiredString())
|
||||
.required('name')
|
||||
.additionalProperties(false)
|
||||
.build();
|
||||
|
||||
schemaRegistry.register(chainUserSchema);
|
||||
schemaRegistry.register(chainUserSummarySchema);
|
||||
|
||||
const userCollection = new TypedCollectionImpl<{
|
||||
name: string;
|
||||
next?: string;
|
||||
}>('users', chainUserSchema, schemaRegistry);
|
||||
|
||||
userCollection.rhizomeConnect(node);
|
||||
|
||||
const startSetup = performance.now();
|
||||
|
||||
// Create a chain of 50 users
|
||||
const chainLength = 50;
|
||||
const userIds: string[] = [];
|
||||
|
||||
for (let i = 0; i < chainLength; i++) {
|
||||
const userId = `chain-user${i}`;
|
||||
userIds.push(userId);
|
||||
await userCollection.put(userId, { name: `Chain User ${i}` });
|
||||
}
|
||||
|
||||
// Link them in a chain
|
||||
for (let i = 0; i < chainLength - 1; i++) {
|
||||
const currentId = userIds[i];
|
||||
const nextId = userIds[i + 1];
|
||||
|
||||
const linkDelta = new Delta({
|
||||
creator: node.config.creator,
|
||||
host: node.config.peerId,
|
||||
pointers: [
|
||||
{ localContext: 'users', target: currentId, targetContext: 'next' },
|
||||
{ localContext: 'next', target: nextId }
|
||||
]
|
||||
});
|
||||
node.lossless.ingestDelta(linkDelta);
|
||||
}
|
||||
|
||||
const setupTime = performance.now() - startSetup;
|
||||
console.log(`Setup time for chain of ${chainLength} users: ${setupTime.toFixed(2)}ms`);
|
||||
|
||||
// Test resolution from the start of the chain
|
||||
const firstUserId = userIds[0];
|
||||
const userViews = node.lossless.view([firstUserId]);
|
||||
const userView = userViews[firstUserId];
|
||||
|
||||
const startResolution = performance.now();
|
||||
|
||||
const nestedView = schemaRegistry.applySchemaWithNesting(
|
||||
userView,
|
||||
'chain-user',
|
||||
node.lossless,
|
||||
{ maxDepth: 5 } // Should resolve 5 levels deep
|
||||
);
|
||||
|
||||
const resolutionTime = performance.now() - startResolution;
|
||||
console.log(`Resolution time for deep chain (maxDepth=5): ${resolutionTime.toFixed(2)}ms`);
|
||||
|
||||
// Verify the resolution worked and respected depth limits
|
||||
expect(nestedView.id).toBe(firstUserId);
|
||||
expect(nestedView.schemaId).toBe('chain-user');
|
||||
|
||||
// Performance assertions
|
||||
expect(setupTime).toBeLessThan(2000); // Setup should take less than 2 seconds
|
||||
expect(resolutionTime).toBeLessThan(500); // Resolution should take less than 500ms
|
||||
|
||||
// Verify depth was respected
|
||||
let currentView = nestedView;
|
||||
let depth = 0;
|
||||
while (currentView.nestedObjects.next && currentView.nestedObjects.next.length > 0) {
|
||||
currentView = currentView.nestedObjects.next[0];
|
||||
depth++;
|
||||
if (depth >= 5) break; // Prevent infinite loop
|
||||
lentil
commented
This condition means the subsequent test of depth will always pass, regardless of actual depth This condition means the subsequent test of depth will always pass, regardless of actual depth
|
||||
}
|
||||
|
||||
expect(depth).toBeLessThanOrEqual(5);
|
||||
console.log(`Actual resolved depth: ${depth}`);
|
||||
});
|
||||
|
||||
it('should handle circular references in large graphs without performance degradation', async () => {
|
||||
const circularUserSchema = SchemaBuilder
|
||||
.create('circular-user')
|
||||
.name('Circular User')
|
||||
.property('name', PrimitiveSchemas.requiredString())
|
||||
.property('connections', ArraySchemas.of(ReferenceSchemas.to('circular-user-summary', 3)))
|
||||
.required('name')
|
||||
.build();
|
||||
|
||||
const circularUserSummarySchema = SchemaBuilder
|
||||
.create('circular-user-summary')
|
||||
.name('Circular User Summary')
|
||||
.property('name', PrimitiveSchemas.requiredString())
|
||||
.required('name')
|
||||
.additionalProperties(false)
|
||||
.build();
|
||||
|
||||
schemaRegistry.register(circularUserSchema);
|
||||
schemaRegistry.register(circularUserSummarySchema);
|
||||
|
||||
const userCollection = new TypedCollectionImpl<{
|
||||
name: string;
|
||||
connections?: string[];
|
||||
}>('users', circularUserSchema, schemaRegistry);
|
||||
|
||||
userCollection.rhizomeConnect(node);
|
||||
|
||||
const startSetup = performance.now();
|
||||
|
||||
// Create 20 users
|
||||
const userCount = 20;
|
||||
const userIds: string[] = [];
|
||||
for (let i = 0; i < userCount; i++) {
|
||||
const userId = `circular-user${i}`;
|
||||
userIds.push(userId);
|
||||
await userCollection.put(userId, { name: `Circular User ${i}` });
|
||||
}
|
||||
|
||||
// Create many circular connections - each user connects to 3 others
|
||||
for (let i = 0; i < userCount; i++) {
|
||||
const userId = userIds[i];
|
||||
|
||||
// Connect to next 3 users (wrapping around)
|
||||
for (let j = 1; j <= 3; j++) {
|
||||
const connectedIndex = (i + j) % userCount;
|
||||
const connectedId = userIds[connectedIndex];
|
||||
|
||||
const connectionDelta = new Delta({
|
||||
creator: node.config.creator,
|
||||
host: node.config.peerId,
|
||||
pointers: [
|
||||
{ localContext: 'users', target: userId, targetContext: 'connections' },
|
||||
{ localContext: 'connections', target: connectedId }
|
||||
]
|
||||
});
|
||||
node.lossless.ingestDelta(connectionDelta);
|
||||
}
|
||||
}
|
||||
|
||||
const setupTime = performance.now() - startSetup;
|
||||
console.log(`Setup time for circular graph with ${userCount} users: ${setupTime.toFixed(2)}ms`);
|
||||
|
||||
// Test resolution performance with circular references
|
||||
const testUserId = userIds[0];
|
||||
const userViews = node.lossless.view([testUserId]);
|
||||
const userView = userViews[testUserId];
|
||||
|
||||
const startResolution = performance.now();
|
||||
|
||||
const nestedView = schemaRegistry.applySchemaWithNesting(
|
||||
userView,
|
||||
'circular-user',
|
||||
node.lossless,
|
||||
{ maxDepth: 3 }
|
||||
);
|
||||
|
||||
const resolutionTime = performance.now() - startResolution;
|
||||
console.log(`Resolution time for circular graph (maxDepth=3): ${resolutionTime.toFixed(2)}ms`);
|
||||
|
||||
// Verify the resolution completed without hanging
|
||||
expect(nestedView.id).toBe(testUserId);
|
||||
expect(nestedView.schemaId).toBe('circular-user');
|
||||
|
||||
// Performance assertions - should handle circular references efficiently
|
||||
expect(setupTime).toBeLessThan(2000);
|
||||
expect(resolutionTime).toBeLessThan(1000); // Should complete in reasonable time despite cycles
|
||||
|
||||
// Verify we got some nested objects but didn't get stuck in infinite loops
|
||||
expect(nestedView.nestedObjects.connections).toBeDefined();
|
||||
if (nestedView.nestedObjects.connections) {
|
||||
expect(nestedView.nestedObjects.connections.length).toBeGreaterThan(0);
|
||||
expect(nestedView.nestedObjects.connections.length).toBeLessThanOrEqual(3);
|
||||
}
|
||||
|
||||
console.log(`Connections resolved: ${nestedView.nestedObjects.connections?.length || 0}`);
|
||||
});
|
||||
});
|
||||
});
|
443
__tests__/nested-resolution.ts
Normal file
443
__tests__/nested-resolution.ts
Normal file
@ -0,0 +1,443 @@
|
||||
/**
|
||||
* Test suite for nested object resolution with schema-controlled depth limiting
|
||||
* and circular reference detection.
|
||||
*
|
||||
* Tests the implementation of:
|
||||
* - Schema-controlled depth limiting
|
||||
* - Circular reference detection and prevention
|
||||
* - "Summary" schema type for references
|
||||
* - Deep nesting scenarios
|
||||
*/
|
||||
|
||||
import { RhizomeNode } from '../src/node';
|
||||
import { Delta } from '../src/delta';
|
||||
import { DefaultSchemaRegistry } from '../src/schema-registry';
|
||||
import { CommonSchemas, SchemaBuilder, PrimitiveSchemas, ReferenceSchemas } from '../src/schema';
|
||||
import { TypedCollectionImpl } from '../src/collection-typed';
|
||||
|
||||
describe('Nested Object Resolution', () => {
|
||||
let node: RhizomeNode;
|
||||
let schemaRegistry: DefaultSchemaRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
node = new RhizomeNode();
|
||||
schemaRegistry = new DefaultSchemaRegistry();
|
||||
|
||||
// Register the common schemas
|
||||
schemaRegistry.register(CommonSchemas.User());
|
||||
schemaRegistry.register(CommonSchemas.UserSummary());
|
||||
schemaRegistry.register(CommonSchemas.Document());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// No cleanup needed for nodes that don't call start()
|
||||
});
|
||||
|
||||
describe('Basic Reference Resolution', () => {
|
||||
it('should resolve single-level user references with UserSummary schema', async () => {
|
||||
const userCollection = new TypedCollectionImpl<{
|
||||
name: string;
|
||||
email?: string;
|
||||
friends?: string[];
|
||||
}>('users', CommonSchemas.User(), schemaRegistry);
|
||||
|
||||
userCollection.rhizomeConnect(node);
|
||||
|
||||
// Create test users
|
||||
await userCollection.put('alice', {
|
||||
name: 'Alice',
|
||||
email: 'alice@test.com'
|
||||
});
|
||||
await userCollection.put('bob', {
|
||||
name: 'Bob',
|
||||
email: 'bob@test.com'
|
||||
});
|
||||
|
||||
// Create friendship relationship
|
||||
const friendshipDelta = new Delta({
|
||||
creator: node.config.creator,
|
||||
host: node.config.peerId,
|
||||
pointers: [
|
||||
{ localContext: 'users', target: 'alice', targetContext: 'friends' },
|
||||
{ localContext: 'friends', target: 'bob' }
|
||||
]
|
||||
});
|
||||
node.lossless.ingestDelta(friendshipDelta);
|
||||
|
||||
// Get Alice's lossless view
|
||||
const aliceViews = node.lossless.view(['alice']);
|
||||
const aliceView = aliceViews['alice'];
|
||||
|
||||
expect(aliceView).toBeDefined();
|
||||
expect(aliceView.propertyDeltas.friends).toBeDefined();
|
||||
expect(aliceView.propertyDeltas.friends.length).toBeGreaterThan(0);
|
||||
|
||||
// Apply schema with nesting
|
||||
const nestedView = schemaRegistry.applySchemaWithNesting(
|
||||
aliceView,
|
||||
'user',
|
||||
node.lossless,
|
||||
{ maxDepth: 2 }
|
||||
);
|
||||
|
||||
expect(nestedView.id).toBe('alice');
|
||||
expect(nestedView.schemaId).toBe('user');
|
||||
expect(nestedView.metadata?.depth).toBe(0);
|
||||
expect(nestedView.metadata?.truncated).toBe(false);
|
||||
|
||||
// Check if friends are resolved as nested objects
|
||||
if (nestedView.nestedObjects.friends) {
|
||||
expect(nestedView.nestedObjects.friends.length).toBe(1);
|
||||
const bobSummary = nestedView.nestedObjects.friends[0];
|
||||
expect(bobSummary.id).toBe('bob');
|
||||
expect(bobSummary.schemaId).toBe('user-summary');
|
||||
expect(bobSummary.metadata?.depth).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle missing references gracefully', async () => {
|
||||
const userCollection = new TypedCollectionImpl<{
|
||||
name: string;
|
||||
friends?: string[];
|
||||
}>('users', CommonSchemas.User(), schemaRegistry);
|
||||
|
||||
userCollection.rhizomeConnect(node);
|
||||
|
||||
// Create user with reference to non-existent friend
|
||||
await userCollection.put('alice', { name: 'Alice' });
|
||||
|
||||
const friendshipDelta = new Delta({
|
||||
creator: node.config.creator,
|
||||
host: node.config.peerId,
|
||||
pointers: [
|
||||
{ localContext: 'users', target: 'alice', targetContext: 'friends' },
|
||||
{ localContext: 'friends', target: 'nonexistent' }
|
||||
]
|
||||
});
|
||||
node.lossless.ingestDelta(friendshipDelta);
|
||||
|
||||
const aliceViews = node.lossless.view(['alice']);
|
||||
const aliceView = aliceViews['alice'];
|
||||
|
||||
const nestedView = schemaRegistry.applySchemaWithNesting(
|
||||
aliceView,
|
||||
'user',
|
||||
node.lossless,
|
||||
{ maxDepth: 2 }
|
||||
);
|
||||
|
||||
// Should not crash and should handle missing reference gracefully
|
||||
expect(nestedView.id).toBe('alice');
|
||||
// The friends array might be empty or have no resolved objects
|
||||
if (nestedView.nestedObjects.friends) {
|
||||
expect(Array.isArray(nestedView.nestedObjects.friends)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Depth Limiting', () => {
|
||||
it('should respect maxDepth parameter and truncate deep nesting', async () => {
|
||||
// Create a custom schema with deeper nesting for testing
|
||||
const deepUserSchema = SchemaBuilder
|
||||
.create('deep-user')
|
||||
.name('Deep User')
|
||||
.property('name', PrimitiveSchemas.requiredString())
|
||||
.property('mentor', ReferenceSchemas.to('deep-user', 2)) // Self-reference with depth 2
|
||||
.required('name')
|
||||
.build();
|
||||
|
||||
schemaRegistry.register(deepUserSchema);
|
||||
|
||||
const userCollection = new TypedCollectionImpl<{
|
||||
name: string;
|
||||
mentor?: string;
|
||||
}>('deep-users', deepUserSchema, schemaRegistry);
|
||||
|
||||
userCollection.rhizomeConnect(node);
|
||||
|
||||
// Create a chain: alice -> bob -> charlie
|
||||
await userCollection.put('alice', { name: 'Alice' });
|
||||
await userCollection.put('bob', { name: 'Bob' });
|
||||
await userCollection.put('charlie', { name: 'Charlie' });
|
||||
|
||||
// Alice's mentor is Bob
|
||||
const mentorshipDelta1 = new Delta({
|
||||
creator: node.config.creator,
|
||||
host: node.config.peerId,
|
||||
pointers: [
|
||||
{ localContext: 'deep-users', target: 'alice', targetContext: 'mentor' },
|
||||
{ localContext: 'mentor', target: 'bob' }
|
||||
]
|
||||
});
|
||||
node.lossless.ingestDelta(mentorshipDelta1);
|
||||
|
||||
// Bob's mentor is Charlie
|
||||
const mentorshipDelta2 = new Delta({
|
||||
creator: node.config.creator,
|
||||
host: node.config.peerId,
|
||||
pointers: [
|
||||
{ localContext: 'deep-users', target: 'bob', targetContext: 'mentor' },
|
||||
{ localContext: 'mentor', target: 'charlie' }
|
||||
]
|
||||
});
|
||||
node.lossless.ingestDelta(mentorshipDelta2);
|
||||
|
||||
const aliceViews = node.lossless.view(['alice']);
|
||||
const aliceView = aliceViews['alice'];
|
||||
|
||||
// Test with maxDepth = 1 (should only resolve Alice and Bob)
|
||||
const shallowView = schemaRegistry.applySchemaWithNesting(
|
||||
aliceView,
|
||||
'deep-user',
|
||||
node.lossless,
|
||||
{ maxDepth: 1 }
|
||||
);
|
||||
|
||||
expect(shallowView.id).toBe('alice');
|
||||
expect(shallowView.metadata?.depth).toBe(0);
|
||||
expect(shallowView.metadata?.truncated).toBe(false);
|
||||
|
||||
if (shallowView.nestedObjects.mentor) {
|
||||
expect(shallowView.nestedObjects.mentor.length).toBe(1);
|
||||
const bobView = shallowView.nestedObjects.mentor[0];
|
||||
expect(bobView.id).toBe('bob');
|
||||
expect(bobView.metadata?.depth).toBe(1);
|
||||
expect(bobView.metadata?.truncated).toBe(true); // Should be truncated at depth 1
|
||||
|
||||
// Bob's mentor should not be resolved due to depth limit
|
||||
expect(bobView.nestedObjects.mentor || []).toHaveLength(0);
|
||||
}
|
||||
|
||||
// Test with maxDepth = 2 (should resolve Alice, Bob, and Charlie)
|
||||
const deepView = schemaRegistry.applySchemaWithNesting(
|
||||
aliceView,
|
||||
'deep-user',
|
||||
node.lossless,
|
||||
{ maxDepth: 2 }
|
||||
);
|
||||
|
||||
if (deepView.nestedObjects.mentor) {
|
||||
const bobView = deepView.nestedObjects.mentor[0];
|
||||
expect(bobView.metadata?.truncated).toBe(false);
|
||||
|
||||
if (bobView.nestedObjects.mentor) {
|
||||
expect(bobView.nestedObjects.mentor.length).toBe(1);
|
||||
const charlieView = bobView.nestedObjects.mentor[0];
|
||||
expect(charlieView.id).toBe('charlie');
|
||||
expect(charlieView.metadata?.depth).toBe(2);
|
||||
expect(charlieView.metadata?.truncated).toBe(true); // Truncated at max depth
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Circular Reference Prevention', () => {
|
||||
it('should detect and prevent circular references', async () => {
|
||||
const userCollection = new TypedCollectionImpl<{
|
||||
name: string;
|
||||
friends?: string[];
|
||||
}>('users', CommonSchemas.User(), schemaRegistry);
|
||||
|
||||
userCollection.rhizomeConnect(node);
|
||||
|
||||
// Create users
|
||||
await userCollection.put('alice', { name: 'Alice' });
|
||||
await userCollection.put('bob', { name: 'Bob' });
|
||||
|
||||
// Create circular friendship: Alice -> Bob -> Alice
|
||||
const friendship1 = new Delta({
|
||||
creator: node.config.creator,
|
||||
host: node.config.peerId,
|
||||
pointers: [
|
||||
{ localContext: 'users', target: 'alice', targetContext: 'friends' },
|
||||
{ localContext: 'friends', target: 'bob' }
|
||||
]
|
||||
});
|
||||
node.lossless.ingestDelta(friendship1);
|
||||
|
||||
const friendship2 = new Delta({
|
||||
creator: node.config.creator,
|
||||
host: node.config.peerId,
|
||||
pointers: [
|
||||
{ localContext: 'users', target: 'bob', targetContext: 'friends' },
|
||||
{ localContext: 'friends', target: 'alice' }
|
||||
]
|
||||
});
|
||||
node.lossless.ingestDelta(friendship2);
|
||||
|
||||
const aliceViews = node.lossless.view(['alice']);
|
||||
const aliceView = aliceViews['alice'];
|
||||
|
||||
// Should handle circular reference without infinite recursion
|
||||
const nestedView = schemaRegistry.applySchemaWithNesting(
|
||||
aliceView,
|
||||
'user',
|
||||
node.lossless,
|
||||
{ maxDepth: 3 }
|
||||
);
|
||||
|
||||
expect(nestedView.id).toBe('alice');
|
||||
|
||||
// The resolution should complete without hanging or crashing
|
||||
// The exact behavior may vary, but it should not cause infinite recursion
|
||||
expect(nestedView.metadata?.truncated).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle self-references correctly', async () => {
|
||||
const userCollection = new TypedCollectionImpl<{
|
||||
name: string;
|
||||
friends?: string[];
|
||||
}>('users', CommonSchemas.User(), schemaRegistry);
|
||||
|
||||
userCollection.rhizomeConnect(node);
|
||||
|
||||
await userCollection.put('alice', { name: 'Alice' });
|
||||
|
||||
// Alice is friends with herself
|
||||
const selfFriendship = new Delta({
|
||||
creator: node.config.creator,
|
||||
host: node.config.peerId,
|
||||
pointers: [
|
||||
{ localContext: 'users', target: 'alice', targetContext: 'friends' },
|
||||
{ localContext: 'friends', target: 'alice' }
|
||||
]
|
||||
});
|
||||
node.lossless.ingestDelta(selfFriendship);
|
||||
|
||||
const aliceViews = node.lossless.view(['alice']);
|
||||
const aliceView = aliceViews['alice'];
|
||||
|
||||
const nestedView = schemaRegistry.applySchemaWithNesting(
|
||||
aliceView,
|
||||
'user',
|
||||
node.lossless,
|
||||
{ maxDepth: 2 }
|
||||
);
|
||||
|
||||
expect(nestedView.id).toBe('alice');
|
||||
// Should detect the self-reference and handle it appropriately
|
||||
});
|
||||
});
|
||||
|
||||
describe('Array References', () => {
|
||||
it('should resolve arrays of references correctly', async () => {
|
||||
const userCollection = new TypedCollectionImpl<{
|
||||
name: string;
|
||||
friends?: string[];
|
||||
}>('users', CommonSchemas.User(), schemaRegistry);
|
||||
|
||||
userCollection.rhizomeConnect(node);
|
||||
|
||||
// Create multiple users
|
||||
await userCollection.put('alice', { name: 'Alice' });
|
||||
await userCollection.put('bob', { name: 'Bob' });
|
||||
await userCollection.put('charlie', { name: 'Charlie' });
|
||||
|
||||
// Alice has multiple friends
|
||||
const friendship1 = new Delta({
|
||||
creator: node.config.creator,
|
||||
host: node.config.peerId,
|
||||
pointers: [
|
||||
{ localContext: 'users', target: 'alice', targetContext: 'friends' },
|
||||
{ localContext: 'friends', target: 'bob' }
|
||||
]
|
||||
});
|
||||
node.lossless.ingestDelta(friendship1);
|
||||
|
||||
const friendship2 = new Delta({
|
||||
creator: node.config.creator,
|
||||
host: node.config.peerId,
|
||||
pointers: [
|
||||
{ localContext: 'users', target: 'alice', targetContext: 'friends' },
|
||||
{ localContext: 'friends', target: 'charlie' }
|
||||
]
|
||||
});
|
||||
node.lossless.ingestDelta(friendship2);
|
||||
|
||||
const aliceViews = node.lossless.view(['alice']);
|
||||
const aliceView = aliceViews['alice'];
|
||||
|
||||
const nestedView = schemaRegistry.applySchemaWithNesting(
|
||||
aliceView,
|
||||
'user',
|
||||
node.lossless,
|
||||
{ maxDepth: 2 }
|
||||
);
|
||||
|
||||
expect(nestedView.id).toBe('alice');
|
||||
|
||||
if (nestedView.nestedObjects.friends) {
|
||||
expect(nestedView.nestedObjects.friends.length).toBe(2);
|
||||
|
||||
const friendIds = nestedView.nestedObjects.friends.map((friend: { id: string }) => friend.id);
|
||||
expect(friendIds).toContain('bob');
|
||||
expect(friendIds).toContain('charlie');
|
||||
|
||||
// All friends should use the user-summary schema
|
||||
nestedView.nestedObjects.friends.forEach((friend: { schemaId: string; metadata?: { depth: number } }) => {
|
||||
expect(friend.schemaId).toBe('user-summary');
|
||||
expect(friend.metadata?.depth).toBe(1);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Summary Schema Pattern', () => {
|
||||
it('should use Summary schema to break infinite recursion', async () => {
|
||||
// The User schema references user-summary for friends
|
||||
// This tests the pattern mentioned in the spec
|
||||
const userCollection = new TypedCollectionImpl<{
|
||||
name: string;
|
||||
email?: string;
|
||||
friends?: string[];
|
||||
}>('users', CommonSchemas.User(), schemaRegistry);
|
||||
|
||||
userCollection.rhizomeConnect(node);
|
||||
|
||||
await userCollection.put('alice', {
|
||||
name: 'Alice',
|
||||
email: 'alice@test.com'
|
||||
});
|
||||
await userCollection.put('bob', {
|
||||
name: 'Bob',
|
||||
email: 'bob@test.com'
|
||||
});
|
||||
|
||||
// Create friendship
|
||||
const friendship = new Delta({
|
||||
creator: node.config.creator,
|
||||
host: node.config.peerId,
|
||||
pointers: [
|
||||
{ localContext: 'users', target: 'alice', targetContext: 'friends' },
|
||||
{ localContext: 'friends', target: 'bob' }
|
||||
]
|
||||
});
|
||||
node.lossless.ingestDelta(friendship);
|
||||
|
||||
const aliceViews = node.lossless.view(['alice']);
|
||||
const aliceView = aliceViews['alice'];
|
||||
|
||||
const nestedView = schemaRegistry.applySchemaWithNesting(
|
||||
aliceView,
|
||||
'user',
|
||||
node.lossless,
|
||||
{ maxDepth: 3 }
|
||||
);
|
||||
|
||||
if (nestedView.nestedObjects.friends) {
|
||||
const bobSummary = nestedView.nestedObjects.friends[0];
|
||||
|
||||
// Bob should be resolved with user-summary schema
|
||||
expect(bobSummary.schemaId).toBe('user-summary');
|
||||
|
||||
// user-summary schema should have limited properties (only name and email)
|
||||
expect(bobSummary.properties.name).toBeDefined();
|
||||
expect(bobSummary.properties.email).toBeDefined();
|
||||
|
||||
// user-summary should NOT have friends property to break recursion
|
||||
expect(bobSummary.properties.friends).toBeUndefined();
|
||||
expect(bobSummary.nestedObjects.friends).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
@ -178,11 +178,57 @@ export class Lossless {
|
||||
if (!deltaFilter) return true;
|
||||
return deltaFilter(delta);
|
||||
};
|
||||
const res = this.view([entityId], (delta) => combinedFilter(delta));
|
||||
const res = this.compose([entityId], (delta) => combinedFilter(delta));
|
||||
return res[entityId];
|
||||
}
|
||||
|
||||
decompose(view: LosslessViewOne): Delta[] {
|
||||
const allDeltas: Delta[] = [];
|
||||
const seenDeltaIds = new Set<DeltaID>();
|
||||
|
||||
// Collect all deltas from all properties
|
||||
for (const [propertyId, deltas] of Object.entries(view.propertyDeltas)) {
|
||||
for (const delta of deltas) {
|
||||
if (!seenDeltaIds.has(delta.id)) {
|
||||
seenDeltaIds.add(delta.id);
|
||||
// Convert CollapsedDelta back to Delta
|
||||
const fullDelta = new Delta({
|
||||
id: delta.id,
|
||||
creator: delta.creator,
|
||||
host: delta.host,
|
||||
timeCreated: delta.timeCreated,
|
||||
pointers: delta.pointers.map(pointer => {
|
||||
// Convert back to V1 pointer format for Delta constructor
|
||||
const pointerEntries = Object.entries(pointer);
|
||||
if (pointerEntries.length === 1) {
|
||||
const [localContext, target] = pointerEntries[0];
|
||||
if (typeof target === 'string' && this.domainEntities.has(target)) {
|
||||
// This is a reference pointer to an entity
|
||||
// The targetContext is the property ID this delta appears under
|
||||
return { localContext, target, targetContext: propertyId };
|
||||
} else {
|
||||
// Scalar pointer
|
||||
return { localContext, target: target as PropertyTypes };
|
||||
}
|
||||
}
|
||||
// Fallback for unexpected pointer structure
|
||||
return { localContext: 'unknown', target: 'unknown' };
|
||||
})
|
||||
});
|
||||
allDeltas.push(fullDelta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allDeltas;
|
||||
}
|
||||
|
||||
// Backward compatibility alias
|
||||
view(entityIds?: DomainEntityID[], deltaFilter?: DeltaFilter): LosslessViewMany {
|
||||
return this.compose(entityIds, deltaFilter);
|
||||
}
|
||||
|
||||
compose(entityIds?: DomainEntityID[], deltaFilter?: DeltaFilter): LosslessViewMany {
|
||||
const view: LosslessViewMany = {};
|
||||
entityIds = entityIds ?? Array.from(this.domainEntities.keys());
|
||||
|
||||
|
@ -10,10 +10,12 @@ import {
|
||||
ReferenceSchema,
|
||||
ArraySchema,
|
||||
SchemaAppliedView,
|
||||
SchemaApplicationOptions
|
||||
SchemaAppliedViewWithNesting,
|
||||
SchemaApplicationOptions,
|
||||
ResolutionContext
|
||||
} from './schema';
|
||||
import { LosslessViewOne } from './lossless';
|
||||
import { DomainEntityID, PropertyID } from './types';
|
||||
import { LosslessViewOne, Lossless } from './lossless';
|
||||
import { DomainEntityID, PropertyID, PropertyTypes } from './types';
|
||||
import { CollapsedDelta } from './lossless';
|
||||
|
||||
const debug = Debug('rz:schema-registry');
|
||||
@ -286,7 +288,7 @@ export class DefaultSchemaRegistry implements SchemaRegistry {
|
||||
throw new Error(`Schema '${schemaId}' not found`);
|
||||
}
|
||||
|
||||
const { includeMetadata = true, strictValidation = false } = options;
|
||||
const { includeMetadata = true, strictValidation = false, maxDepth: _maxDepth = 3 } = options;
|
||||
|
||||
const appliedView: SchemaAppliedView = {
|
||||
id: view.id,
|
||||
@ -315,14 +317,419 @@ export class DefaultSchemaRegistry implements SchemaRegistry {
|
||||
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
|
||||
depth: 1,
|
||||
truncated: false
|
||||
};
|
||||
}
|
||||
|
||||
return appliedView;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply schema with nested object resolution
|
||||
* Resolves references to other entities according to schema specifications
|
||||
*/
|
||||
applySchemaWithNesting(
|
||||
view: LosslessViewOne,
|
||||
schemaId: SchemaID,
|
||||
losslessView: Lossless,
|
||||
options: SchemaApplicationOptions = {}
|
||||
): SchemaAppliedViewWithNesting {
|
||||
const { maxDepth = 3, includeMetadata = true, strictValidation = false } = options;
|
||||
const resolutionContext = new ResolutionContext(maxDepth);
|
||||
|
||||
return this.resolveNestedView(
|
||||
view,
|
||||
schemaId,
|
||||
losslessView,
|
||||
resolutionContext,
|
||||
{ includeMetadata, strictValidation }
|
||||
);
|
||||
}
|
||||
|
||||
private resolveNestedView(
|
||||
view: LosslessViewOne,
|
||||
schemaId: SchemaID,
|
||||
losslessView: Lossless,
|
||||
context: ResolutionContext,
|
||||
options: { includeMetadata: boolean; strictValidation: boolean }
|
||||
): SchemaAppliedViewWithNesting {
|
||||
const schema = this.get(schemaId);
|
||||
if (!schema) {
|
||||
throw new Error(`Schema '${schemaId}' not found`);
|
||||
}
|
||||
|
||||
// Check for circular reference
|
||||
if (context.hasVisited(view.id, schemaId)) {
|
||||
return this.createTruncatedView(view.id, schemaId, context.currentDepth, true);
|
||||
}
|
||||
|
||||
// Check depth limit
|
||||
if (context.currentDepth >= context.maxDepth) {
|
||||
return this.createTruncatedView(view.id, schemaId, context.currentDepth, true);
|
||||
}
|
||||
|
||||
// Mark this entity/schema combination as visited
|
||||
context.visit(view.id, schemaId);
|
||||
|
||||
const appliedView: SchemaAppliedViewWithNesting = {
|
||||
id: view.id,
|
||||
schemaId,
|
||||
properties: {},
|
||||
nestedObjects: {}
|
||||
};
|
||||
|
||||
// Validate the view once
|
||||
const overallValidationResult = this.validate(view.id, schemaId, view);
|
||||
|
||||
// Process each property
|
||||
for (const [propertyId, propertySchema] of Object.entries(schema.properties)) {
|
||||
const deltas = view.propertyDeltas[propertyId] || [];
|
||||
|
||||
appliedView.properties[propertyId] = {
|
||||
deltas,
|
||||
schema: propertySchema,
|
||||
validationResult: overallValidationResult
|
||||
};
|
||||
|
||||
// Handle reference resolution
|
||||
if (propertySchema.type === 'reference') {
|
||||
const referenceSchema = propertySchema as ReferenceSchema;
|
||||
const nestedViews = this.resolveReferenceProperty(
|
||||
deltas,
|
||||
referenceSchema,
|
||||
losslessView,
|
||||
context.withDepth(context.currentDepth + 1),
|
||||
options,
|
||||
view.id
|
||||
);
|
||||
if (nestedViews.length > 0) {
|
||||
appliedView.nestedObjects[propertyId] = nestedViews;
|
||||
}
|
||||
} else if (propertySchema.type === 'array' && propertySchema.itemSchema?.type === 'reference') {
|
||||
const arraySchema = propertySchema as ArraySchema;
|
||||
const referenceSchema = arraySchema.itemSchema as ReferenceSchema;
|
||||
const nestedViews = this.resolveReferenceProperty(
|
||||
deltas,
|
||||
referenceSchema,
|
||||
losslessView,
|
||||
context.withDepth(context.currentDepth + 1),
|
||||
options,
|
||||
view.id
|
||||
);
|
||||
if (nestedViews.length > 0) {
|
||||
appliedView.nestedObjects[propertyId] = nestedViews;
|
||||
}
|
||||
}
|
||||
|
||||
// Validation error handling
|
||||
if (options.strictValidation && !overallValidationResult.valid) {
|
||||
throw new Error(`Schema validation failed for property '${propertyId}': ${overallValidationResult.errors[0]?.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add metadata
|
||||
if (options.includeMetadata) {
|
||||
appliedView.metadata = {
|
||||
appliedAt: Date.now(),
|
||||
depth: context.currentDepth,
|
||||
truncated: context.currentDepth >= context.maxDepth
|
||||
};
|
||||
}
|
||||
|
||||
// Mark as unvisited when leaving this path
|
||||
context.unvisit(view.id, schemaId);
|
||||
|
||||
return appliedView;
|
||||
}
|
||||
|
||||
private resolveReferenceProperty(
|
||||
deltas: CollapsedDelta[],
|
||||
referenceSchema: ReferenceSchema,
|
||||
losslessView: Lossless,
|
||||
context: ResolutionContext,
|
||||
options: { includeMetadata: boolean; strictValidation: boolean },
|
||||
parentEntityId: string
|
||||
): SchemaAppliedViewWithNesting[] {
|
||||
const resolvedViews: SchemaAppliedViewWithNesting[] = [];
|
||||
const referenceDepthLimit = referenceSchema.maxDepth || context.maxDepth;
|
||||
|
||||
// Check if we're at the reference's specific depth limit
|
||||
if (context.currentDepth >= referenceDepthLimit) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Create composite objects from deltas - one per delta
|
||||
for (const delta of deltas) {
|
||||
try {
|
||||
const compositeObject = this.createCompositeObjectFromDelta(
|
||||
delta,
|
||||
parentEntityId,
|
||||
referenceSchema.targetSchema,
|
||||
losslessView,
|
||||
context,
|
||||
options
|
||||
);
|
||||
if (compositeObject) {
|
||||
resolvedViews.push(compositeObject);
|
||||
} else {
|
||||
// Fall back to original logic for single entity references
|
||||
const referenceIds = this.extractReferenceIdsFromDelta(delta, parentEntityId);
|
||||
for (const referenceId of referenceIds) {
|
||||
try {
|
||||
// Get the referenced entity's lossless view
|
||||
const referencedViews = losslessView.view([referenceId]);
|
||||
const referencedView = referencedViews[referenceId];
|
||||
|
||||
if (referencedView) {
|
||||
// Recursively resolve the referenced entity with its target schema
|
||||
const nestedView = this.resolveNestedView(
|
||||
referencedView,
|
||||
referenceSchema.targetSchema,
|
||||
losslessView,
|
||||
context,
|
||||
options
|
||||
);
|
||||
resolvedViews.push(nestedView);
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle resolution errors gracefully
|
||||
console.warn(`Failed to resolve reference ${referenceId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle resolution errors gracefully
|
||||
console.warn(`Failed to resolve composite object from delta ${delta.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedViews;
|
||||
}
|
||||
|
||||
private createCompositeObjectFromDelta(
|
||||
delta: CollapsedDelta,
|
||||
parentEntityId: string,
|
||||
targetSchema: SchemaID,
|
||||
losslessView: Lossless,
|
||||
context: ResolutionContext,
|
||||
options: { includeMetadata: boolean; strictValidation: boolean }
|
||||
): SchemaAppliedViewWithNesting | null {
|
||||
// Group pointers by localContext, excluding the parent pointer
|
||||
const pointersByContext: { [localContext: string]: PropertyTypes[] } = {};
|
||||
let entityReferenceCount = 0;
|
||||
let scalarCount = 0;
|
||||
|
||||
for (const pointer of delta.pointers) {
|
||||
for (const [localContext, target] of Object.entries(pointer)) {
|
||||
// Skip the pointer that references the parent entity (the "up" pointer)
|
||||
if (typeof target === 'string' && target === parentEntityId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!pointersByContext[localContext]) {
|
||||
pointersByContext[localContext] = [];
|
||||
}
|
||||
pointersByContext[localContext].push(target);
|
||||
|
||||
// Count entity references vs scalars
|
||||
if (typeof target === 'string') {
|
||||
const referencedViews = losslessView.view([target]);
|
||||
if (referencedViews[target]) {
|
||||
entityReferenceCount++;
|
||||
} else {
|
||||
scalarCount++;
|
||||
}
|
||||
} else {
|
||||
scalarCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no non-parent pointers found, return null
|
||||
if (Object.keys(pointersByContext).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only create composite objects for deltas with multiple entity references or mixed entity/scalar
|
||||
// Single entity reference should use the original behavior
|
||||
if (entityReferenceCount === 1 && scalarCount === 0) {
|
||||
return null; // Let the original logic handle single entity references
|
||||
}
|
||||
|
||||
// Create the composite object
|
||||
const nestedObjects: { [propertyId: string]: SchemaAppliedViewWithNesting[] } = {};
|
||||
const scalarProperties: { [key: string]: PropertyTypes | PropertyTypes[] } = {};
|
||||
|
||||
for (const [localContext, targets] of Object.entries(pointersByContext)) {
|
||||
if (targets.length === 1) {
|
||||
const target = targets[0];
|
||||
if (typeof target === 'string') {
|
||||
// Try to resolve as entity reference
|
||||
try {
|
||||
const referencedViews = losslessView.view([target]);
|
||||
const referencedView = referencedViews[target];
|
||||
|
||||
if (referencedView) {
|
||||
// Recursively resolve the referenced entity
|
||||
const nestedView = this.resolveNestedView(
|
||||
referencedView,
|
||||
targetSchema,
|
||||
losslessView,
|
||||
context,
|
||||
options
|
||||
);
|
||||
nestedObjects[localContext] = [nestedView];
|
||||
} else {
|
||||
// Not a valid entity reference, treat as scalar
|
||||
scalarProperties[localContext] = target;
|
||||
}
|
||||
} catch (_error) {
|
||||
// Failed to resolve as entity, treat as scalar
|
||||
scalarProperties[localContext] = target;
|
||||
}
|
||||
} else {
|
||||
// Scalar value
|
||||
scalarProperties[localContext] = target;
|
||||
}
|
||||
} else {
|
||||
// Multiple values for same localContext - create array
|
||||
const resolvedArray: (PropertyTypes | SchemaAppliedViewWithNesting)[] = [];
|
||||
|
||||
for (const target of targets) {
|
||||
if (typeof target === 'string') {
|
||||
// Try to resolve as entity reference
|
||||
try {
|
||||
const referencedViews = losslessView.view([target]);
|
||||
const referencedView = referencedViews[target];
|
||||
|
||||
if (referencedView) {
|
||||
const nestedView = this.resolveNestedView(
|
||||
referencedView,
|
||||
targetSchema,
|
||||
losslessView,
|
||||
context,
|
||||
options
|
||||
);
|
||||
resolvedArray.push(nestedView);
|
||||
} else {
|
||||
// Not a valid entity reference, treat as scalar
|
||||
resolvedArray.push(target);
|
||||
}
|
||||
} catch (_error) {
|
||||
// Failed to resolve as entity, treat as scalar
|
||||
resolvedArray.push(target);
|
||||
}
|
||||
} else {
|
||||
// Scalar value
|
||||
resolvedArray.push(target);
|
||||
}
|
||||
}
|
||||
|
||||
// Separate entities from scalars in the array
|
||||
const entities: SchemaAppliedViewWithNesting[] = [];
|
||||
const scalars: PropertyTypes[] = [];
|
||||
|
||||
for (const item of resolvedArray) {
|
||||
if (typeof item === 'object' && item !== null && 'schemaId' in item) {
|
||||
entities.push(item as SchemaAppliedViewWithNesting);
|
||||
} else {
|
||||
scalars.push(item as PropertyTypes);
|
||||
}
|
||||
}
|
||||
|
||||
if (entities.length > 0) {
|
||||
nestedObjects[localContext] = entities;
|
||||
}
|
||||
if (scalars.length > 0) {
|
||||
scalarProperties[localContext] = scalars.length === 1 ? scalars[0] : scalars;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a synthetic composite object
|
||||
const compositeObject = {
|
||||
id: `composite-${delta.id}`, // Synthetic ID for the composite object
|
||||
schemaId: targetSchema,
|
||||
properties: scalarProperties, // Custom field for scalar values
|
||||
nestedObjects,
|
||||
metadata: {
|
||||
appliedAt: Date.now(),
|
||||
depth: context.currentDepth,
|
||||
truncated: false
|
||||
}
|
||||
};
|
||||
|
||||
return compositeObject as unknown as SchemaAppliedViewWithNesting;
|
||||
}
|
||||
|
||||
private extractReferenceIdsFromDelta(delta: CollapsedDelta, parentEntityId: string): string[] {
|
||||
const referenceIds = new Set<string>();
|
||||
|
||||
// For each pointer in the delta, collect all values that aren't the parent entity
|
||||
for (const pointer of delta.pointers) {
|
||||
for (const [_key, value] of Object.entries(pointer)) {
|
||||
if (typeof value === 'string' && value !== parentEntityId) {
|
||||
// This is a potential reference - any string value that's not the parent
|
||||
referenceIds.add(value);
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
// For object values, collect the entity IDs (keys) that aren't the parent
|
||||
for (const entityId of Object.keys(value)) {
|
||||
if (typeof entityId === 'string' && entityId !== parentEntityId) {
|
||||
referenceIds.add(entityId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(referenceIds);
|
||||
}
|
||||
|
||||
private extractReferenceIds(deltas: CollapsedDelta[], parentEntityId: string): string[] {
|
||||
const referenceIds = new Set<string>();
|
||||
|
||||
for (const delta of deltas) {
|
||||
// For each pointer in the delta, collect all values that aren't the parent entity
|
||||
for (const pointer of delta.pointers) {
|
||||
for (const [_key, value] of Object.entries(pointer)) {
|
||||
if (typeof value === 'string' && value !== parentEntityId) {
|
||||
// This is a potential reference - any string value that's not the parent
|
||||
referenceIds.add(value);
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
// For object values, collect the entity IDs (keys) that aren't the parent
|
||||
for (const entityId of Object.keys(value)) {
|
||||
if (typeof entityId === 'string' && entityId !== parentEntityId) {
|
||||
referenceIds.add(entityId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(referenceIds);
|
||||
}
|
||||
|
||||
private createTruncatedView(
|
||||
entityId: string,
|
||||
schemaId: SchemaID,
|
||||
depth: number,
|
||||
truncated: boolean
|
||||
): SchemaAppliedViewWithNesting {
|
||||
return {
|
||||
id: entityId,
|
||||
schemaId,
|
||||
properties: {},
|
||||
nestedObjects: {},
|
||||
metadata: {
|
||||
appliedAt: Date.now(),
|
||||
depth,
|
||||
truncated
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Helper method to resolve circular dependencies
|
||||
getDependencyGraph(): Map<SchemaID, Set<SchemaID>> {
|
||||
const dependencies = new Map<SchemaID, Set<SchemaID>>();
|
||||
|
@ -94,6 +94,13 @@ export interface SchemaAppliedView {
|
||||
};
|
||||
}
|
||||
|
||||
// Extended schema applied view with nested object resolution
|
||||
export interface SchemaAppliedViewWithNesting extends SchemaAppliedView {
|
||||
nestedObjects: {
|
||||
[propertyId: PropertyID]: SchemaAppliedViewWithNesting[];
|
||||
};
|
||||
}
|
||||
|
||||
// Schema-based collection interface
|
||||
export interface TypedCollection<T> {
|
||||
schema: ObjectSchema;
|
||||
@ -202,7 +209,7 @@ export const CommonSchemas = {
|
||||
.property('email', PrimitiveSchemas.string())
|
||||
.property('age', PrimitiveSchemas.number())
|
||||
.property('active', PrimitiveSchemas.boolean())
|
||||
.property('friends', ArraySchemas.of(ReferenceSchemas.to('user-summary', 1)))
|
||||
.property('friends', ArraySchemas.of(ReferenceSchemas.to('user-summary', 2)))
|
||||
.required('name')
|
||||
.build(),
|
||||
|
||||
@ -230,4 +237,55 @@ export const CommonSchemas = {
|
||||
.property('published', PrimitiveSchemas.boolean())
|
||||
.required('title', 'author', 'created')
|
||||
.build()
|
||||
} as const;
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Context for tracking resolution state during nested object resolution
|
||||
* Prevents circular references and manages depth tracking
|
||||
*/
|
||||
export class ResolutionContext {
|
||||
private visited: Set<string> = new Set();
|
||||
|
||||
constructor(
|
||||
public readonly maxDepth: number,
|
||||
public readonly currentDepth: number = 0
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new context with incremented depth
|
||||
*/
|
||||
withDepth(depth: number): ResolutionContext {
|
||||
return new ResolutionContext(this.maxDepth, depth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if entity/schema combination has been visited
|
||||
*/
|
||||
hasVisited(entityId: string, schemaId: SchemaID): boolean {
|
||||
const key = `${entityId}:${schemaId}`;
|
||||
return this.visited.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark entity/schema combination as visited
|
||||
*/
|
||||
visit(entityId: string, schemaId: SchemaID): void {
|
||||
const key = `${entityId}:${schemaId}`;
|
||||
this.visited.add(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove entity/schema combination from visited set
|
||||
*/
|
||||
unvisit(entityId: string, schemaId: SchemaID): void {
|
||||
const key = `${entityId}:${schemaId}`;
|
||||
this.visited.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're at maximum depth
|
||||
*/
|
||||
isAtMaxDepth(): boolean {
|
||||
return this.currentDepth >= this.maxDepth;
|
||||
}
|
||||
}
|
12
todo.md
12
todo.md
@ -39,12 +39,12 @@ This document tracks work needed to achieve full specification compliance, organ
|
||||
- [x] Add custom resolver plugin system
|
||||
- [x] Test concurrent write scenarios
|
||||
|
||||
### 2.3 Nested Object Resolution
|
||||
- [ ] Implement schema-controlled depth limiting
|
||||
- [ ] Add circular reference detection
|
||||
- [ ] Create "Summary" schema type for references
|
||||
- [ ] Test deep nesting scenarios
|
||||
- [ ] Add performance tests for large graphs
|
||||
### 2.3 Nested Object Resolution ✅
|
||||
- [x] Implement schema-controlled depth limiting
|
||||
- [x] Add circular reference detection
|
||||
- [x] Create "Summary" schema type for references
|
||||
- [x] Test deep nesting scenarios
|
||||
- [x] Add performance tests for large graphs
|
||||
|
||||
## Phase 3: Query System
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user
This line appears to do nothing