Maintinance to standardize some code structure and conventions #5

Merged
lentil merged 28 commits from chore/major-cleanup into main 2025-07-08 14:18:59 -05:00
47 changed files with 596 additions and 519 deletions
Showing only changes of commit f79a6921fc - Show all commits

View File

@ -4,10 +4,12 @@ import { Delta } from '@src/core/delta';
import { createDelta } from '@src/core/delta-builder';
import { CustomResolver } from '@src/views/resolvers/custom-resolvers';
import { ResolverPlugin } from '@src/views/resolvers/custom-resolvers/plugin';
import Debug from 'debug';
const debug = Debug('rz:test:resolver-test-helper');
// Define a test plugin map that enforces string dependencies
type TestPluginMap = {
[key: string]: ResolverPlugin<unknown, string>;
[key: string]: ResolverPlugin<unknown>;
};
interface TestHelperOptions<T extends TestPluginMap> {
@ -38,6 +40,7 @@ export async function testResolverWithPlugins<T extends TestPluginMap>(
// Get the resolved view
const resolvedView = view.resolve([entityId]);
if (!resolvedView) throw new Error(`Resolved view for entity ${entityId} is undefined`);
debug(`Resolved view for entity ${entityId}:`, JSON.stringify(resolvedView));
return resolvedView[entityId];
}

View File

@ -11,6 +11,8 @@ import { CommonSchemas } from '../../util/schemas';
import { TypedCollectionImpl, SchemaValidationError } from '@src/collections';
import { RhizomeNode } from '@src/node';
import { createDelta } from '@src/core/delta-builder';
import Debug from 'debug';
const debug = Debug('rz:schema-test');
describe('Schema System', () => {
let schemaRegistry: DefaultSchemaRegistry;
@ -158,22 +160,17 @@ describe('Schema System', () => {
// Create a valid lossless view
const validView: LosslessViewOne = {
id: 'user123',
referencedAs: ['user'],
propertyDeltas: {
name: [{
id: 'delta1',
timeCreated: 123,
host: 'host1',
creator: 'creator1',
pointers: [{ name: 'Alice' }]
}],
age: [{
id: 'delta2',
timeCreated: 124,
host: 'host1',
creator: 'creator1',
pointers: [{ age: 25 }]
}]
name: [
createDelta('creator1', 'host1')
.addPointer('name', 'Alice')
.buildV1()
],
age: [
createDelta('creator1', 'host1')
.addPointer('age', 25)
.buildV1()
]
},
};
@ -184,15 +181,12 @@ describe('Schema System', () => {
// Test invalid view (missing required property)
const invalidView: LosslessViewOne = {
id: 'user456',
referencedAs: ['user'],
propertyDeltas: {
age: [{
id: 'delta3',
timeCreated: 125,
host: 'host1',
creator: 'creator1',
pointers: [{ age: 30 }]
}]
age: [
createDelta('creator1', 'host1')
.addPointer('age', 30)
.buildV1()
]
}
};
@ -220,11 +214,25 @@ describe('Schema System', () => {
// Valid types
const validView: LosslessViewOne = {
id: 'test1',
referencedAs: [],
propertyDeltas: {
stringProp: [{ id: 'd1', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ stringProp: 'hello' }] }],
numberProp: [{ id: 'd2', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ numberProp: 42 }] }],
booleanProp: [{ id: 'd3', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ booleanProp: true }] }]
stringProp: [
createDelta('creator1', 'host1')
.addPointer('stringProp', 'hello')
.buildV1(),
// { id: 'd1', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ localContext: 'stringProp', target: 'hello' }] }],
],
numberProp: [
createDelta('creator1', 'host1')
.addPointer('numberProp', 42)
.buildV1(),
// { id: 'd2', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ localContext: 'numberProp', target: 42 }] }],
],
booleanProp: [
createDelta('creator1', 'host1')
.addPointer('booleanProp', true)
.buildV1(),
// { id: 'd3', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ localContext: 'booleanProp', target: true }] }]
],
}
};
@ -234,10 +242,19 @@ describe('Schema System', () => {
// Invalid types
const invalidView: LosslessViewOne = {
id: 'test2',
referencedAs: [],
propertyDeltas: {
stringProp: [{ id: 'd4', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ stringProp: 123 as never }] }],
numberProp: [{ id: 'd5', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ numberProp: 'not-number' as never }] }]
stringProp: [
createDelta('creator1', 'host1')
.addPointer('stringProp', 123 as never)
.buildV1(),
// { id: 'd4', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ localContext: 'stringProp', target: 123 as never }] }],
],
numberProp: [
createDelta('creator1', 'host1')
.addPointer('numberProp', 'not-number' as never)
.buildV1(),
// { id: 'd5', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ localContext: 'numberProp', target: 'not-number' as never }] }]
],
}
};
@ -336,11 +353,14 @@ describe('Schema System', () => {
// Create invalid entity manually
const invalidDelta = createDelta(node.config.creator, node.config.peerId)
.addPointer('users', 'user3', 'age')
.addPointer('age', 'not-a-number')
.setProperty('user3', 'age', 'not-a-number', 'users')
.buildV1();
node.lossless.ingestDelta(invalidDelta);
debug(`Manually ingested invalid delta: ${JSON.stringify(invalidDelta)}`)
debug(`Lossless view: ${JSON.stringify(node.lossless.compose(), null, 2)}`)
const validIds = collection.getValidEntities();
expect(validIds).toContain('user1');
expect(validIds).toContain('user2');

View File

@ -7,7 +7,9 @@ import {
LastWriteWinsPlugin,
MajorityVotePlugin,
TimestampResolver
} from "../../src";
} from "@src";
import Debug from 'debug';
const debug = Debug('rz:test:performance');
describe('Concurrent Write Scenarios', () => {
let node: RhizomeNode;
@ -20,6 +22,8 @@ describe('Concurrent Write Scenarios', () => {
describe('Simultaneous Writes with Same Timestamp', () => {
test('should handle simultaneous writes using last-write-wins resolver', () => {
const resolver = new TimestampResolver(lossless);
const timestamp = 1000;
// Simulate two writers updating the same property at the exact same time
@ -37,10 +41,10 @@ describe('Concurrent Write Scenarios', () => {
.buildV1()
);
const resolver = new TimestampResolver(lossless);
const result = resolver.resolve();
expect(result).toBeDefined();
debug(`Result: ${JSON.stringify(result, null, 2)}`)
// Should resolve deterministically using the LastWriteWins resolver's tie-breaking algorithm
expect(typeof result!['entity1'].properties.score).toBe('number');
expect([100, 200]).toContain(result!['entity1'].properties.score);
@ -48,6 +52,7 @@ describe('Concurrent Write Scenarios', () => {
test('should handle simultaneous writes using timestamp resolver with tie-breaking', () => {
const timestamp = 1000;
const resolver = new TimestampResolver(lossless, 'creator-id');
lossless.ingestDelta(createDelta('writer_z', 'host1') // Lexicographically later
.withId('delta-a')
@ -63,7 +68,6 @@ describe('Concurrent Write Scenarios', () => {
.buildV1()
);
const resolver = new TimestampResolver(lossless, 'creator-id');
const result = resolver.resolve();
expect(result).toBeDefined();
@ -72,6 +76,8 @@ describe('Concurrent Write Scenarios', () => {
});
test('should handle multiple writers with aggregation resolver', () => {
const resolver = new SumResolver(lossless, ['points']);
// Multiple writers add values simultaneously
lossless.ingestDelta(createDelta('writer1', 'host1')
.withTimestamp(1000)
@ -92,7 +98,6 @@ describe('Concurrent Write Scenarios', () => {
.buildV1()
);
const resolver = new SumResolver(lossless, ['points']);
const result = resolver.resolve();
expect(result).toBeDefined();
@ -103,6 +108,8 @@ describe('Concurrent Write Scenarios', () => {
describe('Out-of-Order Write Arrival', () => {
test('should handle writes arriving out of chronological order', () => {
const resolver = new TimestampResolver(lossless);
// Newer delta arrives first
lossless.ingestDelta(createDelta('writer1', 'host1')
.withTimestamp(2000)
@ -119,7 +126,6 @@ describe('Concurrent Write Scenarios', () => {
.buildV1()
);
const resolver = new TimestampResolver(lossless);
const result = resolver.resolve();
expect(result).toBeDefined();
@ -128,6 +134,8 @@ describe('Concurrent Write Scenarios', () => {
});
test('should maintain correct aggregation despite out-of-order arrival', () => {
const resolver = new SumResolver(lossless, ['score']);
// Add deltas in reverse chronological order
lossless.ingestDelta(createDelta('writer1', 'host1')
.withTimestamp(3000)
@ -150,7 +158,6 @@ describe('Concurrent Write Scenarios', () => {
.buildV1()
);
const resolver = new SumResolver(lossless, ['score']);
const result = resolver.resolve();
expect(result).toBeDefined();
@ -161,6 +168,8 @@ describe('Concurrent Write Scenarios', () => {
describe('High-Frequency Concurrent Updates', () => {
test('should handle rapid concurrent updates to the same entity', () => {
const resolver = new SumResolver(lossless, ['counter']);
const baseTimestamp = 1000;
const numWriters = 10;
const writesPerWriter = 5;
@ -177,7 +186,6 @@ describe('Concurrent Write Scenarios', () => {
}
}
const resolver = new SumResolver(lossless, ['counter']);
const result = resolver.resolve();
expect(result).toBeDefined();
@ -186,6 +194,11 @@ describe('Concurrent Write Scenarios', () => {
});
test('should handle concurrent updates to multiple properties', () => {
const resolver = new CustomResolver(lossless, {
name: new LastWriteWinsPlugin(),
score: new LastWriteWinsPlugin()
});
const timestamp = 1000;
// Writer 1 updates name and score
@ -218,11 +231,6 @@ describe('Concurrent Write Scenarios', () => {
.buildV1()
);
const resolver = new CustomResolver(lossless, {
name: new LastWriteWinsPlugin(),
score: new LastWriteWinsPlugin()
});
const result = resolver.resolve();
expect(result).toBeDefined();
@ -233,6 +241,8 @@ describe('Concurrent Write Scenarios', () => {
describe('Cross-Entity Concurrent Writes', () => {
test('should handle concurrent writes to different entities', () => {
const resolver = new TimestampResolver(lossless);
const timestamp = 1000;
// Multiple writers updating different entities simultaneously
@ -245,7 +255,6 @@ describe('Concurrent Write Scenarios', () => {
);
}
const resolver = new TimestampResolver(lossless);
const result = resolver.resolve();
expect(result).toBeDefined();
@ -257,6 +266,10 @@ describe('Concurrent Write Scenarios', () => {
});
test('should handle mixed entity and property conflicts', () => {
const resolver = new CustomResolver(lossless, {
votes: new MajorityVotePlugin(),
status: new LastWriteWinsPlugin()
});
const timestamp = 1000;
// Entity1: Multiple writers competing for same property
@ -289,11 +302,6 @@ describe('Concurrent Write Scenarios', () => {
.buildV1()
);
const resolver = new CustomResolver(lossless, {
votes: new MajorityVotePlugin(),
status: new LastWriteWinsPlugin()
});
const result = resolver.resolve();
expect(result).toBeDefined();
@ -304,6 +312,8 @@ describe('Concurrent Write Scenarios', () => {
describe('Stress Testing', () => {
test('should handle large number of concurrent writes efficiently', () => {
const resolver = new SumResolver(lossless, ['score']);
const numEntities = 100;
const numWritersPerEntity = 10;
const baseTimestamp = 1000;
@ -320,7 +330,6 @@ describe('Concurrent Write Scenarios', () => {
}
}
const resolver = new SumResolver(lossless, ['score']);
const result = resolver.resolve();
expect(result).toBeDefined();
@ -335,6 +344,8 @@ describe('Concurrent Write Scenarios', () => {
});
test('should maintain consistency under rapid updates and resolution calls', () => {
const resolver = new SumResolver(lossless, ['counter']);
const entityId = 'stress-test-entity';
let updateCount = 0;
@ -353,8 +364,7 @@ describe('Concurrent Write Scenarios', () => {
}
// Verify initial state
let resolver = new SumResolver(lossless, ['counter']);
let result = resolver.resolve();
const result = resolver.resolve();
expect(result).toBeDefined();
expect(result![entityId].properties.counter).toBe(updateCount);
@ -369,8 +379,7 @@ describe('Concurrent Write Scenarios', () => {
updateCount += 2;
// Create a fresh resolver to avoid accumulator caching issues
resolver = new SumResolver(lossless, ['counter']);
result = resolver.resolve();
const result = resolver.resolve();
expect(result![entityId].properties.counter).toBe(updateCount);
}
});

View File

@ -45,7 +45,7 @@ describe('Lossless View Compose/Decompose', () => {
expect(decomposed).toHaveLength(2);
// Check that original deltas are preserved
// Check that original delta ids are preserved
const originalIds = nameDeltas.map(d => d.id).sort();
const decomposedIds = decomposed.map(d => d.id).sort();
expect(decomposedIds).toEqual(originalIds);

View File

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

View File

@ -47,11 +47,11 @@ describe('Lossless', () => {
creator: "a",
host: "h",
pointers: [
{actor: "keanu"},
{role: "neo"},
{film: "the_matrix"},
{base_salary: 1000000},
{salary_currency: "usd"},
{localContext: "actor", target: "keanu", targetContext: "roles"},
{localContext: "role", target: "neo", targetContext: "actor"},
{localContext: "film", target: "the_matrix", targetContext: "cast"},
{localContext: "base_salary", target: 1000000},
{localContext: "salary_currency", target: "usd"},
],
}],
},
@ -63,11 +63,11 @@ describe('Lossless', () => {
creator: "a",
host: "h",
pointers: [
{actor: "keanu"},
{role: "neo"},
{film: "the_matrix"},
{base_salary: 1000000},
{salary_currency: "usd"},
{localContext: "actor", target: "keanu", targetContext: "roles"},
{localContext: "role", target: "neo", targetContext: "actor"},
{localContext: "film", target: "the_matrix", targetContext: "cast"},
{localContext: "base_salary", target: 1000000},
{localContext: "salary_currency", target: "usd"},
],
}],
},
@ -79,11 +79,11 @@ describe('Lossless', () => {
creator: "a",
host: "h",
pointers: [
{actor: "keanu"},
{role: "neo"},
{film: "the_matrix"},
{base_salary: 1000000},
{salary_currency: "usd"},
{localContext: "actor", target: "keanu", targetContext: "roles"},
{localContext: "role", target: "neo", targetContext: "actor"},
{localContext: "film", target: "the_matrix", targetContext: "cast"},
{localContext: "base_salary", target: 1000000},
{localContext: "salary_currency", target: "usd"},
],
}],
},
@ -112,11 +112,11 @@ describe('Lossless', () => {
creator: "a",
host: "h",
pointers: [
{actor: "keanu"},
{role: "neo"},
{film: "the_matrix"},
{base_salary: 1000000},
{salary_currency: "usd"},
{localContext: "actor", target: "keanu", targetContext: "roles"},
{localContext: "role", target: "neo", targetContext: "actor"},
{localContext: "film", target: "the_matrix", targetContext: "cast"},
{localContext: "base_salary", target: 1000000},
{localContext: "salary_currency", target: "usd"},
],
}],
},
@ -128,11 +128,11 @@ describe('Lossless', () => {
creator: "a",
host: "h",
pointers: [
{actor: "keanu"},
{role: "neo"},
{film: "the_matrix"},
{base_salary: 1000000},
{salary_currency: "usd"},
{localContext: "actor", target: "keanu", targetContext: "roles"},
{localContext: "role", target: "neo", targetContext: "actor"},
{localContext: "film", target: "the_matrix", targetContext: "cast"},
{localContext: "base_salary", target: 1000000},
{localContext: "salary_currency", target: "usd"},
],
}],
},
@ -144,11 +144,11 @@ describe('Lossless', () => {
creator: "a",
host: "h",
pointers: [
{actor: "keanu"},
{role: "neo"},
{film: "the_matrix"},
{base_salary: 1000000},
{salary_currency: "usd"},
{localContext: "actor", target: "keanu", targetContext: "roles"},
{localContext: "role", target: "neo", targetContext: "actor"},
{localContext: "film", target: "the_matrix", targetContext: "cast"},
{localContext: "base_salary", target: 1000000},
{localContext: "salary_currency", target: "usd"},
],
}],
},
@ -163,7 +163,7 @@ describe('Lossless', () => {
// First delta
lossless.ingestDelta(
createDelta('A', 'H')
.addPointer('1', 'ace', 'value')
.setProperty('ace', 'value', '1', 'ace')
.buildV1()
);
@ -171,25 +171,28 @@ describe('Lossless', () => {
lossless.ingestDelta(
createDelta('B', 'H')
// 10 11j 12q 13k 14a
.addPointer('14', 'ace', 'value')
// .addPointer('14', 'ace', 'value')
.setProperty('ace', 'value', '14', 'ace')
.buildV1()
);
expect(lossless.compose()).toMatchObject({
ace: {
referencedAs: ["1", "14"],
referencedAs: ["ace"],
propertyDeltas: {
value: [{
creator: 'A',
host: 'H',
pointers: [
{"1": "ace"},
{localContext: "ace", target: "ace", targetContext: "value"},
{localContext: "value", target: "1"},
]
}, {
creator: 'B',
host: 'H',
pointers: [
{"14": "ace"},
{localContext: "ace", target: "ace", targetContext: "value"},
{localContext: "value", target: "14"},
]
}],
}
@ -204,13 +207,14 @@ describe('Lossless', () => {
expect(lossless.compose(undefined, filter)).toMatchObject({
ace: {
referencedAs: ["1"],
referencedAs: ["ace"],
propertyDeltas: {
value: [{
creator: 'A',
host: 'H',
pointers: [
{"1": "ace"},
{localContext: "ace", target: "ace", targetContext: "value"},
{localContext: "value", target: "1"},
]
}]
}
@ -219,13 +223,14 @@ describe('Lossless', () => {
expect(lossless.compose(["ace"], filter)).toMatchObject({
ace: {
referencedAs: ["1"],
referencedAs: ["ace"],
propertyDeltas: {
value: [{
creator: 'A',
host: 'H',
pointers: [
{"1": "ace"},
{localContext: "ace", target: "ace", targetContext: "value"},
{localContext: "value", target: "1"},
]
}]
}

View File

@ -3,7 +3,7 @@ import { PointerTarget } from "@src/core/delta";
import { Lossless, LosslessViewOne } from "@src/views/lossless";
import { Lossy } from "@src/views/lossy";
import { RhizomeNode } from "@src/node";
import { valueFromCollapsedDelta } from "@src/views/lossless";
import { valueFromDelta } from "@src/views/lossless";
import { latestFromCollapsedDeltas } from "@src/views/resolvers/timestamp-resolvers";
import { createDelta } from "@src/core/delta-builder";
const debug = Debug('rz:test:lossy');
@ -38,10 +38,10 @@ class Summarizer extends Lossy<Summary> {
// TODO: Prove with failing test
reducer(acc: Summary, cur: LosslessViewOne): Summary {
this.debug(`Processing view for entity ${cur.id} (referenced as: ${cur.referencedAs.join(', ')})`);
this.debug(`Processing view for entity ${cur.id} (referenced as: ${cur.referencedAs?.join(', ')})`);
this.debug(`lossless view:`, JSON.stringify(cur));
if (cur.referencedAs.includes("role")) {
if (cur.referencedAs?.includes("role")) {
this.debug(`Found role entity: ${cur.id}`);
const actorDeltas = cur.propertyDeltas["actor"];
@ -60,7 +60,7 @@ class Summarizer extends Lossy<Summary> {
}
this.debug(`Found actor: ${actor}`);
const film = valueFromCollapsedDelta("film", delta);
const film = valueFromDelta("film", delta);
if (!film) {
this.debug('No film property found in delta');

View File

@ -9,6 +9,8 @@ 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 Debug from 'debug';
const debug = Debug('rz:test:multi-pointer-resolution');
describe('Multi-Pointer Delta Resolution', () => {
let node: RhizomeNode;
@ -255,6 +257,8 @@ describe('Multi-Pointer Delta Resolution', () => {
{ maxDepth: 2 }
);
debug(`nestedProjectView:`, JSON.stringify(nestedProjectView, null, 2));
expect(nestedProjectView.id).toBe('website');
expect(nestedProjectView.nestedObjects.collaborations).toBeDefined();

View File

@ -16,7 +16,7 @@ describe('Basic Dependency Resolution', () => {
test('should resolve dependencies in correct order', () => {
// Define a simple plugin that depends on another
class FirstPlugin extends ResolverPlugin<{ value: string }, string> {
class FirstPlugin extends ResolverPlugin<{ value: string }> {
readonly dependencies = [] as const;
initialize() {
@ -34,7 +34,7 @@ describe('Basic Dependency Resolution', () => {
}
class SecondPlugin extends ResolverPlugin<{ value: string }, string> {
class SecondPlugin extends ResolverPlugin<{ value: string }> {
readonly dependencies = ['first'] as const;
initialize() {

View File

@ -16,7 +16,7 @@ describe('Circular Dependency Detection', () => {
test('should detect circular dependencies', () => {
// PluginA depends on PluginB
class PluginA extends ResolverPlugin<{ value: string }, string> {
class PluginA extends ResolverPlugin<{ value: string }> {
readonly dependencies = ['b'] as const;
initialize() {
@ -34,7 +34,7 @@ describe('Circular Dependency Detection', () => {
// PluginB depends on PluginA (circular dependency)
class PluginB extends ResolverPlugin<{ value: string }, string> {
class PluginB extends ResolverPlugin<{ value: string }> {
readonly dependencies = ['a'] as const;
initialize() {
@ -61,21 +61,21 @@ describe('Circular Dependency Detection', () => {
});
test('should detect longer circular dependency chains', () => {
class PluginA extends ResolverPlugin<{ value: string }, string> {
class PluginA extends ResolverPlugin<{ value: string }> {
readonly dependencies = ['c'] as const;
initialize() { return { value: '' }; }
update() { return { value: '' }; }
resolve() { return 'a'; }
}
class PluginB extends ResolverPlugin<{ value: string }, string> {
class PluginB extends ResolverPlugin<{ value: string }> {
readonly dependencies = ['a'] as const;
initialize() { return { value: '' }; }
update() { return { value: '' }; }
resolve() { return 'b'; }
}
class PluginC extends ResolverPlugin<{ value: string }, string> {
class PluginC extends ResolverPlugin<{ value: string }> {
readonly dependencies = ['b'] as const;
initialize() { return { value: '' }; }
update() { return { value: '' }; }

View File

@ -18,7 +18,7 @@ describe('Edge Cases', () => {
test('should handle null values', () => {
// Create a type-safe plugin that handles null/undefined values
class NullSafeLastWriteWinsPlugin extends ResolverPlugin<{ value: PropertyTypes | null, timestamp: number }, never> {
class NullSafeLastWriteWinsPlugin extends ResolverPlugin<{ value: PropertyTypes | null, timestamp: number }> {
readonly dependencies = [] as const;
initialize() {
@ -64,7 +64,7 @@ describe('Edge Cases', () => {
test('should handle concurrent updates with same timestamp', () => {
// Custom plugin that handles concurrent updates with the same timestamp
class ConcurrentUpdatePlugin extends ResolverPlugin<{ value: PropertyTypes, timestamp: number }, never> {
class ConcurrentUpdatePlugin extends ResolverPlugin<{ value: PropertyTypes, timestamp: number }> {
readonly dependencies = [] as const;
initialize() {
@ -125,7 +125,7 @@ describe('Edge Cases', () => {
test('should handle very large numbers of updates', () => {
// Plugin that handles large numbers of updates efficiently
class CounterPlugin extends ResolverPlugin<{ count: number }, never> {
class CounterPlugin extends ResolverPlugin<{ count: number }> {
readonly dependencies = [] as const;
initialize() {
@ -173,7 +173,7 @@ describe('Edge Cases', () => {
test('should handle missing properties gracefully', () => {
// No deltas added - should handle empty state
// Plugin that handles missing properties gracefully
class MissingPropertyPlugin extends ResolverPlugin<{ initialized: boolean }, never> {
class MissingPropertyPlugin extends ResolverPlugin<{ initialized: boolean }> {
readonly dependencies = [] as const;
initialize() {

View File

@ -9,9 +9,11 @@ import {
MinPlugin,
ResolverPlugin
} from '@src/views/resolvers/custom-resolvers';
import Debug from 'debug';
const debug = Debug('rz:test:lossless');
// A simple plugin that depends on other plugins
class AveragePlugin<Targets extends PropertyID> extends ResolverPlugin<{ initialized: boolean }, Targets> {
class AveragePlugin<Targets extends PropertyID> extends ResolverPlugin<{ initialized: boolean }> {
readonly dependencies: Targets[] = [];
constructor(...targets: Targets[]) {
@ -90,9 +92,10 @@ describe('Multiple Plugins Integration', () => {
const resolver = new CustomResolver(lossless, {
name: new LastWriteWinsPlugin(),
tags: new ConcatenationPlugin(),
score: new MaxPlugin('score')
score: new MaxPlugin()
});
debug(`Creating and ingesting first delta`);
// Add data for entity1
lossless.ingestDelta(
createDelta('user1', 'host1')
@ -102,6 +105,7 @@ describe('Multiple Plugins Integration', () => {
.buildV1()
);
debug(`Creating and ingesting second delta`);
// Add more tags to entity1
lossless.ingestDelta(
createDelta('user1', 'host1')
@ -110,6 +114,7 @@ describe('Multiple Plugins Integration', () => {
.buildV1()
);
debug(`Creating and ingesting third delta`);
// Add data for entity2
lossless.ingestDelta(
createDelta('user1', 'host1')
@ -118,6 +123,7 @@ describe('Multiple Plugins Integration', () => {
.buildV1()
);
debug(`Creating and ingesting fourth delta`);
// Update score for entity2
lossless.ingestDelta(
createDelta('user1', 'host1')
@ -132,7 +138,7 @@ describe('Multiple Plugins Integration', () => {
const entity1 = results!['entity1'];
expect(entity1).toBeDefined();
expect(entity1?.properties.name).toBe('Test Entity');
expect(entity1?.properties.tags).toEqual(['tag1', 'tag2']);
expect(entity1?.properties.tags).toBe('tag1 tag2');
const entity2 = results!['entity2'];
expect(entity2).toBeDefined();

View File

@ -6,6 +6,9 @@ import {
DependencyStates
} from '@src/views/resolvers/custom-resolvers';
import { PropertyTypes } from '@src/core/types';
import Debug from 'debug';
const debug = Debug('rz:plugin-lifecycle');
// A simple plugin for testing lifecycle methods
class LifecycleTestPlugin extends ResolverPlugin<LifecycleTestState> {
@ -56,22 +59,23 @@ describe('Plugin Lifecycle', () => {
});
test('should call initialize, update, and resolve in order', () => {
// Add some data
lossless.ingestDelta(
createDelta('user1', 'host1')
.withTimestamp(1000)
.setProperty('test1', 'test', 'value1', 'test')
.buildV1()
);
const resolver = new CustomResolver(lossless, {
test: new LifecycleTestPlugin()
});
const results = resolver.resolve() || [];
expect(Array.isArray(results)).toBe(true);
// Add some data
lossless.ingestDelta(
createDelta('user1', 'host1')
.withTimestamp(1000)
.setProperty('test1', 'test', 'value1')
.buildV1()
);
const entity = results.find(r => r.id === 'test1');
const results = resolver.resolve();
expect(results).toBeDefined();
debug(`Results: ${JSON.stringify(results, null, 2)}`)
const entity = results!['test1']
expect(entity).toBeDefined();
// Verify all lifecycle methods were called in the correct order
@ -88,11 +92,15 @@ describe('Plugin Lifecycle', () => {
});
test('should handle multiple updates correctly', () => {
const resolver = new CustomResolver(lossless, {
test: new LifecycleTestPlugin()
});
// First update
lossless.ingestDelta(
createDelta('user1', 'host1')
.withTimestamp(1000)
.setProperty('test2', 'test', 'value1', 'test')
.setProperty('test2', 'test', 'value1')
.buildV1()
);
@ -100,18 +108,14 @@ describe('Plugin Lifecycle', () => {
lossless.ingestDelta(
createDelta('user1', 'host1')
.withTimestamp(2000)
.setProperty('test2', 'test', 'value2', 'test')
.setProperty('test2', 'test', 'value2')
.buildV1()
);
const resolver = new CustomResolver(lossless, {
test: new LifecycleTestPlugin()
});
const results = resolver.resolve();
expect(results).toBeDefined();
const results = resolver.resolve() || [];
expect(Array.isArray(results)).toBe(true);
const entity = results.find(r => r.id === 'test2');
const entity = results!['test2'];
expect(entity).toBeDefined();
// Verify state after multiple updates
@ -133,7 +137,7 @@ describe('Plugin Lifecycle', () => {
});
const results = resolver.resolve();
expect(Array.isArray(results)).toBe(true);
expect(results).toHaveLength(0);
expect(results).toBeDefined();
expect(results).toMatchObject({});
});
});

View File

@ -4,7 +4,7 @@ import { PropertyTypes } from '@src/core/types';
import type { CollapsedDelta } from '@src/views/lossless';
import { testResolverWithPlugins, createTestDelta } from '@test-helpers/resolver-test-helper';
class CountPlugin extends ResolverPlugin<{ count: number }, never> {
class CountPlugin extends ResolverPlugin<{ count: number }> {
readonly dependencies = [] as const;
initialize() {
@ -33,7 +33,7 @@ describe('CountPlugin', () => {
// Arrange & Act
const entityId = 'counter1';
await testResolverWithPlugins({
const result = await testResolverWithPlugins({
entityId,
plugins: {
count: new CountPlugin()
@ -51,13 +51,11 @@ describe('CountPlugin', () => {
.withTimestamp(3000)
.setProperty(entityId, 'count', 'value3', 'test')
.buildV1()
],
expectedResult: (result) => {
]});
// Assert
expect(result).toBeDefined();
expect(result.properties.count).toBe(3);
}
});
});
test('should handle multiple entities independently', async () => {
@ -81,29 +79,33 @@ describe('CountPlugin', () => {
];
// Act & Assert - Test counter1
await testResolverWithPlugins({
{
const result = await testResolverWithPlugins({
entityId: 'counter1',
plugins: {
count: new CountPlugin()
},
deltas: counter1Deltas,
expectedResult: (result) => {
deltas: counter1Deltas
});
// Assert
expect(result).toBeDefined();
expect(result.properties.count).toBe(2);
}
});
// Act & Assert - Test counter2
await testResolverWithPlugins({
{
const result = await testResolverWithPlugins({
entityId: 'counter2',
plugins: {
count: new CountPlugin()
},
deltas: counter2Deltas,
expectedResult: (result) => {
deltas: counter2Deltas
});
// Assert
expect(result).toBeDefined();
expect(result.properties.count).toBe(1);
}
});
});
});

View File

@ -5,7 +5,7 @@ import { testResolverWithPlugins, createTestDelta } from '@test-helpers/resolver
import Debug from 'debug';
const debug = Debug('rz:test:discount-plugins');
// Mock plugins for testing
class DiscountPlugin extends ResolverPlugin<number, never> {
class DiscountPlugin extends ResolverPlugin<number> {
readonly name = 'discount' as const;
readonly dependencies = [] as const;
@ -14,9 +14,12 @@ class DiscountPlugin extends ResolverPlugin<number, never> {
}
update(
_currentState: number,
currentState: number,
newValue: PropertyTypes,
) {
if (newValue === undefined) {
return currentState;
}
const numValue = typeof newValue === 'number' ? newValue : 0;
const clampedValue = Math.min(100, Math.max(0, numValue)); // Clamp between 0-100
debug(`DiscountPlugin: updated discount to ${clampedValue}`);
@ -28,7 +31,7 @@ class DiscountPlugin extends ResolverPlugin<number, never> {
}
}
class DiscountedPricePlugin extends ResolverPlugin<number | null, 'discount'> {
class DiscountedPricePlugin extends ResolverPlugin<number | null> {
readonly name = 'price' as const;
readonly dependencies = ['discount'] as const;
@ -37,9 +40,13 @@ class DiscountedPricePlugin extends ResolverPlugin<number | null, 'discount'> {
}
update(
_currentState: number | null,
currentState: number | null,
newValue: PropertyTypes,
) {
debug(`DiscountedPricePlugin: updating price with state ${currentState} and value ${newValue}`)
if (newValue === undefined) {
return currentState;
}
const numValue = typeof newValue === 'number' ? newValue : 0;
debug(`DiscountedPricePlugin: updated price to ${numValue}`);
return numValue;
@ -49,13 +56,16 @@ class DiscountedPricePlugin extends ResolverPlugin<number | null, 'discount'> {
state: number | null,
dependencies: DependencyStates
): number | null {
debug(`DiscountedPricePlugin: resolving price with state ${state} and discount ${dependencies.discount}`);
if (state === null) {
return null;
}
// Ensure discount is a number and default to 0 if undefined
const discount = typeof dependencies.discount === 'number' ? dependencies.discount : 0;
const discountMultiplier = (100 - discount) / 100;
return state * discountMultiplier;
const result = state * discountMultiplier;
debug(`DiscountedPricePlugin: resolved price to ${result}`);
return result;
}
}

View File

@ -7,7 +7,7 @@ describe('RunningAveragePlugin', () => {
// Arrange & Act
const entityId = 'player1';
await testResolverWithPlugins({
const result = await testResolverWithPlugins({
entityId,
plugins: {
score: new RunningAveragePlugin()
@ -28,20 +28,18 @@ describe('RunningAveragePlugin', () => {
.withTimestamp(3000)
.setProperty(entityId, 'score', 30, 'game')
.buildV1()
],
expectedResult: (result) => {
]});
// Assert
expect(result).toBeDefined();
expect(result.properties.score).toBe(20); // (10 + 20 + 30) / 3 = 20
}
});
});
test('should handle non-numeric values gracefully', async () => {
// Arrange & Act
const entityId = 'test1';
await testResolverWithPlugins({
const result = await testResolverWithPlugins({
entityId,
plugins: {
value: new RunningAveragePlugin()
@ -55,36 +53,25 @@ describe('RunningAveragePlugin', () => {
.withTimestamp(2000)
.setProperty(entityId, 'value', 10, 'test')
.buildV1()
],
expectedResult: (result) => {
]});
// Assert
expect(result).toBeDefined();
// First value is treated as 0, second as 10, average is (0 + 10) / 2 = 5
expect(result.properties.value).toBe(5);
}
});
});
test('should handle empty state', async () => {
// Arrange & Act
const entityId = 'non-existent';
let error: Error | undefined;
try {
await testResolverWithPlugins({
const result = await testResolverWithPlugins({
entityId,
plugins: {
value: new RunningAveragePlugin()
},
deltas: [],
expectedResult: () => {}
});
} catch (err) {
error = err as Error;
}
// Assert - The helper should throw when entity is not found
expect(error).toBeDefined();
expect(error?.message).toContain('No view found for entity non-existent');
expect(result).toBeUndefined();
});
});

View File

@ -8,11 +8,12 @@ import { ResolverPlugin } from '@src/views/resolvers/custom-resolvers/plugin';
// const debug = Debug('rz:test:resolver');
// Mock plugins for testing
class TestPlugin extends ResolverPlugin<unknown, string> {
class TestPlugin extends ResolverPlugin<unknown> {
name: string;
dependencies: readonly string[];
constructor(name: string, dependencies: string[] = []) {
super();
this.name = name;
this.dependencies = dependencies;
}
@ -70,11 +71,11 @@ describe('CustomResolver', () => {
const graph = resolver.dependencyGraph;
// Assert
expect(graph.get('a')).toBeDefined();
expect(graph.get('b')).toBeDefined();
expect(graph.get('plugin:a')).toBeDefined();
expect(graph.get('plugin:b')).toBeDefined();
// Both 'plugin:b' and 'another:b' should be in the graph as 'b'
expect(Array.from(graph.get('a') || [])).toContain('b');
expect(Array.from(graph.get('plugin:a') || [])).toContain('plugin:b');
});
test('should throw error for missing dependency', () => {

View File

@ -18,7 +18,7 @@ describe('State Visibility', () => {
});
// A test plugin that records which states it sees
class StateSpyPlugin extends ResolverPlugin<{ values: string[] }, 'dependsOn'> {
class StateSpyPlugin extends ResolverPlugin<{ values: string[] }> {
readonly dependencies = [] as const;
seenStates: Record<string, unknown>[] = [];
@ -51,7 +51,7 @@ describe('State Visibility', () => {
}
// A simple plugin that depends on another property
class DependentPlugin extends ResolverPlugin<{ value: string }, 'dependsOn'> {
class DependentPlugin extends ResolverPlugin<{ value: string }> {
readonly dependencies = ['dependsOn'] as const;
seenStates: Record<string, unknown>[] = [];
@ -94,8 +94,8 @@ describe('State Visibility', () => {
lossless.ingestDelta(
createDelta('user1', 'host1')
.withTimestamp(1000)
.setProperty('entity1', 'prop1', 'value1', 'prop1')
.setProperty('entity1', 'prop2', 'value2', 'prop2')
.setProperty('entity1', 'prop1', 'value1', 'entity-prop1')
.setProperty('entity1', 'prop2', 'value2', 'entity-prop2')
.buildV1()
);
@ -189,7 +189,7 @@ describe('State Visibility', () => {
});
test('should throw error for unknown dependencies', () => {
class PluginWithBadDeps extends ResolverPlugin<{ value: string }, 'nonexistent'> {
class PluginWithBadDeps extends ResolverPlugin<{ value: string }> {
readonly dependencies = ['nonexistent'] as const;
initialize() {
@ -217,6 +217,6 @@ describe('State Visibility', () => {
new CustomResolver(lossless, {
bad: new PluginWithBadDeps()
});
}).toThrow("Plugin 'bad' depends on unknown property: nonexistent");
}).toThrow("Dependency nonexistent not found for plugin bad");
});
});

View File

@ -6,8 +6,10 @@ import {
DeltaIdTimestampResolver,
HostIdTimestampResolver,
LexicographicTimestampResolver
} from "../../../../src";
import { createDelta } from "../../../../src/core/delta-builder";
} from "@src";
import { createDelta } from "@src/core/delta-builder";
import Debug from "debug";
const debug = Debug('rz:test:timestamp-resolvers');
describe('Timestamp Resolvers', () => {
let node: RhizomeNode;
@ -20,6 +22,8 @@ describe('Timestamp Resolvers', () => {
describe('Basic Timestamp Resolution', () => {
test('should resolve by most recent timestamp', () => {
const resolver = new TimestampResolver(lossless);
// Add older delta
lossless.ingestDelta(createDelta('user1', 'host1')
.withId('delta1')
@ -38,14 +42,16 @@ describe('Timestamp Resolvers', () => {
.buildV1()
);
const resolver = new TimestampResolver(lossless);
const result = resolver.resolve();
expect(result).toBeDefined();
debug(`Result: ${JSON.stringify(result, null, 2)}`)
expect(result!['entity1'].properties.score).toBe(20); // More recent value wins
});
test('should handle multiple entities with different timestamps', () => {
const resolver = new TimestampResolver(lossless);
// Entity1 - older value
lossless.ingestDelta(createDelta('user1', 'host1')
.withTimestamp(1000)
@ -62,7 +68,6 @@ describe('Timestamp Resolvers', () => {
.buildV1()
);
const resolver = new TimestampResolver(lossless);
const result = resolver.resolve();
expect(result).toBeDefined();
@ -73,6 +78,8 @@ describe('Timestamp Resolvers', () => {
describe('Tie-Breaking Strategies', () => {
test('should break ties using creator-id strategy', () => {
const resolver = new CreatorIdTimestampResolver(lossless);
// Two deltas with same timestamp, different creators
lossless.ingestDelta(createDelta('user_z', 'host1')
.withId('delta1')
@ -90,7 +97,6 @@ describe('Timestamp Resolvers', () => {
.buildV1()
);
const resolver = new CreatorIdTimestampResolver(lossless);
const result = resolver.resolve();
expect(result).toBeDefined();
@ -99,6 +105,8 @@ describe('Timestamp Resolvers', () => {
});
test('should break ties using delta-id strategy', () => {
const resolver = new DeltaIdTimestampResolver(lossless);
// Two deltas with same timestamp, different delta IDs
lossless.ingestDelta(createDelta('user1', 'host1')
.withId('delta_a') // Lexicographically earlier
@ -116,7 +124,6 @@ describe('Timestamp Resolvers', () => {
.buildV1()
);
const resolver = new DeltaIdTimestampResolver(lossless);
const result = resolver.resolve();
expect(result).toBeDefined();
@ -125,6 +132,8 @@ describe('Timestamp Resolvers', () => {
});
test('should break ties using host-id strategy', () => {
const resolver = new HostIdTimestampResolver(lossless);
// Two deltas with same timestamp, different hosts
lossless.ingestDelta(createDelta('user1', 'host_z') // Lexicographically later
.withId('delta1')
@ -142,7 +151,6 @@ describe('Timestamp Resolvers', () => {
.buildV1()
);
const resolver = new HostIdTimestampResolver(lossless);
const result = resolver.resolve();
expect(result).toBeDefined();
@ -151,6 +159,8 @@ describe('Timestamp Resolvers', () => {
});
test('should break ties using lexicographic strategy with string values', () => {
const resolver = new LexicographicTimestampResolver(lossless);
// Two deltas with same timestamp, different string values
lossless.ingestDelta(createDelta('user1', 'host1')
.withId('delta1')
@ -168,7 +178,6 @@ describe('Timestamp Resolvers', () => {
.buildV1()
);
const resolver = new LexicographicTimestampResolver(lossless);
const result = resolver.resolve();
expect(result).toBeDefined();
@ -177,6 +186,8 @@ describe('Timestamp Resolvers', () => {
});
test('should break ties using lexicographic strategy with numeric values (falls back to delta ID)', () => {
const resolver = new LexicographicTimestampResolver(lossless);
// Two deltas with same timestamp, numeric values (should fall back to delta ID comparison)
lossless.ingestDelta(createDelta('user1', 'host1')
.withId('delta_a') // Lexicographically earlier
@ -194,7 +205,6 @@ describe('Timestamp Resolvers', () => {
.buildV1()
);
const resolver = new LexicographicTimestampResolver(lossless);
const result = resolver.resolve();
expect(result).toBeDefined();
@ -205,6 +215,9 @@ describe('Timestamp Resolvers', () => {
describe('Complex Tie-Breaking Scenarios', () => {
test('should handle multiple properties with different tie-breaking outcomes', () => {
const creatorResolver = new CreatorIdTimestampResolver(lossless);
const deltaResolver = new DeltaIdTimestampResolver(lossless);
// Add deltas for multiple properties with same timestamp
lossless.ingestDelta(createDelta('user_a', 'host1')
.withId('delta_z')
@ -222,9 +235,6 @@ describe('Timestamp Resolvers', () => {
.buildV1()
);
const creatorResolver = new CreatorIdTimestampResolver(lossless);
const deltaResolver = new DeltaIdTimestampResolver(lossless);
const creatorResult = creatorResolver.resolve();
const deltaResult = deltaResolver.resolve();
@ -239,6 +249,8 @@ describe('Timestamp Resolvers', () => {
});
test('should work consistently with timestamp priority over tie-breaking', () => {
const resolver = new CreatorIdTimestampResolver(lossless);
// Add older delta with "better" tie-breaking attributes
lossless.ingestDelta(createDelta('user_z', 'host1')
.withId('delta_z') // Would win in delta ID tie-breaking
@ -257,7 +269,6 @@ describe('Timestamp Resolvers', () => {
.buildV1()
);
const resolver = new CreatorIdTimestampResolver(lossless);
const result = resolver.resolve();
expect(result).toBeDefined();
@ -268,6 +279,7 @@ describe('Timestamp Resolvers', () => {
describe('Edge Cases', () => {
test('should handle single delta correctly', () => {
const resolver = new TimestampResolver(lossless, 'creator-id');
lossless.ingestDelta(createDelta('user1', 'host1')
.withId('delta1')
.withTimestamp(1000)
@ -276,7 +288,6 @@ describe('Timestamp Resolvers', () => {
.buildV1()
);
const resolver = new TimestampResolver(lossless, 'creator-id');
const result = resolver.resolve();
expect(result).toBeDefined();
@ -284,6 +295,8 @@ describe('Timestamp Resolvers', () => {
});
test('should handle mixed value types correctly', () => {
const resolver = new TimestampResolver(lossless);
lossless.ingestDelta(createDelta('user1', 'host1')
.withId('delta1')
.withTimestamp(1000)
@ -300,7 +313,6 @@ describe('Timestamp Resolvers', () => {
.buildV1()
);
const resolver = new TimestampResolver(lossless);
const result = resolver.resolve();
expect(result).toBeDefined();

View File

@ -131,7 +131,7 @@ The resolver is configured with an object mapping property IDs to their resolver
```typescript
interface ResolverConfig {
[propertyId: string]: ResolverPlugin<any, string>;
[propertyId: string]: ResolverPlugin<any>;
}
```
@ -164,7 +164,7 @@ const resolver = new CustomResolver(view, {
taxRate: new LastWriteWinsPlugin(),
// Complex plugin with multiple dependencies
subtotal: new class extends ResolverPlugin<SubtotalState, 'unitPrice' | 'quantity'> {
subtotal: new class extends ResolverPlugin<SubtotalState> {
readonly dependencies = ['unitPrice', 'quantity'] as const;
initialize() { return { value: 0 }; }

View File

@ -7,7 +7,7 @@ The `ResolverPlugin` interface defines the contract that all resolver plugins mu
## Interface Definition
```typescript
interface ResolverPlugin<T = unknown, D extends string = never> {
interface ResolverPlugin<T = unknown> {
/**
* Unique identifier for the plugin
*/
@ -126,7 +126,7 @@ class CounterPlugin extends ResolverPlugin<CounterState> {
### Accessing Dependencies
```typescript
class PriceCalculator extends ResolverPlugin<PriceState, 'basePrice' | 'taxRate'> {
class PriceCalculator extends ResolverPlugin<PriceState> {
readonly dependencies = ['basePrice', 'taxRate'] as const;
update(
@ -147,7 +147,7 @@ class PriceCalculator extends ResolverPlugin<PriceState, 'basePrice' | 'taxRate'
### Optional Dependencies
```typescript
class OptionalDepPlugin extends ResolverPlugin<State, 'required' | 'optional?'> {
class OptionalDepPlugin extends ResolverPlugin<State> {
readonly dependencies = ['required', 'optional?'] as const;
update(

View File

@ -51,10 +51,10 @@ Represents a collapsed delta with metadata about the change.
## Plugin Types
### `ResolverPlugin<T, D>`
### `ResolverPlugin<T>`
```typescript
interface ResolverPlugin<T = unknown, D extends string = never> {
interface ResolverPlugin<T = unknown> {
readonly name: string;
readonly dependencies?: readonly D[];
@ -106,7 +106,7 @@ Maps entity IDs to their resolved properties.
```typescript
interface ResolverConfig {
[propertyId: string]: ResolverPlugin<any, string>;
[propertyId: string]: ResolverPlugin<any>;
}
```
@ -197,7 +197,7 @@ Extracts the dependency keys from a plugin type.
```typescript
type DependencyKeys<T> =
T extends ResolverPlugin<any, infer D> ? D : never;
T extends ResolverPlugin<any> ? D : never;
```
### `PluginState<T>`
@ -206,7 +206,7 @@ Extracts the state type from a plugin type.
```typescript
type PluginState<T> =
T extends ResolverPlugin<infer S, any> ? S : never;
T extends ResolverPlugin<infer S> ? S : never;
```
## Example: Working with Types
@ -222,7 +222,7 @@ interface CounterState {
type CounterDeps = 'incrementBy' | 'resetThreshold';
// Implement plugin with type safety
class CounterPlugin extends ResolverPlugin<CounterState, CounterDeps> {
class CounterPlugin extends ResolverPlugin<CounterState> {
readonly dependencies = ['incrementBy', 'resetThreshold'] as const;
initialize(): CounterState {

View File

@ -50,7 +50,7 @@ const resolver = new CustomResolver(view, {
To make a dependency optional, mark it with a `?` suffix:
```typescript
class MyPlugin extends ResolverPlugin<MyState, 'required' | 'optional?'> {
class MyPlugin extends ResolverPlugin<MyState> {
readonly dependencies = ['required', 'optional?'] as const;
// ...

View File

@ -21,7 +21,7 @@ The Custom Resolver system provides a powerful dependency management system that
## Example
```typescript
class TotalPricePlugin extends ResolverPlugin<TotalState, 'price' | 'tax'> {
class TotalPricePlugin extends ResolverPlugin<TotalState> {
readonly dependencies = ['price', 'tax'] as const;
initialize(): TotalState {

View File

@ -14,7 +14,7 @@ The Custom Resolver system leverages TypeScript's type system to provide compile
The `ResolverPlugin` interface uses TypeScript generics to enforce type safety:
```typescript
interface ResolverPlugin<T = unknown, D extends string = never> {
interface ResolverPlugin<T = unknown> {
readonly name: string;
readonly dependencies?: readonly D[];
@ -37,7 +37,7 @@ type DependencyStates = {
Dependencies are declared as a readonly array of string literals:
```typescript
class MyPlugin extends ResolverPlugin<MyState, 'dep1' | 'dep2'> {
class MyPlugin extends ResolverPlugin<MyState> {
readonly dependencies = ['dep1', 'dep2'] as const;
// ... implementation
@ -101,7 +101,7 @@ if (typeof deps.price === 'number') {
### Optional Dependencies
```typescript
class MyPlugin extends ResolverPlugin<MyState, 'required' | 'optional?'> {
class MyPlugin extends ResolverPlugin<MyState> {
readonly dependencies = ['required', 'optional?'] as const;
update(_state: MyState, _value: unknown, _delta: CollapsedDelta, deps: any) {
@ -118,7 +118,7 @@ class MyPlugin extends ResolverPlugin<MyState, 'required' | 'optional?'> {
```typescript
type PriceDependencies = 'price1' | 'price2' | 'price3';
class PriceAggregator extends ResolverPlugin<PriceState, PriceDependencies> {
class PriceAggregator extends ResolverPlugin<PriceState> {
readonly dependencies: readonly PriceDependencies[] = ['price1', 'price2', 'price3'] as const;
update(_state: PriceState, _value: unknown, _delta: CollapsedDelta, deps: any) {

View File

@ -40,7 +40,7 @@ class MyPlugin extends ResolverPlugin<MyState> {
To depend on other properties, specify the dependency types:
```typescript
class DiscountedPricePlugin extends ResolverPlugin<DiscountState, 'basePrice' | 'discount'> {
class DiscountedPricePlugin extends ResolverPlugin<DiscountState> {
readonly dependencies = ['basePrice', 'discount'] as const;
initialize(): DiscountState {

View File

@ -91,8 +91,7 @@ export abstract class Collection<View> {
if (deltas.length > 1) {
// We can generate a separate delta describing this transaction
transactionDelta = createDelta(creator, host)
.addPointer('_transaction', transactionId, 'size')
.addPointer('size', deltas.length)
.declareTransaction(transactionId, deltas.length)
.buildV1();
// Also need to annotate the deltas with the transactionId
@ -123,16 +122,21 @@ export abstract class Collection<View> {
}
getIds(): string[] {
if (!this.rhizomeNode) return [];
const set = this.rhizomeNode.lossless.referencedAs.get(this.name);
if (!set) return [];
return Array.from(set.values());
if (!this.rhizomeNode) {
debug(`No rhizome node connected`)
return [];
}
debug(`Getting ids for collection ${this.name}`)
const ids = new Set<string>();
for (const [entityId, names] of this.rhizomeNode.lossless.referencedAs.entries()) {
if (names.has(this.name)) {
ids.add(entityId);
}
}
debug(`Found ${ids.size} ids for collection ${this.name}`);
return Array.from(ids.values());
}
// THIS PUT SHOULD CORRESOND TO A PARTICULAR MATERIALIZED VIEW...
// How can we encode that?
// Well, we have a way to do that, we just need the same particular inputs.
// We take a resolver as an optional argument.
async put(
entityId: DomainEntityID | undefined,
properties: EntityProperties,

View File

@ -11,8 +11,9 @@ import {
} from '../schema/schema';
import { DefaultSchemaRegistry } from '../schema/schema-registry';
import { LosslessViewOne } from '../views/lossless';
import { DomainEntityID, PropertyTypes } from '../core/types';
import { DomainEntityID } from '../core/types';
import { EntityProperties } from '../core/entity';
import { createDelta } from '@src/core';
const debug = Debug('rz:typed-collection');
@ -77,17 +78,11 @@ export class TypedCollectionImpl<T extends Record<string, unknown>>
propertyDeltas: {},
};
// Create mock deltas for each property
for (const [key, value] of Object.entries(entity)) {
if (value !== undefined) {
mockLosslessView.propertyDeltas[key] = [{
id: 'mock-delta',
timeCreated: Date.now(),
host: 'validation',
creator: 'validation',
pointers: [{ [key]: value as PropertyTypes }]
}];
}
mockLosslessView.propertyDeltas[key] = [createDelta('validation', 'validation')
.addPointer(key, value as string)
.buildV1(),
];
}
return this.schemaRegistry.validate('validation-mock', this.schema.id, mockLosslessView);
@ -130,9 +125,11 @@ export class TypedCollectionImpl<T extends Record<string, unknown>>
entityId: DomainEntityID | undefined,
properties: EntityProperties,
): Promise<ResolvedViewOne> {
// Validate against schema if strict validation is enabled
if (this.applicationOptions.strictValidation) {
// Validate against schema
const validationResult = this.validate(properties as T);
// If strict validation is enabled, throw on validation failure
if (this.applicationOptions.strictValidation) {
if (!validationResult.valid) {
throw new SchemaValidationError(
`Schema validation failed: ${validationResult.errors.map(e => e.message).join(', ')}`,
@ -145,7 +142,6 @@ export class TypedCollectionImpl<T extends Record<string, unknown>>
const result = await super.put(entityId, properties);
// Log validation warnings if any
const validationResult = this.validate(properties as T);
if (validationResult.warnings.length > 0) {
debug(`Validation warnings for entity ${entityId}:`, validationResult.warnings);
}
@ -200,13 +196,21 @@ export class TypedCollectionImpl<T extends Record<string, unknown>>
// Filter entities by schema validation status
getValidEntities(): DomainEntityID[] {
if (!this.rhizomeNode) return [];
if (!this.rhizomeNode) {
debug(`No rhizome node connected`)
return [];
}
const losslessView = this.rhizomeNode.lossless.compose(this.getIds());
if (!losslessView) {
debug(`No lossless view found`)
return [];
}
debug(`getValidEntities, losslessView: ${JSON.stringify(losslessView, null, 2)}`)
debug(`Validating ${this.getIds().length} entities`)
return this.getIds().filter(entityId => {
const losslessView = this.rhizomeNode!.lossless.compose([entityId])[entityId];
if (!losslessView) return false;
const validationResult = this.schemaRegistry.validate(entityId, this.schema.id, losslessView);
debug(`Validating entity ${entityId}`)
const validationResult = this.schemaRegistry.validate(entityId, this.schema.id, losslessView[entityId]);
debug(`Validation result for entity ${entityId}: ${JSON.stringify(validationResult)}`)
return validationResult.valid;
});
}

View File

@ -2,6 +2,7 @@ import { DeltaV1, DeltaV2 } from './delta';
import { randomUUID } from 'crypto';
import { PropertyTypes } from './types';
import { PointersV2 } from './delta';
import { DeltaNetworkImageV1, DeltaNetworkImageV2 } from './delta';
import Debug from 'debug';
const debug = Debug('rz:delta-builder');
@ -16,6 +17,7 @@ export class DeltaBuilder {
private host: string;
private creator: string;
private pointers: PointersV2 = {};
private references: Record<string, string | null> = {};
/**
* Create a new DeltaBuilder instance
@ -96,7 +98,7 @@ export class DeltaBuilder {
* ! 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: PropertyTypes, entityLabel = "entity"): this {
this.addPointer(entityLabel, entityId, property)
this.addPointer(property, value);
return this;
@ -123,6 +125,37 @@ export class DeltaBuilder {
return this;
}
reference(entityId: string, entityLabel: string): this {
if (this.references[entityLabel]) {
debug(`Reference for '${entityLabel}' already exists with different value: ${this.references[entityLabel]} !== ${entityId}`);
throw new Error(`Reference for ${entityLabel} already exists with different value`);
}
this.references[entityLabel] = entityId;
return this;
}
static fromNetworkImage(delta: DeltaNetworkImageV1 | DeltaNetworkImageV2): DeltaBuilder {
const builder = new DeltaBuilder(delta.creator, delta.host)
.withId(delta.id)
.withTimestamp(delta.timeCreated);
if (Array.isArray(delta.pointers)) {
for (const pointer of delta.pointers) {
builder.addPointer(pointer.localContext, pointer.target, pointer.targetContext);
}
} else {
for (const [localContext, target] of Object.entries(delta.pointers)) {
if (typeof target === 'object') {
const [[targetContext, targetValue]] = Object.entries(target!);
builder.addPointer(localContext, targetValue, targetContext);
} else {
builder.addPointer(localContext, target as PropertyTypes);
}
}
}
return builder;
}
/**
* Build and return a DeltaV2 instance
*/

View File

@ -137,7 +137,7 @@ export class HttpApi {
const {params: {id}} = req;
const v = this.rhizomeNode.lossless.compose([id]);
const ent = v[id];
if (!ent.referencedAs.includes("_transaction")) {
if (!ent.referencedAs?.includes("_transaction")) {
res.status(400).json({error: "Entity is not a transaction", id});
return;
}

View File

@ -2,9 +2,9 @@ import jsonLogic from 'json-logic-js';
const { apply, is_logic } = jsonLogic;
import Debug from 'debug';
import { SchemaRegistry, SchemaID, ObjectSchema } from '../schema/schema';
import { Lossless, LosslessViewOne, LosslessViewMany, CollapsedDelta } from '../views/lossless';
import { Lossless, LosslessViewMany, LosslessViewOne, valueFromDelta } from '../views/lossless';
import { DomainEntityID } from '../core/types';
import { DeltaFilter } from '../core/delta';
import { Delta, DeltaFilter } from '../core/delta';
const debug = Debug('rz:query');
@ -290,7 +290,7 @@ export class QueryEngine {
case 'reference': {
// For references, include the target IDs
const refValues = deltas
.map(delta => this.extractReferenceValue(delta, propertyId))
.map(delta => this.extractPrimitiveValue(delta, propertyId))
.filter(value => value !== null);
obj[propertyId] = refValues;
break;
@ -308,28 +308,8 @@ export class QueryEngine {
/**
* Extract primitive value from a delta for a given property
*/
private extractPrimitiveValue(delta: CollapsedDelta, propertyId: string): unknown {
// Look for the value in collapsed pointers
// CollapsedPointer is {[key: PropertyID]: PropertyTypes}
for (const pointer of delta.pointers) {
if (pointer[propertyId] !== undefined) {
return pointer[propertyId];
}
}
return null;
}
/**
* Extract reference value (target ID) from a delta for a given property
*/
private extractReferenceValue(delta: CollapsedDelta, _propertyId: string): string | null {
// For references, we want the value pointer that contains the reference ID
for (const pointer of delta.pointers) {
if (pointer.value !== undefined && typeof pointer.value === 'string') {
return pointer.value;
}
}
return null;
private extractPrimitiveValue(delta: Delta, propertyId: string): unknown {
return valueFromDelta(propertyId, delta);
}
/**

View File

@ -254,7 +254,7 @@ export class StorageQueryEngine {
case 'reference': {
// For references, include the target IDs
const refValues = propDeltas
.map(delta => this.extractReferenceValue(delta, propertyId))
.map(delta => this.extractPrimitiveValue(delta, propertyId))
.filter(value => value !== null);
properties[propertyId] = refValues;
break;
@ -282,18 +282,6 @@ export class StorageQueryEngine {
return null;
}
/**
* Extract reference value from a delta for a given property
*/
private extractReferenceValue(delta: Delta, propertyId: string): string | null {
for (const pointer of delta.pointers) {
if (pointer.localContext === propertyId && typeof pointer.target === 'string') {
return pointer.target;
}
}
return null;
}
/**
* Apply JSON Logic filter to entity results
*/

View File

@ -14,9 +14,10 @@ import {
SchemaApplicationOptions,
ResolutionContext
} from '../schema/schema';
import { LosslessViewOne, Lossless } from '../views/lossless';
import { Lossless, LosslessViewOne } from '../views/lossless';
import { DomainEntityID, PropertyID, PropertyTypes } from '../core/types';
import { CollapsedDelta } from '../views/lossless';
import { Delta } from '@src/core';
const debug = Debug('rz:schema-registry');
@ -146,11 +147,13 @@ export class DefaultSchemaRegistry implements SchemaRegistry {
// Validate each delta for this property
for (const delta of deltas) {
debug(`Validating delta ${delta.id} on property ${propertyId}`);
const validationResult = this.validateDeltaAgainstPropertySchema(
delta,
propertySchema,
propertyId
);
debug(`Validation result for delta ${delta.id}: ${JSON.stringify(validationResult)}`)
errors.push(...validationResult.errors);
warnings.push(...validationResult.warnings);
}
@ -176,7 +179,7 @@ export class DefaultSchemaRegistry implements SchemaRegistry {
}
private validateDeltaAgainstPropertySchema(
delta: CollapsedDelta,
delta: Delta,
schema: PropertySchema,
propertyId: PropertyID
): SchemaValidationResult {
@ -184,7 +187,7 @@ export class DefaultSchemaRegistry implements SchemaRegistry {
const warnings: SchemaValidationError[] = [];
// Extract the value from the delta
const valuePointer = delta.pointers.find(p => p[propertyId] !== undefined);
const valuePointer = delta.pointers.find(p => p.localContext === propertyId);
if (!valuePointer) {
errors.push({
property: propertyId,
@ -193,7 +196,7 @@ export class DefaultSchemaRegistry implements SchemaRegistry {
return { valid: false, errors, warnings };
}
const value = valuePointer[propertyId];
const value = valuePointer.target;
switch (schema.type) {
case 'primitive':
@ -444,7 +447,7 @@ export class DefaultSchemaRegistry implements SchemaRegistry {
}
private resolveReferenceProperty(
deltas: CollapsedDelta[],
deltas: Delta[],
referenceSchema: ReferenceSchema,
losslessView: Lossless,
context: ResolutionContext,
@ -508,7 +511,7 @@ export class DefaultSchemaRegistry implements SchemaRegistry {
}
private createCompositeObjectFromDelta(
delta: CollapsedDelta,
delta: Delta,
parentEntityId: string,
targetSchema: SchemaID,
losslessView: Lossless,
@ -520,8 +523,7 @@ export class DefaultSchemaRegistry implements SchemaRegistry {
let entityReferenceCount = 0;
let scalarCount = 0;
for (const pointer of delta.pointers) {
for (const [localContext, target] of Object.entries(pointer)) {
for (const {localContext, target} of delta.pointers) {
// Skip the pointer that references the parent entity (the "up" pointer)
if (typeof target === 'string' && target === parentEntityId) {
continue;
@ -544,7 +546,6 @@ export class DefaultSchemaRegistry implements SchemaRegistry {
scalarCount++;
}
}
}
// If no non-parent pointers found, return null
if (Object.keys(pointersByContext).length === 0) {

View File

@ -9,7 +9,6 @@ import {Transactions} from '../features/transactions';
import {DomainEntityID, PropertyID, PropertyTypes, TransactionID, ViewMany} from "../core/types";
import {Negation} from '../features/negation';
import {NegationHelper} from '../features/negation';
import { createDelta } from '../core/delta-builder';
const debug = Debug('rz:lossless');
export type CollapsedPointer = {[key: PropertyID]: PropertyTypes};
@ -19,28 +18,53 @@ export type CollapsedDelta = Omit<DeltaNetworkImageV1, 'pointers'> & {
};
// Extract a particular value from a delta's pointers
export function valueFromCollapsedDelta(
export function valueFromDelta(
key: string,
delta: CollapsedDelta
): string | number | undefined {
delta: Delta | CollapsedDelta
): PropertyTypes | undefined {
let result: PropertyTypes | undefined;
for (const pointer of delta.pointers) {
// Should be equivalent to delta instanceof Delta
if (['localContext', 'target'].every(k => k in pointer)) {
if (pointer.localContext === key) {
if (result) {
debug(`multiple values for key ${key} in delta ${delta.id}`);
throw new Error(`Multiple values for key ${key} in delta ${delta.id}`);
}
result = pointer.target;
}
} else {
for (const [k, value] of Object.entries(pointer)) {
if (k === key && (typeof value === "string" || typeof value === "number")) {
return value;
if (k === key) {
if (result) {
debug(`multiple values for key ${key} in delta ${delta.id}`);
throw new Error(`Multiple values for key ${key} in delta ${delta.id}`);
}
result = value;
}
}
}
}
return result;
}
// TODO: Store property deltas as references to reduce memory footprint
export type LosslessViewOne = {
id: DomainEntityID,
referencedAs?: string[];
propertyDeltas: {
[key: PropertyID]: Delta[]
}
}
export type LosslessViewOne = {
id: DomainEntityID,
referencedAs: string[];
propertyDeltas: {
export type CollapsedViewOne = Omit<LosslessViewOne, 'propertyDeltas'> & {
propertyCollapsedDeltas: {
[key: PropertyID]: CollapsedDelta[]
}
};
export type LosslessViewMany = ViewMany<LosslessViewOne>;
export type CollapsedViewMany = ViewMany<CollapsedViewOne>;
class LosslessEntityMap extends Map<DomainEntityID, LosslessEntity> {};
@ -67,8 +91,9 @@ class LosslessEntity {
}
propertyDeltas.add(delta);
debug(`[${this.lossless.rhizomeNode.config.peerId}]`, `entity ${this.id} added delta:`, JSON.stringify(delta));
}
debug(`[${this.lossless.rhizomeNode.config.peerId}]`, `entity ${this.id} added delta:`, JSON.stringify(delta));
}
toJSON() {
@ -78,6 +103,7 @@ class LosslessEntity {
}
return {
id: this.id,
referencedAs: Array.from(this.lossless.referencedAs.get(this.id) ?? []),
properties
};
}
@ -86,9 +112,11 @@ class LosslessEntity {
export class Lossless {
domainEntities = new LosslessEntityMap();
transactions: Transactions;
referencedAs = new Map<string, Set<string>>();
eventStream = new EventEmitter();
// TODO: This referencedAs map doesn't really belong at this layer of abstraction
referencedAs = new Map<string, Set<string>>();
// Track all deltas by ID for negation processing
private allDeltas = new Map<DeltaID, Delta>();
// Track which entities are affected by each delta
@ -185,39 +213,11 @@ export class Lossless {
const seenDeltaIds = new Set<DeltaID>();
// Collect all deltas from all properties
for (const [propertyId, deltas] of Object.entries(view.propertyDeltas)) {
for (const [_propertyId, deltas] of Object.entries(view.propertyDeltas)) {
for (const delta of deltas) {
if (!seenDeltaIds.has(delta.id)) {
seenDeltaIds.add(delta.id);
// Create a new delta using DeltaBuilder
const builder = createDelta(delta.creator, delta.host)
.withId(delta.id)
.withTimestamp(delta.timeCreated);
// Add all pointers from the collapsed delta
for (const pointer of delta.pointers) {
const pointerEntries = Object.entries(pointer);
if (pointerEntries.length === 1) {
const [localContext, target] = pointerEntries[0];
if (target === null || target === undefined) {
continue; // Skip null/undefined targets
}
if (typeof target === 'string' && this.domainEntities.has(target)) {
// This is a reference pointer to an entity
builder.addPointer(localContext, target, propertyId);
} else if (typeof target === 'string' || typeof target === 'number' || typeof target === 'boolean') {
// Scalar pointer with valid type
builder.addPointer(localContext, target);
} else {
// For other types (objects, arrays), convert to string
builder.addPointer(localContext, JSON.stringify(target));
}
}
}
// Build the delta and add to results
allDeltas.push(builder.buildV1());
allDeltas.push(delta);
}
}
}
@ -229,14 +229,14 @@ export class Lossless {
const view: LosslessViewMany = {};
entityIds = entityIds ?? Array.from(this.domainEntities.keys());
for (const id of entityIds) {
const ent = this.domainEntities.get(id);
for (const entityId of entityIds) {
const ent = this.domainEntities.get(entityId);
if (!ent) continue;
const referencedAs = new Set<string>();
const propertyDeltas: {
[key: PropertyID]: CollapsedDelta[]
[key: PropertyID]: Delta[]
} = {};
let hasVisibleDeltas = false;
@ -255,7 +255,7 @@ export class Lossless {
for (const [key, deltas] of ent.properties.entries()) {
// Filter deltas for this property based on negation status
const filteredDeltas = Array.from(deltas).filter(delta => nonNegatedDeltaIds.has(delta.id));
const visibleDeltas: CollapsedDelta[] = [];
const visibleDeltas: Delta[] = [];
for (const delta of filteredDeltas) {
if (deltaFilter && !deltaFilter(delta)) {
@ -271,19 +271,12 @@ export class Lossless {
}
}
const pointers: CollapsedPointer[] = [];
for (const {localContext, target} of delta.pointers) {
if (target === ent.id) {
referencedAs.add(localContext);
}
pointers.push({[localContext]: target});
const ref = delta.pointers.find(p => p.target === entityId)
if (ref) {
referencedAs.add(ref.localContext);
}
visibleDeltas.push({
...delta,
pointers
});
visibleDeltas.push(delta);
hasVisibleDeltas = true;
}
@ -302,10 +295,10 @@ export class Lossless {
// Only include entity in view if it has visible deltas
if (hasVisibleDeltas) {
view[ent.id] = {
id: ent.id,
view[entityId] = {
id: entityId,
referencedAs: Array.from(referencedAs.values()),
propertyDeltas
propertyDeltas,
};
}
}

View File

@ -47,7 +47,6 @@ export abstract class Lossy<Accumulator, Result = Accumulator> {
return this.deltaFilter(delta);
};
const losslessPartial = this.lossless.compose([entityId], combinedFilter);
debug(`Lossless partial for entity ${entityId}:`, JSON.stringify(losslessPartial));
if (!losslessPartial) {
// This should not happen; this should only be called after the lossless view has been updated

View File

@ -1,8 +1,10 @@
import { Lossless, LosslessViewOne } from "../lossless";
import { Lossy } from '../lossy';
import { DomainEntityID, PropertyID, ViewMany } from "../../core/types";
import { valueFromCollapsedDelta } from "../lossless";
import { valueFromDelta } from "../lossless";
import { EntityRecord, EntityRecordMany } from "@src/core/entity";
import Debug from 'debug';
const debug = Debug('rz:test:performance');
export type AggregationType = 'min' | 'max' | 'sum' | 'average' | 'count';
@ -76,18 +78,11 @@ export class AggregationResolver extends Lossy<Accumulator, Result> {
}
// Extract numeric values from all deltas for this property
for (const delta of deltas || []) {
const value = valueFromCollapsedDelta(propertyId, delta);
for (const delta of deltas) {
const value = valueFromDelta(propertyId, delta);
if (typeof value === 'number') {
if (this.config[propertyId] === 'count') {
// For count, include all values (including duplicates)
acc[cur.id].properties[propertyId].values.push(value);
} else {
// For other aggregations, only add unique values
if (!acc[cur.id].properties[propertyId].values.includes(value)) {
acc[cur.id].properties[propertyId].values.push(value);
}
}
}
}
}

View File

@ -1,5 +1,7 @@
import { PropertyID, PropertyTypes } from "../../../core/types";
import { CollapsedDelta } from "../../lossless";
import Debug from 'debug';
const debug = Debug('rz:custom-resolver:plugin');
/**
* Type representing a mapping of dependency names to their state types
@ -15,18 +17,16 @@ export type DependencyStates = Record<string, unknown>;
* @template T - Type of the plugin's internal state
* @template D - Union type of dependency names (e.g., 'discount' | 'tax')
*/
export abstract class ResolverPlugin<
T = unknown,
D extends string = never
> {
export abstract class ResolverPlugin< T = unknown > {
name?: string;
dependencies?: readonly string[];
name?: PropertyID;
constructor(readonly target?: string) {
if (target) {
this.dependencies = [target];
}
}
/**
* Array of property IDs that this plugin depends on.
* The plugins corresponding to these properties will be processed before this plugin.
*/
dependencies?: readonly D[];
/**
* Convenience wrapper to avoid calling update() when there is no new value
@ -42,22 +42,18 @@ export abstract class ResolverPlugin<
delta?: CollapsedDelta,
dependencies?: DependencyStates
): T {
debug(`applyUpdate, currentState: ${JSON.stringify(currentState)}, newValue: ${JSON.stringify(newValue)}, dependencies: ${JSON.stringify(dependencies)}`)
if (newValue === undefined) {
switch(this.dependencies?.length) {
case 0: {
debug(`No new value, checking dependencies. Plugin target is ${JSON.stringify(this.target)}`)
if (this.target && dependencies) {
// Pass the target value as the new value
newValue = dependencies[this.target] as PropertyTypes;
debug(`Found target ${JSON.stringify(this.target)}, value: ${JSON.stringify(newValue)}`)
} else if (!this.dependencies?.length) {
// No dependencies, no new value -- nothing to do.
debug(`No dependencies, no new value -- nothing to do.`)
return currentState;
}
case 1: {
// Only one dependency, use it as the new value.
newValue = dependencies![this.dependencies[0]] as PropertyTypes;
break;
}
default: {
// Pass dependencies as is, and leave newValue undefined.
break;
}
}
}
return this.update(currentState, newValue, delta, dependencies);
};
@ -92,15 +88,15 @@ export abstract class ResolverPlugin<
* Configuration for custom resolver with type-safe plugin configurations
*/
export type CustomResolverConfig = {
[P in PropertyID]: ResolverPlugin<unknown, string>;
[P in PropertyID]: ResolverPlugin<unknown>;
};
/**
* Helper type to extract the state type from a ResolverPlugin
*/
export type PluginState<T> = T extends ResolverPlugin<infer S, string> ? S : never;
export type PluginState<T> = T extends ResolverPlugin<infer S> ? S : never;
/**
* Helper type to extract the dependency names from a ResolverPlugin
*/
export type PluginDependencies<T> = T extends ResolverPlugin<unknown, infer D> ? D : never;
export type PluginDependencies<T> = T extends ResolverPlugin<unknown> ? string[] : never;

View File

@ -13,7 +13,7 @@ type ConcatenationState = {
*
* Concatenates all string values with a separator
*/
export class ConcatenationPlugin extends ResolverPlugin<ConcatenationState, never> {
export class ConcatenationPlugin extends ResolverPlugin<ConcatenationState> {
readonly dependencies = [] as const;
constructor(private separator: string = ' ') {

View File

@ -12,7 +12,7 @@ type FirstWriteWinsState = {
*
* Keeps the first value that was written, ignoring subsequent writes
*/
export class FirstWriteWinsPlugin extends ResolverPlugin<FirstWriteWinsState, never> {
export class FirstWriteWinsPlugin extends ResolverPlugin<FirstWriteWinsState> {
readonly dependencies = [] as const;
initialize(): FirstWriteWinsState {

View File

@ -12,7 +12,7 @@ type LastWriteWinsState = {
*
* Keeps the most recent value based on the delta's timestamp
*/
export class LastWriteWinsPlugin extends ResolverPlugin<LastWriteWinsState, never> {
export class LastWriteWinsPlugin extends ResolverPlugin<LastWriteWinsState> {
readonly dependencies = [] as const;
initialize(): LastWriteWinsState {

View File

@ -10,7 +10,7 @@ type MajorityVoteState = {
*
* Returns the value that appears most frequently
*/
export class MajorityVotePlugin extends ResolverPlugin<MajorityVoteState, never> {
export class MajorityVotePlugin extends ResolverPlugin<MajorityVoteState> {
readonly dependencies = [] as const;
initialize(): MajorityVoteState {

View File

@ -1,4 +1,4 @@
import { PropertyID, PropertyTypes } from "@src/core/types";
import { PropertyTypes } from "@src/core/types";
import { ResolverPlugin, DependencyStates } from "../plugin";
type MaxPluginState = {
@ -10,16 +10,8 @@ type MaxPluginState = {
*
* Tracks the maximum numeric value
*/
export class MaxPlugin<Target extends PropertyID> extends ResolverPlugin<MaxPluginState, Target> {
export class MaxPlugin extends ResolverPlugin<MaxPluginState> {
name = 'max';
readonly dependencies: Target[] = [];
constructor(private readonly target?: Target) {
super();
if (target) {
this.dependencies = [target];
}
}
initialize(dependencies: DependencyStates): MaxPluginState {
return { max: this.target ? dependencies[this.target] as number : undefined };

View File

@ -1,4 +1,4 @@
import { PropertyTypes, PropertyID } from "../../../../core/types";
import { PropertyTypes } from "../../../../core/types";
import { DependencyStates, ResolverPlugin } from "../plugin";
type MinPluginState = {
@ -10,16 +10,8 @@ type MinPluginState = {
*
* Tracks the minimum numeric value
*/
export class MinPlugin<Target extends PropertyID> extends ResolverPlugin<MinPluginState, Target> {
export class MinPlugin extends ResolverPlugin<MinPluginState> {
name = 'min';
readonly dependencies: Target[] = [];
constructor(private readonly target?: Target) {
super();
if (target) {
this.dependencies = [target];
}
}
initialize(dependencies: DependencyStates): MinPluginState {
return { min: this.target ? dependencies[this.target] as number : undefined };

View File

@ -12,7 +12,7 @@ type RunningAverageState = {
*
* Tracks the running average of numeric values
*/
export class RunningAveragePlugin extends ResolverPlugin<RunningAverageState, never> {
export class RunningAveragePlugin extends ResolverPlugin<RunningAverageState> {
readonly dependencies = [] as const;
initialize(): RunningAverageState {

View File

@ -1,9 +1,10 @@
import { CollapsedDelta, Lossless, LosslessViewOne } from "../../lossless";
import { Lossless, LosslessViewOne } from "../../lossless";
import { Lossy } from '../../lossy';
import { DomainEntityID, PropertyID, PropertyTypes } from "../../../core/types";
import { ResolverPlugin, DependencyStates } from "./plugin";
import { EntityRecord } from "@src/core/entity";
import Debug from 'debug';
import { Delta } from "@src/core";
const debug = Debug('rz:custom-resolver');
const debugState = Debug('rz:custom-resolver:state');
@ -29,7 +30,7 @@ type Result = Record<DomainEntityID, EntityRecord>;
* @template D - The type of the plugin's dependencies (defaults to PropertyID)
*/
type PluginMap = {
[P in PropertyID]: ResolverPlugin<unknown, PropertyID>;
[P in PropertyID]: ResolverPlugin<unknown>;
};
/**
@ -85,7 +86,7 @@ export class CustomResolver extends Lossy<Accumulator, Result> {
this.dependencyGraph.forEach((deps, plugin) => {
graphLog[plugin] = Array.from(deps);
});
debug(`Dependency graph: ${JSON.stringify(graphLog, null, 2)}`);
debug(`Dependency graph: ${JSON.stringify(graphLog)}`);
}
@ -99,25 +100,15 @@ export class CustomResolver extends Lossy<Accumulator, Result> {
// Initialize the graph with all plugins
Object.keys(this.config).forEach(pluginKey => {
this.dependencyGraph.set(pluginKey, new Set());
debug(`Added plugin node: ${pluginKey}`);
});
debug('Processing plugin dependencies...');
// Add edges based on dependencies
Object.entries(this.config).forEach(([pluginKey, plugin]) => {
const pluginId = plugin.name || pluginKey;
const deps = plugin.dependencies || [];
if (deps.length === 0) {
debug(`Plugin ${pluginId} has no dependencies`);
} else {
debug(`Plugin ${pluginId} depends on: ${deps.join(', ')}`);
}
deps.forEach((depId: string) => {
// This dependency may have an alias in our current config
const depKey = this.pluginKeyFromBasename(depId);
debug(`Processing dependency ${depKey} for plugin ${pluginKey}`);
if (!this.config[depKey]) {
// TODO: This could still be a property, not a plugin
@ -136,7 +127,6 @@ export class CustomResolver extends Lossy<Accumulator, Result> {
});
debug('Dependency graph construction complete');
debug(`Config: ${JSON.stringify(this.config, null, 2)}`);
this.logGraph();
}
@ -211,19 +201,26 @@ export class CustomResolver extends Lossy<Accumulator, Result> {
for (const depKey of this.executionOrder) {
if (depKey === pluginKey) continue;
const depPlugin = this.config[depKey];
if (depPlugin) {
if (!depPlugin) continue;
if (!entityPluginStates[depKey]) {
dependencyStates[depKey] = depPlugin.initialize(dependencyStates);
entityPluginStates[depKey] = dependencyStates[depKey];
}
dependencyStates[depKey] = depPlugin.resolve(entityPluginStates[depKey], dependencyStates);
}
// We should only include the dependencies for this plugin
Object.keys(dependencyStates).forEach(key => {
if (!plugin.dependencies?.includes(key)) {
delete dependencyStates[key];
}
});
return dependencyStates;
}
private initializePlugins(acc: Accumulator, entityId: DomainEntityID) {
private getEntityState(acc: Accumulator, entityId: DomainEntityID) {
if (!acc[entityId]) {
acc[entityId] = {};
}
@ -241,7 +238,7 @@ export class CustomResolver extends Lossy<Accumulator, Result> {
entityState[pluginKey] = entityState[pluginKey] ?? plugin.initialize(dependencies);
}
return { entityState };
return entityState;
}
/**
@ -251,46 +248,68 @@ export class CustomResolver extends Lossy<Accumulator, Result> {
debug(`Processing deltas for entity: ${entityId}`);
debug('Property deltas:', JSON.stringify(propertyDeltas));
const { entityState } = this.initializePlugins(acc, entityId);
const entityState = this.getEntityState(acc, entityId);
type PropertyRecord = {
delta: Delta;
value: PropertyTypes;
}
// First pass through deltas to see if there are any duplicate property values
const deltaPropertyRecords : Record<PropertyID, PropertyRecord> = {};
for (const [propertyId, deltas] of Object.entries(propertyDeltas)) {
for (const delta of deltas) {
// Iterate through the pointers; throw an error if a duplicate key is found
for (const pointer of delta.pointers.filter(p => p.localContext === propertyId)) {
const deltaPropertyValue = deltaPropertyRecords[propertyId];
if (deltaPropertyValue) {
// It's possible that there are multiple deltas in this set with the same property ID.
// That can only happen if they are part of a transaction. Otherwise this function is
// only called once per delta, per entity affected.
// TODO: More flexible/robust error handling protocols?
// Some views might be more tolerant of errors than others.
debug(`propertyDeltas: ${JSON.stringify(propertyDeltas, null, 2)}`);
throw new Error(`Delta ${delta.id}: '${propertyId}' already has value '${deltaPropertyValue}'`);
}
deltaPropertyRecords[propertyId] = {
delta,
value: pointer.target
};
}
}
}
debug('Delta property records:', JSON.stringify(deltaPropertyRecords));
// Now let's go through each plugin in order.
for (const pluginId of this.executionOrder) {
const pluginKey = this.pluginKeyFromBasename(pluginId);
const plugin = this.config[pluginKey];
if (!plugin) throw new Error(`Plugin for property ${pluginId} not found`);
const pluginState = entityState[pluginKey];
debug(`Processing plugin: ${pluginId} (key: ${pluginKey})`);
const pluginState = entityState[pluginKey];
const deltaPropertyValues : Record<PropertyID, PropertyTypes> = {};
let propertyValue : PropertyTypes | undefined;
let updateDelta : CollapsedDelta | undefined;
for (const [propertyId, deltas] of Object.entries(propertyDeltas)) {
for (const delta of deltas) {
// Iterate through the pointers; throw an error if a duplicate key is found
for (const pointer of delta.pointers) {
if (deltaPropertyValues[propertyId]) {
// It's possible that there are multiple deltas in this set with the same property ID.
// That can only happen if they are part of a transaction. Otherwise this function is
// only called once per delta, per entity affected.
// TODO: More flexible/robust error handling protocols?
// Some views might be more tolerant of errors than others.
throw new Error(`Duplicate property ID ${propertyId} found in delta ${delta.id}`);
}
deltaPropertyValues[propertyId] = pointer[propertyId];
// If there's an updated entity property matching the plugin key,
// pass it to the plugin update as the new property value.
// pass it to plugin.applyUpdate as the new property value.
let propertyValue : PropertyTypes | undefined;
let updateDelta : Delta | undefined;
for (const [propertyId, record] of Object.entries(deltaPropertyRecords)) {
if (propertyId === pluginKey) {
propertyValue = pointer[propertyId];
updateDelta = delta;
}
if (propertyValue !== undefined) {
throw new Error(`Delta ${record.delta.id}: '${propertyId}' already has value '${propertyValue}'`);
}
debug(`Found delta for plugin ${pluginKey}: ${JSON.stringify(record)}`);
propertyValue = record.value;
updateDelta = record.delta;
}
}
// Update the plugin state with the new delta
debug(`Getting dependency states for plugin ${pluginKey}`)
// TODO: There is some redundancy in calling the dependency resolvers. They can be cached/memoized.
const dependencies = this.getDependencyStates(entityState, pluginKey);
debug(`Updating plugin ${pluginKey} with value ${JSON.stringify(propertyValue)}, dependencies: ${JSON.stringify(dependencies)}`)
entityState[pluginKey] = plugin.applyUpdate(pluginState, propertyValue, updateDelta, dependencies);
debugState(`Updated state for entity ${entityId} plugin ${pluginKey}:`,
JSON.stringify(entityState[pluginKey]));
@ -306,7 +325,7 @@ export class CustomResolver extends Lossy<Accumulator, Result> {
for (const entityId in acc) {
if (!entityIds.includes(entityId)) continue;
this.initializePlugins(acc, entityId);
this.getEntityState(acc, entityId);
result[entityId] = {
id: entityId,
@ -324,12 +343,14 @@ export class CustomResolver extends Lossy<Accumulator, Result> {
debug(`State for ${pluginKey}:`, JSON.stringify(state));
const resolvedValue = plugin.resolve(state, dependencies);
if (resolvedValue === undefined) throw new Error(`Resolved value for property ${pluginKey} is undefined`)
//if (resolvedValue === undefined) throw new Error(`Resolved value for property ${pluginKey} is undefined`)
if (resolvedValue !== undefined) {
debug(`Resolved value for ${pluginKey}:`, resolvedValue);
result[entityId].properties[pluginKey] = resolvedValue;
}
}
}
debug(`Result:`, JSON.stringify(result));

View File

@ -0,0 +1,11 @@
import { ResolverPlugin } from "./plugin";
export abstract class TargetedPlugin<T> extends ResolverPlugin<T> {
constructor(target?: string) {
// If no target is provided, then we want to implicitly target the property
// to which this plugin is attached. That means that when we apply an update,
// we want to take the value of
super(target);
}
}

View File

@ -1,7 +1,10 @@
import { EntityProperties } from "../../core/entity";
import { Lossless, LosslessViewOne, CollapsedDelta, valueFromCollapsedDelta } from "../lossless";
import { Lossless, CollapsedDelta, valueFromDelta, LosslessViewOne } from "../lossless";
import { Lossy } from '../lossy';
import { DomainEntityID, PropertyID, PropertyTypes, Timestamp, ViewMany } from "../../core/types";
import Debug from 'debug';
const debug = Debug('rz:views:resolvers:timestamp-resolvers');
export type TimestampedProperty = {
value: PropertyTypes,
@ -88,8 +91,10 @@ export class TimestampResolver extends Lossy<Accumulator, Result> {
for (const [key, deltas] of Object.entries(cur.propertyDeltas)) {
let bestProperty: TimestampedPropertyWithTieBreaking | undefined;
for (const delta of deltas || []) {
const value = valueFromCollapsedDelta(key, delta);
for (const delta of deltas) {
const value = valueFromDelta(key, delta);
debug(`delta: ${JSON.stringify(delta)}`);
debug(`valueFromDelta(${key}) = ${value}`);
if (value === undefined) continue;
const property: TimestampedPropertyWithTieBreaking = {
@ -162,18 +167,18 @@ export function latestFromCollapsedDeltas(
deltas?: CollapsedDelta[]
): {
delta?: CollapsedDelta,
value?: string | number,
value?: PropertyTypes,
timeUpdated?: number
} | undefined {
const res: {
delta?: CollapsedDelta,
value?: string | number,
value?: PropertyTypes,
timeUpdated?: number
} = {};
res.timeUpdated = 0;
for (const delta of deltas || []) {
const value = valueFromCollapsedDelta(key, delta);
const value = valueFromDelta(key, delta);
if (value === undefined) continue;
if (res.timeUpdated && delta.timeCreated < res.timeUpdated) continue;
res.delta = delta;