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).
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.
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):
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):
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(...):
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:
<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:
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:
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:
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:
| Field | Meaning |
|---|---|
table | the model's table name |
action | ModelAction::Created / Updated / Deleted |
instance | the row as serde_json::Value |
actor | who 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) |
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.