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)