Aller au contenu

Temps réel & WebSocket

Rebase inclut un moteur temps réel intégré qui transmet les modifications de données aux clients connectés via WebSocket. Lorsqu’un enregistrement est créé, mis à jour ou supprimé, chaque abonné observant cette collection ou entité reçoit la mise à jour instantanément — aucun sondage (polling) n’est requis.

Le pipeline temps réel comporte trois étapes :

  1. Déclencheur de base de données — Une mutation atteint la base de données PostgreSQL (via l’API REST, le SDK ou Studio).
  2. Distribution par le serveur (fan-out) — Le serveur Rebase détecte le changement et le distribue à chaque abonnement WebSocket actif correspondant à la collection ou à l’entité concernée.
  3. Rappel côté client — Le SDK typé exécute votre fonction de rappel onUpdate avec les nouvelles données.
┌──────────────┐ ┌────────────────────┐ ┌──────────────┐
│ PostgreSQL │─────▶│ Rebase Server │─────▶│ Client SDK │
│ LISTEN/NOTIFY│ │ RealtimeService │ │ WebSocket │
└──────────────┘ └────────────────────┘ └──────────────┘

Pour les déploiements multi-instances, Rebase utilise le mécanisme LISTEN/NOTIFY de PostgreSQL pour acheminer les modifications entre les instances du serveur. Cela est géré automatiquement. Lorsque la capture des modifications au niveau de la base de données est activée (c’est le cas par défaut partout où le rôle de base de données peut créer des déclencheurs), chaque instance écoute le canal rebase_cdc sur lequel publient les déclencheurs de capture : une écriture est donc entendue quel que soit le processus ou l’outil qui l’a faite. Sans capture, chaque instance annonce ses propres écritures sur le canal rebase_entity_changes, et une connexion dédiée sur chacune des autres instances les relaie à ses abonnés locaux.

Le temps réel est activé par défaut. Il n’y a aucun paramètre à modifier ni aucun service à démarrer — si votre serveur Rebase fonctionne, le point de terminaison WebSocket est disponible.

En 0.24, le socket prend chaque mise à niveau WebSocket du serveur, quel que soit son chemin.

Le socket répond aux mises à niveau (upgrades) sur un seul chemin : le chemin de base de l’API privé d’un éventuel /api final, soit / pour une API à /api. C’est là que le SDK se connecte : il se connecte à son baseUrl, tandis que ses requêtes HTTP vont à ${baseUrl}/api. Une mise à niveau vers tout autre chemin est laissée aux autres écouteurs upgrade du serveur, de sorte que votre application peut faire tourner son propre serveur WebSocket sur le même http.Server. Sans autre écouteur, une telle mise à niveau reçoit la réponse 404.

Dans un serveur personnalisé, backend.attachRealtime(server, { path }) définit le chemin, et le realtime.path de l’adaptateur Postgres remplace celui que le serveur a déduit.

Par défaut, Rebase émet également des événements en temps réel pour les écritures effectuées en dehors de l’API (via psql, un autre service ou l’éditeur SQL de Studio) dès que la connexion à la base de données le permet — voir capture de données modifiées au niveau de la base de données.

Le SDK typé de Rebase expose deux méthodes d’abonnement sur chaque accesseur de collection :

  • listen() — S’abonner à une collection entière (avec des filtres optionnels).
  • listenById() — S’abonner à une entité spécifique par son identifiant.

Les deux méthodes renvoient une fonction de désabonnement que vous appelez pour cesser de recevoir les mises à jour.

Utilisez listen() pour recevoir des mises à jour dès que les enregistrements d’une collection changent :

const unsubscribe = client.data.products.listen(
undefined, // FindParams — pass undefined for all records
(response) => {
console.log("Products updated:", response.data);
console.log("Total:", response.meta.total);
},
(error) => {
console.error("Subscription error:", error);
}
);

La fonction de rappel reçoit un objet FindResult<M> contenant :

  • data — Les lignes, à plat, telles que find() les renvoie (row.name, et non row.values.name).
  • meta — Informations de pagination (total, limit, offset, hasMore).

Passez FindParams comme premier argument pour filtrer l’abonnement :

const unsubscribe = client.data.products.listen(
{
where: { status: ["==", "published"] },
orderBy: ["createdAt", "desc"],
limit: 50,
},
(response) => {
console.log("Published products:", response.data);
}
);

Le serveur respecte ces filtres — seuls les enregistrements correspondants sont inclus dans les mises à jour.

Utilisez listenById() pour observer un enregistrement spécifique :

const unsubscribe = client.data.products.listenById(
"product-123",
(row) => {
if (row) {
console.log("Product updated:", row.name);
} else {
console.log("Product was deleted");
}
},
(error) => {
console.error("Subscription error:", error);
}
);

La fonction de rappel reçoit la ligne, à plat, telle que la renvoie findById() (row.name, et non row.values.name), ou undefined une fois la ligne supprimée.

Chaque trame porte la version de la ligne. etagOf(row) est l’ETag que renvoie un GET de la ligne : une modification faite à partir de ce qu’une trame montrait peut donc être enregistrée avec ifMatch: etagOf(row) et est refusée avec un 412 si la ligne a changé entre-temps (voir Écritures conditionnelles). Sur le fil, le subscribe_one du SDK le demande avec etag: true, et chaque trame single_update pour une ligne qui existe porte "etag" à côté de "row". Un serveur antérieur ignore la demande et n’envoie aucun jeton, et un client qui ne le demande pas reçoit des trames sans jeton.

Tant listen() que listenById() renvoient une fonction de désabonnement. Appelez-la pour cesser de recevoir les mises à jour et libérer les ressources côté serveur :

const unsubscribe = client.data.products.listen(undefined, (response) => {
// handle updates
});
// Later, when you no longer need updates:
unsubscribe();

Le query builder fluide prend également en charge les abonnements en temps réel. Enchaînez vos filtres, puis appelez .listen() au lieu de .find() :

const unsubscribe = client.data.orders
.where("status", "==", "pending")
.orderBy("createdAt", "desc")
.limit(20)
.listen(
(response) => {
console.log("Pending orders:", response.data);
},
(error) => {
console.error("Error:", error);
}
);

Distribution des mises à jour : Patch instantané + Récupération pour exactitude

Section intitulée « Distribution des mises à jour : Patch instantané + Récupération pour exactitude »

Une modification ne voyage jamais vers un abonné sous forme de données brutes. Elle voyage sous la forme du fait que quelque chose a changé, et chaque abonné est ensuite informé de ce qu’il est autorisé à voir via une requête exécutée avec son identité :

  1. Invalidation. Lorsqu’une entité change (créée, mise à jour, supprimée), le serveur marque les chemins affectés. La ligne écrite n’est pas transmise — elle a été lue sous l’autorisation de l’auteur de l’écriture, ce qui ne préjuge en rien de ce qu’un abonné quelconque a le droit de voir.

  2. Nouvelle requête RLS temporisée (debounce). Après 300 ms (REFETCH_DEBOUNCE_MS), le serveur réexécute la requête sur la collection avec vos filtres et votre ordre de tri d’origine. La requête s’exécute dans une transaction qui définit app.user_id et app.user_roles au niveau de la transaction à partir du SubscriptionAuthContext de l’abonné. Postgres évalue ainsi la sécurité au niveau des lignes (RLS) sous l’identité de ce client, et seules les lignes qu’il est autorisé à voir sont envoyées dans collection_update. La temporisation (debounce) permet également de regrouper une rafale d’écritures en une seule requête. Elle attend 300 ms sans nouvelle modification, mais jamais plus de 1 s (REFETCH_MAX_WAIT_MS) après la première, de sorte qu’une collection écrite plusieurs fois par seconde envoie tout de même une trame à ses abonnés au moins une fois par seconde. Réglez cette borne avec REALTIME_REFETCH_MAX_WAIT_MS (ou realtime.refetchMaxWaitMs) : plus haute, elle relit moins souvent une table très sollicitée ; plus basse, elle envoie des trames plus fraîches.

    Une nouvelle requête qui lit exactement ce qu’un abonné a reçu en dernier ne lui envoie rien : une écriture sur la ligne 9 000 d’une liste qui affiche les 50 premières coûte la lecture, pas une trame à chaque spectateur. Après une trame d’erreur, les lignes suivantes sont renvoyées même si elles sont identiques.

Les versions antérieures envoyaient un collection_patch immédiat contenant la ligne écrite avant cette nouvelle requête, pour un retour inter-onglets inférieur à la milliseconde. Cette ligne ayant été lue sous les droits de l’auteur de l’écriture, elle pouvait — et de fait parvenait — à des abonnés dont les propres politiques l’auraient refusée, et le filtre where de l’abonnement ne lui était pas non plus appliqué. Ce patch a été supprimé : la latence perçue pour une mise à jour correspond désormais à la fenêtre de temporisation (debounce).

La nouvelle requête exécute le même pipeline que GET /api/data/<collection>, avec la même gestion de include. C’est ce qui permet à find({ q }) et listen({ q }) de renvoyer des lignes rigoureusement identiques, champ par champ.

Auparavant, il s’agissait d’une méthode différente — qui imbriquait chaque relation sous une enveloppe { "__type": "relation" } et, comme un abonnement ne pouvait pas comporter de include, chargeait systématiquement (eager loading) chaque relation déclarée par la collection. Ainsi, la même requête renvoyait un format via HTTP et un autre via le socket, et un client affichant les deux voyait ses lignes changer de format dès qu’une écriture survenait.

Une trame d’abonnement prend donc les mêmes paramètres qu’une requête de liste : filter, logical, orderBy, limit, offset/page, searchString, include et fields. vectorSearch constitue l’exception et est refusé avec l’erreur VECTOR_SEARCH_NOT_LIVE — un abonnement étant réexécuté à chaque écriture correspondante, rien à ce niveau ne calcule de distances.

collection_update transporte ses propres métadonnées

Section intitulée « collection_update transporte ses propres métadonnées »

La trame est de la forme { rows, pks, meta } :

{
"type": "collection_update",
"subscriptionId": "…",
"rows": [ { "id": 1, "title": "Widget" } ],
"pks": [ { "fieldName": "id", "type": "number" } ],
"meta": { "total": 150, "limit": 20, "offset": 0, "hasMore": true, "nextCursor": "eyJ…" }
}

meta est comptabilisé au sein de la même transaction soumise au RLS ayant lu les lignes, il décrit donc précisément les lignes qui l’accompagnent. Sans cela, un client ayant besoin d’un total devait émettre un GET /count par notification reçue — un aller-retour supplémentaire par écriture et par abonné, et un intervalle durant lequel le total et les lignes décrivaient des états différents de la collection.

Lorsque le décompte lui-même échoue, la trame comporte partial: true et aucun total ; cela ne constitue pas une erreur d’abonnement, et un client doit conserver le dernier total réel plutôt que de lui substituer la longueur de la page. Sur une grande table, le décompte représente l’essentiel du coût d’une trame. Avec REALTIME_RECOUNT_INTERVAL_MS défini, une liste dont le dernier total était d’au moins REALTIME_RECOUNT_MIN_ROWS (100 000 par défaut) réutilise ce total pendant l’intervalle au lieu de compter à chaque écriture, et une telle trame porte meta.totalAsOf, le moment où le total a été pris. Quand l’intervalle est écoulé, une liste dont la dernière trame a réutilisé le total est recomptée et reçoit le nouveau total, que quelque chose ait été écrit ou non depuis. Non défini, comme par défaut, chaque trame compte.

Comme chaque trame est une requête exécutée en tant que son abonné, le coût d’une écriture croît avec le nombre de questions distinctes posées par des identités distinctes sur la collection qu’elle a touchée — pas avec le nombre de sockets ouverts :

  • Les abonnements qui posent la même question (même chemin, filtres, tri, page, include, fields, recherche) en tant que la même identité (même id d’utilisateur, mêmes rôles, même statut invité et mêmes revendications) partagent une seule nouvelle requête. Une écriture sur une liste coûte une transaction par groupe de ce genre — la lecture et son décompte — quel que soit le nombre de sockets qui le détiennent ; chaque membre reçoit tout de même une trame construite à partir de lignes lues sous cette identité. Une première page qui revient plus courte que sa limit est l’ensemble complet : elle n’est donc pas comptée du tout.
  • Un abonnement à une seule entité n’est interrogé que lorsque son propre enregistrement change.
  • Des identités différentes ne partagent jamais une lecture — c’est ce qui empêche une trame de porter une ligne que son destinataire ne devrait pas voir. Une liste surveillée par mille utilisateurs connectés différents, c’est mille lectures et mille décomptes à chaque écriture qui la touche, dans mille transactions.

Mesuré sur un ordinateur portable contre un PostgreSQL local avec un pool de 20 connexions, une écriture sur une liste :

Abonnés Transactions pour l’écriture Dernière trame après l’écriture
1 000, une seule identité 2 ~310 ms
1 000, chacun une identité différente ~1 000 ~600 ms

300 ms de chacune sont la temporisation (debounce). La seconde ligne est le plafond à prévoir. Les nouvelles requêtes s’exécutent sur le même pool que votre trafic REST, mais au plus sur un quart de ses connexions à la fois (5 sur les 20 par défaut, jamais moins de 2) ; les autres attendent leur tour dans le serveur, si bien que la diffusion d’une écriture retarde ses propres trames plutôt que vos requêtes. Une collection écrite plusieurs fois par seconde tandis que des centaines d’utilisateurs différents surveillent la même liste est le point où ces trames commencent à prendre du retard. Au-delà, préférez des abonnements plus étroits (une page, un filtre sur les propres lignes de l’utilisateur) ou un canal de diffusion qui porte le changement pour que les clients le relisent à leur propre rythme.

Un socket peut détenir au plus 1 000 abonnements. Le suivant est refusé avec une trame d’erreur codée TOO_MANY_SUBSCRIPTIONS ; se réabonner sous un id que le socket détient déjà remplace cet abonnement et ne compte pas une deuxième fois. Le SDK partage les abonnements identiques sur un socket, ce qui compte donc les listes et enregistrements distincts qu’une page a ouverts. Changez cela avec REALTIME_MAX_SUBSCRIPTIONS_PER_SOCKET ou realtime.maxSubscriptionsPerSocket sur l’adaptateur Postgres (la variable d’environnement l’emporte) ; une valeur qui n’est pas un entier positif arrête le serveur au démarrage. Sur la 0.23 les abonnements d’un socket ne sont pas limités. Les sockets qu’un même utilisateur détient peuvent aussi être limités, par instance de serveur : définissez REALTIME_MAX_SOCKETS_PER_USER (ou realtime.maxSocketsPerUser), et une connexion au-delà est refusée avec TOO_MANY_SOCKETS et le socket fermé avec le code 4002. Les sockets connectés avec une clé d’API comptent pour cette clé, chaque clé séparément, et non pour le compte qui l’a créée ; la clé de service est un seul identifiant, donc tous ses sockets comptent ensemble. Les sockets d’un administrateur qui usurpent un utilisateur comptent pour l’administrateur, avec ses propres sockets, et non pour l’utilisateur qu’il incarne. Le SDK notifie en une fois chaque abonnement actif et onConnectionLost, avec une erreur CONNECTION_LOST dont details.reason vaut TOO_MANY_SOCKETS, et continue de réessayer en attendant plus longtemps à chaque fois, jusqu’à 30 s, de sorte que l’onglet finit par entrer une fois qu’un autre socket est fermé. Non défini, comme par défaut, il n’y a aucun plafond.

Un id d’abonnement est propre au socket : deux clients qui nomment tous deux un abonnement "sub-1" conservent chacun le leur, et un unsubscribe ne met fin qu’à celui de l’émetteur.

S’abonner compte dans la limite de débit des données. La première trame d’un abonnement est une lecture, exécutée en tant que l’abonné, de même que la demande d’historique d’un canal. subscribe_collection, subscribe_one et channel_history comptent donc comme des requêtes de données dans le compartiment de limite de débit de l’appelant (celui que partagent ses requêtes HTTP et ses autres sockets) une fois l’allocation propre au socket épuisée. Chaque socket peut en envoyer 1 000 sans les compter, ou autant que son plafond d’abonnements lorsqu’il est plus élevé, et l’allocation se reconstitue sur 15 minutes. C’est tout ce qu’un socket peut détenir : le réabonnement après une reconnexion n’est donc jamais refusé et ne dépense jamais le budget de la personne. Au-delà de l’allocation, une trame au-delà de la limite est refusée par une trame d’erreur codée RATE_LIMITED, que le SDK remet au onError de cet abonnement, ou à celui du canal. Sur la 0.23, ces trames ne comptent que dans le budget propre au socket, de 2 000 trames par minute.

Un client peut disparaître sans fermer son socket : un téléphone perd le réseau, un ordinateur portable se met en veille, un NAT oublie la connexion. Aucune trame de fermeture n’arrive, donc rien ne prévient le serveur. Le serveur envoie donc un ping à chaque socket toutes les 30 secondes et met fin à celui qui n’a pas répondu avant le ping suivant : un client disparu est ainsi libéré en moins d’une minute. Les navigateurs et le SDK répondent seuls aux pings ; un client qui parle directement le protocole doit y répondre aussi, comme le font la plupart des bibliothèques WebSocket.

Côté navigateur. Un navigateur ne peut ni envoyer ni voir un ping : un socket à moitié ouvert lui paraît donc connecté pour toujours. Le SDK ouvre le socket avec ?rebase_caps=heartbeat,channel_seq,cancel_sql ; un serveur qui les connaît répond par {"type":"hello","capabilities":[…],"heartbeatMs":30000}, qui liste celles qu’il honore (cancel_sql : il accepte la trame CANCEL_SQL de la console SQL), et envoie {"type":"heartbeat"} toutes les 30 secondes ; le SDK abandonne le socket et se reconnecte après deux battements manqués. Un client qui n’en nomme aucune n’en reçoit aucune, et un serveur plus ancien ignore le paramètre : un SDK qui parle à l’un d’eux se comporte comme avant.

Un socket est aussi fermé de force quand plus de 16 Mio de trames qui lui ont été envoyées restent non lues. C’est un client qui ne lit pas ce qu’on lui envoie, ou un client si en retard que ce qu’il lirait est déjà périmé. Et lorsque les sockets d’un serveur détiennent ensemble plus de 256 Mio non lus (ou un quart de la limite de mémoire sous laquelle le serveur s’exécute, si elle est plus petite), ceux qui ont le plus de données non lues sont fermés en premier, jusqu’à ce que le reste tienne.

Un socket fermé de force est nettoyé comme un socket fermé. Ses abonnements prennent fin, il quitte ses canaux et sa présence est retirée, son départ étant annoncé aux autres membres. Un client encore présent voit la connexion tomber, et le SDK se reconnecte et se réabonne. Sur 0.23, un tel socket reste ouvert jusqu’à ce que le système d’exploitation abandonne la connexion (environ deux heures sous Linux), et chaque écriture dans une collection à laquelle il est abonné coûte toujours une nouvelle requête pour lui.

Les canaux de diffusion permettent aux clients de s’envoyer mutuellement des messages arbitraires en temps réel — utile pour des fonctionnalités telles que les indicateurs de frappe, la position des curseurs ou les notifications personnalisées.

La diffusion est gérée au niveau du protocole WebSocket. Le serveur prend en charge ces types de messages :

Type de message Sens Description
join_channel Client → Serveur Rejoindre un canal nommé
leave_channel Client → Serveur Quitter un canal
broadcast Client → Serveur Envoyer un message à tous les membres du canal
broadcast Serveur → Client Recevoir un message d’un autre membre
channel_history Client → Serveur Demander les messages conservés après une séquence
channel_history Serveur → Client Les messages conservés qu’un client a manqués

Lorsqu’un client envoie un message broadcast, le serveur le relaie à tous les autres membres de ce canal (l’expéditeur ne reçoit pas son propre message).

// Broadcast message structure (sent by client)
{
type: "broadcast",
payload: {
channel: "room-42",
event: "typing",
payload: { userId: "user-1", isTyping: true }
}
}
// Received by other clients in the channel
{
type: "broadcast",
channel: "room-42",
event: "typing",
payload: { userId: "user-1", isTyping: true }
}

Par défaut, une diffusion parvient aux membres actuellement connectés, puis disparaît. C’est le compromis idéal pour les notifications et les curseurs, et cela ne coûte rien.

Pour un flux d’opérations — édition collaborative ou toute situation où une interruption silencieuse entraîne une divergence —, un canal peut être configuré pour retenir ses messages. Les diffusions retenues se voient attribuer un numéro de séquence par canal et sont stockées, de sorte qu’un client qui se reconnecte puisse demander tout ce qui a suivi le dernier message qu’il a reçu.

La rétention est optionnelle et se configure ici, sur le serveur :

import { initializeRebaseBackend } from "@rebasepro/server";
import { createPostgresAdapter } from "@rebasepro/server-postgres";
await initializeRebaseBackend({
app,
server,
database: createPostgresAdapter({
connection: db,
schema: { tables, enums, relations },
realtime: {
channels: [
// Most specific first — the first match wins.
{ match: "doc:draft:*", limit: 100 },
{ match: "doc:*", limit: 500, ttl: "24h" }
]
}
})
});
Champ Description
match Nom exact du canal ("doc:42") ou préfixe terminé par un astérisque * ("doc:*")
limit Conserver au maximum ce nombre de messages les plus récents par canal
ttl Conserver les messages pendant cette durée au maximum — "30s", "15m", "24h", "7d" ou millisecondes

Une règle nécessite au moins limit ou ttl. Une règle ne comportant ni l’un ni l’autre est ignorée et journalisée, car une rétention illimitée n’est presque jamais souhaitée et il est impossible de revenir en arrière une fois que la table a grossi.

Les canaux avec rétention utilisent deux tables dans le schéma rebase, créées automatiquement au démarrage lorsqu’au moins une règle est configurée :

Table Contenu
rebase.channel_messages Les messages retenus, indexés par (channel, seq)
rebase.channel_cursors Le numéro de séquence le plus élevé émis par canal

Le nettoyage (pruning) s’effectue à l’arrivée des messages, régulé par canal afin que le coût soit proportionnel au temps écoulé plutôt qu’au volume d’écriture. Il ne supprime jamais que des lignes de channel_messages — les curseurs sont conservés indéfiniment (ils représentent une petite ligne par canal), car réinitialiser la séquence d’un canal modifierait la signification du point de reprise enregistré d’un client.

  • Ordonnée. Les numéros de séquence sont alloués par canal, et l’ordre de livraison correspond à l’ordre des séquences — sur une seule instance, et entre instances sur le bus postgres, où l’instruction qui numérote un message l’annonce aussi, si bien que chaque instance (y compris celle de l’émetteur) reçoit les annonces dans l’ordre des séquences. Un bus que vous fournissez vous-même transporte les trames dans l’ordre où il les livre.
  • Durable avant d’être livré. Un message qui ne peut pas être stocké n’est livré à personne, et l’expéditeur en est informé. Le livrer le présenterait aux abonnés connectés en direct tout en l’excluant de tout rejeu futur, et aucun message ultérieur ne pourrait combler cet écart.
  • Au moins une fois lors du rattrapage (At-least-once). Une plage de rejeu peut chevaucher des messages qu’un client a déjà reçus ; le SDK élimine ceux qu’il a déjà livrés.
  • Aucun trou. Un message conservé atteint chaque membre : l’expéditeur, à qui son propre message n’est jamais envoyé, reçoit à la place {"type":"broadcast_ack","channel","seq"} lorsqu’il a opté pour channel_seq. Un message au-delà de lastSeq + 1 signifie donc qu’un message a été manqué : le SDK le retient, demande l’historique intermédiaire et les livre tous dans l’ordre. Face à un serveur sans channel_seq, un saut est livré tel qu’il arrive.

La présence permet de suivre quels utilisateurs sont actuellement en ligne dans un canal et permet à chacun de partager un état personnalisé (ex. : position du curseur, statut).

Type de message Sens Description
presence_track Client → Serveur Commencer à suivre la présence avec un état personnalisé
presence_untrack Client → Serveur Arrêter le suivi de présence
presence_state Client → Serveur Demander l’état de présence complet d’un canal
presence_state Serveur → Client Toutes les présences du canal, en entier
presence_diff Serveur → Client Mise à jour incrémentielle (arrivées et départs)

Lorsqu’un client envoie presence_track, le serveur l’ajoute automatiquement au canal (nul besoin d’un join_channel distinct) et diffuse un presence_diff à tous les membres du canal. Le SDK renvoie son état toutes les 20 secondes environ en guise de battement de cœur (heartbeat) ; un battement dont l’état n’a pas changé ne fait que rafraîchir le délai d’expiration du client et n’envoie aucun presence_diff.

// Track presence
{
type: "presence_track",
payload: {
channel: "document-edit-42",
state: { name: "Alice", cursor: { line: 10, col: 5 } }
}
}
// Presence diff received by other clients
{
type: "presence_diff",
channel: "document-edit-42",
joins: { "client-abc": { name: "Alice", cursor: { line: 10, col: 5 } } },
leaves: {}
}
// Full presence state response
{
type: "presence_state",
channel: "document-edit-42",
presences: {
"client-abc": { name: "Alice", cursor: { line: 10, col: 5 } },
"client-def": { name: "Bob", cursor: { line: 22, col: 0 } }
}
}

Les présences inactives sont automatiquement nettoyées après 30 secondes d’inactivité.

Le SDK typé se reconnecte automatiquement lorsque la connexion WebSocket est interrompue :

  • Temporisation exponentielle (Exponential backoff) — Chaque recomposition attend un palier qui commence à 2 secondes et double à chaque fois, jusqu’à 30 secondes. Le délai est tiré au hasard n’importe où dans son palier, mais jamais en dessous d’un quart de seconde, de sorte que des clients coupés en même temps par un déploiement reviennent répartis sur tout le palier plutôt qu’au même instant.
  • Pas d’abandon tant que quelque chose est actif — Tant qu’un abonnement ou un canal rejoint existe, le client continue de recomposer au plafond de 30 secondes pendant toute la durée de la coupure. Sans rien d’enregistré, il s’arrête après 5 tentatives infructueuses, et le prochain abonnement recompose. Dans un navigateur, l’événement online et le fait que l’onglet redevienne visible recomposent tous les deux immédiatement plutôt que d’attendre la fin de la temporisation.
  • Les coupures sont signalées une seule fois — Lorsque la connexion est tombée depuis 14 secondes, quel que soit le nombre de recompositions que cela a représenté, l’onError de chaque abonnement actif et de chaque canal rejoint reçoit une RebaseApiError avec le code CONNECTION_LOST. L’abonnement est conservé : l’erreur signale que ses données ne se mettent plus à jour, pas qu’il a pris fin.
  • Réabonnement automatique — En cas de reconnexion réussie, tous les abonnements actifs sont réenregistrés auprès du serveur, et le prochain onUpdate de chacun porte tout ce qui a été écrit pendant l’absence du client. Cette mise à jour est le signal de rétablissement. Aucune intervention manuelle n’est requise.
  • Les requêtes sont au plus une fois (at-most-once) — Une requête faite pendant que le socket est en panne attend sa remise en ligne, pendant 30 secondes au plus à partir de l’appel, puis échoue avec REQUEST_TIMEOUT sans jamais avoir été envoyée. Une requête déjà envoyée au moment de la coupure échoue avec CONNECTION_LOST et n’est pas renvoyée : le serveur a pu l’exécuter ou non, et seul l’appelant sait s’il est sûr de l’exécuter deux fois.

client.ws.state indique où en est la connexion, et onStateChange est informé de chaque changement. Sur la 0.23 ni l’un ni l’autre n’existe, le client s’arrête après 5 tentatives infructueuses, et les messages envoyés pendant la déconnexion sont mis en file et envoyés après la reconnexion :

État Signification
idle Aucun socket, et aucun désiré pour l’instant (la connexion est paresseuse), ou une déconnexion l’a abandonné.
connecting En train de composer, sans coupure en cours.
connected Le socket est ouvert.
reconnecting Le socket s’est coupé et le client recompose. Rien n’a encore été signalé.
disconnected La coupure dure depuis 14 secondes ou plus. CONNECTION_LOST a été signalé, et le client continue de recomposer.
closed client.close() a été appelé. Définitif.
// `ws` is undefined on a client built without realtime, so narrow it once.
const ws = client.ws;
if (ws) {
ws.onStateChange((state) => {
showOfflineBanner(state === "disconnected");
});
ws.on("connect", () => console.log("Connected"));
ws.on("disconnect", () => console.log("Disconnected"));
ws.on("reconnect", () => console.log("Reconnected"));
ws.on("error", (error) => console.error("Error:", error));
}

L’admin Rebase affiche sa propre bannière tant que l’état est disconnected, et conserve les lignes et enregistrements déjà à l’écran plutôt que de les remplacer par une erreur.

Les abonnements WebSocket respectent automatiquement les politiques de sécurité au niveau des lignes (RLS). Lorsque le client est authentifié :

  1. La connexion WebSocket s’authentifie en utilisant le même jeton JWT que l’API REST.
  2. Chaque réévaluation d’abonnement s’exécute dans une transaction PostgreSQL avec set_config('app.user_id', ...) et set_config('app.user_roles', ...) — garantissant ainsi l’application des politiques RLS.
  3. L’identité est revérifiée pendant toute la durée où le socket reste ouvert, pas seulement à son authentification. Avant chaque trame, le serveur demande ce qu’une requête HTTP demanderait : le jeton est-il toujours valide, sa session a-t-elle été déconnectée ou révoquée, le compte existe-t-il encore, et quels rôles détient-il maintenant. Les trames de canal sont vérifiées au plus une fois par seconde. Les trames qui arrivent sur un même socket pendant que sa vérification est encore en cours — un client qui renvoie ses abonnements après une reconnexion — partagent cette vérification au lieu de la redemander chacune ; la trame suivante, une fois celle-ci terminée, redemande à neuf. Un socket qui ne fait qu’écouter est vérifié au moins toutes les 30 secondes, et un jeton cesse d’être honoré à l’instant où il expire. Un rôle retiré s’applique à partir de la trame suivante (ou, pour une trame qui a partagé une vérification déjà en cours, de celle d’après), aussi bien pour les lectures, les écritures que les abonnements ouverts. Un socket dont l’identité a pris fin reçoit une trame AUTH_ERROR et est fermé avec le code 4001. Avec l’authentification intégrée, le code de la trame dit pourquoi, comme le disent les routes HTTP : SESSION_REVOKED (déconnectée, révoquée, ou compte supprimé), ACCOUNT_DISABLED ou TOKEN_EXPIRED ; celui d’un autre adaptateur d’authentification est SESSION_ENDED. Un AUTHENTICATE pour une session révoquée ou un compte désactivé est refusé avec SESSION_REVOKED ou ACCOUNT_DISABLED (un jeton qui ne se vérifie pas, ou qui a expiré, avec INVALID_TOKEN). Le SDK ne redemande pas avec le même jeton, et transmet le refus, code compris, au onError de ce qui avait besoin du compte. Le SDK réauthentifie son socket chaque fois qu’il actualise son jeton ; un nouveau jeton pour la même identité (mêmes id d’utilisateur, rôles, statut invité et revendications) laisse les abonnements ouverts tels quels, et un jeton pour une identité différente relit chacun d’eux sous cette identité. Après un 4001 il se reconnecte avec la session qu’il détient alors, ou sans aucune. Un client utilisant directement le protocole doit envoyer un jeton frais dans AUTHENTICATE avant que l’ancien n’expire.

Cela signifie que chaque socket ne reçoit que les mises à jour relatives aux enregistrements que son identité authentifiée est autorisée à voir.

Une clé API authentifie aussi le socket : envoyez la clé rk_… là où irait le jeton d’accès. Le socket vérifie alors chaque trame au regard des portées de la clé : une lecture, un comptage ou un abonnement exige data:read sur sa collection, un enregistrement data:write, une suppression data:delete. Un chemin imbriqué exige la portée simple. Les canaux (diffusion et présence) sont refusés pour une clé, car aucune portée ne les couvre. Les messages de l’éditeur SQL et des branches exigent database:read ou database:write, pour une clé comme pour une personne.

L’exécution de plusieurs instances — le bus LISTEN/NOTIFY, le comportement de la présence inter-processus et l’écriture de votre propre transport — dispose de sa propre page : Le temps réel multi-instances.

Capture des modifications au niveau de la base de données (CDC)

Section intitulée « Capture des modifications au niveau de la base de données (CDC) »

Chaque écriture validée émet des événements en temps réel, quelle que soit sa provenance : REST, le SDK, Studio, psql, une tâche cron dans un autre service. Comment fonctionne la capture, le paramètre REALTIME_CDC et ce dont elle a besoin : Capture des modifications de la base de données.

Pour éviter que les requêtes des clients ne restent indéfiniment bloquées, toutes les opérations WebSocket en attente qui attendent une réponse du serveur (telles que les requêtes ponctuelles de collection FETCH_COLLECTION, les requêtes d’entité unique FETCH_ONE, la création/mise à jour SAVE, les suppressions DELETE, les comptages COUNT et les vérifications d’unicité CHECK_UNIQUE_FIELD) ont un délai d’expiration par défaut de 30 secondes.

Si le serveur ne répond pas dans cet intervalle de 30 secondes, le client supprime automatiquement la requête en attente et rejette la promesse avec une RebaseApiError dont le code est REQUEST_TIMEOUT.

Les messages unidirectionnels qui n’attendent pas de réponse (comme subscribe_collection, subscribe_one, unsubscribe, join_channel, leave_channel, broadcast, presence_track, presence_untrack, presence_state et channel_history) sont résolus immédiatement lors de l’envoi et ne déclenchent pas ce délai d’expiration. Un abonnement dont la première trame n’est pas arrivée 30 secondes après l’envoi de sa souscription échoue en revanche, avec SUBSCRIPTION_TIMEOUT sur son onError.

Une trame de canal fonctionne selon le principe « tirer et oublier » (fire-and-forget) : await channel.broadcast(...) est résolu dès que la trame est écrite sur le socket, et non lorsque le serveur l’a acceptée. C’est délibéré — une application collaborative diffuse la position d’un curseur soixante fois par seconde, et attendre un accusé de réception pour chacune transformerait chaque envoi en un aller-retour réseau.

Un refus ne peut donc pas être une promesse rejetée. Il parvient via onError :

const channel = client.realtime.channel("doc:42");
channel.onError((error) => {
if (error.code === "CHANNEL_FORBIDDEN") showReadOnlyBanner();
if (error.code === "RATE_LIMITED") throttleCursorUpdates();
});
Code Signification
CHANNEL_FORBIDDEN Vous n’êtes pas membre du canal — rejoignez-le avant d’émettre ou de lire son historique
RATE_LIMITED Dépassement du quota de trames de canal ci-dessus (7 200 trames de canal par minute et par socket), ou, pour une demande d’historique, de la limite de débit des données
CHANNEL_HISTORY_WRITE_FAILED Une diffusion retenue n’a pas pu être persistée et a donc été abandonnée
CHANNEL_HISTORY_READ_FAILED Une requête de rattrapage n’a pas pu être satisfaite
CHANNEL_HISTORY_GAP Levée par le client : un rattrapage a constaté que le serveur ne conserve plus des messages que ce client n’a jamais reçus. details vaut { from, to }, les numéros de séquence manqués. Resynchronisez l’état du canal depuis sa source de vérité
CHANNEL_BUS_PAYLOAD_TOO_LARGE La diffusion n’a atteint que cette instance — voir La limite de 8 Ko sur le bus Postgres

Si aucun gestionnaire n’est attaché, ces événements sont journalisés sous forme d’avertissement. Auparavant, ils étaient entièrement ignorés : il n’y avait aucune promesse à rejeter ni aucun canal auquel les transmettre, de sorte qu’une diffusion interdite ne pouvait pas être distinguée d’une diffusion menée à bien.

  • SDK typé — Référence complète du SDK, y compris les accesseurs de collection typés.
  • Authentification — Configurer l’authentification JWT et les politiques RLS.
  • Architecture Backend — Vue d’ensemble de l’architecture du serveur Rebase.