fix(offline): harden sync catch + add queue/sync test coverage + atomic transactions
This commit is contained in:
+53
-33
@@ -27,47 +27,67 @@ export interface SyncResult {
|
||||
failed: number;
|
||||
}
|
||||
|
||||
// Re-entrancy guard: concurrent `online` events shouldn't kick off parallel
|
||||
// sync passes that race on the same `markInFlight`/`markSynced` records.
|
||||
let inFlightSync: Promise<SyncResult> | null = null;
|
||||
|
||||
export async function syncPending(dispatcher: Dispatcher, options: SyncOptions): Promise<SyncResult> {
|
||||
const result: SyncResult = { synced: 0, conflicts: 0, failed: 0 };
|
||||
if (!options.online) return result;
|
||||
if (inFlightSync) return inFlightSync;
|
||||
inFlightSync = (async () => {
|
||||
const result: SyncResult = { synced: 0, conflicts: 0, failed: 0 };
|
||||
if (!options.online) return result;
|
||||
|
||||
const actions = await getQueuedActions();
|
||||
for (const action of actions) {
|
||||
if (action.status === "conflict" || action.status === "failed") {
|
||||
// Don't auto-retry conflicts/failures; require manual user intervention
|
||||
continue;
|
||||
}
|
||||
if (action.attempts >= MAX_ATTEMPTS) {
|
||||
await markFailed(action.id, "max attempts reached");
|
||||
result.failed += 1;
|
||||
continue;
|
||||
}
|
||||
const actions = await getQueuedActions();
|
||||
for (const action of actions) {
|
||||
if (action.status === "conflict" || action.status === "failed") {
|
||||
// Don't auto-retry conflicts/failures; require manual user intervention
|
||||
continue;
|
||||
}
|
||||
if (action.attempts >= MAX_ATTEMPTS) {
|
||||
await markFailed(action.id, "max attempts reached");
|
||||
result.failed += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const backoff = calculateBackoff(action.attempts);
|
||||
if (action.lastAttemptAt) {
|
||||
const elapsed = (options.now?.() ?? Date.now()) - action.lastAttemptAt;
|
||||
if (elapsed < backoff) continue; // not time yet
|
||||
}
|
||||
const backoff = calculateBackoff(action.attempts);
|
||||
if (action.lastAttemptAt) {
|
||||
const elapsed = (options.now?.() ?? Date.now()) - action.lastAttemptAt;
|
||||
if (elapsed < backoff) continue; // not time yet
|
||||
}
|
||||
|
||||
await markInFlight(action.id);
|
||||
try {
|
||||
const dispatchResult = await dispatcher(action.actionName, action.payload, action.id);
|
||||
if (dispatchResult.ok) {
|
||||
await markSynced(action.id);
|
||||
result.synced += 1;
|
||||
} else if ("conflict" in dispatchResult) {
|
||||
await markConflict(action.id, dispatchResult.conflict);
|
||||
result.conflicts += 1;
|
||||
} else {
|
||||
await markFailed(action.id, dispatchResult.error);
|
||||
await markInFlight(action.id);
|
||||
try {
|
||||
const dispatchResult = await dispatcher(action.actionName, action.payload, action.id);
|
||||
if (dispatchResult.ok) {
|
||||
await markSynced(action.id);
|
||||
result.synced += 1;
|
||||
} else if ("conflict" in dispatchResult) {
|
||||
await markConflict(action.id, dispatchResult.conflict);
|
||||
result.conflicts += 1;
|
||||
} else if ("error" in dispatchResult) {
|
||||
await markFailed(action.id, dispatchResult.error);
|
||||
result.failed += 1;
|
||||
}
|
||||
} catch (err) {
|
||||
// A single poisoned record (IDB closed, quota, etc.) must not abort
|
||||
// the whole sync pass. Swallow the secondary failure with a warn so
|
||||
// it stays observable in dev.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
try {
|
||||
await markFailed(action.id, message);
|
||||
} catch (innerErr) {
|
||||
console.warn("[offline-sync] markFailed threw for", action.id, innerErr);
|
||||
}
|
||||
result.failed += 1;
|
||||
}
|
||||
} catch (err) {
|
||||
await markFailed(action.id, (err as Error).message);
|
||||
result.failed += 1;
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
try {
|
||||
return await inFlightSync;
|
||||
} finally {
|
||||
inFlightSync = null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user