Realtime

Live lists, record locks and unread badges over a browser, WebSocket, server-sent events or Supabase transport.

Realtime lets one user's change refresh what another user is looking at. A transport carries events between clients; Chelone turns each event into a refetch. It adds three things to a data provider: subscribe and publish for events, and lock, unlock, getLock and getLocks for editing locks.

The examples below use @chelone/react; the Vue section lists what differs in @chelone/vue. Both demos wire all of it in src/realtime.ts, with a panel that plays "another user".

Wrapping a provider

realtimeDataProvider adds the realtime members to any data provider. addEventsForMutations makes the provider publish after each successful write.

import {
  addEventsForMutations,
  inMemoryLockProvider,
  memoryTransport,
  realtimeDataProvider,
} from "@chelone/core";
import { baseProvider } from "./baseProvider";
 
export const transport = memoryTransport();
export const locks = inMemoryLockProvider({ publisher: transport });
 
export const dataProvider = addEventsForMutations(
  realtimeDataProvider(baseProvider, transport, { locks }),
  transport,
);

Pass the result to <Admin dataProvider> as usual.

  • realtimeDataProvider(base, transport, { locks }) keeps every method of base and adds subscribe and publish from the transport. The optional transport members (onReconnect, onStatusChange, getStatus, onError) are copied only when the transport has them.
  • locks is optional. Without it, the lock methods reject with an error that says no LockProvider was configured.
  • addEventsForMutations(provider, publisher) publishes after create, update, delete, updateMany and deleteMany. The write resolves after the publishes settle and never fails because of them. Pass { onError } to hear a rejected publish.
  • Leave out addEventsForMutations when your server already publishes events for every write, such as a database change feed. Publishing twice refetches twice.

Combined providers

realtimeDataProvider must wrap the result of combineDataProviders, not the other way round. combineDataProviders returns an object with only the nine verbs, so it drops subscribe, publish and the lock methods of any provider it combines.

const combined = combineDataProviders((resource) =>
  resource === "invoices" ? billingProvider : catalogProvider,
);
 
export const dataProvider = addEventsForMutations(
  realtimeDataProvider(combined, transport, { locks }),
  transport,
);

isRealtimeDataProvider(dataProvider) is true for a provider that went through realtimeDataProvider. Every live hook and component throws a message naming itself and asking you to wrap the provider when it is not.

Topics and event payloads

An event goes to a topic, and a subscriber listens on one. The topic helpers in @chelone/core build the names, so none is written by hand.

HelperTopicCarries
resourceTopic("posts")resource/postsCreates, and the ids of any write
recordTopic("posts", 1)resource/posts/1Writes to one record
lockResourceTopic("posts")lock/postsEvery lock taken or released on the resource
lockTopic("posts", 1)lock/posts/1The lock of one record

1 and "1" give the same topic.

An event is { topic, type, payload, meta? }. type is "created", "updated", "deleted", "locked" or "unlocked", or any string of your own. addEventsForMutations publishes these payloads:

WriteTopicTypePayload
createresourcecreated{ ids: [id] }
updaterecordupdated{ id, data }
updateresourceupdated{ ids: [id] }
deleterecorddeleted{ id, previousData }
deleteresourcedeleted{ ids: [id] }
updateMany, deleteManyresourceupdated, deleted{ ids }
updateMany, deleteManyeach recordupdated, deleted{ id }

A lock event has type locked or unlocked and a Lock as its payload: { resource, recordId, identity, createdAt }.

The hooks

All of these read the data provider from context and throw when it is not a realtime one.

HookDoes
useSubscribe(topic, callback, { enabled })Calls callback for each event on topic. A new callback every render does not resubscribe.
useSubscribeToRecord(resource, id, callback)useSubscribe for one record; waits while id is null or undefined.
useSubscribeToRecordList(resource, callback)useSubscribe for the resource topic.
useSubscribeCallback()Returns subscribe(topic, callback), for subscribing outside an effect.
usePublish()Returns publish(topic, event).
useOnReconnect(callback)Calls callback after the transport reconnects. Does nothing when the transport cannot say.
useRealtimeStatus()Returns { status, lastError }; status is "idle" when the transport reports neither.
useGetListLive, useGetOneLive, useGetManyLiveThe plain read hook, refetching when its data changes or the connection returns.
useGetLockLive, useGetLocksLiveThe lock read hooks, refetching when a lock is taken or released.
import { useRealtimeStatus, useSubscribeToRecordList } from "@chelone/react";
 
function PostsWatcher() {
  const { status } = useRealtimeStatus();
  useSubscribeToRecordList("posts", (event) => {
    console.log(event.type, event.payload);
  });
  return <span>{status}</span>;
}

The components

Each component wraps its plain version and adds a subscription, so swap the import and the view stays live. All need a realtime data provider.

import { DataTable, EditLive, ListLive, TextInput } from "@chelone/react";
 
export const PostList = () => (
  <ListLive>
    <DataTable>
      <DataTable.Col source="title" />
    </DataTable>
  </ListLive>
);
 
export const PostEdit = () => (
  <EditLive>
    <TextInput source="title" />
  </EditLive>
);

ListLive

A <List> that refetches when a record in its resource changes or the connection returns. It takes the props of <List>.

PropTypeDefaultDescription
actionsfalse | ReactElement<unknown, string | JSXElementConstructor<any>>—Header actions (create/export/filter buttons); nothing by default.
childrenReactNode—
classNamestring—
componentElementType<any, keyof IntrinsicElements>—Element/component wrapping the children (defaults to `"div"`).
perPagenumber—Rows-per-page for the initial query.
resourcestring—Override the ambient resource (defaults to the `<Resource>` context).
titleReactNode—Header title. A string/element renders as an `<h2>`; `false` omits it.

ShowLive

A <Show> that refetches when its record changes or the connection returns. It takes the props of <Show>.

PropTypeDefaultDescription
actionsReactNode—Header actions (an edit button); nothing by default.
childrenReactNode—
classNamestring—
componentElementType<any, keyof IntrinsicElements>—
idIdentifier—Override the record id (defaults to the `:id` route param).
resourcestring—Override the ambient resource (defaults to the `<Resource>` context).
titleReactNode—Header title. A string/element renders as an `<h2>`; `false` omits it.

EditLive

An <Edit> that refetches the record when it changes or the connection returns. A change that arrives while the form has unsaved edits is held, and one refetch runs when the form is saved or reset, so a user's typing is never overwritten. It takes the props of <Edit>.

PropTypeDefaultDescription
actionsReactNode—Header actions (show/delete buttons); nothing by default.
childrenReactNode—
classNamestring—
componentElementType<any, keyof IntrinsicElements>—
idIdentifier—Override the record id (defaults to the `:id` route param).
onSuccess(record: TRecord) => void | Promise<void>—Runs after a successful save with the SERVER record, post-reconciliation.
resourcestring—Override the ambient resource (defaults to the `<Resource>` context).
sanitizeEmptyValuesboolean—ra parity: nullify/strip empty TOP-LEVEL values before save. Default false.
schemaStandardSchemaV1<Partial<TRecord>, unknown>—Resource schema (zod or any Standard Schema) — wired as the default form validator by the submit pipeline (T2).
titleReactNode—Header title. A string/element renders as an `<h2>`; `false` omits it.
validateFormLevelValidate<Partial<TRecord>>—Form-level validate fn (ra contract) — consumed by the submit pipeline (T2).

A nav of MenuLiveItemLinks. Each link carries a badge counting the resource's new records since the user last opened it. The count lives in the store, so it survives a reload when the store has an adapter, and clears while the current route is the link's to or below it.

<MenuLive items={[{ to: "/posts", resource: "posts", label: "Posts" }]} />
PropTypeDefaultDescription
itemsMenuLiveItemLinkProps[]—

One link of MenuLive, for use on its own.

PropTypeDefaultDescription
badgeLabel(count: number) => ReactNode—Formats the badge text. Defaults to the count, `99+` above 99.
eventsRealtimeEventType[]—Event types that count as new. Defaults to `["created"]`.
iconReactNode—Icon rendered before `label`.
labelReactNode—Link text.
resourcestring—Resource whose events are counted.
tostring—Target path. The count clears while the current route is `to` or below it.

LockOnMount

Locks a record while mounted and renders its children once it holds the lock. While the lock is requested it shows loading; when another identity holds the lock it shows lockedBy, which defaults to an alert reading Locked by <identity>.

<EditLive>
  <LockOnMount identity="you">
    <TextInput source="title" />
  </LockOnMount>
</EditLive>
PropTypeDefaultDescription
childrenReactNode—Shown only while this component holds the lock.
idIdentifier—Defaults to the id of the record context.
identityIdentifier—Defaults to the auth provider's identity.
loadingReactNode—Shown while the lock is requested. Defaults to a `Skeleton`.
lockedBy(lock: Lock) => ReactNode—Shown when another identity holds the lock. Defaults to an `Alert` naming the holder.
metaRecord<string, unknown>—
onLockError(error: Error) => void—Called with a failed lock or release. A new function every render never relocks.
resourcestring—Defaults to the resource context.

LockStatus

Shows who holds a record's lock, and updates when it is taken or released. A free record renders renderFree, which defaults to nothing.

PropTypeDefaultDescription
idIdentifier—Defaults to the id of the record context.
renderFree() => ReactNode—Rendered when the record is free. Defaults to nothing.
renderLock(lock: Lock) => ReactNode—Replaces the default badge naming the holder.
resourcestring—Defaults to the resource context.

PresenceBar

Shows avatars of the other users who have the same record open, and renders nothing when nobody else is there. Place it inside a record view such as <Edit>, where the resource and record contexts name the topic.

import { Edit, PresenceBar, TextInput } from "@chelone/react";
 
export const PostEdit = () => (
  <Edit actions={<PresenceBar />}>
    <TextInput source="title" />
  </Edit>
);
  • Presence needs a realtime data provider. With memoryTransport it works for one page only, such as two components in the same tab. To reach other users, use webSocketTransport, sseTransport or supabaseRealtimeTransport, or broadcastChannelTransport for the tabs of one browser. See The browser transports.
  • The bar announces the current user on presence/<resource>/<id>, a topic built by presenceTopic(resource, id), and listens on it for the others. The user comes from the auth provider's identity; currentUser replaces it.
  • It republishes the announce every 15 seconds (heartbeatMs) and drops a user unheard for 30 seconds (staleMs). A user who leaves the view, by navigating away within the app, leaves at once: the bar announces its departure as it unmounts. A closed tab cannot announce, so it goes after staleMs.
  • Stale time is measured by the receiver's clock, from when each announce arrives, so clocks that disagree between browsers do not matter.
  • A second tab of the same user is left out.
PropTypeDefaultDescription
classNamestring—
currentUser{ avatar?: string; id: string; name: string }—Replaces the auth provider's identity as the user who is announced.
heartbeatMsnumber—How often the announce is republished. Default 15000.
maxAvatarsnumber—How many avatars to show before the rest collapse into a `+N` count. Default 5.
staleMsnumber—A user unheard for longer than this is dropped. Default 30000.
topicstring—Defaults to the presence topic of the resource and record contexts.

usePresence(options) is the hook under the bar, for a custom display. It announces the current user and returns the other users as PresenceUsers ({ id, name, avatar? }) while the component is mounted. It takes topic, user, heartbeatMs, staleMs and enabled, and announces nothing when it has no topic or no user.

CommentsThread

The comments on a record, and a box to add one. Comments are records of an ordinary resource: reference names that resource and target is the field on a comment that holds the id of its parent record. The parent is the record context, or record when you pass one.

import { CommentsThread, Edit, TextInput } from "@chelone/react";
 
export const PostEdit = () => (
  <Edit>
    <TextInput source="title" />
    <CommentsThread reference="comments" target="postId" resolvable />
  </Edit>
);

A comment holds these fields, besides its id and the target field:

FieldHolds
bodyThe text
authorIdThe identity id of the author
authorNameThe name shown for the author
createdAtAn ISO timestamp
resolvedAtAn ISO timestamp, or null while unresolved
  • The client sets authorId, authorName and createdAt as hints. The server should set them from the session and the clock, and overwrite what the client sent.
  • With a realtime data provider the thread refreshes on its own when a comment is created or changed. With a plain one it updates only on refetch.
  • It offers no edit, delete or reply. resolvable adds a Mark resolved button, which sets resolvedAt.
PropTypeDefaultDescription
classNamestring—
perPagenumber—How many comments to fetch. Default 100.
record{ id?: Identifier | null }—Defaults to the record context.
referencestring—The resource that holds the comments.
resolvableboolean—Adds a `Mark resolved` button to unresolved comments. Default false.
targetstring—The field on a comment that holds the id of its parent record.

Locks

A lock marks a record as being edited by an identity, so a second editor can be told to wait. Chelone does not enforce it on writes: the lock lives in a LockProvider, and your server decides what a lock blocks.

interface LockProvider {
  lock(resource, params: { id; identity; meta? }): Promise<Lock>;
  unlock(resource, params: { id; identity; meta? }): Promise<Lock>;
  getLock(resource, params: { id; meta? }): Promise<Lock | null>;
  getLocks(resource, params?: { meta? }): Promise<Lock[]>;
}
  • lock rejects with LockConflictError when another identity holds the record. The error's existingLock is that lock. Locking again as the holder resolves with the existing lock.
  • unlock rejects with LockConflictError when another identity holds the record, and with a plain error when nobody does.
  • getLock resolves null for a free record.
  • inMemoryLockProvider({ publisher }) keeps locks in a Map and announces each lock and unlock on lockTopic and lockResourceTopic. Without a publisher it is silent, and LockStatus does not update on its own. It is for tests and demos: locks do not outlive the page or reach another browser.

identity defaults to the identity from the auth provider. Pass it explicitly when the app has none.

The hooks useLock() and useUnlock() return { lock, isLoading, error } and { unlock, isLoading, error }. useLockOnMount(options) holds a lock while the component is mounted and returns { lock, isLocking, lockError }. Changing the resource, id or identity releases the old lock before it takes the next. useGetLock and useGetLocks read locks without subscribing.

Vue

@chelone/vue has the same engine, wrapping and components. Wrapping a provider is identical, since it lives in @chelone/core; pass the result to <Admin :data-provider>. What differs is the shape of the API.

Composables

Every hook above has a composable of the same name in @chelone/vue: useSubscribe, useSubscribeToRecord, useSubscribeToRecordList, useSubscribeCallback, usePublish, useOnReconnect, useRealtimeStatus, useGetListLive, useGetOneLive, useGetManyLive, useGetLock, useGetLocks, useGetLockLive, useGetLocksLive, useLock, useUnlock and useLockOnMount.

  • Arguments that can change take MaybeRefOrGetter: pass a value, a ref or a getter. This covers topic of useSubscribe, resource and id of useSubscribeToRecord, resource of useSubscribeToRecordList, resource and params of useGetListLive, params of useGetOneLive, useGetManyLive, useGetLock and useGetLockLive, the enabled option, and the resource, id and identity options of useLockOnMount. A change resubscribes or refetches.
  • resource of useGetOneLive, useGetManyLive, useGetLock, useGetLocks, useGetLockLive and useGetLocksLive is a plain string, so those composables read one resource for the life of the component.
  • The values they return are refs: status and lastError of useRealtimeStatus, lock, isLocking and lockError of useLockOnMount, and isLoading and error of useLock and useUnlock. The lock and unlock of those two are plain functions.
  • useSubscribe reads its callback when it subscribes. A Vue callback closes over live refs, so it needs no latest-callback wrapper.
<script setup lang="ts">
import { useRealtimeStatus, useSubscribeToRecordList } from "@chelone/vue";
 
const { status } = useRealtimeStatus();
useSubscribeToRecordList("posts", (event) => {
  console.log(event.type, event.payload);
});
</script>
 
<template>
  <span>{{ status }}</span>
</template>

Components

ListLive, ShowLive, EditLive, MenuLive, MenuLiveItemLink, LockOnMount, LockStatus, PresenceBar and CommentsThread are exported from @chelone/vue with the same props as their React twins. Slots stand in for the render props.

ComponentSlots
ListLive, ShowLive, EditLiveThe slots of List, Show and Edit, passed through, such as #actions and #toolbar.
MenuLiveItemLink#icon replaces the icon prop, for markup.
LockOnMountDefault once the lock is held; #loading while it is requested; #locked-by="{ lock }" when another identity holds it.
LockStatus#lock="{ lock }" replaces the default badge; #free renders when the record is free.
PresenceBar, CommentsThreadNone.

MenuLive takes items like the React component.

usePresence is a composable too. It takes the same options, each as a MaybeRefOrGetter, and returns { users }, where users is a ref of the users on the topic. Unlike the React hook, it does not filter out the current user's other tabs; PresenceBar does, so filter by id in a custom display.

<script setup lang="ts">
import {
  DataTable,
  DataTableCol,
  EditLive,
  ListLive,
  LockOnMount,
  LockStatus,
  TextInput,
} from "@chelone/vue";
</script>
 
<template>
  <ListLive resource="posts">
    <DataTable>
      <DataTableCol source="title" />
    </DataTable>
  </ListLive>
 
  <EditLive resource="posts">
    <template #actions><LockStatus /></template>
    <LockOnMount identity="you">
      <template #locked-by="{ lock }">Held by {{ lock.identity }}</template>
      <TextInput source="title" />
    </LockOnMount>
  </EditLive>
</template>
<script setup lang="ts">
import { EditLive, PresenceBar, TextInput } from "@chelone/vue";
</script>
 
<template>
  <EditLive resource="posts">
    <template #actions><PresenceBar /></template>
    <TextInput source="title" />
  </EditLive>
</template>

EditLive holds events while the form has unsaved edits and replays one refetch once it is saved or reset, as the React component does. The Vue demo (apps/vue-demo/src/realtime.ts) wires it all with ListLive on the posts list and LockOnMount inside EditLive on the edit page, so the lock reads the record EditLive provides.

The transport contract

A transport is what connects clients. Only subscribe and publish are required.

interface RealtimeTransport {
  subscribe(topic, callback): Unsubscribe;
  publish(topic, event): Promise<void>;
  onReconnect?(callback): Unsubscribe;
  onStatusChange?(callback: (status) => void): Unsubscribe;
  getStatus?(): "connected" | "reconnecting" | "disconnected" | "idle";
  onError?(callback: (error: RealtimeTransportError) => void): Unsubscribe;
}
  • subscribe calls callback with the full event, topic included, and returns a function that stops it.
  • onReconnect lets a view catch up on events it missed while offline. The live components and hooks refetch when it fires.
  • onStatusChange and getStatus feed useRealtimeStatus. A transport with onStatusChange but no getStatus reads as "disconnected" until it speaks.
  • onError reports connect_failed, auth_failed, send_failed, parse_failed and handler_threw, each with retrying set.

Chelone ships transports for the browser, a WebSocket server, a server-sent events server and Supabase, listed below. For any other service, write one that implements the interface above with createTopicHub, see Writing your own.

The in-memory transport

memoryTransport() routes events inside one process, with no server and no library. Use it for tests and demos.

const transport = memoryTransport({ delayMs: 50, onError: console.error });
 
transport.publishedEvents; // every event published so far, in order
transport.setStatus("reconnecting");
transport.simulateReconnect(); // calls every onReconnect listener
  • delayMs waits between recording a publish and delivering it.
  • A subscriber that throws goes to onError as handler_threw; the other subscribers still run.
  • It starts as "connected".

The browser transports

Three transports in @chelone/core need no library. Each returns a RealtimeTransport with every optional member and a close(). Pass one to realtimeDataProvider like memoryTransport.

TransportReachesNeeds a server
broadcastChannelTransportThe tabs of one browserNo
webSocketTransportEvery client of a WebSocket serverYes
sseTransportEvery client of a server-sent events serverYes, plus an endpoint to publish to

For Supabase, see Supabase Realtime.

BroadcastChannel

broadcastChannelTransport shares events between the tabs of one browser through a BroadcastChannel. Every tab that uses the same channel name joins.

import { broadcastChannelTransport, realtimeDataProvider } from "@chelone/core";
import { baseProvider } from "./baseProvider";
 
export const transport = broadcastChannelTransport({ channel: "admin" });
 
export const dataProvider = realtimeDataProvider(baseProvider, transport);
  • BroadcastChannel reaches only tabs of one browser, and only for one origin. It does not reach another browser or another device.
  • A publish also reaches the subscribers of the tab that published, so a tab sees its own events.
  • It throws on creation where the global BroadcastChannel is missing.
  • It reports "connected" until close().

WebSocket

webSocketTransport keeps one socket open and sends every topic through it.

import { realtimeDataProvider, webSocketTransport } from "@chelone/core";
import { baseProvider } from "./baseProvider";
import { getSession } from "./session";
 
export const transport = webSocketTransport({
  url: "wss://api.example.com/realtime",
  getAuthToken: async () => (await getSession())?.accessToken,
  authMode: "subprotocol",
});
 
export const dataProvider = realtimeDataProvider(baseProvider, transport);

The socket opens on the first subscribe or publish. It carries JSON text frames. The client sends:

{"op":"subscribe","topic":"resource/posts"}
{"op":"unsubscribe","topic":"resource/posts"}
{"op":"publish","topic":"resource/posts","event":{"type":"updated","payload":{"ids":[1]}}}
{"op":"ping"}

The server sends an event as a frame with the shape of the event, and answers a ping with a pong:

{"topic":"resource/posts","type":"updated","payload":{"ids":[1]},"meta":{}}
{"op":"pong"}
  • topic and type are required strings. payload and meta are optional. A frame that is not text, not JSON or not an event goes to onError as parse_failed.
  • After a reconnect the transport sends subscribe again for every topic that still has a listener, so the server needs no memory of the old socket.
  • A publish made before the socket is open waits in a queue and is sent when it opens. close() rejects the ones still waiting.
  • heartbeatMs (default 30000) sets how often a ping goes out. A ping with no frame of any kind back by the next one counts as a dropped connection.
  • idleDisconnectMs (default 30000) closes the socket once nothing is subscribed, and it opens again on the next subscribe.
  • protocols lists the subprotocols offered on every connection.

getAuthToken runs before every connection, so a retry carries a fresh token. authMode says where the token goes:

  • "query" (the default) adds a token parameter to the URL. Server logs, proxy logs and browser history can keep a URL, and so can the token in it.
  • "subprotocol" offers the token as a subprotocol after protocols, and keeps it out of the URL. The server must accept that subprotocol in the handshake. Use it when the server can read the Sec-WebSocket-Protocol header.

Server-sent events

sseTransport reads a stream with EventSource and publishes with a POST.

import { realtimeDataProvider, sseTransport } from "@chelone/core";
import { baseProvider } from "./baseProvider";
import { getSession } from "./session";
 
export const transport = sseTransport({
  url: "https://api.example.com/realtime/stream",
  publishUrl: "https://api.example.com/realtime/publish",
  getAuthToken: async () => (await getSession())?.accessToken,
});
 
export const dataProvider = realtimeDataProvider(baseProvider, transport);

The stream opens on the first subscribe, and subscribes in the same tick share one request. It is a GET on url with the topics in a topics query parameter (rename it with topicFilterParam), comma separated: /realtime/stream?topics=resource%2Fposts%2Clock%2Fposts&token=....

The server sends one named event per topic. The event name is the topic, and data is JSON of { type, payload, meta }:

event: resource/posts
data: {"type":"updated","payload":{"ids":[1]}}
  • type is a required string; payload and meta are optional. The topic comes from the event name, so data does not carry one. Data that is not JSON goes to onError as parse_failed; JSON without a type is dropped.
  • EventSource cannot set headers, so the token is always in the stream URL as token. It carries the same risk as authMode: "query" above: use a short-lived token.
  • A publish sends a POST to publishUrl with a JSON body of { "topic": "...", "event": { "type": "...", ... } } and an Authorization: Bearer <token> header. Any status outside 200 to 299 rejects it. Without publishUrl a publish rejects.
  • Subscribing to a topic the open stream does not carry reopens it with the new topic added, which can take a moment. Unsubscribing from one leaves the stream as it is, and the server keeps sending that topic on it until the stream closes; events for a topic with no subscriber are dropped.
  • withCredentials is passed to EventSource and sends credentials on the POST. fetch replaces the global one for the POST.
  • idleDisconnectMs (default 30000) closes the stream once nothing is subscribed.

Reconnect and status

webSocketTransport and sseTransport retry a dropped connection with exponential backoff. reconnect takes these options:

OptionDefaultDoes
enabledtruefalse turns retries off.
initialDelayMs1000The wait before the first retry; each retry doubles it.
maxDelayMs30000The wait never grows past this.
maxAttemptsInfinityRetries before giving up.
jitter0.3The share of each wait that is randomised, up or down.

A connection that opens again resets the count. A reconnect calls the onReconnect listeners, which refetch the live views. getStatus reports:

StatusMeans
"idle"Nothing is subscribed, so no connection is open. A subscribe opens one, and so does a webSocketTransport publish. A sseTransport publish only sends the POST.
"connected"The connection is open.
"reconnecting"The connection dropped and a retry is waiting or running.
"disconnected"close() was called, a retry gave up, retries are off, or the server closed the connection cleanly.

A failed connection goes to onError as connect_failed with retrying set. A token that cannot be read is auth_failed, a send that throws is send_failed.

Closing

close() ends a transport and sets "disconnected". Call it when the app tears the transport down, such as a test cleanup or a hot-reload dispose.

  • On broadcastChannelTransport, a publish after close() rejects.
  • On webSocketTransport it closes the socket and rejects queued publishes.
  • On sseTransport it closes the stream.
  • A later subscribe on a WebSocket or server-sent events transport opens a connection again.

Writing your own

For a service Chelone has no transport for, such as a hosted pub/sub client, createTopicHub holds the parts every transport shares: the subscribers of each topic, with a throwing subscriber isolated from the others, and the status, reconnect and error listeners. Your transport connects to the service and calls dispatch for each event it receives.

import { createTopicHub, type RealtimeTransport } from "@chelone/core";
 
export function myTransport(client: MyClient): RealtimeTransport {
  const hub = createTopicHub();
  client.onMessage((topic, event) => hub.dispatch({ topic, ...event }));
  return {
    subscribe: hub.subscribe,
    publish: (topic, event) => client.send(topic, event),
    onReconnect: hub.onReconnect,
    onStatusChange: hub.onStatusChange,
    getStatus: hub.getStatus,
    onError: hub.onError,
  };
}

hub.setStatus, hub.reconnected and hub.report let the transport tell the hub about the connection. hub.has(topic) and hub.topics() say what to subscribe to on the service.

How events reach the query cache

An event invalidates queries. It does not patch them: the payload is never written into the cache, so a view always shows what the data provider returns. The payload.data of an updated event is for your own subscribers.

  • ListLive, ShowLive, EditLive and the use*Live hooks subscribe to a topic and, on an event, invalidate every query under the resource: lists, single records and references. Active queries refetch.
  • Lock queries are left out of that invalidation. They have their own subscribers on the lock topics.
  • Events in the same tick fold into one invalidation, however many arrive.
  • Events are held while an undoable mutation is pending, because a refetch would bring back its optimistic delete. They are replayed once it settles.
  • EditLive holds events while the form has unsaved edits, and replays one invalidation once the form is saved or reset.
  • A reconnect invalidates the same queries, since events may have been missed.

Tips

  • Create the transport, lock provider and data provider at module scope, next to the QueryClient, so they are stable across renders.
  • Publish from the server where you can. addEventsForMutations only reports writes made through this client's provider.