# ADR 0003 — The data story: live query cache, mutations, streams (v0.7)

- **Status:** Accepted
- **Date:** 2026-07-02
- **Context:** the v0.7 data-story milestone — promoting `query` from a per-position
  dedup into a real runtime-level cache, and adding the mutation and stream primitives.
  Builds on [ADR 0001](./0001-fiber-reconciliation.md) and [ADR 0002](./0002-async-lifecycle-hardening.md).

## Context

ADR 0001/0002 gave `query` load-once-by-position dedup and hardened its lifecycle. v0.7
turns it into a data layer at TanStack-Query parity: cross-component dedup by key,
invalidation, staleTime/SWR, manual refetch, `enabled`; plus a `mutation` primitive and a
`Stream` bridge. Each needed a decision about identity, observability, or where the code
lives.

## Decisions

### 1. Keyed queries are deduped runtime-wide; keyless stay positional

A cache entry's identity is `runtimeScope + Hash.hash(key)` for a **keyed** query — so two
different components under one `mount` issuing `query(fetchUser(1), 1)` share one fetch, and
moving the query line no longer changes its identity. A **keyless** `suspend` / raw async has
no user identity, so it keeps its `(runtimeScope, componentScope, index)` positional identity
(load-once-by-position). Dedup is scoped by runtime, never global, so a second mount or a
per-request server runtime stays isolated. The user's key must uniquely identify the data
(structured keys recommended); "effect identity" is not a reliable second axis because a fresh
effect object is built per call.

### 2. Cache entries are observable; a query is two hooks (subscribe + use)

To support invalidation, refetch, and stale-while-revalidate, each entry carries a `version`
and listeners. A query read now does **two** React hooks: it `useSyncExternalStore`s the
entry's version (so invalidate / refetch / a background revalidation re-render exactly the
components reading that data) and `use`s the entry's current promise (suspend / return the
possibly-stale value / throw for `.catch`). The entry's promise is always resolved to the
current displayable value; a background revalidation replaces it and bumps the version only on
settle, so a stale read never suspends. The interpreter counts every suspending yield as two
hooks accordingly — pinned by the `#37` invariant test, so the `.catch` guard stays correct.

### 3. `query.enabled` reads `Option<A>`

A dependent query (`query.enabled(effect, key, enabled)`) keeps the type honest: while
disabled the effect never runs and the read is `Option.none()`; enabled, it runs and reads
`Option.some(value)`. `enabled` is folded into the refetch key, so toggling it re-runs.

### 4. Invalidation / refetch are runtime-bound hooks

`useInvalidateQueries()` and `useRefetchQuery()` return plain functions bound to the current
runtime (via its executor identity), so they only touch this `mount`'s cache and can be called
in an event handler or handed to a service method / the mutation primitive. A fully
service-context (`QueryClient`-as-a-service) API is a possible later extension; the hooks cover
the component and mutation cases the acceptance requires.

### 5. Mutations: optimistic apply, typed-error rollback, defects never swallowed

`useMutation` runs its effect through a new typed `Executor.runPromiseExit` (which preserves
`A`/`E` — the one erased→typed coercion lives in the executor adapter, so the call site is
cast-free). On a **typed** failure it calls the `optimistic` rollback thunk and lands the typed
error in `error`. A **defect** is never swallowed: it is re-thrown (to the app's error
reporting), not folded into `error`, honouring the "defects are never swallowed" guarantee.

### 6. Streams: `atom.fromStream`, server-safe, scope-bound

`atom.fromStream(stream, initial)` is an `Effect` that seeds an atom and forks the drain into
the **current scope** (`Effect.forkScoped`). Used in a service's `make`, the fiber is tied to
the runtime's lifetime — interrupted on dispose. A component just reads the atom with `yield*`
and re-renders per emission. It lives in the server-safe `reactivity-core`, so a universal
service can hold live state. The component-side `yield* stream(s)` sugar is deferred to the
component-lifetime `Scope` work (v0.8), since it needs a per-instance scope.

## Consequences

- `query` is now a cache, not a per-position dedup — the basis v0.8 hydration seeds.
- Every suspending yield is two React hooks; the hook-count contract is documented and tested.
- The mutation and stream primitives are client and server-safe respectively, matching where
  their capabilities make sense.

## Alternatives considered

- **Global (not runtime-scoped) query cache.** Rejected: it would share state across mounts and
  break per-request server isolation (ADR 0002 #3).
- **A separate atom-backed query engine.** Rejected as redundant: the existing store already had
  the claim/sweep/StrictMode-safe lifecycle; adding observability to it was less surface than a
  parallel engine.
