# ADR 0008 — The query cache is a service (v0.8)

- **Status:** Accepted
- **Date:** 2026-07-02
- **Context:** [ADR 0003](./0003-data-story.md) built the runtime-level query cache but exposed it
  only through hooks (`useMutation`, `useInvalidateQueries`, `useRefetchQuery`) — its decision 4
  already flagged a `QueryClient`-as-a-service API as the likely extension. This ADR is that
  extension, taken to its conclusion: the cache becomes a built-in **`QueryCache` service**,
  mutations are declared in services, and the three hooks are removed. It supersedes decisions 4
  and 5 of ADR 0003 and rides the channel machinery of [ADR 0007](./0007-render-channel.md) —
  mutation state is a canonically labeled atom, so `RendersOn`, the ledger, and devtools apply
  unchanged.

## Decisions

### 1. `QueryCache` is a built-in service

A `Context.Tag` service — `{ invalidate(tag): number; refetch(key): number }`, plain sync methods
delegating to the existing store ops (`invalidateByTag` / `refetchByKey` in
`suspensable-store.ts`). The store is module-global keyed by `runtimeScopeOf(runtime)`, but the
runtime does not exist while its layer is being built — so the implementation closes over a
**scope thunk**: the Runtime provider (`runtime.tsx`) builds
`ManagedRuntime.make(Layer.provideMerge(layer, Layer.succeed(QueryCache, cache)))` and sets the
thunk's ref immediately after; `ManagedRuntime.make` is lazy, so no service resolves `QueryCache`
before the ref is live. `provideMerge` — not `merge` — because user layers may *require* the
cache (`yield* QueryCache` in a service `make`). Accordingly `mount`'s layer parameter widens
`Layer<ROut, LE, never>` → `Layer<ROut, LE, QueryCache>` (RIn is contravariant, so every existing
`never`-RIn layer stays assignable), and the `MissingServices` constraint becomes
`[Effective<R>] extends [ROut | QueryCache]` — a component may `yield* QueryCache` directly. The
server (RSC) `mount` provides an honest **no-op** (`invalidate`/`refetch` return 0) the same
`provideMerge` way: the server driver has no store, so there is nothing to lie about.
`renderToStream` reuses the client mount and inherits the real cache. One limitation, documented,
not papered over: an **injected `ManagedRuntime`** was built outside `mount`, so it cannot be
retrofitted — a service resolving `QueryCache` under an injected runtime that lacks it fails at
runtime. Build with the Layer form if your services use the cache.

### 2. Mutations are declared in services

`mutation(label, run, { optimistic?, onSuccess?, invalidates? })` is an
`Effect<Mutation<I, A, E>, never, QueryCache | R>` — yielded in a service `make`, where it
captures the cache and `Effect.runtime<R>()` (the service's own context satisfies `run`'s `R`).
The value is `{ run(input): void; state: ReadableAtom<MutationState<A, E>>; reset(): void }`.
`state` is a canonically labeled atom (`` `${label}.state` ``) — which is the whole payoff:
reactive reads, `RendersOn` membership, ledger attribution, devtools visibility, and DOM-free
testing all apply for free, because a mutation is now just an atom a service holds. The
semantics port **verbatim** from the hook: the `optimistic` rollback thunk is invoked on failure
*before* the state update; typed failures are recovered via `Cause.findErrorOption` and land in
`state.error`; defects are re-thrown out of band — never folded into `.error` (the ADR 0003
guarantee, kept); interrupts are silent. The construct lives in a React-free module
(`mutation-core.ts`), exported from both entries. State is **shared by default** — per-mutation,
not per-instance: two components rendering the same mutation see one state, which is what a
service-owned value means. Per-entity state is `atomFamily` (one mutation per key); the rare
genuinely per-instance case is `hook(useState)`.

### 3. The three hooks are removed

`useMutation`, `useInvalidateQueries`, and `useRefetchQuery` are deleted. The philosophy argument,
not a surface diet: effract's own rule is *logic in services* — yet the hooks were the only door
to the cache, so optimistic policy and invalidation had to be written in components, against the
rule the library teaches. Cache-as-a-service removes the reason the hooks existed; keeping them
would keep teaching the component-side idiom. A component that needs to invalidate calls a
service method; the service holds the mutation.

### 4. `VERSION` is dropped from the public surface

It shipped hardcoded to `'0.1.0'` while the package is at 0.7.0. No build step derives it, so it
can only be maintained by hand — and it wasn't. An unmaintained constant is worse than none;
pre-1.0, removal is the honest fix.

## Consequences

- Breaking, sanctioned pre-1.0 by the version policy: three hooks and `VERSION` gone. The
  migration is mechanical — `useMutation` → `mutation(...)` in a service `make`;
  `useInvalidateQueries()(tag)` → `cache.invalidate(tag)`; `useRefetchQuery()(key)` →
  `cache.refetch(key)` — and the CHANGELOG carries the table.
- `mount` / `renderToStream` / server `mount` accept `Layer<ROut, LE, QueryCache>`; every
  existing call site compiles unchanged (contravariant RIn).
- Mutations are testable as plain Effect programs — no DOM, no `act`: run the `mutation(...)`
  effect under a test layer providing `QueryCache`, drive `run`, assert on the state atom.
- The frozen surface tests shrink by three hooks and one constant, and grow `mutation` and
  `QueryCache` on both entries.
- The injected-runtime limitation is a documented edge: `mount(managedRuntime, Root)` works as
  before unless a service in it resolves `QueryCache` — the Layer form is the supported path for
  cache-using services.

## Alternatives considered

- **Keep the hooks as thin wrappers over the service.** Rejected — two ways to do one thing, and
  the wrapper would keep teaching the component-side idiom this ADR exists to end.
- **A per-instance mutation-state API.** Rejected — `atomFamily` already covers per-key state,
  and `hook(useState)` covers the rare genuinely per-instance rest; a third mechanism buys
  nothing.
- **The cache as a React context.** Rejected — unreachable from services, which was the original
  sin: anything only a component can reach forces logic into components.
- **Fixing `VERSION` instead of dropping it.** Rejected — no build step derives it, so it will
  drift again; a constant that lies is worse than no constant.
