Build · Client integration

Client implementation patterns

Recover an event projection, retry a publication, page through filtered history, and work with versioned state.

These recipes focus on client responsibilities owned by your application. The quickstart is a complete runnable program; fragments below identify the application-owned pieces they assume.

Retry one logical publication

A lost HTTP response does not tell you whether the server committed the event. Keep one clientEventId for that logical send, along with its unchanged metadata and payload, and reuse it when retrying.

TypeScript
const pendingSend = {
  clientEventId: crypto.randomUUID(),
  channelId: "chat",
  type: "text.message.committed",
  payload: {text: "Please check the shipment."},
};

// Your application's Retry button calls this same function with pendingSend.
const send = () => room.publish(pendingSend);
await send();

The key is unique across the room, not just a channel or actor. The runtime validates the original actor and publication metadata before returning a prior event. For a caller allowed to read the original event, compatible retries are first-write-wins even if the payload changed. For a publish-only caller that cannot read it, only a semantically equal JSON payload can receive the original acknowledgment. Conflicting identity or metadata is rejected without revealing the earlier payload.

Use a new key for a new user action or an edit. Do not infer that a changed retry payload replaced the event. The SDK does not automatically retry HTTP publication or expose a structured HTTP error type; choose retry UI and backoff at the application layer. Read the implementation and idempotency tests.

Persist a projection and its progress

For a consumer that must survive a process restart, save the projection and the cursor, not just a sequence number. Restoring cursor 500 with an empty UI model silently omits the first 500 records needed to rebuild it.

Use a storage key that includes room ID, participant identity, and a versioned selector/projection definition. A newly authorized participant or a broader selector may need historical events that an old cursor already skipped.

The database functions below are application-owned. applyOnceAndSaveCursor must deduplicate by event ID, update the projection, and save event.seq in one local transaction.

TypeScript
const saved = await database.loadProjection(projectionKey);
const subscription = room.subscribe({
  selector: {channelIds: ["chat"]},
  afterSeq: saved?.cursor ?? 0,
  onEvent: event => database.applyOnceAndSaveCursor(projectionKey, event),
  onCheckpoint: cursor => database.saveCursor(projectionKey, cursor),
});

Restore the saved projection into your view before consuming new events. These database operations are pseudocode, not SDK methods or an RMC output-plus-checkpoint feature. The local transaction keeps projection writes and their progress consistent. External side effects still require an independent idempotency mechanism.

If a handler fails, preserve subscription.cursor, show the error, and let recovery create a new subscription from that position. Do not move a cursor forward in a finally block after a failed handler.

Hydrate state, then follow updates

resumeRoom creates a handle and fetches its authorized snapshot. It does not open a subscription. Subscribe explicitly after hydrating the resource you need.

TypeScript
const {room, snapshot} = await client.resumeRoom(roomId, token);
const documents = new Map((snapshot.state ?? []).map(doc => [doc.key, doc]));
const updates = room.subscribe({
  afterSeq: snapshot.last_seq,
  selector: {eventTypes: ["state.patched"]},
  onEvent: event => applyNewerState(documents, event.payload),
});

applyNewerState is an application-owned function, not an SDK method. It must validate the complete state document schema, compare the incoming version with documents.get(doc.key)?.version, and update the map and rendered view only when the incoming version is newer. Close updates when its owner ends.

The current SQLite snapshot reader loads room metadata and projections through multiple queries, not a single read transaction. Concurrent updates can therefore appear in a projection ahead of snapshot.last_seq and then arrive again during replay. Apply only newer resource versions. Do not describe this response as an atomic snapshot at exactly that sequence. See Store.Snapshot.

This pattern works for the projections present in the snapshot. It is insufficient for a custom event feed whose old events are not in a snapshot field. Replay that feed from its own saved position or from zero.

Page through filtered HTTP history

room.events(afterSeq) returns one page of visible events. The server scans at most 1,000 room records per request, so an empty array does not prove that it reached the end: all records in that page might be hidden. The scan position is returned in the X-RMC-Cursor header.

The linked helper advances using that header. It stops only when the scan makes no progress, or when it reaches a target sequence captured by the caller. This REST endpoint applies authorization; it does not expose the WebSocket selector fields as query filters at this revision.

TypeScript
import {history} from "./history.mjs";

const snapshot = await room.snapshot();
for await (const event of history({
  baseUrl, roomId, token, through: snapshot.last_seq,
})) {
  await applyEvent(event);
}

Download history.mjs next to your client file. applyEvent is your event handler.

Download history.mjs. For a finite catch-up, obtain through from await room.snapshot() and iterate history({baseUrl, roomId, token, through: snapshot.last_seq}). Use this helper from Node or a same-origin browser: the current CORS handler does not expose X-RMC-Cursor to cross-origin browser JavaScript. For ordinary live client consumption, prefer room.subscribe(); it already handles filtered checkpoints.

Update versioned state

TypeScript
// Version zero means the document is expected not to exist yet.
const initial = await room.patchState("preferences", {language: "en"}, 0);
const updated = await room.patchState(
  "preferences",
  {language: "fr", obsoleteSetting: null},
  initial.version,
);

A concurrent writer can cause a version conflict. Read the latest authorized state, reconcile the user's intent, and submit a new write with that version. Blindly retrying the old version cannot succeed. If you omit expectedVersion, you forgo that optimistic concurrency check.

For a report-like result, use room.putArtifact({id, type, content, expectedVersion}); the SDK defaults its status to current. State is authorized by key, artifacts by type. Their corresponding mutation events use event-type permissions when channel-less. See versioned writes in SQLite.

Search the documentation

Type to search all guides.

Diagram

100%Open original ↗