Presence
Who is here, what they are editing, where their cursor is: ephemeral state that belongs next to the documents, not in them. @alexify/syncom/presence keeps it per node — never in the log, never as an op — and the sync plugin carries it over the same links the ops take.
import { createPresence } from '@alexify/syncom/presence';
const presence = createPresence(storage, { scope: 'Doc', timeout: 30_000 });
storage.sync.presence(presence); // carried to and from every host
await presence.set({ name: 'ada', doc: id, cursor: await storage.cursor(id, 'body', 12) });
presence.on('change', ({ added, updated, removed }) => render(presence.states()));
presence.get(peerNode); // { name: 'bob', … } or undefined
await presence.delete(); // leaves: null for everyoneThe model
- One state per node, a JSON object of at most 64 keys, 8 deep, 16 KiB as JSON (
PRESENCE_SIZEotherwise). Everyset()is newer than the last by this node's own clock, so the newest wins wherever two arrive out of order; a state is gone when it is not renewed withintimeout— this node's renews itself, a third of the timeout at a time — anddelete()leaves at once with a null state. - Signed when the node can sign. With the signing plugin registered, every state goes out signed with the node's Ed25519 key over
[1, node, clock, ts, state]in canonical JSON, and a state received is verified against the node it claims: a signature that does not verify is forged and dropped, and undersync.signatures: 'require'an unsigned state is refused. Without the plugin states travel unsigned. - A scope per instance.
scopeis the sync scope the states are of ('*', the global one, by default): a host relays them to the peers of that scope. One instance per scope a node takes part in. - Nothing of it is written — not to the log, not to the chain, not to a record. A
Recordnever sees it; history never shows it.
Over the hub
storage.sync.presence(instance) makes the sync manager carry an instance: every update it emits goes to every connected host as the fire-and-forget event syncom/presence, a host's events come back to it, and at hello a host hands this node what its room holds. On the host, the unit's inbound event on.presence checks an update — every entry the greeted node's own, of a scope the peer asked for, within unit({ presence: { rate } }) updates a second (20), signed under 'require' — refuses what fails (counted as syncom.presence.refused, logged as sync.presence.refused, announced as refused on storage.sync, never relayed), relays the rest to the wrpc room syncom:<scope> but the sender, and keeps the newest state per node (at most presence.max, 1 024, per scope; for presence.timeout, 30 000 ms) to hand to the next newcomer. A hub that only relays needs no instance of its own; one that takes part registers one like any node.
// the host
const router = defineRouter({ syncom: storage.sync.unit({ access: 'public', presence: { rate: 20 } }) });
// a peer, over wrpc: the adapter maps `send` to client.sendEvent and the unit's events
const storage = await open({ plugins: [sync, signing], sync: { connect: (node) => createWrpcLink(client) } });A link of the application's own carries it with the optional send(event, data) of the link contract and the syncom/presence event it raises; a transport that is not syncom's at all — a WebRTC room, a broadcast channel between tabs — carries encode() and feeds apply():
mesh.broadcast('presence/state', presence.encode()); // everything known here
mesh.on('presence/state', (update) => presence.apply(update)); // the newest per node, the forged droppedapply() resolves to what it counted — { applied, stale, forged, unsigned, refused } — and with { from: node } refuses an entry that is not that node's, which is how a host holds a peer to its own name. An update with entries refused raises refused on the instance — { from, forged, unsigned, refused } — and, carried by storage.sync, counts them in syncom.presence.refused: a stale state is routine, a forged one is not.
Cursors in a state
A cursor is a token that survives the edits around it, so a selection shared through presence stays right while others type: cursor() makes it, position() reads it back as { index, side } on the node that renders it. A state is replaced whole on every set(); keep what changes often (a caret) apart from what does not (a name, a color) if the size matters, or send both — 16 KiB is the bound.