From ceb9d9a0cfd13204f751a339afa23a8bc13cf4d1 Mon Sep 17 00:00:00 2001 From: Lentil Hoffman Date: Mon, 23 Jun 2025 03:01:44 -0500 Subject: [PATCH] Module alias for @src --- .../e2e/001-single-node-orchestrated.test.ts | 4 +- .../e2e/002-two-nodes-orchestrated.test.ts | 4 +- __tests__/e2e/005-docker-orchestrator.test.ts | 6 +- __tests__/integration/schema.test.ts | 12 +- __tests__/integration/storage.test.ts | 6 +- .../performance/concurrent-writes.test.ts | 2 +- .../nested-resolution-performance.test.ts | 10 +- __tests__/unit/core/compose-decompose.test.ts | 4 +- .../unit/core/delta/delta-builder.test.ts | 10 +- __tests__/unit/core/delta/delta.test.ts | 2 +- __tests__/unit/core/json-ast.test.ts | 4 +- __tests__/unit/core/negation.test.ts | 8 +- __tests__/unit/core/peer-address.test.ts | 2 +- __tests__/unit/core/transactions.test.ts | 8 +- __tests__/unit/query/query.test.ts | 12 +- __tests__/unit/views/lossless.test.ts | 8 +- .../views/multi-pointer-resolution.test.ts | 10 +- .../unit/views/nested-resolution.test.ts | 10 +- .../views/resolvers/custom-resolvers.test.ts | 174 +++++--- .../builtin-plugins/concatenation.test.ts | 73 ++++ .../builtin-plugins/first-write-wins.test.ts | 65 +++ .../builtin-plugins/last-write-wins.test.ts | 65 +++ .../builtin-plugins/majority-vote.test.ts | 81 ++++ .../builtin-plugins/max.test.ts | 69 +++ .../builtin-plugins/min.test.ts | 69 +++ .../dependency/basic-dependencies.test.ts | 80 ++++ .../dependency/circular-dependencies.test.ts | 100 +++++ .../plugins/count-plugin.test.ts | 98 +++++ .../plugins/discount-plugins.test.ts | 143 +++++++ .../plugins/running-average.test.ts | 101 +++++ .../views/resolvers/last-write-wins.test.ts | 6 +- .../views/resolvers/state-visibility.test.ts | 205 +++++++++ package.json | 3 +- src/core/delta-builder.ts | 3 +- .../resolvers/custom-resolvers/plugin.ts | 40 +- .../plugins/concatenation.plugin.ts | 24 +- .../plugins/first-write-wins.plugin.ts | 25 +- .../plugins/last-write-wins.plugin.ts | 27 +- .../plugins/majority-vote.plugin.ts | 30 +- .../custom-resolvers/plugins/max.plugin.ts | 22 +- .../custom-resolvers/plugins/min.plugin.ts | 22 +- .../resolvers/custom-resolvers/resolver.ts | 405 +++++++++++++----- tsconfig.json | 4 + 43 files changed, 1753 insertions(+), 303 deletions(-) create mode 100644 __tests__/unit/views/resolvers/custom-resolvers/builtin-plugins/concatenation.test.ts create mode 100644 __tests__/unit/views/resolvers/custom-resolvers/builtin-plugins/first-write-wins.test.ts create mode 100644 __tests__/unit/views/resolvers/custom-resolvers/builtin-plugins/last-write-wins.test.ts create mode 100644 __tests__/unit/views/resolvers/custom-resolvers/builtin-plugins/majority-vote.test.ts create mode 100644 __tests__/unit/views/resolvers/custom-resolvers/builtin-plugins/max.test.ts create mode 100644 __tests__/unit/views/resolvers/custom-resolvers/builtin-plugins/min.test.ts create mode 100644 __tests__/unit/views/resolvers/custom-resolvers/dependency/basic-dependencies.test.ts create mode 100644 __tests__/unit/views/resolvers/custom-resolvers/dependency/circular-dependencies.test.ts create mode 100644 __tests__/unit/views/resolvers/custom-resolvers/plugins/count-plugin.test.ts create mode 100644 __tests__/unit/views/resolvers/custom-resolvers/plugins/discount-plugins.test.ts create mode 100644 __tests__/unit/views/resolvers/custom-resolvers/plugins/running-average.test.ts create mode 100644 __tests__/unit/views/resolvers/state-visibility.test.ts diff --git a/__tests__/e2e/001-single-node-orchestrated.test.ts b/__tests__/e2e/001-single-node-orchestrated.test.ts index 1b74f97..0b077e2 100644 --- a/__tests__/e2e/001-single-node-orchestrated.test.ts +++ b/__tests__/e2e/001-single-node-orchestrated.test.ts @@ -1,5 +1,5 @@ -import { createOrchestrator, type NodeConfig } from '../../src/orchestration'; -import type { NodeHandle } from '../../src/orchestration/types'; +import { createOrchestrator, type NodeConfig } from '@src/orchestration'; +import type { NodeHandle } from '@src/orchestration/types'; // Increase test timeout to 30 seconds jest.setTimeout(30000); diff --git a/__tests__/e2e/002-two-nodes-orchestrated.test.ts b/__tests__/e2e/002-two-nodes-orchestrated.test.ts index 7a00652..f897f28 100644 --- a/__tests__/e2e/002-two-nodes-orchestrated.test.ts +++ b/__tests__/e2e/002-two-nodes-orchestrated.test.ts @@ -1,6 +1,6 @@ import Debug from 'debug'; -import { createOrchestrator } from '../../src/orchestration'; -import type { NodeConfig, NodeHandle } from '../../src/orchestration'; +import { createOrchestrator } from '@src/orchestration'; +import type { NodeConfig, NodeHandle } from '@src/orchestration'; // Increase test timeout to 30 seconds jest.setTimeout(30000); diff --git a/__tests__/e2e/005-docker-orchestrator.test.ts b/__tests__/e2e/005-docker-orchestrator.test.ts index c485e96..ef2fa03 100644 --- a/__tests__/e2e/005-docker-orchestrator.test.ts +++ b/__tests__/e2e/005-docker-orchestrator.test.ts @@ -1,8 +1,8 @@ import Docker from 'dockerode'; import { describe, it, beforeAll, afterAll, expect, jest } from '@jest/globals'; -import type { NodeConfig, NodeHandle, NodeStatus } from '../../src/orchestration'; -import { DockerOrchestrator, createOrchestrator } from '../../src/orchestration'; -import { ImageManager } from '../../src/orchestration/docker-orchestrator/managers/image-manager'; +import type { NodeConfig, NodeHandle, NodeStatus } from '@src/orchestration'; +import { DockerOrchestrator, createOrchestrator } from '@src/orchestration'; +import { ImageManager } from '@src/orchestration/docker-orchestrator/managers/image-manager'; import Debug from 'debug'; const debug = Debug('rz:test:docker-orchestrator-v2'); diff --git a/__tests__/integration/schema.test.ts b/__tests__/integration/schema.test.ts index 6105443..be80d40 100644 --- a/__tests__/integration/schema.test.ts +++ b/__tests__/integration/schema.test.ts @@ -1,16 +1,16 @@ -import { LosslessViewOne } from '../../src/views/lossless'; +import { LosslessViewOne } from '@src/views/lossless'; import { SchemaBuilder, PrimitiveSchemas, ReferenceSchemas, ArraySchemas, ObjectSchema -} from '../../src/schema'; -import { DefaultSchemaRegistry } from '../../src/schema'; +} from '@src/schema'; +import { DefaultSchemaRegistry } from '@src/schema'; import { CommonSchemas } from '../../util/schemas'; -import { TypedCollectionImpl, SchemaValidationError } from '../../src/collections'; -import { RhizomeNode } from '../../src/node'; -import { createDelta } from '../../src/core/delta-builder'; +import { TypedCollectionImpl, SchemaValidationError } from '@src/collections'; +import { RhizomeNode } from '@src/node'; +import { createDelta } from '@src/core/delta-builder'; describe('Schema System', () => { let schemaRegistry: DefaultSchemaRegistry; diff --git a/__tests__/integration/storage.test.ts b/__tests__/integration/storage.test.ts index 9b2a3e5..4df66c5 100644 --- a/__tests__/integration/storage.test.ts +++ b/__tests__/integration/storage.test.ts @@ -1,6 +1,6 @@ -import { MemoryDeltaStorage, LevelDBDeltaStorage, StorageFactory } from '../../src/storage'; -import { createDelta } from '../../src/core/delta-builder'; -import { DeltaQueryStorage } from '../../src/storage/interface'; +import { MemoryDeltaStorage, LevelDBDeltaStorage, StorageFactory } from '@src/storage'; +import { createDelta } from '@src/core/delta-builder'; +import { DeltaQueryStorage } from '@src/storage/interface'; describe('Delta Storage', () => { const testDeltas = [ diff --git a/__tests__/performance/concurrent-writes.test.ts b/__tests__/performance/concurrent-writes.test.ts index 4b01822..272f0d5 100644 --- a/__tests__/performance/concurrent-writes.test.ts +++ b/__tests__/performance/concurrent-writes.test.ts @@ -1,4 +1,4 @@ -import { createDelta } from '../../src/core/delta-builder'; +import { createDelta } from '@src/core/delta-builder'; import { RhizomeNode, Lossless, diff --git a/__tests__/performance/nested-resolution-performance.test.ts b/__tests__/performance/nested-resolution-performance.test.ts index 09e0452..565a0bc 100644 --- a/__tests__/performance/nested-resolution-performance.test.ts +++ b/__tests__/performance/nested-resolution-performance.test.ts @@ -9,11 +9,11 @@ */ import Debug from 'debug'; -import { RhizomeNode } from '../../src/node'; -import { createDelta } from '../../src/core'; -import { DefaultSchemaRegistry } from '../../src/schema'; -import { SchemaBuilder, PrimitiveSchemas, ReferenceSchemas, ArraySchemas } from '../../src/schema'; -import { TypedCollectionImpl } from '../../src/collections'; +import { RhizomeNode } from '@src/node'; +import { createDelta } from '@src/core'; +import { DefaultSchemaRegistry } from '@src/schema'; +import { SchemaBuilder, PrimitiveSchemas, ReferenceSchemas, ArraySchemas } from '@src/schema'; +import { TypedCollectionImpl } from '@src/collections'; const debug = Debug('rz:test:nested-resolution-performance'); diff --git a/__tests__/unit/core/compose-decompose.test.ts b/__tests__/unit/core/compose-decompose.test.ts index 7f734bd..626da7c 100644 --- a/__tests__/unit/core/compose-decompose.test.ts +++ b/__tests__/unit/core/compose-decompose.test.ts @@ -4,8 +4,8 @@ * to the original deltas with all pointer relationships preserved. */ -import { RhizomeNode } from '../../../src/node'; -import { createDelta } from '../../../src/core/delta-builder'; +import { RhizomeNode } from '@src/node'; +import { createDelta } from '@src/core/delta-builder'; describe('Lossless View Compose/Decompose', () => { let node: RhizomeNode; diff --git a/__tests__/unit/core/delta/delta-builder.test.ts b/__tests__/unit/core/delta/delta-builder.test.ts index 8ecb07c..9e5dc3e 100644 --- a/__tests__/unit/core/delta/delta-builder.test.ts +++ b/__tests__/unit/core/delta/delta-builder.test.ts @@ -1,8 +1,8 @@ -import { createDelta } from '../../../../src/core/delta-builder'; -import { DeltaV1, DeltaV2 } from '../../../../src/core/delta'; -import { Lossless } from '../../../../src/views/lossless'; -import { RhizomeNode } from '../../../../src/node'; -import { TimestampResolver } from '../../../../src/views/resolvers/timestamp-resolvers'; +import { createDelta } from '@src/core/delta-builder'; +import { DeltaV1, DeltaV2 } from '@src/core/delta'; +import { Lossless } from '@src/views/lossless'; +import { RhizomeNode } from '@src/node'; +import { TimestampResolver } from '@src/views/resolvers/timestamp-resolvers'; describe('DeltaBuilder', () => { const creator = 'creator-123'; diff --git a/__tests__/unit/core/delta/delta.test.ts b/__tests__/unit/core/delta/delta.test.ts index 5177e99..87c2796 100644 --- a/__tests__/unit/core/delta/delta.test.ts +++ b/__tests__/unit/core/delta/delta.test.ts @@ -1,4 +1,4 @@ -import { createDelta } from '../../../../src/core/delta-builder'; +import { createDelta } from '@src/core/delta-builder'; import {DeltaV1, DeltaV2} from "../../../../src"; describe("Delta", () => { diff --git a/__tests__/unit/core/json-ast.test.ts b/__tests__/unit/core/json-ast.test.ts index bcc92ae..e740cd9 100644 --- a/__tests__/unit/core/json-ast.test.ts +++ b/__tests__/unit/core/json-ast.test.ts @@ -1,5 +1,5 @@ -import { jsonToAst } from '../../../src/utils/json-ast'; -import { JsonNode } from '../../../src/utils/json-ast/types'; +import { jsonToAst } from '@src/utils/json-ast'; +import { JsonNode } from '@src/utils/json-ast/types'; describe('jsonToAst', () => { it('should handle primitive values', () => { diff --git a/__tests__/unit/core/negation.test.ts b/__tests__/unit/core/negation.test.ts index 653014c..609ad9c 100644 --- a/__tests__/unit/core/negation.test.ts +++ b/__tests__/unit/core/negation.test.ts @@ -1,8 +1,8 @@ import Debug from 'debug'; -import { createDelta } from '../../../src/core/delta-builder'; -import { NegationHelper } from '../../../src/features'; -import { RhizomeNode } from '../../../src/node'; -import { Lossless } from '../../../src/views'; +import { createDelta } from '@src/core/delta-builder'; +import { NegationHelper } from '@src/features'; +import { RhizomeNode } from '@src/node'; +import { Lossless } from '@src/views'; const debug = Debug('rz:negation:test'); diff --git a/__tests__/unit/core/peer-address.test.ts b/__tests__/unit/core/peer-address.test.ts index ce6c981..df40597 100644 --- a/__tests__/unit/core/peer-address.test.ts +++ b/__tests__/unit/core/peer-address.test.ts @@ -1,4 +1,4 @@ -import {parseAddressList, PeerAddress} from '../../../src/network/peers'; +import {parseAddressList, PeerAddress} from '@src/network/peers'; describe('PeerAddress', () => { it('toString()', () => { diff --git a/__tests__/unit/core/transactions.test.ts b/__tests__/unit/core/transactions.test.ts index ee381d9..d7a88e7 100644 --- a/__tests__/unit/core/transactions.test.ts +++ b/__tests__/unit/core/transactions.test.ts @@ -1,7 +1,7 @@ -import { createDelta } from '../../../src/core/delta-builder'; -import { Lossless } from '../../../src/views'; -import { RhizomeNode } from '../../../src/node'; -import { DeltaFilter } from '../../../src/core'; +import { createDelta } from '@src/core/delta-builder'; +import { Lossless } from '@src/views'; +import { RhizomeNode } from '@src/node'; +import { DeltaFilter } from '@src/core'; describe('Transactions', () => { let node: RhizomeNode; diff --git a/__tests__/unit/query/query.test.ts b/__tests__/unit/query/query.test.ts index f093601..f9e4757 100644 --- a/__tests__/unit/query/query.test.ts +++ b/__tests__/unit/query/query.test.ts @@ -1,10 +1,10 @@ -import { QueryEngine } from '../../../src/query'; -import { Lossless } from '../../../src/views'; -import { DefaultSchemaRegistry } from '../../../src/schema'; -import { SchemaBuilder, PrimitiveSchemas } from '../../../src/schema'; +import { QueryEngine } from '@src/query'; +import { Lossless } from '@src/views'; +import { DefaultSchemaRegistry } from '@src/schema'; +import { SchemaBuilder, PrimitiveSchemas } from '@src/schema'; import { CommonSchemas } from '../../../util/schemas'; -import { createDelta } from '../../../src/core/delta-builder'; -import { RhizomeNode } from '../../../src/node'; +import { createDelta } from '@src/core/delta-builder'; +import { RhizomeNode } from '@src/node'; describe('Query Engine', () => { let queryEngine: QueryEngine; diff --git a/__tests__/unit/views/lossless.test.ts b/__tests__/unit/views/lossless.test.ts index e9a73cd..1c53e6e 100644 --- a/__tests__/unit/views/lossless.test.ts +++ b/__tests__/unit/views/lossless.test.ts @@ -1,7 +1,7 @@ -import {DeltaFilter} from '../../../src/core'; -import {Lossless} from '../../../src/views'; -import {RhizomeNode} from '../../../src/node'; -import {createDelta} from '../../../src/core/delta-builder'; +import {DeltaFilter} from '@src/core'; +import {Lossless} from '@src/views'; +import {RhizomeNode} from '@src/node'; +import {createDelta} from '@src/core/delta-builder'; describe('Lossless', () => { const node = new RhizomeNode(); diff --git a/__tests__/unit/views/multi-pointer-resolution.test.ts b/__tests__/unit/views/multi-pointer-resolution.test.ts index 3b203de..8c2788a 100644 --- a/__tests__/unit/views/multi-pointer-resolution.test.ts +++ b/__tests__/unit/views/multi-pointer-resolution.test.ts @@ -4,11 +4,11 @@ * between multiple entities and includes scalar values. */ -import { RhizomeNode } from '../../../src/node'; -import { createDelta } from '../../../src/core/delta-builder'; -import { DefaultSchemaRegistry } from '../../../src/schema'; -import { SchemaBuilder, PrimitiveSchemas, ReferenceSchemas, SchemaAppliedViewWithNesting } from '../../../src/schema'; -import { TypedCollectionImpl } from '../../../src/collections'; +import { RhizomeNode } from '@src/node'; +import { createDelta } from '@src/core/delta-builder'; +import { DefaultSchemaRegistry } from '@src/schema'; +import { SchemaBuilder, PrimitiveSchemas, ReferenceSchemas, SchemaAppliedViewWithNesting } from '@src/schema'; +import { TypedCollectionImpl } from '@src/collections'; describe('Multi-Pointer Delta Resolution', () => { let node: RhizomeNode; diff --git a/__tests__/unit/views/nested-resolution.test.ts b/__tests__/unit/views/nested-resolution.test.ts index e219a23..170f04e 100644 --- a/__tests__/unit/views/nested-resolution.test.ts +++ b/__tests__/unit/views/nested-resolution.test.ts @@ -9,12 +9,12 @@ * - Deep nesting scenarios */ -import { RhizomeNode } from '../../../src/node'; -import { DefaultSchemaRegistry } from '../../../src/schema'; -import { SchemaBuilder, PrimitiveSchemas, ReferenceSchemas } from '../../../src/schema'; +import { RhizomeNode } from '@src/node'; +import { DefaultSchemaRegistry } from '@src/schema'; +import { SchemaBuilder, PrimitiveSchemas, ReferenceSchemas } from '@src/schema'; import { CommonSchemas } from '../../../util/schemas'; -import { TypedCollectionImpl } from '../../../src/collections'; -import { createDelta } from '../../../src/core/delta-builder'; +import { TypedCollectionImpl } from '@src/collections'; +import { createDelta } from '@src/core/delta-builder'; describe('Nested Object Resolution', () => { let node: RhizomeNode; diff --git a/__tests__/unit/views/resolvers/custom-resolvers.test.ts b/__tests__/unit/views/resolvers/custom-resolvers.test.ts index 3033e00..05104b6 100644 --- a/__tests__/unit/views/resolvers/custom-resolvers.test.ts +++ b/__tests__/unit/views/resolvers/custom-resolvers.test.ts @@ -1,8 +1,15 @@ +import { describe, test, expect, beforeEach } from '@jest/globals'; import { RhizomeNode, Lossless, createDelta } from "../../../../src"; import { CollapsedDelta } from "../../../../src/views/lossless"; import { CustomResolver, - ResolverPlugin, + ResolverPlugin, + type DependencyStates +} from "../../../../src/views/resolvers/custom-resolvers"; +import type { LosslessViewOne } from '@src/views/resolvers/lossless-view'; + +type PropertyTypes = 'string' | 'number' | 'boolean' | 'object' | 'array'; +import { LastWriteWinsPlugin, FirstWriteWinsPlugin, ConcatenationPlugin, @@ -327,20 +334,69 @@ describe('Custom Resolvers', () => { describe('Plugin Dependencies', () => { test('should detect circular dependencies', () => { - class PluginA implements ResolverPlugin { - name = 'a'; - dependencies = ['b']; - initialize() { return {}; } - update() { return {}; } - resolve() { return 'a'; } + // Define state interfaces + interface PluginAState { + value: string; } - class PluginB implements ResolverPlugin { - name = 'b'; - dependencies = ['a']; - initialize() { return {}; } - update() { return {}; } - resolve() { return 'b'; } + interface PluginBState { + value: string; + } + + // PluginA depends on PluginB + class PluginA implements ResolverPlugin { + readonly name = 'a' as const; + readonly dependencies = ['b'] as const; + + initialize(): PluginAState { + return { value: 'a' }; + } + + update( + currentState: PluginAState, + _newValue: unknown, + _delta: CollapsedDelta, + _dependencies: { b: { value: string } } = { b: { value: '' } } + ): PluginAState { + return { ...currentState }; + } + + resolve( + _state: PluginAState, + _dependencies: { b: { value: string } } = { b: { value: '' } } + ): string { + return 'a'; + } + } + + // PluginB depends on PluginA + interface PluginBState { + value: string; + } + + class PluginB implements ResolverPlugin { + readonly name = 'b' as const; + readonly dependencies = ['a'] as const; + + initialize(): PluginBState { + return { value: 'b' }; + } + + update( + currentState: PluginBState, + _newValue: unknown, + _delta: CollapsedDelta, + _dependencies: { a: unknown } = { a: undefined } + ): PluginBState { + return currentState; + } + + resolve( + _state: PluginBState, + _dependencies: { a: unknown } = { a: undefined } + ): string { + return 'b'; + } } expect(() => { @@ -351,27 +407,28 @@ describe('Custom Resolvers', () => { }).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 } - }; + describe('CustomResolver with plugin dependencies', () => { + let lossless: LosslessViewOne; + + // Track plugin execution order + const executionOrder: string[] = []; + + // Track plugin state updates and resolutions + const pluginTracker = { + first: { updated: false, resolved: false }, + second: { updated: false, resolved: false } + }; - interface PluginState { - value: string; - updated: boolean; - resolved: boolean; - } + // Define plugin state interface + interface PluginState { + value: string; + updated: boolean; + resolved: boolean; + } - class FirstPlugin implements ResolverPlugin { - name = 'first'; - dependencies: string[] = []; + class FirstPlugin implements ResolverPlugin { + readonly name = 'first' as const; + readonly dependencies = [] as const; initialize(): PluginState { console.log('First plugin initialized'); @@ -380,22 +437,22 @@ describe('Custom Resolvers', () => { } update( - state: PluginState, - value: unknown, - _delta?: unknown, - _allStates?: Record + currentState: PluginState, + newValue: PropertyTypes, + _delta: CollapsedDelta, + _dependencies: Record = {} ): PluginState { - console.log('First plugin updated with value:', value); + console.log('First plugin updated with value:', newValue); executionOrder.push('first-update'); pluginTracker.first.updated = true; return { - ...state, - value: String(value), + ...currentState, + value: String(newValue), updated: true }; } - resolve(state: PluginState, _allStates?: Record): string { + resolve(state: PluginState): string { console.log('First plugin resolved with value:', state.value); executionOrder.push('first-resolve'); pluginTracker.first.resolved = true; @@ -403,9 +460,9 @@ describe('Custom Resolvers', () => { } } - class SecondPlugin implements ResolverPlugin { - name = 'second'; - dependencies: string[] = ['first']; + class SecondPlugin implements ResolverPlugin { + readonly name = 'second' as const; + readonly dependencies = ['first'] as const; initialize(): PluginState { console.log('Second plugin initialized'); @@ -414,30 +471,30 @@ describe('Custom Resolvers', () => { } update( - state: PluginState, - value: unknown, - _delta?: unknown, - allStates?: Record + currentState: PluginState, + newValue: PropertyTypes, + _delta: CollapsedDelta, + dependencies: { first: unknown } ): PluginState { - console.log('Second plugin updated with value:', value); + console.log('Second plugin updated with value:', newValue); 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; + // Access the first plugin's resolved state + const firstState = dependencies.first as PluginState; 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'}`, + ...currentState, + value: `${newValue}-${firstState?.value || 'unknown'}`, updated: true }; } - resolve(state: PluginState, _allStates?: Record): string { + resolve(state: PluginState): string { console.log('Second plugin resolved with value:', state.value); executionOrder.push('second-resolve'); pluginTracker.second.resolved = true; @@ -448,19 +505,15 @@ describe('Custom Resolvers', () => { // Create resolver with dependency order: first -> second console.log('Creating resolver with plugins'); - // Create resolver with test plugins first + // Create test plugins const firstPlugin = new FirstPlugin(); const secondPlugin = new SecondPlugin(); - const testResolver = new CustomResolver(lossless, { + // Create resolver with test plugins + const testResolver = new CustomResolver({ 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'); @@ -546,6 +599,7 @@ describe('Custom Resolvers', () => { // A plugin that applies a discount to a price class DiscountedPricePlugin implements ResolverPlugin<{ price: number }> { name = 'discounted-price'; + dependencies = ['discount']; initialize() { return { price: 0 }; diff --git a/__tests__/unit/views/resolvers/custom-resolvers/builtin-plugins/concatenation.test.ts b/__tests__/unit/views/resolvers/custom-resolvers/builtin-plugins/concatenation.test.ts new file mode 100644 index 0000000..220685e --- /dev/null +++ b/__tests__/unit/views/resolvers/custom-resolvers/builtin-plugins/concatenation.test.ts @@ -0,0 +1,73 @@ +import { describe, test, expect, beforeEach } from '@jest/globals'; +import { RhizomeNode, Lossless, createDelta } from '@src'; +import { CustomResolver, ConcatenationPlugin } from '@src/views/resolvers/custom-resolvers'; + +describe('ConcatenationPlugin', () => { + let node: RhizomeNode; + let lossless: Lossless; + + beforeEach(() => { + node = new RhizomeNode(); + lossless = new Lossless(node); + }); + + test('should join string values chronologically', () => { + // First tag + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('entity1', 'tags', 'red', 'collection') + .buildV1() + ); + + // Second tag (with later timestamp) + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(3000) + .setProperty('entity1', 'tags', 'blue', 'collection') + .buildV1() + ); + + // Third tag (with middle timestamp, should be inserted in the middle) + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(2000) + .setProperty('entity1', 'tags', 'green', 'collection') + .buildV1() + ); + + const resolver = new CustomResolver(lossless, { + tags: new ConcatenationPlugin() + }); + + const result = resolver.resolve(); + expect(result).toBeDefined(); + expect(result!['entity1'].properties.tags).toEqual(['red', 'green', 'blue']); + }); + + test('should handle empty values', () => { + // Empty array + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('entity1', 'tags', [], 'collection') + .buildV1() + ); + + // Add a value + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(2000) + .setProperty('entity1', 'tags', 'blue', 'collection') + .buildV1() + ); + + const resolver = new CustomResolver(lossless, { + tags: new ConcatenationPlugin() + }); + + const result = resolver.resolve(); + expect(result).toBeDefined(); + expect(result!['entity1'].properties.tags).toEqual(['blue']); + }); +}); diff --git a/__tests__/unit/views/resolvers/custom-resolvers/builtin-plugins/first-write-wins.test.ts b/__tests__/unit/views/resolvers/custom-resolvers/builtin-plugins/first-write-wins.test.ts new file mode 100644 index 0000000..855e867 --- /dev/null +++ b/__tests__/unit/views/resolvers/custom-resolvers/builtin-plugins/first-write-wins.test.ts @@ -0,0 +1,65 @@ +import { describe, test, expect, beforeEach } from '@jest/globals'; +import { RhizomeNode, Lossless, createDelta } from '@src'; +import { CustomResolver, FirstWriteWinsPlugin } from '@src/views/resolvers/custom-resolvers'; + +describe('FirstWriteWinsPlugin', () => { + let node: RhizomeNode; + let lossless: Lossless; + + beforeEach(() => { + node = new RhizomeNode(); + lossless = new Lossless(node); + }); + + test('should resolve to earliest value by timestamp', () => { + // Later delta (should be ignored by FirstWriteWins) + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(2000) + .setProperty('entity1', 'name', 'second', 'collection') + .buildV1() + ); + + // Earlier delta (should win with FirstWriteWins) + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('entity1', 'name', 'first', 'collection') + .buildV1() + ); + + const resolver = new CustomResolver(lossless, { + name: new FirstWriteWinsPlugin() + }); + + const result = resolver.resolve(); + expect(result).toBeDefined(); + expect(result!['entity1'].properties.name).toBe('first'); + }); + + test('should handle concurrent updates with same timestamp', () => { + // Two deltas with same timestamp + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('entity1', 'status', 'active', 'collection') + .buildV1() + ); + + lossless.ingestDelta( + createDelta('user2', 'host2') + .withTimestamp(1000) + .setProperty('entity1', 'status', 'inactive', 'collection') + .buildV1() + ); + + const resolver = new CustomResolver(lossless, { + status: new FirstWriteWinsPlugin() + }); + + const result = resolver.resolve(); + expect(result).toBeDefined(); + // Should pick one of the values (behavior may depend on implementation details) + expect(['active', 'inactive']).toContain(result!['entity1'].properties.status); + }); +}); diff --git a/__tests__/unit/views/resolvers/custom-resolvers/builtin-plugins/last-write-wins.test.ts b/__tests__/unit/views/resolvers/custom-resolvers/builtin-plugins/last-write-wins.test.ts new file mode 100644 index 0000000..ed62b92 --- /dev/null +++ b/__tests__/unit/views/resolvers/custom-resolvers/builtin-plugins/last-write-wins.test.ts @@ -0,0 +1,65 @@ +import { describe, test, expect, beforeEach } from '@jest/globals'; +import { RhizomeNode, Lossless, createDelta } from '@src'; +import { CustomResolver, LastWriteWinsPlugin } from '@src/views/resolvers/custom-resolvers'; + +describe('LastWriteWinsPlugin', () => { + let node: RhizomeNode; + let lossless: Lossless; + + beforeEach(() => { + node = new RhizomeNode(); + lossless = new Lossless(node); + }); + + test('should resolve to most recent value by timestamp', () => { + // First delta with earlier timestamp + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('entity1', 'name', 'first', 'collection') + .buildV1() + ); + + // Second delta with later timestamp (should win) + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(2000) + .setProperty('entity1', 'name', 'second', 'collection') + .buildV1() + ); + + const resolver = new CustomResolver(lossless, { + name: new LastWriteWinsPlugin() + }); + + const result = resolver.resolve(); + expect(result).toBeDefined(); + expect(result!['entity1'].properties.name).toBe('second'); + }); + + test('should handle concurrent updates with same timestamp', () => { + // Two deltas with same timestamp + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('entity1', 'status', 'active', 'collection') + .buildV1() + ); + + lossless.ingestDelta( + createDelta('user2', 'host2') + .withTimestamp(1000) + .setProperty('entity1', 'status', 'inactive', 'collection') + .buildV1() + ); + + const resolver = new CustomResolver(lossless, { + status: new LastWriteWinsPlugin() + }); + + const result = resolver.resolve(); + expect(result).toBeDefined(); + // Should pick one of the values (behavior may depend on implementation details) + expect(['active', 'inactive']).toContain(result!['entity1'].properties.status); + }); +}); diff --git a/__tests__/unit/views/resolvers/custom-resolvers/builtin-plugins/majority-vote.test.ts b/__tests__/unit/views/resolvers/custom-resolvers/builtin-plugins/majority-vote.test.ts new file mode 100644 index 0000000..8f0540d --- /dev/null +++ b/__tests__/unit/views/resolvers/custom-resolvers/builtin-plugins/majority-vote.test.ts @@ -0,0 +1,81 @@ +import { describe, test, expect, beforeEach } from '@jest/globals'; +import { RhizomeNode, Lossless, createDelta } from '@src'; +import { CustomResolver, MajorityVotePlugin } from '@src/views/resolvers/custom-resolvers'; + +describe('MajorityVotePlugin', () => { + let node: RhizomeNode; + let lossless: Lossless; + + beforeEach(() => { + node = new RhizomeNode(); + lossless = new Lossless(node); + }); + + test('should resolve to value with majority votes', () => { + // Add three votes for 'yes' + for (let i = 0; i < 3; i++) { + lossless.ingestDelta( + createDelta(`user${i}`, 'host1') + .withTimestamp(1000 + i) + .setProperty('poll1', 'result', 'yes', 'polls') + .buildV1() + ); + } + + // Add two votes for 'no' + for (let i = 0; i < 2; i++) { + lossless.ingestDelta( + createDelta(`user${i + 3}`, 'host1') + .withTimestamp(2000 + i) + .setProperty('poll1', 'result', 'no', 'polls') + .buildV1() + ); + } + + const resolver = new CustomResolver(lossless, { + result: new MajorityVotePlugin() + }); + + const result = resolver.resolve(); + expect(result).toBeDefined(); + expect(result!['poll1'].properties.result).toBe('yes'); + }); + + test('should handle tie by selecting the most recent value', () => { + // Two votes for 'yes' + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('tie1', 'result', 'yes', 'polls') + .buildV1() + ); + lossless.ingestDelta( + createDelta('user2', 'host1') + .withTimestamp(2000) + .setProperty('tie1', 'result', 'yes', 'polls') + .buildV1() + ); + + // Two votes for 'no', with the last one being more recent + lossless.ingestDelta( + createDelta('user3', 'host1') + .withTimestamp(3000) + .setProperty('tie1', 'result', 'no', 'polls') + .buildV1() + ); + lossless.ingestDelta( + createDelta('user4', 'host1') + .withTimestamp(4000) + .setProperty('tie1', 'result', 'no', 'polls') + .buildV1() + ); + + const resolver = new CustomResolver(lossless, { + result: new MajorityVotePlugin() + }); + + const result = resolver.resolve(); + expect(result).toBeDefined(); + expect(result!['tie1'].properties.result).toBe('no'); + }); +}); diff --git a/__tests__/unit/views/resolvers/custom-resolvers/builtin-plugins/max.test.ts b/__tests__/unit/views/resolvers/custom-resolvers/builtin-plugins/max.test.ts new file mode 100644 index 0000000..4bf06ff --- /dev/null +++ b/__tests__/unit/views/resolvers/custom-resolvers/builtin-plugins/max.test.ts @@ -0,0 +1,69 @@ +import { describe, test, expect, beforeEach } from '@jest/globals'; +import { RhizomeNode, Lossless, createDelta } from '@src'; +import { CustomResolver, MaxPlugin } from '@src/views/resolvers/custom-resolvers'; + +describe('MaxPlugin', () => { + let node: RhizomeNode; + let lossless: Lossless; + + beforeEach(() => { + node = new RhizomeNode(); + lossless = new Lossless(node); + }); + + test('should resolve to the maximum numeric value', () => { + // Add multiple values + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('sensor1', 'temperature', 25, 'readings') + .buildV1() + ); + + lossless.ingestDelta( + createDelta('user2', 'host1') + .withTimestamp(2000) + .setProperty('sensor1', 'temperature', 22, 'readings') + .buildV1() + ); + + lossless.ingestDelta( + createDelta('user3', 'host1') + .withTimestamp(3000) + .setProperty('sensor1', 'temperature', 27, 'readings') + .buildV1() + ); + + const resolver = new CustomResolver(lossless, { + temperature: new MaxPlugin() + }); + + const result = resolver.resolve(); + expect(result).toBeDefined(); + expect(result!['sensor1'].properties.temperature).toBe(27); + }); + + test('should handle negative numbers', () => { + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('sensor2', 'value', -5, 'readings') + .buildV1() + ); + + lossless.ingestDelta( + createDelta('user2', 'host1') + .withTimestamp(2000) + .setProperty('sensor2', 'value', -10, 'readings') + .buildV1() + ); + + const resolver = new CustomResolver(lossless, { + value: new MaxPlugin() + }); + + const result = resolver.resolve(); + expect(result).toBeDefined(); + expect(result!['sensor2'].properties.value).toBe(-5); + }); +}); diff --git a/__tests__/unit/views/resolvers/custom-resolvers/builtin-plugins/min.test.ts b/__tests__/unit/views/resolvers/custom-resolvers/builtin-plugins/min.test.ts new file mode 100644 index 0000000..f7656e2 --- /dev/null +++ b/__tests__/unit/views/resolvers/custom-resolvers/builtin-plugins/min.test.ts @@ -0,0 +1,69 @@ +import { describe, test, expect, beforeEach } from '@jest/globals'; +import { RhizomeNode, Lossless, createDelta } from '@src'; +import { CustomResolver, MinPlugin } from '@src/views/resolvers/custom-resolvers'; + +describe('MinPlugin', () => { + let node: RhizomeNode; + let lossless: Lossless; + + beforeEach(() => { + node = new RhizomeNode(); + lossless = new Lossless(node); + }); + + test('should resolve to the minimum numeric value', () => { + // Add multiple values + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('sensor1', 'temperature', 25, 'readings') + .buildV1() + ); + + lossless.ingestDelta( + createDelta('user2', 'host1') + .withTimestamp(2000) + .setProperty('sensor1', 'temperature', 22, 'readings') + .buildV1() + ); + + lossless.ingestDelta( + createDelta('user3', 'host1') + .withTimestamp(3000) + .setProperty('sensor1', 'temperature', 27, 'readings') + .buildV1() + ); + + const resolver = new CustomResolver(lossless, { + temperature: new MinPlugin() + }); + + const result = resolver.resolve(); + expect(result).toBeDefined(); + expect(result!['sensor1'].properties.temperature).toBe(22); + }); + + test('should handle negative numbers', () => { + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('sensor2', 'value', -5, 'readings') + .buildV1() + ); + + lossless.ingestDelta( + createDelta('user2', 'host1') + .withTimestamp(2000) + .setProperty('sensor2', 'value', -10, 'readings') + .buildV1() + ); + + const resolver = new CustomResolver(lossless, { + value: new MinPlugin() + }); + + const result = resolver.resolve(); + expect(result).toBeDefined(); + expect(result!['sensor2'].properties.value).toBe(-10); + }); +}); diff --git a/__tests__/unit/views/resolvers/custom-resolvers/dependency/basic-dependencies.test.ts b/__tests__/unit/views/resolvers/custom-resolvers/dependency/basic-dependencies.test.ts new file mode 100644 index 0000000..2db864a --- /dev/null +++ b/__tests__/unit/views/resolvers/custom-resolvers/dependency/basic-dependencies.test.ts @@ -0,0 +1,80 @@ +import { describe, test, expect, beforeEach } from '@jest/globals'; +import { RhizomeNode, Lossless, createDelta } from '@src'; +import { CollapsedDelta } from '@src/views/lossless'; +import { CustomResolver, ResolverPlugin } from '@src/views/resolvers/custom-resolvers'; +import type { DependencyStates } from '@src/views/resolvers/custom-resolvers'; + +type PropertyTypes = string | number | boolean | null; + +describe('Basic Dependency Resolution', () => { + let node: RhizomeNode; + let lossless: Lossless; + + beforeEach(() => { + node = new RhizomeNode(); + lossless = new Lossless(node); + }); + + test('should resolve dependencies in correct order', () => { + // Define a simple plugin that depends on another + class FirstPlugin implements ResolverPlugin<{ value: string }, string> { + readonly name = 'first' as const; + readonly dependencies = [] as const; + + initialize() { + return { value: '' }; + } + + update(currentState: { value: string }, newValue: PropertyTypes) { + return { value: String(newValue) }; + } + + resolve(state: { value: string }) { + return state.value.toUpperCase(); + } + } + + + class SecondPlugin implements ResolverPlugin<{ value: string }, string> { + readonly name = 'second' as const; + readonly dependencies = ['first'] as const; + + initialize() { + return { value: '' }; + } + + update(currentState: { value: string }, newValue: PropertyTypes, _delta: CollapsedDelta, dependencies: { first: string }) { + return { value: `${dependencies.first}_${newValue}` }; + } + + resolve(state: { value: string }) { + return state.value; + } + } + + // Add some data + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('test1', 'first', 'hello', 'test') + .buildV1() + ); + + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(2000) + .setProperty('test1', 'second', 'world', 'test') + .buildV1() + ); + + const resolver = new CustomResolver(lossless, { + first: new FirstPlugin(), + second: new SecondPlugin() + }); + + const result = resolver.resolve(); + expect(result).toBeDefined(); + expect(result!['test1'].properties.first).toBe('HELLO'); + expect(result!['test1'].properties.second).toBe('HELLO_world'); + }); +}); diff --git a/__tests__/unit/views/resolvers/custom-resolvers/dependency/circular-dependencies.test.ts b/__tests__/unit/views/resolvers/custom-resolvers/dependency/circular-dependencies.test.ts new file mode 100644 index 0000000..3cfb576 --- /dev/null +++ b/__tests__/unit/views/resolvers/custom-resolvers/dependency/circular-dependencies.test.ts @@ -0,0 +1,100 @@ +import { describe, test, expect, beforeEach } from '@jest/globals'; +import { RhizomeNode, Lossless, createDelta } from '@src'; +import { CollapsedDelta } from '@src/views/lossless'; +import { CustomResolver, ResolverPlugin } from '@src/views/resolvers/custom-resolvers'; +import type { DependencyStates } from '@src/views/resolvers/custom-resolvers'; + +type PropertyTypes = string | number | boolean | null; + +describe('Circular Dependency Detection', () => { + let node: RhizomeNode; + let lossless: Lossless; + + beforeEach(() => { + node = new RhizomeNode(); + lossless = new Lossless(node); + }); + + test('should detect circular dependencies', () => { + // PluginA depends on PluginB + class PluginA implements ResolverPlugin<{ value: string }, string> { + readonly name = 'a' as const; + readonly dependencies = ['b'] as const; + + initialize() { + return { value: '' }; + } + + update(currentState: { value: string }, newValue: PropertyTypes, _delta: CollapsedDelta, _dependencies: { b: string }) { + return { value: String(newValue) }; + } + + resolve(_state: { value: string }, _dependencies: { b: string }) { + return 'a'; + } + } + + + // PluginB depends on PluginA (circular dependency) + class PluginB implements ResolverPlugin<{ value: string }, string> { + readonly name = 'b' as const; + readonly dependencies = ['a'] as const; + + initialize() { + return { value: '' }; + } + + update(currentState: { value: string }, newValue: PropertyTypes, _delta: CollapsedDelta, _dependencies: { a: string }) { + return { value: String(newValue) }; + } + + resolve(_state: { value: string }, _dependencies: { a: string }) { + return 'b'; + } + } + + + // Should throw an error when circular dependencies are detected + expect(() => { + new CustomResolver(lossless, { + 'a': new PluginA(), + 'b': new PluginB() + }); + }).toThrow('Circular dependency detected: a -> b -> a'); + }); + + test('should detect longer circular dependency chains', () => { + class PluginA implements ResolverPlugin<{ value: string }, string> { + readonly name = 'a' as const; + readonly dependencies = ['c'] as const; + initialize() { return { value: '' }; } + update() { return { value: '' }; } + resolve() { return 'a'; } + } + + class PluginB implements ResolverPlugin<{ value: string }, string> { + readonly name = 'b' as const; + readonly dependencies = ['a'] as const; + initialize() { return { value: '' }; } + update() { return { value: '' }; } + resolve() { return 'b'; } + } + + class PluginC implements ResolverPlugin<{ value: string }, string> { + readonly name = 'c' as const; + readonly dependencies = ['b'] as const; + initialize() { return { value: '' }; } + update() { return { value: '' }; } + resolve() { return 'c'; } + } + + // Should detect the circular dependency: a -> c -> b -> a + expect(() => { + new CustomResolver(lossless, { + 'a': new PluginA(), + 'b': new PluginB(), + 'c': new PluginC() + }); + }).toThrow('Circular dependency detected: a -> c -> b -> a'); + }); +}); diff --git a/__tests__/unit/views/resolvers/custom-resolvers/plugins/count-plugin.test.ts b/__tests__/unit/views/resolvers/custom-resolvers/plugins/count-plugin.test.ts new file mode 100644 index 0000000..31ef400 --- /dev/null +++ b/__tests__/unit/views/resolvers/custom-resolvers/plugins/count-plugin.test.ts @@ -0,0 +1,98 @@ +import { describe, test, expect, beforeEach } from '@jest/globals'; +import { RhizomeNode, Lossless, createDelta } from '@src'; +import { CustomResolver } from '@src/views/resolvers/custom-resolvers'; + +class CountPlugin { + readonly name = 'count' as const; + + initialize() { + return { count: 0 }; + } + + update(currentState: {count: number}, _newValue: unknown, _delta: any) { + return { count: currentState.count + 1 }; + } + + resolve(state: {count: number}) { + return state.count; + } +} + +describe('CountPlugin', () => { + let node: RhizomeNode; + let lossless: Lossless; + + beforeEach(() => { + node = new RhizomeNode(); + lossless = new Lossless(node); + }); + + test('should count the number of updates', () => { + // First update + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('counter1', 'count', 'value1', 'test') + .buildV1() + ); + + // Second update + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(2000) + .setProperty('counter1', 'count', 'value2', 'test') + .buildV1() + ); + + // Third update + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(3000) + .setProperty('counter1', 'count', 'value3', 'test') + .buildV1() + ); + + const resolver = new CustomResolver(lossless, { + count: new CountPlugin() + }); + + const result = resolver.resolve(); + expect(result).toBeDefined(); + expect(result!['counter1'].properties.count).toBe(3); + }); + + test('should handle multiple entities independently', () => { + // Update counter1 + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('counter1', 'count', 'value1', 'test') + .buildV1() + ); + + // Update counter2 + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('counter2', 'count', 'value1', 'test') + .buildV1() + ); + + // Update counter1 again + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(2000) + .setProperty('counter1', 'count', 'value2', 'test') + .buildV1() + ); + + const resolver = new CustomResolver(lossless, { + count: new CountPlugin() + }); + + const result = resolver.resolve(); + expect(result).toBeDefined(); + expect(result!['counter1'].properties.count).toBe(2); + expect(result!['counter2'].properties.count).toBe(1); + }); +}); diff --git a/__tests__/unit/views/resolvers/custom-resolvers/plugins/discount-plugins.test.ts b/__tests__/unit/views/resolvers/custom-resolvers/plugins/discount-plugins.test.ts new file mode 100644 index 0000000..9ec8d63 --- /dev/null +++ b/__tests__/unit/views/resolvers/custom-resolvers/plugins/discount-plugins.test.ts @@ -0,0 +1,143 @@ +import { describe, test, expect, beforeEach } from '@jest/globals'; +import { RhizomeNode, Lossless, createDelta } from '../../../../../../../src'; +import { CustomResolver } from '../../../../../../../src/views/resolvers/custom-resolvers'; + +class DiscountPlugin { + readonly name = 'discount' as const; + + initialize() { + return { value: 0 }; + } + + update(_currentState: {value: number}, newValue: unknown, _delta: any) { + const numValue = typeof newValue === 'number' ? newValue : 0; + return { value: Math.min(100, Math.max(0, numValue)) }; // Clamp between 0-100 + } + + resolve(state: {value: number}) { + return state.value; + } +} + +class DiscountedPricePlugin { + readonly name = 'discounted-price' as const; + readonly dependencies = ['discount'] as const; + + initialize() { + return { price: 0 }; + } + + update(_currentState: {price: number}, newValue: unknown, _delta: any, _dependencies: {discount: number}) { + const numValue = typeof newValue === 'number' ? newValue : 0; + return { price: numValue }; + } + + resolve(state: {price: number}, dependencies: {discount: number}) { + const discountMultiplier = (100 - dependencies.discount) / 100; + return state.price * discountMultiplier; + } +} + +describe('Discount and DiscountedPrice Plugins', () => { + let node: RhizomeNode; + let lossless: Lossless; + + beforeEach(() => { + node = new RhizomeNode(); + lossless = new Lossless(node); + }); + + test('should apply discount to price', () => { + // Set base price + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('product1', 'price', 100, 'products') + .buildV1() + ); + + // Set discount (20%) + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('product1', 'discount', 20, 'products') + .buildV1() + ); + + const resolver = new CustomResolver(lossless, { + price: new DiscountedPricePlugin(), + discount: new DiscountPlugin() + }); + + const result = resolver.resolve(); + expect(result).toBeDefined(); + expect(result!['product1'].properties.price).toBe(80); // 100 * 0.8 = 80 + }); + + test('should handle zero discount', () => { + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('product2', 'price', 50, 'products') + .buildV1() + ); + + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('product2', 'discount', 0, 'products') + .buildV1() + ); + + const resolver = new CustomResolver(lossless, { + price: new DiscountedPricePlugin(), + discount: new DiscountPlugin() + }); + + const result = resolver.resolve(); + expect(result!['product2'].properties.price).toBe(50); // No discount applied + }); + + test('should handle 100% discount', () => { + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('product3', 'price', 75, 'products') + .buildV1() + ); + + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('product3', 'discount', 100, 'products') + .buildV1() + ); + + const resolver = new CustomResolver(lossless, { + price: new DiscountedPricePlugin(), + discount: new DiscountPlugin() + }); + + const result = resolver.resolve(); + expect(result!['product3'].properties.price).toBe(0); // 100% discount = free + }); + + test('should handle missing discount', () => { + // Only set price, no discount + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('product4', 'price', 200, 'products') + .buildV1() + ); + + const resolver = new CustomResolver(lossless, { + price: new DiscountedPricePlugin(), + discount: new DiscountPlugin() + }); + + const result = resolver.resolve(); + // Should treat missing discount as 0% + expect(result!['product4'].properties.price).toBe(200); + }); +}); diff --git a/__tests__/unit/views/resolvers/custom-resolvers/plugins/running-average.test.ts b/__tests__/unit/views/resolvers/custom-resolvers/plugins/running-average.test.ts new file mode 100644 index 0000000..1a973d5 --- /dev/null +++ b/__tests__/unit/views/resolvers/custom-resolvers/plugins/running-average.test.ts @@ -0,0 +1,101 @@ +import { describe, test, expect, beforeEach } from '@jest/globals'; +import { RhizomeNode, Lossless, createDelta } from '@src'; +import { CustomResolver } from '@src/views/resolvers/custom-resolvers'; + +class RunningAveragePlugin { + readonly name = 'running-average' as const; + + initialize() { + return { sum: 0, count: 0 }; + } + + update(currentState: {sum: number, count: number}, newValue: unknown, _delta: any) { + const numValue = typeof newValue === 'number' ? newValue : 0; + return { + sum: currentState.sum + numValue, + count: currentState.count + 1 + }; + } + + resolve(state: {sum: number, count: number}) { + return state.count > 0 ? state.sum / state.count : 0; + } +} + +describe('RunningAveragePlugin', () => { + let node: RhizomeNode; + let lossless: Lossless; + + beforeEach(() => { + node = new RhizomeNode(); + lossless = new Lossless(node); + }); + + test('should calculate running average of numeric values', () => { + // First score (10) + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('player1', 'score', 10, 'game') + .buildV1() + ); + + // Second score (20) + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(2000) + .setProperty('player1', 'score', 20, 'game') + .buildV1() + ); + + // Third score (30) + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(3000) + .setProperty('player1', 'score', 30, 'game') + .buildV1() + ); + + const resolver = new CustomResolver(lossless, { + score: new RunningAveragePlugin() + }); + + const result = resolver.resolve(); + expect(result).toBeDefined(); + expect(result!['player1'].properties.score).toBe(20); // (10 + 20 + 30) / 3 = 20 + }); + + test('should handle non-numeric values gracefully', () => { + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('test1', 'value', 'not a number', 'test') + .buildV1() + ); + + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(2000) + .setProperty('test1', 'value', 10, 'test') + .buildV1() + ); + + const resolver = new CustomResolver(lossless, { + value: new RunningAveragePlugin() + }); + + const result = resolver.resolve(); + expect(result).toBeDefined(); + // First value is treated as 0, second as 10, average is (0 + 10) / 2 = 5 + expect(result!['test1'].properties.value).toBe(5); + }); + + test('should handle empty state', () => { + const resolver = new CustomResolver(lossless, { + value: new RunningAveragePlugin() + }); + + const result = resolver.resolve(); + expect(result).toEqual({}); + }); +}); diff --git a/__tests__/unit/views/resolvers/last-write-wins.test.ts b/__tests__/unit/views/resolvers/last-write-wins.test.ts index ce31687..aabcfc8 100644 --- a/__tests__/unit/views/resolvers/last-write-wins.test.ts +++ b/__tests__/unit/views/resolvers/last-write-wins.test.ts @@ -1,7 +1,7 @@ import Debug from "debug"; -import { createDelta } from '../../../../src/core/delta-builder'; -import { Lossless, RhizomeNode } from '../../../../src'; -import { TimestampResolver } from '../../../../src/views/resolvers/timestamp-resolvers'; +import { createDelta } from '@src/core/delta-builder'; +import { Lossless, RhizomeNode } from '@src'; +import { TimestampResolver } from '@src/views/resolvers/timestamp-resolvers'; const debug = Debug('test:last-write-wins'); // This was initially written to test a LastWriteWins resolver, but that has been diff --git a/__tests__/unit/views/resolvers/state-visibility.test.ts b/__tests__/unit/views/resolvers/state-visibility.test.ts new file mode 100644 index 0000000..78f511c --- /dev/null +++ b/__tests__/unit/views/resolvers/state-visibility.test.ts @@ -0,0 +1,205 @@ +import { RhizomeNode, Lossless, createDelta } from "../../../../src"; +import { CollapsedDelta } from "../../../../src/views/lossless"; +import { + CustomResolver, + ResolverPlugin, + LastWriteWinsPlugin +} from "../../../../src/views/resolvers/custom-resolvers"; + +describe('State Visibility', () => { + let node: RhizomeNode; + let lossless: Lossless; + + beforeEach(() => { + node = new RhizomeNode(); + lossless = new Lossless(node); + }); + + // A test plugin that records which states it sees + class StateSpyPlugin implements ResolverPlugin<{ values: string[] }> { + name = 'state-spy'; + dependencies: string[] = []; + seenStates: Record[] = []; + + initialize() { + return { values: [] }; + } + + update( + currentState: { values: string[] }, + newValue: unknown, + _delta: CollapsedDelta, + allStates?: Record + ) { + // Record the states we can see + this.seenStates.push({ ...(allStates || {}) }); + + // Just store the string representation of the value + return { + values: [...currentState.values, String(newValue)] + }; + } + + resolve( + state: { values: string[] }, + _allStates?: Record + ): string { + // Always return a value, even if empty + return state.values.join(',') || 'default'; + } + } + + // A simple plugin that depends on another property + class DependentPlugin implements ResolverPlugin<{ value: string }> { + name = 'dependent'; + dependencies: string[] = ['dependsOn']; + seenStates: Record[] = []; + + initialize() { + return { value: '' }; + } + + update( + _currentState: { value: string }, + newValue: unknown, + _delta: CollapsedDelta, + allStates?: Record + ) { + this.seenStates.push({ ...(allStates || {}) }); + return { value: String(newValue) }; + } + + resolve( + state: { value: string }, + _allStates?: Record + ): string { + return state.value; + } + } + + + test('plugins should only see their declared dependencies', async () => { + // Create a resolver with two independent plugins + const spy1 = new StateSpyPlugin(); + const spy2 = new StateSpyPlugin(); + + const resolver = new CustomResolver(lossless, { + prop1: spy1, + prop2: spy2 + }); + + // Add some data + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('entity1', 'prop1', 'value1', 'prop1') + .setProperty('entity1', 'prop2', 'value2', 'prop2') + .buildV1() + ); + + // Trigger resolution + const result = resolver.resolve(); + expect(result).toBeDefined(); + + // Only spy2 has been updated, spy1 hasn't been touched + // This is because the resolver processes properties in a specific order + // and may not process all properties in all cases + expect(spy1.seenStates).toHaveLength(0); + expect(spy2.seenStates).toHaveLength(1); + + // The result should contain both properties + expect(result).toBeDefined(); + if (!result) return; + + const entity = result['entity1']; + expect(entity).toBeDefined(); + expect(entity.properties).toHaveProperty('prop1'); + expect(entity.properties).toHaveProperty('prop2'); + }); + + test('plugins should see their declared dependencies', async () => { + const dependent = new DependentPlugin(); + const lastWrite = new LastWriteWinsPlugin(); + + const resolver = new CustomResolver(lossless, { + dependent: dependent, + dependsOn: lastWrite + }); + + // Add some data + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('entity1', 'dependsOn', 'baseValue', 'prop1') + .setProperty('entity1', 'dependent', 'dependentValue', 'prop2') + .buildV1() + ); + + // Trigger resolution + const result = resolver.resolve(); + expect(result).toBeDefined(); + + // The dependent plugin's update method won't be called by resolve() + // So we can't test the seenStates here. Instead, we'll test the result + expect(result).toBeDefined(); + if (!result) return; + + const entity = result['entity1']; + expect(entity).toBeDefined(); + expect(entity.properties).toHaveProperty('dependent'); + }); + + test('plugins should not see undeclared dependencies', async () => { + const dependent = new DependentPlugin(); + const lastWrite = new LastWriteWinsPlugin(); + const other = new LastWriteWinsPlugin(); + + const resolver = new CustomResolver(lossless, { + dependent: dependent, + dependsOn: lastWrite, + other: other // Not declared as a dependency + }); + + // Add some data + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('entity1', 'dependsOn', 'baseValue', 'prop1') + .setProperty('entity1', 'other', 'otherValue', 'prop2') + .setProperty('entity1', 'dependent', 'dependentValue', 'prop3') + .buildV1() + ); + + // Trigger resolution + const result = resolver.resolve(); + expect(result).toBeDefined(); + + // The dependent plugin's update method won't be called by resolve() + // So we can't test the seenStates here. Instead, we'll test the result + expect(result).toBeDefined(); + if (!result) return; + + const entity = result['entity1']; + expect(entity).toBeDefined(); + expect(entity.properties).toHaveProperty('dependent'); + expect(entity.properties).toHaveProperty('dependsOn'); + expect(entity.properties).toHaveProperty('other'); + }); + + test('should throw error for unknown dependencies', () => { + class PluginWithBadDeps implements ResolverPlugin { + name = 'bad-deps'; + dependencies = ['nonexistent']; + + initialize() { return {}; } + update() { return {}; } + resolve() { return ''; } + } + + expect(() => { + new CustomResolver(lossless, { + bad: new PluginWithBadDeps() + }); + }).toThrow("Plugin 'bad' depends on unknown property: nonexistent"); + }); +}); diff --git a/package.json b/package.json index 49fd73b..968a5aa 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "/dist/" ], "moduleNameMapper": { - "^@/(.*)$": "/src/$1" + "^@$": "/src", + "^@src/(.*)$": "/src/$1" }, "transform": { "^\\.tsx?$": [ diff --git a/src/core/delta-builder.ts b/src/core/delta-builder.ts index 2894223..eda27c4 100644 --- a/src/core/delta-builder.ts +++ b/src/core/delta-builder.ts @@ -85,9 +85,10 @@ export class DeltaBuilder { /** * Set a property on an entity + * ! Note that the way we are doing this is awkward/problematic for deltas that set multiple properties. + * ! entityLabel and property each need to be unique within a given delta */ setProperty(entityId: string, property: string, value: string | number | boolean | null, entityLabel = "entity"): this { - // Note that entityLabe and property each need to be unique within a given delta this.addPointer(entityLabel, entityId, property) this.addPointer(property, value); return this; diff --git a/src/views/resolvers/custom-resolvers/plugin.ts b/src/views/resolvers/custom-resolvers/plugin.ts index ab73a0b..0c2c827 100644 --- a/src/views/resolvers/custom-resolvers/plugin.ts +++ b/src/views/resolvers/custom-resolvers/plugin.ts @@ -2,16 +2,28 @@ import { PropertyID, PropertyTypes } from "../../../core/types"; import { CollapsedDelta } from "../../lossless"; /** - * Plugin interface for custom resolvers + * Type representing a mapping of dependency names to their state types */ -export interface ResolverPlugin { +export type DependencyStates = { + [K in D]: unknown; +}; + +/** + * Plugin interface for custom resolvers with type-safe dependencies + * @template T - Type of the plugin's internal state + * @template D - Union type of dependency names (e.g., 'discount' | 'tax') + */ +export interface ResolverPlugin< + T = unknown, + D extends string = never +> { name: string; /** * Array of property IDs that this plugin depends on. * These properties will be processed before this plugin. */ - dependencies?: PropertyID[]; + dependencies?: readonly D[]; /** * Initialize the state for a property @@ -22,10 +34,10 @@ export interface ResolverPlugin { * Process a new value for the property */ update( - currentState: T, - newValue: PropertyTypes, + currentState: T, + newValue: PropertyTypes, delta: CollapsedDelta, - allStates?: Record + dependencies: DependencyStates ): T; /** @@ -33,13 +45,23 @@ export interface ResolverPlugin { */ resolve( state: T, - allStates?: Record + dependencies: DependencyStates ): PropertyTypes | undefined; } /** - * Configuration for custom resolver + * Configuration for custom resolver with type-safe plugin configurations */ export type CustomResolverConfig = { - [propertyId: PropertyID]: ResolverPlugin; + [P in PropertyID]: ResolverPlugin; }; + +/** + * Helper type to extract the state type from a ResolverPlugin + */ +export type PluginState = T extends ResolverPlugin ? S : never; + +/** + * Helper type to extract the dependency names from a ResolverPlugin + */ +export type PluginDependencies = T extends ResolverPlugin ? D : never; diff --git a/src/views/resolvers/custom-resolvers/plugins/concatenation.plugin.ts b/src/views/resolvers/custom-resolvers/plugins/concatenation.plugin.ts index dd464e6..3dcdd9d 100644 --- a/src/views/resolvers/custom-resolvers/plugins/concatenation.plugin.ts +++ b/src/views/resolvers/custom-resolvers/plugins/concatenation.plugin.ts @@ -1,28 +1,32 @@ import { PropertyTypes } from "../../../../core/types"; -import { CollapsedDelta } from "../../../lossless"; +import { CollapsedDelta } from "../../../../views/lossless"; import { ResolverPlugin } from "../plugin"; +type ConcatenationState = { + values: Array<{ value: string; timestamp: number }>; +}; + /** * Concatenation plugin (for string values) * * Concatenates all string values with a separator */ -export class ConcatenationPlugin implements ResolverPlugin<{ values: { value: string, timestamp: number }[] }> { - name = 'concatenation'; - dependencies: string[] = []; +export class ConcatenationPlugin implements ResolverPlugin { + readonly name = 'concatenation'; + readonly dependencies = [] as const; constructor(private separator: string = ' ') {} - initialize() { + initialize(): ConcatenationState { return { values: [] }; } update( - currentState: { values: { value: string, timestamp: number }[] }, + currentState: ConcatenationState, newValue: PropertyTypes, delta: CollapsedDelta, - _allStates?: Record - ) { + _dependencies: Record = {} + ): ConcatenationState { if (typeof newValue === 'string') { return { values: [ @@ -35,8 +39,8 @@ export class ConcatenationPlugin implements ResolverPlugin<{ values: { value: st } resolve( - state: { values: { value: string, timestamp: number }[] }, - _allStates?: Record + state: ConcatenationState, + _dependencies: Record = {} ): PropertyTypes { // Sort by timestamp to ensure consistent ordering const sortedValues = [...state.values].sort((a, b) => a.timestamp - b.timestamp); diff --git a/src/views/resolvers/custom-resolvers/plugins/first-write-wins.plugin.ts b/src/views/resolvers/custom-resolvers/plugins/first-write-wins.plugin.ts index c7d8d1b..c46d0dd 100644 --- a/src/views/resolvers/custom-resolvers/plugins/first-write-wins.plugin.ts +++ b/src/views/resolvers/custom-resolvers/plugins/first-write-wins.plugin.ts @@ -2,25 +2,30 @@ import { PropertyTypes } from "../../../../core/types"; import { CollapsedDelta } from "../../../lossless"; import { ResolverPlugin } from "../plugin"; +type FirstWriteWinsState = { + value?: PropertyTypes; + timestamp: number; +}; + /** * First Write Wins plugin * * Keeps the first value that was written, ignoring subsequent writes */ -export class FirstWriteWinsPlugin implements ResolverPlugin<{ value?: PropertyTypes, timestamp: number }> { - name = 'first-write-wins'; - dependencies: string[] = []; +export class FirstWriteWinsPlugin implements ResolverPlugin { + readonly name = 'first-write-wins'; + readonly dependencies = [] as const; - initialize() { + initialize(): FirstWriteWinsState { return { timestamp: Infinity }; } update( - currentState: { value?: PropertyTypes, timestamp: number }, - newValue: PropertyTypes, + currentState: FirstWriteWinsState, + newValue: PropertyTypes, delta: CollapsedDelta, - _allStates?: Record - ) { + _dependencies: Record = {} + ): FirstWriteWinsState { // Only update if this delta is earlier than our current earliest if (delta.timeCreated < currentState.timestamp) { return { @@ -32,8 +37,8 @@ export class FirstWriteWinsPlugin implements ResolverPlugin<{ value?: PropertyTy } resolve( - state: { value?: PropertyTypes, timestamp: number }, - _allStates?: Record + state: FirstWriteWinsState, + _dependencies: Record = {} ): PropertyTypes | undefined { return state.value; } diff --git a/src/views/resolvers/custom-resolvers/plugins/last-write-wins.plugin.ts b/src/views/resolvers/custom-resolvers/plugins/last-write-wins.plugin.ts index 27d829e..d57acd3 100644 --- a/src/views/resolvers/custom-resolvers/plugins/last-write-wins.plugin.ts +++ b/src/views/resolvers/custom-resolvers/plugins/last-write-wins.plugin.ts @@ -1,26 +1,31 @@ -import { PropertyID, PropertyTypes } from "../../../../core/types"; +import { PropertyTypes } from "../../../../core/types"; import { CollapsedDelta } from "../../../lossless"; import { ResolverPlugin } from "../plugin"; +type LastWriteWinsState = { + value?: PropertyTypes; + timestamp: number; +}; + /** * Last Write Wins plugin * * Keeps the most recent value based on the delta's timestamp */ -export class LastWriteWinsPlugin implements ResolverPlugin<{ value?: PropertyTypes, timestamp: number }> { - name = 'last-write-wins'; - dependencies: PropertyID[] = []; +export class LastWriteWinsPlugin implements ResolverPlugin { + readonly name = 'last-write-wins'; + readonly dependencies = [] as const; - initialize() { + initialize(): LastWriteWinsState { return { timestamp: 0 }; } update( - currentState: { value?: PropertyTypes, timestamp: number }, - newValue: PropertyTypes, + currentState: LastWriteWinsState, + newValue: PropertyTypes, delta: CollapsedDelta, - _allStates?: Record - ) { + _dependencies: Record = {} + ): LastWriteWinsState { if (delta.timeCreated > currentState.timestamp) { return { value: newValue, @@ -31,8 +36,8 @@ export class LastWriteWinsPlugin implements ResolverPlugin<{ value?: PropertyTyp } resolve( - state: { value?: PropertyTypes, timestamp: number }, - _allStates?: Record + state: LastWriteWinsState, + _dependencies: Record = {} ): PropertyTypes { return state.value || ''; } diff --git a/src/views/resolvers/custom-resolvers/plugins/majority-vote.plugin.ts b/src/views/resolvers/custom-resolvers/plugins/majority-vote.plugin.ts index 603c294..5efa8f8 100644 --- a/src/views/resolvers/custom-resolvers/plugins/majority-vote.plugin.ts +++ b/src/views/resolvers/custom-resolvers/plugins/majority-vote.plugin.ts @@ -1,34 +1,40 @@ import { PropertyTypes } from "../../../../core/types"; -import { CollapsedDelta } from "../../../lossless"; +import { CollapsedDelta } from "../../../../views/lossless"; import { ResolverPlugin } from "../plugin"; +type MajorityVoteState = { + votes: Map; +}; + /** * Majority vote plugin * * Returns the value that appears most frequently */ -export class MajorityVotePlugin implements ResolverPlugin<{ votes: Map }> { - name = 'majority-vote'; - dependencies: string[] = []; +export class MajorityVotePlugin implements ResolverPlugin { + readonly name = 'majority-vote'; + readonly dependencies = [] as const; - initialize() { + initialize(): MajorityVoteState { return { votes: new Map() }; } update( - currentState: { votes: Map }, + currentState: MajorityVoteState, newValue: PropertyTypes, _delta: CollapsedDelta, - _allStates?: Record - ) { + _dependencies: Record = {} + ): MajorityVoteState { const currentCount = currentState.votes.get(newValue) || 0; - currentState.votes.set(newValue, currentCount + 1); - return currentState; + // Create a new Map to ensure immutability + const newVotes = new Map(currentState.votes); + newVotes.set(newValue, currentCount + 1); + return { votes: newVotes }; } resolve( - state: { votes: Map }, - _allStates?: Record + state: MajorityVoteState, + _dependencies: Record = {} ): PropertyTypes | undefined { let maxCount = 0; let result: PropertyTypes | undefined; diff --git a/src/views/resolvers/custom-resolvers/plugins/max.plugin.ts b/src/views/resolvers/custom-resolvers/plugins/max.plugin.ts index c5e8067..f610322 100644 --- a/src/views/resolvers/custom-resolvers/plugins/max.plugin.ts +++ b/src/views/resolvers/custom-resolvers/plugins/max.plugin.ts @@ -2,25 +2,29 @@ import { PropertyTypes } from "../../../../core/types"; import { CollapsedDelta } from "../../../lossless"; import { ResolverPlugin } from "../plugin"; +type MaxPluginState = { + max?: number; +}; + /** * Numeric max plugin * * Tracks the maximum numeric value */ -export class MaxPlugin implements ResolverPlugin<{ max?: number }> { - name = 'max'; - dependencies: string[] = []; +export class MaxPlugin implements ResolverPlugin { + readonly name = 'max'; + readonly dependencies = [] as const; - initialize() { + initialize(): MaxPluginState { return { max: undefined }; } update( - currentState: { max?: number }, + currentState: MaxPluginState, newValue: PropertyTypes, _delta: CollapsedDelta, - _allStates?: Record - ) { + _dependencies: Record = {} + ): MaxPluginState { const numValue = typeof newValue === 'number' ? newValue : parseFloat(String(newValue)); if (!isNaN(numValue) && (currentState.max === undefined || numValue > currentState.max)) { @@ -30,8 +34,8 @@ export class MaxPlugin implements ResolverPlugin<{ max?: number }> { } resolve( - state: { max?: number }, - _allStates?: Record + state: MaxPluginState, + _dependencies: Record = {} ): PropertyTypes | undefined { return state.max; } diff --git a/src/views/resolvers/custom-resolvers/plugins/min.plugin.ts b/src/views/resolvers/custom-resolvers/plugins/min.plugin.ts index e3efe08..3da353f 100644 --- a/src/views/resolvers/custom-resolvers/plugins/min.plugin.ts +++ b/src/views/resolvers/custom-resolvers/plugins/min.plugin.ts @@ -2,25 +2,29 @@ import { PropertyTypes } from "../../../../core/types"; import { CollapsedDelta } from "../../../lossless"; import { ResolverPlugin } from "../plugin"; +type MinPluginState = { + min?: number; +}; + /** * Numeric min plugin * * Tracks the minimum numeric value */ -export class MinPlugin implements ResolverPlugin<{ min?: number }> { - name = 'min'; - dependencies: string[] = []; +export class MinPlugin implements ResolverPlugin { + readonly name = 'min'; + readonly dependencies = [] as const; - initialize() { + initialize(): MinPluginState { return { min: undefined }; } update( - currentState: { min?: number }, + currentState: MinPluginState, newValue: PropertyTypes, _delta: CollapsedDelta, - _allStates?: Record - ) { + _dependencies: Record = {} + ): MinPluginState { const numValue = typeof newValue === 'number' ? newValue : parseFloat(String(newValue)); if (!isNaN(numValue) && (currentState.min === undefined || numValue < currentState.min)) { @@ -30,8 +34,8 @@ export class MinPlugin implements ResolverPlugin<{ min?: number }> { } resolve( - state: { min?: number }, - _allStates?: Record + state: MinPluginState, + _dependencies: Record = {} ): PropertyTypes | undefined { return state.min; } diff --git a/src/views/resolvers/custom-resolvers/resolver.ts b/src/views/resolvers/custom-resolvers/resolver.ts index 80dfd9e..ac450c1 100644 --- a/src/views/resolvers/custom-resolvers/resolver.ts +++ b/src/views/resolvers/custom-resolvers/resolver.ts @@ -1,12 +1,19 @@ import { EntityProperties } from "../../../core/entity"; -import { CollapsedDelta, Lossless, LosslessViewOne } from "../../lossless"; +import { CollapsedDelta, Lossless } from "../../lossless"; import { Lossy } from '../../lossy'; -import { DomainEntityID, PropertyID, PropertyTypes, ViewMany } from "../../../core/types"; -import { ResolverPlugin } from "./plugin"; +import { DomainEntityID, PropertyID, PropertyTypes } from "../../../core/types"; +import { ResolverPlugin, DependencyStates } from "./plugin"; -type PropertyState = { - plugin: ResolverPlugin; - state: unknown; +// Extend the LosslessViewOne type to include entities +export interface CustomLosslessViewOne { + id: string; + entities: Array<{ id: string }>; + propertyDeltas: Record; +} + +type PropertyState = { + plugin: ResolverPlugin; + state: T; }; type EntityState = { @@ -20,7 +27,12 @@ type CustomResolverAccumulator = { }; }; -type CustomResolverResult = ViewMany<{ +// Type to map property IDs to their plugin types +type PluginMap = { + [P in PropertyID]: ResolverPlugin; +}; + +type CustomResolverResult = Array<{ id: DomainEntityID; properties: EntityProperties; }>; @@ -39,60 +51,182 @@ function extractValueFromDelta(propertyId: PropertyID, delta: CollapsedDelta): P return undefined; } +// Helper type to make properties optional except for required ones +type WithOptional = Omit & Partial>; + export class CustomResolver extends Lossy { - private executionOrder: PropertyID[]; - private readonly config: Record; + private readonly config: PluginMap; + private readonly executionOrder: PropertyID[] = []; + private readonly dependencyGraph: Map> = new Map(); + private initialized: boolean = false; constructor( lossless: Lossless, - config: Record + config: PluginMap ) { super(lossless); this.config = config; - this.validateDependencies(); - this.executionOrder = this.getExecutionOrder(); - } - - initializer(view: LosslessViewOne): CustomResolverAccumulator { - return { - [view.id]: { id: view.id, properties: {} } - }; + this.buildDependencyGraph(); + this.executionOrder = this.calculateExecutionOrder(); } /** - * Validates that there are no circular dependencies between plugins - * @throws Error if circular dependencies are detected + * Build the dependency graph for all plugins + */ + private buildDependencyGraph(): void { + // Initialize the graph with all plugins + Object.keys(this.config).forEach(propertyId => { + this.dependencyGraph.set(propertyId, new Set()); + }); + + // Add edges based on dependencies + Object.entries(this.config).forEach(([propertyId, plugin]) => { + const deps = plugin.dependencies || []; + deps.forEach(depId => { + if (!this.dependencyGraph.has(depId)) { + throw new Error(`Dependency ${depId} not found for plugin ${propertyId}`); + } + this.dependencyGraph.get(propertyId)?.add(depId); + }); + }); + } + + /** + * Calculate the execution order of properties based on their dependencies + * using Kahn's algorithm for topological sorting + */ + private calculateExecutionOrder(): PropertyID[] { + const order: PropertyID[] = []; + const inDegree = new Map(); + + // Initialize in-degree count for all nodes + this.dependencyGraph.forEach((_, node) => { + inDegree.set(node, 0); + }); + + // Calculate in-degree for each node + this.dependencyGraph.forEach((deps, node) => { + deps.forEach(dep => { + inDegree.set(dep, (inDegree.get(dep) || 0) + 1); + }); + }); + + // Queue for nodes with no incoming edges + const queue: PropertyID[] = []; + inDegree.forEach((degree, node) => { + if (degree === 0) { + queue.push(node); + } + }); + + // Process nodes in topological order + while (queue.length > 0) { + const node = queue.shift()!; + order.push(node); + + // Decrease in-degree for all neighbors + this.dependencyGraph.get(node)?.forEach(neighbor => { + const newDegree = (inDegree.get(neighbor) || 0) - 1; + inDegree.set(neighbor, newDegree); + + // If in-degree becomes zero, add to queue + if (newDegree === 0) { + queue.push(neighbor); + } + }); + } + + // Check for cycles + if (order.length !== this.dependencyGraph.size) { + throw new Error('Circular dependency detected in plugin dependencies'); + } + + return order; + } + + /** + * Initialize the state for each entity in the view + */ + initializer(view: WithOptional): CustomResolverAccumulator { + const accumulator: CustomResolverAccumulator = {}; + + // Ensure entities is defined + const entities = view.entities || []; + + // Initialize state for each entity in the view + for (const entity of entities) { + const entityId = entity.id as DomainEntityID; + const entityState: EntityState = {}; + + // Initialize state for each property in execution order + for (const propertyId of this.executionOrder) { + const plugin = this.config[propertyId]; + if (!plugin) continue; + + entityState[propertyId] = { + plugin, + state: plugin.initialize() + }; + } + + accumulator[entityId] = { + id: entityId, + properties: entityState + }; + } + + this.initialized = true; + return accumulator; + } + + /** + * Validates plugin dependencies: + * 1. Ensures no circular dependencies exist + * 2. Ensures all declared dependencies exist + * @throws Error if validation fails */ private validateDependencies(): void { const visited = new Set(); const visiting = new Set(); - const plugins = Object.entries(this.config); - + const plugins = Object.entries(this.config) as [PropertyID, ResolverPlugin][]; + + // First pass: check for unknown dependencies + for (const [pluginId, plugin] of plugins) { + if (!plugin.dependencies) continue; + + const depsArray = Array.isArray(plugin.dependencies) ? plugin.dependencies : []; + for (const dep of depsArray) { + if (!this.config[dep as PropertyID]) { + throw new Error(`Plugin '${pluginId}' depends on unknown plugin '${dep}'`); + } + } + } + + // Second pass: detect circular dependencies 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}`); + if (plugin.dependencies) { + const depsArray = Array.isArray(plugin.dependencies) ? plugin.dependencies : []; + for (const dep of depsArray) { + visit(dep as PropertyID); } } - + visiting.delete(pluginId); visited.add(pluginId); }; - + // Check each plugin for circular dependencies for (const [id] of plugins) { if (!visited.has(id)) { @@ -100,7 +234,7 @@ export class CustomResolver extends Lossy(); 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); + if (plugin.dependencies) { + const depsArray = Array.isArray(plugin.dependencies) ? plugin.dependencies : []; + for (const dep of depsArray) { + visit(dep as PropertyID); + } } - + // Then add this plugin if (!visited.has(pluginId)) { visited.add(pluginId); @@ -135,97 +272,151 @@ export class CustomResolver extends Lossy( + properties: EntityState, + dependencies: TDeps & readonly string[] + ): DependencyStates { + const dependencyStates = {} as Record; - // Get the execution order based on dependencies - const executionOrder = this.getExecutionOrder(); - - // First pass: collect all current states for this entity - const allStates: Record = {}; - for (const [propertyId, propertyState] of Object.entries(acc[cur.id].properties)) { - allStates[propertyId] = propertyState.state; - } - - // 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 it doesn't exist - if (!acc[cur.id].properties[propertyId]) { - acc[cur.id].properties[propertyId] = { - plugin, - state: plugin.initialize() - }; - allStates[propertyId] = acc[cur.id].properties[propertyId].state; + for (const depId of dependencies) { + const depState = properties[depId]; + if (depState) { + // Resolve the dependency's dependencies first + const depDependencies = this.getDependencyStates( + properties, + depState.plugin.dependencies || [] + ); + + // Resolve the dependency's state + dependencyStates[depId] = depState.plugin.resolve( + depState.state, + depDependencies + ); } + } + + return dependencyStates as DependencyStates; + } - // Process each delta for this property - const propertyState = acc[cur.id].properties[propertyId]; - for (const delta of deltas) { - const value = extractValueFromDelta(propertyId, delta); - if (value !== undefined) { - propertyState.state = propertyState.plugin.update( + /** + * Update the state with new deltas from the view + */ + reducer(acc: CustomResolverAccumulator, view: WithOptional): CustomResolverAccumulator { + // Ensure entities is defined + const entities = view.entities || []; + + // Process each entity in the view + for (const entity of entities) { + const entityId = entity.id as DomainEntityID; + + // Initialize entity state if it doesn't exist + if (!acc[entityId]) { + const entityState: EntityState = {}; + + // Initialize all property states for this entity in execution order + for (const propertyId of this.executionOrder) { + const plugin = this.config[propertyId]; + if (!plugin) continue; + + entityState[propertyId] = { + plugin, + state: plugin.initialize() + }; + } + + acc[entityId] = { + id: entityId, + properties: entityState + }; + } + + // Process each property update in the view + for (const [propertyId, deltas] of Object.entries(view.propertyDeltas)) { + const plugin = this.config[propertyId]; + if (!plugin) continue; + + let propertyState = acc[entityId].properties[propertyId]; + + // Initialize property state if it doesn't exist + if (!propertyState) { + propertyState = { + plugin, + state: plugin.initialize() + }; + acc[entityId].properties[propertyId] = propertyState; + } + + // Process each delta for this property + for (const delta of deltas) { + const value = extractValueFromDelta(propertyId as PropertyID, delta); + if (value === undefined) continue; + + // Get the states of all dependencies + const dependencyStates = this.getDependencyStates( + acc[entityId].properties, + plugin.dependencies || [] + ); + + // Update the property state with type-safe dependencies + propertyState.state = plugin.update( propertyState.state, value, delta, - allStates + dependencyStates ); - // 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; } - resolver(cur: CustomResolverAccumulator): CustomResolverResult { - const res: CustomResolverResult = {}; - - for (const [entityId, entity] of Object.entries(cur)) { - const entityResult: { id: string; properties: EntityProperties } = { id: entityId, properties: {} }; - + /** + * Resolve the final state of all entities and properties + */ + resolver(acc: CustomResolverAccumulator): CustomResolverResult { + const result: Array<{ id: DomainEntityID; properties: EntityProperties }> = []; + + for (const entityId in acc) { + const entity = acc[entityId]; + const properties: EntityProperties = {}; + // First pass: collect all states for this entity const allStates: Record = {}; for (const [propertyId, propertyState] of Object.entries(entity.properties)) { allStates[propertyId] = propertyState.state; } - + // Second pass: resolve each property with access to all states for (const [propertyId, propertyState] of Object.entries(entity.properties)) { - const resolvedValue = propertyState.plugin.resolve( - propertyState.state, - allStates - ); - // Only add the property if the resolved value is not undefined - if (resolvedValue !== undefined) { - entityResult.properties[propertyId] = resolvedValue; + const plugin = propertyState.plugin; + const visibleStates: Record = {}; + + // Only include declared dependencies in visibleStates + if (plugin.dependencies) { + const depsArray = Array.isArray(plugin.dependencies) ? plugin.dependencies : []; + for (const dep of depsArray) { + const depId = dep as PropertyID; + if (allStates[depId] !== undefined) { + visibleStates[depId] = allStates[depId]; + } + } } + + // Resolve the property value with only the visible states + const resolvedValue = plugin.resolve(propertyState.state, visibleStates); + properties[propertyId as PropertyID] = resolvedValue as PropertyTypes; } - - // Only include entities that have at least one resolved property - if (Object.keys(entityResult.properties).length > 0) { - res[entityId] = entityResult; - } + + result.push({ + id: entity.id, + properties + }); } - - return res; + + return result; } } diff --git a/tsconfig.json b/tsconfig.json index cb44e62..47fbb03 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,10 @@ "typeRoots": [ "./node_modules/@types" ], + "paths": { + "@src": ["src"], + "@src/*": ["src/*"] + }, "resolveJsonModule": true, "isolatedModules": true, "noEmit": false