mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
- Clear scoped reads before the server session changes - Reconcile every tab with a fresh access response
207 lines
8.3 KiB
TypeScript
207 lines
8.3 KiB
TypeScript
/**
|
|
* v1.36.0 — the account dimension of the query cache.
|
|
*
|
|
* Every cached read in this app is about one health record. Nothing in the key
|
|
* factory says which one, because until now there was only ever one: the
|
|
* signed-in account's. Once a browser can act on somebody else's record, a key
|
|
* like `["dashboard","snapshot"]` names two different answers, and a cache that
|
|
* cannot tell them apart will hand the second reader the first one's data.
|
|
* That is this repository's known cache-poison class, on health data, across
|
|
* two people.
|
|
*
|
|
* The fix is one line at the root rather than a dimension threaded through
|
|
* twenty-three key modules: a `queryKeyHashFn` on the QueryClient. The hash is
|
|
* what the cache stores entries under and what `getQueryData` looks them up by,
|
|
* so folding the record id into it partitions every entry in the app at once.
|
|
* Key ARRAYS are untouched, which matters more than it looks: prefix
|
|
* invalidation (`invalidateQueries({ queryKey: ["measurements"] })`) and the
|
|
* module-scope dependent-key bundles in `./index.ts` both match on the array,
|
|
* were computed at import time before any record was known, and keep working
|
|
* unchanged. Prefixing the arrays instead would have silently broken every one
|
|
* of those bundles.
|
|
*
|
|
* ## Why this is defence in depth and not the primary control
|
|
*
|
|
* Switching records is a full page reload (design §2), so the in-memory cache
|
|
* is empty on the other side of it and this hash can only matter if that ever
|
|
* stops being true. The primary control for the layers that DO survive a
|
|
* reload — the IndexedDB snapshot and the service worker's data cache — is the
|
|
* wipe the switch performs before reloading, in `switchRecord()`. Both are
|
|
* cheap; only one of them is the reason the feature is safe.
|
|
*
|
|
* ## Why the mirror is in localStorage
|
|
*
|
|
* The scope has to be known synchronously at boot, before `/api/auth/me`
|
|
* answers, because the persister decides whether to restore from disk in the
|
|
* first effect after mount. A switch is a property of the SESSION — it is
|
|
* carried on the session row, not per tab — so every tab of the browser is
|
|
* switched at once, which is what localStorage models and sessionStorage does
|
|
* not.
|
|
*
|
|
* A mirror can go stale, and the honest question is what a stale one costs.
|
|
* Nothing, because the disk layers are wiped on every switch in both
|
|
* directions: a mirror claiming a record the session has left points the
|
|
* persister at a store that was emptied when it left, and a mirror claiming
|
|
* "own" while the session is inside a record points it at a store that was
|
|
* emptied on the way in and never written to since (the persister refuses to
|
|
* write while switched — see `isPersistableKey`'s caller). Either way the
|
|
* worst case is an empty restore and a skeleton, never another person's data.
|
|
* `fetchMe` re-stamps the mirror from the server's resolved answer on every
|
|
* boot, so the staleness window is one round trip.
|
|
*/
|
|
import type { QueryKey } from "@tanstack/react-query";
|
|
|
|
export const RECORD_SCOPE_STORAGE_KEY = "healthlog-record-scope";
|
|
const REFUSED_RECORD_SCOPE = "__healthlog_refused_record__";
|
|
|
|
/**
|
|
* The in-process copy. Seeded from storage on first read so the hash function
|
|
* — which runs on every single query — never touches localStorage.
|
|
*/
|
|
let scope: string | null = null;
|
|
let seeded = false;
|
|
const scopeListeners = new Set<() => void>();
|
|
|
|
function notifyScopeListeners(): void {
|
|
for (const listener of scopeListeners) listener();
|
|
}
|
|
|
|
function readMirror(): string | null {
|
|
try {
|
|
const raw = localStorage.getItem(RECORD_SCOPE_STORAGE_KEY);
|
|
return raw === null || raw.length === 0 ? null : raw;
|
|
} catch {
|
|
// Private mode, disabled storage, or SSR. "Own record" is the safe
|
|
// reading: it is what every browser reported before this feature existed.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The record this browser is currently reading, or null when it is reading its
|
|
* own.
|
|
*
|
|
* Server-side this stays null for the life of the process: the mirror is only
|
|
* ever seeded from browser storage, and `setRecordScope` is called from client
|
|
* code alone. That matters more than it looks — the value is a module-level
|
|
* variable, so a server that could write it would be sharing one scope across
|
|
* every request it serves.
|
|
*/
|
|
export function getRecordScope(): string | null {
|
|
if (!seeded) {
|
|
scope = typeof window === "undefined" ? null : readMirror();
|
|
seeded = true;
|
|
}
|
|
return scope;
|
|
}
|
|
|
|
/**
|
|
* Point the cache at a record, or (with `null`) back at the caller's own.
|
|
*
|
|
* Called from exactly two places: the switch flow, before it reloads the page,
|
|
* and `fetchMe`, which corrects the mirror from the server's resolved
|
|
* `accountAccess.active` on every account read. The second is what keeps a
|
|
* mirror written by a switch that the server has since ended from surviving
|
|
* past one round trip.
|
|
*/
|
|
export function setRecordScope(accountId: string | null): void {
|
|
scope = accountId;
|
|
seeded = true;
|
|
if (typeof window === "undefined") return;
|
|
try {
|
|
if (accountId === null) {
|
|
localStorage.removeItem(RECORD_SCOPE_STORAGE_KEY);
|
|
} else {
|
|
localStorage.setItem(RECORD_SCOPE_STORAGE_KEY, accountId);
|
|
}
|
|
} catch {
|
|
/* storage unavailable — the in-process copy still partitions this tab */
|
|
}
|
|
notifyScopeListeners();
|
|
}
|
|
|
|
/**
|
|
* React bridge for consumers that must stop work as soon as another tab moves
|
|
* the session to a different record. `storage` never fires in the tab that
|
|
* wrote the mirror, so local writes notify directly above; other tabs adopt
|
|
* the new value before notifying their subscribers.
|
|
*/
|
|
export function subscribeToRecordScope(listener: () => void): () => void {
|
|
scopeListeners.add(listener);
|
|
if (typeof window === "undefined") {
|
|
return () => scopeListeners.delete(listener);
|
|
}
|
|
const onStorage = (event: StorageEvent) => {
|
|
if (event.key !== RECORD_SCOPE_STORAGE_KEY) return;
|
|
scope =
|
|
event.newValue === null || event.newValue.length === 0
|
|
? null
|
|
: event.newValue;
|
|
seeded = true;
|
|
notifyScopeListeners();
|
|
};
|
|
window.addEventListener("storage", onStorage);
|
|
return () => {
|
|
scopeListeners.delete(listener);
|
|
window.removeEventListener("storage", onStorage);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Keep a malformed active-record response out of the caller's own cache slot.
|
|
*
|
|
* No record id from an invalid payload is trusted, but `null` would make
|
|
* record-scoped reads share the caller's own cache identity. This sentinel is
|
|
* a cache partition only; the shell refuses the presentation until the caller
|
|
* leaves the server-side switch.
|
|
*/
|
|
export function setRefusedRecordScope(): void {
|
|
setRecordScope(REFUSED_RECORD_SCOPE);
|
|
}
|
|
|
|
/** True while this browser is reading somebody else's record. */
|
|
export function isReadingSharedRecord(): boolean {
|
|
return getRecordScope() !== null;
|
|
}
|
|
|
|
/**
|
|
* The QueryClient's key hash function.
|
|
*
|
|
* Identical to TanStack's own `hashKey` when the browser is in its own record
|
|
* — a `JSON.stringify` with sorted object keys — so nothing about the app's
|
|
* cache changes for the overwhelming majority of sessions. Inside a shared
|
|
* record every entry lands under a distinct hash, which is what makes an
|
|
* inherited entry unreachable rather than merely stale.
|
|
*/
|
|
export function recordScopedQueryKeyHashFn(queryKey: QueryKey): string {
|
|
const key = JSON.stringify(queryKey, (_, val) =>
|
|
isPlainObject(val)
|
|
? Object.keys(val)
|
|
.sort()
|
|
.reduce<Record<string, unknown>>((result, k) => {
|
|
result[k] = (val as Record<string, unknown>)[k];
|
|
return result;
|
|
}, {})
|
|
: val,
|
|
);
|
|
// `/api/auth/me` identifies the caller, never the record they are reading.
|
|
// Its query begins before `fetchMe` can resolve or refuse the record scope,
|
|
// so it must remain reachable across that transition.
|
|
if (queryKey[0] === "auth") return key;
|
|
const record = getRecordScope();
|
|
return record === null ? key : `record:${record}|${key}`;
|
|
}
|
|
|
|
/** TanStack's own predicate, copied rather than imported — it is not exported. */
|
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
if (Object.prototype.toString.call(value) !== "[object Object]") return false;
|
|
const prototype = Object.getPrototypeOf(value);
|
|
return prototype === null || prototype === Object.prototype;
|
|
}
|
|
|
|
/** @internal — lets a test start from a known state. */
|
|
export const __resetRecordScopeForTests = (): void => {
|
|
scope = null;
|
|
seeded = false;
|
|
};
|