diff --git a/AGENTS.md b/AGENTS.md index 6010795b..1ea90f32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,7 @@ Release, versioning, or publishing: read `PUBLISHING.md`; decisions live in `doc ## Domain Domain terms (highlight, passage, Bible version, auth flow): read `CONTEXT.md`. +Bible read cache (opted-in Bible read, remaining lifetime, CachePolicy): read `docs/bible-read-cache.md`. ## Cursor Cloud Cloud VM, env files, Vite bind, or demo startup: read `docs/cursor-cloud.md`. diff --git a/CONTEXT.md b/CONTEXT.md index f13c3b03..3b62c7e7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -175,3 +175,55 @@ survives the full-page redirect round-trip and expires (~10 min) so an abandoned round-trip can never silently apply a highlight during a much later sign-in. It is intent, not highlight data (highlights stay server-only, ADR-001); discarded on decline, cancel, failure, or successful apply. + +## Opted-in Bible read + +An opted-in Bible read is a GET under `/v1/bibles/{id}` that honors Cache-Control remaining lifetime. + +The list is version, book, books, chapter, chapters, verse, verses, and passage. + +Highlights, GET `/v1/bibles` versions list, VOTD, languages, organizations, theme, and auth stay out. + +The how and where live in `docs/bible-read-cache.md`. +ADR 0006 is the why. +This is not YPE-5262. + +## Remaining lifetime + +Remaining lifetime is `max-age` minus `Age`, in milliseconds, on CachePolicy as `remainingMs`. + +Missing max-age is 7 days. +Missing Age is 0. +`no-cache` / `no-store` set remainingMs 0. + +## CachePolicy + +CachePolicy is the parsed Cache-Control result from `parseCachePolicy`. + +Fields include `remainingMs` and `allowsCaching`. + +## getWithPolicy + +getWithPolicy is the `ApiClient` GET that returns `{ data, policy }`. + +`ApiClient.get` stays body-only. + +## Memory-only + +Memory-only means QueryClient lives in process memory. +One QueryClient per YouVersionProvider. + +Expo DOM WebViews do not share this memory. + +Do not use this layer as the React Native Expo disk cache. +Do not persist QueryClient. +Do not share one QueryClient across Expo WebViews. +Do not wrap window.fetch in the Web SDK to close YPE-5262. + +## Remount + +A remount is a new hook mount after the previous observer unmounted, under the same YouVersionProvider. + +## Still-mounted observer + +A still-mounted observer is a hook that stayed mounted after remaining lifetime ended. diff --git a/docs/adr/0006-tanstack-query-read-layer.md b/docs/adr/0006-tanstack-query-read-layer.md index 0af27167..00c61788 100644 --- a/docs/adr/0006-tanstack-query-read-layer.md +++ b/docs/adr/0006-tanstack-query-read-layer.md @@ -8,6 +8,15 @@ Amended: 2026-09-01 Accepted +## How to navigate + +This ADR is the why, not the file map. +Read `docs/bible-read-cache.md` for owners, remount contracts, and files. +Do not use this layer as the React Native Expo disk cache. +Do not persist QueryClient. +Do not share one QueryClient across Expo WebViews. +Do not wrap window.fetch in the Web SDK to close YPE-5262. + ## Context `useApiData` was a hand-rolled effect: every mount and every dep change refetched, nothing was cached, and disabling a query threw its data away. Revisiting a chapter re-blanked the reader; account data was cleared imperatively on auth changes, which is easy to get wrong once and leak one user's highlights to another. The hook-level call shape is documented in `packages/hooks/AGENTS.md`. diff --git a/docs/bible-read-cache.md b/docs/bible-read-cache.md new file mode 100644 index 00000000..31e0299b --- /dev/null +++ b/docs/bible-read-cache.md @@ -0,0 +1,113 @@ +# Bible read cache + +Opted-in Bible reads honor Cache-Control remaining lifetime in a memory-only QueryClient. + +This map is the how and where. ADR 0006 is the why. This is not YPE-5262. + +## Tower + +1. The Platform API returns Cache-Control and Age on GET bodies. +2. `parseCachePolicy` in core turns headers into CachePolicy (`remainingMs`, `allowsCaching`). + Missing max-age is 7 days. + Missing Age is 0. + `no-cache` / `no-store` set remainingMs 0. + Parse errors fail open to no cache. +3. `ApiClient.get` stays body-only. + `getWithPolicy` returns `{ data, policy }`. +4. BibleClient methods for an opted-in Bible read call getWithPolicy. + Public body-only wrappers stay. +5. Opted-in Bible read hooks pass getWithPolicy into useApiData. + useApiData writes remainingMs onto that query's staleTime and gcTime after a successful first fetch. + The write covers the live query, not only setQueryDefaults. +6. Public hook shape stays `{ data, loading, error, refetch }`. + No TanStack types leave the package. +7. QueryClient is private, memory-only, and one per YouVersionProvider. + Expo DOM WebViews do not share this memory. + This is not YPE-5262. + +## Opt-in + +An opted-in Bible read is GET `/v1/bibles/{id}` or a subpath of that id: + +- version +- book +- books +- chapter +- chapters +- verse +- verses +- passage + +These stay out: + +- highlights +- GET `/v1/bibles` versions list +- VOTD +- languages +- organizations +- theme +- auth + +## Contracts + +| Event | Result | +| --- | --- | +| remount inside remaining lifetime | cache hit, no fetch | +| remount after remaining lifetime | miss, fetch | +| still-mounted observer after expiry | keep showing the body (focus refetch is off) | +| no-cache / no-store | write while mounted with gcTime 0 so the still-mounted observer can keep the body; remount misses | +| refetch() | always fetches | +| default hooks | staleTime 0, gcTime 5 minutes | + +## Fence + +Do not use this layer as the React Native Expo disk cache. +Do not persist QueryClient. +Do not share one QueryClient across Expo WebViews. +Do not wrap window.fetch in the Web SDK to close YPE-5262. + +## File ownership + +| Package | Owns | +| --- | --- | +| core | `parse-cache-policy.ts`, client getWithPolicy, bible.ts `*WithPolicy` methods, MSW tests for HTTP+parse | +| hooks | useApiData envelope, opted-in hook method swap, remount tests | +| ui | no cache policy code | +| Expo SDK | out of this repo | + +Hooks do not re-test parse tables. + +### Files + +core: + +- `packages/core/src/parse-cache-policy.ts` +- `packages/core/src/parse-cache-policy.test.ts` (Kotlin/Swift parity table) +- `packages/core/src/client.ts` (`get`, `getWithPolicy`) +- `packages/core/src/bible.ts` (`*WithPolicy` twins) +- `packages/core/src/__tests__/client.test.ts` +- `packages/core/src/__tests__/bible.test.ts` + +hooks: + +- `packages/hooks/src/useApiData.ts` +- `packages/hooks/src/useApiData.test.tsx` +- `packages/hooks/src/useVersion.ts` and `useVersion.test.tsx` +- `packages/hooks/src/usePassage.ts` and `usePassage.test.tsx` +- other opted-in Bible read hooks: `useBook.ts`, `useBooks.ts`, `useChapter.ts`, `useChapters.ts`, `useVerse.ts`, `useVerses.ts` +- `packages/hooks/src/context/YouVersionProvider.tsx` (one memory-only QueryClient) +- `packages/hooks/src/internal/queryClientDefaults.ts` + +ui has no cache policy code. + +Kotlin and Swift parse the same way. The parity table is `packages/core/src/parse-cache-policy.test.ts`. + +## Agent control + +If you add a new Bible GET hook, opt it in only when the path is under `/v1/bibles/{id}`. + +If you change `parseCachePolicy`, update the Kotlin/Swift parity table in `parse-cache-policy.test.ts` and this map. + +If you change remount behavior, update useApiData / useVersion / usePassage remount tests and this map. + +ADR 0006 stays the why. This map is the how and where. diff --git a/docs/testing.md b/docs/testing.md index 665145f2..dfc1cf67 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -31,6 +31,8 @@ Do not use "integration" as a style term — choose by capability: core owns HTTP+Zod+MSW; hooks own React state against stubbed clients; UI owns user-visible behavior against stubbed hooks/providers. Stub UI hook results with `YouVersionContext.hookOverrides` (see `packages/ui/src/test/hook-overrides.tsx`), not `vi.mock`. Do not re-test a lower package's contract unless the bug is at the boundary. Rare vertical smokes (e.g. highlight auth) may climb one rung for critical journeys. +Remount tests for opted-in Bible reads live in hooks and must stay. Core owns the `parseCachePolicy` table. See `docs/bible-read-cache.md`. + ## Scope Bind on new/edited tests. When touching a file, bend the cases you edit toward this style — no mass rewrite of untouched suites. diff --git a/packages/core/AGENTS.md b/packages/core/AGENTS.md index c4070823..52829c9e 100644 --- a/packages/core/AGENTS.md +++ b/packages/core/AGENTS.md @@ -107,6 +107,23 @@ See `docs/adding-a-core-endpoint.md`. - `YouVersionAPI` is a separate static header helper, not a base client. Do not build a new client on it. +## CachePolicy and getWithPolicy + +`parseCachePolicy` turns Cache-Control and Age into CachePolicy. + +`ApiClient.get` stays body-only. `getWithPolicy` returns `{ data, policy }`. + +A new Bible GET under `/v1/bibles/{id}` gets a `*WithPolicy` twin. + +Public body-only wrappers stay. Do not put TanStack here. + +The how and where live in `docs/bible-read-cache.md`. + +Do not use this layer as the React Native Expo disk cache. +Do not persist QueryClient. +Do not share one QueryClient across Expo WebViews. +Do not wrap window.fetch in the Web SDK to close YPE-5262. + ## CONVENTIONS - Schema-first: All types defined in schemas/*.ts using Zod - Zero React: Pure TypeScript, no React dependencies diff --git a/packages/hooks/AGENTS.md b/packages/hooks/AGENTS.md index 9e932fea..d5a00710 100644 --- a/packages/hooks/AGENTS.md +++ b/packages/hooks/AGENTS.md @@ -58,10 +58,20 @@ the pnpm `minimumReleaseAge` window): - Cache is memory-only; `refetch` performs exact query invalidation. Writes stay outside this layer (the highlights machine owns them) and refresh via `refetch` after the write. -- Opted-in Bible reads (version, book, books, chapter, chapters, verse, - verses, passage) may serve from memory until remaining Cache-Control - lifetime ends. Remount after expiry fetches. Highlights, the versions - list, and VOTD keep `staleTime: 0` and revalidate on remount. +- Opted-in Bible read remount contracts live in `docs/bible-read-cache.md`. + + | Event | Result | + | --- | --- | + | remount inside remaining lifetime | cache hit, no fetch | + | remount after remaining lifetime | miss, fetch | + | still-mounted observer after expiry | keep showing the body | + | no-cache / no-store | write while mounted with gcTime 0; remount misses | + | refetch() | always fetches | + +- Do not use this layer as the React Native Expo disk cache. +- Do not persist QueryClient. +- Do not share one QueryClient across Expo WebViews. +- Do not wrap window.fetch in the Web SDK to close YPE-5262. - Design decisions: `docs/adr/0006-tanstack-query-read-layer.md`. ## CONVENTIONS