Skip to content

Decide how cached pages are invalidated when the data changes #117

Description

@davidtaing

TL;DR

  • The problemThe directory reads from the database #53 put the directory and /p/[handle] behind Next's cache, so a revoked badge or a withdrawn profile can keep being served after the database says otherwise. It fails open and looks exactly like a working directory.
  • The fix — attach cache tags to the Supabase reads and purge on write. The two purges are not interchangeable: revocation needs updateTag, which expires immediately and is Server-Actions-only, while a bio edit can take revalidateTag(tag, "max"). Serving a pulled badge one more time is the whole failure this closes.
  • To decide — how tags attach to a supabase-js query, since there is no fetch of ours to hang them on: cacheComponents: true with 'use cache' and cacheTag(), or wrapping the client's fetch. Also whether a practitioner's own edit purges (read-your-own-writes), and whether the out-of-band gap — psql, the dashboard — is closed with an authenticated webhook or written down as accepted.
Full context — the reasoning, the constraints and what has already been ruled out. This is the part a coding agent should read.

#53 puts the directory and /p/[handle] behind Next's cache. That is correct for a read-heavy public directory, but it means a revoked badge or a withdrawn profile can keep being served from a cache after the database says otherwise — the failure AGENTS.md describes as silent, fails-open and indistinguishable from a working directory. This issue decides how the application invalidates a cached page when the data behind it changes.

#53 names the tags — practitioner:<id> and practitioners — and does nothing else with them, because it has no write path to purge from. Everything below is what #14 needs before its writes are correct.

What the Next 16 docs settle, and what they leave open

Established by reading node_modules/next/dist/docs, because most published advice on this predates 16:

  • updateTag(tag) expires immediately. The next request waits for fresh data. Server Actions only — not Route Handlers, not Client Components.
  • revalidateTag(tag, "max") marks stale and serves stale-while-revalidate — the old content goes out once more while fresh data is fetched behind it. Callable from Server Functions and Route Handlers.
  • Bare revalidateTag(tag) with no second argument is deprecated.
  • Tags must be attached to the cached data first, either with fetch(url, { next: { tags: [...] } }) or with cacheTag() inside a 'use cache' function.
  • unstable_cache has been replaced by use cache in 16.

The difference between the two purges is load-bearing here, not a tuning choice. Stale-while-revalidate is right for a bio edit and wrong for revocation: it serves the pulled badge or the withdrawn profile exactly one more time. So revocation wants updateTag, which constrains it to a Server Action — consistent with #72, which already requires every admin action to go through a Server Action calling the RPC rather than a client writing to PostgREST.

The decision

How do tags get attached to a Supabase query? We query through supabase-js, so there is no fetch call of ours to hang next.tags on.

  1. Enable cacheComponents: true and use 'use cache' with cacheTag(). The sanctioned Next 16 route. Costs a config change with application-wide caching semantics, which is why it is a decision and not a detail.
  2. Wrap the client's fetchcreateClient accepts global.fetch — and inject next.tags. No config change, but varying tags per query through a shared client is awkward and easy to get subtly wrong.

Pick one and record why. Whichever wins, revalidate stays as the backstop clock rather than the freshness mechanism.

Also decide: does a practitioner's own edit purge?

#53 specifies purging on status and verified changes, both Bluehex-owned. It says nothing about a practitioner rewriting their bio, which falls through to the clock — so they edit, see no change, and reasonably conclude it is broken. This is the read-your-own-writes case updateTag exists for. It is cheap to include and easy to forget.

The gap this does not close by itself

Anything changing data outside the applicationpsql, the Supabase dashboard, a future background job — purges nothing, and the cache goes stale with no signal. Closing it needs a Supabase database webhook calling a Route Handler that calls revalidateTag (a Route Handler cannot use updateTag), authenticated with a shared secret so it is not an open purge endpoint anyone can use to stampede the cache.

Worth deciding whether to close it now or to write down that the application is the only sanctioned writer.

Done when

  • The tagging mechanism is chosen, implemented, and the reasoning recorded.
  • Revocation expires immediately rather than serving one more stale response, and there is a test that would fail if someone swapped it for stale-while-revalidate.
  • It is settled whether a practitioner's own edit purges their page.
  • The out-of-band gap is either closed with an authenticated webhook or documented as accepted.

Related: #53 (defines the tag names), #14 (the writes that need this), #72 (already requires admin actions to be Server Actions).

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: appApplication code outside UI and schemaenhancementNew feature or requesthitlNeeds a human (decision/design/review)size: MA weekend for someone new to this codebase

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions