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.

  • BEfsh_lib.invalidation.QueryInvalidations is a per-request collector injected via Depends(QueryInvalidations). Each add_all / add_one re-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_wiring in codegen-be/operations/renderers.py) and emit invalidations.add_all(<cache_key>).

  • The cache key is resolve_cache_key(resource) — the plural snake of the model (Category"categories"), or a cache_key override. The same string is stamped as each route’s x-cache-key openapi extension, so the FE and the invalidation header agree by construction.

  • FEinstallInvalidations(queryClient) (wired once in main.tsx) installs a response interceptor on the generated client that reads the header and invalidates each queryKey. Generated query hooks (_generated/queries/, from the cache-key-queries openapi-ts plugin) root their queryKeys at [cache_key, ...path, body?], so add_all("categories") emits ["categories"], whose prefix match blows the list and every ["categories", id] detail in one shot.

  • CORSX-Invalidate-Queries is a custom response header. Cross-origin, browsers hide it from JS unless it is in expose_headers, so the be_root main.py template lists it; without it the interceptor reads null and 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 editfe/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 writefe/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.