EctoFDB Watches in Phoenix LiveView
Mix.install([
{:phoenix_playground, "~> 0.1.0"},
{:ecto_foundationdb, git: "https://github.com/foundationdb-beam/ecto_foundationdb", branch: "main"}
])
Intro
We’re going to create a simple LiveView to showcase your favorite quote. We want to make sure our LiveView always has the most up-to-date information about the quote. A common appproach to a problem like this is to use Phoenix.PubSub
. Instead, we’ll use EctoFoundationDB’s Watches to deliver the messaging.
Setup Ecto
First, we set up Ecto, defining a Quote schema, and starting the Repo. For this to work, you must have foundationdb-server
running locally. Refer to the EctoFoundationDB documentation for installation help.
defmodule Repo do
use Ecto.Repo, otp_app: :my_app, adapter: Ecto.Adapters.FoundationDB
use EctoFoundationDB.Migrator
def migrations(), do: []
end
defmodule Quote do
use Ecto.Schema
alias Ecto.Changeset
@schema_context usetenant: true
@primary_key {:id, :binary_id, autogenerate: true}
schema "quotes" do
field(:author, :string)
field(:content, :string)
field(:likes, :integer, default: 0)
field(:as_of, :naive_datetime)
timestamps()
end
def changeset(quote, params \\ %{}) do
quote
|> Changeset.cast(params, [:author, :content, :likes])
end
def like!(tenant, id) do
Repo.transaction(fn ->
quote = Repo.get!(Quote, id)
quote
|> changeset(%{likes: quote.likes+1})
|> Repo.update!()
end, prefix: tenant)
end
end
{:ok, _} = Ecto.Adapters.FoundationDB.ensure_all_started(Repo.config(), :temporary)
Repo.start_link(log: false)
alias EctoFoundationDB.Tenant
tenant = Tenant.open!(Repo, "experiment-with-watches-in-liveview")
Next, we create a Quote in the DB. We’ll focus on how this specific Quote is rendered throughout the rest of the Livebook.
Repo.insert!(
%Quote{
id: "my-favorite-quote",
author: "Philippe Verdoux",
content: """
Enlightenment leads to benightedness; Science entails nescience.
""",
as_of: NaiveDateTime.utc_now(:second)
}, prefix: tenant, on_conflict: :replace_all)
Setup LiveView
We’re using Phoenix Playground to create a sample LiveView. Some key takeaways:
-
In
assign_watch!/4
, we’re reading the Quote with id"my-favorite-quote"
that we’ve inserted above. In the same transaction, we’re creating a watch withlabel: :quote
. In the LiveView assigns, we store the:quote
and the list of:futures
. You’ll want to use the same label for both the watch and the assigns. -
handle_event/3
receives the"like"
event, and uses an FDB transaction to add 1 to the count of likes. Notice we choose not to update the assigns, for demonstration purposes. -
handle_info/2
receives the:ready
message from the watch future. It usesRepo.assign_ready/3
to update the LiveView assigns map. Whenever"my-favorite-quote"
changes in the DB, this function will be called automatically. Also, we providewatch?: true
to continue listening for updates. -
assign_watch!/4
andhandle_info/2
both contain nothing specific to the Quote schema. This is a general purpose approach that can be re-used for any other schema types.
defmodule DemoLive do
use Phoenix.LiveView
def mount(_params, _session, socket) do
tenant = Tenant.open!(Repo, "experiment-with-watches-in-liveview")
{:ok, socket
|> assign(tenant: tenant)
|> assign_watch!(Quote, :quote, "my-favorite-quote")
}
end
defp assign_watch!(socket, schema, label, id) do
{struct, futures} = Repo.transaction(
fn ->
struct = Repo.get!(schema, id)
future = Repo.watch(struct, label: label)
{struct, [future]}
end,
prefix: socket.assigns.tenant)
socket
|> assign(label, struct)
|> assign(futures: futures)
end
def render(assigns) do
~H"""
My Favorite Quote
as of <%= @quote.as_of %> UTC
<%= @quote.content %>
- <%= @quote.author %>
Likes:
<%= @quote.likes %>
👍
h1 { text-align: center; }
h3 { text-align: right; }
h6 { text-align: right; }
p { text-align: center; }
.content {
max-width: 500px;
margin: auto;
}
"""
end
def handle_event("like", _params, socket) do
Quote.like!(socket.assigns.tenant, socket.assigns.quote.id)
{:noreply, socket}
end
def handle_info({ref, :ready}, socket) when is_reference(ref) do
%{assigns: assigns} = socket
{new_assigns, futures} =
Repo.assign_ready(
assigns.futures,
[ref],
watch?: true,
prefix: assigns.tenant
)
{:noreply,
socket
|> assign(new_assigns)
|> assign(futures: futures)
}
end
end
PhoenixPlayground.start(live: DemoLive)
With the block above evaluated, you can now open a web browser to http://localhost:4000
. We suggest you keep this open side-by-side with this Livebook if possible, so that you can watch the updates in real-time.
Updating My Favorite Quote
Your favorite quote might change from moment to moment. We’ll update the database with your current favorite quote, and because of the FDB watch, the LiveView will always render the most up-to-date content, without any PubSub.
tenant = Tenant.open!(Repo, "experiment-with-watches-in-liveview")
defmodule ChangeFavoriteQuote do
@id "my-favorite-quote"
def to(tenant, author, content) do
Repo.transaction(fn ->
Repo.get(Quote, @id)
|> Quote.changeset(%{
author: author,
content: content,
as_of: NaiveDateTime.utc_now(:second),
likes: 0
})
|> Repo.update()
end, prefix: tenant)
end
end
ChangeFavoriteQuote.to(
tenant,
"Duke Leto Atreides",
"""
Give as few orders as possible. \
Once you've given orders on a subject, \
you must always give orders on that subject.
""")
ChangeFavoriteQuote.to(tenant, "Captain America", "I can do this all day.")
Real-time updates like this are common to many LiveView applications, and FDB watches fit very well into such a design. Each LiveView process can directly subscribe to all the database entities it’s interested in, without having an intermediary dispatch.