defmodule LiveSync do @moduledoc ~S""" LiveSync is the core of all interactions include a postgres replication process and LiveView hooks to automatically sync data to the client. ## Installation This project builds on top of PostgreSQL replication and it requires PostgreSQL 14+. You must also enable replication in your PostgreSQL instance: ALTER SYSTEM SET wal_level='logical'; ALTER SYSTEM SET max_wal_senders='64'; ALTER SYSTEM SET max_replication_slots='64'; Then **you MUST restart your database**. Add `live_sync` to the list of dependencies in `mix.exs`: def deps do [ {:live_sync, "~> 0.1.0"} ] end Add a migration to setup replication (requires superuser permissions to subscribe to all tables): defmodule MyApp.Repo.Migrations.SetupLiveSync do use Ecto.Migration def up do LiveSync.Migration.up() # If you don't have superuser you can pass specific tables # LiveSync.Migration.up(["table1", "table2"]) end def down do LiveSync.Migration.down() end end Add `LiveSync` to your supervision tree: * `repo` (required): The Ecto repo to use for replication. * `otp_app` (required): The OTP app to use to lookup schemas deriving the watch protocol. defmodule MyApp.Application do @moduledoc false use Application @impl true def start(_type, _args) do children = [ ... {LiveSync, [repo: MyApp.Repo, otp_app: :my_app]} ] opts = [strategy: :one_for_one, name: MyApp.Supervisor] Supervisor.start_link(children, opts) end end ## Usage For any Ecto schemas you want to watch, add the `LiveSync.Watch` derive: * `id` (optional): The primary key on the schema, defaults to `:id`. * `subscription_key` (required): The field on the schema that is used to filter messages before sending to the client. * `table` (optional): The table to watch, defaults to the schema's table name, if using a view you need to specify the table name defmodule MyApp.MyObject do use Ecto.Schema @derive {LiveSync.Watch, [ subscription_key: :organization_id, table: "objects" ]} schema "visible_objects" do field :name, :string field :organization_id, :integer end ... end Add the `LiveSync` macro to any LiveView module you want to automatically sync data: * `subscription_key` (required): This is the key that MUST exist in the assigns of the LiveView and is used for the subscription. This value must match what is in the schema's `@derive` attribute. * `watch` (required): A list of keys in assigns that should be watched. You may optionally specify a tuple with options. Schema is required for lists of objects to support inserting. use LiveSync, subscription_key: :organization_id, watch: [ :single_object, list_of_objects: [schema: MyApp.MyObject] ] ## Handling Sync Events An optional callback can also be added to the LiveView module to handle the updated data. This callback will be called for each assign key that is watched and changed. It must return the socket with updated assigns. def sync(:list_of_objects, updated, socket) do updates = updated |> Enum.filter(&is_nil(&1.executed_at)) |> Enum.sort_by(& &1.name) |> Repo.preload([...]) assign(socket, list_of_objects: updates) end If you want to also receive a list of operations, you can add the following callback: def sync(:list_of_objects, updated, operations, socket) do updates = updated |> Enum.filter(&is_nil(&1.executed_at)) |> Enum.sort_by(& &1.name) |> Repo.preload([...]) end > #### Note {: .neutral} > > For operations in a single record, operations will be an atom of :insert, :update or :delete. """ use Supervisor import Phoenix.LiveView require Logger def start_link(opts) do Supervisor.start_link(__MODULE__, opts, name: __MODULE__) end @impl true def init(opts) do repo = Keyword.fetch!(opts, :repo) otp_app = Keyword.fetch!(opts, :otp_app) children = case repo.query!("show wal_level;") do %Postgrex.Result{command: :show, columns: ["wal_level"], rows: [["logical"]]} -> [ {Registry, name: LiveSync.Registry, keys: :duplicate}, {LiveSync.Replication, [name: LiveSync.Replication, otp_app: otp_app] ++ repo.config()}, {Task, fn -> LiveSync.Replication.wait_for_connection!(LiveSync.Replication) end} ] _not_setup -> Logger.error(""" Postgres replication not enabled, not starting LiveSync. To enable replication, run the following commands and restart your database. ALTER SYSTEM SET wal_level='logical'; ALTER SYSTEM SET max_wal_senders='64'; ALTER SYSTEM SET max_replication_slots='64'; """) [] end Supervisor.init(children, strategy: :one_for_one) end defmacro __using__(opts) do quote do on_mount({LiveSync, unquote(opts)}) def sync(key, value, socket), do: assign(socket, key, value) def sync(key, value, _operations, socket), do: sync(key, value, socket) @before_compile {LiveSync, :add_sync_fallback} defoverridable sync: 3, sync: 4 end end @doc false defmacro add_sync_fallback(_env) do quote do def sync(key, value, socket), do: assign(socket, key, value) def sync(key, value, _operations, socket), do: sync(key, value, socket) end end @doc false def on_mount(opts, _params, _session, socket) do socket = socket |> attach_hook(:live_sync, :handle_params, fn _params, _uri, socket -> if connected?(socket) do subscription_key = socket.assigns[opts[:subscription_key]] LiveSync.Replication.subscribe("live_sync:#{subscription_key}") end {:cont, socket} end) |> attach_hook(:sync, :handle_info, fn msg, socket -> LiveSync.Socket.handle_info(msg, socket, opts) end) {:cont, socket} end def opts(module) do LiveSync.Watch.impl_for(struct(module)).opts() end @doc """ Returns the info of how to compare the given struct for updates. iex> LiveSync.lookup_info(%MyObject{id: 1, organization_id: 2}) {MyObject, 1} """ def lookup_info(struct) do case LiveSync.Watch.impl_for(struct) do nil -> nil impl -> impl.info(struct) end end @doc """ Returns the value for the subscription key for the given struct iex> LiveSync.subscription_key(%MyObject{id: 1, organization_id: 2}) 2 """ def subscription_key(struct) do impl = LiveSync.Watch.impl_for(struct) impl.subscription_key(struct) end end