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
+64
View File
@@ -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);
});
});
+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);
}