diff --git a/__tests__/unit/core/json-ast.test.ts b/__tests__/unit/core/json-ast.test.ts index 2a4fc43..bcc92ae 100644 --- a/__tests__/unit/core/json-ast.test.ts +++ b/__tests__/unit/core/json-ast.test.ts @@ -1,5 +1,5 @@ -import { jsonToAst } from '../../../src/util/json-ast'; -import { JsonNode } from '../../../src/util/json-ast/types'; +import { jsonToAst } from '../../../src/utils/json-ast'; +import { JsonNode } from '../../../src/utils/json-ast/types'; describe('jsonToAst', () => { it('should handle primitive values', () => { diff --git a/__tests__/unit/views/resolvers/custom-resolvers.test.ts b/__tests__/unit/views/resolvers/custom-resolvers.test.ts index 339c3c0..2484b1d 100644 --- a/__tests__/unit/views/resolvers/custom-resolvers.test.ts +++ b/__tests__/unit/views/resolvers/custom-resolvers.test.ts @@ -436,6 +436,95 @@ describe('Custom Resolvers', () => { }); }); + describe('Plugin Communication', () => { + test('plugins should be able to access each other\'s states', () => { + // Create a plugin that depends on another property's value + class DependentPlugin implements ResolverPlugin<{ value?: string }> { + name = 'dependent'; + + initialize() { + return { value: 'initial' }; + } + + update( + currentState: { value?: string }, + _newValue: PropertyTypes, + _delta: CollapsedDelta, + context?: { entityState: Record, resolvedValues: Record } + ) { + // This plugin's value depends on the 'source' property's resolved value + const sourceValue = context?.resolvedValues['source']; + return { + value: typeof sourceValue === 'string' ? `Processed: ${sourceValue}` : currentState.value + }; + } + + resolve( + state: { value?: string }, + context?: { entityState: Record, resolvedValues: Record } + ): PropertyTypes | undefined { + // In resolve, we can also check the context if needed + const sourceValue = context?.resolvedValues['source']; + if (typeof sourceValue === 'string' && state.value === 'initial') { + return `Processed: ${sourceValue}`; + } + return state.value; + } + } + + // Create a resolver with both plugins + const resolver = new CustomResolver(lossless, { + source: new LastWriteWinsPlugin(), + dependent: new DependentPlugin() + }); + + // First, set up the source property + const sourceDelta = createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('entity1', 'source', 'original', 'collection') + .buildV1(); + + lossless.ingestDelta(sourceDelta); + + // Then set up the dependent property + const dependentDelta = createDelta('user1', 'host1') + .withTimestamp(2000) + .setProperty('entity1', 'dependent', 'initial', 'collection') + .buildV1(); + + lossless.ingestDelta(dependentDelta); + + // Get the first result + const result = resolver.resolve(); + expect(result).toBeDefined(); + + // The dependent plugin should see the source value + expect(result!['entity1'].properties).toMatchObject({ + source: 'original', + dependent: expect.stringContaining('Processed: original') + }); + + // Create a new delta that updates the source property + const updateDelta = createDelta('user1', 'host1') + .withTimestamp(3000) + .setProperty('entity1', 'source', 'updated', 'collection') + .buildV1(); + + // Ingest the update delta + lossless.ingestDelta(updateDelta); + + // Get the updated result + const updatedResult = resolver.resolve(); + expect(updatedResult).toBeDefined(); + + // The dependent plugin should see the updated source value + expect(updatedResult!['entity1'].properties).toMatchObject({ + source: 'updated', + dependent: expect.stringContaining('Processed: updated') + }); + }); + }); + describe('Edge Cases', () => { test('should handle empty delta sets', () => { const resolver = new CustomResolver(lossless, { diff --git a/__tests__/unit/views/resolvers/relationship-graph.test.ts b/__tests__/unit/views/resolvers/relationship-graph.test.ts new file mode 100644 index 0000000..3eb2345 --- /dev/null +++ b/__tests__/unit/views/resolvers/relationship-graph.test.ts @@ -0,0 +1,67 @@ +import { RhizomeNode, Lossless } from "../../../../src"; +import { RelationshipGraphResolver } from "../../../../src/views/resolvers/relationship-graph"; +import { createDelta } from "../../../../src/core/delta-builder"; + +// Deferring until we figure out lossy view composition +describe.skip('RelationshipGraphResolver', () => { + let node: RhizomeNode; + let lossless: Lossless; + let resolver: RelationshipGraphResolver; + + beforeEach(() => { + node = new RhizomeNode(); + lossless = new Lossless(node); + resolver = new RelationshipGraphResolver(lossless); + }); + + describe('Basic Relationship Resolution', () => { + test('should create a basic relationship between two entities', () => { + const delta = createDelta('user1', 'host1') + .withId('delta1') + .withTimestamp(1000) + .relate('entity1', 'entity2', 'friends', { since: 1640995200000 }) + .buildV1(); + + // Ingest the delta + lossless.ingestDelta(delta); + + // Resolve the graph + const graph = resolver.resolve(); + + // Verify the relationship was created + expect(graph).toBeDefined(); + if (!graph) return; + + console.log(JSON.stringify(graph, null, 2)); + + // Check entities exist + expect(graph.entities.has('entity1')).toBe(true); + expect(graph.entities.has('entity2')).toBe(true); + + // Check relationship exists + const relationshipId = 'delta1'; + expect(graph.relationships.has(relationshipId)).toBe(true); + + const relationship = graph.relationships.get(relationshipId)!; + expect(relationship).toEqual({ + id: relationshipId, + type: 'friends', + source: 'entity1', + target: 'entity2', + properties: { since: 1640995200000 }, + deltas: ['delta1'] + }); + + // Check entity relationships + const entity1 = graph.entities.get('entity1')!; + const entity2 = graph.entities.get('entity2')!; + + expect(entity1.outbound.has(relationshipId)).toBe(true); + expect(entity2.inbound.has(relationshipId)).toBe(true); + + // Check relationship type index + expect(graph.relationshipsByType.has('friends')).toBe(true); + expect(graph.relationshipsByType.get('friends')?.has(relationshipId)).toBe(true); + }); + }); +}); diff --git a/examples/json-ast.ts b/examples/json-ast.ts index a7721d5..b702428 100644 --- a/examples/json-ast.ts +++ b/examples/json-ast.ts @@ -1,4 +1,4 @@ -import { jsonToAst } from '../src/util/json-ast/index'; +import { jsonToAst } from '../src/utils/json-ast/index'; // Example JSON data const exampleJson = { diff --git a/plans/ent-rel-graph.md b/plans/ent-rel-graph.md index 2413ce2..885a913 100644 --- a/plans/ent-rel-graph.md +++ b/plans/ent-rel-graph.md @@ -1,222 +1,23 @@ -# Entity Relationship Graph Implementation Plan +# Entity Relationship Graph -## Overview -This document outlines the plan to implement entity relationship tracking in the rhizome-node system. The implementation treats relationships as first-class entities, each with their own identity and properties. +## Background -## Core Design +Deltas can express relationships by declaring a domain entity for the relationship itself. -### Relationship as First-Class Entities -- Each relationship is a domain entity with its own unique ID -- Relationships have standard properties: `source`, `target`, and `type` -- Additional properties can be added to relationships -- Relationships are created using the `relate()` method in `DeltaBuilder` +Our initial implementation assumes there is only one structure for a relationship: a directed edge from one entity to another. +- `source` and `target` are required properties. +- `type` as well as arbitrary additional properties are optional. -### Delta Structure for Relationships -```typescript -// Creating a relationship -createDelta(creator, host) - .relate( - sourceId, // ID of the source entity - targetId, // ID of the target entity - 'REL_TYPE', // Relationship type - { // Optional properties - prop1: 'value1', - prop2: 'value2' - } - ) - .build(); -``` +Future work may allow for more complex relationships. -### Data Structures +## Goal -#### `LosslessEntity` Updates -```typescript -class LosslessEntity { - // Existing properties - properties = new Map>(); - - // Track relationships where this entity is the source - outboundRelationships = new Map>(); // relationshipType -> Set - - // Track relationships where this entity is the target - inboundRelationships = new Map>(); // relationshipType -> Set - - // ... rest of the class -} -``` +Resolve a graph of relationships among entities. -#### `LosslessViewOne` Extension -```typescript -type RelationshipView = { - id: string; // Relationship ID - type: string; // Relationship type - direction: 'inbound' | 'outbound'; - target: string; // Target entity ID - properties: Record; // Relationship properties -}; +## Discussion of Strategy -type LosslessViewOne = { - id: DomainEntityID; - // ... existing fields ... - relationships?: { - outbound: RelationshipView[]; - inbound: RelationshipView[]; - }; -}; -``` +### Lossy View Composition -## Implementation Steps -### Phase 1: Core Data Structures -1. [x] Update `DeltaBuilder.relate()` to create relationship entities -2. [ ] Update `LosslessEntity` to track relationship IDs -3. [ ] Extend `LosslessViewOne` type to include relationships -### Phase 2: Relationship Management -1. [ ] Implement relationship tracking in `Lossless` class - - Track all relationships by ID - - Maintain source/target indexes -2. [ ] Implement methods for querying relationships - - Get relationships for an entity - - Filter by type and direction - - Support pagination -### Phase 3: Delta Processing -1. [ ] Update `ingestDelta` to handle relationship deltas - - Extract relationship information from deltas - - Update relationship indexes - - Handle relationship updates and deletions -2. [ ] Add conflict resolution for concurrent relationship updates - -### Phase 4: View Generation -1. [ ] Update `view` method to include relationships - - Option to include/exclude relationships - - Support for filtering relationships - - Handle circular references - -### Phase 5: Performance Optimization -1. [ ] Add indexing for relationship lookups -2. [ ] Implement lazy loading for large relationship sets -3. [ ] Add caching for frequently accessed relationships - -## API Extensions - -### Get Entity with Relationships -```typescript -// Get an entity with its relationships -GET /entities/{id}?include=relationships - -// Response -{ - "id": "entity1", - "properties": { /* ... */ }, - "relationships": { - "outbound": [ - { - "id": "rel-123", - "type": "OWNS", - "target": "entity2", - "direction": "outbound", - "properties": { - "since": "2023-01-01" - } - } - ], - "inbound": [] - } -} -``` - -### Query Relationships -```typescript -// Get relationships for an entity -GET /entities/{id}/relationships?type=OWNS&direction=outbound - -// Response -{ - "relationships": [ - { - "id": "rel-123", - "type": "OWNS", - "source": "entity1", - "target": "entity2", - "properties": { - "since": "2023-01-01" - } - } - ] -} -``` - -### Create Relationship -```typescript -// Create a new relationship -POST /relationships -{ - "source": "entity1", - "target": "entity2", - "type": "OWNS", - "properties": { - "since": "2023-01-01" - } -} - -// Response -{ - "id": "rel-123", - "source": "entity1", - "target": "entity2", - "type": "OWNS", - "properties": { - "since": "2023-01-01" - } -} -``` - -## Performance Considerations - -1. **Memory Usage**: - - Store only relationship IDs in entity maps - - Use lazy loading for relationship properties - - Consider weak references if memory becomes an issue - -2. **Query Performance**: - - Add indexes for common relationship queries - - Cache frequently accessed relationships - - Support pagination for large relationship sets - -3. **Delta Processing**: - - Batch process relationship updates - - Optimize delta application for relationship-heavy workloads - -## Future Enhancements - -1. **Advanced Querying**: - - GraphQL support for complex relationship queries - - Support for recursive relationship traversal - -2. **Schema Validation**: - - Define relationship schemas with property validation - - Support for required/optional properties - - Default values for relationship properties - -3. **Indexing**: - - Add support for indexing relationship properties - - Implement efficient querying of relationships by property values - -## Testing Strategy - -1. **Unit Tests**: - - Test relationship creation and deletion - - Verify relationship queries with various filters - - Test delta processing for relationships - -2. **Integration Tests**: - - Test relationship persistence across restarts - - Verify concurrent relationship updates - - Test with large numbers of relationships - -3. **Performance Tests**: - - Measure memory usage with large relationship graphs - - Test query performance with complex relationship patterns - - Benchmark delta processing speed for relationship operations diff --git a/plans/fluent-view-composition.md b/plans/fluent-view-composition.md new file mode 100644 index 0000000..255c1c6 --- /dev/null +++ b/plans/fluent-view-composition.md @@ -0,0 +1,191 @@ +# Fluent API for View Composition + +## Overview + +This document outlines a fluent API for declaring and composing lossy views in a declarative and type-safe manner. The API is designed to make it easy to create complex view compositions with minimal boilerplate. + +## Core Concepts + +1. **View Composition**: Combining multiple resolvers to create a unified view +2. **Fluent Builder Pattern**: Method chaining for declarative configuration +3. **Type Safety**: Leveraging TypeScript's type system for better developer experience +4. **Extensibility**: Easy to add new composition patterns and resolvers + +## API Design + +### 1. Base Builder + +```typescript +import { Lossless } from '../lossless'; + +type ViewTransformer = (view: LosslessViewOne) => LosslessViewOne; +type ResultTransformer = (result: T) => R; + +class ViewCompositionBuilder { + private resolvers: Array<{ + key: string; + resolver: Lossy; + viewTransformer?: ViewTransformer; + resultTransformer?: (result: any) => any; + }> = []; + + constructor(private readonly lossless: Lossless) {} + + // Start building a new composition + static create(lossless: Lossless): ViewCompositionBuilder { + return new ViewCompositionBuilder(lossless); + } + + // Add a resolver to the composition + withResolver( + key: string, + resolver: Lossy, + options: { + view?: ViewTransformer; + result?: ResultTransformer; + } = {} + ): this { + this.resolvers.push({ + key, + resolver, + viewTransformer: options.view, + resultTransformer: options.result, + }); + return this; + } + + // Build the final composition + build>(): Lossy, T> { + // Implementation that creates a combined resolver + return new CombinedResolver(this.lossless, this.resolvers); + } +} +``` + +### 2. Example Usage: Relationship Graph + +```typescript +const relationshipGraph = ViewCompositionBuilder + .create(lossless) + .withResolver('entities', new EntityResolver(), { + view: view => ({ + ...view, + propertyDeltas: Object.fromEntries( + Object.entries(view.propertyDeltas).filter(([k]) => !k.startsWith('_rel_')) + ) + }) + }) + .withResolver('relationships', new RelationshipResolver(), { + view: view => ({ + ...view, + propertyDeltas: Object.fromEntries( + Object.entries(view.propertyDeltas).filter(([k]) => k.startsWith('_rel_')) + ) + }) + }) + .withResolver('stats', new StatsCollector()) + .withResolver('metadata', new MetadataResolver(), { + result: (metadata) => ({ + ...metadata, + generatedAt: new Date().toISOString() + }) + }) + .build<{ + entities: EntityMap; + relationships: RelationshipMap; + stats: Stats; + metadata: Metadata; + }>(); +``` + +### 3. Advanced Composition + +```typescript +// Nested composition +const userProfile = ViewCompositionBuilder + .create(lossless) + .withResolver('basicInfo', new BasicInfoResolver()) + .withResolver('activity', ViewCompositionBuilder + .create(lossless) + .withResolver('recentPosts', new RecentPostsResolver()) + .withResolver('notifications', new NotificationsResolver()) + .build() + ) + .withResolver('recommendations', new RecommendationsResolver()) + .build<{ + basicInfo: UserBasicInfo; + activity: { + recentPosts: Post[]; + notifications: Notification[]; + }; + recommendations: Recommendation[]; + }>(); +``` + +## Implementation Details + +### CombinedResolver Implementation + +```typescript +class CombinedResolver, Result> + extends Lossy { + + constructor( + private readonly lossless: Lossless, + private readonly resolvers: Array<{ + key: string; + resolver: Lossy; + viewTransformer?: ViewTransformer; + resultTransformer?: (result: any) => any; + }> + ) { + super(lossless); + } + + initializer(view: LosslessViewOne): State { + return this.resolvers.reduce((state, { key, resolver, viewTransformer }) => { + const transformedView = viewTransformer ? viewTransformer(view) : view; + return { + ...state, + [key]: resolver.initializer(transformedView) + }; + }, {} as State); + } + + reducer(state: State, view: LosslessViewOne): State { + return this.resolvers.reduce((newState, { key, resolver, viewTransformer }) => { + const transformedView = viewTransformer ? viewTransformer(view) : view; + return { + ...newState, + [key]: resolver.reducer(state[key], transformedView) + }; + }, { ...state }); + } + + resolver(state: State): Result { + return this.resolvers.reduce((result, { key, resolver, resultTransformer }) => { + const resolved = resolver.resolver(state[key]); + return { + ...result, + [key]: resultTransformer ? resultTransformer(resolved) : resolved + }; + }, {} as Result); + } +} +``` + +## Benefits + +1. **Readability**: Clear, declarative syntax +2. **Type Safety**: Full TypeScript support with proper type inference +3. **Composability**: Easy to combine and nest resolvers +4. **Maintainability**: Isolated concerns and transformations +5. **Flexibility**: Custom view and result transformations + +## Next Steps + +1. Implement the base `ViewCompositionBuilder` and `CombinedResolver` +2. Add support for common patterns (filtering, mapping, etc.) +3. Create documentation with examples +4. Refactor existing resolvers to use the new composition API +5. Add performance optimizations (memoization, lazy evaluation) diff --git a/plans/view-composition.md b/plans/view-composition.md new file mode 100644 index 0000000..3a0f402 --- /dev/null +++ b/plans/view-composition.md @@ -0,0 +1,182 @@ +# View Composition with Combined State + +## Overview + +This document outlines the design for composing multiple lossy views using a combined state approach. This pattern allows different resolvers to work on their own parts of the state while sharing the same underlying delta stream. + +## Core Concept + +The combined state approach involves: +1. Creating a parent resolver that manages multiple child resolvers +2. Each child resolver maintains its own state +3. The parent coordinates the initialization, reduction, and resolution of child states +4. The final result combines the outputs of all child resolvers + +## Implementation Pattern + +### 1. Base Interfaces + +```typescript +interface CombinedState { + [resolverKey: string]: unknown; +} + +interface CombinedResult { + [resolverKey: string]: unknown; +} + +class CombinedResolver + extends Lossy { + + private resolvers: { + [key: string]: { + instance: Lossy; + initializer: (view: LosslessViewOne) => any; + reducer: (state: any, view: LosslessViewOne) => any; + resolver: (state: any) => any; + }; + } = {}; +} +``` + +### 2. Registering Resolvers + +```typescript +registerResolver( + key: string, + resolver: Lossy, + options?: { + // Optional: Transform the view before passing to the child resolver + viewTransformer?: (view: LosslessViewOne) => LosslessViewOne; + // Optional: Transform the result after resolution + resultTransformer?: (result: U) => unknown; + } +) { + this.resolvers[key] = { + instance: resolver, + initializer: (view) => { + const transformedView = options?.viewTransformer?.(view) ?? view; + return resolver.initializer(transformedView); + }, + reducer: (state, view) => { + const transformedView = options?.viewTransformer?.(view) ?? view; + return resolver.reducer(state, transformedView); + }, + resolver: (state) => { + const result = resolver.resolver(state); + return options?.resultTransformer?.(result) ?? result; + } + }; +} +``` + +### 3. Core Methods + +```typescript +initializer(view: LosslessViewOne): State { + const state = {} as State; + + for (const [key, { initializer }] of Object.entries(this.resolvers)) { + state[key] = initializer(view); + } + + return state; +} + +reducer(state: State, view: LosslessViewOne): State { + const newState = { ...state }; + + for (const [key, { reducer }] of Object.entries(this.resolvers)) { + newState[key] = reducer(state[key], view); + } + + return newState; +} + +resolver(state: State): Result { + const result = {} as Result; + + for (const [key, { resolver }] of Object.entries(this.resolvers)) { + result[key] = resolver(state[key]); + } + + return result; +} +``` + +## Example: Relationship Graph with Multiple Resolvers + +```typescript +class RelationshipGraphResolver extends CombinedResolver< + { + entities: EntityState; + relationships: RelationshipState; + metadata: MetadataState; + }, + { + graph: GraphResult; + stats: StatsResult; + metadata: MetadataResult; + } +> { + constructor(lossless: Lossless) { + super(lossless); + + // Register entity resolver + this.registerResolver('entities', new EntityResolver(lossless)); + + // Register relationship resolver + this.registerResolver( + 'relationships', + new RelationshipResolver(lossless), + { + // Only process relationship deltas + viewTransformer: view => ({ + ...view, + propertyDeltas: Object.fromEntries( + Object.entries(view.propertyDeltas) + .filter(([key]) => key.startsWith('_rel_')) + ) + }) + } + ); + + // Register metadata resolver + this.registerResolver('metadata', new MetadataResolver(lossless)); + } + + // Override resolver to combine results + resolver(state: any) { + const results = super.resolver(state); + + return { + nodes: results.entities, + edges: results.relationships, + stats: results.stats, + metadata: results.metadata + }; + } +} +``` + +## Benefits + +1. **Separation of Concerns**: Each resolver handles a specific aspect of the data +2. **Reusability**: Resolvers can be reused in different combinations +3. **Maintainability**: Changes to one resolver don't affect others +4. **Testability**: Each resolver can be tested in isolation +5. **Flexibility**: Easy to add, remove, or modify resolvers + +## Performance Considerations + +1. **Memory Usage**: Combined state increases memory usage +2. **Processing Overhead**: Each delta is processed by all resolvers +3. **Optimization**: Use view transformers to filter deltas early + +## Next Steps + +1. Implement the base `CombinedResolver` class +2. Refactor `RelationshipGraphResolver` to use this pattern +3. Add tests for the composition behavior +4. Document common patterns and best practices +5. Consider adding middleware support for cross-cutting concerns diff --git a/src/http/html.ts b/src/http/html.ts index e675140..534b814 100644 --- a/src/http/html.ts +++ b/src/http/html.ts @@ -1,6 +1,6 @@ import express, {Router} from "express"; import {RhizomeNode} from "../node"; -import {htmlDocFromMarkdown, MDFiles} from "../util/md-files"; +import {htmlDocFromMarkdown, MDFiles} from "../utils/md-files"; export class HttpHtml { router = Router(); diff --git a/src/util/json-ast/index.ts b/src/utils/json-ast/index.ts similarity index 100% rename from src/util/json-ast/index.ts rename to src/utils/json-ast/index.ts diff --git a/src/util/json-ast/types.ts b/src/utils/json-ast/types.ts similarity index 100% rename from src/util/json-ast/types.ts rename to src/utils/json-ast/types.ts diff --git a/src/util/md-files.ts b/src/utils/md-files.ts similarity index 100% rename from src/util/md-files.ts rename to src/utils/md-files.ts diff --git a/src/views/resolvers/custom-resolvers.ts b/src/views/resolvers/custom-resolvers.ts index ee9d914..1c18d4c 100644 --- a/src/views/resolvers/custom-resolvers.ts +++ b/src/views/resolvers/custom-resolvers.ts @@ -11,11 +11,30 @@ export interface ResolverPlugin { initialize(): T; // Process a new value for the property - update(currentState: T, newValue: PropertyTypes, delta: CollapsedDelta): T; + update( + currentState: T, + newValue: PropertyTypes, + delta: CollapsedDelta, + // Additional context including other properties' states + context?: { + // Current state of all properties for the entity + entityState: Record; + // Current resolved values of all properties for the entity + resolvedValues: Record; + } + ): T; // Resolve the final value from the accumulated state - // Returns undefined if no valid value could be resolved - resolve(state: T): PropertyTypes | undefined; + resolve( + state: T, + // Additional context including other properties' states + context?: { + // Current state of all properties for the entity + entityState: Record; + // Current resolved values of all properties for the entity + resolvedValues: Record; + } + ): PropertyTypes | undefined; } // Configuration for custom resolver @@ -75,25 +94,67 @@ export class CustomResolver extends Lossy = {}; + const resolvedValues: Record = {}; + + // Initialize all properties first + for (const propertyId of Object.keys(cur.propertyDeltas)) { const plugin = this.config[propertyId]; if (!plugin) continue; - - // Initialize property state if not exists + if (!acc[cur.id].properties[propertyId]) { acc[cur.id].properties[propertyId] = { plugin, state: plugin.initialize() }; } + + // Store the current state + entityState[propertyId] = acc[cur.id].properties[propertyId].state; + + // Resolve current value if possible + try { + const resolved = plugin.resolve(acc[cur.id].properties[propertyId].state, { + entityState: {}, + resolvedValues: {} + }); + if (resolved !== undefined) { + resolvedValues[propertyId] = resolved; + } + } catch (_e) { + // Ignore resolution errors during reduction + } + } + + // Second pass: process deltas with full context + for (const [propertyId, deltas] of Object.entries(cur.propertyDeltas)) { + const plugin = this.config[propertyId]; + if (!plugin) continue; const propertyState = acc[cur.id].properties[propertyId]; + const context = { entityState, resolvedValues }; // Process all deltas for this property for (const delta of deltas || []) { const value = extractValueFromDelta(propertyId, delta); if (value !== undefined) { - propertyState.state = propertyState.plugin.update(propertyState.state, value, delta); + propertyState.state = plugin.update( + propertyState.state, + value, + delta, + context + ); + + // Update the resolved value after each update + try { + const resolved = plugin.resolve(propertyState.state, context); + if (resolved !== undefined) { + resolvedValues[propertyId] = resolved; + } + } catch (_e) { + // Ignore resolution errors during reduction + } } } } @@ -106,12 +167,40 @@ export class CustomResolver extends Lossy = {}; + const resolvedValues: Record = {}; + + // Initialize with current states and resolve all properties for (const [propertyId, propertyState] of Object.entries(entity.properties)) { - const resolvedValue = propertyState.plugin.resolve(propertyState.state); - // Only add the property if the resolved value is not undefined - if (resolvedValue !== undefined) { - entityResult.properties[propertyId] = resolvedValue; + entityState[propertyId] = propertyState.state; + // Initial resolution with empty context + try { + const resolved = propertyState.plugin.resolve(propertyState.state, { + entityState: {}, + resolvedValues: {} + }); + if (resolved !== undefined) { + resolvedValues[propertyId] = resolved; + } + } catch (_e) { + // Ignore resolution errors + } + } + + // Second pass: resolve with full context + for (const [propertyId, propertyState] of Object.entries(entity.properties)) { + const context = { entityState, resolvedValues }; + try { + const resolvedValue = propertyState.plugin.resolve(propertyState.state, context); + if (resolvedValue !== undefined) { + entityResult.properties[propertyId] = resolvedValue; + // Update the resolved value for dependent properties + resolvedValues[propertyId] = resolvedValue; + } + } catch (_e) { + // Ignore resolution errors } } @@ -137,7 +226,12 @@ export class LastWriteWinsPlugin implements ResolverPlugin<{ value?: PropertyTyp return { timestamp: 0 }; } - update(currentState: { value?: PropertyTypes, timestamp: number }, newValue: PropertyTypes, delta: CollapsedDelta) { + update( + currentState: { value?: PropertyTypes, timestamp: number }, + newValue: PropertyTypes, + delta: CollapsedDelta, + _context?: { entityState: Record, resolvedValues: Record } + ) { if (delta.timeCreated > currentState.timestamp) { return { value: newValue, @@ -160,7 +254,12 @@ export class FirstWriteWinsPlugin implements ResolverPlugin<{ value?: PropertyTy return { timestamp: Infinity }; } - update(currentState: { value?: PropertyTypes, timestamp: number }, newValue: PropertyTypes, delta: CollapsedDelta) { + update( + currentState: { value?: PropertyTypes, timestamp: number }, + newValue: PropertyTypes, + delta: CollapsedDelta, + _context?: { entityState: Record, resolvedValues: Record } + ) { if (delta.timeCreated < currentState.timestamp) { return { value: newValue, @@ -185,7 +284,12 @@ export class ConcatenationPlugin implements ResolverPlugin<{ values: { value: st return { values: [] }; } - update(currentState: { values: { value: string, timestamp: number }[] }, newValue: PropertyTypes, delta: CollapsedDelta) { + update( + currentState: { values: { value: string, timestamp: number }[] }, + newValue: PropertyTypes, + delta: CollapsedDelta, + _context?: { entityState: Record, resolvedValues: Record } + ) { if (typeof newValue === 'string') { // Check if this value already exists (avoid duplicates) const exists = currentState.values.some(v => v.value === newValue); @@ -201,7 +305,10 @@ export class ConcatenationPlugin implements ResolverPlugin<{ values: { value: st return currentState; } - resolve(state: { values: { value: string, timestamp: number }[] }): PropertyTypes { + resolve( + state: { values: { value: string, timestamp: number }[] }, + _context?: { entityState: Record, resolvedValues: Record } + ): PropertyTypes { return state.values.map(v => v.value).join(this.separator); } } @@ -214,24 +321,32 @@ export class MajorityVotePlugin implements ResolverPlugin<{ votes: Map }, newValue: PropertyTypes, _delta: CollapsedDelta) { - const currentCount = currentState.votes.get(newValue) || 0; - currentState.votes.set(newValue, currentCount + 1); + update( + currentState: { votes: Map }, + newValue: PropertyTypes, + _delta: CollapsedDelta, + _context?: { entityState: Record, resolvedValues: Record } + ) { + const count = (currentState.votes.get(newValue) || 0) + 1; + currentState.votes.set(newValue, count); return currentState; } - resolve(state: { votes: Map }): PropertyTypes { + resolve( + state: { votes: Map }, + _context?: { entityState: Record, resolvedValues: Record } + ): PropertyTypes { let maxVotes = 0; - let winner: PropertyTypes = ''; - - for (const [value, votes] of state.votes.entries()) { - if (votes > maxVotes) { - maxVotes = votes; - winner = value; + let result: PropertyTypes = ''; + + for (const [value, count] of state.votes.entries()) { + if (count > maxVotes) { + maxVotes = count; + result = value; } } - - return winner; + + return result; } } @@ -243,7 +358,12 @@ export class MinPlugin implements ResolverPlugin<{ min?: number }> { return {}; } - update(currentState: { min?: number }, newValue: PropertyTypes, _delta: CollapsedDelta) { + update( + currentState: { min?: number }, + newValue: PropertyTypes, + _delta: CollapsedDelta, + _context?: { entityState: Record, resolvedValues: Record } + ) { if (typeof newValue === 'number') { if (currentState.min === undefined || newValue < currentState.min) { return { min: newValue }; @@ -252,7 +372,10 @@ export class MinPlugin implements ResolverPlugin<{ min?: number }> { return currentState; } - resolve(state: { min?: number }): PropertyTypes | undefined { + resolve( + state: { min?: number }, + _context?: { entityState: Record, resolvedValues: Record } + ): PropertyTypes | undefined { return state.min; } } @@ -264,7 +387,12 @@ export class MaxPlugin implements ResolverPlugin<{ max?: number }> { return {}; } - update(currentState: { max?: number }, newValue: PropertyTypes, _delta: CollapsedDelta) { + update( + currentState: { max?: number }, + newValue: PropertyTypes, + _delta: CollapsedDelta, + _context?: { entityState: Record, resolvedValues: Record } + ) { if (typeof newValue === 'number') { if (currentState.max === undefined || newValue > currentState.max) { return { max: newValue }; @@ -273,7 +401,10 @@ export class MaxPlugin implements ResolverPlugin<{ max?: number }> { return currentState; } - resolve(state: { max?: number }): PropertyTypes | undefined { + resolve( + state: { max?: number }, + _context?: { entityState: Record, resolvedValues: Record } + ): PropertyTypes | undefined { return state.max; } } \ No newline at end of file diff --git a/src/views/resolvers/relationship-graph.ts b/src/views/resolvers/relationship-graph.ts index d5b5232..4840b56 100644 --- a/src/views/resolvers/relationship-graph.ts +++ b/src/views/resolvers/relationship-graph.ts @@ -1,6 +1,12 @@ -import { CollapsedDelta, LosslessViewOne } from "../lossless"; +import { CollapsedDelta, Lossless, LosslessViewOne } from "../lossless"; import { PropertyTypes } from "../../core/types"; import { Lossy } from "../lossy"; +import Debug from 'debug'; +import { CustomResolver, LastWriteWinsPlugin } from "./custom-resolvers"; + +const debug = Debug('rz:resolver:relationship-graph'); +const trace = Debug('rz:resolver:relationship-graph:trace'); +trace.enabled = true; // Always enable trace for now /** * Represents a single relationship between entities @@ -51,31 +57,43 @@ export type RelationshipView = { * A resolver that builds a relationship graph from a LosslessViewMany */ export class RelationshipGraphResolver extends Lossy { + private relData: CustomResolver; + constructor(lossless: Lossless) { + super(lossless); + + this.relData = new CustomResolver(lossless, { + relationships: new LastWriteWinsPlugin(), + }); + } /** * Initialize a new accumulator */ protected createRelationshipGraphAccumulator(): RelationshipGraphAccumulator { - return { + debug('Creating new relationship graph accumulator'); + const accumulator = { entities: new Map(), relationships: new Map(), relationshipsByType: new Map(), lastUpdated: Date.now() }; + trace('Created accumulator:', accumulator); + return accumulator; } /** * Initialize the accumulator with a view */ initializer(view: LosslessViewOne): RelationshipGraphAccumulator { + debug('Initializing relationship graph for view:', view.id); const graph = this.createRelationshipGraphAccumulator(); + + this.relData.initializer(view); - // Initialize entity relationships if they don't exist - if (!graph.entities.has(view.id)) { - graph.entities.set(view.id, { - outbound: new Map(), - inbound: new Map() - }); - } + trace('Initialized graph state:', { + entities: Array.from(graph.entities.keys()), + relationships: Array.from(graph.relationships.keys()), + relationshipTypes: Array.from(graph.relationshipsByType.keys()) + }); return graph; } @@ -84,8 +102,21 @@ export class RelationshipGraphResolver extends Lossy ({ + type, + count: ids.size + })) + }); return graph; } @@ -118,17 +163,29 @@ export class RelationshipGraphResolver extends Lossy