feat(data-fetching): scope docStore's cache to the signed-in user automatically - #1006
feat(data-fetching): scope docStore's cache to the signed-in user automatically#1006netchampfaris wants to merge 1 commit into
Conversation
Confidence Score: 3/5This PR is not safe to merge until open tabs update or invalidate their cache namespace when the authenticated account changes. A live tab retains account A’s namespace after another tab changes the shared session to account B, allowing A’s cached documents to be displayed under B’s session and B’s responses to be stored under A. Files Needing Attention: src/data-fetching/docStore.ts
|
|
Concerns (2/5) — the namespace shape is right, but the switch path doesn't fully deliver the isolation the PR claims.
The global setter over a per-call barista · claude-opus-5 · 63 in / 10.0k out · 1215k cached · 151s · $0.783 |
|
I would prefer to not expand the public API surface further, can this be achieved without exposing docStore? |
5d5aad6 to
29a1cda
Compare
…omatically useDoc caches every fetched doc unconditionally, keyed only doctype/name, in memory and in IndexedDB. On a shared browser, user B signing in can be served user A's cached doc offline (or before B's own fetch resolves) -- a cross-account data leak. useList/useCall avoid this with a per-call cacheKey, but that only works because their persistence is opt-in; useDoc has no such call site to namespace from. An earlier version of this change added a setCacheNamespace(namespace) entry point for apps to call once at session start. Dropped that in favor of doing it automatically instead: any opt-in API, even a single global call, is a call site an app can forget, and a missed one leaks silently by default -- the exact failure mode this is supposed to close. There is nothing to forget if there is nothing to call. docStore now reads the standard Frappe session cookie (`user_id`, set by every logged-in Frappe app already) once at construction and prefixes every doc cache key with it. No public API changes at all -- docStore stays fully internal, reached only by useDoc/useNewDoc/useDoctype/useList, and this repo's public barrel (src/data-fetching/index.ts) is untouched. Because the cookie is only read at construction, a namespace change is only ever detected on the next page load (a Frappe login/logout flow reloads the document, so this holds in practice; an app that swapped the session user without a reload would not see the new namespace take effect until the next load). The store records the last-seen namespace in IDB across loads; when a load's namespace differs from the previous one and both are real accounts (not Guest/no-cookie), the outgoing account's docs are purged from IDB rather than left to sit there indefinitely. A load with no prior recorded namespace never purges, so a returning user's own offline cache survives the first time this runs for them. Ref frappe/gameplan#516, which worked around this for lists and calls.
29a1cda to
0f01590
Compare
| * no live cookie watcher. In practice that's fine: a Frappe login/logout | ||
| * flow reloads the document, which re-runs this module and constructs a | ||
| * fresh store. An app that somehow swaps the session user without a reload | ||
| * would not see the new namespace take effect until the next load. | ||
| * |
There was a problem hiding this comment.
When another tab changes the shared session from account A to account B, an already-open tab retains A's construction-time namespace while subsequent requests authenticate as B, causing A's cached documents to be displayed in B's session and B's responses to be stored in A's cache.
Knowledge Base Used: Data Fetching Resources
Problem
useDoccaches every fetched document unconditionally indocStore, keyed only by`${doctype}/${name}`, both in memory and in IndexedDB. On a shared browser, user B signing in can be served user A's cached document — offline, or even briefly before B's own fetch resolves. This is a cross-account data leak.useListanduseCallalready avoid this via a per-callcacheKey(frappe/gameplan#516 uses it to scope Gameplan's list/call caches to the session user), but that pattern doesn't transfer touseDoc: those two only persist to IDB when a caller opts in withcacheKey, so namespacing the key is something the opted-in call site can do.useDocpersists on every fetch with no opt-in point at all.Design: automatic, zero adoption code
This PR went through two earlier shapes before landing here — a per-call
useDoc({ cacheKey })option, then a globalsetCacheNamespace(namespace)function apps would call once at session start. Both were opt-in, and opt-in is the wrong shape for this specific problem: any call an app has to remember to make is a call it can forget, and a forgotten one leaks silently by default — which is the exact failure mode this PR exists to close. There's nothing to forget if there's nothing to call.So
docStorenow derives its namespace itself, automatically, from the standard Frappe session cookieuser_id— a cookie every logged-in Frappe app already sets, with nothing for the app to configure. It's read once when the store is constructed and prefixes every doc cache key for the lifetime of that page load. There is no public API change in this PR at all —docStorewas never exported from the package, still isn't, andsrc/data-fetching/index.tshas a zero-line diff againstmain.Cookie handling:
decodeURIComponent-ed (Frappe URL-encodes it, e.g. for an email address).Guestall resolve tonull— the unnamespaced default, identical to today's behavior for a signed-out visitor.typeof document === 'undefined'(SSR, or a non-browser test environment) also resolves tonull.Purge-on-switch, adapted to being automatic
There's no explicit "switch" event anymore, so detection moves to construction time: the store records the last-seen namespace in IDB (a reserved meta key, not a
doc:-prefixed one, so it can never collide with a real doctype/name). Each time the store is constructed it compares the freshly-read cookie namespace against that record:clearAll()already scans) rather than just left fenced off behind their own prefix indefinitely — a shared browser shouldn't keep accumulating every past account's cached docs forever.Limitation, called out explicitly: namespace changes are only ever detected at construction (page load) — there's no live cookie watcher. In practice this is fine, because a Frappe login/logout flow reloads the document, which re-runs this module and constructs a fresh store. An app that somehow swapped the session user without a full reload would not see the new namespace take effect (or the purge fire) until the next load.
Tests
Reworked
src/data-fetching/docStore.test.ts'scache namespacinggroup to drivedocument.cookiedirectly (this file runs under@vitest-environment node, sodocumentdoesn't exist unless a test stubs it) and construct freshDocStoreinstances to stand in for separate page loads:Guestcookie, behave byte-identically to before this feature (baredoctype/namekeys)yarn test: 54 files / 473 tests, all green. No pre-existing failures encountered.Adoption
None needed. Gameplan (frappe/gameplan#516) already scopes its own
useList/useCallcaches to the session user with an explicitcacheKey;useDocnow gets the same protection automatically, with no code change on the Gameplan side.🤖 Generated with Claude Code
Docs preview: https://ui.frappe.io/pr-preview/pr-1006/
Coverage: 64.27% (+0.05% vs
main)