stabilized in prep for continuing on lossy relationship resolver

This commit is contained in:
Lentil Hoffman 2025-06-22 14:02:21 -05:00
parent c04439713c
commit 76e00f38af
Signed by: lentil
GPG Key ID: 0F5B99F3F4D0C087
11 changed files with 103 additions and 91 deletions

View File

@ -2,13 +2,11 @@ import { createDelta } from '../src/core/delta-builder';
import { import {
RhizomeNode, RhizomeNode,
Lossless, Lossless,
Delta,
LastWriteWins,
TimestampResolver,
SumResolver, SumResolver,
CustomResolver, CustomResolver,
LastWriteWinsPlugin, LastWriteWinsPlugin,
MajorityVotePlugin MajorityVotePlugin,
TimestampResolver
} from "../src"; } from "../src";
describe('Concurrent Write Scenarios', () => { describe('Concurrent Write Scenarios', () => {
@ -39,7 +37,7 @@ describe('Concurrent Write Scenarios', () => {
.buildV1() .buildV1()
); );
const resolver = new LastWriteWins(lossless); const resolver = new TimestampResolver(lossless);
const result = resolver.resolve(); const result = resolver.resolve();
expect(result).toBeDefined(); expect(result).toBeDefined();
@ -123,7 +121,7 @@ describe('Concurrent Write Scenarios', () => {
.buildV1() .buildV1()
); );
const resolver = new LastWriteWins(lossless); const resolver = new TimestampResolver(lossless);
const result = resolver.resolve(); const result = resolver.resolve();
expect(result).toBeDefined(); expect(result).toBeDefined();
@ -249,7 +247,7 @@ describe('Concurrent Write Scenarios', () => {
); );
} }
const resolver = new LastWriteWins(lossless); const resolver = new TimestampResolver(lossless);
const result = resolver.resolve(); const result = resolver.resolve();
expect(result).toBeDefined(); expect(result).toBeDefined();

View File

@ -2,7 +2,7 @@ import { createDelta } from '../src/core/delta-builder';
import { DeltaV1, DeltaV2 } from '../src/core/delta'; import { DeltaV1, DeltaV2 } from '../src/core/delta';
import { Lossless } from '../src/views/lossless'; import { Lossless } from '../src/views/lossless';
import { RhizomeNode } from '../src/node'; import { RhizomeNode } from '../src/node';
import { LastWriteWins } from '../src/views/resolvers/last-write-wins'; import { TimestampResolver } from '../src/views/resolvers/timestamp-resolvers';
describe('DeltaBuilder', () => { describe('DeltaBuilder', () => {
const creator = 'creator-123'; const creator = 'creator-123';
@ -47,7 +47,7 @@ describe('DeltaBuilder', () => {
// Verify that the entity property resolves correctly // Verify that the entity property resolves correctly
const lossless = new Lossless(node); const lossless = new Lossless(node);
lossless.ingestDelta(delta); lossless.ingestDelta(delta);
const lossy = new LastWriteWins(lossless); const lossy = new TimestampResolver(lossless);
const result = lossy.resolve(); const result = lossy.resolve();
expect(result).toBeDefined(); expect(result).toBeDefined();
expect(result!['entity-1'].properties.name).toBe('Test Entity'); expect(result!['entity-1'].properties.name).toBe('Test Entity');
@ -72,7 +72,7 @@ describe('DeltaBuilder', () => {
// Verify that the entity property resolves correctly // Verify that the entity property resolves correctly
const lossless = new Lossless(node); const lossless = new Lossless(node);
lossless.ingestDelta(delta); lossless.ingestDelta(delta);
const lossy = new LastWriteWins(lossless); const lossy = new TimestampResolver(lossless);
const result = lossy.resolve(); const result = lossy.resolve();
expect(result).toBeDefined(); expect(result).toBeDefined();
expect(result!['entity-1'].properties.name).toBe('Test Entity'); expect(result!['entity-1'].properties.name).toBe('Test Entity');
@ -86,18 +86,18 @@ describe('DeltaBuilder', () => {
// This delta sets values on a new relationship entity // This delta sets values on a new relationship entity
expect(delta.pointers).toContainEqual({ expect(delta.pointers).toContainEqual({
localContext: '_target', localContext: '_rel_target',
target: expect.any(String), target: expect.any(String),
targetContext: 'target' targetContext: 'target'
}); });
const relId = delta.pointers.find(p => p.localContext === '_target')?.target; const relId = delta.pointers.find(p => p.localContext === '_rel_target')?.target;
expect(delta.pointers).toContainEqual({ expect(delta.pointers).toContainEqual({
localContext: '_source', localContext: '_rel_source',
target: relId, target: relId,
targetContext: 'source' targetContext: 'source'
}); });
expect(delta.pointers).toContainEqual({ expect(delta.pointers).toContainEqual({
localContext: '_type', localContext: '_rel_type',
target: relId, target: relId,
targetContext: 'type' targetContext: 'type'
}); });
@ -110,23 +110,23 @@ describe('DeltaBuilder', () => {
// This delta sets values on a new relationship entity // This delta sets values on a new relationship entity
expect(delta.pointers).toContainEqual({ expect(delta.pointers).toContainEqual({
localContext: '_target', localContext: '_rel_target',
target: expect.any(String), target: expect.any(String),
targetContext: 'target' targetContext: 'target'
}); });
const relId = delta.pointers.find(p => p.localContext === '_target')?.target; const relId = delta.pointers.find(p => p.localContext === '_rel_target')?.target;
expect(delta.pointers).toContainEqual({ expect(delta.pointers).toContainEqual({
localContext: '_source', localContext: '_rel_source',
target: relId, target: relId,
targetContext: 'source' targetContext: 'source'
}); });
expect(delta.pointers).toContainEqual({ expect(delta.pointers).toContainEqual({
localContext: '_type', localContext: '_rel_type',
target: relId, target: relId,
targetContext: 'type' targetContext: 'type'
}); });
expect(delta.pointers).toContainEqual({ expect(delta.pointers).toContainEqual({
localContext: '_version', localContext: '_rel_version',
target: relId, target: relId,
targetContext: 'version' targetContext: 'version'
}); });
@ -161,8 +161,27 @@ describe('DeltaBuilder', () => {
.relate('user-1', 'user-2', 'follows') .relate('user-1', 'user-2', 'follows')
.buildV2(); .buildV2();
expect(delta.pointers).toHaveProperty('follows', { 'user-2': 'follows' }); expect(delta.pointers).toHaveProperty('_rel_source');
expect(delta.pointers).toHaveProperty('source', { 'user-1': 'follows' }); const [relId] = Object.entries(delta.pointers._rel_source!)[0];
expect(delta.pointers).toHaveProperty('_rel_source', { [relId]: 'source' });
expect(delta.pointers).toHaveProperty('_rel_target', { [relId]: 'target' });
expect(delta.pointers).toHaveProperty('_rel_type', { [relId]: 'type' });
expect(delta.pointers).toHaveProperty('source', 'user-1');
expect(delta.pointers).toHaveProperty('target', 'user-2');
expect(delta.pointers).toHaveProperty('type', 'follows');
const lossless = new Lossless(node);
lossless.ingestDelta(delta);
const lossy = new TimestampResolver(lossless);
const result = lossy.resolve([relId]);
expect(result).toBeDefined();
expect(result![relId]).toMatchObject({
properties: {
source: 'user-1',
target: 'user-2',
type: 'follows'
}
});
}); });
it('should create a V2 delta with relationships and properties', () => { it('should create a V2 delta with relationships and properties', () => {
@ -170,9 +189,30 @@ describe('DeltaBuilder', () => {
.relate('user-1', 'user-2', 'follows', { version: 1}) .relate('user-1', 'user-2', 'follows', { version: 1})
.buildV2(); .buildV2();
expect(delta.pointers).toHaveProperty('follows', { 'user-2': 'follows' }); expect(delta.pointers).toHaveProperty('_rel_source');
expect(delta.pointers).toHaveProperty('source', { 'user-1': 'follows' }); const [relId] = Object.entries(delta.pointers._rel_source!)[0];
expect(delta.pointers).toHaveProperty('version', { 1: 'follows' }); expect(delta.pointers).toHaveProperty('_rel_source', { [relId]: 'source' });
expect(delta.pointers).toHaveProperty('_rel_target', { [relId]: 'target' });
expect(delta.pointers).toHaveProperty('_rel_type', { [relId]: 'type' });
expect(delta.pointers).toHaveProperty('_rel_version', { [relId]: 'version' });
expect(delta.pointers).toHaveProperty('source', 'user-1');
expect(delta.pointers).toHaveProperty('target', 'user-2');
expect(delta.pointers).toHaveProperty('type', 'follows');
expect(delta.pointers).toHaveProperty('version', 1);
const lossless = new Lossless(node);
lossless.ingestDelta(delta);
const lossy = new TimestampResolver(lossless);
const result = lossy.resolve([relId]);
expect(result).toBeDefined();
expect(result![relId]).toMatchObject({
properties: {
source: 'user-1',
target: 'user-2',
type: 'follows',
version: 1
}
});
}); });
}); });

View File

@ -4,7 +4,7 @@
// NO_DEBUG=true npm test // will not set DEBUG // NO_DEBUG=true npm test // will not set DEBUG
// DEBUG=other npm test // will set DEBUG=other // DEBUG=other npm test // will set DEBUG=other
if (!process.env.DEBUG && !process.env.NO_DEBUG) { if (!process.env.DEBUG && !process.env.NO_DEBUG) {
process.env.DEBUG = 'rz:*'; // process.env.DEBUG = 'rz:*';
} }
// This is a placeholder test to satisfy Jest's requirement for at least one test // This is a placeholder test to satisfy Jest's requirement for at least one test

View File

@ -1,15 +1,19 @@
import Debug from "debug"; import Debug from "debug";
import { createDelta } from '../src/core/delta-builder'; import { createDelta } from '../src/core/delta-builder';
import {Delta, LastWriteWins, Lossless, RhizomeNode} from "../src"; import {Lossless, RhizomeNode} from "../src";
import {TimestampResolver} from "../src/views/resolvers/timestamp-resolvers";
const debug = Debug('test:last-write-wins'); const debug = Debug('test:last-write-wins');
// This was initially written to test a LastWriteWins resolver, but that has been
// superceded by the TimestampResolver.
describe('Last write wins', () => { describe('Last write wins', () => {
describe('given that two separate writes occur', () => { describe('given that two separate writes occur', () => {
const node = new RhizomeNode(); const node = new RhizomeNode();
const lossless = new Lossless(node); const lossless = new Lossless(node);
const lossy = new LastWriteWins(lossless); const lossy = new TimestampResolver(lossless);
beforeAll(() => { beforeAll(() => {
lossless.ingestDelta(createDelta('a', 'h') lossless.ingestDelta(createDelta('a', 'h')

View File

@ -1,3 +1,4 @@
import { LosslessViewOne } from '../src/views/lossless';
import { import {
SchemaBuilder, SchemaBuilder,
PrimitiveSchemas, PrimitiveSchemas,
@ -155,7 +156,7 @@ describe('Schema System', () => {
schemaRegistry.register(userSchema); schemaRegistry.register(userSchema);
// Create a valid lossless view // Create a valid lossless view
const validView = { const validView: LosslessViewOne = {
id: 'user123', id: 'user123',
referencedAs: ['user'], referencedAs: ['user'],
propertyDeltas: { propertyDeltas: {
@ -173,7 +174,7 @@ describe('Schema System', () => {
creator: 'creator1', creator: 'creator1',
pointers: [{ age: 25 }] pointers: [{ age: 25 }]
}] }]
} },
}; };
const result = schemaRegistry.validate('user123', 'user', validView); const result = schemaRegistry.validate('user123', 'user', validView);
@ -181,7 +182,7 @@ describe('Schema System', () => {
expect(result.errors).toHaveLength(0); expect(result.errors).toHaveLength(0);
// Test invalid view (missing required property) // Test invalid view (missing required property)
const invalidView = { const invalidView: LosslessViewOne = {
id: 'user456', id: 'user456',
referencedAs: ['user'], referencedAs: ['user'],
propertyDeltas: { propertyDeltas: {
@ -217,7 +218,7 @@ describe('Schema System', () => {
schemaRegistry.register(schema); schemaRegistry.register(schema);
// Valid types // Valid types
const validView = { const validView: LosslessViewOne = {
id: 'test1', id: 'test1',
referencedAs: [], referencedAs: [],
propertyDeltas: { propertyDeltas: {
@ -231,7 +232,7 @@ describe('Schema System', () => {
expect(validResult.valid).toBe(true); expect(validResult.valid).toBe(true);
// Invalid types // Invalid types
const invalidView = { const invalidView: LosslessViewOne = {
id: 'test2', id: 'test2',
referencedAs: [], referencedAs: [],
propertyDeltas: { propertyDeltas: {

5
plans/housekeeping.md Normal file
View File

@ -0,0 +1,5 @@
# Housekeeping tasks
- [] Organize tests?
- [] More documentation in docs/
- [] Rename/consolidate, lossless view() and compose() --> composeView()

View File

@ -4,14 +4,15 @@
// It could then be further extended with e.g. table semantics like filter, sort, join // It could then be further extended with e.g. table semantics like filter, sort, join
import {Collection} from '../collections/collection-abstract'; import {Collection} from '../collections/collection-abstract';
import {LastWriteWins, ResolvedViewOne} from '../views/resolvers/last-write-wins'; import {ResolvedViewOne} from '../views/resolvers/last-write-wins';
import {TimestampResolver} from '../views/resolvers/timestamp-resolvers';
export class BasicCollection extends Collection<LastWriteWins> { export class BasicCollection extends Collection<TimestampResolver> {
declare lossy?: LastWriteWins; declare lossy?: TimestampResolver;
initializeView() { initializeView() {
if (!this.rhizomeNode) throw new Error('not connected to rhizome'); if (!this.rhizomeNode) throw new Error('not connected to rhizome');
this.lossy = new LastWriteWins(this.rhizomeNode.lossless); this.lossy = new TimestampResolver(this.rhizomeNode.lossless);
} }
resolve( resolve(

View File

@ -1,7 +1,8 @@
import {Collection} from "./collection-abstract"; import {Collection} from "./collection-abstract";
import {LastWriteWins, ResolvedViewOne} from "../views/resolvers/last-write-wins"; import {ResolvedViewOne} from "../views/resolvers/last-write-wins";
import {TimestampResolver} from "../views/resolvers/timestamp-resolvers";
class RelationalView extends LastWriteWins { class RelationalView extends TimestampResolver {
} }
export class RelationalCollection extends Collection<RelationalView> { export class RelationalCollection extends Collection<RelationalView> {

View File

@ -1,6 +1,7 @@
import Debug from 'debug'; import Debug from 'debug';
import { Collection } from '../collections/collection-abstract'; import { Collection } from '../collections/collection-abstract';
import { LastWriteWins, ResolvedViewOne } from '../views/resolvers/last-write-wins'; import { ResolvedViewOne } from '../views/resolvers/last-write-wins';
import { TimestampResolver } from '../views/resolvers/timestamp-resolvers'
import { import {
ObjectSchema, ObjectSchema,
SchemaValidationResult, SchemaValidationResult,
@ -23,7 +24,7 @@ export class SchemaValidationError extends Error {
} }
export class TypedCollectionImpl<T extends Record<string, unknown>> export class TypedCollectionImpl<T extends Record<string, unknown>>
extends Collection<LastWriteWins> extends Collection<TimestampResolver>
implements TypedCollection<T> { implements TypedCollection<T> {
schema: ObjectSchema; schema: ObjectSchema;
@ -56,7 +57,7 @@ export class TypedCollectionImpl<T extends Record<string, unknown>>
initializeView(): void { initializeView(): void {
if (!this.rhizomeNode) throw new Error('not connected to rhizome'); if (!this.rhizomeNode) throw new Error('not connected to rhizome');
this.lossy = new LastWriteWins(this.rhizomeNode.lossless); this.lossy = new TimestampResolver(this.rhizomeNode.lossless);
} }
resolve(id: string): ResolvedViewOne | undefined { resolve(id: string): ResolvedViewOne | undefined {
@ -73,7 +74,7 @@ export class TypedCollectionImpl<T extends Record<string, unknown>>
const mockLosslessView: LosslessViewOne = { const mockLosslessView: LosslessViewOne = {
id: 'validation-mock', id: 'validation-mock',
referencedAs: [], referencedAs: [],
propertyDeltas: {} propertyDeltas: {},
}; };
// Create mock deltas for each property // Create mock deltas for each property

View File

@ -95,17 +95,22 @@ export class DeltaBuilder {
/** /**
* Create a relationship between two entities * Create a relationship between two entities
* @param sourceId The ID of the source entity
* @param targetId The ID of the target entity
* @param relationship The type of relationship
* @param properties Optional properties for the relationship
*/ */
relate(sourceId: string, targetId: string, relationship: string, properties?: Record<string, any>): this { relate(sourceId: string, targetId: string, relationship: string, properties?: Record<string, any>): this {
const relId = randomUUID(); const relId = randomUUID();
this.setProperty(relId, 'source', sourceId, '_source'); this.setProperty(relId, 'source', sourceId, '_rel_source');
this.setProperty(relId, 'target', targetId, '_target'); this.setProperty(relId, 'target', targetId, '_rel_target');
this.setProperty(relId, 'type', relationship, '_type'); this.setProperty(relId, 'type', relationship, '_rel_type');
if (properties) { if (properties) {
for (const [key, value] of Object.entries(properties)) { for (const [key, value] of Object.entries(properties)) {
this.setProperty(relId, key, value, `_${key}`); this.setProperty(relId, key, value, `_rel_${key}`);
} }
} }
return this; return this;
} }

View File

@ -1,7 +1,6 @@
// import Debug from 'debug'; // import Debug from 'debug';
import {EntityProperties} from "../../core/entity"; import {EntityProperties} from "../../core/entity";
import {CollapsedDelta, LosslessViewOne} from "../lossless"; import {CollapsedDelta} from "../lossless";
import {Lossy} from '../lossy';
import {DomainEntityID, PropertyID, PropertyTypes, Timestamp, ViewMany} from "../../core/types"; import {DomainEntityID, PropertyID, PropertyTypes, Timestamp, ViewMany} from "../../core/types";
// const debug = Debug('rz:lossy:last-write-wins'); // const debug = Debug('rz:lossy:last-write-wins');
@ -68,46 +67,3 @@ export function lastValueFromDeltas(
return res; return res;
} }
export class LastWriteWins extends Lossy<Accumulator, Result> {
initializer(view: LosslessViewOne): Accumulator {
return {
[view.id]: { id: view.id, properties: {} }
};
}
reducer(acc: Accumulator, cur: LosslessViewOne): Accumulator {
if (!acc[cur.id]) {
acc[cur.id] = { id: cur.id, properties: {} };
}
for (const [key, deltas] of Object.entries(cur.propertyDeltas)) {
const { value, timeUpdated } = lastValueFromDeltas(key, deltas) || {};
if (!value || timeUpdated === undefined) continue;
const currentTime = acc[cur.id].properties[key]?.timeUpdated || 0;
if (timeUpdated > currentTime) {
acc[cur.id].properties[key] = { value, timeUpdated };
}
}
return acc;
}
resolver(cur: Accumulator): Result {
const result: Result = {};
for (const [id, entity] of Object.entries(cur)) {
result[id] = {
id,
properties: Object.fromEntries(
Object.entries(entity.properties)
.map(([key, { value }]) => [key, value])
)
};
}
return result;
}
}