Summary#
Listen() is how a page receives from a topic. It yields a stream<T> of the topic's payloads;
a live var bound to it holds each message as it arrives.
[Principal] entity User {
[MaxLength(200)] string Email;
security { allow read when IsAuthenticated; }
}
entity Room {
[Required, MaxLength(120)] string Name;
security { allow read, create when IsAuthenticated; }
}
entity Member {
[Required] Room Room;
[Required] User Person;
security {
allow read where Member.Any(m => m.Room == Room && m.Person == user);
allow create where Person == user;
}
}
class ChatLine { public string Body; }
topic RoomFeed(Guid roomId) {
Candidates = u => Member.Any(m => m.Room.Id == roomId && m.Person == u);
Carries = ChatLine;
}
[Page("/room/{roomId}")]
[Render(CSR)]
component RoomPage(Guid roomId) {
live var lines = RoomFeed.For(roomId).Listen(); // stream<ChatLine> — appends as they arrive
render {
foreach (var line in lines) { Text(line.Body); }
}
}Signature#
live var lines = Topic.For(<address>).Listen(); // stream<T>, appended to as messages arriveDescription#
There is nothing to opt in to#
A page participates in realtime by writing a subscription, and by nothing else. There is no attribute, no shell
setting and no registration step: the live var above is the declaration, in the same way that declaring security on
an entity is the whole of declaring it.
Listen yields a stream, deliberately#
Listen() returns a stream<T> — the same thing a live var already consumes from a streaming function. That is the
point of the choice: a topic subscription needs no new rendering machinery, no new reconciler and no new foreach
path, because it is the mechanism the UI already has, with a topic as its producer instead of a function.
It also means the ordinary rules apply. The subscription is opened when the component mounts and released when it unmounts; a reconnect re-establishes it; and the same page open twice on one topic is one subscription, not two.
A stream is observed, never awaited#
Listen() is legal in exactly one place: as the whole initializer of a live var. There is no single value to hold —
the messages are still arriving — so var x = Topic.For(a).Listen(), await Topic.For(a).Listen() and
live var n = Topic.For(a).Listen().Count are all refused, and say so.
What you are holding is a list — query it like one#
The live var a subscription is bound to is a collection that grows, so read it with the collection vocabulary you
already have. foreach renders it, .Count says how many have arrived, and ordinary LINQ answers the rest:
live var nudges = Inbox.For(personId).Listen();
// …in the render:
foreach (var n in nudges.Reverse().Take(nudges.Count - dismissed)) { … } // newest first, undismissed only
if (nudges.Any(n => n.Kind == NudgeKind.Mention)) { … }Nothing re-reads anything to answer these — the messages are already in hand, and the chain runs where they are.
.Done, .Failed, .Interrupted and .Error are the reads that are not about the items: whether the producer
finished, and whether it stopped early. See component.
⚠ The one thing you may not do is query a subscription you have not bound. Topic.For(a).Listen().Where(…) is
refused for the same reason await on it is: there is no list yet, only a producer. Bind it to a live var first.
The address is checked at the call site — and followed while the page lives#
A topic's parameters are its address, so RoomFeed.For(a) and RoomFeed.For(b) are two subscriptions. The address binds
through the same argument rules as every other call in the language, and it is checked where you wrote it — because the
only runtime symptom of a wrong address is a page that never updates, which is indistinguishable from a quiet room.
The subscription follows the address. If what the address reads changes — a state member, a parameter — the subscription is released and re-opened at the new one, and the stream is emptied first: what was said in the room you left is not part of the room you joined.
string room = "general";
live var lines = RoomFeed.For(room).Listen(); // switching `room` moves the subscriptionA refused join is re-asked when data changes. Candidates is a question about your data, so a write is the only
thing that can change its answer — and after one, every refused subscription asks again. A page that opens a room the
reader may not enter yet therefore starts working the moment they are let in, with nothing to write for it.
⚠ What that does not do is re-run a plain query. A var read is a snapshot taken at mount by design, so a page
that also shows history has to decide for itself whether entering is worth re-reading it.
What arrives is the value, not a signal#
Each message carries the publisher's payload, and the page renders it directly. It does not re-read the database:
the subscription was admitted one principal at a time by the topic's Candidates rule, which is what makes carrying
the value sound. That is the whole economic argument for topics — a room of ten people, a hundred messages deep, costs
one message per viewer instead of a full re-query each.
A topic that carries nothing cannot be listened to#
Carries is what a subscriber binds. A signal-only topic has no value to deliver, so Listen() on one is a compile
error naming the missing declaration rather than a subscription that delivers empty frames.
Examples#
The example above is compiled by the documentation gate.
For who is present on a topic rather than what was said on it, see Here, Announce.
See also#
- topic — the declaration, and publishing
- Here, Announce — who is here, and what they are doing
- Realtime — realtime in one page
- component — the
live vara subscription feeds