VerifiedPubSub

CI Hex.pm Docs

Compile-time verified PubSub for Elixir. It applies the idea of verified routes to topics and events. Declare your topics and events once. Then a typo is a compile error, not a message that no process receives.

VerifiedPubSub is before version 1.0, so its API can change between minor versions. The changelog lists each change.

Installation

VerifiedPubSub needs Elixir 1.16 or later. It broadcasts through a Phoenix.PubSub that your application starts.

With Igniter:

mix igniter.install verified_pubsub

The installer does these steps:

To install by hand, add the dependency:

def deps do
  [
    {:verified_pubsub, "~> 0.1.0"}
  ]
end

Start a Phoenix.PubSub in your supervision tree:

children = [{Phoenix.PubSub, name: MyApp.PubSub}]

Then add the formatter import, so that the DSL has no parentheses:

# .formatter.exs
[import_deps: [:verified_pubsub]]

Declare your topics and events

defmodule MyApp.Topics do
  use VerifiedPubSub.Manifest, pubsub: MyApp.PubSub

  topic :pets, ["owners", :owner_id, "pets"] do
    message :adopted do
      field :id, :string
      field :name, :string
    end

    message :rehomed do
      field :id, :string
    end
  end

  topic :treats, ["owners", :owner_id, "treats"] do
    message :given do
      field :pet_id, :string
      field :from_owner_id, :string
      field :treat, :atom
    end
  end
end

:pets is the name that call sites use. The list is the wire topic. A : joins its segments, so this wire topic is "owners:<owner_id>:pets". A string is a literal segment, and an atom is a param.

Each message declares one event on its topic. Its fields declare the keys that the payload of that event must have. A payload is a map of exactly those fields. To make a field optional, add required: false to it.

Document your topics

The manifest writes its own docs. Its @moduledoc gets a ## Topics section. The section lists the wire pattern of each topic, its events, and the fields and types of each event. So the docs always agree with the checks of the manifest. To say what a topic, an event, or a field means, add a description. Put it inside the block, or use a keyword when there is no block:

topic :pets, ["owners", :owner_id, "pets"] do
  description "Pets of one owner."

  message :adopted do
    description "The owner adopted a pet."
    field :pet, MyApp.Pet, description: "The adopted pet."
  end

  message :rehomed, description: "The pet moved to a new owner."
end

A @moduledoc that you write stays first. @moduledoc false keeps the manifest out of the docs.

Broadcast

defmodule MyApp.Pets do
  use VerifiedPubSub, manifest: MyApp.Topics

  def adopt(attrs) do
    with {:ok, pet} <- insert(attrs) do
      broadcast!(:pets, %{owner_id: pet.owner_id}, :adopted, %{
        id: pet.id,
        name: pet.name
      })

      {:ok, pet}
    end
  end
end

Any owner can give a treat to the pet of any owner. The broadcast goes to the owner of the pet:

broadcast!(:treats, %{owner_id: pet.owner_id}, :given, %{
  pet_id: pet.id,
  from_owner_id: giver.id,
  treat: :biscuit
})

A LiveView that writes to a topic and also subscribes to it usually wants to skip the sender:

broadcast_from!(self(), :pets, %{owner_id: id}, :adopted, payload)

Subscribe

defmodule MyAppWeb.PetsLive do
  use MyAppWeb, :live_view
  use VerifiedPubSub.Subscriber, manifest: MyApp.Topics

  def mount(_params, _session, socket) do
    if connected?(socket) do
      :ok = subscribe(:pets, %{owner_id: socket.assigns.owner.id})
    end

    {:ok, stream(socket, :pets, [])}
  end

  handle_message :pets, :adopted, payload, socket do
    {:noreply, stream_insert(socket, :pets, payload)}
  end

  ignore_message :pets, :rehomed
end

The module must handle or ignore each event on a subscribed topic. To match on the params of the topic, put them where a broadcast puts them:

handle_message :pets, %{owner_id: owner}, :adopted, payload, socket do
  {:noreply, assign(socket, :owner_id, owner)}
end

Compile-time errors

An event with a typo:

broadcast!(:pets, %{owner_id: id}, :adoptd, %{id: "p1", name: "Biscuit"})
** (CompileError) lib/my_app_web/live/pets_live.ex:42: unknown event :adoptd on topic :pets (owners:<owner_id>:pets).

Declared events: [:adopted, :rehomed]
:pets is declared at lib/my_app/topics.ex:7.

When a change to the manifest can fix an error, the error points at the declaration. A manifest error is also a diagnostic on the line that causes it, so an editor can mark it.

A subscriber that does not handle or ignore each event:

handle_message :pets, :adopted, payload, socket do
  {:noreply, socket}
end
** (CompileError) MyAppWeb.PetsLive subscribes to topics with events it does not
account for:

  * :pets, :rehomed

Add a `handle_message` clause for each, or dismiss it explicitly:

    ignore_message :pets, :rehomed

Manifest: MyApp.Topics

These errors also stop the compile:

Runtime errors

Each broadcast checks a payload built at runtime, in each environment:

broadcast!(:pets, %{owner_id: id}, :adopted, %{id: 42, extra: true})
** (VerifiedPubSub.PayloadError) invalid payload for :pets :adopted in MyApp.Topics:

  * missing required key: :name
  * unexpected key: :extra — not declared on this event
  * :id is declared as :string, got: 42

Use with LLMs

VerifiedPubSub includes a usage-rules.md for coding agents. When you have usage_rules, add it to project/0 in your mix.exs:

usage_rules: [
  file: "AGENTS.md",
  usage_rules: [:verified_pubsub]
]

Then run mix usage_rules.sync to copy the rules into that file.

Documentation

The full documentation is on HexDocs.

Contributing

Please open an issue before you send a pull request. See CONTRIBUTING.md.

License

MIT. See LICENSE.