diff --git a/__tests__/concurrent-writes.ts b/__tests__/concurrent-writes.ts index 97c0c55..19f34f0 100644 --- a/__tests__/concurrent-writes.ts +++ b/__tests__/concurrent-writes.ts @@ -2,13 +2,11 @@ import { createDelta } from '../src/core/delta-builder'; import { RhizomeNode, Lossless, - Delta, - LastWriteWins, - TimestampResolver, SumResolver, CustomResolver, LastWriteWinsPlugin, - MajorityVotePlugin + MajorityVotePlugin, + TimestampResolver } from "../src"; describe('Concurrent Write Scenarios', () => { @@ -39,7 +37,7 @@ describe('Concurrent Write Scenarios', () => { .buildV1() ); - const resolver = new LastWriteWins(lossless); + const resolver = new TimestampResolver(lossless); const result = resolver.resolve(); expect(result).toBeDefined(); @@ -123,7 +121,7 @@ describe('Concurrent Write Scenarios', () => { .buildV1() ); - const resolver = new LastWriteWins(lossless); + const resolver = new TimestampResolver(lossless); const result = resolver.resolve(); 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(); expect(result).toBeDefined(); diff --git a/__tests__/delta-builder.test.ts b/__tests__/delta-builder.test.ts index 36061c3..3b18fec 100644 --- a/__tests__/delta-builder.test.ts +++ b/__tests__/delta-builder.test.ts @@ -2,7 +2,7 @@ import { createDelta } from '../src/core/delta-builder'; import { DeltaV1, DeltaV2 } from '../src/core/delta'; import { Lossless } from '../src/views/lossless'; import { RhizomeNode } from '../src/node'; -import { LastWriteWins } from '../src/views/resolvers/last-write-wins'; +import { TimestampResolver } from '../src/views/resolvers/timestamp-resolvers'; describe('DeltaBuilder', () => { const creator = 'creator-123'; @@ -47,7 +47,7 @@ describe('DeltaBuilder', () => { // Verify that the entity property resolves correctly const lossless = new Lossless(node); lossless.ingestDelta(delta); - const lossy = new LastWriteWins(lossless); + const lossy = new TimestampResolver(lossless); const result = lossy.resolve(); expect(result).toBeDefined(); expect(result!['entity-1'].properties.name).toBe('Test Entity'); @@ -72,7 +72,7 @@ describe('DeltaBuilder', () => { // Verify that the entity property resolves correctly const lossless = new Lossless(node); lossless.ingestDelta(delta); - const lossy = new LastWriteWins(lossless); + const lossy = new TimestampResolver(lossless); const result = lossy.resolve(); expect(result).toBeDefined(); expect(result!['entity-1'].properties.name).toBe('Test Entity'); @@ -86,18 +86,18 @@ describe('DeltaBuilder', () => { // This delta sets values on a new relationship entity expect(delta.pointers).toContainEqual({ - localContext: '_target', + localContext: '_rel_target', target: expect.any(String), 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({ - localContext: '_source', + localContext: '_rel_source', target: relId, targetContext: 'source' }); expect(delta.pointers).toContainEqual({ - localContext: '_type', + localContext: '_rel_type', target: relId, targetContext: 'type' }); @@ -110,23 +110,23 @@ describe('DeltaBuilder', () => { // This delta sets values on a new relationship entity expect(delta.pointers).toContainEqual({ - localContext: '_target', + localContext: '_rel_target', target: expect.any(String), 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({ - localContext: '_source', + localContext: '_rel_source', target: relId, targetContext: 'source' }); expect(delta.pointers).toContainEqual({ - localContext: '_type', + localContext: '_rel_type', target: relId, targetContext: 'type' }); expect(delta.pointers).toContainEqual({ - localContext: '_version', + localContext: '_rel_version', target: relId, targetContext: 'version' }); @@ -161,8 +161,27 @@ describe('DeltaBuilder', () => { .relate('user-1', 'user-2', 'follows') .buildV2(); - expect(delta.pointers).toHaveProperty('follows', { 'user-2': 'follows' }); - expect(delta.pointers).toHaveProperty('source', { 'user-1': 'follows' }); + expect(delta.pointers).toHaveProperty('_rel_source'); + 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', () => { @@ -170,9 +189,30 @@ describe('DeltaBuilder', () => { .relate('user-1', 'user-2', 'follows', { version: 1}) .buildV2(); - expect(delta.pointers).toHaveProperty('follows', { 'user-2': 'follows' }); - expect(delta.pointers).toHaveProperty('source', { 'user-1': 'follows' }); - expect(delta.pointers).toHaveProperty('version', { 1: 'follows' }); + expect(delta.pointers).toHaveProperty('_rel_source'); + 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('_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 + } + }); }); }); diff --git a/__tests__/jest-setup.ts b/__tests__/jest-setup.ts index 795567d..26539cb 100644 --- a/__tests__/jest-setup.ts +++ b/__tests__/jest-setup.ts @@ -4,7 +4,7 @@ // NO_DEBUG=true npm test // will not set DEBUG // DEBUG=other npm test // will set DEBUG=other 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 diff --git a/__tests__/last-write-wins.ts b/__tests__/last-write-wins.ts index 38a4ddd..c34205d 100644 --- a/__tests__/last-write-wins.ts +++ b/__tests__/last-write-wins.ts @@ -1,15 +1,19 @@ import Debug from "debug"; 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'); +// This was initially written to test a LastWriteWins resolver, but that has been +// superceded by the TimestampResolver. + describe('Last write wins', () => { describe('given that two separate writes occur', () => { const node = new RhizomeNode(); const lossless = new Lossless(node); - const lossy = new LastWriteWins(lossless); + const lossy = new TimestampResolver(lossless); beforeAll(() => { lossless.ingestDelta(createDelta('a', 'h') diff --git a/__tests__/schema.ts b/__tests__/schema.ts index 24bccb4..06a926b 100644 --- a/__tests__/schema.ts +++ b/__tests__/schema.ts @@ -1,3 +1,4 @@ +import { LosslessViewOne } from '../src/views/lossless'; import { SchemaBuilder, PrimitiveSchemas, @@ -155,7 +156,7 @@ describe('Schema System', () => { schemaRegistry.register(userSchema); // Create a valid lossless view - const validView = { + const validView: LosslessViewOne = { id: 'user123', referencedAs: ['user'], propertyDeltas: { @@ -173,7 +174,7 @@ describe('Schema System', () => { creator: 'creator1', pointers: [{ age: 25 }] }] - } + }, }; const result = schemaRegistry.validate('user123', 'user', validView); @@ -181,7 +182,7 @@ describe('Schema System', () => { expect(result.errors).toHaveLength(0); // Test invalid view (missing required property) - const invalidView = { + const invalidView: LosslessViewOne = { id: 'user456', referencedAs: ['user'], propertyDeltas: { @@ -217,7 +218,7 @@ describe('Schema System', () => { schemaRegistry.register(schema); // Valid types - const validView = { + const validView: LosslessViewOne = { id: 'test1', referencedAs: [], propertyDeltas: { @@ -231,7 +232,7 @@ describe('Schema System', () => { expect(validResult.valid).toBe(true); // Invalid types - const invalidView = { + const invalidView: LosslessViewOne = { id: 'test2', referencedAs: [], propertyDeltas: { diff --git a/plans/housekeeping.md b/plans/housekeeping.md new file mode 100644 index 0000000..c71f8af --- /dev/null +++ b/plans/housekeeping.md @@ -0,0 +1,5 @@ +# Housekeeping tasks + +- [] Organize tests? +- [] More documentation in docs/ +- [] Rename/consolidate, lossless view() and compose() --> composeView() \ No newline at end of file diff --git a/src/collections/collection-basic.ts b/src/collections/collection-basic.ts index 8f99c9c..7827df7 100644 --- a/src/collections/collection-basic.ts +++ b/src/collections/collection-basic.ts @@ -4,14 +4,15 @@ // It could then be further extended with e.g. table semantics like filter, sort, join 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 { - declare lossy?: LastWriteWins; +export class BasicCollection extends Collection { + declare lossy?: TimestampResolver; initializeView() { 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( diff --git a/src/collections/collection-relational.ts b/src/collections/collection-relational.ts index e2343ef..939a173 100644 --- a/src/collections/collection-relational.ts +++ b/src/collections/collection-relational.ts @@ -1,7 +1,8 @@ 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 { diff --git a/src/collections/collection-typed.ts b/src/collections/collection-typed.ts index 4f5974d..6f2313d 100644 --- a/src/collections/collection-typed.ts +++ b/src/collections/collection-typed.ts @@ -1,6 +1,7 @@ import Debug from 'debug'; 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 { ObjectSchema, SchemaValidationResult, @@ -23,7 +24,7 @@ export class SchemaValidationError extends Error { } export class TypedCollectionImpl> - extends Collection + extends Collection implements TypedCollection { schema: ObjectSchema; @@ -56,7 +57,7 @@ export class TypedCollectionImpl> initializeView(): void { 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 { @@ -73,7 +74,7 @@ export class TypedCollectionImpl> const mockLosslessView: LosslessViewOne = { id: 'validation-mock', referencedAs: [], - propertyDeltas: {} + propertyDeltas: {}, }; // Create mock deltas for each property diff --git a/src/core/delta-builder.ts b/src/core/delta-builder.ts index d332829..2894223 100644 --- a/src/core/delta-builder.ts +++ b/src/core/delta-builder.ts @@ -95,17 +95,22 @@ export class DeltaBuilder { /** * 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): this { const relId = randomUUID(); - this.setProperty(relId, 'source', sourceId, '_source'); - this.setProperty(relId, 'target', targetId, '_target'); - this.setProperty(relId, 'type', relationship, '_type'); + this.setProperty(relId, 'source', sourceId, '_rel_source'); + this.setProperty(relId, 'target', targetId, '_rel_target'); + this.setProperty(relId, 'type', relationship, '_rel_type'); if (properties) { for (const [key, value] of Object.entries(properties)) { - this.setProperty(relId, key, value, `_${key}`); + this.setProperty(relId, key, value, `_rel_${key}`); } } + return this; } diff --git a/src/views/resolvers/last-write-wins.ts b/src/views/resolvers/last-write-wins.ts index 4404e7c..de8d529 100644 --- a/src/views/resolvers/last-write-wins.ts +++ b/src/views/resolvers/last-write-wins.ts @@ -1,7 +1,6 @@ // import Debug from 'debug'; import {EntityProperties} from "../../core/entity"; -import {CollapsedDelta, LosslessViewOne} from "../lossless"; -import {Lossy} from '../lossy'; +import {CollapsedDelta} from "../lossless"; import {DomainEntityID, PropertyID, PropertyTypes, Timestamp, ViewMany} from "../../core/types"; // const debug = Debug('rz:lossy:last-write-wins'); @@ -68,46 +67,3 @@ export function lastValueFromDeltas( return res; } - -export class LastWriteWins extends Lossy { - 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; - } -} -