fixed tons of stuff

This commit is contained in:
Lentil Hoffman 2025-07-08 14:05:23 -05:00
parent 29b1b8bb9a
commit f79a6921fc
Signed by: lentil
GPG Key ID: 0F5B99F3F4D0C087
47 changed files with 596 additions and 519 deletions

View File

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

View File

@ -11,6 +11,8 @@ 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';
import Debug from 'debug';
const debug = Debug('rz:schema-test');
describe('Schema System', () => { describe('Schema System', () => {
let schemaRegistry: DefaultSchemaRegistry; let schemaRegistry: DefaultSchemaRegistry;
@ -158,22 +160,17 @@ describe('Schema System', () => {
// Create a valid lossless view // Create a valid lossless view
const validView: LosslessViewOne = { const validView: LosslessViewOne = {
id: 'user123', id: 'user123',
referencedAs: ['user'],
propertyDeltas: { propertyDeltas: {
name: [{ name: [
id: 'delta1', createDelta('creator1', 'host1')
timeCreated: 123, .addPointer('name', 'Alice')
host: 'host1', .buildV1()
creator: 'creator1', ],
pointers: [{ name: 'Alice' }] age: [
}], createDelta('creator1', 'host1')
age: [{ .addPointer('age', 25)
id: 'delta2', .buildV1()
timeCreated: 124, ]
host: 'host1',
creator: 'creator1',
pointers: [{ age: 25 }]
}]
}, },
}; };
@ -184,15 +181,12 @@ describe('Schema System', () => {
// Test invalid view (missing required property) // Test invalid view (missing required property)
const invalidView: LosslessViewOne = { const invalidView: LosslessViewOne = {
id: 'user456', id: 'user456',
referencedAs: ['user'],
propertyDeltas: { propertyDeltas: {
age: [{ age: [
id: 'delta3', createDelta('creator1', 'host1')
timeCreated: 125, .addPointer('age', 30)
host: 'host1', .buildV1()
creator: 'creator1', ]
pointers: [{ age: 30 }]
}]
} }
}; };
@ -220,11 +214,25 @@ describe('Schema System', () => {
// Valid types // Valid types
const validView: LosslessViewOne = { const validView: LosslessViewOne = {
id: 'test1', id: 'test1',
referencedAs: [],
propertyDeltas: { propertyDeltas: {
stringProp: [{ id: 'd1', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ stringProp: 'hello' }] }], stringProp: [
numberProp: [{ id: 'd2', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ numberProp: 42 }] }], createDelta('creator1', 'host1')
booleanProp: [{ id: 'd3', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ booleanProp: true }] }] .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 // Invalid types
const invalidView: LosslessViewOne = { const invalidView: LosslessViewOne = {
id: 'test2', id: 'test2',
referencedAs: [],
propertyDeltas: { propertyDeltas: {
stringProp: [{ id: 'd4', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ stringProp: 123 as never }] }], stringProp: [
numberProp: [{ id: 'd5', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ numberProp: 'not-number' as never }] }] 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 // Create invalid entity manually
const invalidDelta = createDelta(node.config.creator, node.config.peerId) const invalidDelta = createDelta(node.config.creator, node.config.peerId)
.addPointer('users', 'user3', 'age') .setProperty('user3', 'age', 'not-a-number', 'users')
.addPointer('age', 'not-a-number')
.buildV1(); .buildV1();
node.lossless.ingestDelta(invalidDelta); 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(); const validIds = collection.getValidEntities();
expect(validIds).toContain('user1'); expect(validIds).toContain('user1');
expect(validIds).toContain('user2'); expect(validIds).toContain('user2');

View File

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

View File

@ -45,7 +45,7 @@ describe('Lossless View Compose/Decompose', () => {
expect(decomposed).toHaveLength(2); 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 originalIds = nameDeltas.map(d => d.id).sort();
const decomposedIds = decomposed.map(d => d.id).sort(); const decomposedIds = decomposed.map(d => d.id).sort();
expect(decomposedIds).toEqual(originalIds); expect(decomposedIds).toEqual(originalIds);

View File

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

View File

@ -47,11 +47,11 @@ describe('Lossless', () => {
creator: "a", creator: "a",
host: "h", host: "h",
pointers: [ pointers: [
{actor: "keanu"}, {localContext: "actor", target: "keanu", targetContext: "roles"},
{role: "neo"}, {localContext: "role", target: "neo", targetContext: "actor"},
{film: "the_matrix"}, {localContext: "film", target: "the_matrix", targetContext: "cast"},
{base_salary: 1000000}, {localContext: "base_salary", target: 1000000},
{salary_currency: "usd"}, {localContext: "salary_currency", target: "usd"},
], ],
}], }],
}, },
@ -63,11 +63,11 @@ describe('Lossless', () => {
creator: "a", creator: "a",
host: "h", host: "h",
pointers: [ pointers: [
{actor: "keanu"}, {localContext: "actor", target: "keanu", targetContext: "roles"},
{role: "neo"}, {localContext: "role", target: "neo", targetContext: "actor"},
{film: "the_matrix"}, {localContext: "film", target: "the_matrix", targetContext: "cast"},
{base_salary: 1000000}, {localContext: "base_salary", target: 1000000},
{salary_currency: "usd"}, {localContext: "salary_currency", target: "usd"},
], ],
}], }],
}, },
@ -79,11 +79,11 @@ describe('Lossless', () => {
creator: "a", creator: "a",
host: "h", host: "h",
pointers: [ pointers: [
{actor: "keanu"}, {localContext: "actor", target: "keanu", targetContext: "roles"},
{role: "neo"}, {localContext: "role", target: "neo", targetContext: "actor"},
{film: "the_matrix"}, {localContext: "film", target: "the_matrix", targetContext: "cast"},
{base_salary: 1000000}, {localContext: "base_salary", target: 1000000},
{salary_currency: "usd"}, {localContext: "salary_currency", target: "usd"},
], ],
}], }],
}, },
@ -112,11 +112,11 @@ describe('Lossless', () => {
creator: "a", creator: "a",
host: "h", host: "h",
pointers: [ pointers: [
{actor: "keanu"}, {localContext: "actor", target: "keanu", targetContext: "roles"},
{role: "neo"}, {localContext: "role", target: "neo", targetContext: "actor"},
{film: "the_matrix"}, {localContext: "film", target: "the_matrix", targetContext: "cast"},
{base_salary: 1000000}, {localContext: "base_salary", target: 1000000},
{salary_currency: "usd"}, {localContext: "salary_currency", target: "usd"},
], ],
}], }],
}, },
@ -128,11 +128,11 @@ describe('Lossless', () => {
creator: "a", creator: "a",
host: "h", host: "h",
pointers: [ pointers: [
{actor: "keanu"}, {localContext: "actor", target: "keanu", targetContext: "roles"},
{role: "neo"}, {localContext: "role", target: "neo", targetContext: "actor"},
{film: "the_matrix"}, {localContext: "film", target: "the_matrix", targetContext: "cast"},
{base_salary: 1000000}, {localContext: "base_salary", target: 1000000},
{salary_currency: "usd"}, {localContext: "salary_currency", target: "usd"},
], ],
}], }],
}, },
@ -144,11 +144,11 @@ describe('Lossless', () => {
creator: "a", creator: "a",
host: "h", host: "h",
pointers: [ pointers: [
{actor: "keanu"}, {localContext: "actor", target: "keanu", targetContext: "roles"},
{role: "neo"}, {localContext: "role", target: "neo", targetContext: "actor"},
{film: "the_matrix"}, {localContext: "film", target: "the_matrix", targetContext: "cast"},
{base_salary: 1000000}, {localContext: "base_salary", target: 1000000},
{salary_currency: "usd"}, {localContext: "salary_currency", target: "usd"},
], ],
}], }],
}, },
@ -163,7 +163,7 @@ describe('Lossless', () => {
// First delta // First delta
lossless.ingestDelta( lossless.ingestDelta(
createDelta('A', 'H') createDelta('A', 'H')
.addPointer('1', 'ace', 'value') .setProperty('ace', 'value', '1', 'ace')
.buildV1() .buildV1()
); );
@ -171,25 +171,28 @@ describe('Lossless', () => {
lossless.ingestDelta( lossless.ingestDelta(
createDelta('B', 'H') createDelta('B', 'H')
// 10 11j 12q 13k 14a // 10 11j 12q 13k 14a
.addPointer('14', 'ace', 'value') // .addPointer('14', 'ace', 'value')
.setProperty('ace', 'value', '14', 'ace')
.buildV1() .buildV1()
); );
expect(lossless.compose()).toMatchObject({ expect(lossless.compose()).toMatchObject({
ace: { ace: {
referencedAs: ["1", "14"], referencedAs: ["ace"],
propertyDeltas: { propertyDeltas: {
value: [{ value: [{
creator: 'A', creator: 'A',
host: 'H', host: 'H',
pointers: [ pointers: [
{"1": "ace"}, {localContext: "ace", target: "ace", targetContext: "value"},
{localContext: "value", target: "1"},
] ]
}, { }, {
creator: 'B', creator: 'B',
host: 'H', host: 'H',
pointers: [ 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({ expect(lossless.compose(undefined, filter)).toMatchObject({
ace: { ace: {
referencedAs: ["1"], referencedAs: ["ace"],
propertyDeltas: { propertyDeltas: {
value: [{ value: [{
creator: 'A', creator: 'A',
host: 'H', host: 'H',
pointers: [ 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({ expect(lossless.compose(["ace"], filter)).toMatchObject({
ace: { ace: {
referencedAs: ["1"], referencedAs: ["ace"],
propertyDeltas: { propertyDeltas: {
value: [{ value: [{
creator: 'A', creator: 'A',
host: 'H', host: 'H',
pointers: [ 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 { Lossless, LosslessViewOne } from "@src/views/lossless";
import { Lossy } from "@src/views/lossy"; import { Lossy } from "@src/views/lossy";
import { RhizomeNode } from "@src/node"; 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 { latestFromCollapsedDeltas } from "@src/views/resolvers/timestamp-resolvers";
import { createDelta } from "@src/core/delta-builder"; import { createDelta } from "@src/core/delta-builder";
const debug = Debug('rz:test:lossy'); const debug = Debug('rz:test:lossy');
@ -38,10 +38,10 @@ class Summarizer extends Lossy<Summary> {
// TODO: Prove with failing test // TODO: Prove with failing test
reducer(acc: Summary, cur: LosslessViewOne): Summary { 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)); this.debug(`lossless view:`, JSON.stringify(cur));
if (cur.referencedAs.includes("role")) { if (cur.referencedAs?.includes("role")) {
this.debug(`Found role entity: ${cur.id}`); this.debug(`Found role entity: ${cur.id}`);
const actorDeltas = cur.propertyDeltas["actor"]; const actorDeltas = cur.propertyDeltas["actor"];
@ -60,7 +60,7 @@ class Summarizer extends Lossy<Summary> {
} }
this.debug(`Found actor: ${actor}`); this.debug(`Found actor: ${actor}`);
const film = valueFromCollapsedDelta("film", delta); const film = valueFromDelta("film", delta);
if (!film) { if (!film) {
this.debug('No film property found in delta'); 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 { 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';
import Debug from 'debug';
const debug = Debug('rz:test:multi-pointer-resolution');
describe('Multi-Pointer Delta Resolution', () => { describe('Multi-Pointer Delta Resolution', () => {
let node: RhizomeNode; let node: RhizomeNode;
@ -255,6 +257,8 @@ describe('Multi-Pointer Delta Resolution', () => {
{ maxDepth: 2 } { maxDepth: 2 }
); );
debug(`nestedProjectView:`, JSON.stringify(nestedProjectView, null, 2));
expect(nestedProjectView.id).toBe('website'); expect(nestedProjectView.id).toBe('website');
expect(nestedProjectView.nestedObjects.collaborations).toBeDefined(); expect(nestedProjectView.nestedObjects.collaborations).toBeDefined();

View File

@ -16,7 +16,7 @@ describe('Basic Dependency Resolution', () => {
test('should resolve dependencies in correct order', () => { test('should resolve dependencies in correct order', () => {
// Define a simple plugin that depends on another // 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; readonly dependencies = [] as const;
initialize() { 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; readonly dependencies = ['first'] as const;
initialize() { initialize() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,7 @@ import { testResolverWithPlugins, createTestDelta } from '@test-helpers/resolver
import Debug from 'debug'; import Debug from 'debug';
const debug = Debug('rz:test:discount-plugins'); const debug = Debug('rz:test:discount-plugins');
// Mock plugins for testing // Mock plugins for testing
class DiscountPlugin extends ResolverPlugin<number, never> { class DiscountPlugin extends ResolverPlugin<number> {
readonly name = 'discount' as const; readonly name = 'discount' as const;
readonly dependencies = [] as const; readonly dependencies = [] as const;
@ -14,9 +14,12 @@ class DiscountPlugin extends ResolverPlugin<number, never> {
} }
update( update(
_currentState: number, currentState: number,
newValue: PropertyTypes, newValue: PropertyTypes,
) { ) {
if (newValue === undefined) {
return currentState;
}
const numValue = typeof newValue === 'number' ? newValue : 0; const numValue = typeof newValue === 'number' ? newValue : 0;
const clampedValue = Math.min(100, Math.max(0, numValue)); // Clamp between 0-100 const clampedValue = Math.min(100, Math.max(0, numValue)); // Clamp between 0-100
debug(`DiscountPlugin: updated discount to ${clampedValue}`); 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 name = 'price' as const;
readonly dependencies = ['discount'] as const; readonly dependencies = ['discount'] as const;
@ -37,9 +40,13 @@ class DiscountedPricePlugin extends ResolverPlugin<number | null, 'discount'> {
} }
update( update(
_currentState: number | null, currentState: number | null,
newValue: PropertyTypes, newValue: PropertyTypes,
) { ) {
debug(`DiscountedPricePlugin: updating price with state ${currentState} and value ${newValue}`)
if (newValue === undefined) {
return currentState;
}
const numValue = typeof newValue === 'number' ? newValue : 0; const numValue = typeof newValue === 'number' ? newValue : 0;
debug(`DiscountedPricePlugin: updated price to ${numValue}`); debug(`DiscountedPricePlugin: updated price to ${numValue}`);
return numValue; return numValue;
@ -49,13 +56,16 @@ class DiscountedPricePlugin extends ResolverPlugin<number | null, 'discount'> {
state: number | null, state: number | null,
dependencies: DependencyStates dependencies: DependencyStates
): number | null { ): number | null {
debug(`DiscountedPricePlugin: resolving price with state ${state} and discount ${dependencies.discount}`);
if (state === null) { if (state === null) {
return null; return null;
} }
// Ensure discount is a number and default to 0 if undefined // Ensure discount is a number and default to 0 if undefined
const discount = typeof dependencies.discount === 'number' ? dependencies.discount : 0; const discount = typeof dependencies.discount === 'number' ? dependencies.discount : 0;
const discountMultiplier = (100 - discount) / 100; 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 // Arrange & Act
const entityId = 'player1'; const entityId = 'player1';
await testResolverWithPlugins({ const result = await testResolverWithPlugins({
entityId, entityId,
plugins: { plugins: {
score: new RunningAveragePlugin() score: new RunningAveragePlugin()
@ -28,20 +28,18 @@ describe('RunningAveragePlugin', () => {
.withTimestamp(3000) .withTimestamp(3000)
.setProperty(entityId, 'score', 30, 'game') .setProperty(entityId, 'score', 30, 'game')
.buildV1() .buildV1()
], ]});
expectedResult: (result) => {
// Assert // Assert
expect(result).toBeDefined(); expect(result).toBeDefined();
expect(result.properties.score).toBe(20); // (10 + 20 + 30) / 3 = 20 expect(result.properties.score).toBe(20); // (10 + 20 + 30) / 3 = 20
}
});
}); });
test('should handle non-numeric values gracefully', async () => { test('should handle non-numeric values gracefully', async () => {
// Arrange & Act // Arrange & Act
const entityId = 'test1'; const entityId = 'test1';
await testResolverWithPlugins({ const result = await testResolverWithPlugins({
entityId, entityId,
plugins: { plugins: {
value: new RunningAveragePlugin() value: new RunningAveragePlugin()
@ -55,36 +53,25 @@ describe('RunningAveragePlugin', () => {
.withTimestamp(2000) .withTimestamp(2000)
.setProperty(entityId, 'value', 10, 'test') .setProperty(entityId, 'value', 10, 'test')
.buildV1() .buildV1()
], ]});
expectedResult: (result) => {
// Assert // Assert
expect(result).toBeDefined(); expect(result).toBeDefined();
// First value is treated as 0, second as 10, average is (0 + 10) / 2 = 5
expect(result.properties.value).toBe(5); expect(result.properties.value).toBe(5);
}
});
}); });
test('should handle empty state', async () => { test('should handle empty state', async () => {
// Arrange & Act // Arrange & Act
const entityId = 'non-existent'; const entityId = 'non-existent';
let error: Error | undefined;
try { const result = await testResolverWithPlugins({
await testResolverWithPlugins({
entityId, entityId,
plugins: { plugins: {
value: new RunningAveragePlugin() value: new RunningAveragePlugin()
}, },
deltas: [], deltas: [],
expectedResult: () => {}
}); });
} catch (err) {
error = err as Error;
}
// Assert - The helper should throw when entity is not found expect(result).toBeUndefined();
expect(error).toBeDefined();
expect(error?.message).toContain('No view found for entity non-existent');
}); });
}); });

View File

@ -8,11 +8,12 @@ import { ResolverPlugin } from '@src/views/resolvers/custom-resolvers/plugin';
// const debug = Debug('rz:test:resolver'); // const debug = Debug('rz:test:resolver');
// Mock plugins for testing // Mock plugins for testing
class TestPlugin extends ResolverPlugin<unknown, string> { class TestPlugin extends ResolverPlugin<unknown> {
name: string; name: string;
dependencies: readonly string[]; dependencies: readonly string[];
constructor(name: string, dependencies: string[] = []) { constructor(name: string, dependencies: string[] = []) {
super();
this.name = name; this.name = name;
this.dependencies = dependencies; this.dependencies = dependencies;
} }
@ -70,11 +71,11 @@ describe('CustomResolver', () => {
const graph = resolver.dependencyGraph; const graph = resolver.dependencyGraph;
// Assert // Assert
expect(graph.get('a')).toBeDefined(); expect(graph.get('plugin:a')).toBeDefined();
expect(graph.get('b')).toBeDefined(); expect(graph.get('plugin:b')).toBeDefined();
// Both 'plugin:b' and 'another:b' should be in the graph as 'b' // 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', () => { 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 // 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; readonly dependencies = [] as const;
seenStates: Record<string, unknown>[] = []; seenStates: Record<string, unknown>[] = [];
@ -51,7 +51,7 @@ describe('State Visibility', () => {
} }
// A simple plugin that depends on another property // 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; readonly dependencies = ['dependsOn'] as const;
seenStates: Record<string, unknown>[] = []; seenStates: Record<string, unknown>[] = [];
@ -94,8 +94,8 @@ describe('State Visibility', () => {
lossless.ingestDelta( lossless.ingestDelta(
createDelta('user1', 'host1') createDelta('user1', 'host1')
.withTimestamp(1000) .withTimestamp(1000)
.setProperty('entity1', 'prop1', 'value1', 'prop1') .setProperty('entity1', 'prop1', 'value1', 'entity-prop1')
.setProperty('entity1', 'prop2', 'value2', 'prop2') .setProperty('entity1', 'prop2', 'value2', 'entity-prop2')
.buildV1() .buildV1()
); );
@ -189,7 +189,7 @@ describe('State Visibility', () => {
}); });
test('should throw error for unknown dependencies', () => { 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; readonly dependencies = ['nonexistent'] as const;
initialize() { initialize() {
@ -217,6 +217,6 @@ describe('State Visibility', () => {
new CustomResolver(lossless, { new CustomResolver(lossless, {
bad: new PluginWithBadDeps() 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, DeltaIdTimestampResolver,
HostIdTimestampResolver, HostIdTimestampResolver,
LexicographicTimestampResolver LexicographicTimestampResolver
} from "../../../../src"; } from "@src";
import { createDelta } from "../../../../src/core/delta-builder"; import { createDelta } from "@src/core/delta-builder";
import Debug from "debug";
const debug = Debug('rz:test:timestamp-resolvers');
describe('Timestamp Resolvers', () => { describe('Timestamp Resolvers', () => {
let node: RhizomeNode; let node: RhizomeNode;
@ -20,6 +22,8 @@ describe('Timestamp Resolvers', () => {
describe('Basic Timestamp Resolution', () => { describe('Basic Timestamp Resolution', () => {
test('should resolve by most recent timestamp', () => { test('should resolve by most recent timestamp', () => {
const resolver = new TimestampResolver(lossless);
// Add older delta // Add older delta
lossless.ingestDelta(createDelta('user1', 'host1') lossless.ingestDelta(createDelta('user1', 'host1')
.withId('delta1') .withId('delta1')
@ -38,14 +42,16 @@ describe('Timestamp Resolvers', () => {
.buildV1() .buildV1()
); );
const resolver = new TimestampResolver(lossless);
const result = resolver.resolve(); const result = resolver.resolve();
expect(result).toBeDefined(); expect(result).toBeDefined();
debug(`Result: ${JSON.stringify(result, null, 2)}`)
expect(result!['entity1'].properties.score).toBe(20); // More recent value wins expect(result!['entity1'].properties.score).toBe(20); // More recent value wins
}); });
test('should handle multiple entities with different timestamps', () => { test('should handle multiple entities with different timestamps', () => {
const resolver = new TimestampResolver(lossless);
// Entity1 - older value // Entity1 - older value
lossless.ingestDelta(createDelta('user1', 'host1') lossless.ingestDelta(createDelta('user1', 'host1')
.withTimestamp(1000) .withTimestamp(1000)
@ -62,7 +68,6 @@ describe('Timestamp Resolvers', () => {
.buildV1() .buildV1()
); );
const resolver = new TimestampResolver(lossless);
const result = resolver.resolve(); const result = resolver.resolve();
expect(result).toBeDefined(); expect(result).toBeDefined();
@ -73,6 +78,8 @@ describe('Timestamp Resolvers', () => {
describe('Tie-Breaking Strategies', () => { describe('Tie-Breaking Strategies', () => {
test('should break ties using creator-id strategy', () => { test('should break ties using creator-id strategy', () => {
const resolver = new CreatorIdTimestampResolver(lossless);
// Two deltas with same timestamp, different creators // Two deltas with same timestamp, different creators
lossless.ingestDelta(createDelta('user_z', 'host1') lossless.ingestDelta(createDelta('user_z', 'host1')
.withId('delta1') .withId('delta1')
@ -90,7 +97,6 @@ describe('Timestamp Resolvers', () => {
.buildV1() .buildV1()
); );
const resolver = new CreatorIdTimestampResolver(lossless);
const result = resolver.resolve(); const result = resolver.resolve();
expect(result).toBeDefined(); expect(result).toBeDefined();
@ -99,6 +105,8 @@ describe('Timestamp Resolvers', () => {
}); });
test('should break ties using delta-id strategy', () => { test('should break ties using delta-id strategy', () => {
const resolver = new DeltaIdTimestampResolver(lossless);
// Two deltas with same timestamp, different delta IDs // Two deltas with same timestamp, different delta IDs
lossless.ingestDelta(createDelta('user1', 'host1') lossless.ingestDelta(createDelta('user1', 'host1')
.withId('delta_a') // Lexicographically earlier .withId('delta_a') // Lexicographically earlier
@ -116,7 +124,6 @@ describe('Timestamp Resolvers', () => {
.buildV1() .buildV1()
); );
const resolver = new DeltaIdTimestampResolver(lossless);
const result = resolver.resolve(); const result = resolver.resolve();
expect(result).toBeDefined(); expect(result).toBeDefined();
@ -125,6 +132,8 @@ describe('Timestamp Resolvers', () => {
}); });
test('should break ties using host-id strategy', () => { test('should break ties using host-id strategy', () => {
const resolver = new HostIdTimestampResolver(lossless);
// Two deltas with same timestamp, different hosts // Two deltas with same timestamp, different hosts
lossless.ingestDelta(createDelta('user1', 'host_z') // Lexicographically later lossless.ingestDelta(createDelta('user1', 'host_z') // Lexicographically later
.withId('delta1') .withId('delta1')
@ -142,7 +151,6 @@ describe('Timestamp Resolvers', () => {
.buildV1() .buildV1()
); );
const resolver = new HostIdTimestampResolver(lossless);
const result = resolver.resolve(); const result = resolver.resolve();
expect(result).toBeDefined(); expect(result).toBeDefined();
@ -151,6 +159,8 @@ describe('Timestamp Resolvers', () => {
}); });
test('should break ties using lexicographic strategy with string values', () => { test('should break ties using lexicographic strategy with string values', () => {
const resolver = new LexicographicTimestampResolver(lossless);
// Two deltas with same timestamp, different string values // Two deltas with same timestamp, different string values
lossless.ingestDelta(createDelta('user1', 'host1') lossless.ingestDelta(createDelta('user1', 'host1')
.withId('delta1') .withId('delta1')
@ -168,7 +178,6 @@ describe('Timestamp Resolvers', () => {
.buildV1() .buildV1()
); );
const resolver = new LexicographicTimestampResolver(lossless);
const result = resolver.resolve(); const result = resolver.resolve();
expect(result).toBeDefined(); 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)', () => { 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) // Two deltas with same timestamp, numeric values (should fall back to delta ID comparison)
lossless.ingestDelta(createDelta('user1', 'host1') lossless.ingestDelta(createDelta('user1', 'host1')
.withId('delta_a') // Lexicographically earlier .withId('delta_a') // Lexicographically earlier
@ -194,7 +205,6 @@ describe('Timestamp Resolvers', () => {
.buildV1() .buildV1()
); );
const resolver = new LexicographicTimestampResolver(lossless);
const result = resolver.resolve(); const result = resolver.resolve();
expect(result).toBeDefined(); expect(result).toBeDefined();
@ -205,6 +215,9 @@ describe('Timestamp Resolvers', () => {
describe('Complex Tie-Breaking Scenarios', () => { describe('Complex Tie-Breaking Scenarios', () => {
test('should handle multiple properties with different tie-breaking outcomes', () => { 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 // Add deltas for multiple properties with same timestamp
lossless.ingestDelta(createDelta('user_a', 'host1') lossless.ingestDelta(createDelta('user_a', 'host1')
.withId('delta_z') .withId('delta_z')
@ -222,9 +235,6 @@ describe('Timestamp Resolvers', () => {
.buildV1() .buildV1()
); );
const creatorResolver = new CreatorIdTimestampResolver(lossless);
const deltaResolver = new DeltaIdTimestampResolver(lossless);
const creatorResult = creatorResolver.resolve(); const creatorResult = creatorResolver.resolve();
const deltaResult = deltaResolver.resolve(); const deltaResult = deltaResolver.resolve();
@ -239,6 +249,8 @@ describe('Timestamp Resolvers', () => {
}); });
test('should work consistently with timestamp priority over tie-breaking', () => { test('should work consistently with timestamp priority over tie-breaking', () => {
const resolver = new CreatorIdTimestampResolver(lossless);
// Add older delta with "better" tie-breaking attributes // Add older delta with "better" tie-breaking attributes
lossless.ingestDelta(createDelta('user_z', 'host1') lossless.ingestDelta(createDelta('user_z', 'host1')
.withId('delta_z') // Would win in delta ID tie-breaking .withId('delta_z') // Would win in delta ID tie-breaking
@ -257,7 +269,6 @@ describe('Timestamp Resolvers', () => {
.buildV1() .buildV1()
); );
const resolver = new CreatorIdTimestampResolver(lossless);
const result = resolver.resolve(); const result = resolver.resolve();
expect(result).toBeDefined(); expect(result).toBeDefined();
@ -268,6 +279,7 @@ describe('Timestamp Resolvers', () => {
describe('Edge Cases', () => { describe('Edge Cases', () => {
test('should handle single delta correctly', () => { test('should handle single delta correctly', () => {
const resolver = new TimestampResolver(lossless, 'creator-id');
lossless.ingestDelta(createDelta('user1', 'host1') lossless.ingestDelta(createDelta('user1', 'host1')
.withId('delta1') .withId('delta1')
.withTimestamp(1000) .withTimestamp(1000)
@ -276,7 +288,6 @@ describe('Timestamp Resolvers', () => {
.buildV1() .buildV1()
); );
const resolver = new TimestampResolver(lossless, 'creator-id');
const result = resolver.resolve(); const result = resolver.resolve();
expect(result).toBeDefined(); expect(result).toBeDefined();
@ -284,6 +295,8 @@ describe('Timestamp Resolvers', () => {
}); });
test('should handle mixed value types correctly', () => { test('should handle mixed value types correctly', () => {
const resolver = new TimestampResolver(lossless);
lossless.ingestDelta(createDelta('user1', 'host1') lossless.ingestDelta(createDelta('user1', 'host1')
.withId('delta1') .withId('delta1')
.withTimestamp(1000) .withTimestamp(1000)
@ -300,7 +313,6 @@ describe('Timestamp Resolvers', () => {
.buildV1() .buildV1()
); );
const resolver = new TimestampResolver(lossless);
const result = resolver.resolve(); const result = resolver.resolve();
expect(result).toBeDefined(); expect(result).toBeDefined();

View File

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

View File

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

View File

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

View File

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

View File

@ -21,7 +21,7 @@ The Custom Resolver system provides a powerful dependency management system that
## Example ## Example
```typescript ```typescript
class TotalPricePlugin extends ResolverPlugin<TotalState, 'price' | 'tax'> { class TotalPricePlugin extends ResolverPlugin<TotalState> {
readonly dependencies = ['price', 'tax'] as const; readonly dependencies = ['price', 'tax'] as const;
initialize(): TotalState { 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: The `ResolverPlugin` interface uses TypeScript generics to enforce type safety:
```typescript ```typescript
interface ResolverPlugin<T = unknown, D extends string = never> { interface ResolverPlugin<T = unknown> {
readonly name: string; readonly name: string;
readonly dependencies?: readonly D[]; readonly dependencies?: readonly D[];
@ -37,7 +37,7 @@ type DependencyStates = {
Dependencies are declared as a readonly array of string literals: Dependencies are declared as a readonly array of string literals:
```typescript ```typescript
class MyPlugin extends ResolverPlugin<MyState, 'dep1' | 'dep2'> { class MyPlugin extends ResolverPlugin<MyState> {
readonly dependencies = ['dep1', 'dep2'] as const; readonly dependencies = ['dep1', 'dep2'] as const;
// ... implementation // ... implementation
@ -101,7 +101,7 @@ if (typeof deps.price === 'number') {
### Optional Dependencies ### Optional Dependencies
```typescript ```typescript
class MyPlugin extends ResolverPlugin<MyState, 'required' | 'optional?'> { class MyPlugin extends ResolverPlugin<MyState> {
readonly dependencies = ['required', 'optional?'] as const; readonly dependencies = ['required', 'optional?'] as const;
update(_state: MyState, _value: unknown, _delta: CollapsedDelta, deps: any) { update(_state: MyState, _value: unknown, _delta: CollapsedDelta, deps: any) {
@ -118,7 +118,7 @@ class MyPlugin extends ResolverPlugin<MyState, 'required' | 'optional?'> {
```typescript ```typescript
type PriceDependencies = 'price1' | 'price2' | 'price3'; 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; readonly dependencies: readonly PriceDependencies[] = ['price1', 'price2', 'price3'] as const;
update(_state: PriceState, _value: unknown, _delta: CollapsedDelta, deps: any) { 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: To depend on other properties, specify the dependency types:
```typescript ```typescript
class DiscountedPricePlugin extends ResolverPlugin<DiscountState, 'basePrice' | 'discount'> { class DiscountedPricePlugin extends ResolverPlugin<DiscountState> {
readonly dependencies = ['basePrice', 'discount'] as const; readonly dependencies = ['basePrice', 'discount'] as const;
initialize(): DiscountState { initialize(): DiscountState {

View File

@ -91,8 +91,7 @@ export abstract class Collection<View> {
if (deltas.length > 1) { if (deltas.length > 1) {
// We can generate a separate delta describing this transaction // We can generate a separate delta describing this transaction
transactionDelta = createDelta(creator, host) transactionDelta = createDelta(creator, host)
.addPointer('_transaction', transactionId, 'size') .declareTransaction(transactionId, deltas.length)
.addPointer('size', deltas.length)
.buildV1(); .buildV1();
// Also need to annotate the deltas with the transactionId // Also need to annotate the deltas with the transactionId
@ -123,16 +122,21 @@ export abstract class Collection<View> {
} }
getIds(): string[] { getIds(): string[] {
if (!this.rhizomeNode) return []; if (!this.rhizomeNode) {
const set = this.rhizomeNode.lossless.referencedAs.get(this.name); debug(`No rhizome node connected`)
if (!set) return []; return [];
return Array.from(set.values()); }
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( async put(
entityId: DomainEntityID | undefined, entityId: DomainEntityID | undefined,
properties: EntityProperties, properties: EntityProperties,

View File

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

View File

@ -2,6 +2,7 @@ import { DeltaV1, DeltaV2 } from './delta';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { PropertyTypes } from './types'; import { PropertyTypes } from './types';
import { PointersV2 } from './delta'; import { PointersV2 } from './delta';
import { DeltaNetworkImageV1, DeltaNetworkImageV2 } from './delta';
import Debug from 'debug'; import Debug from 'debug';
const debug = Debug('rz:delta-builder'); const debug = Debug('rz:delta-builder');
@ -16,6 +17,7 @@ export class DeltaBuilder {
private host: string; private host: string;
private creator: string; private creator: string;
private pointers: PointersV2 = {}; private pointers: PointersV2 = {};
private references: Record<string, string | null> = {};
/** /**
* Create a new DeltaBuilder instance * 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. * ! 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 * ! 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(entityLabel, entityId, property)
this.addPointer(property, value); this.addPointer(property, value);
return this; return this;
@ -123,6 +125,37 @@ export class DeltaBuilder {
return this; 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 * Build and return a DeltaV2 instance
*/ */

View File

@ -137,7 +137,7 @@ export class HttpApi {
const {params: {id}} = req; const {params: {id}} = req;
const v = this.rhizomeNode.lossless.compose([id]); const v = this.rhizomeNode.lossless.compose([id]);
const ent = v[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}); res.status(400).json({error: "Entity is not a transaction", id});
return; return;
} }

View File

@ -2,9 +2,9 @@ import jsonLogic from 'json-logic-js';
const { apply, is_logic } = jsonLogic; const { apply, is_logic } = jsonLogic;
import Debug from 'debug'; import Debug from 'debug';
import { SchemaRegistry, SchemaID, ObjectSchema } from '../schema/schema'; 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 { DomainEntityID } from '../core/types';
import { DeltaFilter } from '../core/delta'; import { Delta, DeltaFilter } from '../core/delta';
const debug = Debug('rz:query'); const debug = Debug('rz:query');
@ -290,7 +290,7 @@ export class QueryEngine {
case 'reference': { case 'reference': {
// For references, include the target IDs // For references, include the target IDs
const refValues = deltas const refValues = deltas
.map(delta => this.extractReferenceValue(delta, propertyId)) .map(delta => this.extractPrimitiveValue(delta, propertyId))
.filter(value => value !== null); .filter(value => value !== null);
obj[propertyId] = refValues; obj[propertyId] = refValues;
break; break;
@ -308,28 +308,8 @@ export class QueryEngine {
/** /**
* Extract primitive value from a delta for a given property * Extract primitive value from a delta for a given property
*/ */
private extractPrimitiveValue(delta: CollapsedDelta, propertyId: string): unknown { private extractPrimitiveValue(delta: Delta, propertyId: string): unknown {
// Look for the value in collapsed pointers return valueFromDelta(propertyId, delta);
// 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;
} }
/** /**

View File

@ -254,7 +254,7 @@ export class StorageQueryEngine {
case 'reference': { case 'reference': {
// For references, include the target IDs // For references, include the target IDs
const refValues = propDeltas const refValues = propDeltas
.map(delta => this.extractReferenceValue(delta, propertyId)) .map(delta => this.extractPrimitiveValue(delta, propertyId))
.filter(value => value !== null); .filter(value => value !== null);
properties[propertyId] = refValues; properties[propertyId] = refValues;
break; break;
@ -282,18 +282,6 @@ export class StorageQueryEngine {
return null; 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 * Apply JSON Logic filter to entity results
*/ */

View File

@ -14,9 +14,10 @@ import {
SchemaApplicationOptions, SchemaApplicationOptions,
ResolutionContext ResolutionContext
} from '../schema/schema'; } from '../schema/schema';
import { LosslessViewOne, Lossless } from '../views/lossless'; import { Lossless, LosslessViewOne } from '../views/lossless';
import { DomainEntityID, PropertyID, PropertyTypes } from '../core/types'; import { DomainEntityID, PropertyID, PropertyTypes } from '../core/types';
import { CollapsedDelta } from '../views/lossless'; import { CollapsedDelta } from '../views/lossless';
import { Delta } from '@src/core';
const debug = Debug('rz:schema-registry'); const debug = Debug('rz:schema-registry');
@ -146,11 +147,13 @@ export class DefaultSchemaRegistry implements SchemaRegistry {
// Validate each delta for this property // Validate each delta for this property
for (const delta of deltas) { for (const delta of deltas) {
debug(`Validating delta ${delta.id} on property ${propertyId}`);
const validationResult = this.validateDeltaAgainstPropertySchema( const validationResult = this.validateDeltaAgainstPropertySchema(
delta, delta,
propertySchema, propertySchema,
propertyId propertyId
); );
debug(`Validation result for delta ${delta.id}: ${JSON.stringify(validationResult)}`)
errors.push(...validationResult.errors); errors.push(...validationResult.errors);
warnings.push(...validationResult.warnings); warnings.push(...validationResult.warnings);
} }
@ -176,7 +179,7 @@ export class DefaultSchemaRegistry implements SchemaRegistry {
} }
private validateDeltaAgainstPropertySchema( private validateDeltaAgainstPropertySchema(
delta: CollapsedDelta, delta: Delta,
schema: PropertySchema, schema: PropertySchema,
propertyId: PropertyID propertyId: PropertyID
): SchemaValidationResult { ): SchemaValidationResult {
@ -184,7 +187,7 @@ export class DefaultSchemaRegistry implements SchemaRegistry {
const warnings: SchemaValidationError[] = []; const warnings: SchemaValidationError[] = [];
// Extract the value from the delta // 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) { if (!valuePointer) {
errors.push({ errors.push({
property: propertyId, property: propertyId,
@ -193,7 +196,7 @@ export class DefaultSchemaRegistry implements SchemaRegistry {
return { valid: false, errors, warnings }; return { valid: false, errors, warnings };
} }
const value = valuePointer[propertyId]; const value = valuePointer.target;
switch (schema.type) { switch (schema.type) {
case 'primitive': case 'primitive':
@ -444,7 +447,7 @@ export class DefaultSchemaRegistry implements SchemaRegistry {
} }
private resolveReferenceProperty( private resolveReferenceProperty(
deltas: CollapsedDelta[], deltas: Delta[],
referenceSchema: ReferenceSchema, referenceSchema: ReferenceSchema,
losslessView: Lossless, losslessView: Lossless,
context: ResolutionContext, context: ResolutionContext,
@ -508,7 +511,7 @@ export class DefaultSchemaRegistry implements SchemaRegistry {
} }
private createCompositeObjectFromDelta( private createCompositeObjectFromDelta(
delta: CollapsedDelta, delta: Delta,
parentEntityId: string, parentEntityId: string,
targetSchema: SchemaID, targetSchema: SchemaID,
losslessView: Lossless, losslessView: Lossless,
@ -520,8 +523,7 @@ export class DefaultSchemaRegistry implements SchemaRegistry {
let entityReferenceCount = 0; let entityReferenceCount = 0;
let scalarCount = 0; let scalarCount = 0;
for (const pointer of delta.pointers) { for (const {localContext, target} of delta.pointers) {
for (const [localContext, target] of Object.entries(pointer)) {
// Skip the pointer that references the parent entity (the "up" pointer) // Skip the pointer that references the parent entity (the "up" pointer)
if (typeof target === 'string' && target === parentEntityId) { if (typeof target === 'string' && target === parentEntityId) {
continue; continue;
@ -544,7 +546,6 @@ export class DefaultSchemaRegistry implements SchemaRegistry {
scalarCount++; scalarCount++;
} }
} }
}
// If no non-parent pointers found, return null // If no non-parent pointers found, return null
if (Object.keys(pointersByContext).length === 0) { 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 {DomainEntityID, PropertyID, PropertyTypes, TransactionID, ViewMany} from "../core/types";
import {Negation} from '../features/negation'; import {Negation} from '../features/negation';
import {NegationHelper} from '../features/negation'; import {NegationHelper} from '../features/negation';
import { createDelta } from '../core/delta-builder';
const debug = Debug('rz:lossless'); const debug = Debug('rz:lossless');
export type CollapsedPointer = {[key: PropertyID]: PropertyTypes}; 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 // Extract a particular value from a delta's pointers
export function valueFromCollapsedDelta( export function valueFromDelta(
key: string, key: string,
delta: CollapsedDelta delta: Delta | CollapsedDelta
): string | number | undefined { ): PropertyTypes | undefined {
let result: PropertyTypes | undefined;
for (const pointer of delta.pointers) { 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)) { for (const [k, value] of Object.entries(pointer)) {
if (k === key && (typeof value === "string" || typeof value === "number")) { if (k === key) {
return value; 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 = { export type CollapsedViewOne = Omit<LosslessViewOne, 'propertyDeltas'> & {
id: DomainEntityID, propertyCollapsedDeltas: {
referencedAs: string[];
propertyDeltas: {
[key: PropertyID]: CollapsedDelta[] [key: PropertyID]: CollapsedDelta[]
} }
}; };
export type LosslessViewMany = ViewMany<LosslessViewOne>; export type LosslessViewMany = ViewMany<LosslessViewOne>;
export type CollapsedViewMany = ViewMany<CollapsedViewOne>;
class LosslessEntityMap extends Map<DomainEntityID, LosslessEntity> {}; class LosslessEntityMap extends Map<DomainEntityID, LosslessEntity> {};
@ -67,8 +91,9 @@ class LosslessEntity {
} }
propertyDeltas.add(delta); 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() { toJSON() {
@ -78,6 +103,7 @@ class LosslessEntity {
} }
return { return {
id: this.id, id: this.id,
referencedAs: Array.from(this.lossless.referencedAs.get(this.id) ?? []),
properties properties
}; };
} }
@ -86,9 +112,11 @@ class LosslessEntity {
export class Lossless { export class Lossless {
domainEntities = new LosslessEntityMap(); domainEntities = new LosslessEntityMap();
transactions: Transactions; transactions: Transactions;
referencedAs = new Map<string, Set<string>>();
eventStream = new EventEmitter(); 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 // Track all deltas by ID for negation processing
private allDeltas = new Map<DeltaID, Delta>(); private allDeltas = new Map<DeltaID, Delta>();
// Track which entities are affected by each delta // Track which entities are affected by each delta
@ -185,39 +213,11 @@ export class Lossless {
const seenDeltaIds = new Set<DeltaID>(); const seenDeltaIds = new Set<DeltaID>();
// Collect all deltas from all properties // 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) { for (const delta of deltas) {
if (!seenDeltaIds.has(delta.id)) { if (!seenDeltaIds.has(delta.id)) {
seenDeltaIds.add(delta.id); seenDeltaIds.add(delta.id);
allDeltas.push(delta);
// 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());
} }
} }
} }
@ -229,14 +229,14 @@ export class Lossless {
const view: LosslessViewMany = {}; const view: LosslessViewMany = {};
entityIds = entityIds ?? Array.from(this.domainEntities.keys()); entityIds = entityIds ?? Array.from(this.domainEntities.keys());
for (const id of entityIds) { for (const entityId of entityIds) {
const ent = this.domainEntities.get(id); const ent = this.domainEntities.get(entityId);
if (!ent) continue; if (!ent) continue;
const referencedAs = new Set<string>(); const referencedAs = new Set<string>();
const propertyDeltas: { const propertyDeltas: {
[key: PropertyID]: CollapsedDelta[] [key: PropertyID]: Delta[]
} = {}; } = {};
let hasVisibleDeltas = false; let hasVisibleDeltas = false;
@ -255,7 +255,7 @@ export class Lossless {
for (const [key, deltas] of ent.properties.entries()) { for (const [key, deltas] of ent.properties.entries()) {
// Filter deltas for this property based on negation status // Filter deltas for this property based on negation status
const filteredDeltas = Array.from(deltas).filter(delta => nonNegatedDeltaIds.has(delta.id)); const filteredDeltas = Array.from(deltas).filter(delta => nonNegatedDeltaIds.has(delta.id));
const visibleDeltas: CollapsedDelta[] = []; const visibleDeltas: Delta[] = [];
for (const delta of filteredDeltas) { for (const delta of filteredDeltas) {
if (deltaFilter && !deltaFilter(delta)) { if (deltaFilter && !deltaFilter(delta)) {
@ -271,19 +271,12 @@ export class Lossless {
} }
} }
const pointers: CollapsedPointer[] = []; const ref = delta.pointers.find(p => p.target === entityId)
if (ref) {
for (const {localContext, target} of delta.pointers) { referencedAs.add(ref.localContext);
if (target === ent.id) {
referencedAs.add(localContext);
}
pointers.push({[localContext]: target});
} }
visibleDeltas.push({ visibleDeltas.push(delta);
...delta,
pointers
});
hasVisibleDeltas = true; hasVisibleDeltas = true;
} }
@ -302,10 +295,10 @@ export class Lossless {
// Only include entity in view if it has visible deltas // Only include entity in view if it has visible deltas
if (hasVisibleDeltas) { if (hasVisibleDeltas) {
view[ent.id] = { view[entityId] = {
id: ent.id, id: entityId,
referencedAs: Array.from(referencedAs.values()), referencedAs: Array.from(referencedAs.values()),
propertyDeltas propertyDeltas,
}; };
} }
} }

View File

@ -47,7 +47,6 @@ export abstract class Lossy<Accumulator, Result = Accumulator> {
return this.deltaFilter(delta); return this.deltaFilter(delta);
}; };
const losslessPartial = this.lossless.compose([entityId], combinedFilter); const losslessPartial = this.lossless.compose([entityId], combinedFilter);
debug(`Lossless partial for entity ${entityId}:`, JSON.stringify(losslessPartial));
if (!losslessPartial) { if (!losslessPartial) {
// This should not happen; this should only be called after the lossless view has been updated // 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 { Lossless, LosslessViewOne } from "../lossless";
import { Lossy } from '../lossy'; import { Lossy } from '../lossy';
import { DomainEntityID, PropertyID, ViewMany } from "../../core/types"; import { DomainEntityID, PropertyID, ViewMany } from "../../core/types";
import { valueFromCollapsedDelta } from "../lossless"; import { valueFromDelta } from "../lossless";
import { EntityRecord, EntityRecordMany } from "@src/core/entity"; 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'; 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 // Extract numeric values from all deltas for this property
for (const delta of deltas || []) { for (const delta of deltas) {
const value = valueFromCollapsedDelta(propertyId, delta); const value = valueFromDelta(propertyId, delta);
if (typeof value === 'number') { if (typeof value === 'number') {
if (this.config[propertyId] === 'count') {
// For count, include all values (including duplicates)
acc[cur.id].properties[propertyId].values.push(value); 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 { PropertyID, PropertyTypes } from "../../../core/types";
import { CollapsedDelta } from "../../lossless"; 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 * 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 T - Type of the plugin's internal state
* @template D - Union type of dependency names (e.g., 'discount' | 'tax') * @template D - Union type of dependency names (e.g., 'discount' | 'tax')
*/ */
export abstract class ResolverPlugin< export abstract class ResolverPlugin< T = unknown > {
T = unknown, name?: string;
D extends string = never 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 * Convenience wrapper to avoid calling update() when there is no new value
@ -42,22 +42,18 @@ export abstract class ResolverPlugin<
delta?: CollapsedDelta, delta?: CollapsedDelta,
dependencies?: DependencyStates dependencies?: DependencyStates
): T { ): T {
debug(`applyUpdate, currentState: ${JSON.stringify(currentState)}, newValue: ${JSON.stringify(newValue)}, dependencies: ${JSON.stringify(dependencies)}`)
if (newValue === undefined) { if (newValue === undefined) {
switch(this.dependencies?.length) { debug(`No new value, checking dependencies. Plugin target is ${JSON.stringify(this.target)}`)
case 0: { 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. // No dependencies, no new value -- nothing to do.
debug(`No dependencies, no new value -- nothing to do.`)
return currentState; 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); return this.update(currentState, newValue, delta, dependencies);
}; };
@ -92,15 +88,15 @@ export abstract class ResolverPlugin<
* Configuration for custom resolver with type-safe plugin configurations * Configuration for custom resolver with type-safe plugin configurations
*/ */
export type CustomResolverConfig = { export type CustomResolverConfig = {
[P in PropertyID]: ResolverPlugin<unknown, string>; [P in PropertyID]: ResolverPlugin<unknown>;
}; };
/** /**
* Helper type to extract the state type from a ResolverPlugin * 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 * 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 * Concatenates all string values with a separator
*/ */
export class ConcatenationPlugin extends ResolverPlugin<ConcatenationState, never> { export class ConcatenationPlugin extends ResolverPlugin<ConcatenationState> {
readonly dependencies = [] as const; readonly dependencies = [] as const;
constructor(private separator: string = ' ') { constructor(private separator: string = ' ') {

View File

@ -12,7 +12,7 @@ type FirstWriteWinsState = {
* *
* Keeps the first value that was written, ignoring subsequent writes * 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; readonly dependencies = [] as const;
initialize(): FirstWriteWinsState { initialize(): FirstWriteWinsState {

View File

@ -12,7 +12,7 @@ type LastWriteWinsState = {
* *
* 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 extends ResolverPlugin<LastWriteWinsState, never> { export class LastWriteWinsPlugin extends ResolverPlugin<LastWriteWinsState> {
readonly dependencies = [] as const; readonly dependencies = [] as const;
initialize(): LastWriteWinsState { initialize(): LastWriteWinsState {

View File

@ -10,7 +10,7 @@ type MajorityVoteState = {
* *
* Returns the value that appears most frequently * Returns the value that appears most frequently
*/ */
export class MajorityVotePlugin extends ResolverPlugin<MajorityVoteState, never> { export class MajorityVotePlugin extends ResolverPlugin<MajorityVoteState> {
readonly dependencies = [] as const; readonly dependencies = [] as const;
initialize(): MajorityVoteState { 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"; import { ResolverPlugin, DependencyStates } from "../plugin";
type MaxPluginState = { type MaxPluginState = {
@ -10,16 +10,8 @@ type MaxPluginState = {
* *
* Tracks the maximum numeric value * Tracks the maximum numeric value
*/ */
export class MaxPlugin<Target extends PropertyID> extends ResolverPlugin<MaxPluginState, Target> { export class MaxPlugin extends ResolverPlugin<MaxPluginState> {
name = 'max'; name = 'max';
readonly dependencies: Target[] = [];
constructor(private readonly target?: Target) {
super();
if (target) {
this.dependencies = [target];
}
}
initialize(dependencies: DependencyStates): MaxPluginState { initialize(dependencies: DependencyStates): MaxPluginState {
return { max: this.target ? dependencies[this.target] as number : undefined }; 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"; import { DependencyStates, ResolverPlugin } from "../plugin";
type MinPluginState = { type MinPluginState = {
@ -10,16 +10,8 @@ type MinPluginState = {
* *
* Tracks the minimum numeric value * Tracks the minimum numeric value
*/ */
export class MinPlugin<Target extends PropertyID> extends ResolverPlugin<MinPluginState, Target> { export class MinPlugin extends ResolverPlugin<MinPluginState> {
name = 'min'; name = 'min';
readonly dependencies: Target[] = [];
constructor(private readonly target?: Target) {
super();
if (target) {
this.dependencies = [target];
}
}
initialize(dependencies: DependencyStates): MinPluginState { initialize(dependencies: DependencyStates): MinPluginState {
return { min: this.target ? dependencies[this.target] as number : undefined }; 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 * Tracks the running average of numeric values
*/ */
export class RunningAveragePlugin extends ResolverPlugin<RunningAverageState, never> { export class RunningAveragePlugin extends ResolverPlugin<RunningAverageState> {
readonly dependencies = [] as const; readonly dependencies = [] as const;
initialize(): RunningAverageState { initialize(): RunningAverageState {

View File

@ -1,9 +1,10 @@
import { CollapsedDelta, Lossless, LosslessViewOne } from "../../lossless"; import { Lossless, LosslessViewOne } from "../../lossless";
import { Lossy } from '../../lossy'; import { Lossy } from '../../lossy';
import { DomainEntityID, PropertyID, PropertyTypes } from "../../../core/types"; import { DomainEntityID, PropertyID, PropertyTypes } from "../../../core/types";
import { ResolverPlugin, DependencyStates } from "./plugin"; import { ResolverPlugin, DependencyStates } from "./plugin";
import { EntityRecord } from "@src/core/entity"; import { EntityRecord } from "@src/core/entity";
import Debug from 'debug'; import Debug from 'debug';
import { Delta } from "@src/core";
const debug = Debug('rz:custom-resolver'); const debug = Debug('rz:custom-resolver');
const debugState = Debug('rz:custom-resolver:state'); 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) * @template D - The type of the plugin's dependencies (defaults to PropertyID)
*/ */
type PluginMap = { 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) => { this.dependencyGraph.forEach((deps, plugin) => {
graphLog[plugin] = Array.from(deps); 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 // Initialize the graph with all plugins
Object.keys(this.config).forEach(pluginKey => { Object.keys(this.config).forEach(pluginKey => {
this.dependencyGraph.set(pluginKey, new Set()); this.dependencyGraph.set(pluginKey, new Set());
debug(`Added plugin node: ${pluginKey}`);
}); });
debug('Processing plugin dependencies...');
// Add edges based on dependencies // Add edges based on dependencies
Object.entries(this.config).forEach(([pluginKey, plugin]) => { Object.entries(this.config).forEach(([pluginKey, plugin]) => {
const pluginId = plugin.name || pluginKey;
const deps = plugin.dependencies || []; 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) => { deps.forEach((depId: string) => {
// This dependency may have an alias in our current config // This dependency may have an alias in our current config
const depKey = this.pluginKeyFromBasename(depId); const depKey = this.pluginKeyFromBasename(depId);
debug(`Processing dependency ${depKey} for plugin ${pluginKey}`);
if (!this.config[depKey]) { if (!this.config[depKey]) {
// TODO: This could still be a property, not a plugin // 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('Dependency graph construction complete');
debug(`Config: ${JSON.stringify(this.config, null, 2)}`);
this.logGraph(); this.logGraph();
} }
@ -211,19 +201,26 @@ export class CustomResolver extends Lossy<Accumulator, Result> {
for (const depKey of this.executionOrder) { for (const depKey of this.executionOrder) {
if (depKey === pluginKey) continue; if (depKey === pluginKey) continue;
const depPlugin = this.config[depKey]; const depPlugin = this.config[depKey];
if (depPlugin) { if (!depPlugin) continue;
if (!entityPluginStates[depKey]) { if (!entityPluginStates[depKey]) {
dependencyStates[depKey] = depPlugin.initialize(dependencyStates); dependencyStates[depKey] = depPlugin.initialize(dependencyStates);
entityPluginStates[depKey] = dependencyStates[depKey]; entityPluginStates[depKey] = dependencyStates[depKey];
} }
dependencyStates[depKey] = depPlugin.resolve(entityPluginStates[depKey], dependencyStates); 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; return dependencyStates;
} }
private initializePlugins(acc: Accumulator, entityId: DomainEntityID) { private getEntityState(acc: Accumulator, entityId: DomainEntityID) {
if (!acc[entityId]) { if (!acc[entityId]) {
acc[entityId] = {}; acc[entityId] = {};
} }
@ -241,7 +238,7 @@ export class CustomResolver extends Lossy<Accumulator, Result> {
entityState[pluginKey] = entityState[pluginKey] ?? plugin.initialize(dependencies); 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(`Processing deltas for entity: ${entityId}`);
debug('Property deltas:', JSON.stringify(propertyDeltas)); 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. // Now let's go through each plugin in order.
for (const pluginId of this.executionOrder) { for (const pluginId of this.executionOrder) {
const pluginKey = this.pluginKeyFromBasename(pluginId); const pluginKey = this.pluginKeyFromBasename(pluginId);
const plugin = this.config[pluginKey]; const plugin = this.config[pluginKey];
if (!plugin) throw new Error(`Plugin for property ${pluginId} not found`); if (!plugin) throw new Error(`Plugin for property ${pluginId} not found`);
const pluginState = entityState[pluginKey];
debug(`Processing plugin: ${pluginId} (key: ${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, // 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) { if (propertyId === pluginKey) {
propertyValue = pointer[propertyId]; if (propertyValue !== undefined) {
updateDelta = delta; 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 // 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); 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); entityState[pluginKey] = plugin.applyUpdate(pluginState, propertyValue, updateDelta, dependencies);
debugState(`Updated state for entity ${entityId} plugin ${pluginKey}:`, debugState(`Updated state for entity ${entityId} plugin ${pluginKey}:`,
JSON.stringify(entityState[pluginKey])); JSON.stringify(entityState[pluginKey]));
@ -306,7 +325,7 @@ export class CustomResolver extends Lossy<Accumulator, Result> {
for (const entityId in acc) { for (const entityId in acc) {
if (!entityIds.includes(entityId)) continue; if (!entityIds.includes(entityId)) continue;
this.initializePlugins(acc, entityId); this.getEntityState(acc, entityId);
result[entityId] = { result[entityId] = {
id: entityId, id: entityId,
@ -324,12 +343,14 @@ export class CustomResolver extends Lossy<Accumulator, Result> {
debug(`State for ${pluginKey}:`, JSON.stringify(state)); debug(`State for ${pluginKey}:`, JSON.stringify(state));
const resolvedValue = plugin.resolve(state, dependencies); 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); debug(`Resolved value for ${pluginKey}:`, resolvedValue);
result[entityId].properties[pluginKey] = resolvedValue; result[entityId].properties[pluginKey] = resolvedValue;
} }
} }
}
debug(`Result:`, JSON.stringify(result)); 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 { EntityProperties } from "../../core/entity";
import { Lossless, LosslessViewOne, CollapsedDelta, valueFromCollapsedDelta } from "../lossless"; import { Lossless, CollapsedDelta, valueFromDelta, LosslessViewOne } from "../lossless";
import { Lossy } from '../lossy'; import { Lossy } from '../lossy';
import { DomainEntityID, PropertyID, PropertyTypes, Timestamp, ViewMany } from "../../core/types"; import { DomainEntityID, PropertyID, PropertyTypes, Timestamp, ViewMany } from "../../core/types";
import Debug from 'debug';
const debug = Debug('rz:views:resolvers:timestamp-resolvers');
export type TimestampedProperty = { export type TimestampedProperty = {
value: PropertyTypes, value: PropertyTypes,
@ -88,8 +91,10 @@ export class TimestampResolver extends Lossy<Accumulator, Result> {
for (const [key, deltas] of Object.entries(cur.propertyDeltas)) { for (const [key, deltas] of Object.entries(cur.propertyDeltas)) {
let bestProperty: TimestampedPropertyWithTieBreaking | undefined; let bestProperty: TimestampedPropertyWithTieBreaking | undefined;
for (const delta of deltas || []) { for (const delta of deltas) {
const value = valueFromCollapsedDelta(key, delta); const value = valueFromDelta(key, delta);
debug(`delta: ${JSON.stringify(delta)}`);
debug(`valueFromDelta(${key}) = ${value}`);
if (value === undefined) continue; if (value === undefined) continue;
const property: TimestampedPropertyWithTieBreaking = { const property: TimestampedPropertyWithTieBreaking = {
@ -162,18 +167,18 @@ export function latestFromCollapsedDeltas(
deltas?: CollapsedDelta[] deltas?: CollapsedDelta[]
): { ): {
delta?: CollapsedDelta, delta?: CollapsedDelta,
value?: string | number, value?: PropertyTypes,
timeUpdated?: number timeUpdated?: number
} | undefined { } | undefined {
const res: { const res: {
delta?: CollapsedDelta, delta?: CollapsedDelta,
value?: string | number, value?: PropertyTypes,
timeUpdated?: number timeUpdated?: number
} = {}; } = {};
res.timeUpdated = 0; res.timeUpdated = 0;
for (const delta of deltas || []) { for (const delta of deltas || []) {
const value = valueFromCollapsedDelta(key, delta); const value = valueFromDelta(key, delta);
if (value === undefined) continue; if (value === undefined) continue;
if (res.timeUpdated && delta.timeCreated < res.timeUpdated) continue; if (res.timeUpdated && delta.timeCreated < res.timeUpdated) continue;
res.delta = delta; res.delta = delta;