This commit is contained in:
Lentil Hoffman 2025-06-25 13:37:16 -05:00
parent 9957dccddd
commit 880affad1c
Signed by: lentil
GPG Key ID: 0F5B99F3F4D0C087
3 changed files with 82 additions and 77 deletions

View File

@ -16,22 +16,7 @@ describe('Edge Cases', () => {
lossless = new Lossless(node); lossless = new Lossless(node);
}); });
test('should handle null and undefined values', () => { test('should handle null 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()
);
// Create a type-safe plugin that handles null/undefined values // Create a type-safe plugin that handles null/undefined values
class NullSafeLastWriteWinsPlugin implements ResolverPlugin<{ value: PropertyTypes | null, timestamp: number }, never> { class NullSafeLastWriteWinsPlugin implements ResolverPlugin<{ value: PropertyTypes | null, timestamp: number }, never> {
readonly dependencies = [] as const; readonly dependencies = [] as const;
@ -42,10 +27,11 @@ describe('Edge Cases', () => {
update( update(
currentState: { value: PropertyTypes | null, timestamp: number }, currentState: { value: PropertyTypes | null, timestamp: number },
newValue: PropertyTypes, newValue?: PropertyTypes,
delta: CollapsedDelta, delta?: CollapsedDelta,
_dependencies: DependencyStates
) { ) {
if (newValue === undefined) return currentState;
if (!delta) return currentState;
if (delta.timeCreated > currentState.timestamp) { if (delta.timeCreated > currentState.timestamp) {
return { value: newValue, timestamp: delta.timeCreated }; return { value: newValue, timestamp: delta.timeCreated };
} }
@ -54,9 +40,8 @@ describe('Edge Cases', () => {
resolve( resolve(
state: { value: PropertyTypes | null, timestamp: number }, state: { value: PropertyTypes | null, timestamp: number },
_dependencies: DependencyStates
): PropertyTypes | undefined { ): PropertyTypes | undefined {
return state.value ?? undefined; return state.value;
} }
} }
@ -64,29 +49,20 @@ describe('Edge Cases', () => {
value: new NullSafeLastWriteWinsPlugin() 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( lossless.ingestDelta(
createDelta('user1', 'host1') createDelta('user1', 'host1')
.withTimestamp(1000) .withTimestamp(1000)
.setProperty('test2', 'value', 'first', 'test') .setProperty('test2', 'value', null, 'test')
.buildV1() .buildV1()
); );
lossless.ingestDelta( const results = resolver.resolve() || {};
createDelta('user2', 'host2') const test1 = results['test2']
.withTimestamp(1000) // Same timestamp expect(test1).toBeDefined();
.setProperty('test2', 'value', 'second', 'test') expect(test1?.properties.value).toBeNull();
.buildV1() });
);
test('should handle concurrent updates with same timestamp', () => {
// Custom plugin that handles concurrent updates with the same timestamp // Custom plugin that handles concurrent updates with the same timestamp
class ConcurrentUpdatePlugin implements ResolverPlugin<{ value: PropertyTypes, timestamp: number }, never> { class ConcurrentUpdatePlugin implements ResolverPlugin<{ value: PropertyTypes, timestamp: number }, never> {
readonly dependencies = [] as const; readonly dependencies = [] as const;
@ -123,25 +99,31 @@ describe('Edge Cases', () => {
value: new ConcurrentUpdatePlugin() value: new ConcurrentUpdatePlugin()
}); });
const results = resolver.resolve() || []; // Two updates with the same timestamp
expect(Array.isArray(results)).toBe(true); lossless.ingestDelta(
const test2 = results.find(r => r.id === 'test2'); 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(); expect(test2).toBeDefined();
// Should pick one of the values deterministically // 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', () => { 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 // Plugin that handles large numbers of updates efficiently
class CounterPlugin implements ResolverPlugin<{ count: number }, never> { class CounterPlugin implements ResolverPlugin<{ count: number }, never> {
readonly dependencies = [] as const; readonly dependencies = [] as const;
@ -171,9 +153,18 @@ describe('Edge Cases', () => {
counter: new CounterPlugin() counter: new CounterPlugin()
}); });
const results = resolver.resolve() || []; // Add 1000 updates
expect(Array.isArray(results)).toBe(true); for (let i = 0; i < 1000; i++) {
const test3 = results.find(r => r.id === 'test3'); 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(); expect(test3).toBeDefined();
// Should handle large numbers of updates efficiently // Should handle large numbers of updates efficiently
expect(test3?.properties.counter).toBe(1000); // Should count all 1000 updates 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 // No deltas added - should handle empty state
// Plugin that handles missing properties gracefully // Plugin that handles missing properties gracefully
class MissingPropertyPlugin implements ResolverPlugin<{ initialized: boolean }, never> { class MissingPropertyPlugin implements ResolverPlugin<{ initialized: boolean }, never> {
private _initialized = false;
readonly dependencies = [] as const; readonly dependencies = [] as const;
initialize() { initialize() {
this._initialized = true;
return { initialized: true }; return { initialized: true };
} }
update( update(
currentState: { initialized: boolean }, currentState: { initialized: boolean },
_newValue: PropertyTypes,
_delta: CollapsedDelta,
_dependencies: DependencyStates
) { ) {
return currentState; return currentState;
} }
resolve( resolve(
_state: { initialized: boolean }, state: { initialized: boolean }
_dependencies: DependencyStates
): boolean { ): boolean {
return this._initialized; return state.initialized;
} }
} }

View File

@ -62,7 +62,7 @@ export abstract class Lossy<Accumulator, Result = Accumulator> {
// Resolve the current state of the view // Resolve the current state of the view
resolve(entityIds?: DomainEntityID[]): Result | undefined { resolve(entityIds?: DomainEntityID[]): Result | undefined {
if (!this.accumulator) { if (!this.accumulator) {
return undefined; this.accumulator =this.initializer?.() || {} as Accumulator;
} }
if (!entityIds) { if (!entityIds) {

View File

@ -5,8 +5,8 @@ import { ResolverPlugin, DependencyStates } from "./plugin";
import { EntityRecord } from "@src/core/entity"; import { EntityRecord } from "@src/core/entity";
import Debug from 'debug'; import Debug from 'debug';
const debug = Debug('rz:resolver'); const debug = Debug('rz:custom-resolver');
const debugState = Debug('rz:resolver:state'); const debugState = Debug('rz:custom-resolver:state');
/** /**
* The state of a property for a single entity * The state of a property for a single entity
@ -224,17 +224,36 @@ export class CustomResolver extends Lossy<Accumulator, Result> {
return dependencyStates; 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 * Update the state with new deltas from the view
*/ */
reducer(acc: Accumulator, {id: entityId, propertyDeltas}: LosslessViewOne): Accumulator { reducer(acc: Accumulator, {id: entityId, propertyDeltas}: LosslessViewOne): Accumulator {
debug(`Processing deltas for entity: ${entityId}`); debug(`Processing deltas for entity: ${entityId}`);
debug('Property deltas:', JSON.stringify(propertyDeltas)); debug('Property deltas:', JSON.stringify(propertyDeltas));
if (!acc[entityId]) { const { entityState } = this.initializePlugins(acc, entityId);
acc[entityId] = {};
}
const entityState = acc[entityId];
// Now let's go through each plugin in order. // Now let's go through each plugin in order.
for (const pluginId of this.executionOrder) { for (const pluginId of this.executionOrder) {
@ -244,12 +263,7 @@ export class CustomResolver extends Lossy<Accumulator, Result> {
debug(`Processing plugin: ${pluginId} (key: ${pluginKey})`); debug(`Processing plugin: ${pluginId} (key: ${pluginKey})`);
// We need to resolve dependencies, including entity properties that are not plugins. const pluginState = entityState[pluginKey];
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 deltaPropertyValues : Record<PropertyID, PropertyTypes> = {}; const deltaPropertyValues : Record<PropertyID, PropertyTypes> = {};
let propertyValue : PropertyTypes | undefined; let propertyValue : PropertyTypes | undefined;
@ -276,6 +290,7 @@ export class CustomResolver extends Lossy<Accumulator, Result> {
} }
// Update the plugin state with the new delta // Update the plugin state with the new delta
const dependencies = this.getDependencyStates(entityState, plugin);
entityState[pluginKey] = plugin.update(pluginState, propertyValue, updateDelta, dependencies); entityState[pluginKey] = plugin.update(pluginState, propertyValue, updateDelta, dependencies);
debugState(`Updated entity state for ${entityId}:`, JSON.stringify(entityState[pluginKey])); debugState(`Updated entity state for ${entityId}:`, JSON.stringify(entityState[pluginKey]));
} }
@ -286,9 +301,12 @@ export class CustomResolver extends Lossy<Accumulator, Result> {
resolver(acc: Accumulator, entityIds: DomainEntityID[]) { resolver(acc: Accumulator, entityIds: DomainEntityID[]) {
const result: Result = {}; const result: Result = {};
debug('Initial accumulator state:', JSON.stringify(acc)); debug('Initial accumulator state:', JSON.stringify(acc));
for (const entityId in acc) { for (const entityId in acc) {
if (!entityIds.includes(entityId)) continue; if (!entityIds.includes(entityId)) continue;
this.initializePlugins(acc, entityId);
result[entityId] = { result[entityId] = {
id: entityId, id: entityId,
properties: {} properties: {}
@ -312,6 +330,8 @@ export class CustomResolver extends Lossy<Accumulator, Result> {
result[entityId].properties[pluginKey] = resolvedValue; result[entityId].properties[pluginKey] = resolvedValue;
} }
} }
debug(`Result:`, JSON.stringify(result));
return result; return result;
} }