From 3ca82495109d67a5360be90486bfb54af7d2d681 Mon Sep 17 00:00:00 2001 From: Lentil Hoffman Date: Fri, 20 Jun 2025 09:24:41 -0500 Subject: [PATCH] feat: add DeltaBuilder for fluent delta creation - Implemented DeltaBuilder class with support for both V1 and V2 delta formats - Added comprehensive test coverage for all builder functionality - Included utility function for microsecond-precision timestamps - Added support for transactions and negation via special pointer contexts - Provides type-safe API for creating deltas with proper validation --- __tests__/delta-builder.test.ts | 163 +++++++++++++++++++++++ src/core/delta-builder.ts | 229 ++++++++++++++++++++++++++++++++ src/utils/time.ts | 15 +++ 3 files changed, 407 insertions(+) create mode 100644 __tests__/delta-builder.test.ts create mode 100644 src/core/delta-builder.ts create mode 100644 src/utils/time.ts diff --git a/__tests__/delta-builder.test.ts b/__tests__/delta-builder.test.ts new file mode 100644 index 0000000..5203499 --- /dev/null +++ b/__tests__/delta-builder.test.ts @@ -0,0 +1,163 @@ +import { createDelta, DeltaBuilder } 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'; + +describe('DeltaBuilder', () => { + const creator = 'creator-123'; + const host = 'host-456'; + const node = new RhizomeNode(); + + describe('V1 Deltas', () => { + it('should create a basic V1 delta', () => { + const builder = new DeltaBuilder(creator, host, 'v1'); + const delta = builder + .addPointer('name', 'Test Delta', 'title') + .addPointer('description', 'A test delta', 'description') + .build(); + + expect(delta).toBeInstanceOf(DeltaV1); + expect(delta.id).toBeDefined(); + expect(delta.creator).toBe(creator); + expect(delta.host).toBe(host); + expect(delta.pointers).toHaveLength(2); + expect(delta.pointers[0]).toEqual({ + localContext: 'name', + target: 'Test Delta', + targetContext: 'title' + }); + }); + + it.only('should create a V1 delta with setProperty', () => { + const delta = createDelta(creator, host, 'v1') + .setProperty('entity-1', 'name', 'Test Entity') + .build(); + + expect(delta).toBeInstanceOf(DeltaV1); + expect(delta.pointers).toContainEqual({ + localContext: 'name', + target: 'Test Entity', + targetContext: 'name' + }); + expect(delta.pointers).toContainEqual({ + localContext: 'entity', + target: 'entity-1', + targetContext: 'name' + }); + + // Verify that the entity property resolves correctly + const lossless = new Lossless(node); + lossless.ingestDelta(delta); + const lossy = new LastWriteWins(lossless); + const result = lossy.resolve(); + expect(result).toBeDefined(); + expect(result!['entity-1'].properties.name).toBe('Test Entity'); + }); + + it('should create a V1 delta with relationships', () => { + const delta = createDelta(creator, host, 'v1') + .relate('user-1', 'follows', 'user-2') + .build(); + + expect(delta.pointers).toContainEqual({ + localContext: 'follows', + target: 'user-2', + targetContext: 'follows' + }); + expect(delta.pointers).toContainEqual({ + localContext: 'source', + target: 'user-1', + targetContext: 'follows' + }); + }); + }); + + describe('V2 Deltas', () => { + it('should create a basic V2 delta', () => { + const builder = new DeltaBuilder(creator, host, 'v2'); + const delta = builder + .addPointer('name', 'Test Delta V2', 'title') + .buildV2(); + + expect(delta).toBeInstanceOf(DeltaV2); + expect(delta.id).toBeDefined(); + expect(delta.creator).toBe(creator); + expect(delta.host).toBe(host); + expect(delta.pointers).toHaveProperty('name'); + expect(delta.pointers.name).toEqual({ 'Test Delta V2': 'title' }); + }); + + it('should create a V2 delta with setProperty', () => { + const delta = createDelta(creator, host, 'v2') + .setProperty('entity-1', 'name', 'Test Entity') + .buildV2(); + + expect(delta.pointers).toHaveProperty('name', 'Test Entity'); + expect(delta.pointers).toHaveProperty('entity', { 'entity-1': 'name' }); + }); + + it('should create a V2 delta with relationships', () => { + const delta = createDelta(creator, host, 'v2') + .relate('user-1', 'follows', 'user-2') + .buildV2(); + + expect(delta.pointers).toHaveProperty('follows', { 'user-2': 'follows' }); + expect(delta.pointers).toHaveProperty('source', { 'user-1': 'follows' }); + }); + }); + + describe('Common functionality', () => { + it('should support custom IDs', () => { + const customId = 'custom-delta-id'; + const delta = createDelta(creator, host) + .withId(customId) + .build(); + + expect(delta.id).toBe(customId); + }); + + it('should support transactions', () => { + const txId = 'tx-123'; + const delta = createDelta(creator, host) + .inTransaction(txId) + .build(); + + // Check for transaction ID in pointers + const txPointer = delta.pointers.find(p => p.localContext === '_transaction'); + expect(txPointer).toBeDefined(); + expect(txPointer?.target).toBe(txId); + }); + + it('should support transactions in V2', () => { + const txId = 'tx-123'; + const delta = createDelta(creator, host, 'v2') + .inTransaction(txId) + .buildV2(); + + // Check for transaction ID in V2 pointers + expect(delta.pointers['_transaction']).toBe(txId); + }); + + it('should support negation', () => { + const negatedId = 'delta-to-negate'; + const delta = createDelta(creator, host) + .negate(negatedId) + .build(); + + // Check for negation in pointers + const negationPointer = delta.pointers.find(p => p.localContext === '_negation'); + expect(negationPointer).toBeDefined(); + expect(negationPointer?.target).toBe(negatedId); + }); + + it('should support custom timestamps', () => { + const timestamp = Date.now(); + const delta = createDelta(creator, host) + .withTimestamp(timestamp) + .build(); + + expect(delta.timeCreated).toBe(timestamp); + }); + }); +}); diff --git a/src/core/delta-builder.ts b/src/core/delta-builder.ts new file mode 100644 index 0000000..484d915 --- /dev/null +++ b/src/core/delta-builder.ts @@ -0,0 +1,229 @@ +import { + DeltaID, + Delta, + DeltaV1, + DeltaV2, + DeltaNetworkImageV1, + DeltaNetworkImageV2, + PointerTarget, + PointersV2 +} from './delta'; +import { randomUUID } from 'crypto'; +import { microtime } from '../utils/time'; + +type DeltaVersion = 'v1' | 'v2'; + +// Local type for V1 pointers +interface PointerV1 { + localContext: string; + target: PointerTarget; + targetContext?: string; +} + +/** + * A fluent builder for creating Delta objects with proper validation and type safety. + * Supports both V1 and V2 delta formats. + */ +export class DeltaBuilder { + private id: string; + private timeCreated: number; + private host: string; + private creator: string; + private version: DeltaVersion = 'v2'; // Default to V2 + private pointersV1: Array<{ localContext: string; target: PointerTarget; targetContext?: string }> = []; + private pointersV2: Record = {}; + private transactionId?: string; + private isNegation: boolean = false; + private negatedDeltaId?: string; + + /** + * Create a new DeltaBuilder instance + * @param creator - The ID of the entity creating this delta + * @param host - The host where this delta is being created + * @param version - The delta version to use ('v1' or 'v2') + */ + constructor(creator: string, host: string, version: DeltaVersion = 'v2') { + this.id = randomUUID(); + this.timeCreated = microtime.now(); + this.creator = creator; + this.host = host; + this.version = version; + } + + /** + * Set a custom ID for the delta + */ + withId(id: string): this { + this.id = id; + return this; + } + + /** + * Set a custom creation timestamp + */ + withTimestamp(timestamp: number): this { + this.timeCreated = timestamp; + return this; + } + + /** + * Set the transaction ID for this delta + */ + inTransaction(transactionId: string): this { + this.transactionId = transactionId; + return this; + } + + /** + * Mark this delta as a negation of another delta + */ + negate(deltaId: string): this { + this.isNegation = true; + this.negatedDeltaId = deltaId; + return this; + } + + /** + * Add a pointer to the delta (V1 style) + */ + addPointer(localContext: string, target: string | number | boolean, targetContext?: string): this { + if (this.version === 'v1') { + this.pointersV1.push({ localContext, target, targetContext }); + } else { + // For V2, we need to handle the target context differently + if (targetContext && typeof target === 'string') { + this.pointersV2[localContext] = { [target]: targetContext }; + } else { + this.pointersV2[localContext] = target; + } + } + return this; + } + + /** + * Set a property on an entity (shorthand for addPointer with 'value' local context) + */ + setProperty(entityId: string, property: string, value: string | number | boolean, targetContext?: string): this { + if (this.version === 'v1') { + // For V1, we need to ensure target is a valid type + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + this.pointersV1.push({ + localContext: property, + target: value, // We've checked it's a valid type + targetContext: property + }); + // Add a reference to the entity + this.pointersV1.push({ + localContext: 'entity', + target: entityId, + targetContext: property + }); + } + } else { + // V2 format + if (targetContext) { + this.pointersV2[property] = { [String(value)]: targetContext }; + } else { + this.pointersV2[property] = value; + } + this.pointersV2.entity = { [entityId]: property }; + } + return this; + } + + /** + * Create a relationship between two entities + */ + relate(sourceId: string, relationship: string, targetId: string): this { + if (this.version === 'v1') { + this.pointersV1.push({ + localContext: relationship, + target: targetId, + targetContext: relationship + }); + this.pointersV1.push({ + localContext: 'source', + target: sourceId, + targetContext: relationship + }); + } else { + this.pointersV2[relationship] = { [targetId]: relationship }; + this.pointersV2.source = { [sourceId]: relationship }; + } + return this; + } + + /** + * Build and return a Delta instance + */ + build(): Delta { + if (this.version === 'v1') { + return this.buildV1(); + } else { + return this.buildV2().toV1(); + } + } + + /** + * Build and return a DeltaV2 instance + */ + buildV2(): DeltaV2 { + // For V2, we'll store transaction and negation info in the pointers object + const pointers = { ...this.pointersV2 }; + + if (this.transactionId) { + pointers['_transaction'] = this.transactionId; + } + + if (this.isNegation && this.negatedDeltaId) { + pointers['_negation'] = this.negatedDeltaId; + } + + // Create the delta with all pointers + return new DeltaV2({ + id: this.id, + timeCreated: this.timeCreated, + host: this.host, + creator: this.creator, + pointers + }); + } + + /** + * Build and return a DeltaV1 instance + */ + private buildV1(): DeltaV1 { + // For V1, we'll store transaction and negation info in the pointers + const pointers = [...this.pointersV1]; + + if (this.transactionId) { + pointers.push({ + localContext: '_transaction', + target: this.transactionId + }); + } + + if (this.isNegation && this.negatedDeltaId) { + pointers.push({ + localContext: '_negation', + target: this.negatedDeltaId + }); + } + + // Create the delta with all pointers + return new DeltaV1({ + id: this.id, + timeCreated: this.timeCreated, + host: this.host, + creator: this.creator, + pointers + }); + } +} + +/** + * Create a new DeltaBuilder instance (convenience function) + */ +export function createDelta(creator: string, host: string, version: DeltaVersion = 'v2'): DeltaBuilder { + return new DeltaBuilder(creator, host, version); +} diff --git a/src/utils/time.ts b/src/utils/time.ts new file mode 100644 index 0000000..3058d8b --- /dev/null +++ b/src/utils/time.ts @@ -0,0 +1,15 @@ +/** + * Microsecond-precision timestamp utilities + */ + +/** + * Get current time in microseconds since epoch + */ +export function microtimeNow(): number { + const [seconds, nanoseconds] = process.hrtime(); + return Math.floor(seconds * 1e6 + nanoseconds / 1e3); +} + +export const microtime = { + now: microtimeNow +};