feat(dashboard/offline): generic idempotent outbox + ID remapping
CI/CD / CI · API (dotnet build + test) (push) Successful in 48s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 53s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 3m12s

Completes offline Phase 1 (frontend). Generalises the POS-orders-only queue into
a reusable write engine and fixes the two correctness bugs in the old path.

- offline-db: generic `outbox` store (DB v3, order_queue/kv preserved) with
  enqueue/list/update/remove + a persisted client→server id map.
- outbox.ts: drains in causal order — remaps local_* ids to server ids (blocking
  an op until its creator syncs), sends each op with its idempotency key, and
  classifies failures (offline → stop; 5xx / in-progress → retry; 4xx → poison
  after 5 attempts). remap/blocked logic validated against representative cases.
- client: apiPost/Put/Patch/Delete take an optional idempotencyKey →
  `Idempotency-Key` header; ApiClientError now carries HTTP status.
- submit-order: generates ONE idempotency key per submit, used for both the
  online attempt and the queued replay → server de-dups (no more double-create);
  offline create carries createsClientId so a later add-items remaps onto the
  real order instead of spawning a second order.
- use-offline-sync: drains the outbox, one-time migrates legacy order_queue
  items, invalidates queries after a successful sync.

tsc + production build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-02 18:19:29 +03:30
parent f4583f5169
commit 3b468b48d9
5 changed files with 479 additions and 122 deletions
+116 -1
View File
@@ -22,10 +22,13 @@ export type OfflineQueueItem = {
};
const DB_NAME = "meezi_pos_offline";
const DB_VERSION = 2;
const DB_VERSION = 3;
/** Legacy POS-orders-only queue (kept for one-time migration into the outbox). */
const STORE = "order_queue";
/** Generic key-value store (used to persist the React Query cache for offline reads). */
const KV_STORE = "kv";
/** Generic write outbox: any mutating request, replayed with idempotency + id remap. */
const OUTBOX_STORE = "outbox";
let _db: IDBDatabase | null = null;
@@ -41,6 +44,9 @@ function openDb(): Promise<IDBDatabase> {
if (!db.objectStoreNames.contains(KV_STORE)) {
db.createObjectStore(KV_STORE);
}
if (!db.objectStoreNames.contains(OUTBOX_STORE)) {
db.createObjectStore(OUTBOX_STORE, { keyPath: "id" });
}
};
req.onsuccess = () => {
_db = req.result;
@@ -161,3 +167,112 @@ export async function kvDelete(key: string): Promise<void> {
// ignore
}
}
// ─── Generic write outbox ──────────────────────────────────────────────────────
export type OutboxMethod = "POST" | "PUT" | "PATCH" | "DELETE";
export type OutboxOp = {
/** Local op id (primary key). */
id: string;
/** Stable Idempotency-Key sent on every send attempt for this op. */
idempotencyKey: string;
method: OutboxMethod;
/** Request URL; may embed a local id (local_*) to be remapped after its creator syncs. */
url: string;
body?: unknown;
/** Coarse entity kind, for conflict policy + UI grouping (e.g. "order", "menu_item"). */
entityType: string;
/** The local id this op creates, if any — enables remapping later ops that reference it. */
createsClientId?: string;
/** Dotted path to the new server id in the response data (default "id"). */
idField?: string;
createdAt: number;
attempts: number;
status: "pending" | "failed";
lastError?: string;
};
export async function enqueueOutboxOp(
op: Omit<OutboxOp, "attempts" | "status">
): Promise<void> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(OUTBOX_STORE, "readwrite");
tx.objectStore(OUTBOX_STORE).put({ ...op, attempts: 0, status: "pending" });
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
/** All queued ops, oldest first (insertion / causal order). */
export async function getOutboxOps(): Promise<OutboxOp[]> {
try {
const db = await openDb();
const ops = await new Promise<OutboxOp[]>((resolve, reject) => {
const tx = db.transaction(OUTBOX_STORE, "readonly");
const req = tx.objectStore(OUTBOX_STORE).getAll();
req.onsuccess = () => resolve(req.result as OutboxOp[]);
req.onerror = () => reject(req.error);
});
return ops.sort((a, b) => a.createdAt - b.createdAt);
} catch {
return [];
}
}
export async function getOutboxCount(): Promise<number> {
try {
const db = await openDb();
return await new Promise<number>((resolve, reject) => {
const tx = db.transaction(OUTBOX_STORE, "readonly");
const req = tx.objectStore(OUTBOX_STORE).count();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
} catch {
return 0;
}
}
export async function removeOutboxOp(id: string): Promise<void> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(OUTBOX_STORE, "readwrite");
tx.objectStore(OUTBOX_STORE).delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export async function updateOutboxOp(
id: string,
patch: Partial<Pick<OutboxOp, "status" | "attempts" | "lastError">>
): Promise<void> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(OUTBOX_STORE, "readwrite");
const store = tx.objectStore(OUTBOX_STORE);
const getReq = store.get(id);
getReq.onsuccess = () => {
const op = getReq.result as OutboxOp | undefined;
if (op) store.put({ ...op, ...patch });
};
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
// ─── client→server id map (persisted across reloads) ───────────────────────────
const ID_MAP_KEY = "outbox_id_map";
export async function getIdMap(): Promise<Record<string, string>> {
return (await kvGet<Record<string, string>>(ID_MAP_KEY)) ?? {};
}
export async function setIdMapEntry(clientId: string, serverId: string): Promise<void> {
const map = await getIdMap();
map[clientId] = serverId;
await kvSet(ID_MAP_KEY, map);
}
+167
View File
@@ -0,0 +1,167 @@
/**
* Generic offline write engine.
*
* Every offline write is recorded as an {@link OutboxOp} carrying a stable
* idempotency key. On reconnect the outbox is drained in causal (insertion)
* order:
* - local ids (local_*) created by earlier ops are remapped to their real
* server ids before an op that references them is sent;
* - each op is sent with its idempotency key, so a replay after a lost response
* is de-duplicated by the server instead of creating a duplicate;
* - failures are classified: offline → stop; server 5xx / in-progress →
* retry next pass; client 4xx → count an attempt and poison after MAX.
*/
import { isAxiosError } from "axios";
import {
apiDelete,
apiPatch,
apiPost,
apiPut,
ApiClientError,
type WriteOptions,
} from "@/lib/api/client";
import {
getIdMap,
getOutboxOps,
removeOutboxOp,
setIdMapEntry,
updateOutboxOp,
type OutboxOp,
} from "@/lib/offline/offline-db";
const MAX_ATTEMPTS = 5;
/** Matches local placeholder ids like `local_1717…_a1b2c3`. */
const LOCAL_ID_RE = /local_[A-Za-z0-9]+(?:_[A-Za-z0-9]+)*/g;
function getByPath(obj: unknown, path: string): string | undefined {
let cur: unknown = obj;
for (const part of path.split(".")) {
if (cur == null || typeof cur !== "object") return undefined;
cur = (cur as Record<string, unknown>)[part];
}
return typeof cur === "string" ? cur : undefined;
}
/**
* Replace known local ids in the op's url/body with their server ids. Returns
* `blocked: true` if it still references an unresolved local id (its creator
* hasn't synced yet) other than the id this op itself creates.
*/
export function remapReferences(
op: Pick<OutboxOp, "url" | "body" | "createsClientId">,
idMap: Record<string, string>
): { url: string; body: unknown; blocked: boolean } {
let url = op.url;
let bodyStr = op.body !== undefined ? JSON.stringify(op.body) : "";
for (const [clientId, serverId] of Object.entries(idMap)) {
if (url.includes(clientId)) url = url.split(clientId).join(serverId);
if (bodyStr && bodyStr.includes(clientId)) bodyStr = bodyStr.split(clientId).join(serverId);
}
const remaining = `${url} ${bodyStr}`.match(LOCAL_ID_RE) ?? [];
const unresolved = remaining.filter((id) => id !== op.createsClientId);
return {
url,
body: bodyStr !== "" ? JSON.parse(bodyStr) : op.body,
blocked: unresolved.length > 0,
};
}
async function sendOp(op: OutboxOp, url: string, body: unknown): Promise<unknown> {
const opts: WriteOptions = { idempotencyKey: op.idempotencyKey };
switch (op.method) {
case "POST":
return apiPost(url, body, opts);
case "PUT":
return apiPut(url, body, opts);
case "PATCH":
return apiPatch(url, body, opts);
case "DELETE":
await apiDelete(url, opts);
return undefined;
}
}
type Disposition = "offline" | "transient" | "permanent";
function classify(err: unknown): Disposition {
if (err instanceof ApiClientError) {
if (err.code === "IDEMPOTENCY_IN_PROGRESS") return "transient"; // another tab/pass owns it
if (err.status !== undefined && err.status >= 500) return "transient";
return "permanent"; // validation / 4xx — retrying the same payload won't help
}
if (isAxiosError(err)) {
if (!err.response) return "offline"; // network down
if (err.response.status >= 500) return "transient";
return "permanent";
}
return "permanent";
}
function errMessage(err: unknown): string {
if (err instanceof Error) return err.message;
return String(err);
}
export type DrainResult = { sent: number; remaining: number; ran: boolean };
let draining = false;
/** Drain the outbox once, in causal order. Never throws. */
export async function drainOutbox(): Promise<DrainResult> {
const isOffline = typeof navigator !== "undefined" && !navigator.onLine;
if (draining || isOffline) {
return { sent: 0, remaining: (await getOutboxOps()).length, ran: false };
}
draining = true;
let sent = 0;
try {
const idMap = await getIdMap();
const ops = await getOutboxOps();
for (const op of ops) {
if (op.status === "failed" && op.attempts >= MAX_ATTEMPTS) continue; // poisoned
const { url, body, blocked } = remapReferences(op, idMap);
if (blocked) continue; // a dependency hasn't synced yet; revisit next pass
try {
const result = await sendOp(op, url, body);
if (op.createsClientId) {
const serverId = getByPath(result, op.idField ?? "id");
if (serverId) {
idMap[op.createsClientId] = serverId;
await setIdMapEntry(op.createsClientId, serverId);
}
}
await removeOutboxOp(op.id);
sent++;
} catch (err) {
const disposition = classify(err);
if (disposition === "offline") break; // stop the whole pass; resume when online
if (disposition === "transient") {
await updateOutboxOp(op.id, { lastError: errMessage(err) }); // retry, don't burn an attempt
continue;
}
await updateOutboxOp(op.id, {
status: "failed",
attempts: op.attempts + 1,
lastError: errMessage(err),
});
}
}
} finally {
draining = false;
}
return { sent, remaining: (await getOutboxOps()).length, ran: true };
}
/** Ops that exhausted their retries and need user attention. */
export async function getPoisonedOps(): Promise<OutboxOp[]> {
const ops = await getOutboxOps();
return ops.filter((o) => o.status === "failed" && o.attempts >= MAX_ATTEMPTS);
}
@@ -1,87 +1,117 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
import {
enqueueOutboxOp,
getAllQueueItems,
getOutboxCount,
getQueueCount,
removeQueueItem,
markQueueItemFailed,
} from "@/lib/offline/offline-db";
import { apiPost } from "@/lib/api/client";
import { drainOutbox } from "@/lib/offline/outbox";
function newId(prefix: string): string {
if (prefix === "idem" && typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
}
/**
* Processes one queued item and returns whether it succeeded.
* One-time migration of any items left in the legacy POS `order_queue` into the
* generic outbox, so orders queued before this release still sync. Best-effort.
*/
async function processItem(item: Awaited<ReturnType<typeof getAllQueueItems>>[number]): Promise<boolean> {
async function migrateLegacyQueue(): Promise<void> {
let legacy: Awaited<ReturnType<typeof getAllQueueItems>> = [];
try {
if (item.type === "create_order") {
const { cafeId, body } = item.payload as { cafeId: string; body: unknown };
await apiPost(`/api/cafes/${cafeId}/orders`, body as Record<string, unknown>);
} else if (item.type === "add_items") {
const { cafeId, orderId, body } = item.payload as {
cafeId: string;
orderId: string;
body: unknown;
};
await apiPost(
`/api/cafes/${cafeId}/orders/${orderId}/items`,
body as Record<string, unknown>
);
}
return true;
legacy = await getAllQueueItems();
} catch {
return false;
return;
}
for (const item of legacy) {
try {
if (item.type === "create_order") {
const { cafeId, body } = item.payload as { cafeId: string; body: unknown };
await enqueueOutboxOp({
id: newId("op"),
idempotencyKey: newId("idem"),
method: "POST",
url: `/api/cafes/${cafeId}/orders`,
body,
entityType: "order",
idField: "id",
createdAt: Date.parse(item.createdAt) || Date.now(),
});
} else if (item.type === "add_items") {
const { cafeId, orderId, body } = item.payload as {
cafeId: string;
orderId: string;
body: unknown;
};
await enqueueOutboxOp({
id: newId("op"),
idempotencyKey: newId("idem"),
method: "POST",
url: `/api/cafes/${cafeId}/orders/${orderId}/items`,
body,
entityType: "order_items",
createdAt: Date.parse(item.createdAt) || Date.now(),
});
}
await removeQueueItem(item.id);
} catch {
// leave the legacy item in place; we'll try again next mount
}
}
}
/**
* Call this hook once in the app shell to:
* - Load initial queue count from IndexedDB on mount
* - Listen to online/offline events
* - Auto-sync when back online or tab becomes visible
* Mount once in the app shell to:
* - migrate any legacy queued orders into the outbox,
* - keep the pending-count badge and online flag in sync,
* - drain the outbox when back online or the tab regains focus,
* - refresh server data once writes have synced.
*/
export function useOfflineSync() {
const { setQueueCount, setSyncing, setOnline } = useSyncQueueStore();
const queryClient = useQueryClient();
const syncLock = useRef(false);
const refreshCount = useCallback(async () => {
const n = await getQueueCount();
const n = (await getOutboxCount()) + (await getQueueCount());
setQueueCount(n);
return n;
}, [setQueueCount]);
const syncQueue = useCallback(async () => {
if (syncLock.current) return;
if (!navigator.onLine) return;
const count = await refreshCount();
if (count === 0) return;
if (typeof navigator !== "undefined" && !navigator.onLine) return;
syncLock.current = true;
setSyncing(true);
try {
const items = await getAllQueueItems();
for (const item of items) {
if (item.status === "failed" && item.retries >= 3) continue; // give up after 3
const ok = await processItem(item);
if (ok) {
await removeQueueItem(item.id);
} else {
await markQueueItemFailed(item.id);
}
const result = await drainOutbox();
if (result.sent > 0) {
// Replace optimistic local data with the authoritative server state.
await queryClient.invalidateQueries();
}
} finally {
syncLock.current = false;
setSyncing(false);
await refreshCount();
}
}, [refreshCount, setSyncing]);
}, [refreshCount, setSyncing, queryClient]);
useEffect(() => {
// Load initial count
void refreshCount();
void (async () => {
await migrateLegacyQueue();
await refreshCount();
// Drain anything pending if we mounted already online.
if (typeof navigator === "undefined" || navigator.onLine) void syncQueue();
})();
// Track online state
const handleOnline = () => {
setOnline(true);
void syncQueue();
@@ -92,7 +122,6 @@ export function useOfflineSync() {
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
// Sync when tab regains focus
const handleVisibility = () => {
if (document.visibilityState === "visible" && navigator.onLine) {
void syncQueue();