fixed tons of stuff
This commit is contained in:
parent
29b1b8bb9a
commit
f79a6921fc
@ -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<unknown, string>;
|
||||
[key: string]: ResolverPlugin<unknown>;
|
||||
};
|
||||
|
||||
interface TestHelperOptions<T extends TestPluginMap> {
|
||||
@ -38,6 +40,7 @@ export async function testResolverWithPlugins<T extends TestPluginMap>(
|
||||
// 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];
|
||||
}
|
||||
|
||||
|
@ -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');
|
||||
|
@ -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,6 +52,7 @@ 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')
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
|
@ -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);
|
||||
|
@ -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", () => {
|
||||
|
@ -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"},
|
||||
]
|
||||
}]
|
||||
}
|
||||
|
@ -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<Summary> {
|
||||
// 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<Summary> {
|
||||
}
|
||||
|
||||
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');
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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() {
|
||||
|
@ -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: '' }; }
|
||||
|
@ -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() {
|
||||
|
@ -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<Targets extends PropertyID> extends ResolverPlugin<{ initialized: boolean }, Targets> {
|
||||
class AveragePlugin<Targets extends PropertyID> 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();
|
||||
|
@ -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<LifecycleTestState> {
|
||||
@ -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 entity = results.find(r => r.id === 'test1');
|
||||
const results = resolver.resolve();
|
||||
expect(results).toBeDefined();
|
||||
debug(`Results: ${JSON.stringify(results, null, 2)}`)
|
||||
|
||||
const entity = results!['test1']
|
||||
expect(entity).toBeDefined();
|
||||
|
||||
// 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(results).toBeDefined();
|
||||
|
||||
const results = resolver.resolve() || [];
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
|
||||
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({});
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle multiple entities independently', async () => {
|
||||
@ -81,29 +79,33 @@ describe('CountPlugin', () => {
|
||||
];
|
||||
|
||||
// Act & Assert - Test counter1
|
||||
await testResolverWithPlugins({
|
||||
{
|
||||
const result = await testResolverWithPlugins({
|
||||
entityId: 'counter1',
|
||||
plugins: {
|
||||
count: new CountPlugin()
|
||||
},
|
||||
deltas: counter1Deltas,
|
||||
expectedResult: (result) => {
|
||||
deltas: counter1Deltas
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
expect(result.properties.count).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
// Act & Assert - Test counter2
|
||||
await testResolverWithPlugins({
|
||||
{
|
||||
const result = await testResolverWithPlugins({
|
||||
entityId: 'counter2',
|
||||
plugins: {
|
||||
count: new CountPlugin()
|
||||
},
|
||||
deltas: counter2Deltas,
|
||||
expectedResult: (result) => {
|
||||
deltas: counter2Deltas
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
expect(result.properties.count).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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<number, never> {
|
||||
class DiscountPlugin extends ResolverPlugin<number> {
|
||||
readonly name = 'discount' as const;
|
||||
readonly dependencies = [] as const;
|
||||
|
||||
@ -14,9 +14,12 @@ class DiscountPlugin extends ResolverPlugin<number, never> {
|
||||
}
|
||||
|
||||
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<number, never> {
|
||||
}
|
||||
}
|
||||
|
||||
class DiscountedPricePlugin extends ResolverPlugin<number | null, 'discount'> {
|
||||
class DiscountedPricePlugin extends ResolverPlugin<number | null> {
|
||||
readonly name = 'price' as const;
|
||||
readonly dependencies = ['discount'] as const;
|
||||
|
||||
@ -37,9 +40,13 @@ class DiscountedPricePlugin extends ResolverPlugin<number | null, 'discount'> {
|
||||
}
|
||||
|
||||
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<number | null, 'discount'> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
@ -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<unknown, string> {
|
||||
class TestPlugin extends ResolverPlugin<unknown> {
|
||||
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', () => {
|
||||
|
@ -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<string, unknown>[] = [];
|
||||
|
||||
@ -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<string, unknown>[] = [];
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
@ -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();
|
||||
|
@ -131,7 +131,7 @@ The resolver is configured with an object mapping property IDs to their resolver
|
||||
|
||||
```typescript
|
||||
interface ResolverConfig {
|
||||
[propertyId: string]: ResolverPlugin<any, string>;
|
||||
[propertyId: string]: ResolverPlugin<any>;
|
||||
}
|
||||
```
|
||||
|
||||
@ -164,7 +164,7 @@ const resolver = new CustomResolver(view, {
|
||||
taxRate: new LastWriteWinsPlugin(),
|
||||
|
||||
// Complex plugin with multiple dependencies
|
||||
subtotal: new class extends ResolverPlugin<SubtotalState, 'unitPrice' | 'quantity'> {
|
||||
subtotal: new class extends ResolverPlugin<SubtotalState> {
|
||||
readonly dependencies = ['unitPrice', 'quantity'] as const;
|
||||
|
||||
initialize() { return { value: 0 }; }
|
||||
|
@ -7,7 +7,7 @@ The `ResolverPlugin` interface defines the contract that all resolver plugins mu
|
||||
## Interface Definition
|
||||
|
||||
```typescript
|
||||
interface ResolverPlugin<T = unknown, D extends string = never> {
|
||||
interface ResolverPlugin<T = unknown> {
|
||||
/**
|
||||
* Unique identifier for the plugin
|
||||
*/
|
||||
@ -126,7 +126,7 @@ class CounterPlugin extends ResolverPlugin<CounterState> {
|
||||
### Accessing Dependencies
|
||||
|
||||
```typescript
|
||||
class PriceCalculator extends ResolverPlugin<PriceState, 'basePrice' | 'taxRate'> {
|
||||
class PriceCalculator extends ResolverPlugin<PriceState> {
|
||||
readonly dependencies = ['basePrice', 'taxRate'] as const;
|
||||
|
||||
update(
|
||||
@ -147,7 +147,7 @@ class PriceCalculator extends ResolverPlugin<PriceState, 'basePrice' | 'taxRate'
|
||||
### Optional Dependencies
|
||||
|
||||
```typescript
|
||||
class OptionalDepPlugin extends ResolverPlugin<State, 'required' | 'optional?'> {
|
||||
class OptionalDepPlugin extends ResolverPlugin<State> {
|
||||
readonly dependencies = ['required', 'optional?'] as const;
|
||||
|
||||
update(
|
||||
|
@ -51,10 +51,10 @@ Represents a collapsed delta with metadata about the change.
|
||||
|
||||
## Plugin Types
|
||||
|
||||
### `ResolverPlugin<T, D>`
|
||||
### `ResolverPlugin<T>`
|
||||
|
||||
```typescript
|
||||
interface ResolverPlugin<T = unknown, D extends string = never> {
|
||||
interface ResolverPlugin<T = unknown> {
|
||||
readonly name: string;
|
||||
readonly dependencies?: readonly D[];
|
||||
|
||||
@ -106,7 +106,7 @@ Maps entity IDs to their resolved properties.
|
||||
|
||||
```typescript
|
||||
interface ResolverConfig {
|
||||
[propertyId: string]: ResolverPlugin<any, string>;
|
||||
[propertyId: string]: ResolverPlugin<any>;
|
||||
}
|
||||
```
|
||||
|
||||
@ -197,7 +197,7 @@ Extracts the dependency keys from a plugin type.
|
||||
|
||||
```typescript
|
||||
type DependencyKeys<T> =
|
||||
T extends ResolverPlugin<any, infer D> ? D : never;
|
||||
T extends ResolverPlugin<any> ? D : never;
|
||||
```
|
||||
|
||||
### `PluginState<T>`
|
||||
@ -206,7 +206,7 @@ Extracts the state type from a plugin type.
|
||||
|
||||
```typescript
|
||||
type PluginState<T> =
|
||||
T extends ResolverPlugin<infer S, any> ? S : never;
|
||||
T extends ResolverPlugin<infer S> ? 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<CounterState, CounterDeps> {
|
||||
class CounterPlugin extends ResolverPlugin<CounterState> {
|
||||
readonly dependencies = ['incrementBy', 'resetThreshold'] as const;
|
||||
|
||||
initialize(): CounterState {
|
||||
|
@ -50,7 +50,7 @@ const resolver = new CustomResolver(view, {
|
||||
To make a dependency optional, mark it with a `?` suffix:
|
||||
|
||||
```typescript
|
||||
class MyPlugin extends ResolverPlugin<MyState, 'required' | 'optional?'> {
|
||||
class MyPlugin extends ResolverPlugin<MyState> {
|
||||
readonly dependencies = ['required', 'optional?'] as const;
|
||||
|
||||
// ...
|
||||
|
@ -21,7 +21,7 @@ The Custom Resolver system provides a powerful dependency management system that
|
||||
## Example
|
||||
|
||||
```typescript
|
||||
class TotalPricePlugin extends ResolverPlugin<TotalState, 'price' | 'tax'> {
|
||||
class TotalPricePlugin extends ResolverPlugin<TotalState> {
|
||||
readonly dependencies = ['price', 'tax'] as const;
|
||||
|
||||
initialize(): TotalState {
|
||||
|
@ -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<T = unknown, D extends string = never> {
|
||||
interface ResolverPlugin<T = unknown> {
|
||||
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<MyState, 'dep1' | 'dep2'> {
|
||||
class MyPlugin extends ResolverPlugin<MyState> {
|
||||
readonly dependencies = ['dep1', 'dep2'] as const;
|
||||
|
||||
// ... implementation
|
||||
@ -101,7 +101,7 @@ if (typeof deps.price === 'number') {
|
||||
### Optional Dependencies
|
||||
|
||||
```typescript
|
||||
class MyPlugin extends ResolverPlugin<MyState, 'required' | 'optional?'> {
|
||||
class MyPlugin extends ResolverPlugin<MyState> {
|
||||
readonly dependencies = ['required', 'optional?'] as const;
|
||||
|
||||
update(_state: MyState, _value: unknown, _delta: CollapsedDelta, deps: any) {
|
||||
@ -118,7 +118,7 @@ class MyPlugin extends ResolverPlugin<MyState, 'required' | 'optional?'> {
|
||||
```typescript
|
||||
type PriceDependencies = 'price1' | 'price2' | 'price3';
|
||||
|
||||
class PriceAggregator extends ResolverPlugin<PriceState, PriceDependencies> {
|
||||
class PriceAggregator extends ResolverPlugin<PriceState> {
|
||||
readonly dependencies: readonly PriceDependencies[] = ['price1', 'price2', 'price3'] as const;
|
||||
|
||||
update(_state: PriceState, _value: unknown, _delta: CollapsedDelta, deps: any) {
|
||||
|
@ -40,7 +40,7 @@ class MyPlugin extends ResolverPlugin<MyState> {
|
||||
To depend on other properties, specify the dependency types:
|
||||
|
||||
```typescript
|
||||
class DiscountedPricePlugin extends ResolverPlugin<DiscountState, 'basePrice' | 'discount'> {
|
||||
class DiscountedPricePlugin extends ResolverPlugin<DiscountState> {
|
||||
readonly dependencies = ['basePrice', 'discount'] as const;
|
||||
|
||||
initialize(): DiscountState {
|
||||
|
@ -91,8 +91,7 @@ export abstract class Collection<View> {
|
||||
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<View> {
|
||||
}
|
||||
|
||||
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<string>();
|
||||
for (const [entityId, names] of this.rhizomeNode.lossless.referencedAs.entries()) {
|
||||
if (names.has(this.name)) {
|
||||
ids.add(entityId);
|
||||
}
|
||||
}
|
||||
debug(`Found ${ids.size} ids for collection ${this.name}`);
|
||||
return Array.from(ids.values());
|
||||
}
|
||||
|
||||
// THIS PUT SHOULD CORRESOND TO A PARTICULAR MATERIALIZED VIEW...
|
||||
// How can we encode that?
|
||||
// Well, we have a way to do that, we just need the same particular inputs.
|
||||
// We take a resolver as an optional argument.
|
||||
async put(
|
||||
entityId: DomainEntityID | undefined,
|
||||
properties: EntityProperties,
|
||||
|
@ -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<T extends Record<string, unknown>>
|
||||
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<T extends Record<string, unknown>>
|
||||
entityId: DomainEntityID | undefined,
|
||||
properties: EntityProperties,
|
||||
): Promise<ResolvedViewOne> {
|
||||
// Validate against schema if strict validation is enabled
|
||||
if (this.applicationOptions.strictValidation) {
|
||||
// Validate against schema
|
||||
const validationResult = this.validate(properties as T);
|
||||
|
||||
// If strict validation is enabled, throw on validation failure
|
||||
if (this.applicationOptions.strictValidation) {
|
||||
if (!validationResult.valid) {
|
||||
throw new SchemaValidationError(
|
||||
`Schema validation failed: ${validationResult.errors.map(e => e.message).join(', ')}`,
|
||||
@ -145,7 +142,6 @@ export class TypedCollectionImpl<T extends Record<string, unknown>>
|
||||
const result = await super.put(entityId, properties);
|
||||
|
||||
// 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<T extends Record<string, unknown>>
|
||||
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
|
@ -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<string, string | null> = {};
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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,8 +523,7 @@ 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)) {
|
||||
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;
|
||||
@ -544,7 +546,6 @@ export class DefaultSchemaRegistry implements SchemaRegistry {
|
||||
scalarCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no non-parent pointers found, return null
|
||||
if (Object.keys(pointersByContext).length === 0) {
|
||||
|
@ -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<DeltaNetworkImageV1, 'pointers'> & {
|
||||
};
|
||||
|
||||
// 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) {
|
||||
// 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 && (typeof value === "string" || typeof value === "number")) {
|
||||
return value;
|
||||
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[];
|
||||
propertyDeltas: {
|
||||
[key: PropertyID]: Delta[]
|
||||
}
|
||||
}
|
||||
|
||||
export type LosslessViewOne = {
|
||||
id: DomainEntityID,
|
||||
referencedAs: string[];
|
||||
propertyDeltas: {
|
||||
export type CollapsedViewOne = Omit<LosslessViewOne, 'propertyDeltas'> & {
|
||||
propertyCollapsedDeltas: {
|
||||
[key: PropertyID]: CollapsedDelta[]
|
||||
}
|
||||
};
|
||||
|
||||
export type LosslessViewMany = ViewMany<LosslessViewOne>;
|
||||
export type CollapsedViewMany = ViewMany<CollapsedViewOne>;
|
||||
|
||||
class LosslessEntityMap extends Map<DomainEntityID, LosslessEntity> {};
|
||||
|
||||
@ -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,9 +112,11 @@ class LosslessEntity {
|
||||
export class Lossless {
|
||||
domainEntities = new LosslessEntityMap();
|
||||
transactions: Transactions;
|
||||
referencedAs = new Map<string, Set<string>>();
|
||||
eventStream = new EventEmitter();
|
||||
|
||||
// TODO: This referencedAs map doesn't really belong at this layer of abstraction
|
||||
referencedAs = new Map<string, Set<string>>();
|
||||
|
||||
// Track all deltas by ID for negation processing
|
||||
private allDeltas = new Map<DeltaID, Delta>();
|
||||
// Track which entities are affected by each delta
|
||||
@ -185,39 +213,11 @@ export class Lossless {
|
||||
const seenDeltaIds = new Set<DeltaID>();
|
||||
|
||||
// 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<string>();
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -47,7 +47,6 @@ export abstract class Lossy<Accumulator, Result = Accumulator> {
|
||||
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
|
||||
|
@ -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<Accumulator, Result> {
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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<string, unknown>;
|
||||
* @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,22 +42,18 @@ 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: {
|
||||
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;
|
||||
}
|
||||
case 1: {
|
||||
// Only one dependency, use it as the new value.
|
||||
newValue = dependencies![this.dependencies[0]] as PropertyTypes;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// Pass dependencies as is, and leave newValue undefined.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.update(currentState, newValue, delta, dependencies);
|
||||
};
|
||||
@ -92,15 +88,15 @@ export abstract class ResolverPlugin<
|
||||
* Configuration for custom resolver with type-safe plugin configurations
|
||||
*/
|
||||
export type CustomResolverConfig = {
|
||||
[P in PropertyID]: ResolverPlugin<unknown, string>;
|
||||
[P in PropertyID]: ResolverPlugin<unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper type to extract the state type from a ResolverPlugin
|
||||
*/
|
||||
export type PluginState<T> = T extends ResolverPlugin<infer S, string> ? S : never;
|
||||
export type PluginState<T> = T extends ResolverPlugin<infer S> ? S : never;
|
||||
|
||||
/**
|
||||
* Helper type to extract the dependency names from a ResolverPlugin
|
||||
*/
|
||||
export type PluginDependencies<T> = T extends ResolverPlugin<unknown, infer D> ? D : never;
|
||||
export type PluginDependencies<T> = T extends ResolverPlugin<unknown> ? string[] : never;
|
||||
|
@ -13,7 +13,7 @@ type ConcatenationState = {
|
||||
*
|
||||
* Concatenates all string values with a separator
|
||||
*/
|
||||
export class ConcatenationPlugin extends ResolverPlugin<ConcatenationState, never> {
|
||||
export class ConcatenationPlugin extends ResolverPlugin<ConcatenationState> {
|
||||
readonly dependencies = [] as const;
|
||||
|
||||
constructor(private separator: string = ' ') {
|
||||
|
@ -12,7 +12,7 @@ type FirstWriteWinsState = {
|
||||
*
|
||||
* Keeps the first value that was written, ignoring subsequent writes
|
||||
*/
|
||||
export class FirstWriteWinsPlugin extends ResolverPlugin<FirstWriteWinsState, never> {
|
||||
export class FirstWriteWinsPlugin extends ResolverPlugin<FirstWriteWinsState> {
|
||||
readonly dependencies = [] as const;
|
||||
|
||||
initialize(): FirstWriteWinsState {
|
||||
|
@ -12,7 +12,7 @@ type LastWriteWinsState = {
|
||||
*
|
||||
* Keeps the most recent value based on the delta's timestamp
|
||||
*/
|
||||
export class LastWriteWinsPlugin extends ResolverPlugin<LastWriteWinsState, never> {
|
||||
export class LastWriteWinsPlugin extends ResolverPlugin<LastWriteWinsState> {
|
||||
readonly dependencies = [] as const;
|
||||
|
||||
initialize(): LastWriteWinsState {
|
||||
|
@ -10,7 +10,7 @@ type MajorityVoteState = {
|
||||
*
|
||||
* Returns the value that appears most frequently
|
||||
*/
|
||||
export class MajorityVotePlugin extends ResolverPlugin<MajorityVoteState, never> {
|
||||
export class MajorityVotePlugin extends ResolverPlugin<MajorityVoteState> {
|
||||
readonly dependencies = [] as const;
|
||||
|
||||
initialize(): MajorityVoteState {
|
||||
|
@ -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<Target extends PropertyID> extends ResolverPlugin<MaxPluginState, Target> {
|
||||
export class MaxPlugin extends ResolverPlugin<MaxPluginState> {
|
||||
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 };
|
||||
|
@ -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<Target extends PropertyID> extends ResolverPlugin<MinPluginState, Target> {
|
||||
export class MinPlugin extends ResolverPlugin<MinPluginState> {
|
||||
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 };
|
||||
|
@ -12,7 +12,7 @@ type RunningAverageState = {
|
||||
*
|
||||
* Tracks the running average of numeric values
|
||||
*/
|
||||
export class RunningAveragePlugin extends ResolverPlugin<RunningAverageState, never> {
|
||||
export class RunningAveragePlugin extends ResolverPlugin<RunningAverageState> {
|
||||
readonly dependencies = [] as const;
|
||||
|
||||
initialize(): RunningAverageState {
|
||||
|
@ -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<DomainEntityID, EntityRecord>;
|
||||
* @template D - The type of the plugin's dependencies (defaults to PropertyID)
|
||||
*/
|
||||
type PluginMap = {
|
||||
[P in PropertyID]: ResolverPlugin<unknown, PropertyID>;
|
||||
[P in PropertyID]: ResolverPlugin<unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -85,7 +86,7 @@ export class CustomResolver extends Lossy<Accumulator, Result> {
|
||||
this.dependencyGraph.forEach((deps, plugin) => {
|
||||
graphLog[plugin] = Array.from(deps);
|
||||
});
|
||||
debug(`Dependency graph: ${JSON.stringify(graphLog, null, 2)}`);
|
||||
debug(`Dependency graph: ${JSON.stringify(graphLog)}`);
|
||||
}
|
||||
|
||||
|
||||
@ -99,25 +100,15 @@ export class CustomResolver extends Lossy<Accumulator, Result> {
|
||||
// Initialize the graph with all plugins
|
||||
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<Accumulator, Result> {
|
||||
});
|
||||
|
||||
debug('Dependency graph construction complete');
|
||||
debug(`Config: ${JSON.stringify(this.config, null, 2)}`);
|
||||
this.logGraph();
|
||||
}
|
||||
|
||||
@ -211,19 +201,26 @@ export class CustomResolver extends Lossy<Accumulator, Result> {
|
||||
for (const depKey of this.executionOrder) {
|
||||
if (depKey === pluginKey) continue;
|
||||
const depPlugin = this.config[depKey];
|
||||
if (depPlugin) {
|
||||
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<Accumulator, Result> {
|
||||
entityState[pluginKey] = entityState[pluginKey] ?? plugin.initialize(dependencies);
|
||||
}
|
||||
|
||||
return { entityState };
|
||||
return entityState;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -251,46 +248,68 @@ export class CustomResolver extends Lossy<Accumulator, Result> {
|
||||
debug(`Processing deltas for entity: ${entityId}`);
|
||||
debug('Property deltas:', JSON.stringify(propertyDeltas));
|
||||
|
||||
const { entityState } = this.initializePlugins(acc, entityId);
|
||||
const entityState = this.getEntityState(acc, entityId);
|
||||
|
||||
type PropertyRecord = {
|
||||
delta: Delta;
|
||||
value: PropertyTypes;
|
||||
}
|
||||
|
||||
// First pass through deltas to see if there are any duplicate property values
|
||||
const deltaPropertyRecords : Record<PropertyID, PropertyRecord> = {};
|
||||
for (const [propertyId, deltas] of Object.entries(propertyDeltas)) {
|
||||
for (const delta of deltas) {
|
||||
// Iterate through the pointers; throw an error if a duplicate key is found
|
||||
for (const pointer of delta.pointers.filter(p => p.localContext === propertyId)) {
|
||||
const deltaPropertyValue = deltaPropertyRecords[propertyId];
|
||||
if (deltaPropertyValue) {
|
||||
// It's possible that there are multiple deltas in this set with the same property ID.
|
||||
// That can only happen if they are part of a transaction. Otherwise this function is
|
||||
// only called once per delta, per entity affected.
|
||||
// TODO: More flexible/robust error handling protocols?
|
||||
// Some views might be more tolerant of errors than others.
|
||||
debug(`propertyDeltas: ${JSON.stringify(propertyDeltas, null, 2)}`);
|
||||
throw new Error(`Delta ${delta.id}: '${propertyId}' already has value '${deltaPropertyValue}'`);
|
||||
}
|
||||
deltaPropertyRecords[propertyId] = {
|
||||
delta,
|
||||
value: pointer.target
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug('Delta property records:', JSON.stringify(deltaPropertyRecords));
|
||||
|
||||
// Now let's go through each plugin in order.
|
||||
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`);
|
||||
const pluginState = entityState[pluginKey];
|
||||
|
||||
debug(`Processing plugin: ${pluginId} (key: ${pluginKey})`);
|
||||
|
||||
const pluginState = entityState[pluginKey];
|
||||
|
||||
const deltaPropertyValues : Record<PropertyID, PropertyTypes> = {};
|
||||
let propertyValue : PropertyTypes | undefined;
|
||||
let updateDelta : CollapsedDelta | undefined;
|
||||
for (const [propertyId, deltas] of Object.entries(propertyDeltas)) {
|
||||
for (const delta of deltas) {
|
||||
// Iterate through the pointers; throw an error if a duplicate key is found
|
||||
for (const pointer of delta.pointers) {
|
||||
if (deltaPropertyValues[propertyId]) {
|
||||
// It's possible that there are multiple deltas in this set with the same property ID.
|
||||
// That can only happen if they are part of a transaction. Otherwise this function is
|
||||
// only called once per delta, per entity affected.
|
||||
// TODO: More flexible/robust error handling protocols?
|
||||
// Some views might be more tolerant of errors than others.
|
||||
throw new Error(`Duplicate property ID ${propertyId} found in delta ${delta.id}`);
|
||||
}
|
||||
deltaPropertyValues[propertyId] = pointer[propertyId];
|
||||
// If there's an updated entity property matching the plugin key,
|
||||
// pass it to the plugin update as the new property value.
|
||||
// pass it to plugin.applyUpdate as the new property value.
|
||||
let propertyValue : PropertyTypes | undefined;
|
||||
let updateDelta : Delta | undefined;
|
||||
for (const [propertyId, record] of Object.entries(deltaPropertyRecords)) {
|
||||
if (propertyId === pluginKey) {
|
||||
propertyValue = pointer[propertyId];
|
||||
updateDelta = delta;
|
||||
}
|
||||
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<Accumulator, Result> {
|
||||
for (const entityId in acc) {
|
||||
if (!entityIds.includes(entityId)) continue;
|
||||
|
||||
this.initializePlugins(acc, entityId);
|
||||
this.getEntityState(acc, entityId);
|
||||
|
||||
result[entityId] = {
|
||||
id: entityId,
|
||||
@ -324,12 +343,14 @@ export class CustomResolver extends Lossy<Accumulator, Result> {
|
||||
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`)
|
||||
|
||||
if (resolvedValue !== undefined) {
|
||||
debug(`Resolved value for ${pluginKey}:`, resolvedValue);
|
||||
result[entityId].properties[pluginKey] = resolvedValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug(`Result:`, JSON.stringify(result));
|
||||
|
||||
|
11
src/views/resolvers/custom-resolvers/targeted-plugin.ts
Normal file
11
src/views/resolvers/custom-resolvers/targeted-plugin.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { ResolverPlugin } from "./plugin";
|
||||
|
||||
export abstract class TargetedPlugin<T> extends ResolverPlugin<T> {
|
||||
constructor(target?: string) {
|
||||
// If no target is provided, then we want to implicitly target the property
|
||||
// to which this plugin is attached. That means that when we apply an update,
|
||||
// we want to take the value of
|
||||
super(target);
|
||||
}
|
||||
}
|
||||
|
@ -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<Accumulator, Result> {
|
||||
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;
|
||||
|
Loading…
x
Reference in New Issue
Block a user