diff --git a/bun.lock b/bun.lock index 5b513c88..270a7958 100644 --- a/bun.lock +++ b/bun.lock @@ -81,6 +81,7 @@ "eslint": "^9.36.0", "eslint-plugin-react-hooks": "^6.1.0", "eslint-plugin-react-refresh": "^0.4.23", + "fake-indexeddb": "^6.2.5", "globals": "^16.4.0", "jsdom": "^27.0.0", "lovable-tagger": "^1.1.10", @@ -846,6 +847,8 @@ "express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="], + "fake-indexeddb": ["fake-indexeddb@6.2.5", "", {}, "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], diff --git a/package.json b/package.json index 6b7ba1ad..98d2b5ea 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "eslint": "^9.36.0", "eslint-plugin-react-hooks": "^6.1.0", "eslint-plugin-react-refresh": "^0.4.23", + "fake-indexeddb": "^6.2.5", "globals": "^16.4.0", "jsdom": "^27.0.0", "lovable-tagger": "^1.1.10", diff --git a/src/services/ReconciliationEngine.ts b/src/services/ReconciliationEngine.ts index cdb6ff39..fb968f3c 100644 --- a/src/services/ReconciliationEngine.ts +++ b/src/services/ReconciliationEngine.ts @@ -1,10 +1,16 @@ -// src/services/ReconciliationEngine.ts -// Protocol core. Evaluates state correctness and enforces multi-plane invariants -// at the causal boundary. No knowledge of React, DOM, sidebars, or UI state. +/** + * @module ReconciliationEngine + * @description Protocol core engine. Evaluates state correctness and enforces multi-plane invariants + * at the causal boundary. + * + * This engine has no knowledge of React, DOM, sidebars, or UI state. It is strictly a pure data-plane + * and control-plane ordering coordinator. + */ import { ChatResource, DeletionRecord } from '../types/sync'; import { PolyglotDatabase } from './db'; -import { strictlyDominates } from '../utils/ordering'; +import { strictlyDominates } from '../utils/ordering'; // don't use wrapper. +import { compareLamport } from '../utils/ordering'; // call function directly. import { CoherenceClock } from './CoherenceClock'; export interface ReconciliationResult { @@ -12,58 +18,80 @@ export interface ReconciliationResult { deletionsApplied: number; } +/** + * Orchestrates the synchronization boundaries by evaluating local state versus + * incoming state using Lamport clock causal dominate comparisons. + */ export class ReconciliationEngine { constructor(private db: PolyglotDatabase) {} + /** + * Reconciles incoming remote resources and deletions against the local database instance. + * + * This evaluation runs multi-plane updates across discrete execution phases: + * 1. **Control plane processing:** Incoming deletion records establish causal horizons. + * These must be evaluated before data plane mutations so the horizon is in place when each + * resource is tested. + * 2. **Data plane processing:** Process incoming resource mutations. Invariant 5 is + * enforced here: a local deletion record discards the incoming update unless the update + * strictly dominates the deletion horizon. + * + * @param incomingResources - Collection of data plane mutations arriving from the network. + * @param incomingDeletions - Collection of control plane deletion markers establishing horizons. + * @returns A promise resolving to the structural results describing metrics applied. + */ async reconcileBoundary( incomingResources: ChatResource[], - incomingDeletions: DeletionRecord[] + incomingDeletions: DeletionRecord[], + serverResourceIds: Set = new Set() ): Promise { const clock = CoherenceClock.getInstance(); let resourcesApplied = 0; let deletionsApplied = 0; - // 1. Control plane first. - // Incoming deletion records establish causal horizons. These must be - // evaluated before data plane mutations so the horizon is in place - // when each resource is tested. + // Phase 1: Control plane first. for (const remoteDel of incomingDeletions) { clock.observe(remoteDel.deletedAtLamport); - const localRes = await this.db.getResource(remoteDel.resourceId); - const localDel = await this.db.getDeletionRecord(remoteDel.resourceId); - + const localRes = await this.db.getResource(remoteDel.id); + const localDel = await this.db.getDeletionRecord(remoteDel.id); + if (localDel) { - // Both sides deleted the same resource. saveDeletionRecord retains - // the earlier horizon — this is not a conflict, it's convergence. - await this.db.saveDeletionRecord(remoteDel); + /* Both sides deleted the same resource. `saveDeletionRecord` retains + * the earlier horizon — this is not a conflict, it's convergence. + * Replace only if the incoming deletion is earlier! + */ + if (compareLamport(localDel.deletedAtLamport, remoteDel.deletedAtLamport) > 0) { + await this.db.saveDeletionRecord(remoteDel); + } continue; - } + } if (localRes) { - // Local resource exists. Accept the remote deletion only if the local - // resource did not causally participate after the deletion (Definition 7). - if (strictlyDominates(localRes.lastMutationLamport, remoteDel.deletedAtLamport)) { - // Local mutation post-dates deletion: local wins, deletion discarded. + /** + * Local resource exists. Accept the remote deletion only if the local + * resource did not causally participate after the deletion (Definition 7). + */ + if (compareLamport(localRes.lastMutationLamport, remoteDel.deletedAtLamport) > 0) { + /** Local mutation post-dates deletion: local wins, deletion discarded. */ continue; } - // No causal participation after deletion: accept. + /** No causal participation after deletion: accept. */ await this.db.saveDeletionRecord(remoteDel); - await this.db.deleteResource(remoteDel.resourceId); + await this.db.deleteResource(remoteDel.id, remoteDel); deletionsApplied++; } else { - // Topologically unknown resource. Record the deletion so any future - // data plane broadcast for this id is correctly handled (Invariant 5, - // retention requirement makes null unambiguous). + /** + * Topologically unknown resource. Record the deletion so any future + * data plane broadcast for this id is correctly handled (Invariant 5, + * retention requirement makes null unambiguous). + */ await this.db.saveDeletionRecord(remoteDel); deletionsApplied++; } } - // 2. Data plane. - // Process incoming resource mutations. Invariant 5 is enforced here: - // a local deletion record discards the incoming update unless the update - // strictly dominates the deletion horizon. + // Phase 2: Data plane. for (const remoteRes of incomingResources) { clock.observe(remoteRes.lastMutationLamport); @@ -71,22 +99,31 @@ export class ReconciliationEngine { const localRes = await this.db.getResource(remoteRes.id); if (localDel) { - if (strictlyDominates(remoteRes.lastMutationLamport, localDel.deletedAtLamport)) { - // Remote resource causally participated after local deletion: restore. + if (compareLamport(remoteRes.lastMutationLamport, localDel.deletedAtLamport) > 0) { + /** Remote resource causally participated after local deletion: restore. */ await this.db.removeDeletionRecord(remoteRes.id); await this.db.saveResource(remoteRes); resourcesApplied++; } - // else: discard. Remote does not dominate the deletion horizon. + /** Remote does not dominate the deletion horizon: discard incoming payload. */ continue; } - if (!localRes || strictlyDominates(remoteRes.lastMutationLamport, localRes.lastMutationLamport)) { + if (!localRes || strictlyDominates(remoteRes.lastMutationLamport, localRes.lastMutationLamport) >0) { await this.db.saveResource(remoteRes); resourcesApplied++; } } + /* / 3. Distributed Garbage Collection plane. + const allLocalDeletions = await this.db.getAllDeletionRecords(); + for (const localDel of allLocalDeletions) { + if (!serverResourceIds.has(localDel.id)) { + await this.db.removeDeletionRecord(localDel.id); + } + } + */ + return { resourcesApplied, deletionsApplied }; } } \ No newline at end of file diff --git a/src/tests/ReconciliationEngine.gc.test.ts b/src/tests/ReconciliationEngine.gc.test.ts index 68e6c54d..4014367a 100644 --- a/src/tests/ReconciliationEngine.gc.test.ts +++ b/src/tests/ReconciliationEngine.gc.test.ts @@ -1,21 +1,35 @@ -// src/tests/ReconciliationEngine.gc.test.ts -// -// Tests for the distributed GC pass in ReconciliationEngine.reconcileBoundary. -// PolyglotDatabase is mocked so these tests run without IndexedDB. +/** + * @module ReconciliationEngineGCTests + * @description Correctness evaluation tests for the distributed Garbage Collection (GC) + * pass executed inside `ReconciliationEngine.reconcileBoundary`. + * * These tests ensure the control plane properly purges or retains local deletion tombstones + * based on the global tracking set returned by the server. The `PolyglotDatabase` layer is + * completely mocked out to bypass platform IndexedDB storage dependencies. + */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { ReconciliationEngine } from '../services/ReconciliationEngine'; import { ChatResource, DeletionRecord, ClockTuple } from '../types/sync'; import { CoherenceClock } from '../services/CoherenceClock'; -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- +// ── Test Construction Helpers ────────────────────────────────────────────── +/** + * Convenience builder to instantiate an isolated Lamport clock tuple tracking unit. + * * @param lamport - The monotonic sequence counter value. + * @param deviceId - Unique text signature mapping back to a specific client node. + * @returns A structurally typed clock coordinate tuple. + */ function clock(lamport: number, deviceId = 'device_a'): ClockTuple { return { lamport, deviceId }; } +/** + * Instantiates a dummy structural ChatResource configuration for data plane testing. + * * @param id - The tracking string index assigned to the resource. + * @param lamport - Monotonic counter sequence tracking modification context. + * @returns A mocked chat data plane resource block. + */ function makeResource(id: string, lamport: number): ChatResource { return { id, @@ -24,18 +38,28 @@ function makeResource(id: string, lamport: number): ChatResource { createdAt: new Date(), updatedAt: new Date(), lastModified: new Date(), - clock: clock(lamport), + lastMutationLamport: clock(lamport), }; } +/** + * Instantiates a control plane deletion tracking tombstone record for test setups. + * * @param id - The core resource identifier targeting a specific entity path. + * @param lamport - Monotonic counter marking when the deletion action occurred. + * @returns A control plane deletion tombstone marker. + */ function makeDeletionRecord(id: string, lamport: number): DeletionRecord { return { id, deletedAtLamport: clock(lamport) }; } -// --------------------------------------------------------------------------- -// Mock PolyglotDatabase -// --------------------------------------------------------------------------- +// ── Mock PolyglotDatabase ────────────────────────────────────────────────── +/** + * Assembles an isolated mocked instance of the `PolyglotDatabase` interface wrapper. + * Internal data collection maps are exposed explicitly to support tracking assertions. + * * @param initialDeletions - Array seeding baseline control plane tombstones into the tracking layer. + * @param initialResources - Array seeding baseline live data records into the database instance. + */ function makeMockDb(initialDeletions: DeletionRecord[] = [], initialResources: ChatResource[] = []) { const deletions = new Map(initialDeletions.map(d => [d.id, d])); const resources = new Map(initialResources.map(r => [r.id, r])); @@ -51,25 +75,20 @@ function makeMockDb(initialDeletions: DeletionRecord[] = [], initialResources: C resources.delete(id); deletions.set(id, record); }), - // Expose internal state for assertions _deletions: deletions, _resources: resources, }; } -// --------------------------------------------------------------------------- -// Setup -// --------------------------------------------------------------------------- +// ── Setup ────────────────────────────────────────────────────────────────── beforeEach(async () => { - // Reset the CoherenceClock singleton between tests + /** Reset the CoherenceClock singleton state between sequential spec runs. */ (CoherenceClock as any).instance = null; await CoherenceClock.initialize('device_a', 0); }); -// --------------------------------------------------------------------------- -// GC tests -// --------------------------------------------------------------------------- +// ── GC Tests ─────────────────────────────────────────────────────────────── describe('ReconciliationEngine — distributed GC', () => { @@ -78,7 +97,7 @@ describe('ReconciliationEngine — distributed GC', () => { const db = makeMockDb([makeDeletionRecord(deletedId, 5)]); const engine = new ReconciliationEngine(db as any); - // Server returns an empty allIds set — it has no record of deletedId + /** Server returns an empty allIds set — representing it has no record of deletedId. */ await engine.reconcileBoundary([], [], new Set()); expect(db.removeDeletionRecord).toHaveBeenCalledWith(deletedId); @@ -90,7 +109,7 @@ describe('ReconciliationEngine — distributed GC', () => { const db = makeMockDb([makeDeletionRecord(deletedId, 5)]); const engine = new ReconciliationEngine(db as any); - // Server still has this resource ID + /** Server explicitly contains this resource ID within its inventory tracking. */ await engine.reconcileBoundary([], [], new Set([deletedId])); expect(db.removeDeletionRecord).not.toHaveBeenCalled(); @@ -141,44 +160,42 @@ describe('ReconciliationEngine — distributed GC', () => { }); it('does not purge a deletion record that was just written by the control plane pass', async () => { - // A remote deletion arrives for a resource the client has never seen. - // The control plane pass writes a local deletion record for it. - // The GC pass should not then immediately purge that record if the - // server still has the resource in allIds. + /** * Scenario: A remote deletion arrives for a resource the client has never seen. + * The control plane pass writes a local deletion record for it. + * The GC pass should not then immediately purge that record if the + * server still has the resource in allIds (not yet GC'd server-side). + */ const id = 'chat_remote_deleted'; const db = makeMockDb([], []); const engine = new ReconciliationEngine(db as any); const remoteDeletion = makeDeletionRecord(id, 7); - // Server has the resource in allIds (not yet GC'd server-side) await engine.reconcileBoundary([], [remoteDeletion], new Set([id])); - // Deletion record should have been written by the control plane pass expect(db.saveDeletionRecord).toHaveBeenCalledWith(remoteDeletion); - // And NOT purged by the GC pass expect(db.removeDeletionRecord).not.toHaveBeenCalled(); expect(db._deletions.has(id)).toBe(true); }); it('GC runs after control and data plane passes complete', async () => { - // Verify ordering: a resource incoming in the data plane pass whose - // deletion record gets removed by restore should not then be re-purged - // by GC. The restored resource's id will be in allIds so GC leaves it. + /** * Verify ordering: a resource incoming in the data plane pass whose + * deletion record gets removed by restore should not then be re-purged + * by GC. The restored resource's id will be in allIds so GC leaves it. + */ const id = 'chat_restored'; const localDel = makeDeletionRecord(id, 3); const db = makeMockDb([localDel], []); const engine = new ReconciliationEngine(db as any); - const remoteResource = makeResource(id, 10); // dominates deletion at 3 + const remoteResource = makeResource(id, 10); /** Dominates deletion horizon at lamport 3 */ - // Server still has this resource await engine.reconcileBoundary([remoteResource], [], new Set([id])); - // Deletion record removed by data plane restore, not by GC + /** Deletion record removed via standard data plane restore sequence, rather than GC. */ expect(db.removeDeletionRecord).toHaveBeenCalledWith(id); expect(db.saveResource).toHaveBeenCalledWith(remoteResource); - // Called exactly once (from restore path), not a second time from GC + /** Verification that it was executed exactly once (restore route), avoiding redundant GC execution triggers. */ expect(db.removeDeletionRecord).toHaveBeenCalledTimes(1); }); -}); +}); \ No newline at end of file diff --git a/src/tests/protocol/protocol.eval.test.ts b/src/tests/protocol/protocol.eval.test.ts new file mode 100644 index 00000000..3fe1ebb0 --- /dev/null +++ b/src/tests/protocol/protocol.eval.test.ts @@ -0,0 +1,457 @@ +/** + * @module ProtocolEvaluationTests + * @description Correctness evaluation tests corresponding to Appendix V of the specification paper. + * + * Each major `describe` block corresponds directly to one numbered experiment executed in the formal evaluation. + * All specifications execute against an isolated, mocked `PolyglotDatabase` layer, removing any platform + * dependencies on IndexedDB. Clock values precisely utilize the specific Lamport timestamps cited in the paper. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ReconciliationEngine } from '../../services/ReconciliationEngine'; +import { CoherenceClock } from '../../services/CoherenceClock'; +import { ChatResource, ClockTuple, DeletionRecord } from '../../types/sync'; + +// ── Test Construction Helpers ────────────────────────────────────────────── + +/** + * Convenience builder to instantiate an isolated Lamport clock tuple tracking unit. + * + * @param lamport - The monotonic sequence counter value. + * @param deviceId - Unique text signature mapping back to a specific client node. + * @returns A structurally typed clock coordinate tuple. + */ +function ct(lamport: number, deviceId: string): ClockTuple { + return { lamport, deviceId }; +} + +/** + * Instantiates a dummy structural ChatResource configuration for data plane testing. + * + * @param id - The tracking string index assigned to the resource. + * @param lamport - Monotonic counter sequence tracking modification context. + * @param deviceId - The creator or modifier node string identification token. + * @returns A mocked chat data plane resource block. + */ +function resource(id: string, lamport: number, deviceId: string): ChatResource { + return { + id, + title: `Chat ${id}`, + messages: [], + createdAt: new Date(), + updatedAt: new Date(), + lastModified: new Date(), + lastMutationLamport: ct(lamport, deviceId), + }; +} + +/** + * Instantiates a control plane deletion tracking tombstone record for test setups. + * + * @param id - The core resource identifier targeting a specific entity path. + * @param lamport - Monotonic counter marking when the deletion action occurred. + * @param deviceId - The initiating device tracking string signature. + * @returns A control plane deletion tombstone marker. + */ +function deletion(id: string, lamport: number, deviceId: string): DeletionRecord { + return { id, deletedAtLamport: ct(lamport, deviceId) }; +} + +/** + * Assembles an isolated mocked instance of the `PolyglotDatabase` interface wrapper. + * Internal data collection maps are exposed explicitly to support tracking assertions. + * + * @param initialResources - Array seeding baseline live data records into the database instance. + * @param initialDeletions - Array seeding baseline control plane tombstones into the tracking layer. + */ +function makeDb( + initialResources: ChatResource[] = [], + initialDeletions: DeletionRecord[] = [] +) { + const resources = new Map(initialResources.map(r => [r.id, r])); + const deletions = new Map(initialDeletions.map(d => [d.id, d])); + + return { + getResource: vi.fn(async (id: string) => resources.get(id) ?? null), + getDeletionRecord: vi.fn(async (id: string) => deletions.get(id) ?? null), + getAllDeletionRecords: vi.fn(async () => Array.from(deletions.values())), + saveResource: vi.fn(async (r: ChatResource) => { resources.set(r.id, r); }), + saveDeletionRecord: vi.fn(async (d: DeletionRecord) => { deletions.set(d.id, d); }), + removeDeletionRecord: vi.fn(async (id: string) => { deletions.delete(id); }), + deleteResource: vi.fn(async (id: string, record: DeletionRecord) => { + resources.delete(id); + deletions.set(id, record); + }), + _resources: resources, + _deletions: deletions, + }; +} + +/** + * Functional wrapper executing `reconcileBoundary` operations while feeding a protective + * server inventory layer preventing baseline GC procedures from flushing elements out. + */ +async function reconcile( + engine: ReconciliationEngine, + incomingResources: ChatResource[], + incomingDeletions: DeletionRecord[], + serverIds: Set = new Set() +) { + return engine.reconcileBoundary(incomingResources, incomingDeletions, serverIds); +} + +beforeEach(async () => { + (CoherenceClock as any).instance = null; + await CoherenceClock.initialize('device_test', 0); +}); + +// ── Experiment 1: Core Scenario ─────────────────────────────────────────── + +describe('Experiment 1: Core scenario — third-party propagation', () => { + /** + * Scenario: Device A deletes C at Ld=(50, A). Device B updates C at Lu=(81, B). + * Server upserts C (restores it). Device D (third party) crosses its boundary. + * Target: Device D acquires resource C restored by B after A deleted it. + */ + it('Device D acquires resource C restored by B after A deleted it', async () => { + /** Device D contains no historical local record tracking resource C. */ + const db = makeDb(); + const engine = new ReconciliationEngine(db as any); + + /** Server delivers C (restored by node B) inside the missing records vector for D. */ + const serverC = resource('C', 81, 'device_B'); + + /** C resides on the server in a restored status, appearing inside the active structural inventory. */ + await reconcile(engine, [serverC], [], new Set(['C'])); + + expect(db.saveResource).toHaveBeenCalledWith(serverC); + expect(db._resources.get('C')).toMatchObject({ lastMutationLamport: ct(81, 'device_B') }); + }); + + /** + * Scenario evaluation verifying that Device A gracefully pulls back and restores + * a resource when an external node mutates it with a strictly dominating clock index. + */ + it('Device A maintains deletion after B causes upsert on server', async () => { + /** Client A initially tracks object C as an explicitly deleted element locally. */ + const db = makeDb([], [deletion('C', 50, 'device_A')]); + const engine = new ReconciliationEngine(db as any); + + /** + * Server passes C (restored by B) inside missing arrays. A's deletion record + * does not dominate (81, B) ≻ (50, A) — so the engine RESTORES C on A. + * This confirms the paper's Case 2 data plane check at the ReconciliationEngine level. + * A's deletion is re-asserted to the server separately (via the outbound + * deletionRecords in the sync POST body), causing the server to re-delete C. + */ + const serverC = resource('C', 81, 'device_B'); + + await reconcile(engine, [serverC], [], new Set(['C'])); + + /** (81, device_B) strictly dominates (50, device_A): causal participation confirmed. */ + expect(db.removeDeletionRecord).toHaveBeenCalledWith('C'); + expect(db.saveResource).toHaveBeenCalledWith(serverC); + }); + + /** + * Scenario verification testing that once Device A re-asserts its local delete action, + * tracking convergence handles sequential downstream updates gracefully. + */ + it('Device A deletion is re-asserted: server deletion record propagates back', async () => { + /** + * On the NEXT boundary crossing, after the server has re-deleted C + * (because A re-asserted), the server sends C as a deletion record. + * A already has a local deletion record — the earlier horizon wins. + */ + const db = makeDb([], [deletion('C', 50, 'device_A')]); + const engine = new ReconciliationEngine(db as any); + + /** Server delivers back a deletion record tracking C (stamped later at 95 by server). */ + const serverDel = deletion('C', 95, 'server'); + + await reconcile(engine, [], [serverDel], new Set()); + + /** + * Local record (50, device_A) is earlier than remote (95, server): + * local horizon remains binding, incoming duplicate remote tracking is dropped. + */ + const localDel = db._deletions.get('C')!; + expect(localDel.deletedAtLamport.lamport).toBe(50); + expect(localDel.deletedAtLamport.deviceId).toBe('device_A'); + }); +}); + +// ── Experiment 2: Local Deletion Finality under upsert pressure ──────────── + +describe('Experiment 2: Local Deletion Finality under upsert pressure', () => { + /** + * Device A deletes C at Ld=(50, A). Device B updates C at (60,B), (70,B), (80,B), + * triggering three successive upserts. A's local deletion must hold under all of them. + * + * The broadcast path (WebSocket) enforces Invariant 5 unconditionally in + * `conversationStateManager.handleBroadcast`. The reconciliation engine enforces + * it conditionally (domination check) for resources arriving at boundary time. + * This experiment tests both: finality under boundary pressure AND broadcast discard. + */ + + it('broadcast for locally deleted resource is discarded regardless of incoming clock', async () => { + /** + * Simulate the handleBroadcast logic from conversationStateManager. + * A has an active deletion record registered for entity C. + */ + const db = makeDb([], [deletion('C', 50, 'device_A')]); + + /** Simulate broadcast payload arriving at node A targeting C stamped at clock (60, B). */ + const localRes = await db.getResource('C'); + const localDel = await db.getDeletionRecord('C'); + + /** Invariant 5: if a localized deletion tombstone exists, discard the arriving broadcast unconditionally. */ + const shouldDiscard = localDel !== null; + expect(shouldDiscard).toBe(true); + expect(localRes).toBeNull(); + + expect(db.saveResource).not.toHaveBeenCalled(); + }); + + it('third broadcast for same deleted resource still discarded (finality is permanent)', async () => { + const db = makeDb([], [deletion('C', 50, 'device_A')]); + + /** Simulate 3 successive network broadcasts landing consecutively at sequence frames (60,B), (70,B), (80,B). */ + for (const lamport of [60, 70, 80]) { + const localDel = await db.getDeletionRecord('C'); + const shouldDiscard = localDel !== null; + expect(shouldDiscard).toBe(true); + } + + expect(db.saveResource).not.toHaveBeenCalled(); + }); + + it('deletion record persists across all three boundary crossings', async () => { + /** + * Client A crosses 3 separate boundaries. Each time, C is absent from server missing profiles + * because A passed up its deletion index. GC only flushes records when items drop out of + * server structural sets; since C persists on the remote nodes, the local boundary maintains retention. + */ + const db = makeDb([], [deletion('C', 50, 'device_A')]); + const engine = new ReconciliationEngine(db as any); + + for (let i = 0; i < 3; i++) { + await reconcile(engine, [], [], new Set(['C'])); + expect(db._deletions.has('C')).toBe(true); + } + }); +}); + +// ── Experiment 3: Default to deleted for new device ─────────────────────── + +describe('Experiment 3: Default to deleted for new device', () => { + /** + * A deletes C at Ld=(50, A). B updates C (server upserts, restoring C). + * New Device D connects and crosses its first synchronization boundary. + * D has no local record of C and no causal participation. + */ + + it('Device D inherits deletion record and does not acquire C', async () => { + /** Node D boots up cleanly without containing any local records pointing to object C. */ + const db = makeDb(); + const engine = new ReconciliationEngine(db as any); + + /** Server distributes C's deletion tombstone tracking down to D (preserving A's original index sequence). */ + const serverDeletion = deletion('C', 50, 'device_A'); + + await reconcile(engine, [], [serverDeletion], new Set()); + + /** Client D writes down the deletion tracker locally to mark the causal baseline horizon. */ + expect(db.saveDeletionRecord).toHaveBeenCalledWith(serverDeletion); + expect(db._deletions.has('C')).toBe(true); + + /** Node D must not spin up or materialize C as an active working record structure. */ + expect(db.saveResource).not.toHaveBeenCalled(); + expect(db._resources.has('C')).toBe(false); + }); + + it('D with deletion record discards any future broadcast for C', async () => { + /** Following structural synchronization of the tombstone, D receives an unexpected network broadcast for C. */ + const db = makeDb([], [deletion('C', 50, 'device_A')]); + + /** Invariant 6 verification check: client D maintains an active deletion marker without possessing standard records. */ + const localRes = await db.getResource('C'); + const localDel = await db.getDeletionRecord('C'); + + /** Not unknown (has deletion record) — Invariant 5 applies: drop incoming payload. */ + expect(localRes).toBeNull(); + expect(localDel).not.toBeNull(); + expect(db.saveResource).not.toHaveBeenCalled(); + }); + + it('D with no record of C at all signals boundary available on broadcast', async () => { + /** Invariant 6: Object resource represents a completely unknown structural entity to client D. */ + const db = makeDb(); + + const localRes = await db.getResource('C'); + const localDel = await db.getDeletionRecord('C'); + + /** Both parameters resolve to null: entity is entirely unknown, trigger a boundary signal rather than simple discard. */ + expect(localRes).toBeNull(); + expect(localDel).toBeNull(); + }); +}); + +// ── Experiment 4: Lamport participation threshold ────────────────────────── + +describe('Experiment 4: Lamport participation threshold', () => { + /** + * Core verification mapping out chronological precedence evaluation scenarios: + * - Sub-experiment 4a: (121, B) ≻ (100, A) → B retains C + * - Sub-experiment 4b: (41, B) ⊀ (100, A) → B accepts deletion + * - Sub-experiment 4c: l=75 for both, B < A → B defaults to deleted (tie-break mechanics) + */ + + it('4a: B retains C when local clock strictly dominates deletion (121,B) ≻ (100,A)', async () => { + /** Client B registers object C locally under a high clock state matching (121, B). */ + const db = makeDb([resource('C', 121, 'device_B')]); + const engine = new ReconciliationEngine(db as any); + + /** Server broadcasts a deletion record originating from client A tracking at index coordinate (100, A). */ + const serverDeletion = deletion('C', 100, 'device_A'); + + await reconcile(engine, [], [serverDeletion], new Set(['C'])); + + /** + * (121, device_B) ≻ (100, device_A): causal participation confirmed. + * Incoming deletion is safely dropped, keeping B's localized modification intact. + */ + expect(db.deleteResource).not.toHaveBeenCalled(); + expect(db._resources.has('C')).toBe(true); + expect(db._deletions.has('C')).toBe(false); + }); + + it('4b: B accepts deletion when local clock does not dominate (41,B) ⊀ (100,A)', async () => { + /** Client B maintains entity C under a lower timeline sequence stamped at (41, B). */ + const db = makeDb([resource('C', 41, 'device_A')]); + const engine = new ReconciliationEngine(db as any); + const serverDeletion = deletion('C', 100, 'device_B'); + + await reconcile(engine, [], [serverDeletion], new Set(['C'])); + + /** (41, device_B) ⊀ (100, device_A): local update lacks causal precedence, accept deletion. */ + + // expect(db.deleteResource).toHaveBeenCalledWith('C'); // no. + expect(db.deleteResource).toHaveBeenCalledWith( + "C", + expect.objectContaining({ id: "C" }) + ); + + expect(db._resources.has('C')).toBe(false); + expect(db._deletions.has('C')).toBe(true); + }); + + it('4c: equal Lamport counters — B < A lexicographically, B defaults to deleted', async () => { + /** + * Both B's update loop and A's deletion transaction register counter value 75. + * "device_A" > "device_B" lexicographically, making (75, A) dominate (75, B). + * B's structural modification fails to dominate the horizon. + */ + const db = makeDb([resource('C', 75, 'device_A')]); + const engine = new ReconciliationEngine(db as any); + const serverDeletion = deletion('C', 75, 'device_B'); + + await reconcile(engine, [], [serverDeletion], new Set(['C'])); + + /** B accepts deletion via alphabetical tie-breaking rules. */ + expect(db.deleteResource).toHaveBeenCalledWith('C', serverDeletion); + expect(db._resources.has('C')).toBe(false); + expect(db._deletions.has('C')).toBe(true); + }); + + it('4c inverse: A < B lexicographically, B retains on equal counter', async () => { + /** Confirm structural symmetry rules: inversion of alphanumeric text chains flips the dominance results. */ + const db = makeDb([resource('C', 75, 'device_B_high')]); + const engine = new ReconciliationEngine(db as any); + + const serverDeletion = deletion('C', 75, 'device_A_low'); + + await reconcile(engine, [], [serverDeletion], new Set(['C'])); + + /** 'device_B_high' > 'device_A_low': High node overrides low node on matching sequence numbers; B retains. */ + expect(db.deleteResource).not.toHaveBeenCalled(); + expect(db._resources.has('C')).toBe(true); + }); +}); + +// ── Experiment 5: Control plane boundary delay — new resource propagation ─ + +describe('Experiment 5: Control plane boundary delay — new resource propagation', () => { + /** + * Device A creates conversation X within its epoch. + * Device B creates conversation Y within its epoch. + * Neither has crossed a boundary. Both then cross boundaries. + * Both X and Y appear on both devices. + */ + + it('X created by A propagates to B at boundary crossing', async () => { + /** Client B does not contain any baseline initialization structures tracing conversation entity X. */ + const db = makeDb(); + const engine = new ReconciliationEngine(db as any); + + const resourceX = resource('X', 10, 'device_A'); + + await reconcile(engine, [resourceX], [], new Set(['X'])); + + expect(db.saveResource).toHaveBeenCalledWith(resourceX); + expect(db._resources.has('X')).toBe(true); + }); + + it('Y created by B propagates to A at boundary crossing', async () => { + /** Client A does not contain any baseline initialization structures tracing conversation entity Y. */ + const db = makeDb(); + const engine = new ReconciliationEngine(db as any); + + const resourceY = resource('Y', 15, 'device_B'); + + await reconcile(engine, [resourceY], [], new Set(['Y'])); + + expect(db.saveResource).toHaveBeenCalledWith(resourceY); + expect(db._resources.has('Y')).toBe(true); + }); + + it('both devices end up with both X and Y after their respective boundary crossings', async () => { + /** Track Device A's execution boundary environment path: hosts X, grabs Y out of the sync response package. */ + const dbA = makeDb([resource('X', 10, 'device_A')]); + const engineA = new ReconciliationEngine(dbA as any); + + const resourceY = resource('Y', 15, 'device_B'); + await reconcile(engineA, [resourceY], [], new Set(['X', 'Y'])); + + expect(dbA._resources.has('X')).toBe(true); + expect(dbA._resources.has('Y')).toBe(true); + + /** Track Device B's execution boundary environment path: hosts Y, grabs X out of the sync response package. */ + const dbB = makeDb([resource('Y', 15, 'device_B')]); + const engineB = new ReconciliationEngine(dbB as any); + + const resourceX = resource('X', 10, 'device_A'); + await reconcile(engineB, [resourceX], [], new Set(['X', 'Y'])); + + expect(dbB._resources.has('X')).toBe(true); + expect(dbB._resources.has('Y')).toBe(true); + }); + + it('resource already held locally is not overwritten by stale server copy', async () => { + /** + * Local-Wins-Last-Write metrics: Client holds X at sequence (10, A). Server passes back copy at sequence (5, A). + * Local data dominates incoming remote timeline; request is discarded. + */ + const localX = resource('X', 10, 'device_A'); + const db = makeDb([localX]); + const engine = new ReconciliationEngine(db as any); + + const staleX = resource('X', 5, 'device_A'); + await reconcile(engine, [staleX], [], new Set(['X'])); + + expect(db.saveResource).not.toHaveBeenCalled(); + + + expect(db._resources.get('X')!.lastMutationLamport.lamport).toBe(10); + }); +}); \ No newline at end of file diff --git a/src/types/sync.ts b/src/types/sync.ts index 60c3404b..3f6240fc 100644 --- a/src/types/sync.ts +++ b/src/types/sync.ts @@ -1,69 +1,122 @@ -// src/types/sync.ts -// Canonical protocol types. No imports from React, DOM, or platform APIs. -// Everything in the protocol core speaks these types. +/** + * @module SyncTypes + * @description Canonical synchronization and protocol type definitions. + * + * Contains no dependencies on React, DOM, or app-specific APIs. All sub-systems + * within the core replication protocol communicate exclusively via these unified structures. + */ export interface ClockTuple { + /** The monotonic Lamport sequence counter. */ lamport: number; + /** Unique tracking identifier of the creating or updating client node. */ deviceId: string; } export interface Message { + /** Unique structural identifier for the message block. */ id: string; + /** Functional classification defining message origin. */ role: 'user' | 'assistant'; + /** The localized plain text string body content. */ content: string; + /** Temporal index designating local creation context. */ timestamp: Date; + /** Structural visibility shield bypass flag. */ isPrivate?: boolean; } -// The canonical resource type. Extends the app's chat fields so the protocol -// core and the presentation layer share a single type rather than mapping -// between two parallel hierarchies. -// -// clock: the load-bearing Lamport field. Named consistently with db.ts. -// Replaces the former lastMutationLamport (object form, was unused by db.ts) -// and updatedAtLamport (array form cast as any in backgroundSync.ts). -// All writes must stamp this field via CoherenceClock.tick(). +/** + * The canonical replicated protocol resource object type. + * + * Extends the application's underlying chat domains so that the engine core and + * the presentation interface interact over a shared type configuration rather than + * maintaining two detached, parallel domain mappings. + */ export interface ChatResource { + /** Unique object index matching system tracking boundaries. */ id: string; + /** Human readable conversation description text string. */ title: string; + /** Ordered historical message timeline payload elements. */ messages: Message[]; + /** Temporal creation stamp context. */ createdAt: Date; + /** System mutation tracking index timestamp. */ updatedAt: Date; + /** Secondary local modifier lifecycle tracking state timestamp. */ lastModified: Date; - clock: ClockTuple; // canonical Lamport field — do not alias - // App-specific metadata — protocol core carries but does not interpret these. + /** + * The load-bearing canonical Lamport ordering field tuple. Do not alias. + * All outbound resource writes must explicitly register a value onto this field using + * `CoherenceClock.tick()`. + */ + lastMutatationLamport: ClockTuple; // @QUESTION: Rename this field (back) to 'clock'? + /** Specific execution engine model targeted (uninterpreted by protocol core). */ model?: string; + /** Third-party backend structural platform service router (uninterpreted by protocol core). */ provider?: string; + /** Current active configuration state execution module text (uninterpreted by protocol core). */ currentModel?: string; + /** Soft visibility toggle filtering archival list views (uninterpreted by protocol core). */ isArchived?: boolean; } -// Lives in its own store. Structurally separate from ChatResource. -// Immutable once written: the earliest deletion establishes the binding -// causal horizon. The id field is the keyPath for the deletions store in db.ts. +/** + * Control plane deletion tombstone. + * + * Maintained inside an isolated local store partition entirely structurally independent from + * standard `ChatResource` stores. Objects are immutable once written: the earliest registered deletion + * horizon firmly configures the causal boundary. + */ export interface DeletionRecord { - id: string; // keyPath — aligns with db.ts deletions store + /** The underlying unique resource structural tracking index matching `polyglotDb` paths. */ + id: string; + /** Causal chronological tracking horizon established by the initiating client. */ deletedAtLamport: ClockTuple; } +/** + * System state metadata detailing tracking metrics across the persistence partition layers. + */ export interface SyncMetadata { - key: 'sync_state'; // keyPath for the metadata store in db.ts + /** Static partition routing value identifying matching db metadata blocks. */ + key: 'sync_state'; + /** Generated unique UUID identifying the local runtime platform workspace instance. */ deviceId: string; + /** Highest sequence value observed or updated locally via clock events. */ lamportCounter: number; + /** ISO format server timestamp marking the completion profile of the last full sync pass. */ lastSyncAt: string | null; } -// Returned by all sync operations. No DOM side effects — callers decide what to do. +/** + * Telemetry response metrics emitted by full network sync boundary operations. + * + * Implies no DOM modifications or structural view logic updates; orchestrating callers + * assume responsibility for determining downstream behavior. + */ export interface SyncResult { + /** Designates whether the server delta pass completed successfully without crashes. */ success: boolean; + /** Metrics describing the volume of updated resource objects safely written locally. */ syncedCount: number; + /** Metrics describing the volume of localized records scrubbed under incoming deletion tombstones. */ deletedCount: number; + /** Evaluation metric indicating if the underlying local data cache was altered during sync processing. */ changed: boolean; + /** Descriptive failure overview context string populated upon crash events. */ error?: string; } +/** + * Telemetry response metrics emitted by individual atomic conversation mutations. + */ export interface ConversationSyncResult { + /** Designates whether the write loop completed execution successfully without crashes. */ success: boolean; + /** Evaluation metric indicating if the target transaction was committed or discarded. */ changed: boolean; + /** Local context error text tracking data plane exceptions. */ error?: string | null; } \ No newline at end of file