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. The client's whole loop is: replace the window with snapshot.records; on changes, store the full doc of each added / updated record and drop the removed ids; render in the query's sort order (a map keyed by id holds membership, not order); on reconnect, replace the window with the new snapshot.
Endpoints
| Scope | Endpoint | Records |
|---|---|---|
| Cross-object | POST /v1/spaces/:spaceId/objects/query/subscribe |
one row per object in the space (the objects storage 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 |
| Files | POST /v1/spaces/:spaceId/objects/:objectId/files/query/subscribe |
an object's payload rows — see Downloading |
| Devices | POST /v1/devices/query/subscribe |
the account's devices — see Devices |
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; limit > 0 without sort is 400 request.invalid_field — a live window has to be ordered |
includeTotal |
populates total and hasNext in the snapshot frame |
projection |
shapes the snapshot and every later changes record — see Reading data |
mailboxCapacity |
per-subscriber event mailbox; default 256, minimum 16 |
driftBudgetPercent |
how much of the window may leave unreplaced before the stream closes; default 30 |
includeDeleted is not supported on a subscription. 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?}(an empty list is omitted).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 record or object is gone. The other reasons remove it from this view only: the same filtered, limited snapshot can still exclude it. A separate query by ID can establish whether it still exists.
An object deletion can produce a synthetic removal with an empty versionId. Apply the removal directly; do not discard it because a version comparison fails. Tombstone rows are not streamed: includeDeleted is snapshot-only and refused on /subscribe with 400 request.invalid_field.
Closing and recovery
| Reason | Cause |
|---|---|
server_shutdown |
the server received a signal (or, on a managed server, POST /v1/shutdown); in-flight streams emit this frame before the listener goes down (10 s deadline) |
deauthorized |
the account behind the stream was torn down in place (DELETE /v1/auth, or a switch to another account) while the server stays up — re-read GET /v1/auth before resubscribing |
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 of them: 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. Before every attempt read GET /v1/auth: authorized: false means clear the view and wait (a restart, or a managed host between accounts — the example below waits for the original account to come back; a host cancels the watcher on a deliberate sign-out), a different accountId means clear the view and stop. Make the same check after a 401, a failed request or a stream that ends without closed — an account switch can swallow the terminal frame.
overflow and drifted are split only so you can log and back off sensibly: a burst of overflow on a hot storage collection is the hint to raise mailboxCapacity (or to look at how slowly the consumer drains), 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, and wrap the stream in one retry loop: a closed frame, a stream that ends without one, and a failed request all mean "open a new POST and replace the window with its snapshot". Node.js 18+ or an allowlisted desktop renderer (an ordinary web page hits the server's CORS policy); SPACE is a space id, CHAT the general chat's root, ACCOUNT the accountId from GET /v1/auth. windowChanged is where your renderer goes — sort by -_ver.id for this query — and stop.abort() is what the view calls on close:
const API = "http://127.0.0.1:7001/v1";
const SPACE = "<spaceId>", CHAT = "<chatId>", ACCOUNT = "<accountId>";
const stop = new AbortController();
let messages = new Map();
function windowChanged() {
console.log(`${messages.size} messages in the current window`);
// Render [...messages.values()] sorted by the query's sort keys.
}
async function requireOK(res) {
if (res.ok) return res;
const body = await res.json().catch(() => null);
throw Object.assign(new Error(body?.error?.code ?? `HTTP ${res.status}`),
{ status: res.status });
}
async function expectedAccount(signal) {
const res = await fetch(`${API}/auth`, { signal });
const auth = await (await requireOK(res)).json();
if (!auth.authorized) {
throw Object.assign(new Error("Waiting for the original account to be authorized."), { status: 401 });
}
return auth.accountId === ACCOUNT;
}
function pause(ms, signal) {
return new Promise((resolve, reject) => {
signal.throwIfAborted();
const aborted = () => { clearTimeout(timer); reject(signal.reason); };
const timer = setTimeout(() => {
signal.removeEventListener("abort", aborted);
resolve();
}, ms);
signal.addEventListener("abort", aborted, { once: true });
});
}
// Returns after `closed` or clean EOF. Both return to the same retry loop.
async function subscribe(url, body, onFrame, signal) {
const res = await requireOK(await fetch(url, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), signal,
}));
if (!res.body) throw new Error("Subscription response has no body");
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buf = "";
try {
for (;;) {
const { value, done } = await reader.read();
if (done) return;
buf += value;
let boundary;
while ((boundary = /\r?\n\r?\n/.exec(buf))) {
const frame = buf.slice(0, boundary.index);
buf = buf.slice(boundary.index + boundary[0].length);
let event = "message";
const data = [];
for (const line of frame.split(/\r?\n/)) {
if (line.startsWith("event:")) event = line.slice(6).trim();
if (line.startsWith("data:")) data.push(line.slice(5).replace(/^ /, ""));
// Keepalive comments and fields this client does not use are ignored.
}
if (!data.length) continue;
const payload = JSON.parse(data.join("\n"));
onFrame(event, payload);
if (event === "closed") return;
}
}
} finally {
await reader.cancel().catch(() => {});
reader.releaseLock();
}
}
function onFrame(event, data) {
if (event === "closed" && data.reason === "deauthorized") {
messages.clear();
} else if (event === "snapshot") {
messages = new Map((data.records ?? []).map(r => [r.id, r]));
} else if (event === "changes") {
for (const ev of data) {
for (const r of ev.added ?? []) messages.set(r.id, r.doc);
for (const r of ev.updated ?? []) messages.set(r.id, r.doc);
for (const r of ev.removed ?? []) messages.delete(r.id);
}
} else return;
windowChanged();
}
async function run(signal) {
while (!signal.aborted) {
try {
if (!await expectedAccount(signal)) {
messages.clear();
windowChanged();
return; // another account is authorized
}
await subscribe(`${API}/spaces/${SPACE}/query/subscribe`,
{ objectId: CHAT, dataset: "chat_messages", sort: ["-_ver.id"], limit: 50 },
onFrame, signal);
} catch (e) {
if (signal.aborted) return;
if (e.status === 401) { messages.clear(); windowChanged(); }
// Retry transport/server failures, 401 and 429; other client errors need a fix.
if (e instanceof SyntaxError ||
(e.status >= 400 && e.status < 500 && ![401, 429].includes(e.status))) throw e;
console.warn("Subscription interrupted:", e.message);
}
await pause(1000, signal);
}
}
run(stop.signal).catch(e => { if (!stop.signal.aborted) console.error(e); });
// When the view closes: stop.abort();
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. --properties opens the cross-object stream; an object id plus --dataset opens a per-object one:
curl -N http://127.0.0.1:7001/v1/spaces/SPACE/objects/query/subscribe \
-H 'Content-Type: application/json' \
-d '{"filter":{"any.type":"page"},"sort":["-modifiedAt"],"limit":20}'
any query-subscribe SPACE --properties --filter '{"any.type":"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.
Records ship their full stored form — _ver included (and _traces / _deletedAt when present) — unless you send a projection, which shapes the snapshot and every later changes record alike, so the stream cannot widen on you. Hold one subscription per open view and size the window for the UI — the engine holds the window on the server side as well, so limit is memory on both ends.