/** * 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 { 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 ): Promise { 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 { 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 { 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 { 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 { 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 { try { const db = await openDb(); await new Promise((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(key: string): Promise { try { const db = await openDb(); return await new Promise((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 { try { const db = await openDb(); await new Promise((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 ): Promise { 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 { try { const db = await openDb(); const ops = await new Promise((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 { try { const db = await openDb(); return await new Promise((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 { 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> ): Promise { 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> { return (await kvGet>(ID_MAP_KEY)) ?? {}; } export async function setIdMapEntry(clientId: string, serverId: string): Promise { const map = await getIdMap(); map[clientId] = serverId; await kvSet(ID_MAP_KEY, map); }