Skip to content
result-rpc
Esc
navigateopen⌘Jpreview
On this page

Entities

Automatic invalidation and in-place updates by model + id — the identity graph over the denormalized cache.

Update your profile picture. The avatar in the header, the byline on every cached doc, and the member row in settings all show the new picture immediately — no query invalidated, no refetch issued, nothing written at any call site:

export const User = defineModel("user", {
  key: "id",
  shape: { id: wire.string, name: wire.string, avatarUrl: wire.url },
})

const setAvatar = app.procedure()
  .input(wire.object({ image: wire.file({ accept: ["image/*"] }) }))
  .output(User.codec)                     // ← returns WHO changed
  .mutation(...)

The mutation returned a user entity; the cache knows every query whose result contains user:u_1; each one is patched in place. That is the whole feature: automatic invalidation and automatic updates by model + id.

A model is to values what an error definition is to failures — a named, shared declaration. Doc.codec is the canonical shape; Doc.pick("id", "title") declares a projection (the key field is mandatory — an entity without its identity is just data). Use them anywhere in outputs, at any depth, including inside each other. The mechanics are the decode pass you already pay for: decoding brands entity objects, the runtime indexes every cached result by the entities it contains, and mutations that return entities patch by identity. There are no heuristics and no schema walking — an inline wire.object collects nothing, silently; composing outputs from model codecs is the one discipline this asks of query writers.

Patches follow the projection rule: merge only the fields the cached object already has (one model, one field vocabulary; projections are subsets). Fields the mutation didn’t return stay stale-until-refetch — correct and honest.

The division of labor

Identities handle field freshness. .affects() handles membership.

A rename updates every cached row by identity. Only “which cached lists should now contain this new doc” needs a declaration — the same boundary Graphcache draws with manual updaters, except ours is typed and lives in the contract. The mutation writer’s decision table:

The write changes… Use
Fields of an entity return the entity — auto-patch everywhere
Fields, but the output must stay scalar .writes(Doc, (input) => input.id) — invalidation by identity
List membership .affects(listQuery)
Entities the output can’t mention (cascades, deletes) touch(Model, id) in the handler

touch rides the response envelope as model:id keys — identities only, never values — and invalidates by identity client-side:

.mutation(({ input, context, touch }) => {
  await context.db.docs.delete(input.id)
  touch(Doc, input.id)                    // a deleted entity can't be returned
  return ok(true)
})

Optimistic by identity, trivial with client-minted ids

cache.updateEntity addresses the cache the way you think about it:

const rename = useResultMutation(client.doc.rename, {
  optimistic: (input, cache) => ({
    rollback: cache.updateEntity(Doc, input.id, (doc) => ({ ...doc, title: input.title })),
  }),
  onFailure: (_e, _i, ctx) => ctx?.rollback(),
})

One line patches the detail view, every list row, every breadcrumb. And if the client mints ids (cuid2, nanoid, uuidv7), optimistic creates stop being a reconciliation problem: the optimistic entity is born under its final identity, so the server’s response is a no-op patch or a field correction — nothing re-keys, nothing flickers, and the id doubles as a natural idempotency key. Add fractional indexing and order becomes a field too: a drag-reorder is one sortKey patch and every cached list re-sorts locally — no list invalidation for reorders, ever.

What this deliberately is not

There is no normalized store. Per-query results stay the source of truth — denormalized, exactly typed — with an identity index over them. The store-as-source-of-truth design (Graphcache, Apollo) exists to serve flexible queries and would trade away exact per-procedure output types, which everything else here is built on. Permanent non-goal.

Was this page helpful?