Files
meezi/web/dashboard/src/lib/offline/offline-db.ts
T
soroush.asadi 3b468b48d9
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
feat(dashboard/offline): generic idempotent outbox + ID remapping
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>
2026-06-02 18:19:29 +03:30

279 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* IndexedDB wrapper for the POS offline order queue.
* All reads/writes happen in a single "order_queue" object store.
*/
export type OfflineQueueItem = {
/** Local UUID primary key */
id: string;
/** "create_order" or "add_items_to_order" */
type: "create_order" | "add_items";
cafeId: string;
/**
* For add_items: the real server order ID.
* For create_order: null (no server ID yet).
*/
targetOrderId: string | null;
/** Raw body to POST/PUT */
payload: unknown;
createdAt: string;
retries: number;
status: "pending" | "failed";
};
const DB_NAME = "meezi_pos_offline";
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;
function openDb(): Promise<IDBDatabase> {
if (_db) return Promise.resolve(_db);
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = (e) => {
const db = (e.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE)) {
db.createObjectStore(STORE, { keyPath: "id" });
}
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;
resolve(_db);
};
req.onerror = () => reject(req.error);
});
}
export async function enqueueOfflineItem(
item: Omit<OfflineQueueItem, "retries" | "status">
): Promise<void> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
tx.objectStore(STORE).put({ ...item, retries: 0, status: "pending" });
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export async function getQueueCount(): Promise<number> {
try {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readonly");
const req = tx.objectStore(STORE).count();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
} catch {
return 0;
}
}
export async function getAllQueueItems(): Promise<OfflineQueueItem[]> {
try {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readonly");
const req = tx.objectStore(STORE).getAll();
req.onsuccess = () => resolve(req.result as OfflineQueueItem[]);
req.onerror = () => reject(req.error);
});
} catch {
return [];
}
}
export async function removeQueueItem(id: string): Promise<void> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
tx.objectStore(STORE).delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export async function markQueueItemFailed(id: string): Promise<void> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
const store = tx.objectStore(STORE);
const getReq = store.get(id);
getReq.onsuccess = () => {
const item = getReq.result as OfflineQueueItem;
if (item) store.put({ ...item, status: "failed", retries: item.retries + 1 });
};
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
// ─── Generic key-value store (React Query cache persistence) ───────────────────
/** Store an arbitrary JSON-serializable value under a key. Never throws. */
export async function kvSet(key: string, value: unknown): Promise<void> {
try {
const db = await openDb();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(KV_STORE, "readwrite");
tx.objectStore(KV_STORE).put(value, key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch {
// IndexedDB unavailable / quota exceeded / blocked — degrade silently.
}
}
/** Read a value previously stored with {@link kvSet}. Returns undefined on any failure. */
export async function kvGet<T>(key: string): Promise<T | undefined> {
try {
const db = await openDb();
return await new Promise<T | undefined>((resolve, reject) => {
const tx = db.transaction(KV_STORE, "readonly");
const req = tx.objectStore(KV_STORE).get(key);
req.onsuccess = () => resolve(req.result as T | undefined);
req.onerror = () => reject(req.error);
});
} catch {
return undefined;
}
}
/** Remove a persisted value (e.g. on logout, to avoid leaking another user's cache). */
export async function kvDelete(key: string): Promise<void> {
try {
const db = await openDb();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(KV_STORE, "readwrite");
tx.objectStore(KV_STORE).delete(key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch {
// 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);
}