feat(offline): add sync dispatcher with backoff and conflict surfacing
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user