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#

JSON
{
	"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"
}
FieldRules
key1–128 characters of a-z A-Z 0-9 _ . : -. Prefixes like item:7 make listings easy.
valueAny JSON value except null, at most 64 KiB as JSON.
version1 when created, +1 on every set or merge. Starts again at 1 after a delete.

Operations#

OperationFlagWebSocketRESTEffect
setPsetPUT …/state/data/{topic}/{key}Create or replace the value.
mergePmergePATCH …/state/data/{topic}/{key}Apply a JSON Merge Patch. Creates the key if missing.
deletePdelDELETE …/state/data/{topic}/{key}Remove the key. Idempotent.
echoPechoPOST …/state/data/{topic}/{key}/echoSend the current value to subscribers again, unchanged.
readRtellGET …/state/data/{topic}/{key}The current value. tell answers this connection only.
listR—GET …/state/data/{topic}Every state of the topic's subtree, by topic and key.
subscribeSsub—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.

Text
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.

JSON
[
	{
		"op": "change",
		"topic": "board:42",
		"key": "item:7",
		"value": { "title": "Fix login", "column": "done", "order": 3 },
		"version": 6,
		"ts": 1792591100004
	}
]
  • value is the new value (after a set or merge, or the current one for an echo), and null when the key was deleted; version is then null too.
  • topic is 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.

TypeScript
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.

TypeScript
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.
TypeScript
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.

Try it#

Open the Playground in a new window ↗