feat: enable inter-plugin state sharing in CustomResolver

- Update ResolverPlugin interface to include context in update and resolve methods
- Modify CustomResolver to pass entity state and resolved values to plugins
- Update built-in plugins to accept and use the new context parameter
- Add comprehensive test for inter-plugin communication
- Add documentation for the new view composition patterns

This change enables plugins to access each other's states during both update and resolve phases, allowing for more powerful and flexible resolver compositions.
This commit is contained in:
Lentil Hoffman 2025-06-22 20:42:05 -05:00
parent a248f1d1a8
commit 5afd3232cb
Signed by: lentil
GPG Key ID: 0F5B99F3F4D0C087
13 changed files with 788 additions and 258 deletions

View File

@ -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', () => {

View File

@ -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<string, unknown>, resolvedValues: Record<string, PropertyTypes> }
) {
// 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<string, unknown>, resolvedValues: Record<string, PropertyTypes> }
): 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, {

View File

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

View File

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

View File

@ -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<PropertyID, Set<Delta>>();
// Track relationships where this entity is the source
outboundRelationships = new Map<string, Set<string>>(); // relationshipType -> Set<relationshipId>
// Track relationships where this entity is the target
inboundRelationships = new Map<string, Set<string>>(); // relationshipType -> Set<relationshipId>
// ... 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<string, any>; // 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

View File

@ -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<T, R> = (result: T) => R;
class ViewCompositionBuilder {
private resolvers: Array<{
key: string;
resolver: Lossy<any, any>;
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<T, R>(
key: string,
resolver: Lossy<T, R>,
options: {
view?: ViewTransformer;
result?: ResultTransformer<R, any>;
} = {}
): this {
this.resolvers.push({
key,
resolver,
viewTransformer: options.view,
resultTransformer: options.result,
});
return this;
}
// Build the final composition
build<T extends Record<string, any>>(): Lossy<Record<string, any>, 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<State extends Record<string, any>, Result>
extends Lossy<State, Result> {
constructor(
private readonly lossless: Lossless,
private readonly resolvers: Array<{
key: string;
resolver: Lossy<any, any>;
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)

182
plans/view-composition.md Normal file
View File

@ -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<State extends CombinedState, Result extends CombinedResult>
extends Lossy<State, Result> {
private resolvers: {
[key: string]: {
instance: Lossy<any, any>;
initializer: (view: LosslessViewOne) => any;
reducer: (state: any, view: LosslessViewOne) => any;
resolver: (state: any) => any;
};
} = {};
}
```
### 2. Registering Resolvers
```typescript
registerResolver<T, U>(
key: string,
resolver: Lossy<T, U>,
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

View File

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

View File

@ -11,11 +11,30 @@ export interface ResolverPlugin<T = unknown> {
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<string, unknown>;
// Current resolved values of all properties for the entity
resolvedValues: Record<string, PropertyTypes>;
}
): 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<string, unknown>;
// Current resolved values of all properties for the entity
resolvedValues: Record<string, PropertyTypes>;
}
): PropertyTypes | undefined;
}
// Configuration for custom resolver
@ -75,25 +94,67 @@ export class CustomResolver extends Lossy<CustomResolverAccumulator, CustomResol
acc[cur.id] = { id: cur.id, properties: {} };
}
for (const [propertyId, deltas] of Object.entries(cur.propertyDeltas)) {
// First pass: collect all property states and resolved values
const entityState: Record<string, unknown> = {};
const resolvedValues: Record<string, PropertyTypes> = {};
// 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<CustomResolverAccumulator, CustomResol
for (const [entityId, entity] of Object.entries(cur)) {
const entityResult: { id: string; properties: EntityProperties } = { id: entityId, properties: {} };
// First pass: collect all property states
const entityState: Record<string, unknown> = {};
const resolvedValues: Record<string, PropertyTypes> = {};
// 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<string, unknown>, resolvedValues: Record<string, PropertyTypes> }
) {
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<string, unknown>, resolvedValues: Record<string, PropertyTypes> }
) {
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<string, unknown>, resolvedValues: Record<string, PropertyTypes> }
) {
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<string, unknown>, resolvedValues: Record<string, PropertyTypes> }
): PropertyTypes {
return state.values.map(v => v.value).join(this.separator);
}
}
@ -214,24 +321,32 @@ export class MajorityVotePlugin implements ResolverPlugin<{ votes: Map<PropertyT
return { votes: new Map() };
}
update(currentState: { votes: Map<PropertyTypes, number> }, newValue: PropertyTypes, _delta: CollapsedDelta) {
const currentCount = currentState.votes.get(newValue) || 0;
currentState.votes.set(newValue, currentCount + 1);
update(
currentState: { votes: Map<PropertyTypes, number> },
newValue: PropertyTypes,
_delta: CollapsedDelta,
_context?: { entityState: Record<string, unknown>, resolvedValues: Record<string, PropertyTypes> }
) {
const count = (currentState.votes.get(newValue) || 0) + 1;
currentState.votes.set(newValue, count);
return currentState;
}
resolve(state: { votes: Map<PropertyTypes, number> }): PropertyTypes {
resolve(
state: { votes: Map<PropertyTypes, number> },
_context?: { entityState: Record<string, unknown>, resolvedValues: Record<string, PropertyTypes> }
): 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<string, unknown>, resolvedValues: Record<string, PropertyTypes> }
) {
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<string, unknown>, resolvedValues: Record<string, PropertyTypes> }
): 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<string, unknown>, resolvedValues: Record<string, PropertyTypes> }
) {
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<string, unknown>, resolvedValues: Record<string, PropertyTypes> }
): PropertyTypes | undefined {
return state.max;
}
}

View File

@ -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<RelationshipGraphAccumulator, RelationshipGraphAccumulator> {
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<RelationshipGraphAccumulato
* Process a view and update the accumulator
*/
reducer(graph: RelationshipGraphAccumulator, view: LosslessViewOne): RelationshipGraphAccumulator {
debug(`Processing view ${view.id} in reducer`);
trace('View details:', {
id: view.id,
propertyCount: Object.keys(view.propertyDeltas).length,
properties: Object.keys(view.propertyDeltas)
});
// if (!isRelationshipEntity) {
// trace(`Skipping non-relationship delta: ${view.id}`);
// return graph;
// }
// Ensure entity exists in the graph
if (!graph.entities.has(view.id)) {
trace(`Adding new entity in reducer: ${view.id}`);
graph.entities.set(view.id, {
outbound: new Map(),
inbound: new Map()
@ -94,8 +125,13 @@ export class RelationshipGraphResolver extends Lossy<RelationshipGraphAccumulato
// Process relationship properties
for (const [property, deltas] of Object.entries(view.propertyDeltas)) {
trace(`Processing property: ${property} with ${deltas.length} deltas`);
// Skip non-relationship properties
if (!property.startsWith('_rel_')) continue;
if (!property.startsWith('_rel_')) {
trace(`Skipping non-relationship property: ${property}`);
continue;
}
for (const delta of deltas) {
this.processRelationshipDelta(graph, delta);
@ -111,6 +147,15 @@ export class RelationshipGraphResolver extends Lossy<RelationshipGraphAccumulato
* For now, we just return the accumulator as is.
*/
resolver(graph: RelationshipGraphAccumulator): RelationshipGraphAccumulator {
debug('Resolving relationship graph');
trace('Graph state at resolution:', {
entities: Array.from(graph.entities.keys()),
relationships: Array.from(graph.relationships.keys()),
relationshipTypes: Array.from(graph.relationshipsByType.entries()).map(([type, ids]) => ({
type,
count: ids.size
}))
});
return graph;
}
@ -118,17 +163,29 @@ export class RelationshipGraphResolver extends Lossy<RelationshipGraphAccumulato
* Process a single relationship delta
*/
private processRelationshipDelta(graph: RelationshipGraphAccumulator, delta: CollapsedDelta): void {
debug('Processing relationship delta:', delta.id);
trace('Delta details:', delta);
// Extract relationship metadata from the delta
const relProps = this.extractRelationshipProperties(delta);
if (!relProps) return;
if (!relProps) {
debug('No relationship properties found in delta:', delta.id);
return;
}
trace('Extracted relationship properties:', relProps);
const { type, sourceId, targetId, relId, properties } = relProps;
debug(`Processing relationship ${relId} of type ${type} from ${sourceId} to ${targetId}`);
// Ensure source and target entities exist in the graph
if (!graph.entities.has(sourceId)) {
trace(`Adding source entity: ${sourceId}`);
graph.entities.set(sourceId, { outbound: new Map(), inbound: new Map() });
}
if (!graph.entities.has(targetId)) {
trace(`Adding target entity: ${targetId}`);
graph.entities.set(targetId, { outbound: new Map(), inbound: new Map() });
}
@ -136,6 +193,7 @@ export class RelationshipGraphResolver extends Lossy<RelationshipGraphAccumulato
let relationship = graph.relationships.get(relId);
if (!relationship) {
debug(`Creating new relationship: ${relId} (${type})`);
// Create new relationship
relationship = {
id: relId,
@ -148,12 +206,15 @@ export class RelationshipGraphResolver extends Lossy<RelationshipGraphAccumulato
// Add to relationships map
graph.relationships.set(relId, relationship);
trace(`Added relationship ${relId} to relationships map`);
// Add to relationships by type index
if (!graph.relationshipsByType.has(type)) {
trace(`Creating new relationship type index: ${type}`);
graph.relationshipsByType.set(type, new Set());
}
graph.relationshipsByType.get(type)?.add(relId);
trace(`Added relationship ${relId} to type index: ${type}`);
// Update entity relationships
const sourceEntity = graph.entities.get(sourceId)!;
@ -161,14 +222,22 @@ export class RelationshipGraphResolver extends Lossy<RelationshipGraphAccumulato
sourceEntity.outbound.set(relId, relationship);
targetEntity.inbound.set(relId, relationship);
trace('Updated entity relationships:', {
sourceOutbound: Array.from(sourceEntity.outbound.keys()),
targetInbound: Array.from(targetEntity.inbound.keys())
});
} else {
// Update existing relationship
debug(`Updating existing relationship: ${relId}`);
// TODO: Conflict resolution e.g. using TimestampResolver
relationship.properties = { ...relationship.properties, ...properties };
// Track this delta if not already present
if (!relationship.deltas.includes(delta.id)) {
relationship.deltas.push(delta.id);
trace(`Added delta ${delta.id} to relationship ${relId}`);
} else {
trace(`Delta ${delta.id} already tracked for relationship ${relId}`);
}
}
}