Realtime between users
A save returns the updated models to whoever saved them. That is why a list refreshes itself for the person who edited it and stays stale for everyone else — and why "one user changes it, the others see it" is a decision you make per model rather than something that silently happens to all of them.
That decision is one line of configuration.
Broadcasting from a config
final newsPostCrudConfig = DwCrudConfig<NewsPost>(
table: NewsPost.t,
getListConfig: DwGetModelListConfig(accessFilter: (session) async => null),
saveConfig: DwSaveConfig<NewsPost>(
allowSave: (session, ctx) async => await session.isStaffMember,
broadcastTo: (session, ctx) => [DwCoreConst.publicUpdatesChannel],
),
deleteConfig: DwDeleteConfig<NewsPost>(
allowDelete: (session, model) => session.isStaffMember,
broadcastTo: (session, model) => [DwCoreConst.publicUpdatesChannel],
),
);
Nothing is written on the Flutter side. Every model arriving on a subscribed channel is routed by
type into any dw.repo.modelList<T>() the app holds, so a list already on screen redraws itself.
An app created by dartway create subscribes to DwCoreConst.publicUpdatesChannel at its root, which
is what makes the config above sufficient on its own.
Delete alongside save, or not at all. A model that broadcasts its saves and not its deletions leaves every other client showing a row that no longer exists — noticed later, by someone else, in a state you cannot reproduce.
A channel is an audience, and accessFilter is not in it
accessFilter governs reading through the API. It has no say over what travels on a channel: every
subscriber receives what you post, including users who could never have fetched that row themselves.
Who is in the audience is a separate declaration — see Declaring a channel
below — but nothing filters the payload once it is in flight.
So broadcastTo is null by default, and the question to answer before setting it to the public
channel is not "should this be live?" but:
Is any user of this app entitled to read this row?
- Yes — a catalogue item, a price, remaining capacity, a published announcement — the public channel is right.
- No — orders, messages, profiles — narrow the channel to the audience that may see it
(
'chat:${ctx.currentModel.chatId}'), or notify the single owner:
session.sendUpdatesToUser(order.clientProfileId, updatedModels: [order]);
When the private row and the public fact differ
broadcastTo sends everything the operation touched, the saved row included. A booking is private
while the remaining capacity it changed is public, so broadcasting from the booking's config would
hand every client somebody else's booking.
Choose what travels instead:
saveConfig: DwSaveConfig<SessionBooking>(
allowSave: ...,
afterSaveSideEffects: (session, ctx) async {
final clubSession = await ClubSession.db.findById(session, ctx.currentModel.clubSessionId);
// only the public row leaves; the booking stays between its owner and the server
session.sendUpdates(
channels: [DwCoreConst.publicUpdatesChannel],
updatedModels: [clubSession],
);
},
),
sendUpdates takes the channels and the models explicitly. sendUpdatesToUser is the shortcut for
the one audience that is always safe — a single known user — and delegates to it.
Declaring a channel
A channel name reaches the server as a plain string chosen by the client. Nothing else in the system
can tell a real name from a guessed one, and every name worth guessing is guessable by construction —
userUpdates42 differs from userUpdates43 by one character. Authentication is not the answer
here: a signed-in stranger guesses just as well as an anonymous one.
So a channel is declared, and a channel nobody declared cannot be subscribed to:
DwCore.init(
crudConfigurations: [...],
channelConfigurations: [
DwChannelConfig.public(prefix: 'catalogue'),
DwChannelConfig.owner(prefix: 'orders'), // orders42 — user 42 and nobody else
DwChannelConfig.guarded(
prefix: 'chat:',
allowListen: (session, suffix) async => await session.isChatMember(int.parse(suffix)),
),
],
);
public— anyone, signed in or not. One word, and it shows up in review.owner— the suffix is a user profile id, and only that user gets in. The shape behindsendUpdatesToUser, given a constructor because the hand-written version is the one that forgets to compare.guarded— your own check, handed the session and the part of the name after the prefix.allowAnonymousdefaults tofalse, so the predicate is never called without a user unless you say otherwise.
The framework declares its own two: DwCoreConst.publicUpdatesChannel and the per-user channel behind
sendUpdatesToUser.
A prefix is a prefix. DwChannelConfig.public(prefix: 'updates') also opens updates_admin.
Name a public channel in full, and give a parametrised one a separator you would not type by accident
— chat:, not chat. When two declarations match, the longer prefix decides.
Subscribing to a narrower channel
The root subscription covers the public channel, and the signed-in user's own channel needs no
subscription at all: the framework follows userUpdates<id> for whoever is signed in, and drops it
on sign-out. That is the other half of sendUpdatesToUser — one name, built from DwCoreConst on
both sides, and handed by the server to nobody else.
A screen that watches a group channel adds its own:
DwChannelSubscriptionWidget(
channel: 'chat:$chatId',
child: messagesList, // an ordinary ref.watch(dw.repo.modelList<ChatMessage>())
)
The channel name is built the same way on both sides, and the server refuses it unless a declaration
above covers it. DwCoreConst.publicUpdatesChannel exists so the common case has one spelling both
halves import rather than two string literals that drift.
Reconnects, and when a subscription ends for good
A channel subscription is a stream, and a stream dies with the socket that carried it. What the app
asked to follow outlives that: DwSocketService keeps the requests apart from the streams and opens
every requested channel again, five seconds after a failure, for as long as it takes.
dw.socketService.statusNotifier says where that stands — DwSocketStatus.idle when nothing is
subscribed (there is no connection without a subscription: the socket opens with the first channel
and closes with the last), connected, or waitingToRetry. It is not a heartbeat: a socket that
dies silently reads as connected until the client's keep-alive notices, which takes up to 40 seconds.
Two things end a subscription rather than pausing it, and neither is retried:
- The channel was refused — no declaration covers it, or this user is not in its audience. Asking again would get the same answer, so it is reported through the app's error handler instead. A screen that never updates and an error naming the channel is the pair to look for.
- The authentication was revoked —
revokeAuthKeysended this account's sessions while it was listening. Every channel closes and the local session ends.
What is not covered
Broadcasting is delivery, not authorisation, and not persistence: a client that was offline when the message went out learns about the change when it next reads. Reconnects keep their subscriptions, but messages sent while the socket was down are not replayed.