Module alias for @src

This commit is contained in:
Lentil Hoffman 2025-06-23 03:01:44 -05:00
parent ef6efe758f
commit ceb9d9a0cf
Signed by: lentil
GPG Key ID: 0F5B99F3F4D0C087
43 changed files with 1753 additions and 303 deletions

View File

@ -1,5 +1,5 @@
import { createOrchestrator, type NodeConfig } from '../../src/orchestration'; import { createOrchestrator, type NodeConfig } from '@src/orchestration';
import type { NodeHandle } from '../../src/orchestration/types'; import type { NodeHandle } from '@src/orchestration/types';
// Increase test timeout to 30 seconds // Increase test timeout to 30 seconds
jest.setTimeout(30000); jest.setTimeout(30000);

View File

@ -1,6 +1,6 @@
import Debug from 'debug'; import Debug from 'debug';
import { createOrchestrator } from '../../src/orchestration'; import { createOrchestrator } from '@src/orchestration';
import type { NodeConfig, NodeHandle } from '../../src/orchestration'; import type { NodeConfig, NodeHandle } from '@src/orchestration';
// Increase test timeout to 30 seconds // Increase test timeout to 30 seconds
jest.setTimeout(30000); jest.setTimeout(30000);

View File

@ -1,8 +1,8 @@
import Docker from 'dockerode'; import Docker from 'dockerode';
import { describe, it, beforeAll, afterAll, expect, jest } from '@jest/globals'; import { describe, it, beforeAll, afterAll, expect, jest } from '@jest/globals';
import type { NodeConfig, NodeHandle, NodeStatus } from '../../src/orchestration'; import type { NodeConfig, NodeHandle, NodeStatus } from '@src/orchestration';
import { DockerOrchestrator, createOrchestrator } from '../../src/orchestration'; import { DockerOrchestrator, createOrchestrator } from '@src/orchestration';
import { ImageManager } from '../../src/orchestration/docker-orchestrator/managers/image-manager'; import { ImageManager } from '@src/orchestration/docker-orchestrator/managers/image-manager';
import Debug from 'debug'; import Debug from 'debug';
const debug = Debug('rz:test:docker-orchestrator-v2'); const debug = Debug('rz:test:docker-orchestrator-v2');

View File

@ -1,16 +1,16 @@
import { LosslessViewOne } from '../../src/views/lossless'; import { LosslessViewOne } from '@src/views/lossless';
import { import {
SchemaBuilder, SchemaBuilder,
PrimitiveSchemas, PrimitiveSchemas,
ReferenceSchemas, ReferenceSchemas,
ArraySchemas, ArraySchemas,
ObjectSchema ObjectSchema
} from '../../src/schema'; } from '@src/schema';
import { DefaultSchemaRegistry } from '../../src/schema'; import { DefaultSchemaRegistry } from '@src/schema';
import { CommonSchemas } from '../../util/schemas'; import { CommonSchemas } from '../../util/schemas';
import { TypedCollectionImpl, SchemaValidationError } from '../../src/collections'; import { TypedCollectionImpl, SchemaValidationError } from '@src/collections';
import { RhizomeNode } from '../../src/node'; import { RhizomeNode } from '@src/node';
import { createDelta } from '../../src/core/delta-builder'; import { createDelta } from '@src/core/delta-builder';
describe('Schema System', () => { describe('Schema System', () => {
let schemaRegistry: DefaultSchemaRegistry; let schemaRegistry: DefaultSchemaRegistry;

View File

@ -1,6 +1,6 @@
import { MemoryDeltaStorage, LevelDBDeltaStorage, StorageFactory } from '../../src/storage'; import { MemoryDeltaStorage, LevelDBDeltaStorage, StorageFactory } from '@src/storage';
import { createDelta } from '../../src/core/delta-builder'; import { createDelta } from '@src/core/delta-builder';
import { DeltaQueryStorage } from '../../src/storage/interface'; import { DeltaQueryStorage } from '@src/storage/interface';
describe('Delta Storage', () => { describe('Delta Storage', () => {
const testDeltas = [ const testDeltas = [

View File

@ -1,4 +1,4 @@
import { createDelta } from '../../src/core/delta-builder'; import { createDelta } from '@src/core/delta-builder';
import { import {
RhizomeNode, RhizomeNode,
Lossless, Lossless,

View File

@ -9,11 +9,11 @@
*/ */
import Debug from 'debug'; import Debug from 'debug';
import { RhizomeNode } from '../../src/node'; import { RhizomeNode } from '@src/node';
import { createDelta } from '../../src/core'; import { createDelta } from '@src/core';
import { DefaultSchemaRegistry } from '../../src/schema'; import { DefaultSchemaRegistry } from '@src/schema';
import { SchemaBuilder, PrimitiveSchemas, ReferenceSchemas, ArraySchemas } from '../../src/schema'; import { SchemaBuilder, PrimitiveSchemas, ReferenceSchemas, ArraySchemas } from '@src/schema';
import { TypedCollectionImpl } from '../../src/collections'; import { TypedCollectionImpl } from '@src/collections';
const debug = Debug('rz:test:nested-resolution-performance'); const debug = Debug('rz:test:nested-resolution-performance');

View File

@ -4,8 +4,8 @@
* to the original deltas with all pointer relationships preserved. * to the original deltas with all pointer relationships preserved.
*/ */
import { RhizomeNode } from '../../../src/node'; import { RhizomeNode } from '@src/node';
import { createDelta } from '../../../src/core/delta-builder'; import { createDelta } from '@src/core/delta-builder';
describe('Lossless View Compose/Decompose', () => { describe('Lossless View Compose/Decompose', () => {
let node: RhizomeNode; let node: RhizomeNode;

View File

@ -1,8 +1,8 @@
import { createDelta } from '../../../../src/core/delta-builder'; import { createDelta } from '@src/core/delta-builder';
import { DeltaV1, DeltaV2 } from '../../../../src/core/delta'; import { DeltaV1, DeltaV2 } from '@src/core/delta';
import { Lossless } from '../../../../src/views/lossless'; import { Lossless } from '@src/views/lossless';
import { RhizomeNode } from '../../../../src/node'; import { RhizomeNode } from '@src/node';
import { TimestampResolver } from '../../../../src/views/resolvers/timestamp-resolvers'; import { TimestampResolver } from '@src/views/resolvers/timestamp-resolvers';
describe('DeltaBuilder', () => { describe('DeltaBuilder', () => {
const creator = 'creator-123'; const creator = 'creator-123';

View File

@ -1,4 +1,4 @@
import { createDelta } from '../../../../src/core/delta-builder'; import { createDelta } from '@src/core/delta-builder';
import {DeltaV1, DeltaV2} from "../../../../src"; import {DeltaV1, DeltaV2} from "../../../../src";
describe("Delta", () => { describe("Delta", () => {

View File

@ -1,5 +1,5 @@
import { jsonToAst } from '../../../src/utils/json-ast'; import { jsonToAst } from '@src/utils/json-ast';
import { JsonNode } from '../../../src/utils/json-ast/types'; import { JsonNode } from '@src/utils/json-ast/types';
describe('jsonToAst', () => { describe('jsonToAst', () => {
it('should handle primitive values', () => { it('should handle primitive values', () => {

View File

@ -1,8 +1,8 @@
import Debug from 'debug'; import Debug from 'debug';
import { createDelta } from '../../../src/core/delta-builder'; import { createDelta } from '@src/core/delta-builder';
import { NegationHelper } from '../../../src/features'; import { NegationHelper } from '@src/features';
import { RhizomeNode } from '../../../src/node'; import { RhizomeNode } from '@src/node';
import { Lossless } from '../../../src/views'; import { Lossless } from '@src/views';
const debug = Debug('rz:negation:test'); const debug = Debug('rz:negation:test');

View File

@ -1,4 +1,4 @@
import {parseAddressList, PeerAddress} from '../../../src/network/peers'; import {parseAddressList, PeerAddress} from '@src/network/peers';
describe('PeerAddress', () => { describe('PeerAddress', () => {
it('toString()', () => { it('toString()', () => {

View File

@ -1,7 +1,7 @@
import { createDelta } from '../../../src/core/delta-builder'; import { createDelta } from '@src/core/delta-builder';
import { Lossless } from '../../../src/views'; import { Lossless } from '@src/views';
import { RhizomeNode } from '../../../src/node'; import { RhizomeNode } from '@src/node';
import { DeltaFilter } from '../../../src/core'; import { DeltaFilter } from '@src/core';
describe('Transactions', () => { describe('Transactions', () => {
let node: RhizomeNode; let node: RhizomeNode;

View File

@ -1,10 +1,10 @@
import { QueryEngine } from '../../../src/query'; import { QueryEngine } from '@src/query';
import { Lossless } from '../../../src/views'; import { Lossless } from '@src/views';
import { DefaultSchemaRegistry } from '../../../src/schema'; import { DefaultSchemaRegistry } from '@src/schema';
import { SchemaBuilder, PrimitiveSchemas } from '../../../src/schema'; import { SchemaBuilder, PrimitiveSchemas } from '@src/schema';
import { CommonSchemas } from '../../../util/schemas'; import { CommonSchemas } from '../../../util/schemas';
import { createDelta } from '../../../src/core/delta-builder'; import { createDelta } from '@src/core/delta-builder';
import { RhizomeNode } from '../../../src/node'; import { RhizomeNode } from '@src/node';
describe('Query Engine', () => { describe('Query Engine', () => {
let queryEngine: QueryEngine; let queryEngine: QueryEngine;

View File

@ -1,7 +1,7 @@
import {DeltaFilter} from '../../../src/core'; import {DeltaFilter} from '@src/core';
import {Lossless} from '../../../src/views'; import {Lossless} from '@src/views';
import {RhizomeNode} from '../../../src/node'; import {RhizomeNode} from '@src/node';
import {createDelta} from '../../../src/core/delta-builder'; import {createDelta} from '@src/core/delta-builder';
describe('Lossless', () => { describe('Lossless', () => {
const node = new RhizomeNode(); const node = new RhizomeNode();

View File

@ -4,11 +4,11 @@
* between multiple entities and includes scalar values. * between multiple entities and includes scalar values.
*/ */
import { RhizomeNode } from '../../../src/node'; import { RhizomeNode } from '@src/node';
import { createDelta } from '../../../src/core/delta-builder'; import { createDelta } from '@src/core/delta-builder';
import { DefaultSchemaRegistry } from '../../../src/schema'; import { DefaultSchemaRegistry } from '@src/schema';
import { SchemaBuilder, PrimitiveSchemas, ReferenceSchemas, SchemaAppliedViewWithNesting } from '../../../src/schema'; import { SchemaBuilder, PrimitiveSchemas, ReferenceSchemas, SchemaAppliedViewWithNesting } from '@src/schema';
import { TypedCollectionImpl } from '../../../src/collections'; import { TypedCollectionImpl } from '@src/collections';
describe('Multi-Pointer Delta Resolution', () => { describe('Multi-Pointer Delta Resolution', () => {
let node: RhizomeNode; let node: RhizomeNode;

View File

@ -9,12 +9,12 @@
* - Deep nesting scenarios * - Deep nesting scenarios
*/ */
import { RhizomeNode } from '../../../src/node'; import { RhizomeNode } from '@src/node';
import { DefaultSchemaRegistry } from '../../../src/schema'; import { DefaultSchemaRegistry } from '@src/schema';
import { SchemaBuilder, PrimitiveSchemas, ReferenceSchemas } from '../../../src/schema'; import { SchemaBuilder, PrimitiveSchemas, ReferenceSchemas } from '@src/schema';
import { CommonSchemas } from '../../../util/schemas'; import { CommonSchemas } from '../../../util/schemas';
import { TypedCollectionImpl } from '../../../src/collections'; import { TypedCollectionImpl } from '@src/collections';
import { createDelta } from '../../../src/core/delta-builder'; import { createDelta } from '@src/core/delta-builder';
describe('Nested Object Resolution', () => { describe('Nested Object Resolution', () => {
let node: RhizomeNode; let node: RhizomeNode;

View File

@ -1,8 +1,15 @@
import { describe, test, expect, beforeEach } from '@jest/globals';
import { RhizomeNode, Lossless, createDelta } from "../../../../src"; import { RhizomeNode, Lossless, createDelta } from "../../../../src";
import { CollapsedDelta } from "../../../../src/views/lossless"; import { CollapsedDelta } from "../../../../src/views/lossless";
import { import {
CustomResolver, 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, LastWriteWinsPlugin,
FirstWriteWinsPlugin, FirstWriteWinsPlugin,
ConcatenationPlugin, ConcatenationPlugin,
@ -327,20 +334,69 @@ describe('Custom Resolvers', () => {
describe('Plugin Dependencies', () => { describe('Plugin Dependencies', () => {
test('should detect circular dependencies', () => { test('should detect circular dependencies', () => {
class PluginA implements ResolverPlugin { // Define state interfaces
name = 'a'; interface PluginAState {
dependencies = ['b']; value: string;
initialize() { return {}; }
update() { return {}; }
resolve() { return 'a'; }
} }
class PluginB implements ResolverPlugin { interface PluginBState {
name = 'b'; value: string;
dependencies = ['a']; }
initialize() { return {}; }
update() { return {}; } // PluginA depends on PluginB
resolve() { return 'b'; } 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(() => { expect(() => {
@ -351,27 +407,28 @@ describe('Custom Resolvers', () => {
}).toThrow('Circular dependency detected'); }).toThrow('Circular dependency detected');
}); });
test('should process plugins in dependency order', () => { describe('CustomResolver with plugin dependencies', () => {
// Enable debug logging for this test let lossless: LosslessViewOne;
process.env.DEBUG = 'rz:*';
// Track plugin execution order
const executionOrder: string[] = []; const executionOrder: string[] = [];
// Create test plugins with dependency tracking // Track plugin state updates and resolutions
const pluginTracker = { const pluginTracker = {
first: { updated: false, resolved: false }, first: { updated: false, resolved: false },
second: { updated: false, resolved: false } second: { updated: false, resolved: false }
}; };
// Define plugin state interface
interface PluginState { interface PluginState {
value: string; value: string;
updated: boolean; updated: boolean;
resolved: boolean; resolved: boolean;
} }
class FirstPlugin implements ResolverPlugin<PluginState> { class FirstPlugin implements ResolverPlugin<PluginState, never> {
name = 'first'; readonly name = 'first' as const;
dependencies: string[] = []; readonly dependencies = [] as const;
initialize(): PluginState { initialize(): PluginState {
console.log('First plugin initialized'); console.log('First plugin initialized');
@ -380,22 +437,22 @@ describe('Custom Resolvers', () => {
} }
update( update(
state: PluginState, currentState: PluginState,
value: unknown, newValue: PropertyTypes,
_delta?: unknown, _delta: CollapsedDelta,
_allStates?: Record<string, unknown> _dependencies: Record<string, never> = {}
): PluginState { ): PluginState {
console.log('First plugin updated with value:', value); console.log('First plugin updated with value:', newValue);
executionOrder.push('first-update'); executionOrder.push('first-update');
pluginTracker.first.updated = true; pluginTracker.first.updated = true;
return { return {
...state, ...currentState,
value: String(value), value: String(newValue),
updated: true updated: true
}; };
} }
resolve(state: PluginState, _allStates?: Record<string, unknown>): string { resolve(state: PluginState): string {
console.log('First plugin resolved with value:', state.value); console.log('First plugin resolved with value:', state.value);
executionOrder.push('first-resolve'); executionOrder.push('first-resolve');
pluginTracker.first.resolved = true; pluginTracker.first.resolved = true;
@ -403,9 +460,9 @@ describe('Custom Resolvers', () => {
} }
} }
class SecondPlugin implements ResolverPlugin<PluginState> { class SecondPlugin implements ResolverPlugin<PluginState, 'first'> {
name = 'second'; readonly name = 'second' as const;
dependencies: string[] = ['first']; readonly dependencies = ['first'] as const;
initialize(): PluginState { initialize(): PluginState {
console.log('Second plugin initialized'); console.log('Second plugin initialized');
@ -414,30 +471,30 @@ describe('Custom Resolvers', () => {
} }
update( update(
state: PluginState, currentState: PluginState,
value: unknown, newValue: PropertyTypes,
_delta?: unknown, _delta: CollapsedDelta,
allStates?: Record<string, unknown> dependencies: { first: unknown }
): PluginState { ): PluginState {
console.log('Second plugin updated with value:', value); console.log('Second plugin updated with value:', newValue);
executionOrder.push('second-update'); executionOrder.push('second-update');
pluginTracker.second.updated = true; pluginTracker.second.updated = true;
// Check if we have access to first plugin's state // Access the first plugin's resolved state
const firstState = allStates?.first as PluginState | undefined; const firstState = dependencies.first as PluginState;
if (firstState) { if (firstState) {
executionOrder.push('second-has-first-state'); executionOrder.push('second-has-first-state');
console.log('Second plugin has access to first plugin state:', firstState); console.log('Second plugin has access to first plugin state:', firstState);
} }
return { return {
...state, ...currentState,
value: `${value}-${firstState?.value || 'unknown'}`, value: `${newValue}-${firstState?.value || 'unknown'}`,
updated: true updated: true
}; };
} }
resolve(state: PluginState, _allStates?: Record<string, unknown>): string { resolve(state: PluginState): string {
console.log('Second plugin resolved with value:', state.value); console.log('Second plugin resolved with value:', state.value);
executionOrder.push('second-resolve'); executionOrder.push('second-resolve');
pluginTracker.second.resolved = true; pluginTracker.second.resolved = true;
@ -448,19 +505,15 @@ describe('Custom Resolvers', () => {
// Create resolver with dependency order: first -> second // Create resolver with dependency order: first -> second
console.log('Creating resolver with plugins'); console.log('Creating resolver with plugins');
// Create resolver with test plugins first // Create test plugins
const firstPlugin = new FirstPlugin(); const firstPlugin = new FirstPlugin();
const secondPlugin = new SecondPlugin(); const secondPlugin = new SecondPlugin();
const testResolver = new CustomResolver(lossless, { // Create resolver with test plugins
const testResolver = new CustomResolver({
first: firstPlugin, first: firstPlugin,
second: secondPlugin 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 // Verify the execution order array is empty before processing
expect(executionOrder).not.toContain('first-init'); expect(executionOrder).not.toContain('first-init');
expect(executionOrder).not.toContain('second-init'); expect(executionOrder).not.toContain('second-init');
@ -546,6 +599,7 @@ describe('Custom Resolvers', () => {
// A plugin that applies a discount to a price // A plugin that applies a discount to a price
class DiscountedPricePlugin implements ResolverPlugin<{ price: number }> { class DiscountedPricePlugin implements ResolverPlugin<{ price: number }> {
name = 'discounted-price'; name = 'discounted-price';
dependencies = ['discount'];
initialize() { initialize() {
return { price: 0 }; return { price: 0 };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import Debug from "debug"; import Debug from "debug";
import { createDelta } from '../../../../src/core/delta-builder'; import { createDelta } from '@src/core/delta-builder';
import { Lossless, RhizomeNode } from '../../../../src'; import { Lossless, RhizomeNode } from '@src';
import { TimestampResolver } from '../../../../src/views/resolvers/timestamp-resolvers'; import { TimestampResolver } from '@src/views/resolvers/timestamp-resolvers';
const debug = Debug('test:last-write-wins'); const debug = Debug('test:last-write-wins');
// This was initially written to test a LastWriteWins resolver, but that has been // This was initially written to test a LastWriteWins resolver, but that has been

View 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");
});
});

View File

@ -27,7 +27,8 @@
"/dist/" "/dist/"
], ],
"moduleNameMapper": { "moduleNameMapper": {
"^@/(.*)$": "<rootDir>/src/$1" "^@$": "<rootDir>/src",
"^@src/(.*)$": "<rootDir>/src/$1"
}, },
"transform": { "transform": {
"^\\.tsx?$": [ "^\\.tsx?$": [

View File

@ -85,9 +85,10 @@ export class DeltaBuilder {
/** /**
* Set a property on an entity * 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 { 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(entityLabel, entityId, property)
this.addPointer(property, value); this.addPointer(property, value);
return this; return this;

View File

@ -2,16 +2,28 @@ import { PropertyID, PropertyTypes } from "../../../core/types";
import { CollapsedDelta } from "../../lossless"; 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; name: string;
/** /**
* Array of property IDs that this plugin depends on. * Array of property IDs that this plugin depends on.
* These properties will be processed before this plugin. * These properties will be processed before this plugin.
*/ */
dependencies?: PropertyID[]; dependencies?: readonly D[];
/** /**
* Initialize the state for a property * Initialize the state for a property
@ -25,7 +37,7 @@ export interface ResolverPlugin<T = unknown> {
currentState: T, currentState: T,
newValue: PropertyTypes, newValue: PropertyTypes,
delta: CollapsedDelta, delta: CollapsedDelta,
allStates?: Record<PropertyID, unknown> dependencies: DependencyStates<D>
): T; ): T;
/** /**
@ -33,13 +45,23 @@ export interface ResolverPlugin<T = unknown> {
*/ */
resolve( resolve(
state: T, state: T,
allStates?: Record<PropertyID, unknown> dependencies: DependencyStates<D>
): PropertyTypes | undefined; ): PropertyTypes | undefined;
} }
/** /**
* Configuration for custom resolver * Configuration for custom resolver with type-safe plugin configurations
*/ */
export type CustomResolverConfig = { 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;

View File

@ -1,28 +1,32 @@
import { PropertyTypes } from "../../../../core/types"; import { PropertyTypes } from "../../../../core/types";
import { CollapsedDelta } from "../../../lossless"; import { CollapsedDelta } from "../../../../views/lossless";
import { ResolverPlugin } from "../plugin"; import { ResolverPlugin } from "../plugin";
type ConcatenationState = {
values: Array<{ value: string; timestamp: number }>;
};
/** /**
* Concatenation plugin (for string values) * Concatenation plugin (for string values)
* *
* Concatenates all string values with a separator * Concatenates all string values with a separator
*/ */
export class ConcatenationPlugin implements ResolverPlugin<{ values: { value: string, timestamp: number }[] }> { export class ConcatenationPlugin implements ResolverPlugin<ConcatenationState> {
name = 'concatenation'; readonly name = 'concatenation';
dependencies: string[] = []; readonly dependencies = [] as const;
constructor(private separator: string = ' ') {} constructor(private separator: string = ' ') {}
initialize() { initialize(): ConcatenationState {
return { values: [] }; return { values: [] };
} }
update( update(
currentState: { values: { value: string, timestamp: number }[] }, currentState: ConcatenationState,
newValue: PropertyTypes, newValue: PropertyTypes,
delta: CollapsedDelta, delta: CollapsedDelta,
_allStates?: Record<string, unknown> _dependencies: Record<string, never> = {}
) { ): ConcatenationState {
if (typeof newValue === 'string') { if (typeof newValue === 'string') {
return { return {
values: [ values: [
@ -35,8 +39,8 @@ export class ConcatenationPlugin implements ResolverPlugin<{ values: { value: st
} }
resolve( resolve(
state: { values: { value: string, timestamp: number }[] }, state: ConcatenationState,
_allStates?: Record<string, unknown> _dependencies: Record<string, never> = {}
): PropertyTypes { ): PropertyTypes {
// Sort by timestamp to ensure consistent ordering // Sort by timestamp to ensure consistent ordering
const sortedValues = [...state.values].sort((a, b) => a.timestamp - b.timestamp); const sortedValues = [...state.values].sort((a, b) => a.timestamp - b.timestamp);

View File

@ -2,25 +2,30 @@ import { PropertyTypes } from "../../../../core/types";
import { CollapsedDelta } from "../../../lossless"; import { CollapsedDelta } from "../../../lossless";
import { ResolverPlugin } from "../plugin"; import { ResolverPlugin } from "../plugin";
type FirstWriteWinsState = {
value?: PropertyTypes;
timestamp: number;
};
/** /**
* First Write Wins plugin * First Write Wins plugin
* *
* Keeps the first value that was written, ignoring subsequent writes * Keeps the first value that was written, ignoring subsequent writes
*/ */
export class FirstWriteWinsPlugin implements ResolverPlugin<{ value?: PropertyTypes, timestamp: number }> { export class FirstWriteWinsPlugin implements ResolverPlugin<FirstWriteWinsState> {
name = 'first-write-wins'; readonly name = 'first-write-wins';
dependencies: string[] = []; readonly dependencies = [] as const;
initialize() { initialize(): FirstWriteWinsState {
return { timestamp: Infinity }; return { timestamp: Infinity };
} }
update( update(
currentState: { value?: PropertyTypes, timestamp: number }, currentState: FirstWriteWinsState,
newValue: PropertyTypes, newValue: PropertyTypes,
delta: CollapsedDelta, delta: CollapsedDelta,
_allStates?: Record<string, unknown> _dependencies: Record<string, never> = {}
) { ): FirstWriteWinsState {
// Only update if this delta is earlier than our current earliest // Only update if this delta is earlier than our current earliest
if (delta.timeCreated < currentState.timestamp) { if (delta.timeCreated < currentState.timestamp) {
return { return {
@ -32,8 +37,8 @@ export class FirstWriteWinsPlugin implements ResolverPlugin<{ value?: PropertyTy
} }
resolve( resolve(
state: { value?: PropertyTypes, timestamp: number }, state: FirstWriteWinsState,
_allStates?: Record<string, unknown> _dependencies: Record<string, never> = {}
): PropertyTypes | undefined { ): PropertyTypes | undefined {
return state.value; return state.value;
} }

View File

@ -1,26 +1,31 @@
import { PropertyID, PropertyTypes } from "../../../../core/types"; import { PropertyTypes } from "../../../../core/types";
import { CollapsedDelta } from "../../../lossless"; import { CollapsedDelta } from "../../../lossless";
import { ResolverPlugin } from "../plugin"; import { ResolverPlugin } from "../plugin";
type LastWriteWinsState = {
value?: PropertyTypes;
timestamp: number;
};
/** /**
* Last Write Wins plugin * Last Write Wins plugin
* *
* Keeps the most recent value based on the delta's timestamp * Keeps the most recent value based on the delta's timestamp
*/ */
export class LastWriteWinsPlugin implements ResolverPlugin<{ value?: PropertyTypes, timestamp: number }> { export class LastWriteWinsPlugin implements ResolverPlugin<LastWriteWinsState> {
name = 'last-write-wins'; readonly name = 'last-write-wins';
dependencies: PropertyID[] = []; readonly dependencies = [] as const;
initialize() { initialize(): LastWriteWinsState {
return { timestamp: 0 }; return { timestamp: 0 };
} }
update( update(
currentState: { value?: PropertyTypes, timestamp: number }, currentState: LastWriteWinsState,
newValue: PropertyTypes, newValue: PropertyTypes,
delta: CollapsedDelta, delta: CollapsedDelta,
_allStates?: Record<string, unknown> _dependencies: Record<string, never> = {}
) { ): LastWriteWinsState {
if (delta.timeCreated > currentState.timestamp) { if (delta.timeCreated > currentState.timestamp) {
return { return {
value: newValue, value: newValue,
@ -31,8 +36,8 @@ export class LastWriteWinsPlugin implements ResolverPlugin<{ value?: PropertyTyp
} }
resolve( resolve(
state: { value?: PropertyTypes, timestamp: number }, state: LastWriteWinsState,
_allStates?: Record<string, unknown> _dependencies: Record<string, never> = {}
): PropertyTypes { ): PropertyTypes {
return state.value || ''; return state.value || '';
} }

View File

@ -1,34 +1,40 @@
import { PropertyTypes } from "../../../../core/types"; import { PropertyTypes } from "../../../../core/types";
import { CollapsedDelta } from "../../../lossless"; import { CollapsedDelta } from "../../../../views/lossless";
import { ResolverPlugin } from "../plugin"; import { ResolverPlugin } from "../plugin";
type MajorityVoteState = {
votes: Map<PropertyTypes, number>;
};
/** /**
* Majority vote plugin * Majority vote plugin
* *
* Returns the value that appears most frequently * Returns the value that appears most frequently
*/ */
export class MajorityVotePlugin implements ResolverPlugin<{ votes: Map<PropertyTypes, number> }> { export class MajorityVotePlugin implements ResolverPlugin<MajorityVoteState> {
name = 'majority-vote'; readonly name = 'majority-vote';
dependencies: string[] = []; readonly dependencies = [] as const;
initialize() { initialize(): MajorityVoteState {
return { votes: new Map<PropertyTypes, number>() }; return { votes: new Map<PropertyTypes, number>() };
} }
update( update(
currentState: { votes: Map<PropertyTypes, number> }, currentState: MajorityVoteState,
newValue: PropertyTypes, newValue: PropertyTypes,
_delta: CollapsedDelta, _delta: CollapsedDelta,
_allStates?: Record<string, unknown> _dependencies: Record<string, never> = {}
) { ): MajorityVoteState {
const currentCount = currentState.votes.get(newValue) || 0; const currentCount = currentState.votes.get(newValue) || 0;
currentState.votes.set(newValue, currentCount + 1); // Create a new Map to ensure immutability
return currentState; const newVotes = new Map(currentState.votes);
newVotes.set(newValue, currentCount + 1);
return { votes: newVotes };
} }
resolve( resolve(
state: { votes: Map<PropertyTypes, number> }, state: MajorityVoteState,
_allStates?: Record<string, unknown> _dependencies: Record<string, never> = {}
): PropertyTypes | undefined { ): PropertyTypes | undefined {
let maxCount = 0; let maxCount = 0;
let result: PropertyTypes | undefined; let result: PropertyTypes | undefined;

View File

@ -2,25 +2,29 @@ import { PropertyTypes } from "../../../../core/types";
import { CollapsedDelta } from "../../../lossless"; import { CollapsedDelta } from "../../../lossless";
import { ResolverPlugin } from "../plugin"; import { ResolverPlugin } from "../plugin";
type MaxPluginState = {
max?: number;
};
/** /**
* Numeric max plugin * Numeric max plugin
* *
* Tracks the maximum numeric value * Tracks the maximum numeric value
*/ */
export class MaxPlugin implements ResolverPlugin<{ max?: number }> { export class MaxPlugin implements ResolverPlugin<MaxPluginState> {
name = 'max'; readonly name = 'max';
dependencies: string[] = []; readonly dependencies = [] as const;
initialize() { initialize(): MaxPluginState {
return { max: undefined }; return { max: undefined };
} }
update( update(
currentState: { max?: number }, currentState: MaxPluginState,
newValue: PropertyTypes, newValue: PropertyTypes,
_delta: CollapsedDelta, _delta: CollapsedDelta,
_allStates?: Record<string, unknown> _dependencies: Record<string, never> = {}
) { ): MaxPluginState {
const numValue = typeof newValue === 'number' ? newValue : parseFloat(String(newValue)); const numValue = typeof newValue === 'number' ? newValue : parseFloat(String(newValue));
if (!isNaN(numValue) && (currentState.max === undefined || numValue > currentState.max)) { if (!isNaN(numValue) && (currentState.max === undefined || numValue > currentState.max)) {
@ -30,8 +34,8 @@ export class MaxPlugin implements ResolverPlugin<{ max?: number }> {
} }
resolve( resolve(
state: { max?: number }, state: MaxPluginState,
_allStates?: Record<string, unknown> _dependencies: Record<string, never> = {}
): PropertyTypes | undefined { ): PropertyTypes | undefined {
return state.max; return state.max;
} }

View File

@ -2,25 +2,29 @@ import { PropertyTypes } from "../../../../core/types";
import { CollapsedDelta } from "../../../lossless"; import { CollapsedDelta } from "../../../lossless";
import { ResolverPlugin } from "../plugin"; import { ResolverPlugin } from "../plugin";
type MinPluginState = {
min?: number;
};
/** /**
* Numeric min plugin * Numeric min plugin
* *
* Tracks the minimum numeric value * Tracks the minimum numeric value
*/ */
export class MinPlugin implements ResolverPlugin<{ min?: number }> { export class MinPlugin implements ResolverPlugin<MinPluginState> {
name = 'min'; readonly name = 'min';
dependencies: string[] = []; readonly dependencies = [] as const;
initialize() { initialize(): MinPluginState {
return { min: undefined }; return { min: undefined };
} }
update( update(
currentState: { min?: number }, currentState: MinPluginState,
newValue: PropertyTypes, newValue: PropertyTypes,
_delta: CollapsedDelta, _delta: CollapsedDelta,
_allStates?: Record<string, unknown> _dependencies: Record<string, never> = {}
) { ): MinPluginState {
const numValue = typeof newValue === 'number' ? newValue : parseFloat(String(newValue)); const numValue = typeof newValue === 'number' ? newValue : parseFloat(String(newValue));
if (!isNaN(numValue) && (currentState.min === undefined || numValue < currentState.min)) { if (!isNaN(numValue) && (currentState.min === undefined || numValue < currentState.min)) {
@ -30,8 +34,8 @@ export class MinPlugin implements ResolverPlugin<{ min?: number }> {
} }
resolve( resolve(
state: { min?: number }, state: MinPluginState,
_allStates?: Record<string, unknown> _dependencies: Record<string, never> = {}
): PropertyTypes | undefined { ): PropertyTypes | undefined {
return state.min; return state.min;
} }

View File

@ -1,12 +1,19 @@
import { EntityProperties } from "../../../core/entity"; import { EntityProperties } from "../../../core/entity";
import { CollapsedDelta, Lossless, LosslessViewOne } from "../../lossless"; import { CollapsedDelta, Lossless } from "../../lossless";
import { Lossy } from '../../lossy'; import { Lossy } from '../../lossy';
import { DomainEntityID, PropertyID, PropertyTypes, ViewMany } from "../../../core/types"; import { DomainEntityID, PropertyID, PropertyTypes } from "../../../core/types";
import { ResolverPlugin } from "./plugin"; import { ResolverPlugin, DependencyStates } from "./plugin";
type PropertyState = { // Extend the LosslessViewOne type to include entities
plugin: ResolverPlugin; export interface CustomLosslessViewOne {
state: unknown; 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 = { 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; id: DomainEntityID;
properties: EntityProperties; properties: EntityProperties;
}>; }>;
@ -39,35 +51,158 @@ function extractValueFromDelta(propertyId: PropertyID, delta: CollapsedDelta): P
return undefined; 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> { export class CustomResolver extends Lossy<CustomResolverAccumulator, CustomResolverResult> {
private executionOrder: PropertyID[]; private readonly config: PluginMap;
private readonly config: Record<PropertyID, ResolverPlugin>; private readonly executionOrder: PropertyID[] = [];
private readonly dependencyGraph: Map<PropertyID, Set<PropertyID>> = new Map();
private initialized: boolean = false;
constructor( constructor(
lossless: Lossless, lossless: Lossless,
config: Record<PropertyID, ResolverPlugin> config: PluginMap
) { ) {
super(lossless); super(lossless);
this.config = config; this.config = config;
this.validateDependencies(); this.buildDependencyGraph();
this.executionOrder = this.getExecutionOrder(); this.executionOrder = this.calculateExecutionOrder();
}
initializer(view: LosslessViewOne): CustomResolverAccumulator {
return {
[view.id]: { id: view.id, properties: {} }
};
} }
/** /**
* Validates that there are no circular dependencies between plugins * Build the dependency graph for all plugins
* @throws Error if circular dependencies are detected */
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 { private validateDependencies(): void {
const visited = new Set<PropertyID>(); const visited = new Set<PropertyID>();
const visiting = 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 => { const visit = (pluginId: PropertyID): void => {
if (visiting.has(pluginId)) { if (visiting.has(pluginId)) {
throw new Error(`Circular dependency detected involving property: ${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]; const plugin = this.config[pluginId];
// Visit all dependencies first // Visit all dependencies first
for (const dep of plugin?.dependencies || []) { if (plugin.dependencies) {
if (this.config[dep]) { const depsArray = Array.isArray(plugin.dependencies) ? plugin.dependencies : [];
visit(dep); for (const dep of depsArray) {
} else { visit(dep as PropertyID);
throw new Error(`Plugin '${pluginId}' depends on unknown property: ${dep}`);
} }
} }
@ -116,8 +250,11 @@ export class CustomResolver extends Lossy<CustomResolverAccumulator, CustomResol
if (!plugin) return; if (!plugin) return;
// Visit dependencies first // Visit dependencies first
for (const dep of plugin.dependencies || []) { if (plugin.dependencies) {
visit(dep); const depsArray = Array.isArray(plugin.dependencies) ? plugin.dependencies : [];
for (const dep of depsArray) {
visit(dep as PropertyID);
}
} }
// Then add this plugin // Then add this plugin
@ -135,72 +272,117 @@ export class CustomResolver extends Lossy<CustomResolverAccumulator, CustomResol
return order; return order;
} }
public reducer( /**
acc: CustomResolverAccumulator, * Get the resolved states of all dependencies for a plugin
cur: LosslessViewOne */
): CustomResolverAccumulator { private getDependencyStates<TDeps extends readonly string[]>(
if (!acc[cur.id]) { properties: EntityState,
acc[cur.id] = { id: cur.id, properties: {} }; 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 return dependencyStates as DependencyStates<TDeps>;
const executionOrder = this.getExecutionOrder();
// First pass: collect all current states for this entity
const allStates: Record<PropertyID, unknown> = {};
for (const [propertyId, propertyState] of Object.entries(acc[cur.id].properties)) {
allStates[propertyId] = propertyState.state;
} }
// Process each property in dependency order /**
for (const propertyId of executionOrder) { * Update the state with new deltas from the view
const deltas = cur.propertyDeltas[propertyId]; */
if (!deltas) continue; reducer(acc: CustomResolverAccumulator, view: WithOptional<CustomLosslessViewOne, 'entities'>): 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]; const plugin = this.config[propertyId];
if (!plugin) continue; if (!plugin) continue;
// Initialize property state if it doesn't exist entityState[propertyId] = {
if (!acc[cur.id].properties[propertyId]) {
acc[cur.id].properties[propertyId] = {
plugin, plugin,
state: plugin.initialize() state: plugin.initialize()
}; };
allStates[propertyId] = acc[cur.id].properties[propertyId].state; }
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 // Process each delta for this property
const propertyState = acc[cur.id].properties[propertyId];
for (const delta of deltas) { for (const delta of deltas) {
const value = extractValueFromDelta(propertyId, delta); const value = extractValueFromDelta(propertyId as PropertyID, delta);
if (value !== undefined) { if (value === undefined) continue;
propertyState.state = propertyState.plugin.update(
// 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, propertyState.state,
value, value,
delta, 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; 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)) { for (const entityId in acc) {
const entityResult: { id: string; properties: EntityProperties } = { id: entityId, properties: {} }; const entity = acc[entityId];
const properties: EntityProperties = {};
// First pass: collect all states for this entity // First pass: collect all states for this entity
const allStates: Record<PropertyID, unknown> = {}; 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 // Second pass: resolve each property with access to all states
for (const [propertyId, propertyState] of Object.entries(entity.properties)) { for (const [propertyId, propertyState] of Object.entries(entity.properties)) {
const resolvedValue = propertyState.plugin.resolve( const plugin = propertyState.plugin;
propertyState.state, const visibleStates: Record<PropertyID, unknown> = {};
allStates
); // Only include declared dependencies in visibleStates
// Only add the property if the resolved value is not undefined if (plugin.dependencies) {
if (resolvedValue !== undefined) { const depsArray = Array.isArray(plugin.dependencies) ? plugin.dependencies : [];
entityResult.properties[propertyId] = resolvedValue; for (const dep of depsArray) {
const depId = dep as PropertyID;
if (allStates[depId] !== undefined) {
visibleStates[depId] = allStates[depId];
}
} }
} }
// Only include entities that have at least one resolved property // Resolve the property value with only the visible states
if (Object.keys(entityResult.properties).length > 0) { const resolvedValue = plugin.resolve(propertyState.state, visibleStates);
res[entityId] = entityResult; properties[propertyId as PropertyID] = resolvedValue as PropertyTypes;
}
} }
return res; result.push({
id: entity.id,
properties
});
}
return result;
} }
} }

View File

@ -16,6 +16,10 @@
"typeRoots": [ "typeRoots": [
"./node_modules/@types" "./node_modules/@types"
], ],
"paths": {
"@src": ["src"],
"@src/*": ["src/*"]
},
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": false "noEmit": false