fix(sync): return existingClock in conflict rejections to fix infinite loop

When encryption is enabled, clients could get stuck in infinite conflict
resolution loops because they couldn't create vector clocks that dominate
the server's state. The server already computed the existingClock during
conflict detection but didn't return it to the client.

This fix:
- Adds existingClock to UploadResult type (server)
- Returns conflict.existingClock in CONFLICT_CONCURRENT/CONFLICT_STALE rejections
- Passes existingClock through client upload service to rejected ops handler
- Merges entity clocks into extraClocks for stale operation resolution

Includes E2E tests for encryption + conflict resolution scenarios.
This commit is contained in:
Johannes Millan 2026-01-25 18:07:23 +01:00
parent ed8b9a5f88
commit 100b8af5f2
10 changed files with 787 additions and 45 deletions

View file

@ -120,7 +120,9 @@ export class SuperSyncPage extends BasePage {
// Select "SuperSync" from provider dropdown
await this.providerSelect.click();
// Wait for dropdown options to appear (Angular Material animation)
const superSyncOption = this.page.locator('mat-option:has-text("SuperSync")');
await superSyncOption.waitFor({ state: 'visible', timeout: 5000 });
await superSyncOption.click();
await this.page.waitForTimeout(500); // Wait for dropdown to close and form to update
@ -221,6 +223,8 @@ export class SuperSyncPage extends BasePage {
if (encryptionSettingsChanged) {
// Trigger a manual sync to ensure we get latest data after encryption change
await this.page.waitForTimeout(500); // Wait for UI to settle
// Ensure any lingering overlays from encryption dialogs are closed
await this.ensureOverlaysClosed();
await this.syncBtn.click();
// Wait for sync to start
@ -276,7 +280,9 @@ export class SuperSyncPage extends BasePage {
// CRITICAL: Select "SuperSync" from provider dropdown to load current configuration
// Without this, the form shows default/empty values instead of the actual current state
await this.providerSelect.click();
// Wait for dropdown options to appear (Angular Material animation)
const superSyncOption = this.page.locator('mat-option:has-text("SuperSync")');
await superSyncOption.waitFor({ state: 'visible', timeout: 5000 });
await superSyncOption.click();
// IMPORTANT: Wait for the provider change listener to complete loading the config
@ -304,45 +310,67 @@ export class SuperSyncPage extends BasePage {
// Check if already enabled
const isChecked = await this.encryptionCheckbox.isChecked();
if (!isChecked) {
// Step 1: Enable checkbox first (this makes the password field visible)
// Since there's no password yet, no dialog will appear
await checkboxLabel.click();
await this.page.waitForTimeout(300);
if (isChecked) {
// Already enabled - just close the dialog
const configDialog = this.page.locator('mat-dialog-container').first();
const cancelBtn = configDialog.locator('button').filter({ hasText: /cancel/i });
await cancelBtn.click();
await this.page.waitForTimeout(500);
return;
}
// IMPORTANT: The "Enable Encryption?" dialog is triggered by the checkbox valueChanges
// subscription ONLY when there's already a password set. So we must:
// 1. First click checkbox to show password field (no dialog since no password)
// 2. Fill the password
// 3. Uncheck the checkbox (might trigger disable dialog - cancel it)
// 4. Re-check the checkbox (this triggers the enable dialog with password set)
// Step 1: Enable checkbox first (this makes the password field visible)
await checkboxLabel.click();
await this.page.waitForTimeout(300);
// Step 2: Wait for password field to appear and fill it
await this.encryptionPasswordInput.waitFor({ state: 'visible', timeout: 5000 });
await this.encryptionPasswordInput.fill(password);
await this.page.waitForTimeout(300); // Wait for Angular model to update
// Step 3: Save the form to trigger encryption enabling
// The form save with encryption enabled + password will configure encryption
const configDialog = this.page.locator('mat-dialog-container').first();
const saveBtn = configDialog.locator('button').filter({ hasText: /save/i });
await saveBtn.click();
// Step 3: Uncheck the checkbox
// NOTE: If the model has encryptKey, this might trigger the "Disable Encryption?" dialog
await checkboxLabel.click();
await this.page.waitForTimeout(300);
// Check if disable dialog appeared and cancel it
const disableDialog = this.page
.locator('mat-dialog-container')
.filter({ hasText: 'Disable Encryption?' });
const disableDialogVisible = await disableDialog.isVisible().catch(() => false);
if (disableDialogVisible) {
const cancelBtn = disableDialog.locator('button').filter({ hasText: /cancel/i });
await cancelBtn.click();
await disableDialog.waitFor({ state: 'hidden', timeout: 5000 });
await this.page.waitForTimeout(300);
}
// Step 4: Re-check the checkbox - NOW the dialog will appear since password is set
await checkboxLabel.click();
await this.page.waitForTimeout(300);
// Wait for the "Enable Encryption?" confirmation dialog to appear
// This dialog should appear when saving with encryption enabled and a password set
const enableDialog = this.page
.locator('mat-dialog-container')
.filter({ hasText: 'Enable Encryption?' });
const dialogAppeared = await enableDialog
.waitFor({ state: 'visible', timeout: 5000 })
.then(() => true)
.catch(() => false);
await enableDialog.waitFor({ state: 'visible', timeout: 10000 });
if (dialogAppeared) {
// Click the confirm button (mat-flat-button with "Enable Encryption" text)
const confirmBtn = enableDialog
.locator('button[mat-flat-button]')
.filter({ hasText: /enable/i });
await confirmBtn.click();
// Click the confirm button (mat-flat-button with "Enable Encryption" text)
const confirmBtn = enableDialog
.locator('button[mat-flat-button]')
.filter({ hasText: /enable/i });
await confirmBtn.click();
// Wait for the enable encryption dialog to close and any loading to complete
await enableDialog.waitFor({ state: 'hidden', timeout: 60000 });
await this.page.waitForTimeout(500);
}
// Wait for the enable encryption dialog to close and any loading to complete
await enableDialog.waitFor({ state: 'hidden', timeout: 60000 });
await this.page.waitForTimeout(500);
// Wait for all dialogs to close
await expect(this.page.locator('mat-dialog-container')).toHaveCount(0, {
@ -381,7 +409,9 @@ export class SuperSyncPage extends BasePage {
// CRITICAL: Select "SuperSync" from provider dropdown to load current configuration
// Without this, the form shows default/empty values instead of the actual current state
await this.providerSelect.click();
// Wait for dropdown options to appear (Angular Material animation)
const superSyncOption = this.page.locator('mat-option:has-text("SuperSync")');
await superSyncOption.waitFor({ state: 'visible', timeout: 5000 });
await superSyncOption.click();
// IMPORTANT: Wait for the provider change listener to complete loading the config

View file

@ -0,0 +1,521 @@
import { test, expect } from '../../fixtures/supersync.fixture';
import {
createTestUser,
getSuperSyncConfig,
createSimulatedClient,
closeClient,
waitForTask,
type SimulatedE2EClient,
} from '../../utils/supersync-helpers';
/**
* SuperSync Encryption + Conflict Resolution E2E Tests
*
* These tests verify that conflict resolution works correctly when encryption is enabled.
*
* BACKGROUND:
* There was a bug where encryption + concurrent modifications caused an infinite conflict loop:
* 1. Client uploads ops server rejects with CONFLICT_CONCURRENT
* 2. Client downloads 0 new ops (already has them, can't read encrypted payloads)
* 3. Client creates LWW update with merged clock
* 4. Server STILL rejects (client didn't know server's existing entity clock)
* 5. Loop repeats infinitely with "conflict was fixed" messages
*
* FIX:
* Server now returns `existingClock` in conflict rejection responses, allowing
* the client to create LWW ops that properly dominate the server's state.
*
* These tests verify the fix works end-to-end with real encryption.
*
* Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-encryption-conflict.spec.ts
*/
test.describe('@supersync @encryption Encryption + Conflict Resolution', () => {
/**
* Regression test: Concurrent modifications with encryption enabled resolve correctly
*
* This is the core test for the existingClock fix. It verifies that when two clients
* make concurrent modifications with encryption enabled, conflicts resolve correctly
* without an infinite loop.
*
* Scenario:
* 1. Client A and B both set up with encryption (same password)
* 2. Client A creates a task, syncs
* 3. Client B syncs (downloads encrypted task)
* 4. Both clients make concurrent changes to the same task
* 5. Client A syncs first (uploads change)
* 6. Client B syncs (triggers conflict, should resolve via existingClock)
* 7. Final sync round to verify convergence
* 8. Verify both clients have consistent state (no infinite loop)
*/
test('Concurrent modifications with encryption resolve without infinite loop (regression)', async ({
browser,
baseURL,
testRunId,
serverHealthy,
}) => {
void serverHealthy; // Ensure fixture is evaluated for server health check
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const baseConfig = getSuperSyncConfig(user);
const encryptionPassword = `conflict-test-${testRunId}`;
const syncConfig = {
...baseConfig,
isEncryptionEnabled: true,
password: encryptionPassword,
};
// ============ PHASE 1: Setup Both Clients with Encryption ============
console.log('[EncryptConflict] Phase 1: Setting up clients with encryption');
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
// Initial sync to establish vector clocks
await clientA.sync.syncAndWait();
await clientB.sync.syncAndWait();
console.log('[EncryptConflict] Both clients synced initially with encryption');
// ============ PHASE 2: Client A Creates Task ============
console.log('[EncryptConflict] Phase 2: Client A creating task');
const taskName = `EncryptedConflictTask-${testRunId}`;
await clientA.workView.addTask(taskName);
await waitForTask(clientA.page, taskName);
await clientA.sync.syncAndWait();
console.log(`[EncryptConflict] Client A created and synced: ${taskName}`);
// ============ PHASE 3: Client B Downloads Task ============
console.log('[EncryptConflict] Phase 3: Client B downloading task');
await clientB.sync.syncAndWait();
await waitForTask(clientB.page, taskName);
console.log('[EncryptConflict] Client B received the encrypted task');
// ============ PHASE 4: Both Clients Make Concurrent Changes ============
console.log('[EncryptConflict] Phase 4: Making concurrent changes');
// Client A marks task as done
const taskLocatorA = clientA.page
.locator(`task:not(.ng-animating):has-text("${taskName}")`)
.first();
await taskLocatorA.hover();
await taskLocatorA.locator('.task-done-btn').click();
await expect(taskLocatorA).toHaveClass(/isDone/);
console.log('[EncryptConflict] Client A marked task done');
// Wait for timestamp gap (ensures B's change has later timestamp)
await clientB.page.waitForTimeout(500);
// Client B also marks task as done (concurrent change, later timestamp)
const taskLocatorB = clientB.page
.locator(`task:not(.ng-animating):has-text("${taskName}")`)
.first();
await taskLocatorB.hover();
await taskLocatorB.locator('.task-done-btn').click();
await expect(taskLocatorB).toHaveClass(/isDone/);
console.log('[EncryptConflict] Client B marked task done (later timestamp)');
// ============ PHASE 5: Client A Syncs First ============
console.log('[EncryptConflict] Phase 5: Client A syncing first');
await clientA.sync.syncAndWait();
console.log('[EncryptConflict] Client A synced');
// ============ PHASE 6: Client B Syncs (Triggers Conflict Resolution) ============
console.log('[EncryptConflict] Phase 6: Client B syncing (conflict resolution)');
// This is where the bug would manifest - without the fix, B would enter
// an infinite loop of "conflict was fixed" messages.
// With the fix, B receives existingClock and creates a dominating LWW op.
await clientB.sync.syncAndWait();
console.log('[EncryptConflict] Client B synced');
// ============ PHASE 7: Final Sync Round for Convergence ============
console.log('[EncryptConflict] Phase 7: Final sync round');
await clientA.sync.syncAndWait();
await clientB.sync.syncAndWait();
// Wait a moment for any lingering operations
await clientA.page.waitForTimeout(1000);
await clientB.page.waitForTimeout(1000);
// ============ PHASE 8: Verify Consistent State ============
console.log('[EncryptConflict] Phase 8: Verifying consistent state');
// Both clients should have the task marked as done
await expect(taskLocatorA).toHaveClass(/isDone/);
await expect(taskLocatorB).toHaveClass(/isDone/);
// Verify no sync errors
const hasErrorA = await clientA.sync.hasSyncError();
const hasErrorB = await clientB.sync.hasSyncError();
expect(hasErrorA).toBe(false);
expect(hasErrorB).toBe(false);
// Verify task count is the same on both clients
const countA = await clientA.page.locator(`task:has-text("${taskName}")`).count();
const countB = await clientB.page.locator(`task:has-text("${taskName}")`).count();
expect(countA).toBe(1);
expect(countB).toBe(1);
console.log(
'[EncryptConflict] ✓ Concurrent encrypted modifications resolved correctly',
);
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
});
/**
* Test: Multiple tasks with concurrent edits and encryption converge
*
* A scenario with multiple tasks being edited concurrently to verify the
* existingClock mechanism handles conflicts across different entities.
*/
test('Multiple tasks with concurrent edits and encryption converge', async ({
browser,
baseURL,
testRunId,
serverHealthy,
}) => {
void serverHealthy;
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const baseConfig = getSuperSyncConfig(user);
const encryptionPassword = `multi-task-${testRunId}`;
const syncConfig = {
...baseConfig,
isEncryptionEnabled: true,
password: encryptionPassword,
};
// Setup clients with encryption
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
await clientA.sync.syncAndWait();
await clientB.sync.syncAndWait();
console.log('[MultiTask] Both clients set up with encryption');
// Create 3 tasks on client A
const task1 = `Task1-${testRunId}`;
const task2 = `Task2-${testRunId}`;
const task3 = `Task3-${testRunId}`;
await clientA.workView.addTask(task1);
await clientA.page.waitForTimeout(100);
await clientA.workView.addTask(task2);
await clientA.page.waitForTimeout(100);
await clientA.workView.addTask(task3);
await clientA.sync.syncAndWait();
await clientB.sync.syncAndWait();
await waitForTask(clientB.page, task1);
await waitForTask(clientB.page, task2);
await waitForTask(clientB.page, task3);
console.log('[MultiTask] All 3 tasks synced');
// Client A marks task1 as done
const task1LocatorA = clientA.page
.locator(`task:not(.ng-animating):has-text("${task1}")`)
.first();
await task1LocatorA.hover();
await task1LocatorA.locator('.task-done-btn').click();
console.log('[MultiTask] Client A marked task1 done');
// Wait for timestamp gap
await clientB.page.waitForTimeout(500);
// Client B marks task1 AND task2 as done (concurrent, later timestamp)
const task1LocatorB = clientB.page
.locator(`task:not(.ng-animating):has-text("${task1}")`)
.first();
await task1LocatorB.hover();
await task1LocatorB.locator('.task-done-btn').click();
const task2LocatorB = clientB.page
.locator(`task:not(.ng-animating):has-text("${task2}")`)
.first();
await task2LocatorB.hover();
await task2LocatorB.locator('.task-done-btn').click();
console.log('[MultiTask] Client B marked task1 and task2 done (later timestamp)');
// Sync sequence
await clientA.sync.syncAndWait();
await clientB.sync.syncAndWait();
await clientA.sync.syncAndWait();
await clientB.sync.syncAndWait();
// Wait for convergence
await clientA.page.waitForTimeout(1000);
await clientB.page.waitForTimeout(1000);
// Verify no errors
const hasErrorA = await clientA.sync.hasSyncError();
const hasErrorB = await clientB.sync.hasSyncError();
expect(hasErrorA).toBe(false);
expect(hasErrorB).toBe(false);
// All 3 tasks should exist on both clients
const countA = await clientA.page.locator('task').count();
const countB = await clientB.page.locator('task').count();
expect(countA).toBeGreaterThanOrEqual(3);
expect(countB).toBeGreaterThanOrEqual(3);
console.log('[MultiTask] ✓ Multiple tasks with concurrent edits resolved');
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
});
/**
* Test: Title edit conflict with encryption resolves correctly
*
* Tests that more complex entity changes (title edits) also resolve
* correctly with encryption enabled.
*/
test('Title edit conflict with encryption resolves correctly', async ({
browser,
baseURL,
testRunId,
serverHealthy,
}) => {
void serverHealthy;
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const baseConfig = getSuperSyncConfig(user);
const encryptionPassword = `title-conflict-${testRunId}`;
const syncConfig = {
...baseConfig,
isEncryptionEnabled: true,
password: encryptionPassword,
};
// Setup clients with encryption
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
await clientA.sync.syncAndWait();
await clientB.sync.syncAndWait();
// Create initial task
const originalTitle = `TitleConflict-${testRunId}`;
const titleA = `A-Edited-${testRunId}`;
const titleB = `B-Edited-${testRunId}`;
await clientA.workView.addTask(originalTitle);
await clientA.sync.syncAndWait();
await clientB.sync.syncAndWait();
await waitForTask(clientB.page, originalTitle);
console.log('[TitleConflict] Initial task synced');
// Client A edits title
const taskLocatorA = clientA.page
.locator(`task:not(.ng-animating):has-text("${originalTitle}")`)
.first();
await taskLocatorA.dblclick();
const editInputA = clientA.page.locator(
'input.mat-mdc-input-element:focus, textarea:focus',
);
await editInputA.waitFor({ state: 'visible', timeout: 5000 });
await editInputA.fill(titleA);
await clientA.page.keyboard.press('Enter');
await clientA.page.waitForTimeout(500);
console.log('[TitleConflict] Client A edited title');
// Wait for timestamp gap
await clientB.page.waitForTimeout(1000);
// Client B edits title (concurrent, later timestamp)
const taskLocatorB = clientB.page
.locator(`task:not(.ng-animating):has-text("${originalTitle}")`)
.first();
await taskLocatorB.dblclick();
const editInputB = clientB.page.locator(
'input.mat-mdc-input-element:focus, textarea:focus',
);
await editInputB.waitFor({ state: 'visible', timeout: 5000 });
await editInputB.fill(titleB);
await clientB.page.keyboard.press('Enter');
await clientB.page.waitForTimeout(500);
console.log('[TitleConflict] Client B edited title (later timestamp)');
// Sync sequence
await clientA.sync.syncAndWait();
await clientB.sync.syncAndWait();
await clientA.sync.syncAndWait();
await clientB.sync.syncAndWait();
// Wait for convergence
await clientA.page.waitForTimeout(1000);
await clientB.page.waitForTimeout(1000);
// Verify no errors
const hasErrorA = await clientA.sync.hasSyncError();
const hasErrorB = await clientB.sync.hasSyncError();
expect(hasErrorA).toBe(false);
expect(hasErrorB).toBe(false);
// B's title should win (later timestamp) - both clients should have titleB
const taskWithBTitleOnA = clientA.page.locator(
`task:not(.ng-animating):has-text("${titleB}")`,
);
const taskWithBTitleOnB = clientB.page.locator(
`task:not(.ng-animating):has-text("${titleB}")`,
);
await expect(taskWithBTitleOnA.first()).toBeVisible({ timeout: 10000 });
await expect(taskWithBTitleOnB.first()).toBeVisible({ timeout: 10000 });
console.log(
'[TitleConflict] ✓ Title conflict resolved correctly - B won (later ts)',
);
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
});
/**
* Test: Three clients with encryption converge correctly
*
* Tests that the existingClock mechanism works when more than two clients
* are involved in conflicts.
*/
test('Three clients with encryption converge correctly', async ({
browser,
baseURL,
testRunId,
serverHealthy,
}) => {
void serverHealthy;
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
let clientC: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const baseConfig = getSuperSyncConfig(user);
const encryptionPassword = `three-way-${testRunId}`;
const syncConfig = {
...baseConfig,
isEncryptionEnabled: true,
password: encryptionPassword,
};
// Setup all three clients with encryption
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
clientC = await createSimulatedClient(browser, baseURL!, 'C', testRunId);
await clientC.sync.setupSuperSync(syncConfig);
// Initial sync
await clientA.sync.syncAndWait();
await clientB.sync.syncAndWait();
await clientC.sync.syncAndWait();
console.log('[ThreeWay] All three clients set up with encryption');
// Create task on A
const taskName = `ThreeWayTask-${testRunId}`;
await clientA.workView.addTask(taskName);
await clientA.sync.syncAndWait();
// B and C sync to get task
await clientB.sync.syncAndWait();
await clientC.sync.syncAndWait();
await waitForTask(clientB.page, taskName);
await waitForTask(clientC.page, taskName);
console.log('[ThreeWay] All clients have the task');
// All three clients make concurrent changes
const taskLocatorA = clientA.page
.locator(`task:not(.ng-animating):has-text("${taskName}")`)
.first();
await taskLocatorA.hover();
await taskLocatorA.locator('.task-done-btn').click();
const taskLocatorB = clientB.page
.locator(`task:not(.ng-animating):has-text("${taskName}")`)
.first();
await taskLocatorB.hover();
await taskLocatorB.locator('.task-done-btn').click();
const taskLocatorC = clientC.page
.locator(`task:not(.ng-animating):has-text("${taskName}")`)
.first();
await taskLocatorC.hover();
await taskLocatorC.locator('.task-done-btn').click();
console.log('[ThreeWay] All three clients made concurrent changes');
// Sequential sync with conflict resolution
await clientA.sync.syncAndWait();
await clientB.sync.syncAndWait();
await clientC.sync.syncAndWait();
// Final sync round to ensure convergence
await clientA.sync.syncAndWait();
await clientB.sync.syncAndWait();
await clientC.sync.syncAndWait();
// Wait for state to settle
await clientA.page.waitForTimeout(1000);
// Verify no errors on any client
const hasErrorA = await clientA.sync.hasSyncError();
const hasErrorB = await clientB.sync.hasSyncError();
const hasErrorC = await clientC.sync.hasSyncError();
expect(hasErrorA).toBe(false);
expect(hasErrorB).toBe(false);
expect(hasErrorC).toBe(false);
// All clients should have consistent state
await expect(taskLocatorA).toHaveClass(/isDone/);
await expect(taskLocatorB).toHaveClass(/isDone/);
await expect(taskLocatorC).toHaveClass(/isDone/);
// Task counts should match
const countA = await clientA.page.locator(`task:has-text("${taskName}")`).count();
const countB = await clientB.page.locator(`task:has-text("${taskName}")`).count();
const countC = await clientC.page.locator(`task:has-text("${taskName}")`).count();
expect(countA).toBe(1);
expect(countB).toBe(1);
expect(countC).toBe(1);
console.log('[ThreeWay] ✓ Three clients with encryption converged correctly');
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
if (clientC) await closeClient(clientC);
}
});
});

View file

@ -378,6 +378,7 @@ export class SyncService {
accepted: false,
error: conflict.reason,
errorCode,
existingClock: conflict.existingClock,
};
}
@ -414,6 +415,7 @@ export class SyncService {
accepted: false,
error: finalConflict.reason,
errorCode,
existingClock: finalConflict.existingClock,
};
}

View file

@ -155,6 +155,11 @@ export interface UploadResult {
serverSeq?: number;
error?: string;
errorCode?: SyncErrorCode;
/**
* The existing entity's vector clock when rejecting due to conflict.
* Allows clients to create LWW updates that dominate the server's state.
*/
existingClock?: VectorClock;
}
export interface UploadOpsResponse {

View file

@ -282,6 +282,60 @@ describe('Conflict Detection', () => {
expect(result2[0].error).toContain('Concurrent modification');
});
it('should return existingClock in rejection response for concurrent conflicts', async () => {
const service = getSyncService();
const entityId = 'task-1';
// First op from client A with clock {a: 1}
const op1 = createOp({
entityId,
clientId: clientA,
vectorClock: { [clientA]: 1 },
opType: 'CRT',
});
await service.uploadOps(userId, clientA, [op1]);
// Second op from client B with clock {b: 1} - CONCURRENT with {a: 1}
const op2 = createOp({
entityId,
clientId: clientB,
vectorClock: { [clientB]: 1 },
});
const result = await service.uploadOps(userId, clientB, [op2]);
// Should return the existing clock so client can create LWW update
expect(result[0].accepted).toBe(false);
expect(result[0].existingClock).toBeDefined();
expect(result[0].existingClock).toEqual({ [clientA]: 1 });
});
it('should return existingClock in rejection response for stale conflicts', async () => {
const service = getSyncService();
const entityId = 'task-1';
// First op with clock {a: 2}
const op1 = createOp({
entityId,
clientId: clientA,
vectorClock: { [clientA]: 2 },
opType: 'CRT',
});
await service.uploadOps(userId, clientA, [op1]);
// Second op with clock {a: 1} - LESS_THAN {a: 2} (stale)
const op2 = createOp({
entityId,
clientId: clientB,
vectorClock: { [clientA]: 1 },
});
const result = await service.uploadOps(userId, clientB, [op2]);
// Should return the existing clock so client can create LWW update
expect(result[0].accepted).toBe(false);
expect(result[0].existingClock).toBeDefined();
expect(result[0].existingClock).toEqual({ [clientA]: 2 });
});
it('should accept operation when clocks are EQUAL from same client (retry)', async () => {
const service = getSyncService();
const entityId = 'task-1';

View file

@ -7,6 +7,11 @@ export interface RejectedOpInfo {
opId: string;
error?: string;
errorCode?: string;
/**
* The existing entity's vector clock when rejecting due to conflict.
* Allows clients to create LWW updates that dominate the server's state.
*/
existingClock?: VectorClock;
}
/**

View file

@ -165,6 +165,11 @@ export interface OpUploadResult {
error?: string;
/** Structured error code for programmatic handling */
errorCode?: string;
/**
* The existing entity's vector clock when rejecting due to conflict.
* Allows clients to create LWW updates that dominate the server's state.
*/
existingClock?: Record<string, number>;
}
/**

View file

@ -318,7 +318,12 @@ export class OperationLogUploadService {
const rejected = response.results.filter((r) => !r.accepted);
if (rejected.length > 0) {
for (const r of rejected) {
rejectedOps.push({ opId: r.opId, error: r.error, errorCode: r.errorCode });
rejectedOps.push({
opId: r.opId,
error: r.error,
errorCode: r.errorCode,
existingClock: r.existingClock,
});
}
rejectedCount += rejected.length;

View file

@ -374,6 +374,85 @@ describe('RejectedOpsHandlerService', () => {
expect(result).toBe(1);
});
it('should pass existingClock from rejection to stale resolver (FIX: encryption conflict loop)', async () => {
// REGRESSION TEST: Bug where encrypted SuperSync gets stuck in infinite conflict loop.
// Root cause: Client cannot create LWW update that dominates server state because
// it doesn't receive the server's existing entity clock in rejection responses.
//
// Fix: Server returns existingClock in rejection, client passes it to stale resolver.
const op = createOp({ id: 'op-1' });
const existingClock = { otherClient: 5 };
opLogStoreSpy.getOpById.and.returnValue(Promise.resolve(mockEntry(op)));
downloadCallback.and.callFake(async (options) => {
if (options?.forceFromSeq0) {
return {
newOpsCount: 0,
allOpClocks: [],
snapshotVectorClock: { snapshot: 1 },
} as DownloadResultForRejection;
}
return { newOpsCount: 0 } as DownloadResultForRejection;
});
staleOperationResolverSpy.resolveStaleLocalOps.and.resolveTo(1);
await service.handleRejectedOps(
[
{
opId: 'op-1',
error: 'concurrent',
errorCode: 'CONFLICT_CONCURRENT',
existingClock,
},
],
downloadCallback,
);
// Should include existingClock in the extraClocks passed to resolver
expect(staleOperationResolverSpy.resolveStaleLocalOps).toHaveBeenCalledWith(
jasmine.arrayContaining([jasmine.objectContaining({ opId: 'op-1' })]),
[existingClock], // existingClock should be in extraClocks
{ snapshot: 1 },
);
});
it('should merge existingClock with allOpClocks from force download', async () => {
// Test that existingClock is merged with other clocks from force download
const op = createOp({ id: 'op-1' });
const existingClock = { conflictClient: 3 };
const remoteClocks = [{ otherClient: 2 }];
opLogStoreSpy.getOpById.and.returnValue(Promise.resolve(mockEntry(op)));
downloadCallback.and.callFake(async (options) => {
if (options?.forceFromSeq0) {
return {
newOpsCount: 0,
allOpClocks: remoteClocks,
snapshotVectorClock: { snapshot: 1 },
} as DownloadResultForRejection;
}
return { newOpsCount: 0 } as DownloadResultForRejection;
});
staleOperationResolverSpy.resolveStaleLocalOps.and.resolveTo(1);
await service.handleRejectedOps(
[
{
opId: 'op-1',
error: 'concurrent',
errorCode: 'CONFLICT_CONCURRENT',
existingClock,
},
],
downloadCallback,
);
// Should merge existingClock with allOpClocks
expect(staleOperationResolverSpy.resolveStaleLocalOps).toHaveBeenCalledWith(
jasmine.arrayContaining([jasmine.objectContaining({ opId: 'op-1' })]),
[...remoteClocks, existingClock], // Both clocks should be passed
{ snapshot: 1 },
);
});
it('should mark ops as rejected when force download returns no clocks', async () => {
const op = createOp({ id: 'op-1' });
opLogStoreSpy.getOpById.and.returnValue(Promise.resolve(mockEntry(op)));

View file

@ -1,18 +1,19 @@
import { inject, Injectable } from '@angular/core';
import { OperationLogStoreService } from '../persistence/operation-log-store.service';
import { Operation } from '../core/operation.types';
import { Operation, VectorClock } from '../core/operation.types';
import { OpLog } from '../../core/log';
import { SnackService } from '../../core/snack/snack.service';
import { T } from '../../t.const';
import { MAX_REJECTED_OPS_BEFORE_WARNING } from '../core/operation-log.const';
import { StaleOperationResolverService } from './stale-operation-resolver.service';
import { DownloadCallback } from '../core/types/sync-results.types';
import { DownloadCallback, RejectedOpInfo } from '../core/types/sync-results.types';
import { handleStorageQuotaError } from './sync-error-utils';
// Re-export for consumers that import from this service
export type {
DownloadResultForRejection,
DownloadCallback,
RejectedOpInfo,
} from '../core/types/sync-results.types';
/**
@ -57,7 +58,7 @@ export class RejectedOpsHandlerService {
* @returns Number of merged ops created (caller should trigger follow-up upload if > 0)
*/
async handleRejectedOps(
rejectedOps: Array<{ opId: string; error?: string; errorCode?: string }>,
rejectedOps: RejectedOpInfo[],
downloadCallback?: DownloadCallback,
): Promise<number> {
if (rejectedOps.length === 0) {
@ -67,8 +68,12 @@ export class RejectedOpsHandlerService {
let mergedOpsCreated = 0;
// Separate concurrent modification rejections from permanent failures
// For concurrent mods, we collect the full operation for later processing
const concurrentModificationOps: Array<{ opId: string; op: Operation }> = [];
// For concurrent mods, we collect the full operation and existingClock for later processing
const concurrentModificationOps: Array<{
opId: string;
op: Operation;
existingClock?: VectorClock;
}> = [];
const permanentlyRejectedOps: string[] = [];
for (const rejected of rejectedOps) {
@ -130,6 +135,7 @@ export class RejectedOpsHandlerService {
concurrentModificationOps.push({
opId: rejected.opId,
op: entry.op,
existingClock: rejected.existingClock,
});
OpLog.warn(
`RejectedOpsHandlerService: Concurrent modification for ${entry.op.entityType}:${entry.op.entityId}, ` +
@ -175,7 +181,11 @@ export class RejectedOpsHandlerService {
* Resolves concurrent modification rejections by downloading and merging.
*/
private async _resolveConcurrentModifications(
concurrentModificationOps: Array<{ opId: string; op: Operation }>,
concurrentModificationOps: Array<{
opId: string;
op: Operation;
existingClock?: VectorClock;
}>,
downloadCallback: DownloadCallback,
): Promise<number> {
let mergedOpsCreated = 0;
@ -189,20 +199,33 @@ export class RejectedOpsHandlerService {
// Try to download new remote ops - if there are any, conflict detection will handle them
const downloadResult = await downloadCallback();
// Helper to check which ops are still pending
// Helper to check which ops are still pending, preserving existingClock from rejection
const getStillPendingOps = async (): Promise<
Array<{ opId: string; op: Operation }>
Array<{ opId: string; op: Operation; existingClock?: VectorClock }>
> => {
const pending: Array<{ opId: string; op: Operation }> = [];
for (const { opId, op } of concurrentModificationOps) {
const pending: Array<{
opId: string;
op: Operation;
existingClock?: VectorClock;
}> = [];
for (const { opId, op, existingClock } of concurrentModificationOps) {
const entry = await this.opLogStore.getOpById(opId);
if (entry && !entry.syncedAt && !entry.rejectedAt) {
pending.push({ opId, op });
pending.push({ opId, op, existingClock });
}
}
return pending;
};
// Helper to extract entity clocks from still-pending ops for merging
const extractEntityClocks = (
ops: Array<{ existingClock?: VectorClock }>,
): VectorClock[] => {
return ops
.map((item) => item.existingClock)
.filter((clock): clock is VectorClock => clock !== undefined);
};
// If download got new ops, conflict detection already happened in _processRemoteOps
// If download got nothing (newOpsCount === 0), we need to resolve locally
if (downloadResult.newOpsCount === 0) {
@ -220,26 +243,34 @@ export class RejectedOpsHandlerService {
const forceDownloadResult = await downloadCallback({ forceFromSeq0: true });
// Use the clocks from force download to resolve stale ops
// Also merge in entity clocks from server rejection responses
const entityClocks = extractEntityClocks(stillPendingOps);
if (
forceDownloadResult.allOpClocks &&
forceDownloadResult.allOpClocks.length > 0
) {
const allExtraClocks = [...forceDownloadResult.allOpClocks, ...entityClocks];
OpLog.normal(
`RejectedOpsHandlerService: Got ${forceDownloadResult.allOpClocks.length} clocks from force download`,
`RejectedOpsHandlerService: Got ${forceDownloadResult.allOpClocks.length} clocks from force download` +
(entityClocks.length > 0
? ` + ${entityClocks.length} entity clocks from rejection`
: ''),
);
mergedOpsCreated += await this.staleOperationResolver.resolveStaleLocalOps(
stillPendingOps,
forceDownloadResult.allOpClocks,
allExtraClocks,
forceDownloadResult.snapshotVectorClock,
);
} else if (forceDownloadResult.snapshotVectorClock) {
// Force download returned no individual clocks but we have snapshot clock
} else if (forceDownloadResult.snapshotVectorClock || entityClocks.length > 0) {
// Force download returned no individual clocks but we have snapshot clock or entity clocks
OpLog.normal(
`RejectedOpsHandlerService: Using snapshotVectorClock from force download`,
`RejectedOpsHandlerService: Using ${forceDownloadResult.snapshotVectorClock ? 'snapshotVectorClock' : ''}` +
`${forceDownloadResult.snapshotVectorClock && entityClocks.length > 0 ? ' + ' : ''}` +
`${entityClocks.length > 0 ? `${entityClocks.length} entity clocks from rejection` : ''}`,
);
mergedOpsCreated += await this.staleOperationResolver.resolveStaleLocalOps(
stillPendingOps,
undefined,
entityClocks.length > 0 ? entityClocks : undefined,
forceDownloadResult.snapshotVectorClock,
);
} else {
@ -266,13 +297,18 @@ export class RejectedOpsHandlerService {
if (stillPendingOps.length > 0) {
// Ops still pending after download - conflict detection didn't resolve them
// This can happen if downloaded ops were for different entities
// Merge entity clocks from rejection responses into extraClocks
const entityClocks = extractEntityClocks(stillPendingOps);
OpLog.warn(
`RejectedOpsHandlerService: Download got ${downloadResult.newOpsCount} ops but ${stillPendingOps.length} ` +
`concurrent ops still pending. Resolving locally with merged clocks...`,
`concurrent ops still pending. Resolving locally with merged clocks...` +
(entityClocks.length > 0
? ` (including ${entityClocks.length} entity clocks from rejection)`
: ''),
);
mergedOpsCreated += await this.staleOperationResolver.resolveStaleLocalOps(
stillPendingOps,
undefined,
entityClocks.length > 0 ? entityClocks : undefined,
downloadResult.snapshotVectorClock,
);
}