diff --git a/__tests__/__helpers__/resolver-test-helper.ts b/__tests__/__helpers__/resolver-test-helper.ts index 9a81a3a..77ba051 100644 --- a/__tests__/__helpers__/resolver-test-helper.ts +++ b/__tests__/__helpers__/resolver-test-helper.ts @@ -4,10 +4,12 @@ import { Delta } from '@src/core/delta'; import { createDelta } from '@src/core/delta-builder'; import { CustomResolver } from '@src/views/resolvers/custom-resolvers'; import { ResolverPlugin } from '@src/views/resolvers/custom-resolvers/plugin'; +import Debug from 'debug'; +const debug = Debug('rz:test:resolver-test-helper'); // Define a test plugin map that enforces string dependencies type TestPluginMap = { - [key: string]: ResolverPlugin; + [key: string]: ResolverPlugin; }; interface TestHelperOptions { @@ -38,6 +40,7 @@ export async function testResolverWithPlugins( // Get the resolved view const resolvedView = view.resolve([entityId]); if (!resolvedView) throw new Error(`Resolved view for entity ${entityId} is undefined`); + debug(`Resolved view for entity ${entityId}:`, JSON.stringify(resolvedView)); return resolvedView[entityId]; } diff --git a/__tests__/integration/schema.test.ts b/__tests__/integration/schema.test.ts index 71d089c..28264ef 100644 --- a/__tests__/integration/schema.test.ts +++ b/__tests__/integration/schema.test.ts @@ -11,6 +11,8 @@ import { CommonSchemas } from '../../util/schemas'; import { TypedCollectionImpl, SchemaValidationError } from '@src/collections'; import { RhizomeNode } from '@src/node'; import { createDelta } from '@src/core/delta-builder'; +import Debug from 'debug'; +const debug = Debug('rz:schema-test'); describe('Schema System', () => { let schemaRegistry: DefaultSchemaRegistry; @@ -158,22 +160,17 @@ describe('Schema System', () => { // Create a valid lossless view const validView: LosslessViewOne = { id: 'user123', - referencedAs: ['user'], propertyDeltas: { - name: [{ - id: 'delta1', - timeCreated: 123, - host: 'host1', - creator: 'creator1', - pointers: [{ name: 'Alice' }] - }], - age: [{ - id: 'delta2', - timeCreated: 124, - host: 'host1', - creator: 'creator1', - pointers: [{ age: 25 }] - }] + name: [ + createDelta('creator1', 'host1') + .addPointer('name', 'Alice') + .buildV1() + ], + age: [ + createDelta('creator1', 'host1') + .addPointer('age', 25) + .buildV1() + ] }, }; @@ -184,15 +181,12 @@ describe('Schema System', () => { // Test invalid view (missing required property) const invalidView: LosslessViewOne = { id: 'user456', - referencedAs: ['user'], propertyDeltas: { - age: [{ - id: 'delta3', - timeCreated: 125, - host: 'host1', - creator: 'creator1', - pointers: [{ age: 30 }] - }] + age: [ + createDelta('creator1', 'host1') + .addPointer('age', 30) + .buildV1() + ] } }; @@ -220,11 +214,25 @@ describe('Schema System', () => { // Valid types const validView: LosslessViewOne = { id: 'test1', - referencedAs: [], propertyDeltas: { - stringProp: [{ id: 'd1', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ stringProp: 'hello' }] }], - numberProp: [{ id: 'd2', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ numberProp: 42 }] }], - booleanProp: [{ id: 'd3', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ booleanProp: true }] }] + stringProp: [ + createDelta('creator1', 'host1') + .addPointer('stringProp', 'hello') + .buildV1(), + // { id: 'd1', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ localContext: 'stringProp', target: 'hello' }] }], + ], + numberProp: [ + createDelta('creator1', 'host1') + .addPointer('numberProp', 42) + .buildV1(), + // { id: 'd2', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ localContext: 'numberProp', target: 42 }] }], + ], + booleanProp: [ + createDelta('creator1', 'host1') + .addPointer('booleanProp', true) + .buildV1(), + // { id: 'd3', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ localContext: 'booleanProp', target: true }] }] + ], } }; @@ -234,10 +242,19 @@ describe('Schema System', () => { // Invalid types const invalidView: LosslessViewOne = { id: 'test2', - referencedAs: [], propertyDeltas: { - stringProp: [{ id: 'd4', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ stringProp: 123 as never }] }], - numberProp: [{ id: 'd5', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ numberProp: 'not-number' as never }] }] + stringProp: [ + createDelta('creator1', 'host1') + .addPointer('stringProp', 123 as never) + .buildV1(), + // { id: 'd4', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ localContext: 'stringProp', target: 123 as never }] }], + ], + numberProp: [ + createDelta('creator1', 'host1') + .addPointer('numberProp', 'not-number' as never) + .buildV1(), + // { id: 'd5', timeCreated: 1, host: 'h', creator: 'c', pointers: [{ localContext: 'numberProp', target: 'not-number' as never }] }] + ], } }; @@ -336,11 +353,14 @@ describe('Schema System', () => { // Create invalid entity manually const invalidDelta = createDelta(node.config.creator, node.config.peerId) - .addPointer('users', 'user3', 'age') - .addPointer('age', 'not-a-number') + .setProperty('user3', 'age', 'not-a-number', 'users') .buildV1(); node.lossless.ingestDelta(invalidDelta); + debug(`Manually ingested invalid delta: ${JSON.stringify(invalidDelta)}`) + + debug(`Lossless view: ${JSON.stringify(node.lossless.compose(), null, 2)}`) + const validIds = collection.getValidEntities(); expect(validIds).toContain('user1'); expect(validIds).toContain('user2'); diff --git a/__tests__/performance/concurrent-writes.test.ts b/__tests__/performance/concurrent-writes.test.ts index 272f0d5..07289bc 100644 --- a/__tests__/performance/concurrent-writes.test.ts +++ b/__tests__/performance/concurrent-writes.test.ts @@ -7,7 +7,9 @@ import { LastWriteWinsPlugin, MajorityVotePlugin, TimestampResolver -} from "../../src"; +} from "@src"; +import Debug from 'debug'; +const debug = Debug('rz:test:performance'); describe('Concurrent Write Scenarios', () => { let node: RhizomeNode; @@ -20,6 +22,8 @@ describe('Concurrent Write Scenarios', () => { describe('Simultaneous Writes with Same Timestamp', () => { test('should handle simultaneous writes using last-write-wins resolver', () => { + const resolver = new TimestampResolver(lossless); + const timestamp = 1000; // Simulate two writers updating the same property at the exact same time @@ -37,10 +41,10 @@ describe('Concurrent Write Scenarios', () => { .buildV1() ); - const resolver = new TimestampResolver(lossless); const result = resolver.resolve(); expect(result).toBeDefined(); + debug(`Result: ${JSON.stringify(result, null, 2)}`) // Should resolve deterministically using the LastWriteWins resolver's tie-breaking algorithm expect(typeof result!['entity1'].properties.score).toBe('number'); expect([100, 200]).toContain(result!['entity1'].properties.score); @@ -48,7 +52,8 @@ describe('Concurrent Write Scenarios', () => { test('should handle simultaneous writes using timestamp resolver with tie-breaking', () => { const timestamp = 1000; - + const resolver = new TimestampResolver(lossless, 'creator-id'); + lossless.ingestDelta(createDelta('writer_z', 'host1') // Lexicographically later .withId('delta-a') .withTimestamp(timestamp) @@ -63,7 +68,6 @@ describe('Concurrent Write Scenarios', () => { .buildV1() ); - const resolver = new TimestampResolver(lossless, 'creator-id'); const result = resolver.resolve(); expect(result).toBeDefined(); @@ -72,6 +76,8 @@ describe('Concurrent Write Scenarios', () => { }); test('should handle multiple writers with aggregation resolver', () => { + const resolver = new SumResolver(lossless, ['points']); + // Multiple writers add values simultaneously lossless.ingestDelta(createDelta('writer1', 'host1') .withTimestamp(1000) @@ -92,7 +98,6 @@ describe('Concurrent Write Scenarios', () => { .buildV1() ); - const resolver = new SumResolver(lossless, ['points']); const result = resolver.resolve(); expect(result).toBeDefined(); @@ -103,6 +108,8 @@ describe('Concurrent Write Scenarios', () => { describe('Out-of-Order Write Arrival', () => { test('should handle writes arriving out of chronological order', () => { + const resolver = new TimestampResolver(lossless); + // Newer delta arrives first lossless.ingestDelta(createDelta('writer1', 'host1') .withTimestamp(2000) @@ -119,7 +126,6 @@ describe('Concurrent Write Scenarios', () => { .buildV1() ); - const resolver = new TimestampResolver(lossless); const result = resolver.resolve(); expect(result).toBeDefined(); @@ -128,6 +134,8 @@ describe('Concurrent Write Scenarios', () => { }); test('should maintain correct aggregation despite out-of-order arrival', () => { + const resolver = new SumResolver(lossless, ['score']); + // Add deltas in reverse chronological order lossless.ingestDelta(createDelta('writer1', 'host1') .withTimestamp(3000) @@ -150,7 +158,6 @@ describe('Concurrent Write Scenarios', () => { .buildV1() ); - const resolver = new SumResolver(lossless, ['score']); const result = resolver.resolve(); expect(result).toBeDefined(); @@ -161,6 +168,8 @@ describe('Concurrent Write Scenarios', () => { describe('High-Frequency Concurrent Updates', () => { test('should handle rapid concurrent updates to the same entity', () => { + const resolver = new SumResolver(lossless, ['counter']); + const baseTimestamp = 1000; const numWriters = 10; const writesPerWriter = 5; @@ -177,7 +186,6 @@ describe('Concurrent Write Scenarios', () => { } } - const resolver = new SumResolver(lossless, ['counter']); const result = resolver.resolve(); expect(result).toBeDefined(); @@ -186,6 +194,11 @@ describe('Concurrent Write Scenarios', () => { }); test('should handle concurrent updates to multiple properties', () => { + const resolver = new CustomResolver(lossless, { + name: new LastWriteWinsPlugin(), + score: new LastWriteWinsPlugin() + }); + const timestamp = 1000; // Writer 1 updates name and score @@ -218,11 +231,6 @@ describe('Concurrent Write Scenarios', () => { .buildV1() ); - const resolver = new CustomResolver(lossless, { - name: new LastWriteWinsPlugin(), - score: new LastWriteWinsPlugin() - }); - const result = resolver.resolve(); expect(result).toBeDefined(); @@ -233,6 +241,8 @@ describe('Concurrent Write Scenarios', () => { describe('Cross-Entity Concurrent Writes', () => { test('should handle concurrent writes to different entities', () => { + const resolver = new TimestampResolver(lossless); + const timestamp = 1000; // Multiple writers updating different entities simultaneously @@ -245,7 +255,6 @@ describe('Concurrent Write Scenarios', () => { ); } - const resolver = new TimestampResolver(lossless); const result = resolver.resolve(); expect(result).toBeDefined(); @@ -257,6 +266,10 @@ describe('Concurrent Write Scenarios', () => { }); test('should handle mixed entity and property conflicts', () => { + const resolver = new CustomResolver(lossless, { + votes: new MajorityVotePlugin(), + status: new LastWriteWinsPlugin() + }); const timestamp = 1000; // Entity1: Multiple writers competing for same property @@ -289,11 +302,6 @@ describe('Concurrent Write Scenarios', () => { .buildV1() ); - const resolver = new CustomResolver(lossless, { - votes: new MajorityVotePlugin(), - status: new LastWriteWinsPlugin() - }); - const result = resolver.resolve(); expect(result).toBeDefined(); @@ -304,6 +312,8 @@ describe('Concurrent Write Scenarios', () => { describe('Stress Testing', () => { test('should handle large number of concurrent writes efficiently', () => { + const resolver = new SumResolver(lossless, ['score']); + const numEntities = 100; const numWritersPerEntity = 10; const baseTimestamp = 1000; @@ -320,7 +330,6 @@ describe('Concurrent Write Scenarios', () => { } } - const resolver = new SumResolver(lossless, ['score']); const result = resolver.resolve(); expect(result).toBeDefined(); @@ -335,6 +344,8 @@ describe('Concurrent Write Scenarios', () => { }); test('should maintain consistency under rapid updates and resolution calls', () => { + const resolver = new SumResolver(lossless, ['counter']); + const entityId = 'stress-test-entity'; let updateCount = 0; @@ -353,8 +364,7 @@ describe('Concurrent Write Scenarios', () => { } // Verify initial state - let resolver = new SumResolver(lossless, ['counter']); - let result = resolver.resolve(); + const result = resolver.resolve(); expect(result).toBeDefined(); expect(result![entityId].properties.counter).toBe(updateCount); @@ -369,8 +379,7 @@ describe('Concurrent Write Scenarios', () => { updateCount += 2; // Create a fresh resolver to avoid accumulator caching issues - resolver = new SumResolver(lossless, ['counter']); - result = resolver.resolve(); + const result = resolver.resolve(); expect(result![entityId].properties.counter).toBe(updateCount); } }); diff --git a/__tests__/unit/core/compose-decompose.test.ts b/__tests__/unit/core/compose-decompose.test.ts index 6c142d0..1bcb1c6 100644 --- a/__tests__/unit/core/compose-decompose.test.ts +++ b/__tests__/unit/core/compose-decompose.test.ts @@ -45,7 +45,7 @@ describe('Lossless View Compose/Decompose', () => { expect(decomposed).toHaveLength(2); - // Check that original deltas are preserved + // Check that original delta ids are preserved const originalIds = nameDeltas.map(d => d.id).sort(); const decomposedIds = decomposed.map(d => d.id).sort(); expect(decomposedIds).toEqual(originalIds); diff --git a/__tests__/unit/core/delta/delta.test.ts b/__tests__/unit/core/delta/delta.test.ts index 015aaec..12620ee 100644 --- a/__tests__/unit/core/delta/delta.test.ts +++ b/__tests__/unit/core/delta/delta.test.ts @@ -1,5 +1,5 @@ import { createDelta } from '@src/core/delta-builder'; -import {DeltaV1, DeltaV2} from "../../../../src"; +import {DeltaV2} from "../../../../src"; describe("Delta", () => { test("can convert DeltaV1 to DeltaV2", () => { diff --git a/__tests__/unit/views/lossless.test.ts b/__tests__/unit/views/lossless.test.ts index abcaec7..0e2ed44 100644 --- a/__tests__/unit/views/lossless.test.ts +++ b/__tests__/unit/views/lossless.test.ts @@ -47,11 +47,11 @@ describe('Lossless', () => { creator: "a", host: "h", pointers: [ - {actor: "keanu"}, - {role: "neo"}, - {film: "the_matrix"}, - {base_salary: 1000000}, - {salary_currency: "usd"}, + {localContext: "actor", target: "keanu", targetContext: "roles"}, + {localContext: "role", target: "neo", targetContext: "actor"}, + {localContext: "film", target: "the_matrix", targetContext: "cast"}, + {localContext: "base_salary", target: 1000000}, + {localContext: "salary_currency", target: "usd"}, ], }], }, @@ -63,11 +63,11 @@ describe('Lossless', () => { creator: "a", host: "h", pointers: [ - {actor: "keanu"}, - {role: "neo"}, - {film: "the_matrix"}, - {base_salary: 1000000}, - {salary_currency: "usd"}, + {localContext: "actor", target: "keanu", targetContext: "roles"}, + {localContext: "role", target: "neo", targetContext: "actor"}, + {localContext: "film", target: "the_matrix", targetContext: "cast"}, + {localContext: "base_salary", target: 1000000}, + {localContext: "salary_currency", target: "usd"}, ], }], }, @@ -79,11 +79,11 @@ describe('Lossless', () => { creator: "a", host: "h", pointers: [ - {actor: "keanu"}, - {role: "neo"}, - {film: "the_matrix"}, - {base_salary: 1000000}, - {salary_currency: "usd"}, + {localContext: "actor", target: "keanu", targetContext: "roles"}, + {localContext: "role", target: "neo", targetContext: "actor"}, + {localContext: "film", target: "the_matrix", targetContext: "cast"}, + {localContext: "base_salary", target: 1000000}, + {localContext: "salary_currency", target: "usd"}, ], }], }, @@ -112,11 +112,11 @@ describe('Lossless', () => { creator: "a", host: "h", pointers: [ - {actor: "keanu"}, - {role: "neo"}, - {film: "the_matrix"}, - {base_salary: 1000000}, - {salary_currency: "usd"}, + {localContext: "actor", target: "keanu", targetContext: "roles"}, + {localContext: "role", target: "neo", targetContext: "actor"}, + {localContext: "film", target: "the_matrix", targetContext: "cast"}, + {localContext: "base_salary", target: 1000000}, + {localContext: "salary_currency", target: "usd"}, ], }], }, @@ -128,11 +128,11 @@ describe('Lossless', () => { creator: "a", host: "h", pointers: [ - {actor: "keanu"}, - {role: "neo"}, - {film: "the_matrix"}, - {base_salary: 1000000}, - {salary_currency: "usd"}, + {localContext: "actor", target: "keanu", targetContext: "roles"}, + {localContext: "role", target: "neo", targetContext: "actor"}, + {localContext: "film", target: "the_matrix", targetContext: "cast"}, + {localContext: "base_salary", target: 1000000}, + {localContext: "salary_currency", target: "usd"}, ], }], }, @@ -144,11 +144,11 @@ describe('Lossless', () => { creator: "a", host: "h", pointers: [ - {actor: "keanu"}, - {role: "neo"}, - {film: "the_matrix"}, - {base_salary: 1000000}, - {salary_currency: "usd"}, + {localContext: "actor", target: "keanu", targetContext: "roles"}, + {localContext: "role", target: "neo", targetContext: "actor"}, + {localContext: "film", target: "the_matrix", targetContext: "cast"}, + {localContext: "base_salary", target: 1000000}, + {localContext: "salary_currency", target: "usd"}, ], }], }, @@ -163,7 +163,7 @@ describe('Lossless', () => { // First delta lossless.ingestDelta( createDelta('A', 'H') - .addPointer('1', 'ace', 'value') + .setProperty('ace', 'value', '1', 'ace') .buildV1() ); @@ -171,25 +171,28 @@ describe('Lossless', () => { lossless.ingestDelta( createDelta('B', 'H') // 10 11j 12q 13k 14a - .addPointer('14', 'ace', 'value') + // .addPointer('14', 'ace', 'value') + .setProperty('ace', 'value', '14', 'ace') .buildV1() ); expect(lossless.compose()).toMatchObject({ ace: { - referencedAs: ["1", "14"], + referencedAs: ["ace"], propertyDeltas: { value: [{ creator: 'A', host: 'H', pointers: [ - {"1": "ace"}, + {localContext: "ace", target: "ace", targetContext: "value"}, + {localContext: "value", target: "1"}, ] }, { creator: 'B', host: 'H', pointers: [ - {"14": "ace"}, + {localContext: "ace", target: "ace", targetContext: "value"}, + {localContext: "value", target: "14"}, ] }], } @@ -204,13 +207,14 @@ describe('Lossless', () => { expect(lossless.compose(undefined, filter)).toMatchObject({ ace: { - referencedAs: ["1"], + referencedAs: ["ace"], propertyDeltas: { value: [{ creator: 'A', host: 'H', pointers: [ - {"1": "ace"}, + {localContext: "ace", target: "ace", targetContext: "value"}, + {localContext: "value", target: "1"}, ] }] } @@ -219,13 +223,14 @@ describe('Lossless', () => { expect(lossless.compose(["ace"], filter)).toMatchObject({ ace: { - referencedAs: ["1"], + referencedAs: ["ace"], propertyDeltas: { value: [{ creator: 'A', host: 'H', pointers: [ - {"1": "ace"}, + {localContext: "ace", target: "ace", targetContext: "value"}, + {localContext: "value", target: "1"}, ] }] } diff --git a/__tests__/unit/views/lossy.test.ts b/__tests__/unit/views/lossy.test.ts index 0ed7460..36ad07a 100644 --- a/__tests__/unit/views/lossy.test.ts +++ b/__tests__/unit/views/lossy.test.ts @@ -3,7 +3,7 @@ import { PointerTarget } from "@src/core/delta"; import { Lossless, LosslessViewOne } from "@src/views/lossless"; import { Lossy } from "@src/views/lossy"; import { RhizomeNode } from "@src/node"; -import { valueFromCollapsedDelta } from "@src/views/lossless"; +import { valueFromDelta } from "@src/views/lossless"; import { latestFromCollapsedDeltas } from "@src/views/resolvers/timestamp-resolvers"; import { createDelta } from "@src/core/delta-builder"; const debug = Debug('rz:test:lossy'); @@ -38,10 +38,10 @@ class Summarizer extends Lossy { // TODO: Prove with failing test reducer(acc: Summary, cur: LosslessViewOne): Summary { - this.debug(`Processing view for entity ${cur.id} (referenced as: ${cur.referencedAs.join(', ')})`); + this.debug(`Processing view for entity ${cur.id} (referenced as: ${cur.referencedAs?.join(', ')})`); this.debug(`lossless view:`, JSON.stringify(cur)); - if (cur.referencedAs.includes("role")) { + if (cur.referencedAs?.includes("role")) { this.debug(`Found role entity: ${cur.id}`); const actorDeltas = cur.propertyDeltas["actor"]; @@ -60,7 +60,7 @@ class Summarizer extends Lossy { } this.debug(`Found actor: ${actor}`); - const film = valueFromCollapsedDelta("film", delta); + const film = valueFromDelta("film", delta); if (!film) { this.debug('No film property found in delta'); diff --git a/__tests__/unit/views/multi-pointer-resolution.test.ts b/__tests__/unit/views/multi-pointer-resolution.test.ts index 17f3160..6dbc362 100644 --- a/__tests__/unit/views/multi-pointer-resolution.test.ts +++ b/__tests__/unit/views/multi-pointer-resolution.test.ts @@ -9,6 +9,8 @@ import { createDelta } from '@src/core/delta-builder'; import { DefaultSchemaRegistry } from '@src/schema'; import { SchemaBuilder, PrimitiveSchemas, ReferenceSchemas, SchemaAppliedViewWithNesting } from '@src/schema'; import { TypedCollectionImpl } from '@src/collections'; +import Debug from 'debug'; +const debug = Debug('rz:test:multi-pointer-resolution'); describe('Multi-Pointer Delta Resolution', () => { let node: RhizomeNode; @@ -255,6 +257,8 @@ describe('Multi-Pointer Delta Resolution', () => { { maxDepth: 2 } ); + debug(`nestedProjectView:`, JSON.stringify(nestedProjectView, null, 2)); + expect(nestedProjectView.id).toBe('website'); expect(nestedProjectView.nestedObjects.collaborations).toBeDefined(); diff --git a/__tests__/unit/views/resolvers/custom-resolvers/dependency/basic-dependencies.test.ts b/__tests__/unit/views/resolvers/custom-resolvers/dependency/basic-dependencies.test.ts index 2fd54d3..436deb5 100644 --- a/__tests__/unit/views/resolvers/custom-resolvers/dependency/basic-dependencies.test.ts +++ b/__tests__/unit/views/resolvers/custom-resolvers/dependency/basic-dependencies.test.ts @@ -16,7 +16,7 @@ describe('Basic Dependency Resolution', () => { test('should resolve dependencies in correct order', () => { // Define a simple plugin that depends on another - class FirstPlugin extends ResolverPlugin<{ value: string }, string> { + class FirstPlugin extends ResolverPlugin<{ value: string }> { readonly dependencies = [] as const; initialize() { @@ -34,7 +34,7 @@ describe('Basic Dependency Resolution', () => { } - class SecondPlugin extends ResolverPlugin<{ value: string }, string> { + class SecondPlugin extends ResolverPlugin<{ value: string }> { readonly dependencies = ['first'] as const; initialize() { diff --git a/__tests__/unit/views/resolvers/custom-resolvers/dependency/circular-dependencies.test.ts b/__tests__/unit/views/resolvers/custom-resolvers/dependency/circular-dependencies.test.ts index 9cdd2ab..20fe60b 100644 --- a/__tests__/unit/views/resolvers/custom-resolvers/dependency/circular-dependencies.test.ts +++ b/__tests__/unit/views/resolvers/custom-resolvers/dependency/circular-dependencies.test.ts @@ -16,7 +16,7 @@ describe('Circular Dependency Detection', () => { test('should detect circular dependencies', () => { // PluginA depends on PluginB - class PluginA extends ResolverPlugin<{ value: string }, string> { + class PluginA extends ResolverPlugin<{ value: string }> { readonly dependencies = ['b'] as const; initialize() { @@ -34,7 +34,7 @@ describe('Circular Dependency Detection', () => { // PluginB depends on PluginA (circular dependency) - class PluginB extends ResolverPlugin<{ value: string }, string> { + class PluginB extends ResolverPlugin<{ value: string }> { readonly dependencies = ['a'] as const; initialize() { @@ -61,21 +61,21 @@ describe('Circular Dependency Detection', () => { }); test('should detect longer circular dependency chains', () => { - class PluginA extends ResolverPlugin<{ value: string }, string> { + class PluginA extends ResolverPlugin<{ value: string }> { readonly dependencies = ['c'] as const; initialize() { return { value: '' }; } update() { return { value: '' }; } resolve() { return 'a'; } } - class PluginB extends ResolverPlugin<{ value: string }, string> { + class PluginB extends ResolverPlugin<{ value: string }> { readonly dependencies = ['a'] as const; initialize() { return { value: '' }; } update() { return { value: '' }; } resolve() { return 'b'; } } - class PluginC extends ResolverPlugin<{ value: string }, string> { + class PluginC extends ResolverPlugin<{ value: string }> { readonly dependencies = ['b'] as const; initialize() { return { value: '' }; } update() { return { value: '' }; } diff --git a/__tests__/unit/views/resolvers/custom-resolvers/edge-cases/edge-cases.test.ts b/__tests__/unit/views/resolvers/custom-resolvers/edge-cases/edge-cases.test.ts index ff64ae1..ba9096f 100644 --- a/__tests__/unit/views/resolvers/custom-resolvers/edge-cases/edge-cases.test.ts +++ b/__tests__/unit/views/resolvers/custom-resolvers/edge-cases/edge-cases.test.ts @@ -18,7 +18,7 @@ describe('Edge Cases', () => { test('should handle null values', () => { // Create a type-safe plugin that handles null/undefined values - class NullSafeLastWriteWinsPlugin extends ResolverPlugin<{ value: PropertyTypes | null, timestamp: number }, never> { + class NullSafeLastWriteWinsPlugin extends ResolverPlugin<{ value: PropertyTypes | null, timestamp: number }> { readonly dependencies = [] as const; initialize() { @@ -64,7 +64,7 @@ describe('Edge Cases', () => { test('should handle concurrent updates with same timestamp', () => { // Custom plugin that handles concurrent updates with the same timestamp - class ConcurrentUpdatePlugin extends ResolverPlugin<{ value: PropertyTypes, timestamp: number }, never> { + class ConcurrentUpdatePlugin extends ResolverPlugin<{ value: PropertyTypes, timestamp: number }> { readonly dependencies = [] as const; initialize() { @@ -125,7 +125,7 @@ describe('Edge Cases', () => { test('should handle very large numbers of updates', () => { // Plugin that handles large numbers of updates efficiently - class CounterPlugin extends ResolverPlugin<{ count: number }, never> { + class CounterPlugin extends ResolverPlugin<{ count: number }> { readonly dependencies = [] as const; initialize() { @@ -173,7 +173,7 @@ describe('Edge Cases', () => { test('should handle missing properties gracefully', () => { // No deltas added - should handle empty state // Plugin that handles missing properties gracefully - class MissingPropertyPlugin extends ResolverPlugin<{ initialized: boolean }, never> { + class MissingPropertyPlugin extends ResolverPlugin<{ initialized: boolean }> { readonly dependencies = [] as const; initialize() { diff --git a/__tests__/unit/views/resolvers/custom-resolvers/integration/multiple-plugins.test.ts b/__tests__/unit/views/resolvers/custom-resolvers/integration/multiple-plugins.test.ts index f86f0ba..06dc9e7 100644 --- a/__tests__/unit/views/resolvers/custom-resolvers/integration/multiple-plugins.test.ts +++ b/__tests__/unit/views/resolvers/custom-resolvers/integration/multiple-plugins.test.ts @@ -9,9 +9,11 @@ import { MinPlugin, ResolverPlugin } from '@src/views/resolvers/custom-resolvers'; +import Debug from 'debug'; +const debug = Debug('rz:test:lossless'); // A simple plugin that depends on other plugins -class AveragePlugin extends ResolverPlugin<{ initialized: boolean }, Targets> { +class AveragePlugin extends ResolverPlugin<{ initialized: boolean }> { readonly dependencies: Targets[] = []; constructor(...targets: Targets[]) { @@ -90,9 +92,10 @@ describe('Multiple Plugins Integration', () => { const resolver = new CustomResolver(lossless, { name: new LastWriteWinsPlugin(), tags: new ConcatenationPlugin(), - score: new MaxPlugin('score') + score: new MaxPlugin() }); + debug(`Creating and ingesting first delta`); // Add data for entity1 lossless.ingestDelta( createDelta('user1', 'host1') @@ -102,6 +105,7 @@ describe('Multiple Plugins Integration', () => { .buildV1() ); + debug(`Creating and ingesting second delta`); // Add more tags to entity1 lossless.ingestDelta( createDelta('user1', 'host1') @@ -110,6 +114,7 @@ describe('Multiple Plugins Integration', () => { .buildV1() ); + debug(`Creating and ingesting third delta`); // Add data for entity2 lossless.ingestDelta( createDelta('user1', 'host1') @@ -118,6 +123,7 @@ describe('Multiple Plugins Integration', () => { .buildV1() ); + debug(`Creating and ingesting fourth delta`); // Update score for entity2 lossless.ingestDelta( createDelta('user1', 'host1') @@ -132,7 +138,7 @@ describe('Multiple Plugins Integration', () => { const entity1 = results!['entity1']; expect(entity1).toBeDefined(); expect(entity1?.properties.name).toBe('Test Entity'); - expect(entity1?.properties.tags).toEqual(['tag1', 'tag2']); + expect(entity1?.properties.tags).toBe('tag1 tag2'); const entity2 = results!['entity2']; expect(entity2).toBeDefined(); diff --git a/__tests__/unit/views/resolvers/custom-resolvers/lifecycle/plugin-lifecycle.test.ts b/__tests__/unit/views/resolvers/custom-resolvers/lifecycle/plugin-lifecycle.test.ts index 7d95cf2..a6fce11 100644 --- a/__tests__/unit/views/resolvers/custom-resolvers/lifecycle/plugin-lifecycle.test.ts +++ b/__tests__/unit/views/resolvers/custom-resolvers/lifecycle/plugin-lifecycle.test.ts @@ -6,6 +6,9 @@ import { DependencyStates } from '@src/views/resolvers/custom-resolvers'; import { PropertyTypes } from '@src/core/types'; +import Debug from 'debug'; + +const debug = Debug('rz:plugin-lifecycle'); // A simple plugin for testing lifecycle methods class LifecycleTestPlugin extends ResolverPlugin { @@ -56,22 +59,23 @@ describe('Plugin Lifecycle', () => { }); test('should call initialize, update, and resolve in order', () => { - // Add some data - lossless.ingestDelta( - createDelta('user1', 'host1') - .withTimestamp(1000) - .setProperty('test1', 'test', 'value1', 'test') - .buildV1() - ); - const resolver = new CustomResolver(lossless, { test: new LifecycleTestPlugin() }); - const results = resolver.resolve() || []; - expect(Array.isArray(results)).toBe(true); + // Add some data + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('test1', 'test', 'value1') + .buildV1() + ); + + const results = resolver.resolve(); + expect(results).toBeDefined(); + debug(`Results: ${JSON.stringify(results, null, 2)}`) - const entity = results.find(r => r.id === 'test1'); + const entity = results!['test1'] expect(entity).toBeDefined(); // Verify all lifecycle methods were called in the correct order @@ -88,11 +92,15 @@ describe('Plugin Lifecycle', () => { }); test('should handle multiple updates correctly', () => { + const resolver = new CustomResolver(lossless, { + test: new LifecycleTestPlugin() + }); + // First update lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(1000) - .setProperty('test2', 'test', 'value1', 'test') + .setProperty('test2', 'test', 'value1') .buildV1() ); @@ -100,18 +108,14 @@ describe('Plugin Lifecycle', () => { lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(2000) - .setProperty('test2', 'test', 'value2', 'test') + .setProperty('test2', 'test', 'value2') .buildV1() ); - const resolver = new CustomResolver(lossless, { - test: new LifecycleTestPlugin() - }); - - const results = resolver.resolve() || []; - expect(Array.isArray(results)).toBe(true); + const results = resolver.resolve(); + expect(results).toBeDefined(); - const entity = results.find(r => r.id === 'test2'); + const entity = results!['test2']; expect(entity).toBeDefined(); // Verify state after multiple updates @@ -133,7 +137,7 @@ describe('Plugin Lifecycle', () => { }); const results = resolver.resolve(); - expect(Array.isArray(results)).toBe(true); - expect(results).toHaveLength(0); + expect(results).toBeDefined(); + expect(results).toMatchObject({}); }); }); diff --git a/__tests__/unit/views/resolvers/custom-resolvers/plugins/count-plugin.test.ts b/__tests__/unit/views/resolvers/custom-resolvers/plugins/count-plugin.test.ts index 5b78ee1..faded27 100644 --- a/__tests__/unit/views/resolvers/custom-resolvers/plugins/count-plugin.test.ts +++ b/__tests__/unit/views/resolvers/custom-resolvers/plugins/count-plugin.test.ts @@ -4,7 +4,7 @@ import { PropertyTypes } from '@src/core/types'; import type { CollapsedDelta } from '@src/views/lossless'; import { testResolverWithPlugins, createTestDelta } from '@test-helpers/resolver-test-helper'; -class CountPlugin extends ResolverPlugin<{ count: number }, never> { +class CountPlugin extends ResolverPlugin<{ count: number }> { readonly dependencies = [] as const; initialize() { @@ -33,7 +33,7 @@ describe('CountPlugin', () => { // Arrange & Act const entityId = 'counter1'; - await testResolverWithPlugins({ + const result = await testResolverWithPlugins({ entityId, plugins: { count: new CountPlugin() @@ -51,13 +51,11 @@ describe('CountPlugin', () => { .withTimestamp(3000) .setProperty(entityId, 'count', 'value3', 'test') .buildV1() - ], - expectedResult: (result) => { - // Assert - expect(result).toBeDefined(); - expect(result.properties.count).toBe(3); - } - }); + ]}); + + // Assert + expect(result).toBeDefined(); + expect(result.properties.count).toBe(3); }); test('should handle multiple entities independently', async () => { @@ -81,29 +79,33 @@ describe('CountPlugin', () => { ]; // Act & Assert - Test counter1 - await testResolverWithPlugins({ - entityId: 'counter1', - plugins: { - count: new CountPlugin() - }, - deltas: counter1Deltas, - expectedResult: (result) => { - expect(result).toBeDefined(); - expect(result.properties.count).toBe(2); - } - }); + { + const result = await testResolverWithPlugins({ + entityId: 'counter1', + plugins: { + count: new CountPlugin() + }, + deltas: counter1Deltas + }); + + // Assert + expect(result).toBeDefined(); + expect(result.properties.count).toBe(2); + } // Act & Assert - Test counter2 - await testResolverWithPlugins({ - entityId: 'counter2', - plugins: { - count: new CountPlugin() - }, - deltas: counter2Deltas, - expectedResult: (result) => { - expect(result).toBeDefined(); - expect(result.properties.count).toBe(1); - } - }); + { + const result = await testResolverWithPlugins({ + entityId: 'counter2', + plugins: { + count: new CountPlugin() + }, + deltas: counter2Deltas + }); + + // Assert + expect(result).toBeDefined(); + expect(result.properties.count).toBe(1); + } }); }); diff --git a/__tests__/unit/views/resolvers/custom-resolvers/plugins/discount-plugins.test.ts b/__tests__/unit/views/resolvers/custom-resolvers/plugins/discount-plugins.test.ts index 9955583..b928656 100644 --- a/__tests__/unit/views/resolvers/custom-resolvers/plugins/discount-plugins.test.ts +++ b/__tests__/unit/views/resolvers/custom-resolvers/plugins/discount-plugins.test.ts @@ -5,7 +5,7 @@ import { testResolverWithPlugins, createTestDelta } from '@test-helpers/resolver import Debug from 'debug'; const debug = Debug('rz:test:discount-plugins'); // Mock plugins for testing -class DiscountPlugin extends ResolverPlugin { +class DiscountPlugin extends ResolverPlugin { readonly name = 'discount' as const; readonly dependencies = [] as const; @@ -14,9 +14,12 @@ class DiscountPlugin extends ResolverPlugin { } update( - _currentState: number, + currentState: number, newValue: PropertyTypes, ) { + if (newValue === undefined) { + return currentState; + } const numValue = typeof newValue === 'number' ? newValue : 0; const clampedValue = Math.min(100, Math.max(0, numValue)); // Clamp between 0-100 debug(`DiscountPlugin: updated discount to ${clampedValue}`); @@ -28,7 +31,7 @@ class DiscountPlugin extends ResolverPlugin { } } -class DiscountedPricePlugin extends ResolverPlugin { +class DiscountedPricePlugin extends ResolverPlugin { readonly name = 'price' as const; readonly dependencies = ['discount'] as const; @@ -37,9 +40,13 @@ class DiscountedPricePlugin extends ResolverPlugin { } update( - _currentState: number | null, + currentState: number | null, newValue: PropertyTypes, ) { + debug(`DiscountedPricePlugin: updating price with state ${currentState} and value ${newValue}`) + if (newValue === undefined) { + return currentState; + } const numValue = typeof newValue === 'number' ? newValue : 0; debug(`DiscountedPricePlugin: updated price to ${numValue}`); return numValue; @@ -49,13 +56,16 @@ class DiscountedPricePlugin extends ResolverPlugin { state: number | null, dependencies: DependencyStates ): number | null { + debug(`DiscountedPricePlugin: resolving price with state ${state} and discount ${dependencies.discount}`); if (state === null) { return null; } // Ensure discount is a number and default to 0 if undefined const discount = typeof dependencies.discount === 'number' ? dependencies.discount : 0; const discountMultiplier = (100 - discount) / 100; - return state * discountMultiplier; + const result = state * discountMultiplier; + debug(`DiscountedPricePlugin: resolved price to ${result}`); + return result; } } diff --git a/__tests__/unit/views/resolvers/custom-resolvers/plugins/running-average.test.ts b/__tests__/unit/views/resolvers/custom-resolvers/plugins/running-average.test.ts index 63596b2..f0f101a 100644 --- a/__tests__/unit/views/resolvers/custom-resolvers/plugins/running-average.test.ts +++ b/__tests__/unit/views/resolvers/custom-resolvers/plugins/running-average.test.ts @@ -7,7 +7,7 @@ describe('RunningAveragePlugin', () => { // Arrange & Act const entityId = 'player1'; - await testResolverWithPlugins({ + const result = await testResolverWithPlugins({ entityId, plugins: { score: new RunningAveragePlugin() @@ -28,20 +28,18 @@ describe('RunningAveragePlugin', () => { .withTimestamp(3000) .setProperty(entityId, 'score', 30, 'game') .buildV1() - ], - expectedResult: (result) => { - // Assert - expect(result).toBeDefined(); - expect(result.properties.score).toBe(20); // (10 + 20 + 30) / 3 = 20 - } - }); + ]}); + + // Assert + expect(result).toBeDefined(); + expect(result.properties.score).toBe(20); // (10 + 20 + 30) / 3 = 20 }); test('should handle non-numeric values gracefully', async () => { // Arrange & Act const entityId = 'test1'; - await testResolverWithPlugins({ + const result = await testResolverWithPlugins({ entityId, plugins: { value: new RunningAveragePlugin() @@ -55,36 +53,25 @@ describe('RunningAveragePlugin', () => { .withTimestamp(2000) .setProperty(entityId, 'value', 10, 'test') .buildV1() - ], - expectedResult: (result) => { - // Assert - expect(result).toBeDefined(); - // First value is treated as 0, second as 10, average is (0 + 10) / 2 = 5 - expect(result.properties.value).toBe(5); - } - }); + ]}); + + // Assert + expect(result).toBeDefined(); + expect(result.properties.value).toBe(5); }); test('should handle empty state', async () => { // Arrange & Act const entityId = 'non-existent'; - let error: Error | undefined; - try { - await testResolverWithPlugins({ + const result = await testResolverWithPlugins({ entityId, plugins: { value: new RunningAveragePlugin() }, deltas: [], - expectedResult: () => {} }); - } catch (err) { - error = err as Error; - } - // Assert - The helper should throw when entity is not found - expect(error).toBeDefined(); - expect(error?.message).toContain('No view found for entity non-existent'); - }); + expect(result).toBeUndefined(); + }); }); diff --git a/__tests__/unit/views/resolvers/custom-resolvers/resolver.test.ts b/__tests__/unit/views/resolvers/custom-resolvers/resolver.test.ts index b15268a..97be6ab 100644 --- a/__tests__/unit/views/resolvers/custom-resolvers/resolver.test.ts +++ b/__tests__/unit/views/resolvers/custom-resolvers/resolver.test.ts @@ -8,11 +8,12 @@ import { ResolverPlugin } from '@src/views/resolvers/custom-resolvers/plugin'; // const debug = Debug('rz:test:resolver'); // Mock plugins for testing -class TestPlugin extends ResolverPlugin { +class TestPlugin extends ResolverPlugin { name: string; dependencies: readonly string[]; constructor(name: string, dependencies: string[] = []) { + super(); this.name = name; this.dependencies = dependencies; } @@ -70,11 +71,11 @@ describe('CustomResolver', () => { const graph = resolver.dependencyGraph; // Assert - expect(graph.get('a')).toBeDefined(); - expect(graph.get('b')).toBeDefined(); + expect(graph.get('plugin:a')).toBeDefined(); + expect(graph.get('plugin:b')).toBeDefined(); // Both 'plugin:b' and 'another:b' should be in the graph as 'b' - expect(Array.from(graph.get('a') || [])).toContain('b'); + expect(Array.from(graph.get('plugin:a') || [])).toContain('plugin:b'); }); test('should throw error for missing dependency', () => { diff --git a/__tests__/unit/views/resolvers/state-visibility.test.ts b/__tests__/unit/views/resolvers/state-visibility.test.ts index 01953be..1783e3d 100644 --- a/__tests__/unit/views/resolvers/state-visibility.test.ts +++ b/__tests__/unit/views/resolvers/state-visibility.test.ts @@ -18,7 +18,7 @@ describe('State Visibility', () => { }); // A test plugin that records which states it sees - class StateSpyPlugin extends ResolverPlugin<{ values: string[] }, 'dependsOn'> { + class StateSpyPlugin extends ResolverPlugin<{ values: string[] }> { readonly dependencies = [] as const; seenStates: Record[] = []; @@ -51,7 +51,7 @@ describe('State Visibility', () => { } // A simple plugin that depends on another property - class DependentPlugin extends ResolverPlugin<{ value: string }, 'dependsOn'> { + class DependentPlugin extends ResolverPlugin<{ value: string }> { readonly dependencies = ['dependsOn'] as const; seenStates: Record[] = []; @@ -94,8 +94,8 @@ describe('State Visibility', () => { lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(1000) - .setProperty('entity1', 'prop1', 'value1', 'prop1') - .setProperty('entity1', 'prop2', 'value2', 'prop2') + .setProperty('entity1', 'prop1', 'value1', 'entity-prop1') + .setProperty('entity1', 'prop2', 'value2', 'entity-prop2') .buildV1() ); @@ -189,7 +189,7 @@ describe('State Visibility', () => { }); test('should throw error for unknown dependencies', () => { - class PluginWithBadDeps extends ResolverPlugin<{ value: string }, 'nonexistent'> { + class PluginWithBadDeps extends ResolverPlugin<{ value: string }> { readonly dependencies = ['nonexistent'] as const; initialize() { @@ -217,6 +217,6 @@ describe('State Visibility', () => { new CustomResolver(lossless, { bad: new PluginWithBadDeps() }); - }).toThrow("Plugin 'bad' depends on unknown property: nonexistent"); + }).toThrow("Dependency nonexistent not found for plugin bad"); }); }); diff --git a/__tests__/unit/views/resolvers/timestamp-resolvers.test.ts b/__tests__/unit/views/resolvers/timestamp-resolvers.test.ts index 52d464d..1470474 100644 --- a/__tests__/unit/views/resolvers/timestamp-resolvers.test.ts +++ b/__tests__/unit/views/resolvers/timestamp-resolvers.test.ts @@ -6,8 +6,10 @@ import { DeltaIdTimestampResolver, HostIdTimestampResolver, LexicographicTimestampResolver -} from "../../../../src"; -import { createDelta } from "../../../../src/core/delta-builder"; +} from "@src"; +import { createDelta } from "@src/core/delta-builder"; +import Debug from "debug"; +const debug = Debug('rz:test:timestamp-resolvers'); describe('Timestamp Resolvers', () => { let node: RhizomeNode; @@ -20,6 +22,8 @@ describe('Timestamp Resolvers', () => { describe('Basic Timestamp Resolution', () => { test('should resolve by most recent timestamp', () => { + const resolver = new TimestampResolver(lossless); + // Add older delta lossless.ingestDelta(createDelta('user1', 'host1') .withId('delta1') @@ -38,14 +42,16 @@ describe('Timestamp Resolvers', () => { .buildV1() ); - const resolver = new TimestampResolver(lossless); const result = resolver.resolve(); expect(result).toBeDefined(); + debug(`Result: ${JSON.stringify(result, null, 2)}`) expect(result!['entity1'].properties.score).toBe(20); // More recent value wins }); test('should handle multiple entities with different timestamps', () => { + const resolver = new TimestampResolver(lossless); + // Entity1 - older value lossless.ingestDelta(createDelta('user1', 'host1') .withTimestamp(1000) @@ -62,7 +68,6 @@ describe('Timestamp Resolvers', () => { .buildV1() ); - const resolver = new TimestampResolver(lossless); const result = resolver.resolve(); expect(result).toBeDefined(); @@ -73,6 +78,8 @@ describe('Timestamp Resolvers', () => { describe('Tie-Breaking Strategies', () => { test('should break ties using creator-id strategy', () => { + const resolver = new CreatorIdTimestampResolver(lossless); + // Two deltas with same timestamp, different creators lossless.ingestDelta(createDelta('user_z', 'host1') .withId('delta1') @@ -90,7 +97,6 @@ describe('Timestamp Resolvers', () => { .buildV1() ); - const resolver = new CreatorIdTimestampResolver(lossless); const result = resolver.resolve(); expect(result).toBeDefined(); @@ -99,6 +105,8 @@ describe('Timestamp Resolvers', () => { }); test('should break ties using delta-id strategy', () => { + const resolver = new DeltaIdTimestampResolver(lossless); + // Two deltas with same timestamp, different delta IDs lossless.ingestDelta(createDelta('user1', 'host1') .withId('delta_a') // Lexicographically earlier @@ -116,7 +124,6 @@ describe('Timestamp Resolvers', () => { .buildV1() ); - const resolver = new DeltaIdTimestampResolver(lossless); const result = resolver.resolve(); expect(result).toBeDefined(); @@ -125,6 +132,8 @@ describe('Timestamp Resolvers', () => { }); test('should break ties using host-id strategy', () => { + const resolver = new HostIdTimestampResolver(lossless); + // Two deltas with same timestamp, different hosts lossless.ingestDelta(createDelta('user1', 'host_z') // Lexicographically later .withId('delta1') @@ -142,7 +151,6 @@ describe('Timestamp Resolvers', () => { .buildV1() ); - const resolver = new HostIdTimestampResolver(lossless); const result = resolver.resolve(); expect(result).toBeDefined(); @@ -151,6 +159,8 @@ describe('Timestamp Resolvers', () => { }); test('should break ties using lexicographic strategy with string values', () => { + const resolver = new LexicographicTimestampResolver(lossless); + // Two deltas with same timestamp, different string values lossless.ingestDelta(createDelta('user1', 'host1') .withId('delta1') @@ -168,7 +178,6 @@ describe('Timestamp Resolvers', () => { .buildV1() ); - const resolver = new LexicographicTimestampResolver(lossless); const result = resolver.resolve(); expect(result).toBeDefined(); @@ -177,6 +186,8 @@ describe('Timestamp Resolvers', () => { }); test('should break ties using lexicographic strategy with numeric values (falls back to delta ID)', () => { + const resolver = new LexicographicTimestampResolver(lossless); + // Two deltas with same timestamp, numeric values (should fall back to delta ID comparison) lossless.ingestDelta(createDelta('user1', 'host1') .withId('delta_a') // Lexicographically earlier @@ -194,7 +205,6 @@ describe('Timestamp Resolvers', () => { .buildV1() ); - const resolver = new LexicographicTimestampResolver(lossless); const result = resolver.resolve(); expect(result).toBeDefined(); @@ -205,6 +215,9 @@ describe('Timestamp Resolvers', () => { describe('Complex Tie-Breaking Scenarios', () => { test('should handle multiple properties with different tie-breaking outcomes', () => { + const creatorResolver = new CreatorIdTimestampResolver(lossless); + const deltaResolver = new DeltaIdTimestampResolver(lossless); + // Add deltas for multiple properties with same timestamp lossless.ingestDelta(createDelta('user_a', 'host1') .withId('delta_z') @@ -222,9 +235,6 @@ describe('Timestamp Resolvers', () => { .buildV1() ); - const creatorResolver = new CreatorIdTimestampResolver(lossless); - const deltaResolver = new DeltaIdTimestampResolver(lossless); - const creatorResult = creatorResolver.resolve(); const deltaResult = deltaResolver.resolve(); @@ -239,6 +249,8 @@ describe('Timestamp Resolvers', () => { }); test('should work consistently with timestamp priority over tie-breaking', () => { + const resolver = new CreatorIdTimestampResolver(lossless); + // Add older delta with "better" tie-breaking attributes lossless.ingestDelta(createDelta('user_z', 'host1') .withId('delta_z') // Would win in delta ID tie-breaking @@ -257,7 +269,6 @@ describe('Timestamp Resolvers', () => { .buildV1() ); - const resolver = new CreatorIdTimestampResolver(lossless); const result = resolver.resolve(); expect(result).toBeDefined(); @@ -268,6 +279,7 @@ describe('Timestamp Resolvers', () => { describe('Edge Cases', () => { test('should handle single delta correctly', () => { + const resolver = new TimestampResolver(lossless, 'creator-id'); lossless.ingestDelta(createDelta('user1', 'host1') .withId('delta1') .withTimestamp(1000) @@ -276,7 +288,6 @@ describe('Timestamp Resolvers', () => { .buildV1() ); - const resolver = new TimestampResolver(lossless, 'creator-id'); const result = resolver.resolve(); expect(result).toBeDefined(); @@ -284,6 +295,8 @@ describe('Timestamp Resolvers', () => { }); test('should handle mixed value types correctly', () => { + const resolver = new TimestampResolver(lossless); + lossless.ingestDelta(createDelta('user1', 'host1') .withId('delta1') .withTimestamp(1000) @@ -300,7 +313,6 @@ describe('Timestamp Resolvers', () => { .buildV1() ); - const resolver = new TimestampResolver(lossless); const result = resolver.resolve(); expect(result).toBeDefined(); diff --git a/docs/custom-resolvers/api/custom-resolver-class.md b/docs/custom-resolvers/api/custom-resolver-class.md index 86846b3..f447d1b 100644 --- a/docs/custom-resolvers/api/custom-resolver-class.md +++ b/docs/custom-resolvers/api/custom-resolver-class.md @@ -131,7 +131,7 @@ The resolver is configured with an object mapping property IDs to their resolver ```typescript interface ResolverConfig { - [propertyId: string]: ResolverPlugin; + [propertyId: string]: ResolverPlugin; } ``` @@ -164,7 +164,7 @@ const resolver = new CustomResolver(view, { taxRate: new LastWriteWinsPlugin(), // Complex plugin with multiple dependencies - subtotal: new class extends ResolverPlugin { + subtotal: new class extends ResolverPlugin { readonly dependencies = ['unitPrice', 'quantity'] as const; initialize() { return { value: 0 }; } diff --git a/docs/custom-resolvers/api/resolver-plugin-interface.md b/docs/custom-resolvers/api/resolver-plugin-interface.md index 8b9e3db..ff84424 100644 --- a/docs/custom-resolvers/api/resolver-plugin-interface.md +++ b/docs/custom-resolvers/api/resolver-plugin-interface.md @@ -7,7 +7,7 @@ The `ResolverPlugin` interface defines the contract that all resolver plugins mu ## Interface Definition ```typescript -interface ResolverPlugin { +interface ResolverPlugin { /** * Unique identifier for the plugin */ @@ -126,7 +126,7 @@ class CounterPlugin extends ResolverPlugin { ### Accessing Dependencies ```typescript -class PriceCalculator extends ResolverPlugin { +class PriceCalculator extends ResolverPlugin { readonly dependencies = ['basePrice', 'taxRate'] as const; update( @@ -147,7 +147,7 @@ class PriceCalculator extends ResolverPlugin { +class OptionalDepPlugin extends ResolverPlugin { readonly dependencies = ['required', 'optional?'] as const; update( diff --git a/docs/custom-resolvers/api/types.md b/docs/custom-resolvers/api/types.md index dd7d620..8b01fdc 100644 --- a/docs/custom-resolvers/api/types.md +++ b/docs/custom-resolvers/api/types.md @@ -51,10 +51,10 @@ Represents a collapsed delta with metadata about the change. ## Plugin Types -### `ResolverPlugin` +### `ResolverPlugin` ```typescript -interface ResolverPlugin { +interface ResolverPlugin { readonly name: string; readonly dependencies?: readonly D[]; @@ -106,7 +106,7 @@ Maps entity IDs to their resolved properties. ```typescript interface ResolverConfig { - [propertyId: string]: ResolverPlugin; + [propertyId: string]: ResolverPlugin; } ``` @@ -197,7 +197,7 @@ Extracts the dependency keys from a plugin type. ```typescript type DependencyKeys = - T extends ResolverPlugin ? D : never; + T extends ResolverPlugin ? D : never; ``` ### `PluginState` @@ -206,7 +206,7 @@ Extracts the state type from a plugin type. ```typescript type PluginState = - T extends ResolverPlugin ? S : never; + T extends ResolverPlugin ? S : never; ``` ## Example: Working with Types @@ -222,7 +222,7 @@ interface CounterState { type CounterDeps = 'incrementBy' | 'resetThreshold'; // Implement plugin with type safety -class CounterPlugin extends ResolverPlugin { +class CounterPlugin extends ResolverPlugin { readonly dependencies = ['incrementBy', 'resetThreshold'] as const; initialize(): CounterState { diff --git a/docs/custom-resolvers/dependencies/dependency-resolution.md b/docs/custom-resolvers/dependencies/dependency-resolution.md index 6f3387e..968cecc 100644 --- a/docs/custom-resolvers/dependencies/dependency-resolution.md +++ b/docs/custom-resolvers/dependencies/dependency-resolution.md @@ -50,7 +50,7 @@ const resolver = new CustomResolver(view, { To make a dependency optional, mark it with a `?` suffix: ```typescript -class MyPlugin extends ResolverPlugin { +class MyPlugin extends ResolverPlugin { readonly dependencies = ['required', 'optional?'] as const; // ... diff --git a/docs/custom-resolvers/dependencies/index.md b/docs/custom-resolvers/dependencies/index.md index 9ea1619..3a4b6af 100644 --- a/docs/custom-resolvers/dependencies/index.md +++ b/docs/custom-resolvers/dependencies/index.md @@ -21,7 +21,7 @@ The Custom Resolver system provides a powerful dependency management system that ## Example ```typescript -class TotalPricePlugin extends ResolverPlugin { +class TotalPricePlugin extends ResolverPlugin { readonly dependencies = ['price', 'tax'] as const; initialize(): TotalState { diff --git a/docs/custom-resolvers/dependencies/type-safety.md b/docs/custom-resolvers/dependencies/type-safety.md index 7fa31ad..ca76822 100644 --- a/docs/custom-resolvers/dependencies/type-safety.md +++ b/docs/custom-resolvers/dependencies/type-safety.md @@ -14,7 +14,7 @@ The Custom Resolver system leverages TypeScript's type system to provide compile The `ResolverPlugin` interface uses TypeScript generics to enforce type safety: ```typescript -interface ResolverPlugin { +interface ResolverPlugin { readonly name: string; readonly dependencies?: readonly D[]; @@ -37,7 +37,7 @@ type DependencyStates = { Dependencies are declared as a readonly array of string literals: ```typescript -class MyPlugin extends ResolverPlugin { +class MyPlugin extends ResolverPlugin { readonly dependencies = ['dep1', 'dep2'] as const; // ... implementation @@ -101,7 +101,7 @@ if (typeof deps.price === 'number') { ### Optional Dependencies ```typescript -class MyPlugin extends ResolverPlugin { +class MyPlugin extends ResolverPlugin { readonly dependencies = ['required', 'optional?'] as const; update(_state: MyState, _value: unknown, _delta: CollapsedDelta, deps: any) { @@ -118,7 +118,7 @@ class MyPlugin extends ResolverPlugin { ```typescript type PriceDependencies = 'price1' | 'price2' | 'price3'; -class PriceAggregator extends ResolverPlugin { +class PriceAggregator extends ResolverPlugin { readonly dependencies: readonly PriceDependencies[] = ['price1', 'price2', 'price3'] as const; update(_state: PriceState, _value: unknown, _delta: CollapsedDelta, deps: any) { diff --git a/docs/custom-resolvers/plugins/creating-plugins.md b/docs/custom-resolvers/plugins/creating-plugins.md index f9ce410..ba28b4a 100644 --- a/docs/custom-resolvers/plugins/creating-plugins.md +++ b/docs/custom-resolvers/plugins/creating-plugins.md @@ -40,7 +40,7 @@ class MyPlugin extends ResolverPlugin { To depend on other properties, specify the dependency types: ```typescript -class DiscountedPricePlugin extends ResolverPlugin { +class DiscountedPricePlugin extends ResolverPlugin { readonly dependencies = ['basePrice', 'discount'] as const; initialize(): DiscountState { diff --git a/src/collections/collection-abstract.ts b/src/collections/collection-abstract.ts index f6d4b52..922d873 100644 --- a/src/collections/collection-abstract.ts +++ b/src/collections/collection-abstract.ts @@ -91,8 +91,7 @@ export abstract class Collection { if (deltas.length > 1) { // We can generate a separate delta describing this transaction transactionDelta = createDelta(creator, host) - .addPointer('_transaction', transactionId, 'size') - .addPointer('size', deltas.length) + .declareTransaction(transactionId, deltas.length) .buildV1(); // Also need to annotate the deltas with the transactionId @@ -123,16 +122,21 @@ export abstract class Collection { } getIds(): string[] { - if (!this.rhizomeNode) return []; - const set = this.rhizomeNode.lossless.referencedAs.get(this.name); - if (!set) return []; - return Array.from(set.values()); + if (!this.rhizomeNode) { + debug(`No rhizome node connected`) + return []; + } + debug(`Getting ids for collection ${this.name}`) + const ids = new Set(); + for (const [entityId, names] of this.rhizomeNode.lossless.referencedAs.entries()) { + if (names.has(this.name)) { + ids.add(entityId); + } + } + debug(`Found ${ids.size} ids for collection ${this.name}`); + return Array.from(ids.values()); } - // THIS PUT SHOULD CORRESOND TO A PARTICULAR MATERIALIZED VIEW... - // How can we encode that? - // Well, we have a way to do that, we just need the same particular inputs. - // We take a resolver as an optional argument. async put( entityId: DomainEntityID | undefined, properties: EntityProperties, diff --git a/src/collections/collection-typed.ts b/src/collections/collection-typed.ts index ed155ac..aba3cf5 100644 --- a/src/collections/collection-typed.ts +++ b/src/collections/collection-typed.ts @@ -11,8 +11,9 @@ import { } from '../schema/schema'; import { DefaultSchemaRegistry } from '../schema/schema-registry'; import { LosslessViewOne } from '../views/lossless'; -import { DomainEntityID, PropertyTypes } from '../core/types'; +import { DomainEntityID } from '../core/types'; import { EntityProperties } from '../core/entity'; +import { createDelta } from '@src/core'; const debug = Debug('rz:typed-collection'); @@ -77,17 +78,11 @@ export class TypedCollectionImpl> propertyDeltas: {}, }; - // Create mock deltas for each property for (const [key, value] of Object.entries(entity)) { - if (value !== undefined) { - mockLosslessView.propertyDeltas[key] = [{ - id: 'mock-delta', - timeCreated: Date.now(), - host: 'validation', - creator: 'validation', - pointers: [{ [key]: value as PropertyTypes }] - }]; - } + mockLosslessView.propertyDeltas[key] = [createDelta('validation', 'validation') + .addPointer(key, value as string) + .buildV1(), + ]; } return this.schemaRegistry.validate('validation-mock', this.schema.id, mockLosslessView); @@ -130,9 +125,11 @@ export class TypedCollectionImpl> entityId: DomainEntityID | undefined, properties: EntityProperties, ): Promise { - // Validate against schema if strict validation is enabled + // Validate against schema + const validationResult = this.validate(properties as T); + + // If strict validation is enabled, throw on validation failure if (this.applicationOptions.strictValidation) { - const validationResult = this.validate(properties as T); if (!validationResult.valid) { throw new SchemaValidationError( `Schema validation failed: ${validationResult.errors.map(e => e.message).join(', ')}`, @@ -145,7 +142,6 @@ export class TypedCollectionImpl> const result = await super.put(entityId, properties); // Log validation warnings if any - const validationResult = this.validate(properties as T); if (validationResult.warnings.length > 0) { debug(`Validation warnings for entity ${entityId}:`, validationResult.warnings); } @@ -200,13 +196,21 @@ export class TypedCollectionImpl> // Filter entities by schema validation status getValidEntities(): DomainEntityID[] { - if (!this.rhizomeNode) return []; - + if (!this.rhizomeNode) { + debug(`No rhizome node connected`) + return []; + } + const losslessView = this.rhizomeNode.lossless.compose(this.getIds()); + if (!losslessView) { + debug(`No lossless view found`) + return []; + } + debug(`getValidEntities, losslessView: ${JSON.stringify(losslessView, null, 2)}`) + debug(`Validating ${this.getIds().length} entities`) return this.getIds().filter(entityId => { - const losslessView = this.rhizomeNode!.lossless.compose([entityId])[entityId]; - if (!losslessView) return false; - - const validationResult = this.schemaRegistry.validate(entityId, this.schema.id, losslessView); + debug(`Validating entity ${entityId}`) + const validationResult = this.schemaRegistry.validate(entityId, this.schema.id, losslessView[entityId]); + debug(`Validation result for entity ${entityId}: ${JSON.stringify(validationResult)}`) return validationResult.valid; }); } diff --git a/src/core/delta-builder.ts b/src/core/delta-builder.ts index 9e288d6..e75a06e 100644 --- a/src/core/delta-builder.ts +++ b/src/core/delta-builder.ts @@ -2,6 +2,7 @@ import { DeltaV1, DeltaV2 } from './delta'; import { randomUUID } from 'crypto'; import { PropertyTypes } from './types'; import { PointersV2 } from './delta'; +import { DeltaNetworkImageV1, DeltaNetworkImageV2 } from './delta'; import Debug from 'debug'; const debug = Debug('rz:delta-builder'); @@ -16,6 +17,7 @@ export class DeltaBuilder { private host: string; private creator: string; private pointers: PointersV2 = {}; + private references: Record = {}; /** * Create a new DeltaBuilder instance @@ -96,7 +98,7 @@ export class DeltaBuilder { * ! Note that the way we are doing this is awkward/problematic for deltas that set multiple properties. * ! entityLabel and property each need to be unique within a given delta */ - setProperty(entityId: string, property: string, value: string | number | boolean | null, entityLabel = "entity"): this { + setProperty(entityId: string, property: string, value: PropertyTypes, entityLabel = "entity"): this { this.addPointer(entityLabel, entityId, property) this.addPointer(property, value); return this; @@ -123,6 +125,37 @@ export class DeltaBuilder { return this; } + reference(entityId: string, entityLabel: string): this { + if (this.references[entityLabel]) { + debug(`Reference for '${entityLabel}' already exists with different value: ${this.references[entityLabel]} !== ${entityId}`); + throw new Error(`Reference for ${entityLabel} already exists with different value`); + } + this.references[entityLabel] = entityId; + return this; + } + + static fromNetworkImage(delta: DeltaNetworkImageV1 | DeltaNetworkImageV2): DeltaBuilder { + const builder = new DeltaBuilder(delta.creator, delta.host) + .withId(delta.id) + .withTimestamp(delta.timeCreated); + if (Array.isArray(delta.pointers)) { + for (const pointer of delta.pointers) { + builder.addPointer(pointer.localContext, pointer.target, pointer.targetContext); + } + } else { + for (const [localContext, target] of Object.entries(delta.pointers)) { + if (typeof target === 'object') { + const [[targetContext, targetValue]] = Object.entries(target!); + builder.addPointer(localContext, targetValue, targetContext); + } else { + builder.addPointer(localContext, target as PropertyTypes); + } + } + } + + return builder; + } + /** * Build and return a DeltaV2 instance */ diff --git a/src/http/api.ts b/src/http/api.ts index a08f22f..f273ac9 100644 --- a/src/http/api.ts +++ b/src/http/api.ts @@ -137,7 +137,7 @@ export class HttpApi { const {params: {id}} = req; const v = this.rhizomeNode.lossless.compose([id]); const ent = v[id]; - if (!ent.referencedAs.includes("_transaction")) { + if (!ent.referencedAs?.includes("_transaction")) { res.status(400).json({error: "Entity is not a transaction", id}); return; } diff --git a/src/query/query-engine.ts b/src/query/query-engine.ts index 76dcaec..4f93a8d 100644 --- a/src/query/query-engine.ts +++ b/src/query/query-engine.ts @@ -2,9 +2,9 @@ import jsonLogic from 'json-logic-js'; const { apply, is_logic } = jsonLogic; import Debug from 'debug'; import { SchemaRegistry, SchemaID, ObjectSchema } from '../schema/schema'; -import { Lossless, LosslessViewOne, LosslessViewMany, CollapsedDelta } from '../views/lossless'; +import { Lossless, LosslessViewMany, LosslessViewOne, valueFromDelta } from '../views/lossless'; import { DomainEntityID } from '../core/types'; -import { DeltaFilter } from '../core/delta'; +import { Delta, DeltaFilter } from '../core/delta'; const debug = Debug('rz:query'); @@ -290,7 +290,7 @@ export class QueryEngine { case 'reference': { // For references, include the target IDs const refValues = deltas - .map(delta => this.extractReferenceValue(delta, propertyId)) + .map(delta => this.extractPrimitiveValue(delta, propertyId)) .filter(value => value !== null); obj[propertyId] = refValues; break; @@ -308,28 +308,8 @@ export class QueryEngine { /** * Extract primitive value from a delta for a given property */ - private extractPrimitiveValue(delta: CollapsedDelta, propertyId: string): unknown { - // Look for the value in collapsed pointers - // CollapsedPointer is {[key: PropertyID]: PropertyTypes} - for (const pointer of delta.pointers) { - if (pointer[propertyId] !== undefined) { - return pointer[propertyId]; - } - } - return null; - } - - /** - * Extract reference value (target ID) from a delta for a given property - */ - private extractReferenceValue(delta: CollapsedDelta, _propertyId: string): string | null { - // For references, we want the value pointer that contains the reference ID - for (const pointer of delta.pointers) { - if (pointer.value !== undefined && typeof pointer.value === 'string') { - return pointer.value; - } - } - return null; + private extractPrimitiveValue(delta: Delta, propertyId: string): unknown { + return valueFromDelta(propertyId, delta); } /** diff --git a/src/query/storage-query-engine.ts b/src/query/storage-query-engine.ts index 3559ce4..18ef7b0 100644 --- a/src/query/storage-query-engine.ts +++ b/src/query/storage-query-engine.ts @@ -254,7 +254,7 @@ export class StorageQueryEngine { case 'reference': { // For references, include the target IDs const refValues = propDeltas - .map(delta => this.extractReferenceValue(delta, propertyId)) + .map(delta => this.extractPrimitiveValue(delta, propertyId)) .filter(value => value !== null); properties[propertyId] = refValues; break; @@ -282,18 +282,6 @@ export class StorageQueryEngine { return null; } - /** - * Extract reference value from a delta for a given property - */ - private extractReferenceValue(delta: Delta, propertyId: string): string | null { - for (const pointer of delta.pointers) { - if (pointer.localContext === propertyId && typeof pointer.target === 'string') { - return pointer.target; - } - } - return null; - } - /** * Apply JSON Logic filter to entity results */ diff --git a/src/schema/schema-registry.ts b/src/schema/schema-registry.ts index 7cbbe51..7993fc0 100644 --- a/src/schema/schema-registry.ts +++ b/src/schema/schema-registry.ts @@ -14,9 +14,10 @@ import { SchemaApplicationOptions, ResolutionContext } from '../schema/schema'; -import { LosslessViewOne, Lossless } from '../views/lossless'; +import { Lossless, LosslessViewOne } from '../views/lossless'; import { DomainEntityID, PropertyID, PropertyTypes } from '../core/types'; import { CollapsedDelta } from '../views/lossless'; +import { Delta } from '@src/core'; const debug = Debug('rz:schema-registry'); @@ -146,11 +147,13 @@ export class DefaultSchemaRegistry implements SchemaRegistry { // Validate each delta for this property for (const delta of deltas) { + debug(`Validating delta ${delta.id} on property ${propertyId}`); const validationResult = this.validateDeltaAgainstPropertySchema( delta, propertySchema, propertyId ); + debug(`Validation result for delta ${delta.id}: ${JSON.stringify(validationResult)}`) errors.push(...validationResult.errors); warnings.push(...validationResult.warnings); } @@ -176,7 +179,7 @@ export class DefaultSchemaRegistry implements SchemaRegistry { } private validateDeltaAgainstPropertySchema( - delta: CollapsedDelta, + delta: Delta, schema: PropertySchema, propertyId: PropertyID ): SchemaValidationResult { @@ -184,7 +187,7 @@ export class DefaultSchemaRegistry implements SchemaRegistry { const warnings: SchemaValidationError[] = []; // Extract the value from the delta - const valuePointer = delta.pointers.find(p => p[propertyId] !== undefined); + const valuePointer = delta.pointers.find(p => p.localContext === propertyId); if (!valuePointer) { errors.push({ property: propertyId, @@ -193,7 +196,7 @@ export class DefaultSchemaRegistry implements SchemaRegistry { return { valid: false, errors, warnings }; } - const value = valuePointer[propertyId]; + const value = valuePointer.target; switch (schema.type) { case 'primitive': @@ -444,7 +447,7 @@ export class DefaultSchemaRegistry implements SchemaRegistry { } private resolveReferenceProperty( - deltas: CollapsedDelta[], + deltas: Delta[], referenceSchema: ReferenceSchema, losslessView: Lossless, context: ResolutionContext, @@ -508,7 +511,7 @@ export class DefaultSchemaRegistry implements SchemaRegistry { } private createCompositeObjectFromDelta( - delta: CollapsedDelta, + delta: Delta, parentEntityId: string, targetSchema: SchemaID, losslessView: Lossless, @@ -520,29 +523,27 @@ export class DefaultSchemaRegistry implements SchemaRegistry { let entityReferenceCount = 0; let scalarCount = 0; - for (const pointer of delta.pointers) { - for (const [localContext, target] of Object.entries(pointer)) { - // Skip the pointer that references the parent entity (the "up" pointer) - if (typeof target === 'string' && target === parentEntityId) { - continue; - } + for (const {localContext, target} of delta.pointers) { + // Skip the pointer that references the parent entity (the "up" pointer) + if (typeof target === 'string' && target === parentEntityId) { + continue; + } - if (!pointersByContext[localContext]) { - pointersByContext[localContext] = []; - } - pointersByContext[localContext].push(target); + if (!pointersByContext[localContext]) { + pointersByContext[localContext] = []; + } + pointersByContext[localContext].push(target); - // Count entity references vs scalars - if (typeof target === 'string') { - const referencedViews = losslessView.compose([target]); - if (referencedViews[target]) { - entityReferenceCount++; - } else { - scalarCount++; - } + // Count entity references vs scalars + if (typeof target === 'string') { + const referencedViews = losslessView.compose([target]); + if (referencedViews[target]) { + entityReferenceCount++; } else { scalarCount++; } + } else { + scalarCount++; } } diff --git a/src/views/lossless.ts b/src/views/lossless.ts index 1422383..59a36f7 100644 --- a/src/views/lossless.ts +++ b/src/views/lossless.ts @@ -9,7 +9,6 @@ import {Transactions} from '../features/transactions'; import {DomainEntityID, PropertyID, PropertyTypes, TransactionID, ViewMany} from "../core/types"; import {Negation} from '../features/negation'; import {NegationHelper} from '../features/negation'; -import { createDelta } from '../core/delta-builder'; const debug = Debug('rz:lossless'); export type CollapsedPointer = {[key: PropertyID]: PropertyTypes}; @@ -19,28 +18,53 @@ export type CollapsedDelta = Omit & { }; // Extract a particular value from a delta's pointers -export function valueFromCollapsedDelta( +export function valueFromDelta( key: string, - delta: CollapsedDelta -): string | number | undefined { + delta: Delta | CollapsedDelta +): PropertyTypes | undefined { + let result: PropertyTypes | undefined; for (const pointer of delta.pointers) { - for (const [k, value] of Object.entries(pointer)) { - if (k === key && (typeof value === "string" || typeof value === "number")) { - return value; + // Should be equivalent to delta instanceof Delta + if (['localContext', 'target'].every(k => k in pointer)) { + if (pointer.localContext === key) { + if (result) { + debug(`multiple values for key ${key} in delta ${delta.id}`); + throw new Error(`Multiple values for key ${key} in delta ${delta.id}`); + } + result = pointer.target; + } + } else { + for (const [k, value] of Object.entries(pointer)) { + if (k === key) { + 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[]; + referencedAs?: string[]; propertyDeltas: { + [key: PropertyID]: Delta[] + } +} + +export type CollapsedViewOne = Omit & { + propertyCollapsedDeltas: { [key: PropertyID]: CollapsedDelta[] } }; export type LosslessViewMany = ViewMany; +export type CollapsedViewMany = ViewMany; class LosslessEntityMap extends Map {}; @@ -67,8 +91,9 @@ class LosslessEntity { } propertyDeltas.add(delta); - debug(`[${this.lossless.rhizomeNode.config.peerId}]`, `entity ${this.id} added delta:`, JSON.stringify(delta)); } + + debug(`[${this.lossless.rhizomeNode.config.peerId}]`, `entity ${this.id} added delta:`, JSON.stringify(delta)); } toJSON() { @@ -78,6 +103,7 @@ class LosslessEntity { } return { id: this.id, + referencedAs: Array.from(this.lossless.referencedAs.get(this.id) ?? []), properties }; } @@ -86,8 +112,10 @@ class LosslessEntity { export class Lossless { domainEntities = new LosslessEntityMap(); transactions: Transactions; - referencedAs = new Map>(); eventStream = new EventEmitter(); + + // TODO: This referencedAs map doesn't really belong at this layer of abstraction + referencedAs = new Map>(); // Track all deltas by ID for negation processing private allDeltas = new Map(); @@ -185,39 +213,11 @@ export class Lossless { const seenDeltaIds = new Set(); // Collect all deltas from all properties - for (const [propertyId, deltas] of Object.entries(view.propertyDeltas)) { + for (const [_propertyId, deltas] of Object.entries(view.propertyDeltas)) { for (const delta of deltas) { if (!seenDeltaIds.has(delta.id)) { seenDeltaIds.add(delta.id); - - // Create a new delta using DeltaBuilder - const builder = createDelta(delta.creator, delta.host) - .withId(delta.id) - .withTimestamp(delta.timeCreated); - - // Add all pointers from the collapsed delta - for (const pointer of delta.pointers) { - const pointerEntries = Object.entries(pointer); - if (pointerEntries.length === 1) { - const [localContext, target] = pointerEntries[0]; - if (target === null || target === undefined) { - continue; // Skip null/undefined targets - } - if (typeof target === 'string' && this.domainEntities.has(target)) { - // This is a reference pointer to an entity - builder.addPointer(localContext, target, propertyId); - } else if (typeof target === 'string' || typeof target === 'number' || typeof target === 'boolean') { - // Scalar pointer with valid type - builder.addPointer(localContext, target); - } else { - // For other types (objects, arrays), convert to string - builder.addPointer(localContext, JSON.stringify(target)); - } - } - } - - // Build the delta and add to results - allDeltas.push(builder.buildV1()); + allDeltas.push(delta); } } } @@ -229,14 +229,14 @@ export class Lossless { const view: LosslessViewMany = {}; entityIds = entityIds ?? Array.from(this.domainEntities.keys()); - for (const id of entityIds) { - const ent = this.domainEntities.get(id); + for (const entityId of entityIds) { + const ent = this.domainEntities.get(entityId); if (!ent) continue; const referencedAs = new Set(); const propertyDeltas: { - [key: PropertyID]: CollapsedDelta[] + [key: PropertyID]: Delta[] } = {}; let hasVisibleDeltas = false; @@ -255,7 +255,7 @@ export class Lossless { for (const [key, deltas] of ent.properties.entries()) { // Filter deltas for this property based on negation status const filteredDeltas = Array.from(deltas).filter(delta => nonNegatedDeltaIds.has(delta.id)); - const visibleDeltas: CollapsedDelta[] = []; + const visibleDeltas: Delta[] = []; for (const delta of filteredDeltas) { if (deltaFilter && !deltaFilter(delta)) { @@ -271,19 +271,12 @@ export class Lossless { } } - const pointers: CollapsedPointer[] = []; - - for (const {localContext, target} of delta.pointers) { - if (target === ent.id) { - referencedAs.add(localContext); - } - pointers.push({[localContext]: target}); + const ref = delta.pointers.find(p => p.target === entityId) + if (ref) { + referencedAs.add(ref.localContext); } - visibleDeltas.push({ - ...delta, - pointers - }); + visibleDeltas.push(delta); hasVisibleDeltas = true; } @@ -302,10 +295,10 @@ export class Lossless { // Only include entity in view if it has visible deltas if (hasVisibleDeltas) { - view[ent.id] = { - id: ent.id, + view[entityId] = { + id: entityId, referencedAs: Array.from(referencedAs.values()), - propertyDeltas + propertyDeltas, }; } } diff --git a/src/views/lossy.ts b/src/views/lossy.ts index bc28076..f01be74 100644 --- a/src/views/lossy.ts +++ b/src/views/lossy.ts @@ -47,7 +47,6 @@ export abstract class Lossy { return this.deltaFilter(delta); }; const losslessPartial = this.lossless.compose([entityId], combinedFilter); - debug(`Lossless partial for entity ${entityId}:`, JSON.stringify(losslessPartial)); if (!losslessPartial) { // This should not happen; this should only be called after the lossless view has been updated diff --git a/src/views/resolvers/aggregation-resolvers.ts b/src/views/resolvers/aggregation-resolvers.ts index e8bcbbb..d714e01 100644 --- a/src/views/resolvers/aggregation-resolvers.ts +++ b/src/views/resolvers/aggregation-resolvers.ts @@ -1,8 +1,10 @@ import { Lossless, LosslessViewOne } from "../lossless"; import { Lossy } from '../lossy'; import { DomainEntityID, PropertyID, ViewMany } from "../../core/types"; -import { valueFromCollapsedDelta } from "../lossless"; +import { valueFromDelta } from "../lossless"; import { EntityRecord, EntityRecordMany } from "@src/core/entity"; +import Debug from 'debug'; +const debug = Debug('rz:test:performance'); export type AggregationType = 'min' | 'max' | 'sum' | 'average' | 'count'; @@ -76,18 +78,11 @@ export class AggregationResolver extends Lossy { } // Extract numeric values from all deltas for this property - for (const delta of deltas || []) { - const value = valueFromCollapsedDelta(propertyId, delta); + for (const delta of deltas) { + const value = valueFromDelta(propertyId, delta); + if (typeof value === 'number') { - if (this.config[propertyId] === 'count') { - // For count, include all values (including duplicates) - acc[cur.id].properties[propertyId].values.push(value); - } else { - // For other aggregations, only add unique values - if (!acc[cur.id].properties[propertyId].values.includes(value)) { - acc[cur.id].properties[propertyId].values.push(value); - } - } + acc[cur.id].properties[propertyId].values.push(value); } } } diff --git a/src/views/resolvers/custom-resolvers/plugin.ts b/src/views/resolvers/custom-resolvers/plugin.ts index 1bbcdf1..09e09b7 100644 --- a/src/views/resolvers/custom-resolvers/plugin.ts +++ b/src/views/resolvers/custom-resolvers/plugin.ts @@ -1,5 +1,7 @@ import { PropertyID, PropertyTypes } from "../../../core/types"; import { CollapsedDelta } from "../../lossless"; +import Debug from 'debug'; +const debug = Debug('rz:custom-resolver:plugin'); /** * Type representing a mapping of dependency names to their state types @@ -15,18 +17,16 @@ export type DependencyStates = Record; * @template T - Type of the plugin's internal state * @template D - Union type of dependency names (e.g., 'discount' | 'tax') */ -export abstract class ResolverPlugin< - T = unknown, - D extends string = never -> { +export abstract class ResolverPlugin< T = unknown > { + name?: string; + dependencies?: readonly string[]; - name?: PropertyID; + constructor(readonly target?: string) { + if (target) { + this.dependencies = [target]; + } + } - /** - * Array of property IDs that this plugin depends on. - * The plugins corresponding to these properties will be processed before this plugin. - */ - dependencies?: readonly D[]; /** * Convenience wrapper to avoid calling update() when there is no new value @@ -42,21 +42,17 @@ export abstract class ResolverPlugin< delta?: CollapsedDelta, dependencies?: DependencyStates ): T { + debug(`applyUpdate, currentState: ${JSON.stringify(currentState)}, newValue: ${JSON.stringify(newValue)}, dependencies: ${JSON.stringify(dependencies)}`) if (newValue === undefined) { - switch(this.dependencies?.length) { - case 0: { - // No dependencies, no new value -- nothing to do. - return currentState; - } - case 1: { - // Only one dependency, use it as the new value. - newValue = dependencies![this.dependencies[0]] as PropertyTypes; - break; - } - default: { - // Pass dependencies as is, and leave newValue undefined. - break; - } + debug(`No new value, checking dependencies. Plugin target is ${JSON.stringify(this.target)}`) + if (this.target && dependencies) { + // Pass the target value as the new value + newValue = dependencies[this.target] as PropertyTypes; + debug(`Found target ${JSON.stringify(this.target)}, value: ${JSON.stringify(newValue)}`) + } else if (!this.dependencies?.length) { + // No dependencies, no new value -- nothing to do. + debug(`No dependencies, no new value -- nothing to do.`) + return currentState; } } return this.update(currentState, newValue, delta, dependencies); @@ -92,15 +88,15 @@ export abstract class ResolverPlugin< * Configuration for custom resolver with type-safe plugin configurations */ export type CustomResolverConfig = { - [P in PropertyID]: ResolverPlugin; + [P in PropertyID]: ResolverPlugin; }; /** * Helper type to extract the state type from a ResolverPlugin */ -export type PluginState = T extends ResolverPlugin ? S : never; +export type PluginState = T extends ResolverPlugin ? S : never; /** * Helper type to extract the dependency names from a ResolverPlugin */ -export type PluginDependencies = T extends ResolverPlugin ? D : never; +export type PluginDependencies = T extends ResolverPlugin ? string[] : never; diff --git a/src/views/resolvers/custom-resolvers/plugins/concatenation.plugin.ts b/src/views/resolvers/custom-resolvers/plugins/concatenation.plugin.ts index f44bcf9..896b364 100644 --- a/src/views/resolvers/custom-resolvers/plugins/concatenation.plugin.ts +++ b/src/views/resolvers/custom-resolvers/plugins/concatenation.plugin.ts @@ -13,7 +13,7 @@ type ConcatenationState = { * * Concatenates all string values with a separator */ -export class ConcatenationPlugin extends ResolverPlugin { +export class ConcatenationPlugin extends ResolverPlugin { readonly dependencies = [] as const; constructor(private separator: string = ' ') { diff --git a/src/views/resolvers/custom-resolvers/plugins/first-write-wins.plugin.ts b/src/views/resolvers/custom-resolvers/plugins/first-write-wins.plugin.ts index 12b2e29..b042461 100644 --- a/src/views/resolvers/custom-resolvers/plugins/first-write-wins.plugin.ts +++ b/src/views/resolvers/custom-resolvers/plugins/first-write-wins.plugin.ts @@ -12,7 +12,7 @@ type FirstWriteWinsState = { * * Keeps the first value that was written, ignoring subsequent writes */ -export class FirstWriteWinsPlugin extends ResolverPlugin { +export class FirstWriteWinsPlugin extends ResolverPlugin { readonly dependencies = [] as const; initialize(): FirstWriteWinsState { diff --git a/src/views/resolvers/custom-resolvers/plugins/last-write-wins.plugin.ts b/src/views/resolvers/custom-resolvers/plugins/last-write-wins.plugin.ts index a3009e6..842a452 100644 --- a/src/views/resolvers/custom-resolvers/plugins/last-write-wins.plugin.ts +++ b/src/views/resolvers/custom-resolvers/plugins/last-write-wins.plugin.ts @@ -12,7 +12,7 @@ type LastWriteWinsState = { * * Keeps the most recent value based on the delta's timestamp */ -export class LastWriteWinsPlugin extends ResolverPlugin { +export class LastWriteWinsPlugin extends ResolverPlugin { readonly dependencies = [] as const; initialize(): LastWriteWinsState { diff --git a/src/views/resolvers/custom-resolvers/plugins/majority-vote.plugin.ts b/src/views/resolvers/custom-resolvers/plugins/majority-vote.plugin.ts index be9896d..b010236 100644 --- a/src/views/resolvers/custom-resolvers/plugins/majority-vote.plugin.ts +++ b/src/views/resolvers/custom-resolvers/plugins/majority-vote.plugin.ts @@ -10,7 +10,7 @@ type MajorityVoteState = { * * Returns the value that appears most frequently */ -export class MajorityVotePlugin extends ResolverPlugin { +export class MajorityVotePlugin extends ResolverPlugin { readonly dependencies = [] as const; initialize(): MajorityVoteState { diff --git a/src/views/resolvers/custom-resolvers/plugins/max.plugin.ts b/src/views/resolvers/custom-resolvers/plugins/max.plugin.ts index 4e2e75e..2454e4c 100644 --- a/src/views/resolvers/custom-resolvers/plugins/max.plugin.ts +++ b/src/views/resolvers/custom-resolvers/plugins/max.plugin.ts @@ -1,4 +1,4 @@ -import { PropertyID, PropertyTypes } from "@src/core/types"; +import { PropertyTypes } from "@src/core/types"; import { ResolverPlugin, DependencyStates } from "../plugin"; type MaxPluginState = { @@ -10,16 +10,8 @@ type MaxPluginState = { * * Tracks the maximum numeric value */ -export class MaxPlugin extends ResolverPlugin { +export class MaxPlugin extends ResolverPlugin { name = 'max'; - readonly dependencies: Target[] = []; - - constructor(private readonly target?: Target) { - super(); - if (target) { - this.dependencies = [target]; - } - } initialize(dependencies: DependencyStates): MaxPluginState { return { max: this.target ? dependencies[this.target] as number : undefined }; diff --git a/src/views/resolvers/custom-resolvers/plugins/min.plugin.ts b/src/views/resolvers/custom-resolvers/plugins/min.plugin.ts index 080747e..2a8e822 100644 --- a/src/views/resolvers/custom-resolvers/plugins/min.plugin.ts +++ b/src/views/resolvers/custom-resolvers/plugins/min.plugin.ts @@ -1,4 +1,4 @@ -import { PropertyTypes, PropertyID } from "../../../../core/types"; +import { PropertyTypes } from "../../../../core/types"; import { DependencyStates, ResolverPlugin } from "../plugin"; type MinPluginState = { @@ -10,16 +10,8 @@ type MinPluginState = { * * Tracks the minimum numeric value */ -export class MinPlugin extends ResolverPlugin { +export class MinPlugin extends ResolverPlugin { name = 'min'; - readonly dependencies: Target[] = []; - - constructor(private readonly target?: Target) { - super(); - if (target) { - this.dependencies = [target]; - } - } initialize(dependencies: DependencyStates): MinPluginState { return { min: this.target ? dependencies[this.target] as number : undefined }; diff --git a/src/views/resolvers/custom-resolvers/plugins/running-average.plugin.ts b/src/views/resolvers/custom-resolvers/plugins/running-average.plugin.ts index 438f7c0..be30dd1 100644 --- a/src/views/resolvers/custom-resolvers/plugins/running-average.plugin.ts +++ b/src/views/resolvers/custom-resolvers/plugins/running-average.plugin.ts @@ -12,7 +12,7 @@ type RunningAverageState = { * * Tracks the running average of numeric values */ -export class RunningAveragePlugin extends ResolverPlugin { +export class RunningAveragePlugin extends ResolverPlugin { readonly dependencies = [] as const; initialize(): RunningAverageState { diff --git a/src/views/resolvers/custom-resolvers/resolver.ts b/src/views/resolvers/custom-resolvers/resolver.ts index 4835578..0afca64 100644 --- a/src/views/resolvers/custom-resolvers/resolver.ts +++ b/src/views/resolvers/custom-resolvers/resolver.ts @@ -1,9 +1,10 @@ -import { CollapsedDelta, Lossless, LosslessViewOne } from "../../lossless"; +import { Lossless, LosslessViewOne } from "../../lossless"; import { Lossy } from '../../lossy'; import { DomainEntityID, PropertyID, PropertyTypes } from "../../../core/types"; import { ResolverPlugin, DependencyStates } from "./plugin"; import { EntityRecord } from "@src/core/entity"; import Debug from 'debug'; +import { Delta } from "@src/core"; const debug = Debug('rz:custom-resolver'); const debugState = Debug('rz:custom-resolver:state'); @@ -29,7 +30,7 @@ type Result = Record; * @template D - The type of the plugin's dependencies (defaults to PropertyID) */ type PluginMap = { - [P in PropertyID]: ResolverPlugin; + [P in PropertyID]: ResolverPlugin; }; /** @@ -85,7 +86,7 @@ export class CustomResolver extends Lossy { this.dependencyGraph.forEach((deps, plugin) => { graphLog[plugin] = Array.from(deps); }); - debug(`Dependency graph: ${JSON.stringify(graphLog, null, 2)}`); + debug(`Dependency graph: ${JSON.stringify(graphLog)}`); } @@ -99,25 +100,15 @@ export class CustomResolver extends Lossy { // Initialize the graph with all plugins Object.keys(this.config).forEach(pluginKey => { this.dependencyGraph.set(pluginKey, new Set()); - debug(`Added plugin node: ${pluginKey}`); }); - debug('Processing plugin dependencies...'); // Add edges based on dependencies Object.entries(this.config).forEach(([pluginKey, plugin]) => { - const pluginId = plugin.name || pluginKey; const deps = plugin.dependencies || []; - - if (deps.length === 0) { - debug(`Plugin ${pluginId} has no dependencies`); - } else { - debug(`Plugin ${pluginId} depends on: ${deps.join(', ')}`); - } deps.forEach((depId: string) => { // This dependency may have an alias in our current config const depKey = this.pluginKeyFromBasename(depId); - debug(`Processing dependency ${depKey} for plugin ${pluginKey}`); if (!this.config[depKey]) { // TODO: This could still be a property, not a plugin @@ -136,7 +127,6 @@ export class CustomResolver extends Lossy { }); debug('Dependency graph construction complete'); - debug(`Config: ${JSON.stringify(this.config, null, 2)}`); this.logGraph(); } @@ -211,19 +201,26 @@ export class CustomResolver extends Lossy { for (const depKey of this.executionOrder) { if (depKey === pluginKey) continue; const depPlugin = this.config[depKey]; - if (depPlugin) { - if (!entityPluginStates[depKey]) { - dependencyStates[depKey] = depPlugin.initialize(dependencyStates); - entityPluginStates[depKey] = dependencyStates[depKey]; - } - dependencyStates[depKey] = depPlugin.resolve(entityPluginStates[depKey], dependencyStates); + if (!depPlugin) continue; + if (!entityPluginStates[depKey]) { + dependencyStates[depKey] = depPlugin.initialize(dependencyStates); + entityPluginStates[depKey] = dependencyStates[depKey]; } + dependencyStates[depKey] = depPlugin.resolve(entityPluginStates[depKey], dependencyStates); + } + // We should only include the dependencies for this plugin + Object.keys(dependencyStates).forEach(key => { + if (!plugin.dependencies?.includes(key)) { + delete dependencyStates[key]; + } + }); + return dependencyStates; } - private initializePlugins(acc: Accumulator, entityId: DomainEntityID) { + private getEntityState(acc: Accumulator, entityId: DomainEntityID) { if (!acc[entityId]) { acc[entityId] = {}; } @@ -241,7 +238,7 @@ export class CustomResolver extends Lossy { entityState[pluginKey] = entityState[pluginKey] ?? plugin.initialize(dependencies); } - return { entityState }; + return entityState; } /** @@ -251,46 +248,68 @@ export class CustomResolver extends Lossy { debug(`Processing deltas for entity: ${entityId}`); debug('Property deltas:', JSON.stringify(propertyDeltas)); - const { entityState } = this.initializePlugins(acc, entityId); + const entityState = this.getEntityState(acc, entityId); + + type PropertyRecord = { + delta: Delta; + value: PropertyTypes; + } + + // First pass through deltas to see if there are any duplicate property values + const deltaPropertyRecords : Record = {}; + for (const [propertyId, deltas] of Object.entries(propertyDeltas)) { + for (const delta of deltas) { + // Iterate through the pointers; throw an error if a duplicate key is found + for (const pointer of delta.pointers.filter(p => p.localContext === propertyId)) { + const deltaPropertyValue = deltaPropertyRecords[propertyId]; + if (deltaPropertyValue) { + // It's possible that there are multiple deltas in this set with the same property ID. + // That can only happen if they are part of a transaction. Otherwise this function is + // only called once per delta, per entity affected. + // TODO: More flexible/robust error handling protocols? + // Some views might be more tolerant of errors than others. + debug(`propertyDeltas: ${JSON.stringify(propertyDeltas, null, 2)}`); + throw new Error(`Delta ${delta.id}: '${propertyId}' already has value '${deltaPropertyValue}'`); + } + deltaPropertyRecords[propertyId] = { + delta, + value: pointer.target + }; + } + } + } + + debug('Delta property records:', JSON.stringify(deltaPropertyRecords)); // Now let's go through each plugin in order. for (const pluginId of this.executionOrder) { const pluginKey = this.pluginKeyFromBasename(pluginId); const plugin = this.config[pluginKey]; if (!plugin) throw new Error(`Plugin for property ${pluginId} not found`); - - debug(`Processing plugin: ${pluginId} (key: ${pluginKey})`); - const pluginState = entityState[pluginKey]; - const deltaPropertyValues : Record = {}; + debug(`Processing plugin: ${pluginId} (key: ${pluginKey})`); + + // If there's an updated entity property matching the plugin key, + // pass it to plugin.applyUpdate as the new property value. let propertyValue : PropertyTypes | undefined; - let updateDelta : CollapsedDelta | undefined; - for (const [propertyId, deltas] of Object.entries(propertyDeltas)) { - for (const delta of deltas) { - // Iterate through the pointers; throw an error if a duplicate key is found - for (const pointer of delta.pointers) { - if (deltaPropertyValues[propertyId]) { - // It's possible that there are multiple deltas in this set with the same property ID. - // That can only happen if they are part of a transaction. Otherwise this function is - // only called once per delta, per entity affected. - // TODO: More flexible/robust error handling protocols? - // Some views might be more tolerant of errors than others. - throw new Error(`Duplicate property ID ${propertyId} found in delta ${delta.id}`); - } - deltaPropertyValues[propertyId] = pointer[propertyId]; - // If there's an updated entity property matching the plugin key, - // pass it to the plugin update as the new property value. - if (propertyId === pluginKey) { - propertyValue = pointer[propertyId]; - updateDelta = delta; - } + let updateDelta : Delta | undefined; + for (const [propertyId, record] of Object.entries(deltaPropertyRecords)) { + if (propertyId === pluginKey) { + if (propertyValue !== undefined) { + throw new Error(`Delta ${record.delta.id}: '${propertyId}' already has value '${propertyValue}'`); } + debug(`Found delta for plugin ${pluginKey}: ${JSON.stringify(record)}`); + propertyValue = record.value; + updateDelta = record.delta; } } // Update the plugin state with the new delta + debug(`Getting dependency states for plugin ${pluginKey}`) + // TODO: There is some redundancy in calling the dependency resolvers. They can be cached/memoized. const dependencies = this.getDependencyStates(entityState, pluginKey); + debug(`Updating plugin ${pluginKey} with value ${JSON.stringify(propertyValue)}, dependencies: ${JSON.stringify(dependencies)}`) entityState[pluginKey] = plugin.applyUpdate(pluginState, propertyValue, updateDelta, dependencies); debugState(`Updated state for entity ${entityId} plugin ${pluginKey}:`, JSON.stringify(entityState[pluginKey])); @@ -306,7 +325,7 @@ export class CustomResolver extends Lossy { for (const entityId in acc) { if (!entityIds.includes(entityId)) continue; - this.initializePlugins(acc, entityId); + this.getEntityState(acc, entityId); result[entityId] = { id: entityId, @@ -324,10 +343,12 @@ export class CustomResolver extends Lossy { debug(`State for ${pluginKey}:`, JSON.stringify(state)); const resolvedValue = plugin.resolve(state, dependencies); - if (resolvedValue === undefined) throw new Error(`Resolved value for property ${pluginKey} is undefined`) + //if (resolvedValue === undefined) throw new Error(`Resolved value for property ${pluginKey} is undefined`) - debug(`Resolved value for ${pluginKey}:`, resolvedValue); - result[entityId].properties[pluginKey] = resolvedValue; + if (resolvedValue !== undefined) { + debug(`Resolved value for ${pluginKey}:`, resolvedValue); + result[entityId].properties[pluginKey] = resolvedValue; + } } } diff --git a/src/views/resolvers/custom-resolvers/targeted-plugin.ts b/src/views/resolvers/custom-resolvers/targeted-plugin.ts new file mode 100644 index 0000000..e8ef059 --- /dev/null +++ b/src/views/resolvers/custom-resolvers/targeted-plugin.ts @@ -0,0 +1,11 @@ +import { ResolverPlugin } from "./plugin"; + +export abstract class TargetedPlugin extends ResolverPlugin { + 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); + } +} + \ No newline at end of file diff --git a/src/views/resolvers/timestamp-resolvers.ts b/src/views/resolvers/timestamp-resolvers.ts index c3c5b71..22f0e77 100644 --- a/src/views/resolvers/timestamp-resolvers.ts +++ b/src/views/resolvers/timestamp-resolvers.ts @@ -1,7 +1,10 @@ import { EntityProperties } from "../../core/entity"; -import { Lossless, LosslessViewOne, CollapsedDelta, valueFromCollapsedDelta } from "../lossless"; +import { Lossless, CollapsedDelta, valueFromDelta, LosslessViewOne } from "../lossless"; import { Lossy } from '../lossy'; import { DomainEntityID, PropertyID, PropertyTypes, Timestamp, ViewMany } from "../../core/types"; +import Debug from 'debug'; + +const debug = Debug('rz:views:resolvers:timestamp-resolvers'); export type TimestampedProperty = { value: PropertyTypes, @@ -88,8 +91,10 @@ export class TimestampResolver extends Lossy { for (const [key, deltas] of Object.entries(cur.propertyDeltas)) { let bestProperty: TimestampedPropertyWithTieBreaking | undefined; - for (const delta of deltas || []) { - const value = valueFromCollapsedDelta(key, delta); + for (const delta of deltas) { + const value = valueFromDelta(key, delta); + debug(`delta: ${JSON.stringify(delta)}`); + debug(`valueFromDelta(${key}) = ${value}`); if (value === undefined) continue; const property: TimestampedPropertyWithTieBreaking = { @@ -162,18 +167,18 @@ export function latestFromCollapsedDeltas( deltas?: CollapsedDelta[] ): { delta?: CollapsedDelta, - value?: string | number, + value?: PropertyTypes, timeUpdated?: number } | undefined { const res: { delta?: CollapsedDelta, - value?: string | number, + value?: PropertyTypes, timeUpdated?: number } = {}; res.timeUpdated = 0; for (const delta of deltas || []) { - const value = valueFromCollapsedDelta(key, delta); + const value = valueFromDelta(key, delta); if (value === undefined) continue; if (res.timeUpdated && delta.timeCreated < res.timeUpdated) continue; res.delta = delta;