diff --git a/__tests__/compose-decompose.ts b/__tests__/compose-decompose.ts new file mode 100644 index 0000000..08e50ee --- /dev/null +++ b/__tests__/compose-decompose.ts @@ -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); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/multi-pointer-resolution.ts b/__tests__/multi-pointer-resolution.ts new file mode 100644 index 0000000..4d66d1e --- /dev/null +++ b/__tests__/multi-pointer-resolution.ts @@ -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'); + } + }); + }); +}); \ No newline at end of file diff --git a/__tests__/nested-resolution-performance.ts b/__tests__/nested-resolution-performance.ts new file mode 100644 index 0000000..192eb3f --- /dev/null +++ b/__tests__/nested-resolution-performance.ts @@ -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}`); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/nested-resolution.ts b/__tests__/nested-resolution.ts new file mode 100644 index 0000000..62bb809 --- /dev/null +++ b/__tests__/nested-resolution.ts @@ -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(); + } + }); + }); +}); \ No newline at end of file diff --git a/src/lossless.ts b/src/lossless.ts index 5762cf3..9ce0d76 100644 --- a/src/lossless.ts +++ b/src/lossless.ts @@ -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(); + + // 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()); diff --git a/src/schema-registry.ts b/src/schema-registry.ts index acc203b..8567a16 100644 --- a/src/schema-registry.ts +++ b/src/schema-registry.ts @@ -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(); + + // 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(); + + 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> { const dependencies = new Map>(); diff --git a/src/schema.ts b/src/schema.ts index 71133c3..26fd78f 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -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 { 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; \ No newline at end of file +} as const; + +/** + * Context for tracking resolution state during nested object resolution + * Prevents circular references and manages depth tracking + */ +export class ResolutionContext { + private visited: Set = 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; + } +} \ No newline at end of file diff --git a/todo.md b/todo.md index 4a89faf..17185a7 100644 --- a/todo.md +++ b/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