Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
103 changes: 70 additions & 33 deletions src/services/ReconciliationEngine.ts
Original file line number Diff line number Diff line change
@@ -1,92 +1,129 @@
// 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 {
resourcesApplied: number;
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<string> = new Set()
): Promise<ReconciliationResult> {
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);

const localDel = await this.db.getDeletionRecord(remoteRes.id);
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 };
}
}
89 changes: 53 additions & 36 deletions src/tests/ReconciliationEngine.gc.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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]));
Expand All @@ -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', () => {

Expand All @@ -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);
Expand All @@ -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();
Expand Down Expand Up @@ -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);
});
});
});
Loading
Loading