|
1 | | -<p align="center"> |
2 | | - <a href="https://electric-sql.com" target="_blank"> |
| 1 | +<p> |
| 2 | + <br /> |
| 3 | + <a href="https://hexdocs.pm/phoenix_sync" target="_blank"> |
3 | 4 | <picture> |
4 | | - <source media="(prefers-color-scheme: dark)" |
5 | | - srcset="https://raw.githubusercontent.com/electric-sql/meta/main/identity/ElectricSQL-logo-next.svg" |
6 | | - /> |
7 | | - <source media="(prefers-color-scheme: light)" |
8 | | - srcset="https://raw.githubusercontent.com/electric-sql/meta/main/identity/ElectricSQL-logo-black.svg" |
9 | | - /> |
10 | | - <img alt="ElectricSQL logo" |
11 | | - src="https://raw.githubusercontent.com/electric-sql/meta/main/identity/ElectricSQL-logo-black.svg" |
| 5 | + <img alt="Phoenix sync illustration" |
| 6 | + src="./docs/phoenix-sync.png" |
12 | 7 | /> |
13 | 8 | </picture> |
14 | 9 | </a> |
| 10 | + <br /> |
15 | 11 | </p> |
16 | 12 |
|
17 | 13 | # Phoenix.Sync |
18 | 14 |
|
19 | | -An adapter to integrate [Electric SQL's sync engine](https://electric-sql.com) |
20 | | -into [Phoenix web applications](https://www.phoenixframework.org/). |
| 15 | +[](https://hex.pm/packages/phoenix_sync) |
| 16 | +[](https://hexdocs.pm/phoenix_sync) |
| 17 | +[](./LICENSE) |
| 18 | +[](https://github.com/electric-sql/phoenix_sync) |
| 19 | +[](https://discord.electric-sql.com) |
| 20 | + |
| 21 | +Sync is the best way of building modern apps. Phoenix.Sync enables real-time sync for Postgres-backed [Phoenix](https://www.phoenixframework.org/) applications. |
| 22 | + |
| 23 | +Documentation is available at [hexdocs.pm/phoenix_sync](https://hexdocs.pm/phoenix_sync). |
| 24 | + |
| 25 | +## Build real-time apps on locally synced data |
| 26 | + |
| 27 | +- sync data into Elixir, `LiveView` and frontend web and mobile applications |
| 28 | +- integrates with `Plug` and `Phoenix.{Controller, LiveView, Router, Stream}` |
| 29 | +- uses [ElectricSQL](https://electric-sql.com) for scalable data delivery and fan out |
| 30 | +- maps `Ecto` queries to [Shapes](https://electric-sql.com/docs/guides/shapes) for partial replication |
| 31 | + |
| 32 | +## Usage |
| 33 | + |
| 34 | +There are four key APIs: |
| 35 | + |
| 36 | +- [`Phoenix.Sync.Client.stream/2`](https://hexdocs.pm/phoenix_sync/Phoenix.Sync.Client.html#stream/2) for low level usage in Elixir |
| 37 | +- [`Phoenix.Sync.LiveView.sync_stream/4`](https://hexdocs.pm/phoenix_sync/Phoenix.Sync.LiveView.html#sync_stream/4) to sync into a LiveView stream |
| 38 | +- [`Phoenix.Sync.Router.sync/2`](https://hexdocs.pm/phoenix_sync/Phoenix.Sync.Router.html#sync/2) macro to expose a statically defined shape in your Router |
| 39 | +- [`Phoenix.Sync.Controller.sync_render/3`](https://hexdocs.pm/phoenix_sync/Phoenix.Sync.Controller.html#sync_render/3) to expose dynamically constructed shapes from a Controller |
| 40 | + |
| 41 | +### Low level usage in Elixir |
| 42 | + |
| 43 | +Use [`Phoenix.Sync.Client.stream/2`](https://hexdocs.pm/phoenix_sync/Phoenix.Sync.Client.html#stream/2) to convert an `Ecto.Query` into an Elixir `Stream`: |
| 44 | + |
| 45 | +```elixir |
| 46 | +stream = Phoenix.Sync.Client.stream(Todos.Todo) |
| 47 | + |
| 48 | +stream = |
| 49 | + Ecto.Query.from(t in Todos.Todo, where: t.completed == false) |
| 50 | + |> Phoenix.Sync.Client.stream() |
| 51 | +``` |
| 52 | + |
| 53 | +### Sync into a LiveView stream |
| 54 | + |
| 55 | +Swap out `Phoenix.LiveView.stream/3` for [`Phoenix.Sync.LiveView.sync_stream/4`](https://hexdocs.pm/phoenix_sync/Phoenix.Sync.LiveView.html#sync_stream/4) to automatically keep a LiveView up-to-date with the state of your Postgres database: |
| 56 | + |
| 57 | +```elixir |
| 58 | +defmodule MyWeb.MyLive do |
| 59 | + use Phoenix.LiveView |
| 60 | + import Phoenix.Sync.LiveView |
| 61 | + |
| 62 | + def mount(_params, _session, socket) do |
| 63 | + {:ok, sync_stream(socket, :todos, Todos.Todo)} |
| 64 | + end |
| 65 | + |
| 66 | + def handle_info({:sync, event}, socket) do |
| 67 | + {:noreply, sync_stream_update(socket, event)} |
| 68 | + end |
| 69 | +end |
| 70 | +``` |
| 71 | + |
| 72 | +LiveView takes care of automatically keeping the front-end up-to-date with the assigned stream. What Phoenix.Sync does is automatically keep the *stream* up-to-date with the state of the database. |
| 73 | + |
| 74 | +This means you can build fully end-to-end real-time multi-user applications without writing Javascript *and* without worrying about message delivery, reconnections, cache invalidation or polling the database for changes. |
| 75 | + |
| 76 | +### Sync shapes through your Router |
| 77 | + |
| 78 | +Use the [`Phoenix.Sync.Router.sync/2`](https://hexdocs.pm/phoenix_sync/Phoenix.Sync.Router.html#sync/2) macro to expose statically (compile-time) defined shapes in your Router: |
| 79 | + |
| 80 | +```elixir |
| 81 | +defmodule MyWeb.Router do |
| 82 | + use Phoenix.Router |
| 83 | + import Phoenix.Sync.Router |
| 84 | + |
| 85 | + pipeline :sync do |
| 86 | + plug :my_auth |
| 87 | + end |
| 88 | + |
| 89 | + scope "/shapes" do |
| 90 | + pipe_through :sync |
| 91 | + |
| 92 | + sync "/todos", Todos.Todo |
| 93 | + end |
| 94 | +end |
| 95 | +``` |
| 96 | + |
| 97 | +Because the shapes are exposed through your Router, the client connects through your existing Plug middleware. This allows you to do real-time sync straight out of Postgres *without* having to translate your auth logic into complex/fragile database rules. |
| 98 | + |
| 99 | +### Sync dynamic shapes from a Controller |
| 100 | + |
| 101 | +Sync shapes from any standard Controller using the [`Phoenix.Sync.Controller.sync_render/3`](https://hexdocs.pm/phoenix_sync/Phoenix.Sync.Controller.html#sync_render/3) view function: |
| 102 | + |
| 103 | +```elixir |
| 104 | +defmodule Phoenix.Sync.LiveViewTest.TodoController do |
| 105 | + use Phoenix.Controller |
| 106 | + import Phoenix.Sync.Controller |
| 107 | + import Ecto.Query, only: [from: 2] |
| 108 | + |
| 109 | + def show(conn, %{"done" => done} = params) do |
| 110 | + sync_render(conn, params, from(t in Todos.Todo, where: t.done == ^done)) |
| 111 | + end |
| 112 | + |
| 113 | + def show_mine(%{assigns: %{current_user: user_id}} = conn, params) do |
| 114 | + sync_render(conn, params, from(t in Todos.Todo, where: t.owner_id == ^user_id)) |
| 115 | + end |
| 116 | +end |
| 117 | +``` |
| 118 | + |
| 119 | +This allows you to define and personalise the shape definition at runtime using the session and request. |
| 120 | + |
| 121 | +### Consume shapes in the frontend |
| 122 | + |
| 123 | +You can sync *into* any client in any language that [speaks HTTP and JSON](https://electric-sql.com/docs/api/http). |
| 124 | + |
| 125 | +For example, using the Electric [Typescript client](https://electric-sql.com/docs/api/clients/typescript): |
| 126 | + |
| 127 | +```typescript |
| 128 | +import { Shape, ShapeStream } from '@electric-sql/client' |
| 129 | + |
| 130 | +const stream = new ShapeStream({ |
| 131 | + url: `/shapes/todos` |
| 132 | +}) |
| 133 | +const shape = new Shape(stream) |
| 134 | + |
| 135 | +// The callback runs every time the data changes. |
| 136 | +shape.subscribe(data => console.log(data)) |
| 137 | +``` |
| 138 | + |
| 139 | +Or binding a shape to a component using the [React bindings](https://electric-sql.com/docs/integrations/react): |
| 140 | + |
| 141 | +```tsx |
| 142 | +import { useShape } from '@electric-sql/react' |
21 | 143 |
|
22 | | -Documentation available at <https://hexdocs.pm/phoenix_sync/>. |
| 144 | +const MyComponent = () => { |
| 145 | + const { data } = useShape({ |
| 146 | + url: `shapes/todos` |
| 147 | + }) |
23 | 148 |
|
24 | | -## Installation |
| 149 | + return ( |
| 150 | + <List todos={data} /> |
| 151 | + ) |
| 152 | +} |
| 153 | +``` |
| 154 | + |
| 155 | +See the Electric [demos](https://electric-sql.com/demos) and [documentation](https://electric-sql.com/demos) for more client-side usage examples. |
| 156 | + |
| 157 | +## Installation and configuration |
| 158 | + |
| 159 | +`Phoenix.Sync` can be used in two modes: |
| 160 | + |
| 161 | +1. `:embedded` where Electric is included as an application dependency and Phoenix.Sync consumes data internally using Elixir APIs |
| 162 | +2. `:http` where Electric does *not* need to be included as an application dependency and Phoenix.Sync consumes data from an external Electric service using it's [HTTP API](https://electric-sql.com/docs/api/http) |
| 163 | + |
| 164 | +### Embedded mode |
| 165 | + |
| 166 | +In `:embedded` mode, Electric must be included an application dependency but does not expose an HTTP API (internally or externally). Messages are streamed internally between Electric and Phoenix.Sync using Elixir function APIs. The only HTTP API for sync is that exposed via your Phoenix Router using the `sync/2` macro and `sync_render/3` function. |
25 | 167 |
|
26 | | -Install by adding `phoenix_sync` to your list of dependencies in `mix.exs`: |
| 168 | +Example config: |
27 | 169 |
|
28 | 170 | ```elixir |
29 | | -def deps do |
| 171 | +# mix.exs |
| 172 | +defp deps do |
30 | 173 | [ |
31 | | - {:phoenix_sync, "~> 0.1.0"} |
| 174 | + {:electric, ">= 1.0.0-beta.20"}, |
| 175 | + {:phoenix_sync, "~> 0.3"} |
32 | 176 | ] |
33 | 177 | end |
| 178 | + |
| 179 | +# config/config.exs |
| 180 | +config :phoenix_sync, |
| 181 | + mode: :embedded, |
| 182 | + repo: MyApp.Repo |
| 183 | + |
| 184 | +# application.ex |
| 185 | +children = [ |
| 186 | + MyApp.Repo, |
| 187 | + # ... |
| 188 | + {MyApp.Endpoint, phoenix_sync: Phoenix.Sync.plug_opts()} |
| 189 | +] |
34 | 190 | ``` |
| 191 | + |
| 192 | +### HTTP |
| 193 | + |
| 194 | +In `:http` mode, Electric does not need to be included as an application dependency. Instead, Phoenix.Sync consumes data from an external Electric service over HTTP. |
| 195 | + |
| 196 | +```elixir |
| 197 | +# mix.exs |
| 198 | +defp deps do |
| 199 | + [ |
| 200 | + {:phoenix_sync, "~> 0.3"} |
| 201 | + ] |
| 202 | +end |
| 203 | + |
| 204 | +# config/config.exs |
| 205 | +config :phoenix_sync, |
| 206 | + mode: :http, |
| 207 | + url: "https://api.electric-sql.cloud", |
| 208 | + credentials: [ |
| 209 | + secret: "...", # required |
| 210 | + source_id: "..." # optional, required for Electric Cloud |
| 211 | + ] |
| 212 | + |
| 213 | +# application.ex |
| 214 | +children = [ |
| 215 | + MyApp.Repo, |
| 216 | + # ... |
| 217 | + {MyApp.Endpoint, phoenix_sync: Phoenix.Sync.plug_opts()} |
| 218 | +] |
| 219 | +``` |
| 220 | + |
| 221 | +### Local HTTP services |
| 222 | + |
| 223 | +It is also possible to include Electric as an application dependency and configure it to expose a local HTTP API that's consumed by Phoenix.Sync running in `:http` mode: |
| 224 | + |
| 225 | +```elixir |
| 226 | +# mix.exs |
| 227 | +defp deps do |
| 228 | + [ |
| 229 | + {:electric, ">= 1.0.0-beta.20"}, |
| 230 | + {:phoenix_sync, "~> 0.3"} |
| 231 | + ] |
| 232 | +end |
| 233 | + |
| 234 | +# config/config.exs |
| 235 | +config :phoenix_sync, |
| 236 | + mode: :http, |
| 237 | + http: [ |
| 238 | + # https://hexdocs.pm/bandit/Bandit.html#t:options/0 |
| 239 | + ip: :loopback, |
| 240 | + port: 3000, |
| 241 | + ], |
| 242 | + repo: MyApp.Repo, |
| 243 | + url: "http://localhost:3000" |
| 244 | + |
| 245 | +# application.ex |
| 246 | +children = [ |
| 247 | + MyApp.Repo, |
| 248 | + # ... |
| 249 | + {MyApp.Endpoint, phoenix_sync: Phoenix.Sync.plug_opts()} |
| 250 | +] |
| 251 | +``` |
| 252 | + |
| 253 | +This is less efficient than running in `:embedded` mode but may be useful for testing or when needing to run an HTTP proxy in front of Electric as part of your development stack. |
| 254 | + |
| 255 | +### Different modes for different envs |
| 256 | + |
| 257 | +Apps using `:http` mode in certain environments can exclude `:electric` as a dependency for that environment. The following example shows how to configure: |
| 258 | + |
| 259 | +- `:embedded` mode in `:dev` |
| 260 | +- `:http` mode with a local Electric service in `:test` |
| 261 | +- `:http` mode with an external Electric service in `:prod` |
| 262 | + |
| 263 | +With Electric only included and compiled as a dependency in `:dev` and `:test`. |
| 264 | + |
| 265 | +```elixir |
| 266 | +# mix.exs |
| 267 | +defp deps do |
| 268 | + [ |
| 269 | + {:electric, "~> 1.0.0-beta.20", only: [:dev, :test]}, |
| 270 | + {:phoenix_sync, "~> 0.3"} |
| 271 | + ] |
| 272 | +end |
| 273 | + |
| 274 | +# config/dev.exs |
| 275 | +config :phoenix_sync, |
| 276 | + mode: :embedded, |
| 277 | + repo: MyApp.Repo |
| 278 | + |
| 279 | +# config/test.esx |
| 280 | +config :phoenix_sync, |
| 281 | + mode: :http, |
| 282 | + http: [ |
| 283 | + ip: :loopback, |
| 284 | + port: 3000, |
| 285 | + ], |
| 286 | + repo: MyApp.Repo, |
| 287 | + url: "http://localhost:3000" |
| 288 | + |
| 289 | +# config/prod.exs |
| 290 | +config :phoenix_sync, |
| 291 | + mode: :http, |
| 292 | + url: "https://api.electric-sql.cloud", |
| 293 | + credentials: [ |
| 294 | + secret: "...", # required |
| 295 | + source_id: "..." # optional, required for Electric Cloud |
| 296 | + ] |
| 297 | + |
| 298 | +# application.ex |
| 299 | +children = [ |
| 300 | + MyApp.Repo, |
| 301 | + # ... |
| 302 | + {MyApp.Endpoint, phoenix_sync: Phoenix.Sync.plug_opts()} |
| 303 | +] |
| 304 | +``` |
| 305 | + |
| 306 | +## Notes |
| 307 | + |
| 308 | +### ElectricSQL |
| 309 | + |
| 310 | +ElectricSQL is a [real-time sync engine for Postgres](https://electric-sql.com). |
| 311 | + |
| 312 | +Phoenix.Sync uses Electric to handle the core concerns of partial replication, fan out and data delivery. |
| 313 | + |
| 314 | +### Partial replication |
| 315 | + |
| 316 | +Electric defines partial replication using [Shapes](https://electric-sql.com/docs/guides/shapes). |
| 317 | + |
| 318 | +Phoenix.Sync maps Ecto queries to shape definitions. This allows you to control what data syncs where using Ecto.Schema and Ecto.Query. |
0 commit comments