This version is in beta. Some features may change before release.

Model subscriptions

Subscribe to a model's live create / update / delete - Supabase-style, but safe by construction. Opt-in per model, field-whitelisted, id-only by default.

Model subscriptions

Stream a model's create / update / delete to the browser with zero polling - the Supabase "subscribe to a table" story, but safe by construction. A model is never broadcast unless you say so, and even then only the fields you list reach the wire.

RealtimePlugin::expose::<T>(spec) turns ORM model changes into real-time events. It wraps the lower-level on_model signal bridge with two things you'd otherwise hand-roll: a field projection (so secrets never leak) and group routing (static or per-row).

Code
rust
use umbral_realtime::{Expose, RealtimePlugin};
 
RealtimePlugin::new()
.expose::<Post>(
Expose::to_group("public:posts") // the group you consciously broadcast to
.fields(&["id", "title", "slug"]) // whitelist - only these columns go on the wire
);

When any Post is created, updated, or deleted, the framework sends an event named "created" / "updated" / "deleted" to public:posts, carrying only id, title, and slug.

Warning

Security is the default in three layers: (1) a model with no expose is never broadcast (default-deny); (2) without .fields(...) the payload is the primary key only; (3) the group you name is governed by GroupPolicy::can_join, so a private group is unjoinable under the default policy. You opt into exposure consciously, one model and one field list at a time.

Picking the group

Static group - to_group

Every matching change lands in one named group. to_group is required (you consciously pick the group whose visibility the policy governs):

Code
rust
Expose::to_group("public:posts")

Per-row group - to_group_with

Route each event to a group computed from the row, so a client can watch a single record. The closure receives the ModelEvent; ev.pk_str() returns the row's primary key as a string, for any PK type (i64, String, uuid):

Code
rust
Expose::to_group_with(|ev| format!("post:{}", ev.pk_str().unwrap_or_default()))

A client then subscribes to post:42 to follow just that post. The computed group is still governed by GroupPolicy::can_join.

Which fields reach the wire

The projection is the core safety control. There are three modes:

Updating the exposed fields

To change what a model broadcasts, edit the .fields(&[...]) list. Adding a field starts broadcasting it; removing a field stops it from ever reaching the wire. There is no separate registry - the .fields(...) whitelist is the contract.

If you'd rather not put any model data on the wire at all, drop the whole list and rely on the id-only default: the client gets a "something changed" nudge and refetches through its authorized endpoint, which already enforces row-level access. This is the safest option when the data is sensitive.

Which actions fan out

By default all three of Created, Updated, Deleted are broadcast. Restrict them with .actions(...):

Code
rust
use umbral_realtime::{Expose, ModelAction, RealtimePlugin};
 
RealtimePlugin::new()
.expose::<Post>(
Expose::to_group("public:posts")
.fields(&["id", "title"])
.actions(&[ModelAction::Created, ModelAction::Updated]) // no deletes
);

Subscribing from the browser

Use umbral.realtime.model(table, handlers, { group }). It's sugar over subscribe that wires the created / updated / deleted event names for you. group is required - name the group you exposed to; there's no magic default:

Code
html
<script src="/realtime/client.js"></script>
<script>
umbral.realtime.model("post", {
created: (row) => addPostToList(row),
updated: (row) => updatePostInList(row),
deleted: (row) => removePostFromList(row.id),
}, { group: "public:posts" });
</script>

With the id-only default, row is just { id } - the cue to refetch:

Code
js
umbral.realtime.model("post", {
updated: (row) => refetchPost(row.id), // re-load through your authorized API
}, { group: "public:posts" });

For a per-row group exposed with to_group_with, subscribe to the specific group:

Code
js
umbral.realtime.model("post", {
updated: (row) => render(row),
}, { group: "post:42" });

The lower-level bridge: on_model / on_table

expose is the safe, projected path. If you need full control - push to a user, transform the payload, fan out to several targets - use the raw signal bridge directly. It hands your handler a ModelEvent for every change and lets you do anything:

Code
rust
use umbral_realtime::{ModelAction, ModelEvent, Realtime, RealtimePlugin};
 
RealtimePlugin::new()
.on_model::<Post, _, _>(|ev: ModelEvent| async move {
if ev.action == ModelAction::Created {
Realtime::to_group(format!("post:{}", ev.pk_str().unwrap_or_default()))
.send("new", &ev.instance)
.await;
}
});

on_table("post", handler) is the same thing keyed by table name when you don't have the Model type handy. The ModelEvent fields:

FieldMeaning
tablethe model's table name
actionModelAction::Created / Updated / Deleted
instancethe row as serde_json::Value
actorwho triggered the change (Null outside a with_actor scope)
ev.pk_str()the row's primary key as Option<String>, for any PK type (i64/String/uuid)
ev.pk()the row's id as Option<i64> (integer-PK convenience accessor)
Info

on_model gives you the raw row in instance - no field projection. If you .send(..., &ev.instance) you broadcast every column. Use expose (with .fields(...)) whenever you want the safety rails; reach for on_model only when you need the extra control and are projecting the payload yourself.

See also

  • Gating access - the policy that governs who can join the group you expose to.
  • Presence - "who's online" on the same connections.
  • Transports - the connection model these events ride on.
realtimemodelssubscriptionssecuritysse