fix(offline): harden sync catch + add queue/sync test coverage + atomic transactions

This commit is contained in:
Tyler
2026-06-17 14:16:20 -06:00
parent d50ec4deda
commit cc5c0e49be
4 changed files with 234 additions and 46 deletions
+53 -33
View File
@@ -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;
}
/**