feat(offline): add IndexedDB mutation queue (enqueue, dequeue, mark synced/conflict)
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<QueuedAction> {
|
||||
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<QueuedAction[]> {
|
||||
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<QueuedAction | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const db = await getOfflineDB();
|
||||
await db.delete("queue", id);
|
||||
}
|
||||
|
||||
export async function markConflict(id: string, error: string): Promise<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user