feat(dashboard/offline): persist React Query cache for offline reads

First slice of offline-first (Phase 1). Makes every dashboard area *viewable*
offline with last-synced data, instead of empty lists on an offline reload
(previously only next-pwa's 10-min API cache survived).

- offline-db: add a generic `kv` IndexedDB store (DB v2, preserves order_queue)
  with kvGet/kvSet/kvDelete; all degrade silently on quota/unavailable.
- query-persister: debounced snapshot of the React Query cache via
  dehydrate/hydrate (no new dependency). Restore is guarded by a schema buster,
  24h max-age, and a café scope so one tenant never hydrates another's data.
- providers: gcTime 24h so hydrated data isn't GC'd; restore on mount + persist
  on cache changes, re-scoped when the signed-in café changes.

No write-path changes; the existing POS order queue is untouched. Next in
Phase 1: generalize that queue into an idempotent outbox with client→server
ID remapping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-02 17:41:15 +03:30
parent bb0be19dac
commit 132f0921e0
3 changed files with 151 additions and 3 deletions
+53 -1
View File
@@ -22,8 +22,10 @@ export type OfflineQueueItem = {
};
const DB_NAME = "meezi_pos_offline";
const DB_VERSION = 1;
const DB_VERSION = 2;
const STORE = "order_queue";
/** Generic key-value store (used to persist the React Query cache for offline reads). */
const KV_STORE = "kv";
let _db: IDBDatabase | null = null;
@@ -36,6 +38,9 @@ function openDb(): Promise<IDBDatabase> {
if (!db.objectStoreNames.contains(STORE)) {
db.createObjectStore(STORE, { keyPath: "id" });
}
if (!db.objectStoreNames.contains(KV_STORE)) {
db.createObjectStore(KV_STORE);
}
};
req.onsuccess = () => {
_db = req.result;
@@ -109,3 +114,50 @@ export async function markQueueItemFailed(id: string): Promise<void> {
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
}
}