A normalized cache with a tag graph has a characteristic failure mode, and it is not "it doesn't work". It is it did something surprising and there is no lever to find out why.
The surprise is rarely a crash. A query refetched that should not have — annoying, visible, findable. Or, far worse because it is silent by construction, a query did not refetch that should have, and the screen is quietly wrong for as long as the session lasts. A missed invalidation produces no event, no error and no request. There is nothing to breakpoint on.
@forge-go/client-devtools is where declaring tags rather than guessing dependencies gets cashed in: the graph is inspectable, so the question has an answer.
Not published to npm yet. See Installation.
Attaching01
if (process.env.NODE_ENV !== 'production') {
void import('@forge-go/client-devtools').then((devtools) => {
globalThis.forge = devtools.attach(client);
});
}Written that way, a production build contains neither this package nor the branch that would have loaded it.
Why did this query not refetch?02
The one that matters.
forge.whyNotRefetched('GET /orders');outcome: missed
reason: `GET /orders` did not refetch because none of the 1 tag(s)
mutation POST /orders raised are tags it carries. The two sets are
disjoint.
invalidated: Order:9
carried: Customer:c1, Order:1, Order:2, Order[]
matched: (none)
nearest: Order:9 vs Order[] (instance-vs-collection)
suggestion: the mutation invalidated the instance `Order:9` but this query
provides the collection `Order[]`, and the two never intersect. A
query only carries `Order:9` once a response has actually put that
entity in its result — which a create never has. Add `Order[]` to
the operation's Invalidates.Read the output as a diagnosis, not a dump. invalidated is what the mutation raised, carried is what the query provides, matched is the intersection — and when that is empty, nearest names how two tags that ought to have met failed to.
Five outcomes, because they are five different bugs
| outcome | what it means |
|---|---|
missed | The tag sets are disjoint. A declaration is wrong; read nearest. |
stale-while-unmounted | They intersected, but nothing has the query mounted, so no request was made. Correct behaviour, and the most common false report of a broken cache. It refetches on the next mount. |
placed | A placement callback answered. If the screen is wrong, the callback is wrong. |
refetched | It did refetch. The problem is downstream of the cache. |
not-tracked | This key is not one the cache holds. A key includes its arguments. |
Conflating any two of these costs an afternoon. stale-while-unmounted in particular looks exactly like a bug and is the cache doing the right thing.
nearest reports instance-vs-collection, collection-vs-instance, different-instance, case or scoped, each with the declaration to change.
The sixth cause, reported separately
A tag template that resolved to nothing and was skipped. Order:{res.id} against a response with no id invalidates nothing and says nothing — it is invisible by construction, so it is not an outcome but a field:
forge.whyNotRefetched('GET /orders').cause.unresolved;If you have a mutation that seems to do nothing, look here first.
Why did this query refetch?03
forge.whyRefetched('GET /orders');
// `GET /orders` refetched because mutation PATCH /orders/{id} invalidated
// Order:1, Order[], of which it carries Order:1, Order[].reason is invalidation, mount or manual; the cause is the mutation or stream frame batch responsible, recovered from the log.
Not sure which question you have? forge.explain(key) picks.
What is in the cache04
forge.entity('Order:7');
// { key, type, id, version, frameAt, fields, refs, dependents }version bumps only when the data actually moved, so a refetch that changed nothing leaves it alone — which is how you confirm a poll is genuinely free. frameAt is non-zero when a stream frame wrote it. References appear in fields as {__ref: 'Customer:c1'}, which is what the cache genuinely holds.
forge.dependents('Customer:c1');This reaches through nested references: a list of orders rendering order.customer.name depends on Customer:c1 without ever declaring it.
Which sockets are open05
forge.sockets();
// [{ endpoint: '/ws/orders', connected: true, refs: 2, opens: 1,
// reconnecting: false, channels: [{ channel: '/ws/orders', handlers: 2 }] }]Ten components on the same live query are one socket. opens > 1 means it dropped and came back — and a reconnect means frames were missed, which is what gap recovery exists for.
Asking without doing06
forge.wouldInvalidate(ops.createOrder, { body: {} }, { id: 9 });
// { tags: ['Order:9'], unresolved: [], missed: ['Order:9'], hits: [...] }missed is tags no mounted query carries. Asking what a mutation would do must not be answered by doing it, so this resolves templates through the same resolveTags the invalidator uses and reads the tag index — no request, no write.
Inspection does not mutate07
Not one function here calls getState, fetch, read or denormalize, and that is less obvious than it sounds. The natural way to read a query — cache.getState(meta, args) — calls open, which moves the record to the back of the LRU order, creates one if missing, rehydrates the skeleton and builds store memos.
An inspector built on that would change which query is evicted next, change what a placement callback is handed as current, and populate memos for queries nobody rendered — all only while somebody has the panel open, which is the worst possible failure mode for a debugging tool.
The whole read surface is map lookups and counters, and every snapshot is a copy: a panel that writes to snapshot.fields cannot move the store.
The log is bounded08
A fixed-size ring, 500 entries by default, allocated once and never grown. When it fills, the oldest is overwritten and dropped counts how many are gone, so a timeline that begins mid-story says so.
What is in an entry is bounded too — no response body, no error object, no rehydrated value. Tags are resolved and copied on arrival, arguments reduced to a truncated cache key, an error to a short string. Nothing retained can keep a page of data alive.
Across an identity change09
setPrincipal drops the whole cache, so nothing recorded before it describes the store that exists after. The inspector does not pretend to span the boundary: it records a principal entry, increments a session counter, and stamps every entry with its session. Queries that re-mount under the new identity are logged as mounts, not as refetches of a query whose data no longer exists.
Zero production cost10
The binding constraint, and it is checked against the built output rather than argued from the source. The core's seam is one nullable field on QueryCache and five optional calls; with devtools never imported, a production bundle contains none of this package.
That is what makes the dynamic-import guard above the recommended shape rather than a nicety.