major refactor to shore up some misalignments, ready for phase 3

This commit is contained in:
Mykola Bilokonsky 2025-06-09 20:50:33 -04:00
parent 2dbf9a420a
commit c2f1f537f7
8 changed files with 1880 additions and 15 deletions

View 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);
});
});
});

View 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');
}
});
});
});

View 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}`);
});
});
});

View 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();
}
});
});
});

View File

@ -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());

View File

@ -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>>();

View File

@ -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
View File

@ -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