Realtime

Live lists, record locks and unread badges over a transport you supply.

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

Props for ListLive are typed in the component source.

ShowLive

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

Props for ShowLive are typed in the component source.

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

Props for EditLive are typed in the component source.

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. Nothing reaches another browser without a server transport, and Chelone ships none.
  • 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.

A transport that needs a client library, such as a WebSocket, server-sent events or a hosted pub/sub service, is the app's to supply. Chelone ships none: write one that implements the interface above and pass it to realtimeDataProvider.

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

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.