feat(offline): add sync dispatcher with backoff and conflict surfacing

This commit is contained in:
Tyler
2026-06-17 14:07:47 -06:00
parent d3b8e4f7cd
commit d50ec4deda
2 changed files with 150 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
// 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<void>;
now?: () => number;
}
export interface SyncResult {
synced: number;
conflicts: number;
failed: number;
}
export async function syncPending(dispatcher: Dispatcher, options: SyncOptions): Promise<SyncResult> {
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 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);
result.failed += 1;
}
} catch (err) {
await markFailed(action.id, (err as Error).message);
result.failed += 1;
}
}
return result;
}
/**
* 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);
}