Source code for fsh_lib.invalidation
"""Server-driven cache invalidation for kiln-generated apps.
Kiln-generated mutations don't tell the FE what to invalidate --
the BE does, via the ``X-Invalidate-Queries`` response header.
Handlers grab a :class:`QueryInvalidations` collector through
FastAPI's dependency system and call ``invalidations.add(key)``
for each TanStack queryKey the FE should drop after the call
succeeds; the collector serializes the running list onto the
header.
Generated CRUD handlers wire this dependency automatically and
register the resource's own cache_key, so the typical case needs
no hand-coding. The helper is exposed for hand-written handlers
that need to opt in or extend the auto-generated set:
.. code-block:: python
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)
"""
from __future__ import annotations
import json
# ``Response`` looks type-only but FastAPI inspects this class's
# ``__init__`` at request time and resolves the annotation to
# inject the per-request response object; importing it under
# ``TYPE_CHECKING`` would leave the name unresolved at runtime
# and pydantic would refuse to build the dependency.
from fastapi import Response # noqa: TC002
HEADER_NAME = "X-Invalidate-Queries"
QueryKey = str | int | bool | float | None
"""One segment of a TanStack queryKey. TanStack accepts arbitrary
JSON, but in practice keys are arrays of these scalars; we
restrict the type so the header stays small and predictable."""
[docs]
class QueryInvalidations:
"""Per-request collector for TanStack queryKeys to invalidate.
Construct via FastAPI dependency injection
(``Depends(QueryInvalidations)``); the framework hands us the
request's mutable :class:`~fastapi.Response` so each ``add_*``
call updates the response header eagerly.
Two scopes:
* :meth:`add_all` -- "blow everything for this resource". The
FE side prefix-matches and drops list caches plus every
per-id detail in one shot. Right for create / delete /
anything that affects multiple list rows.
* :meth:`add_one` -- "blow this specific id only". Just the
detail cache for that row; lists are left alone. Useful
when you've changed one row's representation but no list
could be filtering on the changed field.
"""
def __init__(self, response: Response) -> None:
"""Bind to the request's :class:`~fastapi.Response`."""
self._response = response
self._keys: list[list[QueryKey]] = []
[docs]
def add_all(self, cache_key: str) -> None:
"""Blow every cache rooted at *cache_key*.
Emits the queryKey ``[cache_key]``; TanStack invalidates
anything with that prefix -- list caches, every
``[cache_key, id]`` detail cache, etc.
"""
self._append([cache_key])
[docs]
def add_one(self, cache_key: str, key_id: QueryKey) -> None:
"""Blow only the ``[cache_key, key_id]`` detail cache."""
self._append([cache_key, key_id])
def _append(self, key: list[QueryKey]) -> None:
if key in self._keys:
return
self._keys.append(key)
# Re-serialize on every add -- writing eagerly means the
# handler can return any time without a "remember to flush"
# ritual, and the dump is cheap (handful of short scalars).
self._response.headers[HEADER_NAME] = json.dumps(self._keys)