Cache invalidation¶
Layer: Backend → Frontend · server-driven query-cache invalidation
Generated mutations tell the frontend what to drop — the backend does, not the
FE. Every write handler stamps an X-Invalidate-Queries response header listing
the TanStack queryKeys to evict; a FE response interceptor turns each into a
queryClient.invalidateQueries call. Generated CRUD wires both ends
automatically, so the common case needs no code.
How it works¶
The header is the whole contract. The backend collects queryKeys through a per-request dependency and serializes them onto the response; the frontend reads that header and invalidates. Because the FE query hooks and the BE header both root their keys at the resource’s cache key, a single string lines the two sides up — no shared client, no manual key bookkeeping.
BE —
fsh_lib.invalidation.QueryInvalidationsis a per-request collector injected viaDepends(QueryInvalidations). Eachadd_all/add_onere-serializes the running list onto the header eagerly, so a handler can return at any point with no flush ritual. Generated create / update / delete / action handlers thread the dep in (invalidations_wiringincodegen-be/operations/renderers.py) and emitinvalidations.add_all(<cache_key>).The cache key is
resolve_cache_key(resource)— the plural snake of the model (Category→"categories"), or acache_keyoverride. The same string is stamped as each route’sx-cache-keyopenapi extension, so the FE and the invalidation header agree by construction.FE —
installInvalidations(queryClient)(wired once inmain.tsx) installs a response interceptor on the generated client that reads the header and invalidates each queryKey. Generated query hooks (_generated/queries/, from thecache-key-queriesopenapi-ts plugin) root their queryKeys at[cache_key, ...path, body?], soadd_all("categories")emits["categories"], whose prefix match blows the list and every["categories", id]detail in one shot.CORS —
X-Invalidate-Queriesis a custom response header. Cross-origin, browsers hide it from JS unless it is inexpose_headers, so thebe_rootmain.pytemplate lists it; without it the interceptor readsnulland no refetch ever fires.allow_headers=["*"]does not cover this (it governs the request side only).
Two scopes:
add_all(cache_key)→[cache_key]— blow everything for the resource (list caches + every per-id detail). Right for create / delete / anything that moves rows in or out of a list. This is what generated CRUD emits.add_one(cache_key, id)→[cache_key, id]— blow just that detail cache, leaving lists alone. Useful when one row’s representation changed but no list filters on the changed field.
The config you write¶
Nothing for generated CRUD — the dependency, the header, and the FE interceptor are all emitted. You touch invalidation only in hand-written handlers, which opt in by declaring the dependency:
from typing import Annotated
from fastapi import APIRouter, Depends
from fsh_lib.invalidation import QueryInvalidations
router = APIRouter(prefix="/projects")
@router.delete("/{id}")
async def delete_project(
id: int,
invalidations: Annotated[QueryInvalidations, Depends(QueryInvalidations)],
) -> None:
# ...delete the row...
invalidations.add_all("projects") # list + every detail
# or, narrower: invalidations.add_one("projects", id)
The same dep also extends the auto-generated set — a post-hook on one resource
can add_all a sibling’s cache key when a write cascades across resources.
The frontend codegen emits¶
Scaffolded, safe to edit — fe/src/api/invalidation.ts: the response
interceptor, written once at bootstrap. It reads the header and invalidates;
edit freely (e.g. to log or filter keys):
import { client } from "../_generated/client.gen";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
const HEADER_NAME = "X-Invalidate-Queries";
export function installInvalidations(queryClient: QueryClient): void {
client.interceptors.response.use((response) => {
const raw = response.headers.get(HEADER_NAME);
if (raw === null) return response;
(JSON.parse(raw) as QueryKey[]).forEach((queryKey) =>
queryClient.invalidateQueries({ queryKey }),
);
return response;
});
}
You write — fe/src/main.tsx: wire it once, right after constructing the
query client:
import { installInvalidations } from "./api/invalidation";
installInvalidations(queryClient);
Most CRUD hides a missing header behind a navigation (a form’s
window.history.back() remounts the list, which refetches on its own). The
mechanism only becomes load-bearing where the mutation and the stale data share
a page — saved views on a same-page tab strip, or comments
posted inline — which is exactly when a dropped expose_headers bites.
See also: resources, table configuration, commenting, auditing.