feat(sync): enhance vector clock management and improve error handling in sync process

This commit is contained in:
Johannes Millan 2026-01-25 20:09:19 +01:00
parent 8963b525a0
commit 1138c2c1c0
4 changed files with 62 additions and 14 deletions

View file

@ -14,8 +14,10 @@
<option value="$PROJECT_DIR$/../node_modules/@capacitor/android/capacitor" />
<option value="$PROJECT_DIR$/../node_modules/@capacitor/app/android" />
<option value="$PROJECT_DIR$/../node_modules/@capacitor/filesystem/android" />
<option value="$PROJECT_DIR$/../node_modules/@capacitor/keyboard/android" />
<option value="$PROJECT_DIR$/../node_modules/@capacitor/local-notifications/android" />
<option value="$PROJECT_DIR$/../node_modules/@capacitor/share/android" />
<option value="$PROJECT_DIR$/../node_modules/@capacitor/status-bar/android" />
<option value="$PROJECT_DIR$/../node_modules/@capawesome/capacitor-android-dark-mode-support/android" />
<option value="$PROJECT_DIR$/../node_modules/@capawesome/capacitor-background-task/android" />
</set>

View file

@ -105,11 +105,22 @@ test.describe('@supersync @encryption Wrong Password Error Handling', () => {
'[WrongPassword] Phase 4: Client B attempting sync with wrong password',
);
// Trigger sync manually (it will fail with DecryptError)
await clientB.sync.triggerSync();
// The sync starts automatically after setup. Check if the decrypt error dialog
// is already open before trying to trigger sync manually.
const decryptErrorDialogEarly = clientB.page.locator('dialog-handle-decrypt-error');
const dialogAlreadyOpen = await decryptErrorDialogEarly
.waitFor({ state: 'visible', timeout: 3000 })
.then(() => true)
.catch(() => false);
// Wait a bit for the error to be processed
await clientB.page.waitForTimeout(2000);
if (!dialogAlreadyOpen) {
// Dialog not open yet - trigger sync manually
await clientB.sync.triggerSync();
// Wait a bit for the error to be processed
await clientB.page.waitForTimeout(2000);
} else {
console.log('[WrongPassword] Decrypt error dialog already open from auto-sync');
}
// ============ PHASE 5: Verify error handling ============
console.log('[WrongPassword] Phase 5: Verifying error is properly surfaced');
@ -155,13 +166,13 @@ test.describe('@supersync @encryption Wrong Password Error Handling', () => {
await passwordInput.fill(newPassword);
console.log('[WrongPassword] Filled in correct password');
// Click the "Re-sync" or similar button to retry
// Click the "Change & Attempt Decrypt" button to retry with new password
const resyncBtn = decryptErrorDialog
.locator('button')
.filter({ hasText: /sync|retry/i })
.filter({ hasText: /change.*decrypt|attempt.*decrypt/i })
.first();
await resyncBtn.click();
console.log('[WrongPassword] Clicked re-sync button');
console.log('[WrongPassword] Clicked Change & Attempt Decrypt button');
// Wait for dialog to close
await decryptErrorDialog.waitFor({ state: 'hidden', timeout: 10000 });
@ -243,22 +254,49 @@ test.describe('@supersync @encryption Wrong Password Error Handling', () => {
waitForInitialSync: false,
});
// The sync starts automatically after setup and may trigger the decrypt error dialog.
// If dialog appears, cancel it so we can add a local task first.
const decryptErrorDialogEarly = clientB.page.locator('dialog-handle-decrypt-error');
const dialogOpenedEarly = await decryptErrorDialogEarly
.waitFor({ state: 'visible', timeout: 3000 })
.then(() => true)
.catch(() => false);
if (dialogOpenedEarly) {
// Cancel the dialog so we can add a local task
const cancelBtn = decryptErrorDialogEarly
.locator('button')
.filter({ hasText: /cancel/i });
await cancelBtn.click();
await decryptErrorDialogEarly.waitFor({ state: 'hidden', timeout: 5000 });
await clientB.page.waitForTimeout(500);
}
// Client B has its own local task
const taskB = `TaskFromB-${uniqueId}`;
await clientB.workView.addTask(taskB);
// Trigger sync - should fail with DecryptError
await clientB.sync.triggerSync();
await clientB.page.waitForTimeout(2000);
// NOTE: Don't use triggerSync() because it waits for success check icon
// With wrong password, sync will fail and show error dialog instead
const syncBtn = clientB.page.locator('button.sync-btn');
await syncBtn.click();
// Verify error dialog opens
// Wait for the decrypt error dialog to appear
const decryptErrorDialog = clientB.page.locator('dialog-handle-decrypt-error');
await decryptErrorDialog.waitFor({ state: 'visible', timeout: 5000 });
await decryptErrorDialog.waitFor({ state: 'visible', timeout: 10000 });
// Look for "Overwrite" or "Force Upload" button
// The "Change & Overwrite Remote" button requires a password to be entered first
// Enter any password (it will become the new encryption password after overwrite)
const passwordInput = decryptErrorDialog.locator('input[type="password"]');
const newPassword = `overwrite-pass-${uniqueId}`;
await passwordInput.fill(newPassword);
await clientB.page.waitForTimeout(300); // Wait for form validation
// Look for "Change & Overwrite Remote" button (should be enabled now)
const overwriteBtn = decryptErrorDialog
.locator('button')
.filter({ hasText: /overwrite|force|upload/i })
.filter({ hasText: /overwrite.*remote|change.*overwrite/i })
.first();
const hasOverwriteBtn = await overwriteBtn.isVisible().catch(() => false);

View file

@ -54,6 +54,7 @@ describe('OperationLogEffects', () => {
'appendWithVectorClockUpdate',
'getCompactionCounter',
'clearVectorClockCache',
'getProtectedClientIds',
]);
mockLockService = jasmine.createSpyObj('LockService', ['request']);
mockVectorClockService = jasmine.createSpyObj('VectorClockService', [
@ -82,6 +83,7 @@ describe('OperationLogEffects', () => {
mockOpLogStore.append.and.returnValue(Promise.resolve(1));
mockOpLogStore.appendWithVectorClockUpdate.and.returnValue(Promise.resolve(1));
mockOpLogStore.getCompactionCounter.and.returnValue(Promise.resolve(0));
mockOpLogStore.getProtectedClientIds.and.returnValue(Promise.resolve([]));
mockVectorClockService.getCurrentVectorClock.and.returnValue(
Promise.resolve({ testClient: 5 }),
);

View file

@ -174,8 +174,14 @@ export class OperationLogEffects {
};
const currentClock = await this.vectorClockService.getCurrentVectorClock();
const incrementedClock = incrementVectorClock(currentClock, clientId);
// Load protected client IDs (e.g., from latest SYNC_IMPORT) to preserve during pruning
const protectedClientIds = await this.opLogStore.getProtectedClientIds();
// Limit vector clock size to prevent unbounded growth with many clients
const newClock = limitVectorClockSize(incrementedClock, clientId);
const newClock = limitVectorClockSize(
incrementedClock,
clientId,
protectedClientIds,
);
// For bulk operations, entityIds is provided but entityId may not be.
// The server requires entityId for non-full-state operations.