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 ofbaseand addssubscribeandpublishfrom the transport. The optional transport members (onReconnect,onStatusChange,getStatus,onError) are copied only when the transport has them.locksis optional. Without it, the lock methods reject with an error that says noLockProviderwas configured.addEventsForMutations(provider, publisher)publishes aftercreate,update,delete,updateManyanddeleteMany. The write resolves after the publishes settle and never fails because of them. Pass{ onError }to hear a rejected publish.- Leave out
addEventsForMutationswhen 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.
| Helper | Topic | Carries |
|---|---|---|
resourceTopic("posts") | resource/posts | Creates, and the ids of any write |
recordTopic("posts", 1) | resource/posts/1 | Writes to one record |
lockResourceTopic("posts") | lock/posts | Every lock taken or released on the resource |
lockTopic("posts", 1) | lock/posts/1 | The 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:
| Write | Topic | Type | Payload |
|---|---|---|---|
create | resource | created | { ids: [id] } |
update | record | updated | { id, data } |
update | resource | updated | { ids: [id] } |
delete | record | deleted | { id, previousData } |
delete | resource | deleted | { ids: [id] } |
updateMany, deleteMany | resource | updated, deleted | { ids } |
updateMany, deleteMany | each record | updated, 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.
| Hook | Does |
|---|---|
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, useGetManyLive | The plain read hook, refetching when its data changes or the connection returns. |
useGetLockLive, useGetLocksLive | The 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>.
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>.
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>.
EditLive are typed in the component source.MenuLive
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" }]} />| Prop | Type | Default | Description |
|---|---|---|---|
| items | MenuLiveItemLinkProps[] | — |
MenuLiveItemLink
One link of MenuLive, for use on its own.
| Prop | Type | Default | Description |
|---|---|---|---|
| badgeLabel | (count: number) => ReactNode | — | Formats the badge text. Defaults to the count, `99+` above 99. |
| events | RealtimeEventType[] | — | Event types that count as new. Defaults to `["created"]`. |
| icon | ReactNode | — | Icon rendered before `label`. |
| label | ReactNode | — | Link text. |
| resource | string | — | Resource whose events are counted. |
| to | string | — | 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>| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | Shown only while this component holds the lock. |
| id | Identifier | — | Defaults to the id of the record context. |
| identity | Identifier | — | Defaults to the auth provider's identity. |
| loading | ReactNode | — | 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. |
| meta | Record<string, unknown> | — | |
| onLockError | (error: Error) => void | — | Called with a failed lock or release. A new function every render never relocks. |
| resource | string | — | 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.
| Prop | Type | Default | Description |
|---|---|---|---|
| id | Identifier | — | 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. |
| resource | string | — | 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
memoryTransportit 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 bypresenceTopic(resource, id), and listens on it for the others. The user comes from the auth provider's identity;currentUserreplaces 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 afterstaleMs. - 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.
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | |
| currentUser | { avatar?: string; id: string; name: string } | — | Replaces the auth provider's identity as the user who is announced. |
| heartbeatMs | number | — | How often the announce is republished. Default 15000. |
| maxAvatars | number | — | How many avatars to show before the rest collapse into a `+N` count. Default 5. |
| staleMs | number | — | A user unheard for longer than this is dropped. Default 30000. |
| topic | string | — | 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:
| Field | Holds |
|---|---|
body | The text |
authorId | The identity id of the author |
authorName | The name shown for the author |
createdAt | An ISO timestamp |
resolvedAt | An ISO timestamp, or null while unresolved |
- The client sets
authorId,authorNameandcreatedAtas 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.
resolvableadds aMark resolvedbutton, which setsresolvedAt.
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | |
| perPage | number | — | How many comments to fetch. Default 100. |
| record | { id?: Identifier | null } | — | Defaults to the record context. |
| reference | string | — | The resource that holds the comments. |
| resolvable | boolean | — | Adds a `Mark resolved` button to unresolved comments. Default false. |
| target | string | — | 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[]>;
}lockrejects withLockConflictErrorwhen another identity holds the record. The error'sexistingLockis that lock. Locking again as the holder resolves with the existing lock.unlockrejects withLockConflictErrorwhen another identity holds the record, and with a plain error when nobody does.getLockresolvesnullfor a free record.inMemoryLockProvider({ publisher })keeps locks in aMapand announces each lock and unlock onlockTopicandlockResourceTopic. Without apublisherit is silent, andLockStatusdoes 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 coverstopicofuseSubscribe,resourceandidofuseSubscribeToRecord,resourceofuseSubscribeToRecordList,resourceandparamsofuseGetListLive,paramsofuseGetOneLive,useGetManyLive,useGetLockanduseGetLockLive, theenabledoption, and theresource,idandidentityoptions ofuseLockOnMount. A change resubscribes or refetches. resourceofuseGetOneLive,useGetManyLive,useGetLock,useGetLocks,useGetLockLiveanduseGetLocksLiveis a plain string, so those composables read one resource for the life of the component.- The values they return are refs:
statusandlastErrorofuseRealtimeStatus,lock,isLockingandlockErrorofuseLockOnMount, andisLoadinganderrorofuseLockanduseUnlock. Thelockandunlockof those two are plain functions. useSubscribereads 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.
| Component | Slots |
|---|---|
ListLive, ShowLive, EditLive | The slots of List, Show and Edit, passed through, such as #actions and #toolbar. |
MenuLiveItemLink | #icon replaces the icon prop, for markup. |
LockOnMount | Default 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, CommentsThread | None. |
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;
}subscribecallscallbackwith the full event, topic included, and returns a function that stops it.onReconnectlets a view catch up on events it missed while offline. The live components and hooks refetch when it fires.onStatusChangeandgetStatusfeeduseRealtimeStatus. A transport withonStatusChangebut nogetStatusreads as"disconnected"until it speaks.onErrorreportsconnect_failed,auth_failed,send_failed,parse_failedandhandler_threw, each withretryingset.
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 listenerdelayMswaits between recording a publish and delivering it.- A subscriber that throws goes to
onErrorashandler_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,EditLiveand theuse*Livehooks 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.
EditLiveholds 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.
addEventsForMutationsonly reports writes made through this client's provider.