major refactor to shore up some misalignments, ready for phase 3
This commit is contained in:
parent
2dbf9a420a
commit
c2f1f537f7
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
|
||||
}
|
||||
|
||||
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