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:
@@ -1,20 +1,46 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ConfirmProvider } from "@/components/providers/confirm-provider";
|
import { ConfirmProvider } from "@/components/providers/confirm-provider";
|
||||||
import { MeeziToaster } from "@/components/ui/meezi-toaster";
|
import { MeeziToaster } from "@/components/ui/meezi-toaster";
|
||||||
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||||
|
import { restoreQueryCache, startPersisting } from "@/lib/offline/query-persister";
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
const [queryClient] = useState(
|
const [queryClient] = useState(
|
||||||
() =>
|
() =>
|
||||||
new QueryClient({
|
new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: { staleTime: 30_000, retry: 1 },
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
retry: 1,
|
||||||
|
// Keep data in memory long enough to back offline reads; it is also
|
||||||
|
// persisted to IndexedDB by the persister below.
|
||||||
|
gcTime: 24 * 60 * 60 * 1000,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Persist the query cache to IndexedDB so the dashboard is viewable offline.
|
||||||
|
// Scoped to the current café so a different tenant never hydrates this data.
|
||||||
|
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||||
|
useEffect(() => {
|
||||||
|
const scope = cafeId ?? "anon";
|
||||||
|
let active = true;
|
||||||
|
let stop: () => void = () => {};
|
||||||
|
void (async () => {
|
||||||
|
await restoreQueryCache(queryClient, scope);
|
||||||
|
if (!active) return;
|
||||||
|
stop = startPersisting(queryClient, scope);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, [queryClient, cafeId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ConfirmProvider>
|
<ConfirmProvider>
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ export type OfflineQueueItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DB_NAME = "meezi_pos_offline";
|
const DB_NAME = "meezi_pos_offline";
|
||||||
const DB_VERSION = 1;
|
const DB_VERSION = 2;
|
||||||
const STORE = "order_queue";
|
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;
|
let _db: IDBDatabase | null = null;
|
||||||
|
|
||||||
@@ -36,6 +38,9 @@ function openDb(): Promise<IDBDatabase> {
|
|||||||
if (!db.objectStoreNames.contains(STORE)) {
|
if (!db.objectStoreNames.contains(STORE)) {
|
||||||
db.createObjectStore(STORE, { keyPath: "id" });
|
db.createObjectStore(STORE, { keyPath: "id" });
|
||||||
}
|
}
|
||||||
|
if (!db.objectStoreNames.contains(KV_STORE)) {
|
||||||
|
db.createObjectStore(KV_STORE);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
req.onsuccess = () => {
|
req.onsuccess = () => {
|
||||||
_db = req.result;
|
_db = req.result;
|
||||||
@@ -109,3 +114,50 @@ export async function markQueueItemFailed(id: string): Promise<void> {
|
|||||||
tx.onerror = () => reject(tx.error);
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Persists the React Query cache to IndexedDB so the dashboard is *viewable*
|
||||||
|
* offline (last-synced data) and survives a reload with no connection.
|
||||||
|
*
|
||||||
|
* Uses `dehydrate`/`hydrate` from @tanstack/react-query directly — no extra
|
||||||
|
* dependency. Writes are debounced; reads are guarded by a schema buster, a
|
||||||
|
* max-age, and a tenant scope so one café never hydrates another's data.
|
||||||
|
*/
|
||||||
|
import { dehydrate, hydrate, type QueryClient } from "@tanstack/react-query";
|
||||||
|
import { kvGet, kvSet } from "@/lib/offline/offline-db";
|
||||||
|
|
||||||
|
const CACHE_KEY = "rq-cache";
|
||||||
|
/** Bump when cached shapes change so stale persisted data is dropped on deploy. */
|
||||||
|
const BUSTER = "v1";
|
||||||
|
const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h
|
||||||
|
const SAVE_DEBOUNCE_MS = 1000;
|
||||||
|
|
||||||
|
type PersistedCache = {
|
||||||
|
buster: string;
|
||||||
|
timestamp: number;
|
||||||
|
/** Tenant/user scope this cache belongs to (cafeId, or "anon"). */
|
||||||
|
scope: string;
|
||||||
|
state: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrate the query cache from IndexedDB if a valid snapshot exists for this
|
||||||
|
* scope. Safe to call before or after queries mount.
|
||||||
|
*/
|
||||||
|
export async function restoreQueryCache(qc: QueryClient, scope: string): Promise<void> {
|
||||||
|
const saved = await kvGet<PersistedCache>(CACHE_KEY);
|
||||||
|
if (!saved) return;
|
||||||
|
if (saved.buster !== BUSTER) return; // schema changed
|
||||||
|
if (saved.scope !== scope) return; // different tenant/user — do not leak
|
||||||
|
if (Date.now() - saved.timestamp > MAX_AGE_MS) return; // too old
|
||||||
|
try {
|
||||||
|
hydrate(qc, saved.state as never);
|
||||||
|
} catch {
|
||||||
|
// corrupt snapshot — ignore, it will be overwritten on next save
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to cache changes and persist a debounced snapshot. Returns an
|
||||||
|
* unsubscribe function.
|
||||||
|
*/
|
||||||
|
export function startPersisting(qc: QueryClient, scope: string): () => void {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
timer = null;
|
||||||
|
const snapshot: PersistedCache = {
|
||||||
|
buster: BUSTER,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
scope,
|
||||||
|
state: dehydrate(qc),
|
||||||
|
};
|
||||||
|
void kvSet(CACHE_KEY, snapshot);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsubscribe = qc.getQueryCache().subscribe(() => {
|
||||||
|
if (timer) return; // a save is already scheduled
|
||||||
|
timer = setTimeout(save, SAVE_DEBOUNCE_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user