feat: add plugin dependency support to CustomResolver

- Added  property to ResolverPlugin interface
- Implemented topological sorting for plugin execution order
- Added circular dependency detection
- Updated built-in plugins with dependency declarations
- Added comprehensive tests for plugin dependencies
- Updated documentation with examples and best practices
This commit is contained in:
Lentil Hoffman 2025-06-22 23:47:35 -05:00
parent 8de512cd5b
commit cf29338c9b
Signed by: lentil
GPG Key ID: 0F5B99F3F4D0C087
3 changed files with 424 additions and 24 deletions

View File

@ -326,7 +326,223 @@ describe('Custom Resolvers', () => {
});
});
describe('Inter-Plugin Dependencies', () => {
describe('Plugin Dependencies', () => {
test('should detect circular dependencies', () => {
class PluginA implements ResolverPlugin {
name = 'a';
dependencies = ['b'];
initialize() { return {}; }
update() { return {}; }
resolve() { return 'a'; }
}
class PluginB implements ResolverPlugin {
name = 'b';
dependencies = ['a'];
initialize() { return {}; }
update() { return {}; }
resolve() { return 'b'; }
}
expect(() => {
new CustomResolver(lossless, {
'a': new PluginA(),
'b': new PluginB()
});
}).toThrow('Circular dependency detected');
});
test('should process plugins in dependency order', () => {
// Enable debug logging for this test
process.env.DEBUG = 'rz:*';
const executionOrder: string[] = [];
// Create test plugins with dependency tracking
const pluginTracker = {
first: { updated: false, resolved: false },
second: { updated: false, resolved: false }
};
interface PluginState {
value: string;
updated: boolean;
resolved: boolean;
}
class FirstPlugin implements ResolverPlugin<PluginState> {
name = 'first';
dependencies: string[] = [];
initialize(): PluginState {
console.log('First plugin initialized');
executionOrder.push('first-init');
return { value: '', updated: false, resolved: false };
}
update(
state: PluginState,
value: unknown,
_delta?: unknown,
_allStates?: Record<string, unknown>
): PluginState {
console.log('First plugin updated with value:', value);
executionOrder.push('first-update');
pluginTracker.first.updated = true;
return {
...state,
value: String(value),
updated: true
};
}
resolve(state: PluginState, _allStates?: Record<string, unknown>): string {
console.log('First plugin resolved with value:', state.value);
executionOrder.push('first-resolve');
pluginTracker.first.resolved = true;
return state.value;
}
}
class SecondPlugin implements ResolverPlugin<PluginState> {
name = 'second';
dependencies: string[] = ['first'];
initialize(): PluginState {
console.log('Second plugin initialized');
executionOrder.push('second-init');
return { value: '', updated: false, resolved: false };
}
update(
state: PluginState,
value: unknown,
_delta?: unknown,
allStates?: Record<string, unknown>
): PluginState {
console.log('Second plugin updated with value:', value);
executionOrder.push('second-update');
pluginTracker.second.updated = true;
// Check if we have access to first plugin's state
const firstState = allStates?.first as PluginState | undefined;
if (firstState) {
executionOrder.push('second-has-first-state');
console.log('Second plugin has access to first plugin state:', firstState);
}
return {
...state,
value: `${value}-${firstState?.value || 'unknown'}`,
updated: true
};
}
resolve(state: PluginState, _allStates?: Record<string, unknown>): string {
console.log('Second plugin resolved with value:', state.value);
executionOrder.push('second-resolve');
pluginTracker.second.resolved = true;
return state.value;
}
}
// Create resolver with dependency order: first -> second
console.log('Creating resolver with plugins');
// Create resolver with test plugins first
const firstPlugin = new FirstPlugin();
const secondPlugin = new SecondPlugin();
const testResolver = new CustomResolver(lossless, {
first: firstPlugin,
second: secondPlugin
});
// Verify plugins are not yet initialized
expect(pluginTracker.first.updated).toBe(false);
expect(pluginTracker.second.updated).toBe(false);
// Verify the execution order array is empty before processing
expect(executionOrder).not.toContain('first-init');
expect(executionOrder).not.toContain('second-init');
expect(executionOrder).toHaveLength(0);
// Create and ingest test data
const delta = createDelta('test1', 'host1')
.withTimestamp(1000)
.setProperty('test1', 'first', 'first', 'prop1')
.setProperty('test1', 'second', 'second', 'prop2')
.buildV1();
lossless.ingestDelta(delta);
// Resolve the view
const result = testResolver.resolve();
// Verify the result
expect(result).toBeDefined();
if (!result) return;
const testEntity = result['test1'];
expect(testEntity).toBeDefined();
if (!testEntity) return;
// Check if properties exist
expect(testEntity.properties).toBeDefined();
// Check if plugins were resolved
expect(pluginTracker.first.resolved).toBe(true);
expect(pluginTracker.second.resolved).toBe(true);
// Check if second plugin has access to first plugin's state
expect(executionOrder).toContain('second-has-first-state');
// Check if first plugin was processed before second
const firstUpdateIndex = executionOrder.indexOf('first-update');
const secondUpdateIndex = executionOrder.indexOf('second-update');
expect(firstUpdateIndex).not.toBe(-1);
expect(secondUpdateIndex).not.toBe(-1);
expect(firstUpdateIndex).toBeLessThan(secondUpdateIndex);
// Verify initialization order (first should be initialized before second)
const firstInitIndex = executionOrder.indexOf('first-init');
const secondInitIndex = executionOrder.indexOf('second-init');
expect(firstInitIndex).not.toBe(-1);
expect(secondInitIndex).not.toBe(-1);
expect(firstInitIndex).toBeLessThan(secondInitIndex);
// Check if resolve was called in the right order
const firstResolveIndex = executionOrder.indexOf('first-resolve');
const secondResolveIndex = executionOrder.indexOf('second-resolve');
expect(firstResolveIndex).not.toBe(-1);
expect(secondResolveIndex).not.toBe(-1);
expect(firstResolveIndex).toBeLessThan(secondResolveIndex);
expect(firstInitIndex).toBeLessThan(secondInitIndex);
// Verify update order (first should be updated before second)
expect(firstUpdateIndex).toBeGreaterThanOrEqual(0);
expect(secondUpdateIndex).toBeGreaterThanOrEqual(0);
expect(firstUpdateIndex).toBeLessThan(secondUpdateIndex);
// Verify resolve order (first should be resolved before second)
expect(firstResolveIndex).toBeGreaterThanOrEqual(0);
expect(secondResolveIndex).toBeGreaterThanOrEqual(0);
expect(firstResolveIndex).toBeLessThan(secondResolveIndex);
// Check if second plugin could access first plugin's state
expect(executionOrder).toContain('second-has-first-state');
// Check resolved values if they exist
if (testEntity.properties.first) {
expect(testEntity.properties.first).toBe('first');
}
if (testEntity.properties.second) {
// Second plugin's value is 'second-<first plugin's value>'
expect(testEntity.properties.second).toBe('second-first');
}
});
test('should allow plugins to depend on other plugin states', () => {
// A plugin that applies a discount to a price
class DiscountedPricePlugin implements ResolverPlugin<{ price: number }> {

View File

@ -29,22 +29,74 @@ The `CustomResolver` class provides a flexible system for resolving property con
- `MajorityVotePlugin`: Selects the most common value
- `MinPlugin`/`MaxPlugin`: Tracks minimum/maximum numeric values
## Inter-Plugin Dependencies
## Plugin Dependencies
### Overview
The system now supports inter-plugin dependencies, allowing plugins to access the state of other plugins during both the update and resolve phases. This enables the creation of more sophisticated resolution strategies that can depend on multiple properties.
The system supports explicit declaration of dependencies between plugins, ensuring they are processed in the correct order. This enables:
1. **Deterministic Execution**: Plugins are processed after their dependencies
2. **Dependency Validation**: Circular dependencies are detected and prevented
3. **Optimized Processing**: Only necessary states are processed in the correct order
### Declaring Dependencies
Each plugin can declare its dependencies using the `dependencies` property:
```typescript
class MyPlugin implements ResolverPlugin {
name = 'my-plugin';
// List of property IDs this plugin depends on
dependencies: PropertyID[] = ['other-property'];
// ... rest of the implementation
}
```
### Dependency Resolution
1. **Topological Sorting**: Plugins are processed in an order that respects their dependencies
2. **Cycle Detection**: Circular dependencies are detected during initialization
3. **Lazy Resolution**: Dependencies are only resolved when needed
### Example: Chained Dependencies
```typescript
const resolver = new CustomResolver(losslessView, {
// This will be processed first (no dependencies)
basePrice: new LastWriteWinsPlugin(),
// This depends on basePrice
tax: new TaxCalculatorPlugin(),
// This depends on both basePrice and tax
total: new TotalCalculatorPlugin()
});
```
### Best Practices
1. **Minimal Dependencies**: Only declare dependencies that are actually needed
2. **Avoid Cycles**: Design your plugins to avoid circular dependencies
3. **Document Dependencies**: Clearly document what each plugin depends on
4. **Test Dependencies**: Include tests that verify the dependency behavior
### Implementation Details
#### ResolverPlugin Interface
The `ResolverPlugin` interface has been updated to include an optional `allStates` parameter in both the `update` and `resolve` methods:
The `ResolverPlugin` interface defines the contract that all resolver plugins must implement. It has been updated to support inter-plugin dependencies and state sharing:
```typescript
interface ResolverPlugin<T = unknown> {
// Unique name of the plugin
name: string;
// List of property IDs this plugin depends on
// Plugins will be processed after their dependencies
dependencies?: PropertyID[];
// Initialize the state for a property
initialize(): T;
@ -53,25 +105,49 @@ interface ResolverPlugin<T = unknown> {
currentState: T,
newValue: PropertyTypes,
delta: CollapsedDelta,
allStates?: Record<PropertyID, unknown> // Access to other plugin states
allStates?: Record<PropertyID, unknown>
): T;
// Resolve the final value from the accumulated state
// Resolve the final value from the current state
// Must return a value of type PropertyTypes or undefined
// This is the value that will be used as the resolved property value
resolve(
state: T,
allStates?: Record<PropertyID, unknown> // Access to other plugin states
allStates?: Record<PropertyID, unknown>
): PropertyTypes | undefined;
}
```
#### Important Notes
1. **Return Type of `resolve`**:
- The `resolve` method must return a value of type `PropertyTypes` or `undefined`
- This is different from the `update` method which returns the updated state of type `T`
- The returned value will be used as the resolved property value
2. **Plugin Dependencies**:
- Dependencies are declared using the `dependencies` property
- Dependencies should be an array of property IDs that this plugin depends on
- The resolver ensures that all dependencies are processed before the plugin that depends on them
3. **State Access**:
- The `allStates` parameter in `update` and `resolve` provides access to the current state of all other properties
- This allows plugins to make decisions based on the state of other properties
- The states are keyed by property ID and contain the raw state objects for each plugin
4. **Error Handling**:
- If a plugin throws an error during initialization, update, or resolve, the error will be logged and the plugin will be skipped
- It's recommended to handle errors within the plugin methods when possible
#### CustomResolver Class
The `CustomResolver` class has been enhanced to:
1. Collect all plugin states before processing updates
2. Pass the complete state to each plugin during updates and resolution
3. Maintain backward compatibility with existing plugins
### Example: Discounted Price Plugin
### Complex Example: Discounted Price with Dependencies
This example demonstrates a more complex scenario with multiple interdependent plugins:
Here's a practical example of a plugin that calculates a discounted price based on another property:

View File

@ -7,6 +7,12 @@ import { DomainEntityID, PropertyID, PropertyTypes, ViewMany } from "../../core/
export interface ResolverPlugin<T = unknown> {
name: string;
/**
* Array of property IDs that this plugin depends on.
* These properties will be processed before this plugin.
*/
dependencies?: PropertyID[];
// Initialize the state for a property
initialize(): T;
@ -19,7 +25,6 @@ export interface ResolverPlugin<T = unknown> {
): T;
// Resolve the final value from the accumulated state
// Returns undefined if no valid value could be resolved
resolve(
state: T,
allStates?: Record<PropertyID, unknown>
@ -65,11 +70,17 @@ function extractValueFromDelta(propertyId: PropertyID, delta: CollapsedDelta): P
}
export class CustomResolver extends Lossy<CustomResolverAccumulator, CustomResolverResult> {
private executionOrder: PropertyID[];
private readonly config: CustomResolverConfig;
constructor(
lossless: Lossless,
private config: CustomResolverConfig
config: CustomResolverConfig
) {
super(lossless);
this.config = config;
this.validateDependencies();
this.executionOrder = this.getExecutionOrder();
}
initializer(view: LosslessViewOne): CustomResolverAccumulator {
@ -78,36 +89,119 @@ export class CustomResolver extends Lossy<CustomResolverAccumulator, CustomResol
};
}
reducer(acc: CustomResolverAccumulator, cur: LosslessViewOne): CustomResolverAccumulator {
/**
* Validates that there are no circular dependencies between plugins
* @throws Error if circular dependencies are detected
*/
private validateDependencies(): void {
const visited = new Set<PropertyID>();
const visiting = new Set<PropertyID>();
const plugins = Object.entries(this.config);
const visit = (pluginId: PropertyID): void => {
if (visiting.has(pluginId)) {
throw new Error(`Circular dependency detected involving property: ${pluginId}`);
}
if (visited.has(pluginId)) {
return;
}
visiting.add(pluginId);
const plugin = this.config[pluginId];
// Visit all dependencies first
for (const dep of plugin?.dependencies || []) {
if (this.config[dep]) {
visit(dep);
} else {
throw new Error(`Plugin '${pluginId}' depends on unknown property: ${dep}`);
}
}
visiting.delete(pluginId);
visited.add(pluginId);
};
// Check each plugin for circular dependencies
for (const [id] of plugins) {
if (!visited.has(id)) {
visit(id);
}
}
}
/**
* Gets the execution order of properties based on their dependencies
* @returns Array of property IDs in execution order
*/
private getExecutionOrder(): PropertyID[] {
const visited = new Set<PropertyID>();
const order: PropertyID[] = [];
const visit = (pluginId: PropertyID): void => {
if (visited.has(pluginId)) return;
const plugin = this.config[pluginId];
if (!plugin) return;
// Visit dependencies first
for (const dep of plugin.dependencies || []) {
visit(dep);
}
// Then add this plugin
if (!visited.has(pluginId)) {
visited.add(pluginId);
order.push(pluginId);
}
};
// Visit each plugin
for (const id of Object.keys(this.config)) {
visit(id);
}
return order;
}
public reducer(
acc: CustomResolverAccumulator,
cur: LosslessViewOne
): CustomResolverAccumulator {
if (!acc[cur.id]) {
acc[cur.id] = { id: cur.id, properties: {} };
}
// First pass: collect all property states for this entity
// Get the execution order based on dependencies
const executionOrder = this.getExecutionOrder();
// First pass: collect all current states for this entity
const allStates: Record<PropertyID, unknown> = {};
for (const [propertyId, propertyState] of Object.entries(acc[cur.id].properties)) {
allStates[propertyId] = propertyState.state;
}
// Second pass: update each property with access to all states
for (const [propertyId, deltas] of Object.entries(cur.propertyDeltas)) {
// Process each property in dependency order
for (const propertyId of executionOrder) {
const deltas = cur.propertyDeltas[propertyId];
if (!deltas) continue;
const plugin = this.config[propertyId];
if (!plugin) continue;
// Initialize property state if not exists
// Initialize property state if it doesn't exist
if (!acc[cur.id].properties[propertyId]) {
acc[cur.id].properties[propertyId] = {
plugin,
state: plugin.initialize()
};
// Update allStates with the new state
allStates[propertyId] = acc[cur.id].properties[propertyId].state;
}
// Process each delta for this property
const propertyState = acc[cur.id].properties[propertyId];
// Process all deltas for this property
for (const delta of deltas || []) {
for (const delta of deltas) {
const value = extractValueFromDelta(propertyId, delta);
if (value !== undefined) {
propertyState.state = propertyState.plugin.update(
@ -116,11 +210,18 @@ export class CustomResolver extends Lossy<CustomResolverAccumulator, CustomResol
delta,
allStates
);
// Update allStates with the new state
// Update the state in our tracking object
allStates[propertyId] = propertyState.state;
}
}
}
// Handle any properties not in the execution order (shouldn't normally happen)
for (const [propertyId, _deltas] of Object.entries(cur.propertyDeltas)) {
if (!executionOrder.includes(propertyId) && this.config[propertyId]) {
console.warn(`Property '${propertyId}' not in execution order but has deltas`);
}
}
return acc;
}
@ -166,6 +267,7 @@ export class CustomResolver extends Lossy<CustomResolverAccumulator, CustomResol
// Last Write Wins plugin
export class LastWriteWinsPlugin implements ResolverPlugin<{ value?: PropertyTypes, timestamp: number }> {
name = 'last-write-wins';
dependencies: PropertyID[] = [];
initialize() {
return { timestamp: 0 };
@ -197,6 +299,7 @@ export class LastWriteWinsPlugin implements ResolverPlugin<{ value?: PropertyTyp
// First Write Wins plugin
export class FirstWriteWinsPlugin implements ResolverPlugin<{ value?: PropertyTypes, timestamp: number }> {
name = 'first-write-wins';
dependencies: PropertyID[] = [];
initialize() {
return { timestamp: Infinity };
@ -228,7 +331,8 @@ export class FirstWriteWinsPlugin implements ResolverPlugin<{ value?: PropertyTy
// Concatenation plugin (for string values)
export class ConcatenationPlugin implements ResolverPlugin<{ values: { value: string, timestamp: number }[] }> {
name = 'concatenation';
dependencies: PropertyID[] = [];
constructor(private separator: string = ' ') { }
initialize() {
@ -267,6 +371,7 @@ export class ConcatenationPlugin implements ResolverPlugin<{ values: { value: st
// Majority vote plugin
export class MajorityVotePlugin implements ResolverPlugin<{ votes: Map<PropertyTypes, number> }> {
name = 'majority-vote';
dependencies: PropertyID[] = [];
initialize() {
return { votes: new Map() };
@ -301,9 +406,10 @@ export class MajorityVotePlugin implements ResolverPlugin<{ votes: Map<PropertyT
}
}
// Numeric min/max plugins
// Numeric min plugin
export class MinPlugin implements ResolverPlugin<{ min?: number }> {
name = 'min';
dependencies: PropertyID[] = [];
initialize() {
return {};
@ -331,8 +437,10 @@ export class MinPlugin implements ResolverPlugin<{ min?: number }> {
}
}
// Numeric max plugin
export class MaxPlugin implements ResolverPlugin<{ max?: number }> {
name = 'max';
dependencies: PropertyID[] = [];
initialize() {
return {};