Forge
1.x
Docs/Forge/Invalidation
Open

Reading6 min
Updated5 Aug 2026
Sourcev1/web-client/invalidation.mdx

Every operation carries two tag sets. Provides is what its result can satisfy; invalidates is what it makes stale elsewhere. A mutation settles, the runtime intersects its invalidates against the tags mounted queries provide, and the matches refetch.

Same-entity effects are derived. You declare only the edges a reader could not predict.

What is derived01

Two tag shapes exist: Order:{id} for one record and Order[] for the collection.

OperationProvidesInvalidates
GET /orders/{id}Order:{id}
GET /orders (collection)Order:{id}, Order[]
POST /ordersOrder:{id}Order[]
PATCH /orders/{id}Order:{id}Order[]
DELETE /orders/{id}Order[]

The rule in one line: every non-GET operation touching entity E invalidates E[]. A DELETE provides nothing, because the record it names no longer exists.

This is real output from the orders API, with nothing declared:

  createOrder: {
    method: 'POST',
    path: '/orders',
    entity: 'Order',
    rootType: 'Order',
    provides: ['Order:{id}'],
    invalidates: ['Order[]'],
    responseCodec: 'Order',
  },
  deleteOrder: {
    method: 'DELETE',
    path: '/orders/{id}',
    entity: 'Order',
    rootType: 'Order',
    provides: [],
    invalidates: ['Order[]'],
    responseCodec: 'Order',
  },

Why PATCH is included

Including PATCH looks over-eager, and it is. A patch only changes list membership when it touches a field some mounted list filters on, and the server cannot know which lists a browser has mounted.

The asymmetry is what settles it. Over-refetching is a performance defect: it shows up in a profiler, on the first page you look at, and it has an explicit escape hatch. Under-refetching is a correctness defect: it shows up as a stale row a user reports three weeks later, with no event, no error and no request to find. The default is chosen to be wrong in the direction you can measure.

Note

Note what a PATCH does not need to invalidate: the record itself. A response carrying the updated Order is normalized straight into the store, so every view referencing Order:7 re-renders without a request. Only membership questions reach the network.

Declaring cross-entity effects02

WithInvalidates is for edges nobody could derive — creating an order moves inventory and touches a customer:

r.POST("/orders", createOrder,
    forge.WithOperationID("createOrder"),
    forge.WithRequestSchema(&CreateOrderRequest{}),
    forge.WithResponseSchema(201, "Order", &Order{}),
    forge.WithInvalidates("Inventory[]", "Customer:{req.customerId}"),
)

Declared tags merge with the derived ones, deduplicated and sorted:

  createOrder: {
    method: 'POST',
    path: '/orders',
    entity: 'Order',
    rootType: 'Order',
    provides: ['Order:{id}'],
    invalidates: ['Customer:{req.customerId}', 'Inventory[]', 'Order[]'],
    responseCodec: 'Order',
  },

Tag templates

A tag naming a specific record needs a value from the request or the response. Templates are resolved per call.

TemplateResolved from
{req.customerId}the request body
{res.customer.id}the response body
{customerId}path, then query, then request body, then response body — first match wins

Prefer the explicit forms. A bare {customerId} is readable until two of those four sources have one, and then it silently resolves to whichever comes first.

Warning

A template that resolves to nothing invalidates nothing and reports nothing — it is the one failure in this system with no symptom at all. @forge-go/client-devtools surfaces it as cause.unresolved; see Devtools.

Suppressing a derived tag03

When an endpoint genuinely cannot change list membership, say so:

r.PATCH("/orders/:id/notes", updateNotes,
    forge.WithOperationID("updateOrderNotes"),
    forge.WithRequestSchema(&UpdateNotesRequest{}),
    forge.WithResponseSchema(200, "Order", &Order{}),
    forge.WithoutInvalidation("Order[]"),
)
  updateOrderNotes: {
    method: 'PATCH',
    path: '/orders/{id}/notes',
    entity: 'Order',
    rootType: 'Order',
    provides: ['Order:{id}'],
    invalidates: [],
    responseCodec: 'Order',
  },

The order itself still updates everywhere — provides: ['Order:{id}'] is untouched and the response is normalized. What is suppressed is only the refetch of every mounted list.

Reach for this when a profiler has shown you the refetch and you can argue no filterable field changed. Suppressing on the grounds that it seems unlikely is how a stale row gets shipped.

Stream bindings04

A socket frame is a mutation the client did not initiate, so it uses the same tag vocabulary and the same code path.

_ = r.WebSocket("/ws/orders", ordersSocket,
    forge.WithWebSocketMessages(&Order{}, &Order{}),
    forge.WithStreamBinding(
        forge.Emits[Order]("order.created"),
        forge.Emits[Order]("order.updated"),
        forge.Emits[Order]("order.deleted"),
        forge.Emits[Order]("order.fulfilled").As(forge.StreamPatch).Invalidates("Shipment[]"),
    ),
)

Intent is read from the message-name suffix:

SuffixIntentDefault invalidation
created, addedupsertOrder[]
deleted, removedevictOrder[]
anything elsepatchnone

Merging a payload is the safe reading of an unrecognised name: it updates what is already cached without inventing or destroying membership. Override with .As(...) and .Invalidates(...) for names outside the convention.

That declaration produces this, in the AsyncAPI document:

"x-forge-stream": [
  { "message": "order.created",   "entityType": "Order", "intent": "upsert", "invalidates": ["Order[]"] },
  { "message": "order.updated",   "entityType": "Order", "intent": "patch",  "invalidates": null },
  { "message": "order.deleted",   "entityType": "Order", "intent": "evict",  "invalidates": ["Order[]"] },
  { "message": "order.fulfilled", "entityType": "Order", "intent": "patch",  "invalidates": ["Shipment[]"] }
]

order.updated costs no request: the payload is an Order, the normalizer patches Order:7, and every mounted view depending on it re-renders. Only created and deleted reach the network, and only for the collection.

Warning

Stream bindings ride on the AsyncAPI document, and forge client generate reads one specification file at a time. Generating from openapi.json gives you REST operations, ops.ts and hooks.ts with an empty streams table; generating from asyncapi.json gives you the WebSocket client but no operation manifest, because the manifest is only emitted when the document has REST endpoints.

Producing one package with both from Forge's two documents is not reachable through the CLI today. Track it under Not yet shipped.

WithWebSocketMessages is what puts message schemas in the AsyncAPI document. Without it the channel is declared but carries no message types, and no WebSocket client is generated for it.

Where this lands in the spec05

DeclarationExtensionDocument
derived tagsprovides / invalidates in ops.tscomputed at generation
WithInvalidatesx-forge-invalidates on the operationOpenAPI
WithoutInvalidationx-forge-no-invalidation on the operationOpenAPI
WithStreamBindingx-forge-stream on the channelAsyncAPI

All of them round-trip through YAML as well as JSON.