Files
meezi/web/dashboard/src/lib/offline/query-persister.ts
T
soroush.asadi 132f0921e0 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>
2026-06-02 17:41:15 +03:30

71 lines
2.2 KiB
TypeScript

/**
* 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();
};
}