Status: accepted · 2026-09-21 · completes ADR-0024
ADR-0024 made an invalidation reach a resource that did not exist when it happened: the store remembers what it invalidated, and the next run whose tags match is forced. That works, and it works for any transport, because all it does is raise a flag every client already honours.
What it does not do is take the wrong answer out of the cache. The stale entry sits there until somebody asks for it and is refused, which has two costs:
ttl expires or the max bound evicts it.The precise thing to do is obvious — throw those entries away when the invalidation happens — and it needs something the cache does not have: a way to tell which entries an invalidation is about.
createCacheClient in front
of an algorithm has no tags and no store, and must not need either.forgetTagged when it invalidates.Option 3, and it is deliberately the second half of a fix rather than a replacement for the first.
The request carries what it has been declared to be about.
readonly declared?: readonly Tag[];
A client declares a document’s tags before it looks in its cache — it has to, because an invalidation arriving mid-flight had to find the run (ADR-0022) — so by the time an entry is created, the request knows. The cache copies that onto the entry and never reads it again except to forget:
const entry: Entry = { value: undefined, tags: request.declared ?? [], … };
The key is untouched. It is still scope + request, as
ADR-0023 left it. Two call sites
carrying tag('directory') and asking for different URLs are still two
entries; the tags on them are a label, not an address. That distinction is the
whole of this ADR.
The store empties the caches it was handed, and only those.
const cache = createCacheClient({ ttl: 30_000 });
const store = createData({ caches: [cache] });
caches takes anything with forgetTagged, declared structurally, so the
store depends on no cache in particular. A cache that is not passed is not
touched — which is the right default for a transport’s own, where force
remains the only contact.
Both halves stay. The store’s memory (ADR-0024) reaches caches it was never
given, including somebody else’s; forgetTagged is precise and immediate for
the one it was. An application that passes its cache gets both, and the second
makes the first almost never fire.
cache.evict is
Apollo’s, and an entity is not a tag.forgetTagged will never match. That case is handled one
layer up — ADR-0024 makes the
run go again — rather than by giving the cache a way to relabel an entry
after the fact, which would mean a second key lookup on every successful run
to fix a case that costs one request.read(key, produce) in front of an
algorithm has no tags, gets an empty array, and is never matched by
forgetTagged — silence is the right answer where nothing was declared.forgetTagged walks the entries once per invalidation, comparing tags with the
same matcher the store uses. The bound is max — 100 by default — against an
event that happens when a person presses a button.
An entry gained a readonly Tag[], which for a fetch client is the array the
resource already holds; nothing is copied per request.
Strictly less than before: entries known to be wrong are dropped at
invalidation instead of waiting for a ttl.
One line to opt in, and the failure mode without it is the old behaviour rather than a broken one. The guide says plainly which half does what, because the difference matters exactly once — when somebody asks why a urql cache is not being emptied, and the answer is that it is urql’s.
caches the default by passing the cache implicitly. There is no
implicit cache: createFetchClient may be given one, several clients may
share one, and an application may have none. Something that must be passed to
be true is better than something that is true only sometimes.