mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
fix(pwa): bound the persisted query cache and stop swallowing quota errors
Cap the serialized snapshot at 2 MB and skip the write when it's over — an oversized blob is the likeliest IndexedDB quota trigger and its offline value is marginal, so keeping the last good snapshot beats evicting it. When a write still hits QuotaExceededError, log it and drop the persisted blob so the next flush has room, instead of silently swallowing the failure and leaving the offline cache stuck on a stale snapshot the user can't explain. The allowlist and per-account binding are untouched.
This commit is contained in:
1 parent
151a56fc66
commit
3465cbe81c
2 files changed
+183
-9
No files matched your search
@@ -323,3 +323,125 @@ describe("persister round-trips through a fake IndexedDB", () => {
|
||||
expect(store.get("react-query")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("persister is bounded and surfaces quota failures", () => {
|
||||
const original = (globalThis as { indexedDB?: unknown }).indexedDB;
|
||||
|
||||
afterEach(() => {
|
||||
if (original !== undefined) {
|
||||
(globalThis as { indexedDB?: unknown }).indexedDB = original;
|
||||
} else {
|
||||
delete (globalThis as { indexedDB?: unknown }).indexedDB;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const makeReq = (run: () => void) => {
|
||||
const req: Record<string, unknown> = {};
|
||||
queueMicrotask(() => {
|
||||
run();
|
||||
(req.onsuccess as (() => void) | undefined)?.();
|
||||
});
|
||||
return req;
|
||||
};
|
||||
|
||||
function installFakeIdb(opts: {
|
||||
onPut: (value: unknown, key: string) => void;
|
||||
store: Map<string, unknown>;
|
||||
}) {
|
||||
const fakeDb = {
|
||||
close() {},
|
||||
transaction() {
|
||||
return {
|
||||
objectStore() {
|
||||
return {
|
||||
put(value: unknown, key: string) {
|
||||
opts.onPut(value, key);
|
||||
return makeReq(() => {});
|
||||
},
|
||||
get(key: string) {
|
||||
const req = makeReq(() => {});
|
||||
(req as { result?: unknown }).result = opts.store.get(key);
|
||||
return req;
|
||||
},
|
||||
delete(key: string) {
|
||||
opts.store.delete(key);
|
||||
return makeReq(() => {});
|
||||
},
|
||||
};
|
||||
},
|
||||
set oncomplete(fn: () => void) {
|
||||
queueMicrotask(fn);
|
||||
},
|
||||
set onerror(_fn: () => void) {},
|
||||
};
|
||||
},
|
||||
};
|
||||
(globalThis as { indexedDB?: unknown }).indexedDB = {
|
||||
open() {
|
||||
const req: Record<string, unknown> = { result: fakeDb };
|
||||
queueMicrotask(() => (req.onsuccess as () => void)?.());
|
||||
return req;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("skips a snapshot that exceeds the size cap, leaving the last write intact", async () => {
|
||||
const store = new Map<string, unknown>();
|
||||
let putCount = 0;
|
||||
installFakeIdb({ store, onPut: () => (putCount += 1) });
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const source = new QueryClient();
|
||||
// A persistable family with a payload well over the 2 MB ceiling.
|
||||
const huge = "x".repeat(3 * 1024 * 1024);
|
||||
source.setQueryData(["chart-data", "WEIGHT"], [{ blob: huge }]);
|
||||
const stop = startPersistingQueryCache(source, "v1.18.6");
|
||||
vi.useFakeTimers();
|
||||
source.setQueryData(["chart-data", "WEIGHT"], [{ blob: huge }]);
|
||||
vi.advanceTimersByTime(1100);
|
||||
vi.useRealTimers();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
stop();
|
||||
|
||||
// The oversized snapshot was never written, and the skip was logged
|
||||
// rather than silently dropped.
|
||||
expect(putCount).toBe(0);
|
||||
expect(store.get("react-query")).toBeUndefined();
|
||||
expect(warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the cache and warns on an IndexedDB quota error instead of swallowing it", async () => {
|
||||
const store = new Map<string, unknown>();
|
||||
let firstPut = true;
|
||||
installFakeIdb({
|
||||
store,
|
||||
onPut: (value, key) => {
|
||||
if (firstPut) {
|
||||
firstPut = false;
|
||||
// First write hits the storage quota.
|
||||
throw new DOMException("quota", "QuotaExceededError");
|
||||
}
|
||||
store.set(key, value);
|
||||
},
|
||||
});
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const source = new QueryClient();
|
||||
source.setQueryData(["dashboard", "snapshot"], { weightKg: 81 });
|
||||
const stop = startPersistingQueryCache(source, "v1.18.6");
|
||||
vi.useFakeTimers();
|
||||
source.setQueryData(["chart-data", "WEIGHT"], [{ v: 81 }]);
|
||||
vi.advanceTimersByTime(1100);
|
||||
vi.useRealTimers();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
// Let the clear-on-quota microtasks settle.
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
stop();
|
||||
|
||||
// The quota failure was surfaced, not swallowed, and the cache key was
|
||||
// cleared so the next flush has room.
|
||||
expect(warn).toHaveBeenCalled();
|
||||
expect(store.get("react-query")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,17 @@ const KEY = "react-query";
|
||||
const VERSION_KEY = "react-query-version";
|
||||
const MAX_AGE_MS = 24 * 60 * 60 * 1000; // discard anything older than a day
|
||||
|
||||
/**
|
||||
* Upper bound on the serialized snapshot. The allowlist already caps WHAT is
|
||||
* persisted (dashboard snapshot + widget layout + daily series), but a tenant
|
||||
* with a long chart history can still grow a single series past what's worth
|
||||
* keeping on disk — and an oversized write is the most likely trigger for an
|
||||
* IndexedDB `QuotaExceededError`. A snapshot above this ceiling is skipped
|
||||
* rather than written: the offline read value of a multi-megabyte blob is
|
||||
* marginal, and skipping keeps the last good (smaller) snapshot in place.
|
||||
*/
|
||||
const MAX_PERSIST_BYTES = 2 * 1024 * 1024; // 2 MB
|
||||
|
||||
/**
|
||||
* Strict allowlist of the only query families written to disk — the
|
||||
* low-sensitivity reads the dashboard needs to paint last-known values when
|
||||
@@ -78,13 +89,30 @@ function openDb(): Promise<IDBDatabase> {
|
||||
|
||||
async function idbSet(value: unknown): Promise<void> {
|
||||
const db = await openDb();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, "readwrite");
|
||||
tx.objectStore(STORE).put(value, KEY);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
db.close();
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, "readwrite");
|
||||
tx.objectStore(STORE).put(value, KEY);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** True for the storage-quota error class across browsers. */
|
||||
function isQuotaError(err: unknown): boolean {
|
||||
if (err instanceof DOMException) {
|
||||
// Chromium/WebKit throw `QuotaExceededError`; Firefox historically
|
||||
// throws `NS_ERROR_DOM_QUOTA_REACHED` (code 22).
|
||||
return (
|
||||
err.name === "QuotaExceededError" ||
|
||||
err.name === "NS_ERROR_DOM_QUOTA_REACHED" ||
|
||||
err.code === 22
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function idbGet<T>(): Promise<T | undefined> {
|
||||
@@ -211,8 +239,32 @@ export function startPersistingQueryCache(
|
||||
userId: currentUserId(queryClient),
|
||||
state,
|
||||
};
|
||||
void idbSet(payload).catch(() => {
|
||||
/* best effort */
|
||||
|
||||
// Size-cap before touching disk: a snapshot past the ceiling is the
|
||||
// likeliest quota trigger and its marginal offline value isn't worth
|
||||
// evicting the last good (smaller) snapshot. Skip the write, keep what's
|
||||
// there.
|
||||
const serialized = JSON.stringify(payload);
|
||||
if (serialized.length > MAX_PERSIST_BYTES) {
|
||||
console.warn(
|
||||
`[query-persister] skipping persist: snapshot ${serialized.length}B exceeds ${MAX_PERSIST_BYTES}B cap`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
void idbSet(payload).catch((err) => {
|
||||
// Don't silently swallow a full-disk failure: a quota error means the
|
||||
// offline cache stopped updating, and a clear signal beats a stale
|
||||
// snapshot the user can't explain. Drop the persisted blob so the next
|
||||
// flush has room rather than failing again against a full store.
|
||||
if (isQuotaError(err)) {
|
||||
console.warn(
|
||||
"[query-persister] IndexedDB quota exceeded; dropping persisted cache",
|
||||
);
|
||||
void clearPersistedQueryCache();
|
||||
return;
|
||||
}
|
||||
console.warn("[query-persister] persist failed", err);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user