Live updates
Any query becomes a subscription when you add live: true. The server sends the first result, keeps the query open, and sends what changed whenever a command touches the data it read. There is no subscription type to write, no event wiring on the server, and no refetching on the client.
Ask for it
// Stays open: every change anyone makes to this book arrives here, until stopLive() is called.
const stopLive = client.live<Book>("book", { id: "b3" }, { shape: "{ id stock }" }, (b, { initial }) => {
console.log(initial ? `Dune starts at ${b.stock}` : `Dune is now at ${b.stock}`);
});// Follows the stock as it changes, whoever buys or restocks.
function Stock({ id }: { id: string }) {
const { data } = useLive<{ stock: number }>("book", { id }, { shape: "{ id stock }" });
return <span data-stock={id}>{data ? `${data.stock} in stock` : "..."}</span>;
}POST /rayfold
Content-Type: application/rayfold+json
{"rayfold":"0.1","ops":[{"id":1,"op":"book","args":{"id":"b1"},"shape":"{ title stock }","live":true}]}What comes back
The first frame is the normal result, but without fin: the operation stays open.
{"id":1,"data":{"$type":"Book","title":"A Wizard of Earthsea","stock":3},"meta":{"cost":1}}A member of staff restocks the book with five copies. The server sends only the change:
{"id":1,"patch":[{"at":"","value":{"stock":8}}]}When the client stops listening, the operation ends:
{"id":1,"error":{"code":"canceled","message":"Canceled"},"fin":true}| Frame | When |
|---|---|
data without fin | the first result, and whenever a change cannot be described as a patch, such as rows in a new order |
patch | the change can be described: set for entities whose fields changed, at and list for parts of the result |
at with data | a deferred part of the first result (@defer, @lazy), in a frame of its own after it |
error with fin | the client cancelled, or running the query again failed, for example because the viewer lost access |
A change that leaves the result as it was sends nothing.
How the server knows
You do not tell the server what to watch. Every command's patch goes onto the server's change bus. Each live query remembers the entities its last result contained and the types it can reach. When a change touches one of them, the server runs the query again with the same arguments, shape and viewer, and sends the difference. Changes that arrive during that run are folded into one more run. A query whose result is an interface, such as query people: [Named], watches every entity type that implements it, so a newly created member shows up. On servers up to 0.2.1 it showed only after a reload.
Because the query runs again, every update passes the same policies as the first result, and a query can never show a field its viewer may not read. A server can also put changes from elsewhere, such as rows written by another system, onto the same bus.
Every change re-runs the query, so some queries are a poor fit. A search whose results depend on a keyword, not on any one entity, would re-run at full cost on every write to a type it can reach. Mark such a query @live(false) and the server refuses to open it live:
query search(q: String, page: PageArgs = { first: 20 }): Page<SearchHit> @live(false)A client asking for it with live: true is refused with invalid_argument, and the whole batch is refused before it runs; the same query still answers normally without live.
The bus belongs to one server. Behind a load balancer, a command that runs on the second server never reaches a live query held by the first. The screen keeps showing stale data, and no error says so. Give every server the same relay and the changes cross between them: MemoryRelay for servers sharing a process, PgRelay over Postgres LISTEN/NOTIFY for separate ones (Deployment).
On the client
The client applies patch frames to its cache exactly as it applies a command's patch. Every other query showing that book updates too, not only the live one. In React, only the components showing the changed data render again.
Connections
Over HTTP, a live query is a response that stays open. The TypeScript server sends an empty line when nothing has happened for 15 seconds (
keepAliveMs), so proxies do not close an idle connection. A client that stops reading does not make the server buffer for it forever: oncemaxBufferedbytes (8 MiB by default) wait unread, the server ends the batch and the response. Cancelling the response body ends the live query too.A live op gets no cache headers. Sent as a safe request (
Rayfold-Safe: true,GETorQUERY), it is streamed withCache-Control: no-store, like a plainPOST. Servers up to 0.2.1 buffered a safe request whole, so a live op sent that way never answered; the clients send live ops as plainPOSTs, which works with every version.To share one connection between many live queries, give the TypeScript client
createWebSocketTransport({ url: "wss://example.com/rayfold/ws" }). On the server, attach the endpoint to the same Node HTTP server your batch endpoint runs on:tsimport { attachWebSocket } from "@rayfold/server"; const http = createServer(handler); attachWebSocket(http, server, { viewer, allowedOrigins: ["https://app.example.com"] }); http.listen(4000);It serves
/rayfold/wsby default, speaks therayfold.0.1subprotocol, and checks the handshake'sOrigin. Browsers attach cookies to a WebSocket handshake, so without that check any site could open a socket as your user.maxMessagebounds an assembled message (1 MiB by default), andmaxBufferedwhat may wait for a client that stops reading (8 MiB). One socket carries many operations.{ "cancel": <id> }cancels one of them without closing the socket, even one sent in the same batch as others. A socket opened with a capability token closes with code1008when the token expires, and its live queries end withunauthenticated. On the JVM,RayfoldWebSocketdoes the same job.A dropped connection does not end the subscription. Both clients reopen it after half a second, doubling up to thirty, so a screen survives a deploy.
onErrorsays whether it is coming back ({ retrying }in TypeScript, the second argument in Kotlin). Only an error that would recur ends it.Reopening re-runs the query, so you get the current answer, not the changes you missed. A value that changed and changed back while you were away leaves no trace. If you need every step, use an event stream.
Stop by calling the function
livereturned, by unmounting the component, or by aborting the request.
Since 0.2.2
maxBuffered, and the Kotlin client reopening a dropped live query (with onError), came with 0.2.2.
Next
- Commands are what live queries react to: Commands and errors.
- Every step, not only the current answer: Streams and events.
- Show a change before the server confirms it: Offline and optimistic.
- The live chapter of the specification describes change detection exactly.