jonnifyelixir
F6 / F6.09 / stream

F6.09 · part 2 of 3

Broadcast engine events

This is where the static dashboard from F6.09.1 comes alive, and it reuses F6.07 wholesale. The domain already broadcasts a tagged event on a topic the moment a write succeeds — {:course_created, course}, {:enrolled, enrollment} — so the dashboard does not need any new emission code; it only needs to listen. It subscribes to the "events" topic on its connected mount (the connected?/1 guard again), and from then on every event arrives in handle_info/2 as a process message. The key idea is the fold: each handle_info/2 clause takes one event and applies it to the read model — bump the matching count with update/3, and stream_insert/3 a row at the top of the feed. This is the same shape as reducing a list, except the “list” arrives over time and each step pushes a diff to the browser. The dashboard never recomputes from scratch and never re-queries the database on each event; it maintains its derived numbers incrementally from the stream of facts. That is exactly the event-sourcing intuition from F5, expressed as a live LiveView: the engine is the source of events, and the dashboard is a projection that folds them as they come.

Four parts: subscribe versus handle versus the projection, the path an event folds along, subscribing at mount, and the handle_info/2 clauses that do the folding.

Subscribe, handle, fold

Three ideas drive the live update. Subscribe joins the engine’s topic. handle_info/2 receives each broadcast. The projection is the result — counts and a feed folded from the events. Select one.

The live update · select one

SUBSCRIBE Portal.subscribe("events") — on the connected mount opt in handle_info handle_info({event, _}, socket) — fold into the read model receive THE PROJECTION counts + feed derived live from the event stream the result

piece: subscribe

is: join the engine’s topic at mount

The dashboard adds no broadcasting of its own. The domain was already emitting these events for F6.07; the dashboard is one more subscriber, which is why making a page live costs so little here.

An event folds in

Trace one enrollment. The engine records it; the context broadcasts {:enrolled, _} on "events"; PubSub delivers it to the dashboard’s mailbox; handle_info/2 bumps enrollments_count and inserts a feed row; the diff goes to the browser. The number ticks up and a line appears, with no reload and no re-query.

event → handle_info → bump count + insert row → diff

broadcast {:enrolled, _} MAILBOX this dashboard handle_info/2 — fold update :enrollments_count stream_insert feed, at: 0 diff to browser count + row update incremental: the count is bumped, not recomputed; the feed gets one row, not a re-query

Folding rather than recomputing is what lets the dashboard stay cheap under load. A thousand events are a thousand small updates, each touching one count and inserting one row — never a thousand full re-reads of the database.

Subscribing at mount

mount/3 from F6.09.1 gains one line: subscribe to "events" behind connected?/1, then seed as before. The emission side is unchanged from F6.07 — the context broadcasts after each write — so the dashboard only consumes. The comment below shows the events the domain already publishes.

# the dashboard joins the engine's event topic on the connected mount
def mount(_params, _session, socket) do
  if connected?(socket), do: Portal.subscribe("events")
  {:ok, seed(socket)}      # seed/1 sets counts + an empty feed (F6.09.1)
end

# the domain already emits these after a write (F6.07) — the dashboard only listens:
#   Portal.broadcast("events", {:course_created, course})
#   Portal.broadcast("events", {:enrolled, enrollment})

Subscribing on the connected mount, not the first paint, means the disconnected HTTP render does no work it would throw away — the same discipline from F6.06.1, applied to a read-only screen.

Folding each event

One handle_info/2 clause per event kind. Each pipes the socket through update/3 to bump the relevant count and stream_insert/3 to prepend a feed row built by a small row/2 helper. The clauses are exhaustive for the events you care about; anything else can fall through to a catch-all that ignores it.

# each event folds into the read model: bump a count, prepend to the feed
def handle_info({:course_created, course}, socket) do
  {:noreply,
   socket
   |> update(:courses_count, fn n -> n + 1 end)
   |> stream_insert(:events, row("course created", course.title), at: 0)}
end

def handle_info({:enrolled, enrollment}, socket) do
  {:noreply,
   socket
   |> update(:enrollments_count, fn n -> n + 1 end)
   |> stream_insert(:events, row("enrolled", enrollment.id), at: 0)}
end

subscribe once

On the connected mount; the domain already broadcasts the events.

fold each event

handle_info/2 bumps a count and inserts a row — incrementally.

Next: many clients, live — one fold per dashboard, every viewer in sync, with a Presence count and clustering for production.