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);
});
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;
}
}

View File

@ -62,7 +62,7 @@ export abstract class Lossy<Accumulator, Result = Accumulator> {
// 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) {

View File

@ -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<Accumulator, Result> {
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<Accumulator, Result> {
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<PropertyID, PropertyTypes> = {};
let propertyValue : PropertyTypes | undefined;
@ -276,6 +290,7 @@ export class CustomResolver extends Lossy<Accumulator, Result> {
}
// 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<Accumulator, Result> {
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<Accumulator, Result> {
result[entityId].properties[pluginKey] = resolvedValue;
}
}
debug(`Result:`, JSON.stringify(result));
return result;
}