Tweak browser extension sync queue

This commit is contained in:
Leendert de Borst
2026-03-01 19:58:09 +01:00
parent ce52f8a4f7
commit 608da07bc4
3 changed files with 114 additions and 25 deletions

View File

@@ -44,6 +44,13 @@ import { t } from '@/i18n/StandaloneI18n';
let cachedSqliteClient: SqliteClient | null = null;
let cachedVaultBlob: string | null = null;
/**
* Global sync queue state.
* Prevents multiple simultaneous sync operations and ensures pending changes are synced.
*/
let isSyncInProgress = false;
let hasPendingSync = false;
/**
* Check if the user is logged in and if the vault is locked, and also check for pending migrations.
*/
@@ -671,7 +678,15 @@ export async function handleUploadVault(
};
} catch (error) {
console.error('Failed to upload vault:', error);
// E-801: Upload failed
// Check if error is UPLOAD_OUTDATED (E-802) - server has newer vault
const errorMessage = error instanceof Error ? error.message : '';
if (errorMessage.includes('E-802')) {
// Return status 2 (Outdated) so caller can handle merge
return { success: false, status: 2, error: errorMessage };
}
// E-801: Upload failed for other reasons
return { success: false, error: formatErrorWithCode(await t('common.errors.unknownError'), AppErrorCode.UPLOAD_FAILED) };
}
}
@@ -956,6 +971,7 @@ export async function handleGetSyncState(): Promise<{
isDirty: boolean;
mutationSequence: number;
serverRevision: number;
isSyncInProgress: boolean;
}> {
const [isDirty, mutationSequence, serverRevision] = await Promise.all([
storage.getItem('local:isDirty') as Promise<boolean | null>,
@@ -966,7 +982,8 @@ export async function handleGetSyncState(): Promise<{
return {
isDirty: isDirty ?? false,
mutationSequence: mutationSequence ?? 0,
serverRevision: serverRevision ?? 0
serverRevision: serverRevision ?? 0,
isSyncInProgress
};
}
@@ -1094,19 +1111,20 @@ export async function handleCheckSyncStatus(): Promise<SyncStatusCheckResult> {
/**
* Full vault sync orchestration that runs entirely in background context.
* This ensures sync completes even if popup closes mid-operation.
*
* Sync logic:
* - If server has newer vault AND we have local changes (isDirty) → merge then upload
* - If server has newer vault AND no local changes → just download
* - If server has same revision AND we have local changes → upload
* - If offline → keep local changes, sync later
*
* Race detection:
* - Upload captures mutationSequence at start
* - After upload, only clears isDirty if sequence unchanged
* - If sequence changed during upload, stays dirty for next sync
*/
export async function handleFullVaultSync(): Promise<FullVaultSyncResult> {
// Check if sync is already in progress
if (isSyncInProgress) {
// Mark that we need to sync again after current sync completes
hasPendingSync = true;
console.info('[VaultSync] Sync already in progress, queued for retry after completion');
return { success: true, hasNewVault: false, wasOffline: false, upgradeRequired: false, requiresLogout: false };
}
// Mark sync as in progress
isSyncInProgress = true;
hasPendingSync = false;
const webApi = new WebApiService();
try {
@@ -1380,6 +1398,23 @@ export async function handleFullVaultSync(): Promise<FullVaultSyncResult> {
? baseMessage
: formatErrorWithCode(baseMessage, AppErrorCode.UNKNOWN_ERROR);
return { success: false, hasNewVault: false, wasOffline: false, upgradeRequired: false, requiresLogout: false, error: errorMessage };
} finally {
// Reset sync in progress flag
isSyncInProgress = false;
/*
* Check if another sync is needed (mutations happened during this sync).
* Trigger follow-up sync asynchronously (don't await to avoid blocking).
* Popup will poll isDirty flag to detect when background sync completes.
*/
if (hasPendingSync) {
console.info('[VaultSync] Pending mutations detected, triggering follow-up sync');
hasPendingSync = false;
handleFullVaultSync().catch(err => {
console.error('[VaultSync] Follow-up sync failed:', err);
});
}
}
}

View File

@@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useRef } from 'react';
import { sendMessage } from 'webext-bridge/popup';
import type { FullVaultSyncResult } from '@/entrypoints/background/VaultMessageHandler';
@@ -26,6 +26,7 @@ export function useVaultMutate(): {
executeVaultMutationAsync: (operation: () => Promise<void>) => Promise<void>;
} {
const dbContext = useDb();
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
/**
* Execute the provided operation and save locally.
@@ -53,13 +54,66 @@ export function useVaultMutate(): {
await dbContext.refreshSyncState();
}, [dbContext]);
/**
* Start polling to detect when background sync completes.
* Polls isDirty flag AND background sync state every 500ms.
* Only clears indicator when vault is clean AND background has no sync in progress.
*/
const startPollingForCompletion = useCallback((): void => {
/* Clear any existing poll interval */
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
console.info('[VaultMutate] Starting to poll for sync completion');
pollIntervalRef.current = setInterval(async () => {
try {
/*
* Get sync state from background - includes both isDirty flag
* and isSyncInProgress status from the background script
*/
const syncState = await sendMessage('GET_SYNC_STATE', {}, 'background') as {
isDirty: boolean;
isSyncInProgress: boolean;
mutationSequence: number;
serverRevision: number;
};
/*
* Only clear uploading indicator when:
* 1. Vault is not dirty (no pending changes)
* 2. Background has no sync in progress (no queued syncs running)
*
* This prevents clearing the indicator between queued syncs.
*/
if (!syncState.isDirty && !syncState.isSyncInProgress) {
console.info('[VaultMutate] Sync completed (isDirty=false, isSyncInProgress=false), clearing uploading indicator');
dbContext.setIsUploading(false);
dbContext.setIsSyncing(false);
/* Refresh React state to match storage */
await dbContext.refreshSyncState();
/* Stop polling */
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
}
} catch (error) {
console.error('[VaultMutate] Error polling sync state:', error);
}
}, 500); /* Poll every 500ms */
}, [dbContext]);
/**
* Trigger a sync in the background script.
* This is fire-and-forget - the sync runs entirely in the background context
* and continues even if the popup closes.
*
* If a merge happened during sync (hasNewVault=true), reload the database
* so the popup shows the merged data.
* Always polls to detect completion since background sync may queue additional
* syncs that we cannot directly observe from the popup context.
*/
const triggerBackgroundSync = useCallback((): void => {
dbContext.setIsUploading(true);
@@ -68,20 +122,21 @@ export function useVaultMutate(): {
* Fire-and-forget: send message to background without awaiting.
* The background script will handle the full sync orchestration
* and will re-sync if mutations happened during the sync.
*
* After sending message, we start polling to detect completion.
*/
sendMessage('FULL_VAULT_SYNC', {}, 'background').then(async (result: FullVaultSyncResult) => {
// If a merge happened, reload the database to show merged data
if (result.hasNewVault) {
void sendMessage('FULL_VAULT_SYNC', {}, 'background').then(async (result) => {
const syncResult = result as FullVaultSyncResult;
if (syncResult.hasNewVault) {
await dbContext.loadStoredDatabase();
}
// Refresh sync state if popup is still open
await dbContext.refreshSyncState();
}).catch((error) => {
console.error('Background sync error:', error);
}).finally(() => {
dbContext.setIsUploading(false);
});
}, [dbContext]);
// Start polling for completion
startPollingForCompletion();
}, [dbContext, startPollingForCompletion]);
/**
* Execute a vault mutation asynchronously: save locally immediately, then

View File

@@ -124,7 +124,6 @@ export const useVaultSync = (): { syncVault: (options?: VaultSyncOptions) => Pro
} finally {
// Always clear syncing/uploading states when done
dbContext.setIsSyncing(false);
dbContext.setIsUploading(false);
}
}, [app, dbContext, t]);