// src/lib/offline/sync.ts import { enqueueAction, getQueuedActions, markInFlight, markSynced, markConflict, markFailed } from "./queue"; export const BACKOFF_SCHEDULE = [1000, 4000, 16000, 60000, 300000] as const; export const MAX_ATTEMPTS = 5; export function calculateBackoff(attempt: number): number { const idx = Math.min(attempt, BACKOFF_SCHEDULE.length - 1); return BACKOFF_SCHEDULE[idx]; } export type Dispatcher = (actionName: string, payload: unknown, clientActionId: string) => Promise< | { ok: true } | { ok: false; conflict: string } | { ok: false; error: string } >; export interface SyncOptions { online: boolean; sleep: (ms: number) => Promise; now?: () => number; } export interface SyncResult { synced: number; conflicts: number; 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 | null = null; export async function syncPending(dispatcher: Dispatcher, options: SyncOptions): Promise { if (inFlightSync) return inFlightSync; inFlightSync = (async () => { const result: SyncResult = { synced: 0, conflicts: 0, failed: 0 }; if (!options.online) return result; const actions = await getQueuedActions(); await Promise.all( actions.map(async (action) => { if (action.status === "conflict" || action.status === "failed") { // Don't auto-retry conflicts/failures; require manual user intervention return; } if (action.attempts >= MAX_ATTEMPTS) { try { await markFailed(action.id, "max attempts reached"); } catch (markErr) { console.warn("[offline-sync] markFailed threw for", action.id, markErr); } result.failed += 1; return; } const backoff = calculateBackoff(action.attempts); if (action.lastAttemptAt) { const elapsed = (options.now?.() ?? Date.now()) - action.lastAttemptAt; if (elapsed < backoff) return; // not time yet } try { await markInFlight(action.id); 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; } }) ); return result; })(); try { return await inFlightSync; } finally { inFlightSync = null; } } /** * Wire this up to navigator.onLine + 'online' event in the client. * Returns an unsubscribe function. */ export function attachSyncListener(dispatcher: Dispatcher): () => void { if (typeof window === "undefined") return () => {}; const handler = () => { void syncPending(dispatcher, { online: navigator.onLine, sleep: (ms) => new Promise((r) => setTimeout(r, ms)) }); }; window.addEventListener("online", handler); // Also fire once on attach if online if (navigator.onLine) void handler(); return () => window.removeEventListener("online", handler); }