443 lines
15 KiB
TypeScript
443 lines
15 KiB
TypeScript
/**
|
|
* 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/core';
|
|
import { DefaultSchemaRegistry } from '../src/schema';
|
|
import { CommonSchemas, SchemaBuilder, PrimitiveSchemas, ReferenceSchemas } from '../src/schema';
|
|
import { TypedCollectionImpl } from '../src/collections';
|
|
|
|
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();
|
|
}
|
|
});
|
|
});
|
|
}); |