Realtime
Subscribe
A subscription is a live version of a query: the same filter, sort and limit, but instead of one response you get a snapshot followed by a stream of windowed deltas over Server-Sent Events. The engine holds the window for you — no client-side diffing, buffering, or version bookkeeping.
Endpoints
| Scope | Endpoint | Records |
|---|---|---|
| Cross-object | POST /v1/spaces/:spaceId/objects/query/subscribe |
one row per object in the space (the objects collection) |
| Per-object dataset | POST /v1/spaces/:spaceId/query/subscribe |
rows of one dataset on one object (chat_messages, editor_blocks, a runtime dataset…) |
| Space list | POST /v1/spaces/query/subscribe |
the account's spaces — see Live space list |
The body is the same one …/query takes, plus two stream-only knobs:
{
"objectId": "obj_abc",
"dataset": "chat_messages",
"filter": { "creator": "A5k…" },
"sort": ["-_ver.id"],
"limit": 50,
"offset": 0,
"includeTotal": true,
"mailboxCapacity": 256,
"driftBudgetPercent": 30
}
| Field | Notes |
|---|---|
objectId, dataset |
per-object variant only |
filter, sort, limit, offset |
as in a snapshot query; sort is required when limit > 0 |
includeTotal |
populates total and hasNext in the snapshot frame |
mailboxCapacity |
per-subscriber event mailbox; default 256, minimum 16 |
driftBudgetPercent |
how much of the window may leave unreplaced before the stream closes; default 30 |
The field set is closed: an unknown top-level key answers 400 request.unknown_field. Errors raised before the stream opens (space.not_found, request.missing_field, …) come back as the normal JSON error envelope; once the response is 200 text/event-stream, problems are closed frames.
Frames
event: ready
data: {}
event: snapshot
data: {"records":[{"id":"msg_1","text":"hello","_ver":{…}}],"total":17,"hasNext":true}
event: changes
data: [{"versionId":"!!%>",
"added": [{"id":"msg_2","doc":{…},"ops":[{"type":"$set","path":[],"payload":{"text":"hi"}}]}],
"updated":[{"id":"msg_1","doc":{…},"ops":[{"type":"$set","path":["text"],"payload":"hello!"}]}],
"removed":[{"id":"msg_0","reason":"displaced"}]}]
: keepalive
event: closed
data: {"reason":"overflow"}
ready— sent once the subscription is registered. Wait for it before treating the stream as live.snapshot— sent once, right afterready.recordsis the materialized window;total/hasNextappear only withincludeTotal. The snapshot and the first event sit at adjacent versions with nothing missed in between, so integrate the snapshot first, then apply changes in order.changes— a JSON array of one or more events, each{versionId, added, updated, removed}.addedandupdatedcarry the full post-applydocplus the$set/$unsetopsof the triggering change. Most clients overwrite their local entry withdoc; clients that want atomic field merges applyops(path: []with an object payload is a multi-field set at the record root).$inc,$addToSetand$pullare never emitted — the engine collapses them to the merged$setbefore delivery, so non-Go clients never reimplement CRDT merge rules.: keepalive— a comment every ~25 s while idle, defeating idle proxy timeouts.closed— terminal.
versionId is the per-change DAG order on this peer. It is useful for fencing ("processed up to X") but is not comparable across devices.
removed carries a reason
| Reason | Meaning | What to do |
|---|---|---|
deleted |
the record was tombstoned | drop it for good |
filtered-out |
an update made it stop matching filter |
it still exists; drop from this view only |
displaced |
a sort-key change or a higher-ranked arrival pushed it past limit |
it still exists, just outside the window |
Only deleted means the object is gone. For the other two, a fresh snapshot would return the record again.
Closing and recovery
| Reason | Cause |
|---|---|
server_shutdown |
the server received a signal or POST /v1/shutdown; in-flight streams emit this frame before the listener goes down (10 s deadline) |
sdk_closed |
the space or the engine was closed |
overflow |
events arrived faster than the client drained them and the mailbox (mailboxCapacity) filled; the engine closes the stream rather than drop events |
drifted |
more than driftBudgetPercent of the window left without replacements; the engine refuses to re-query on the hot path |
Recovery is the same for all four: open a new POST and take the fresh snapshot. There is no replay across reconnects and no resume cursor — the new snapshot already reflects current state, which is strictly cheaper than reconstructing it from a backlog. overflow and drifted are split only so you can log and back off sensibly; a burst of overflow on a hot collection is the hint to raise mailboxCapacity, a stream of drifted on a churny list is the hint to raise driftBudgetPercent or widen limit.
Note. Drift detection needs a window to measure against: with
limit: 0there is no window auto-shift and no drift safety net. Always subscribe with a limit.
Client example: fetch streaming, no EventSource
Windowed subscribes are POST, so the browser's EventSource cannot open them. Parse the SSE frames from a streaming fetch body instead:
async function subscribe(url, body, onFrame) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error((await res.json()).error.code);
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buf = "";
for (;;) {
const { value, done } = await reader.read();
if (done) return; // connection dropped without `closed`
buf += value;
let i;
while ((i = buf.indexOf("\n\n")) >= 0) { // one frame per blank line
const frame = buf.slice(0, i); buf = buf.slice(i + 2);
let event = "message", data = "";
for (const line of frame.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) data += line.slice(5).trim();
// lines starting with ":" are keepalive comments — ignore
}
if (data) onFrame(event, JSON.parse(data));
}
}
}
const window = new Map();
function run() {
subscribe("http://127.0.0.1:7001/v1/spaces/SPACE/query/subscribe",
{ objectId: "CHAT", dataset: "chat_messages", sort: ["-_ver.id"], limit: 50 },
(event, data) => {
if (event === "snapshot") for (const r of data.records) window.set(r.id, r);
if (event === "changes") for (const ev of data) {
for (const r of ev.added) window.set(r.id, r.doc);
for (const r of ev.updated) window.set(r.id, r.doc);
for (const r of ev.removed) window.delete(r.id);
}
if (event === "closed") setTimeout(run, 500); // every reason: resubscribe
}).catch(() => setTimeout(run, 2000));
}
run();
From the shell, curl -N shows the raw frames, and the CLI prints one JSON object per frame ({"event": …, "data": …}) so the stream pipes through jq:
curl -N http://127.0.0.1:7001/v1/spaces/SPACE/objects/query/subscribe \
-H 'Content-Type: application/json' \
-d '{"filter":{"any.types":"page"},"sort":["-modifiedAt"],"limit":20}'
any query-subscribe SPACE --filter '{"any.types":"page"}' --sort -modifiedAt --limit 20
any query-subscribe SPACE CHAT --dataset chat_messages --sort -_ver.id --limit 50 --total \
| jq 'select(.event=="changes") | .data[]'
What to subscribe to
objects(cross-object) — one row per object holding computed property values. Creates land asadded, property writes asupdated, deletes asremovedwithreason: "deleted"— the canonical place to observe deletion across a space.editor_blocks— the block tree of one document; every frame ships the full post-apply block, whether it came from a block write or a bulk markdown import. See Editor.chat_messages— one message peradded; a reaction toggle arrives as anupdatedevent with a$set/$unsetop underreactions.<emoji>.<accountId>. See Chat.
Every record ships its full stored form, including _ver (and _traces / _deletedAt when present). The projection field is accepted but not applied yet; strip system fields client-side if you want a leaner model. Hold one subscription per open view and size the window for the UI — the server streams straight from the indexed store and is indifferent to window size; the memory cost is the client's.