diff --git a/package.json b/package.json index de16dd2..fc629f2 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "drizzle-kit": "^0.30.6", "eslint": "^9", "eslint-config-next": "16.2.5", + "fake-indexeddb": "^6.2.5", "jsdom": "^25.0.1", "pg": "^8.20.0", "playwright": "^1.59.1", diff --git a/src/lib/offline/queue.test.ts b/src/lib/offline/queue.test.ts new file mode 100644 index 0000000..f5e6d0f --- /dev/null +++ b/src/lib/offline/queue.test.ts @@ -0,0 +1,64 @@ +// @vitest-environment jsdom +// src/lib/offline/queue.test.ts +import { describe, it, expect, beforeEach } from "vitest"; +import "fake-indexeddb/auto"; +import { enqueueAction, dequeueAction, getQueuedActions, markSynced, markConflict } from "./queue"; + +beforeEach(async () => { + // Clear the queue between tests + const { getOfflineDB } = await import("./db"); + const db = await getOfflineDB(); + await db.clear("queue"); +}); + +describe("offline queue", () => { + it("enqueueAction stores an action with a generated clientActionId", async () => { + const action = await enqueueAction("markOrderReady", { orderId: "abc" }); + expect(action.id).toMatch(/^[0-9a-f-]{36}$/i); + expect(action.actionName).toBe("markOrderReady"); + expect(action.status).toBe("pending"); + expect(action.attempts).toBe(0); + }); + + it("enqueueAction is idempotent on the same clientActionId", async () => { + const a = await enqueueAction("markOrderReady", { orderId: "abc" }, { id: "fixed-id" }); + const b = await enqueueAction("markOrderReady", { orderId: "abc" }, { id: "fixed-id" }); + expect(a.id).toBe(b.id); + expect(a.id).toBe("fixed-id"); + const all = await getQueuedActions(); + expect(all).toHaveLength(1); + }); + + it("getQueuedActions returns pending actions in createdAt order", async () => { + const a = await enqueueAction("a", {}, { id: "a" }); + // Small delay to ensure ordering + await new Promise((r) => setTimeout(r, 5)); + const b = await enqueueAction("b", {}, { id: "b" }); + const all = await getQueuedActions(); + expect(all.map((x) => x.id)).toEqual([a.id, b.id]); + }); + + it("dequeueAction removes the action and returns its data", async () => { + const a = await enqueueAction("markOrderReady", { orderId: "abc" }); + const removed = await dequeueAction(a.id); + expect(removed?.id).toBe(a.id); + const all = await getQueuedActions(); + expect(all).toHaveLength(0); + }); + + it("markSynced removes the action", async () => { + const a = await enqueueAction("markOrderReady", { orderId: "abc" }); + await markSynced(a.id); + const all = await getQueuedActions(); + expect(all).toHaveLength(0); + }); + + it("markConflict moves the action to conflict status", async () => { + const a = await enqueueAction("markOrderReady", { orderId: "abc" }); + await markConflict(a.id, "stale updated_at"); + const all = await getQueuedActions(); + expect(all).toHaveLength(1); + expect(all[0].status).toBe("conflict"); + expect(all[0].error).toBe("stale updated_at"); + }); +}); diff --git a/src/lib/offline/queue.ts b/src/lib/offline/queue.ts new file mode 100644 index 0000000..f4d3b6d --- /dev/null +++ b/src/lib/offline/queue.ts @@ -0,0 +1,83 @@ +// src/lib/offline/queue.ts +import { v4 as uuidv4 } from "uuid"; +import { getOfflineDB, type QueuedAction } from "./db"; + +export interface EnqueueOptions { + id?: string; // supply for idempotency +} + +export async function enqueueAction( + actionName: string, + payload: unknown, + options: EnqueueOptions = {} +): Promise { + const db = await getOfflineDB(); + const id = options.id ?? uuidv4(); + const existing = await db.get("queue", id); + if (existing) return existing; + const action: QueuedAction = { + id, + actionName, + payload, + createdAt: Date.now(), + attempts: 0, + lastAttemptAt: null, + status: "pending", + }; + await db.put("queue", action); + return action; +} + +export async function getQueuedActions(): Promise { + const db = await getOfflineDB(); + const all = await db.getAllFromIndex("queue", "by-created"); + return all.filter((a) => a.status !== "synced"); +} + +export async function dequeueAction(id: string): Promise { + const db = await getOfflineDB(); + const tx = db.transaction("queue", "readwrite"); + const action = await tx.store.get(id); + if (!action) { + await tx.done; + return null; + } + await tx.store.delete(id); + await tx.done; + return action; +} + +export async function markInFlight(id: string): Promise { + const db = await getOfflineDB(); + const action = await db.get("queue", id); + if (!action) return; + action.status = "in-flight"; + action.lastAttemptAt = Date.now(); + action.attempts += 1; + await db.put("queue", action); +} + +export async function markSynced(id: string): Promise { + const db = await getOfflineDB(); + await db.delete("queue", id); +} + +export async function markConflict(id: string, error: string): Promise { + const db = await getOfflineDB(); + const action = await db.get("queue", id); + if (!action) return; + action.status = "conflict"; + action.error = error; + await db.put("queue", action); +} + +export async function markFailed(id: string, error: string): Promise { + const db = await getOfflineDB(); + const action = await db.get("queue", id); + if (!action) return; + action.status = "failed"; + action.error = error; + action.attempts += 1; + action.lastAttemptAt = Date.now(); + await db.put("queue", action); +} diff --git a/vitest.config.ts b/vitest.config.ts index d9db233..ee1af17 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ "tests/unit/**/*.test.tsx", "src/lib/__tests__/**/*.test.ts", "src/lib/__tests__/**/*.test.tsx", + "src/lib/offline/**/*.test.ts", ], exclude: ["node_modules", ".next", "tests/e2e/**", "tests/login/**", "tests/smoke.spec.ts"], // Supabase REST, Auth.js v5, and Next.js `cookies()` / `headers()` are