Concepts
State Broker
Shared JSON states with versions and change notifications. Set, merge, delete, echo, tell; listings, TTLs and size limits.
The State Broker stores states: the current JSON value of a key in a topic, with a version number. Every change is pushed to the subscribers of the topic and of its super topics. Only the current value is kept; there is no history of states.
States#
{
"topic": "board:42",
"key": "item:7",
"value": { "title": "Fix login", "column": "doing", "order": 3 },
"version": 5,
"inserted_at": "2026-10-21T13:51:37.599068Z",
"updated_at": "2026-10-21T13:58:02.112907Z"
}| Field | Rules |
|---|---|
key | 1–128 characters of a-z A-Z 0-9 _ . : -. Prefixes like item:7 make listings easy. |
value | Any JSON value except null, at most 64 KiB as JSON. |
version | 1 when created, +1 on every set or merge. Starts again at 1 after a delete. |
Operations#
| Operation | Flag | WebSocket | REST | Effect |
|---|---|---|---|---|
| set | P | set | PUT …/state/data/{topic}/{key} | Create or replace the value. |
| merge | P | merge | PATCH …/state/data/{topic}/{key} | Apply a JSON Merge Patch. Creates the key if missing. |
| delete | P | del | DELETE …/state/data/{topic}/{key} | Remove the key. Idempotent. |
| echo | P | echo | POST …/state/data/{topic}/{key}/echo | Send the current value to subscribers again, unchanged. |
| read | R | tell | GET …/state/data/{topic}/{key} | The current value. tell answers this connection only. |
| list | R | — | GET …/state/data/{topic} | Every state of the topic's subtree, by topic and key. |
| subscribe | S | sub | — | Change notifications for the topic's subtree. |
Every change notifies, whichever route made it: there are no silent writes. A delete notifies only if the key existed.
Merge#
merge applies a JSON Merge Patch (RFC 7396): objects
merge recursively, a null member removes that member, and arrays and other values are replaced
whole. The patch must be an object.
current { "name": "Ana", "pos": { "x": 1, "y": 2 }, "tags": ["a"] }
patch { "pos": { "y": 5 }, "tags": ["b"], "name": null }
result { "pos": { "x": 1, "y": 5 }, "tags": ["b"] }Merges are applied atomically on the server. Two clients merging different members at the same
time never lose each other's change — the reason to prefer merge over read-modify-set. Writes to
the same member are last-writer-wins.
Change notifications#
sub (needs S) delivers changes only. It doesn't send current values when you subscribe;
read them with tell, or list them over REST, right after subscribing.
[
{
"op": "change",
"topic": "board:42",
"key": "item:7",
"value": { "title": "Fix login", "column": "done", "order": 3 },
"version": 6,
"ts": 1792591100004
}
]valueis the new value (after a set or merge, or the current one for an echo), andnullwhen the key was deleted;versionis thennulltoo.topicis the exact topic that changed.- Delivery is live and best-effort, once per connection, like the Event Broker's.
Versions and ordering#
Each key's version grows by one on every write. Keep the highest version you have seen per key
and drop any notification or reply with a lower one: it is older than what you already show.
const seen = new Map<string, number>();
socket.onChange(({ topic, key, value, version }) => {
const id = `${topic}\n${key}`;
if (version === null) {
seen.delete(id); // deleted: a new key starts again at 1
return remove(id);
}
if (version < (seen.get(id) ?? 0)) return; // out of order
seen.set(id, version);
render(id, value);
});An echo repeats the current version, so keep equal versions. There are no conditional writes
(compare-and-set); design so that last-writer-wins is acceptable, or coordinate through a state that
names who holds a lock (see Collaborative editing).
Listing#
GET /api/{domain}/state/data/{topic} lists the topic's subtree, ordered by topic then key (byte
order), 100 per page by default and at most 1000.
let page = await acme.stateBroker.data.list('board:42', { prefix: 'item:', limit: 1000 });
while (page.length === 1000) {
const last = page[page.length - 1];
render(page);
page = await acme.stateBroker.data.list('board:42', {
prefix: 'item:',
limit: 1000,
after_topic: last.topic,
after: last.key
});
}
render(page);after_topic and after together are the cursor: the last row of the previous page.
Expiry and size#
Without a config, states never expire. Per Main Topic you can set (needs the claim's topic flag
W):
ttl_seconds: remove states not written for this long (measured from their last set or merge);max_states: keep at most this many states in the family, the most recently written.
await acme.stateBroker.topics.put('presence', { ttl_seconds: 120 });
await acme.stateBroker.topics.put('cursors', { ttl_seconds: 60, max_states: 5_000 });A state written once (a document's title, say) expires one TTL after it was written even if the document is still in use: choose TTLs per Main Topic with that in mind, and keep long-lived data in a Main Topic with a long TTL or none.