diff --git a/src/lib/offline/sync.test.ts b/src/lib/offline/sync.test.ts new file mode 100644 index 0000000..7a4489b --- /dev/null +++ b/src/lib/offline/sync.test.ts @@ -0,0 +1,64 @@ +// @vitest-environment jsdom +// src/lib/offline/sync.test.ts +import { describe, it, expect, beforeEach, vi } from "vitest"; +import "fake-indexeddb/auto"; +import { enqueueAction } from "./queue"; +import { syncPending, calculateBackoff, BACKOFF_SCHEDULE, MAX_ATTEMPTS } from "./sync"; + +beforeEach(async () => { + const { getOfflineDB } = await import("./db"); + const db = await getOfflineDB(); + await db.clear("queue"); +}); + +describe("offline sync — backoff", () => { + it("BACKOFF_SCHEDULE returns 1s, 4s, 16s, 60s, 300s", () => { + expect(BACKOFF_SCHEDULE).toEqual([1000, 4000, 16000, 60000, 300000]); + }); + + it("MAX_ATTEMPTS is 5", () => { + expect(MAX_ATTEMPTS).toBe(5); + }); + + it("calculateBackoff(0) returns the first delay", () => { + expect(calculateBackoff(0)).toBe(1000); + }); + + it("calculateBackoff(4) returns the last delay", () => { + expect(calculateBackoff(4)).toBe(300000); + }); + + it("calculateBackoff(99) caps at MAX_ATTEMPTS (gives up)", () => { + expect(calculateBackoff(99)).toBe(300000); + }); +}); + +describe("offline sync — replay", () => { + it("syncPending posts each action and marks it synced on success", async () => { + const a = await enqueueAction("markOrderReady", { orderId: "abc" }); + const dispatcher = vi.fn().mockResolvedValue({ ok: true }); + const result = await syncPending(dispatcher, { online: true, sleep: () => Promise.resolve() }); + expect(dispatcher).toHaveBeenCalledWith("markOrderReady", { orderId: "abc" }, a.id); + expect(result.synced).toBe(1); + expect(result.conflicts).toBe(0); + expect(result.failed).toBe(0); + }); + + it("syncPending marks action as conflict when dispatcher returns conflict", async () => { + await enqueueAction("markOrderReady", { orderId: "abc" }); + const dispatcher = vi.fn().mockResolvedValue({ ok: false, conflict: "stale" }); + const result = await syncPending(dispatcher, { online: true, sleep: () => Promise.resolve() }); + expect(result.conflicts).toBe(1); + const { getQueuedActions } = await import("./queue"); + const all = await getQueuedActions(); + expect(all[0].status).toBe("conflict"); + }); + + it("syncPending skips when offline", async () => { + await enqueueAction("markOrderReady", { orderId: "abc" }); + const dispatcher = vi.fn(); + const result = await syncPending(dispatcher, { online: false, sleep: () => Promise.resolve() }); + expect(dispatcher).not.toHaveBeenCalled(); + expect(result.synced).toBe(0); + }); +}); diff --git a/src/lib/offline/sync.ts b/src/lib/offline/sync.ts new file mode 100644 index 0000000..387e422 --- /dev/null +++ b/src/lib/offline/sync.ts @@ -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; + now?: () => number; +} + +export interface SyncResult { + synced: number; + conflicts: number; + failed: number; +} + +export async function syncPending(dispatcher: Dispatcher, options: SyncOptions): Promise { + 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); +}