Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

EctoFDB Watches in Phoenix LiveView

notebooks/watches.livemd

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:

  1. 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 with label: :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.

  2. 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.

  3. handle_info/2 receives the :ready message from the watch future. It uses Repo.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 provide watch?: true to continue listening for updates.

  4. assign_watch!/4 and handle_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.