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);
}