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 40fa22b..0b44b76 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 @@ -16,22 +16,7 @@ describe('Edge Cases', () => { lossless = new Lossless(node); }); - test('should handle null and undefined values', () => { - lossless.ingestDelta( - createDelta('user1', 'host1') - .withTimestamp(1000) - .setProperty('test1', 'value', null, 'test') - .buildV1() - ); - - // Use null instead of undefined as it's a valid PropertyType - lossless.ingestDelta( - createDelta('user1', 'host1') - .withTimestamp(2000) - .setProperty('test1', 'value', null, 'test') - .buildV1() - ); - + test('should handle null values', () => { // Create a type-safe plugin that handles null/undefined values class NullSafeLastWriteWinsPlugin implements ResolverPlugin<{ value: PropertyTypes | null, timestamp: number }, never> { readonly dependencies = [] as const; @@ -42,10 +27,11 @@ describe('Edge Cases', () => { update( currentState: { value: PropertyTypes | null, timestamp: number }, - newValue: PropertyTypes, - delta: CollapsedDelta, - _dependencies: DependencyStates + newValue?: PropertyTypes, + delta?: CollapsedDelta, ) { + if (newValue === undefined) return currentState; + if (!delta) return currentState; if (delta.timeCreated > currentState.timestamp) { return { value: newValue, timestamp: delta.timeCreated }; } @@ -54,9 +40,8 @@ describe('Edge Cases', () => { resolve( state: { value: PropertyTypes | null, timestamp: number }, - _dependencies: DependencyStates ): PropertyTypes | undefined { - return state.value ?? undefined; + return state.value; } } @@ -64,29 +49,20 @@ describe('Edge Cases', () => { value: new NullSafeLastWriteWinsPlugin() }); - const results = resolver.resolve() || []; - expect(Array.isArray(results)).toBe(true); - const test1 = results.find(r => r.id === 'test1'); - expect(test1).toBeDefined(); - expect(test1?.properties.value).toBeUndefined(); - }); - - test('should handle concurrent updates with same timestamp', () => { - // Two updates with the same timestamp lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(1000) - .setProperty('test2', 'value', 'first', 'test') + .setProperty('test2', 'value', null, 'test') .buildV1() ); - lossless.ingestDelta( - createDelta('user2', 'host2') - .withTimestamp(1000) // Same timestamp - .setProperty('test2', 'value', 'second', 'test') - .buildV1() - ); + const results = resolver.resolve() || {}; + const test1 = results['test2'] + expect(test1).toBeDefined(); + expect(test1?.properties.value).toBeNull(); + }); + test('should handle concurrent updates with same timestamp', () => { // Custom plugin that handles concurrent updates with the same timestamp class ConcurrentUpdatePlugin implements ResolverPlugin<{ value: PropertyTypes, timestamp: number }, never> { readonly dependencies = [] as const; @@ -123,25 +99,31 @@ describe('Edge Cases', () => { value: new ConcurrentUpdatePlugin() }); - const results = resolver.resolve() || []; - expect(Array.isArray(results)).toBe(true); - const test2 = results.find(r => r.id === 'test2'); + // Two updates with the same timestamp + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000) + .setProperty('test2', 'value', null, 'test') + .buildV1() + ); + + lossless.ingestDelta( + createDelta('user2', 'host2') + .withTimestamp(1000) // Same timestamp + .setProperty('test2', 'value', 'xylophone', 'test') + .buildV1() + ); + + + + const results = resolver.resolve() || {}; + const test2 = results['test2']; expect(test2).toBeDefined(); // Should pick one of the values deterministically - expect(test2?.properties.value).toBe('first'); + expect(test2?.properties.value).toBeNull(); }); test('should handle very large numbers of updates', () => { - // Add 1000 updates - for (let i = 0; i < 1000; i++) { - lossless.ingestDelta( - createDelta('user1', 'host1') - .withTimestamp(1000 + i) - .setProperty('test3', 'counter', i, 'test') - .buildV1() - ); - } - // Plugin that handles large numbers of updates efficiently class CounterPlugin implements ResolverPlugin<{ count: number }, never> { readonly dependencies = [] as const; @@ -171,9 +153,18 @@ describe('Edge Cases', () => { counter: new CounterPlugin() }); - const results = resolver.resolve() || []; - expect(Array.isArray(results)).toBe(true); - const test3 = results.find(r => r.id === 'test3'); + // Add 1000 updates + for (let i = 0; i < 1000; i++) { + lossless.ingestDelta( + createDelta('user1', 'host1') + .withTimestamp(1000 + i) + .setProperty('test3', 'counter', i, 'test') + .buildV1() + ); + } + + const results = resolver.resolve() || {}; + const test3 = results['test3'] expect(test3).toBeDefined(); // Should handle large numbers of updates efficiently expect(test3?.properties.counter).toBe(1000); // Should count all 1000 updates @@ -183,28 +174,22 @@ describe('Edge Cases', () => { // No deltas added - should handle empty state // Plugin that handles missing properties gracefully class MissingPropertyPlugin implements ResolverPlugin<{ initialized: boolean }, never> { - private _initialized = false; readonly dependencies = [] as const; initialize() { - this._initialized = true; return { initialized: true }; } update( currentState: { initialized: boolean }, - _newValue: PropertyTypes, - _delta: CollapsedDelta, - _dependencies: DependencyStates ) { return currentState; } resolve( - _state: { initialized: boolean }, - _dependencies: DependencyStates + state: { initialized: boolean } ): boolean { - return this._initialized; + return state.initialized; } } diff --git a/src/views/lossy.ts b/src/views/lossy.ts index a8101d6..bc28076 100644 --- a/src/views/lossy.ts +++ b/src/views/lossy.ts @@ -62,7 +62,7 @@ export abstract class Lossy { // Resolve the current state of the view resolve(entityIds?: DomainEntityID[]): Result | undefined { if (!this.accumulator) { - return undefined; + this.accumulator =this.initializer?.() || {} as Accumulator; } if (!entityIds) { diff --git a/src/views/resolvers/custom-resolvers/resolver.ts b/src/views/resolvers/custom-resolvers/resolver.ts index bba9a7a..7a4c46d 100644 --- a/src/views/resolvers/custom-resolvers/resolver.ts +++ b/src/views/resolvers/custom-resolvers/resolver.ts @@ -5,8 +5,8 @@ import { ResolverPlugin, DependencyStates } from "./plugin"; import { EntityRecord } from "@src/core/entity"; import Debug from 'debug'; -const debug = Debug('rz:resolver'); -const debugState = Debug('rz:resolver:state'); +const debug = Debug('rz:custom-resolver'); +const debugState = Debug('rz:custom-resolver:state'); /** * The state of a property for a single entity @@ -224,17 +224,36 @@ export class CustomResolver extends Lossy { return dependencyStates; } + private initializePlugins(acc: Accumulator, entityId: DomainEntityID) { + if (!acc[entityId]) { + acc[entityId] = {}; + } + const entityState = acc[entityId]; + + 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`); + + // We need to resolve dependencies, including entity properties that are not plugins. + const dependencies = this.getDependencyStates(entityState, plugin); + debug('Dependencies for', pluginId, ':', JSON.stringify(dependencies)); + + // Initialize the plugin if it hasn't been initialized yet + entityState[pluginKey] = entityState[pluginKey] ?? plugin.initialize(dependencies); + } + + return { entityState }; + } + /** * Update the state with new deltas from the view */ reducer(acc: Accumulator, {id: entityId, propertyDeltas}: LosslessViewOne): Accumulator { debug(`Processing deltas for entity: ${entityId}`); debug('Property deltas:', JSON.stringify(propertyDeltas)); - - if (!acc[entityId]) { - acc[entityId] = {}; - } - const entityState = acc[entityId]; + + const { entityState } = this.initializePlugins(acc, entityId); // Now let's go through each plugin in order. for (const pluginId of this.executionOrder) { @@ -244,12 +263,7 @@ export class CustomResolver extends Lossy { debug(`Processing plugin: ${pluginId} (key: ${pluginKey})`); - // We need to resolve dependencies, including entity properties that are not plugins. - const dependencies = this.getDependencyStates(entityState, plugin); - debug('Dependencies for', pluginId, ':', JSON.stringify(dependencies)); - - // Initialize the plugin if it hasn't been initialized yet - const pluginState = entityState[pluginKey] ?? plugin.initialize(dependencies); + const pluginState = entityState[pluginKey]; const deltaPropertyValues : Record = {}; let propertyValue : PropertyTypes | undefined; @@ -276,6 +290,7 @@ export class CustomResolver extends Lossy { } // Update the plugin state with the new delta + const dependencies = this.getDependencyStates(entityState, plugin); entityState[pluginKey] = plugin.update(pluginState, propertyValue, updateDelta, dependencies); debugState(`Updated entity state for ${entityId}:`, JSON.stringify(entityState[pluginKey])); } @@ -286,9 +301,12 @@ export class CustomResolver extends Lossy { resolver(acc: Accumulator, entityIds: DomainEntityID[]) { const result: Result = {}; debug('Initial accumulator state:', JSON.stringify(acc)); - + for (const entityId in acc) { if (!entityIds.includes(entityId)) continue; + + this.initializePlugins(acc, entityId); + result[entityId] = { id: entityId, properties: {} @@ -312,6 +330,8 @@ export class CustomResolver extends Lossy { result[entityId].properties[pluginKey] = resolvedValue; } } + + debug(`Result:`, JSON.stringify(result)); return result; }