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