rhizome-node/__tests__/unit/views/nested-resolution.test.ts
Lentil Hoffman d7c4fda93e
refactor(resolver): overhaul plugin system and dependency handling
Core Changes:
- Completely rewrote CustomResolver reducer with dependency-ordered processing
- Enhanced plugin initialization with proper dependency injection
- Improved delta processing and property value tracking
- Added robust error handling for duplicate property IDs

Resolver Improvements:
- Updated to use new accumulator structure
- Implemented execution order processing for plugins
- Enhanced debug logging and error reporting
- Simplified TimestampResolver by removing unused initializer

Configuration Updates:
- Added TypeScript path aliases for test helpers
- Improved module resolution paths

Key Benefits:
- More robust plugin dependency management
- More efficient state updates
- Enhanced type safety
- Better error messages and debugging
- More consistent plugin initialization

This refactoring focuses on improving the robustness of the resolver,
especially around plugin lifecycle management and dependency handling.
The changes ensure better separation of concerns and more predictable
behavior when dealing with complex plugin dependencies.
2025-06-25 06:10:34 -05:00

404 lines
14 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 { DefaultSchemaRegistry } from '@src/schema';
import { SchemaBuilder, PrimitiveSchemas, ReferenceSchemas } from '@src/schema';
import { CommonSchemas } from '../../../util/schemas';
import { TypedCollectionImpl } from '@src/collections';
import { createDelta } from '@src/core/delta-builder';
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', () => {
test('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 = createDelta(node.config.creator, node.config.peerId)
.addPointer('users', 'alice', 'friends')
.addPointer('friends', 'bob')
.buildV1();
node.lossless.ingestDelta(friendshipDelta);
// Get Alice's lossless view
const aliceViews = node.lossless.compose(['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);
}
});
test('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 = createDelta(node.config.creator, node.config.peerId)
.addPointer('users', 'alice', 'friends')
.addPointer('friends', 'nonexistent')
.buildV1();
node.lossless.ingestDelta(friendshipDelta);
const aliceViews = node.lossless.compose(['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', () => {
test('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 = createDelta(node.config.creator, node.config.peerId)
.addPointer('deep-users', 'alice', 'mentor')
.addPointer('mentor', 'bob')
.buildV1();
node.lossless.ingestDelta(mentorshipDelta1);
// Bob's mentor is Charlie
const mentorshipDelta2 = createDelta(node.config.creator, node.config.peerId)
.addPointer('deep-users', 'bob', 'mentor')
.addPointer('mentor', 'charlie')
.buildV1();
node.lossless.ingestDelta(mentorshipDelta2);
const aliceViews = node.lossless.compose(['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', () => {
test('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 = createDelta(node.config.creator, node.config.peerId)
.addPointer('users', 'alice', 'friends')
.addPointer('friends', 'bob')
.buildV1();
node.lossless.ingestDelta(friendship1);
const friendship2 = createDelta(node.config.creator, node.config.peerId)
.addPointer('users', 'bob', 'friends')
.addPointer('friends', 'alice')
.buildV1();
node.lossless.ingestDelta(friendship2);
const aliceViews = node.lossless.compose(['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();
});
test('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 = createDelta(node.config.creator, node.config.peerId)
.addPointer('users', 'alice', 'friends')
.addPointer('friends', 'alice')
.buildV1();
node.lossless.ingestDelta(selfFriendship);
const aliceViews = node.lossless.compose(['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', () => {
test('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 = createDelta(node.config.creator, node.config.peerId)
.addPointer('users', 'alice', 'friends')
.addPointer('friends', 'bob')
.buildV1();
node.lossless.ingestDelta(friendship1);
const friendship2 = createDelta(node.config.creator, node.config.peerId)
.addPointer('users', 'alice', 'friends')
.addPointer('friends', 'charlie')
.buildV1();
node.lossless.ingestDelta(friendship2);
const aliceViews = node.lossless.compose(['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', () => {
test('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 = createDelta(node.config.creator, node.config.peerId)
.addPointer('users', 'alice', 'friends')
.addPointer('friends', 'bob')
.buildV1();
node.lossless.ingestDelta(friendship);
const aliceViews = node.lossless.compose(['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();
}
});
});
});