Maintinance to standardize some code structure and conventions #5
@ -1,5 +1,5 @@
|
|||||||
import { jsonToAst } from '../../../src/util/json-ast';
|
import { jsonToAst } from '../../../src/utils/json-ast';
|
||||||
import { JsonNode } from '../../../src/util/json-ast/types';
|
import { JsonNode } from '../../../src/utils/json-ast/types';
|
||||||
|
|
||||||
describe('jsonToAst', () => {
|
describe('jsonToAst', () => {
|
||||||
it('should handle primitive values', () => {
|
it('should handle primitive values', () => {
|
||||||
|
@ -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', () => {
|
describe('Edge Cases', () => {
|
||||||
test('should handle empty delta sets', () => {
|
test('should handle empty delta sets', () => {
|
||||||
const resolver = new CustomResolver(lossless, {
|
const resolver = new CustomResolver(lossless, {
|
||||||
|
67
__tests__/unit/views/resolvers/relationship-graph.test.ts
Normal file
67
__tests__/unit/views/resolvers/relationship-graph.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,4 +1,4 @@
|
|||||||
import { jsonToAst } from '../src/util/json-ast/index';
|
import { jsonToAst } from '../src/utils/json-ast/index';
|
||||||
|
|
||||||
// Example JSON data
|
// Example JSON data
|
||||||
const exampleJson = {
|
const exampleJson = {
|
||||||
|
@ -1,222 +1,23 @@
|
|||||||
# Entity Relationship Graph Implementation Plan
|
# Entity Relationship Graph
|
||||||
|
|
||||||
## Overview
|
## Background
|
||||||
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.
|
|
||||||
|
|
||||||
## Core Design
|
Deltas can express relationships by declaring a domain entity for the relationship itself.
|
||||||
|
|
||||||
### Relationship as First-Class Entities
|
Our initial implementation assumes there is only one structure for a relationship: a directed edge from one entity to another.
|
||||||
- Each relationship is a domain entity with its own unique ID
|
- `source` and `target` are required properties.
|
||||||
- Relationships have standard properties: `source`, `target`, and `type`
|
- `type` as well as arbitrary additional properties are optional.
|
||||||
- Additional properties can be added to relationships
|
|
||||||
- Relationships are created using the `relate()` method in `DeltaBuilder`
|
|
||||||
|
|
||||||
### Delta Structure for Relationships
|
Future work may allow for more complex 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();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Data Structures
|
## Goal
|
||||||
|
|
||||||
#### `LosslessEntity` Updates
|
Resolve a graph of relationships among entities.
|
||||||
```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
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `LosslessViewOne` Extension
|
## Discussion of Strategy
|
||||||
```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
|
|
||||||
};
|
|
||||||
|
|
||||||
type LosslessViewOne = {
|
### Lossy View Composition
|
||||||
id: DomainEntityID;
|
|
||||||
// ... existing fields ...
|
|
||||||
relationships?: {
|
|
||||||
outbound: RelationshipView[];
|
|
||||||
inbound: RelationshipView[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
191
plans/fluent-view-composition.md
Normal file
191
plans/fluent-view-composition.md
Normal 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
182
plans/view-composition.md
Normal 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
|
@ -1,6 +1,6 @@
|
|||||||
import express, {Router} from "express";
|
import express, {Router} from "express";
|
||||||
import {RhizomeNode} from "../node";
|
import {RhizomeNode} from "../node";
|
||||||
import {htmlDocFromMarkdown, MDFiles} from "../util/md-files";
|
import {htmlDocFromMarkdown, MDFiles} from "../utils/md-files";
|
||||||
|
|
||||||
export class HttpHtml {
|
export class HttpHtml {
|
||||||
router = Router();
|
router = Router();
|
||||||
|
@ -11,11 +11,30 @@ export interface ResolverPlugin<T = unknown> {
|
|||||||
initialize(): T;
|
initialize(): T;
|
||||||
|
|
||||||
// Process a new value for the property
|
// 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
|
// Resolve the final value from the accumulated state
|
||||||
// Returns undefined if no valid value could be resolved
|
resolve(
|
||||||
resolve(state: T): PropertyTypes | undefined;
|
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
|
// Configuration for custom resolver
|
||||||
@ -75,25 +94,67 @@ export class CustomResolver extends Lossy<CustomResolverAccumulator, CustomResol
|
|||||||
acc[cur.id] = { id: cur.id, properties: {} };
|
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];
|
const plugin = this.config[propertyId];
|
||||||
if (!plugin) continue;
|
if (!plugin) continue;
|
||||||
|
|
||||||
// Initialize property state if not exists
|
|
||||||
if (!acc[cur.id].properties[propertyId]) {
|
if (!acc[cur.id].properties[propertyId]) {
|
||||||
acc[cur.id].properties[propertyId] = {
|
acc[cur.id].properties[propertyId] = {
|
||||||
plugin,
|
plugin,
|
||||||
state: plugin.initialize()
|
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 propertyState = acc[cur.id].properties[propertyId];
|
||||||
|
const context = { entityState, resolvedValues };
|
||||||
|
|
||||||
// Process all deltas for this property
|
// Process all deltas for this property
|
||||||
for (const delta of deltas || []) {
|
for (const delta of deltas || []) {
|
||||||
const value = extractValueFromDelta(propertyId, delta);
|
const value = extractValueFromDelta(propertyId, delta);
|
||||||
if (value !== undefined) {
|
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)) {
|
for (const [entityId, entity] of Object.entries(cur)) {
|
||||||
const entityResult: { id: string; properties: EntityProperties } = { id: entityId, properties: {} };
|
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)) {
|
for (const [propertyId, propertyState] of Object.entries(entity.properties)) {
|
||||||
const resolvedValue = propertyState.plugin.resolve(propertyState.state);
|
entityState[propertyId] = propertyState.state;
|
||||||
// Only add the property if the resolved value is not undefined
|
// Initial resolution with empty context
|
||||||
if (resolvedValue !== undefined) {
|
try {
|
||||||
entityResult.properties[propertyId] = resolvedValue;
|
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 };
|
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) {
|
if (delta.timeCreated > currentState.timestamp) {
|
||||||
return {
|
return {
|
||||||
value: newValue,
|
value: newValue,
|
||||||
@ -160,7 +254,12 @@ export class FirstWriteWinsPlugin implements ResolverPlugin<{ value?: PropertyTy
|
|||||||
return { timestamp: Infinity };
|
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) {
|
if (delta.timeCreated < currentState.timestamp) {
|
||||||
return {
|
return {
|
||||||
value: newValue,
|
value: newValue,
|
||||||
@ -185,7 +284,12 @@ export class ConcatenationPlugin implements ResolverPlugin<{ values: { value: st
|
|||||||
return { values: [] };
|
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') {
|
if (typeof newValue === 'string') {
|
||||||
// Check if this value already exists (avoid duplicates)
|
// Check if this value already exists (avoid duplicates)
|
||||||
const exists = currentState.values.some(v => v.value === newValue);
|
const exists = currentState.values.some(v => v.value === newValue);
|
||||||
@ -201,7 +305,10 @@ export class ConcatenationPlugin implements ResolverPlugin<{ values: { value: st
|
|||||||
return currentState;
|
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);
|
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() };
|
return { votes: new Map() };
|
||||||
}
|
}
|
||||||
|
|
||||||
update(currentState: { votes: Map<PropertyTypes, number> }, newValue: PropertyTypes, _delta: CollapsedDelta) {
|
update(
|
||||||
const currentCount = currentState.votes.get(newValue) || 0;
|
currentState: { votes: Map<PropertyTypes, number> },
|
||||||
currentState.votes.set(newValue, currentCount + 1);
|
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;
|
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 maxVotes = 0;
|
||||||
let winner: PropertyTypes = '';
|
let result: PropertyTypes = '';
|
||||||
|
|
||||||
for (const [value, votes] of state.votes.entries()) {
|
for (const [value, count] of state.votes.entries()) {
|
||||||
if (votes > maxVotes) {
|
if (count > maxVotes) {
|
||||||
maxVotes = votes;
|
maxVotes = count;
|
||||||
winner = value;
|
result = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return winner;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -243,7 +358,12 @@ export class MinPlugin implements ResolverPlugin<{ min?: number }> {
|
|||||||
return {};
|
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 (typeof newValue === 'number') {
|
||||||
if (currentState.min === undefined || newValue < currentState.min) {
|
if (currentState.min === undefined || newValue < currentState.min) {
|
||||||
return { min: newValue };
|
return { min: newValue };
|
||||||
@ -252,7 +372,10 @@ export class MinPlugin implements ResolverPlugin<{ min?: number }> {
|
|||||||
return currentState;
|
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;
|
return state.min;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -264,7 +387,12 @@ export class MaxPlugin implements ResolverPlugin<{ max?: number }> {
|
|||||||
return {};
|
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 (typeof newValue === 'number') {
|
||||||
if (currentState.max === undefined || newValue > currentState.max) {
|
if (currentState.max === undefined || newValue > currentState.max) {
|
||||||
return { max: newValue };
|
return { max: newValue };
|
||||||
@ -273,7 +401,10 @@ export class MaxPlugin implements ResolverPlugin<{ max?: number }> {
|
|||||||
return currentState;
|
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;
|
return state.max;
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,6 +1,12 @@
|
|||||||
import { CollapsedDelta, LosslessViewOne } from "../lossless";
|
import { CollapsedDelta, Lossless, LosslessViewOne } from "../lossless";
|
||||||
import { PropertyTypes } from "../../core/types";
|
import { PropertyTypes } from "../../core/types";
|
||||||
import { Lossy } from "../lossy";
|
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
|
* Represents a single relationship between entities
|
||||||
@ -51,31 +57,43 @@ export type RelationshipView = {
|
|||||||
* A resolver that builds a relationship graph from a LosslessViewMany
|
* A resolver that builds a relationship graph from a LosslessViewMany
|
||||||
*/
|
*/
|
||||||
export class RelationshipGraphResolver extends Lossy<RelationshipGraphAccumulator, RelationshipGraphAccumulator> {
|
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
|
* Initialize a new accumulator
|
||||||
*/
|
*/
|
||||||
protected createRelationshipGraphAccumulator(): RelationshipGraphAccumulator {
|
protected createRelationshipGraphAccumulator(): RelationshipGraphAccumulator {
|
||||||
return {
|
debug('Creating new relationship graph accumulator');
|
||||||
|
const accumulator = {
|
||||||
entities: new Map(),
|
entities: new Map(),
|
||||||
relationships: new Map(),
|
relationships: new Map(),
|
||||||
relationshipsByType: new Map(),
|
relationshipsByType: new Map(),
|
||||||
lastUpdated: Date.now()
|
lastUpdated: Date.now()
|
||||||
};
|
};
|
||||||
|
trace('Created accumulator:', accumulator);
|
||||||
|
return accumulator;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the accumulator with a view
|
* Initialize the accumulator with a view
|
||||||
*/
|
*/
|
||||||
initializer(view: LosslessViewOne): RelationshipGraphAccumulator {
|
initializer(view: LosslessViewOne): RelationshipGraphAccumulator {
|
||||||
|
debug('Initializing relationship graph for view:', view.id);
|
||||||
const graph = this.createRelationshipGraphAccumulator();
|
const graph = this.createRelationshipGraphAccumulator();
|
||||||
|
|
||||||
|
this.relData.initializer(view);
|
||||||
|
|
||||||
// Initialize entity relationships if they don't exist
|
trace('Initialized graph state:', {
|
||||||
if (!graph.entities.has(view.id)) {
|
entities: Array.from(graph.entities.keys()),
|
||||||
graph.entities.set(view.id, {
|
relationships: Array.from(graph.relationships.keys()),
|
||||||
outbound: new Map(),
|
relationshipTypes: Array.from(graph.relationshipsByType.keys())
|
||||||
inbound: new Map()
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return graph;
|
return graph;
|
||||||
}
|
}
|
||||||
@ -84,8 +102,21 @@ export class RelationshipGraphResolver extends Lossy<RelationshipGraphAccumulato
|
|||||||
* Process a view and update the accumulator
|
* Process a view and update the accumulator
|
||||||
*/
|
*/
|
||||||
reducer(graph: RelationshipGraphAccumulator, view: LosslessViewOne): RelationshipGraphAccumulator {
|
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
|
// Ensure entity exists in the graph
|
||||||
if (!graph.entities.has(view.id)) {
|
if (!graph.entities.has(view.id)) {
|
||||||
|
trace(`Adding new entity in reducer: ${view.id}`);
|
||||||
graph.entities.set(view.id, {
|
graph.entities.set(view.id, {
|
||||||
outbound: new Map(),
|
outbound: new Map(),
|
||||||
inbound: new Map()
|
inbound: new Map()
|
||||||
@ -94,8 +125,13 @@ export class RelationshipGraphResolver extends Lossy<RelationshipGraphAccumulato
|
|||||||
|
|
||||||
// Process relationship properties
|
// Process relationship properties
|
||||||
for (const [property, deltas] of Object.entries(view.propertyDeltas)) {
|
for (const [property, deltas] of Object.entries(view.propertyDeltas)) {
|
||||||
|
trace(`Processing property: ${property} with ${deltas.length} deltas`);
|
||||||
|
|
||||||
// Skip non-relationship properties
|
// 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) {
|
for (const delta of deltas) {
|
||||||
this.processRelationshipDelta(graph, delta);
|
this.processRelationshipDelta(graph, delta);
|
||||||
@ -111,6 +147,15 @@ export class RelationshipGraphResolver extends Lossy<RelationshipGraphAccumulato
|
|||||||
* For now, we just return the accumulator as is.
|
* For now, we just return the accumulator as is.
|
||||||
*/
|
*/
|
||||||
resolver(graph: RelationshipGraphAccumulator): RelationshipGraphAccumulator {
|
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;
|
return graph;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,17 +163,29 @@ export class RelationshipGraphResolver extends Lossy<RelationshipGraphAccumulato
|
|||||||
* Process a single relationship delta
|
* Process a single relationship delta
|
||||||
*/
|
*/
|
||||||
private processRelationshipDelta(graph: RelationshipGraphAccumulator, delta: CollapsedDelta): void {
|
private processRelationshipDelta(graph: RelationshipGraphAccumulator, delta: CollapsedDelta): void {
|
||||||
|
debug('Processing relationship delta:', delta.id);
|
||||||
|
trace('Delta details:', delta);
|
||||||
|
|
||||||
// Extract relationship metadata from the delta
|
// Extract relationship metadata from the delta
|
||||||
const relProps = this.extractRelationshipProperties(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;
|
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
|
// Ensure source and target entities exist in the graph
|
||||||
if (!graph.entities.has(sourceId)) {
|
if (!graph.entities.has(sourceId)) {
|
||||||
|
trace(`Adding source entity: ${sourceId}`);
|
||||||
graph.entities.set(sourceId, { outbound: new Map(), inbound: new Map() });
|
graph.entities.set(sourceId, { outbound: new Map(), inbound: new Map() });
|
||||||
}
|
}
|
||||||
if (!graph.entities.has(targetId)) {
|
if (!graph.entities.has(targetId)) {
|
||||||
|
trace(`Adding target entity: ${targetId}`);
|
||||||
graph.entities.set(targetId, { outbound: new Map(), inbound: new Map() });
|
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);
|
let relationship = graph.relationships.get(relId);
|
||||||
|
|
||||||
if (!relationship) {
|
if (!relationship) {
|
||||||
|
debug(`Creating new relationship: ${relId} (${type})`);
|
||||||
// Create new relationship
|
// Create new relationship
|
||||||
relationship = {
|
relationship = {
|
||||||
id: relId,
|
id: relId,
|
||||||
@ -148,12 +206,15 @@ export class RelationshipGraphResolver extends Lossy<RelationshipGraphAccumulato
|
|||||||
|
|
||||||
// Add to relationships map
|
// Add to relationships map
|
||||||
graph.relationships.set(relId, relationship);
|
graph.relationships.set(relId, relationship);
|
||||||
|
trace(`Added relationship ${relId} to relationships map`);
|
||||||
|
|
||||||
// Add to relationships by type index
|
// Add to relationships by type index
|
||||||
if (!graph.relationshipsByType.has(type)) {
|
if (!graph.relationshipsByType.has(type)) {
|
||||||
|
trace(`Creating new relationship type index: ${type}`);
|
||||||
graph.relationshipsByType.set(type, new Set());
|
graph.relationshipsByType.set(type, new Set());
|
||||||
}
|
}
|
||||||
graph.relationshipsByType.get(type)?.add(relId);
|
graph.relationshipsByType.get(type)?.add(relId);
|
||||||
|
trace(`Added relationship ${relId} to type index: ${type}`);
|
||||||
|
|
||||||
// Update entity relationships
|
// Update entity relationships
|
||||||
const sourceEntity = graph.entities.get(sourceId)!;
|
const sourceEntity = graph.entities.get(sourceId)!;
|
||||||
@ -161,14 +222,22 @@ export class RelationshipGraphResolver extends Lossy<RelationshipGraphAccumulato
|
|||||||
|
|
||||||
sourceEntity.outbound.set(relId, relationship);
|
sourceEntity.outbound.set(relId, relationship);
|
||||||
targetEntity.inbound.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 {
|
} else {
|
||||||
// Update existing relationship
|
debug(`Updating existing relationship: ${relId}`);
|
||||||
// TODO: Conflict resolution e.g. using TimestampResolver
|
// TODO: Conflict resolution e.g. using TimestampResolver
|
||||||
relationship.properties = { ...relationship.properties, ...properties };
|
relationship.properties = { ...relationship.properties, ...properties };
|
||||||
|
|
||||||
// Track this delta if not already present
|
// Track this delta if not already present
|
||||||
if (!relationship.deltas.includes(delta.id)) {
|
if (!relationship.deltas.includes(delta.id)) {
|
||||||
relationship.deltas.push(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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user