Skip to content

Commit 70a5b18

Browse files
magnetisedthruflo
andauthored
Add support for optimistic writes (#14)
Specifically supporting https://github.com/TanStack/optimistic --------- Co-authored-by: James Arthur <[email protected]>
1 parent 8d33702 commit 70a5b18

File tree

19 files changed

+3513
-79
lines changed

19 files changed

+3513
-79
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
## 0.3.4
8+
## [0.3.4] - 2025-03-25
99

1010
### Changed
1111

README.md

Lines changed: 66 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
# Phoenix.Sync
22

3+
Real-time sync for Postgres-backed [Phoenix](https://www.phoenixframework.org/) applications.
4+
35
<p>
4-
<br />
56
<a href="https://hexdocs.pm/phoenix_sync" target="_blank">
67
<picture>
78
<img alt="Phoenix sync illustration"
89
src="https://github.com/electric-sql/phoenix_sync/raw/main/docs/phoenix-sync.png"
910
/>
1011
</picture>
1112
</a>
12-
<br />
1313
</p>
1414

1515
[![Hex.pm](https://img.shields.io/hexpm/v/phoenix_sync.svg)](https://hex.pm/packages/phoenix_sync)
@@ -18,29 +18,30 @@
1818
[![Status](https://img.shields.io/badge/status-beta-orange)](https://github.com/electric-sql/phoenix_sync)
1919
[![Discord](https://img.shields.io/discord/933657521581858818?color=5969EA&label=discord)](https://discord.electric-sql.com)
2020

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-
2321
Documentation is available at [hexdocs.pm/phoenix_sync](https://hexdocs.pm/phoenix_sync).
2422

25-
## Build real-time apps on locally synced data
23+
## Build real-time apps on sync
24+
25+
Phoenix.Sync is a library that adds real-time sync to Postgres-backed [Phoenix](https://www.phoenixframework.org/) applications. Use it to sync data into both LiveView and front-end web and mobile applications.
2626

27-
- sync data into Elixir, `LiveView` and frontend web and mobile applications
2827
- 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
28+
- uses [ElectricSQL](https://electric-sql.com) for core sync, fan-out and data delivery
29+
- maps `Ecto.Query`s to [Shapes](https://electric-sql.com/docs/guides/shapes) for partial replication
3130

32-
## Usage
31+
There are four key APIs for [read-path sync](#read-path-sync) out of Postgres:
3332

34-
There are four key APIs:
33+
- `Phoenix.Sync.Client.stream/2` for low level usage in Elixir
34+
- `Phoenix.Sync.LiveView.sync_stream/4` to sync into a LiveView
35+
- `Phoenix.Sync.Router.sync/2` macro to expose a shape in your Router
36+
- `Phoenix.Sync.Controller.sync_render/3` to return shapes from a Controller
3537

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
38+
And a `Phoenix.Sync.Writer` module for handling [write-path sync](#write-path-sync) back into Postgres.
39+
40+
## Read-path sync
4041

4142
### Low level usage in Elixir
4243

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+
Use `Phoenix.Sync.Client.stream/2` to convert an `Ecto.Query` into an Elixir `Stream`:
4445

4546
```elixir
4647
stream = Phoenix.Sync.Client.stream(Todos.Todo)
@@ -52,7 +53,7 @@ stream =
5253

5354
### Sync into a LiveView stream
5455

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+
Swap out `Phoenix.LiveView.stream/3` for `Phoenix.Sync.LiveView.sync_stream/4` to automatically keep a LiveView up-to-date with the state of your Postgres database:
5657

5758
```elixir
5859
defmodule MyWeb.MyLive do
@@ -75,7 +76,7 @@ This means you can build fully end-to-end real-time multi-user applications with
7576

7677
### Sync shapes through your Router
7778

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+
Use the `Phoenix.Sync.Router.sync/2` macro to expose statically (compile-time) defined shapes in your Router:
7980

8081
```elixir
8182
defmodule MyWeb.Router do
@@ -98,7 +99,7 @@ Because the shapes are exposed through your Router, the client connects through
9899

99100
### Sync dynamic shapes from a Controller
100101

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+
Sync shapes from any standard Controller using the `Phoenix.Sync.Controller.sync_render/3` view function:
102103

103104
```elixir
104105
defmodule Phoenix.Sync.LiveViewTest.TodoController do
@@ -122,7 +123,7 @@ This allows you to define and personalise the shape definition at runtime using
122123

123124
You can sync _into_ any client in any language that [speaks HTTP and JSON](https://electric-sql.com/docs/api/http).
124125

125-
For example, using the Electric [Typescript client](https://electric-sql.com/docs/api/clients/typescript):
126+
For example, using the Electric [TypeScript client](https://electric-sql.com/docs/api/clients/typescript):
126127

127128
```typescript
128129
import { Shape, ShapeStream } from "@electric-sql/client";
@@ -152,6 +153,51 @@ const MyComponent = () => {
152153

153154
See the Electric [demos](https://electric-sql.com/demos) and [documentation](https://electric-sql.com/docs) for more client-side usage examples.
154155

156+
## Write-path sync
157+
158+
The `Phoenix.Sync.Writer` module allows you to ingest batches of writes from the client.
159+
160+
The idea is that the front-end can batch up [local optimistic writes](https://electric-sql.com/docs/guides/writes). For example using a library like [@TanStack/optimistic](https://github.com/TanStack/optimistic) or by [monitoring changes to a local embedded database](https://electric-sql.com/docs/guides/writes#through-the-db).
161+
162+
These changes can be POSTed to a `Phoenix.Controller`, which then constructs a `Phoenix.Sync.Writer` instance. The writer instance authorizes and validates the writes before applying them to the database. Under the hood this uses `Ecto.Multi`, to ensure that transactions (batches of writes) are applied atomically.
163+
164+
For example, the controller below handles local writes made to a project management app. It constructs a writer instance and pipes it through a series of `Phoenix.Sync.Writer.allow/3` calls. These register functions against `Ecto.Schema`s (in this case `Projects.Project` and `Projects.Issue`):
165+
166+
```elixir
167+
defmodule MutationController do
168+
use Phoenix.Controller, formats: [:json]
169+
170+
alias Phoenix.Sync.Writer
171+
alias Phoenix.Sync.Writer.Format
172+
173+
def mutate(conn, %{"transaction" => transaction} = _params) do
174+
user_id = conn.assigns.user_id
175+
176+
{:ok, txid, _changes} =
177+
Phoenix.Sync.Writer.new()
178+
|> Phoenix.Sync.Writer.allow(
179+
Projects.Project,
180+
check: reject_invalid_params/2,
181+
load: &Projects.load_for_user(&1, user_id),
182+
validate: &Projects.Project.changeset/2
183+
)
184+
|> Phoenix.Sync.Writer.allow(
185+
Projects.Issue,
186+
# Use the sensible defaults:
187+
# validate: Projects.Issue.changeset/2
188+
# etc.
189+
)
190+
|> Phoenix.Sync.Writer.apply(transaction, Repo, format: Format.TanstackOptimistic)
191+
192+
render(conn, :mutations, txid: txid)
193+
end
194+
end
195+
```
196+
197+
This facilitates incrementally adding bi-directional sync support to a Phoenix application, re-using your existing auth and schema/validation logic.
198+
199+
See the `Phoenix.Sync.Writer` module docs for more information.
200+
155201
## Installation and configuration
156202

157203
`Phoenix.Sync` can be used in two modes:
@@ -356,7 +402,7 @@ You can also include `replica` (see below) in your static shape definitions:
356402
sync "/incomplete-todos", Todos.Todo, where: "completed = false", replica: :full
357403
```
358404

359-
For anything else more dyanamic, or to use Ecto queries, you should switch from using the `sync` macros in your router to using `sync_render/3` in a controller.
405+
For anything else more dynamic, or to use Ecto queries, you should switch from using the `sync` macros in your router to using `sync_render/3` in a controller.
360406

361407
### Using a keyword list
362408

@@ -372,4 +418,3 @@ The available options are:
372418
- `replica` (optional). By default Electric will only send primary keys + changed columns on updates. Set `replica: :full` to receive the full row, not just the changed columns.
373419

374420
See the [Electric Shapes guide](https://electric-sql.com/docs/guides/shapes) for more information.
375-

config/test.exs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ config :phoenix_sync, Support.Repo,
1717
port: 54321,
1818
stacktrace: true,
1919
show_sensitive_data_on_connection_error: true,
20-
pool_size: 10
20+
pool_size: 10,
21+
pool: Ecto.Adapters.SQL.Sandbox
2122

2223
config :phoenix_sync, Support.ConfigTestRepo,
2324
username: "postgres",

lib/phoenix/sync.ex

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
defmodule Phoenix.Sync do
2+
@moduledoc """
3+
Real-time sync for Postgres-backed Phoenix applications.
4+
5+
See the [docs](../../README.md) for more information.
6+
"""
7+
28
alias Electric.Client.ShapeDefinition
39

410
@shape_keys [:namespace, :where, :columns]
@@ -18,7 +24,54 @@ defmodule Phoenix.Sync do
1824
| {:columns, String.t()}
1925
@type param_overrides :: [param_override()]
2026

27+
@doc """
28+
Returns the required adapter configuration for your Phoenix Endpoint or
29+
`Plug.Router`.
30+
31+
## Phoenix
32+
33+
Configure your endpoint with the configuration at runtime by passing the
34+
`phoenix_sync` configuration to your endpoint in the `Application.start/2`
35+
callback:
36+
37+
def start(_type, _args) do
38+
children = [
39+
# ...
40+
{MyAppWeb.Endpoint, phoenix_sync: Phoenix.Sync.plug_opts()}
41+
]
42+
end
43+
44+
## Plug
45+
46+
Add the configuration to the Plug opts in your server configuration:
47+
48+
children = [
49+
{Bandit, plug: {MyApp.Router, phoenix_sync: Phoenix.Sync.plug_opts()}}
50+
]
51+
52+
Your `Plug.Router` must be configured with
53+
[`copy_opts_to_assign`](https://hexdocs.pm/plug/Plug.Builder.html#module-options) and you should `use` the rele
54+
55+
defmodule MyApp.Router do
56+
use Plug.Router, copy_opts_to_assign: :options
57+
58+
use Phoenix.Sync.Controller
59+
use Phoenix.Sync.Router
60+
61+
plug :match
62+
plug :dispatch
63+
64+
sync "/shapes/todos", Todos.Todo
65+
66+
get "/shapes/user-todos" do
67+
%{"user_id" => user_id} = conn.params
68+
sync_render(conn, from(t in Todos.Todo, where: t.owner_id == ^user_id)
69+
end
70+
end
71+
"""
2172
defdelegate plug_opts(), to: Phoenix.Sync.Application
73+
74+
@doc false
2275
defdelegate plug_opts(config), to: Phoenix.Sync.Application
2376

2477
defdelegate client!(), to: Phoenix.Sync.Client, as: :new!

lib/phoenix/sync/application.ex

Lines changed: 2 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
defmodule Phoenix.Sync.Application do
2+
@moduledoc false
3+
24
use Application
35

46
require Logger
@@ -52,51 +54,6 @@ defmodule Phoenix.Sync.Application do
5254
}
5355
end
5456

55-
@doc """
56-
Returns the required adapter configuration for your Phoenix Endpoint or
57-
`Plug.Router`.
58-
59-
## Phoenix
60-
61-
Configure your endpoint with the configuration at runtime by passing the
62-
`phoenix_sync` configuration to your endpoint in the `Application.start/2`
63-
callback:
64-
65-
def start(_type, _args) do
66-
children = [
67-
# ...
68-
{MyAppWeb.Endpoint, phoenix_sync: Phoenix.Sync.plug_opts()}
69-
]
70-
end
71-
72-
## Plug
73-
74-
Add the configuration to the Plug opts in your server configuration:
75-
76-
children = [
77-
{Bandit, plug: {MyApp.Router, phoenix_sync: Phoenix.Sync.plug_opts()}}
78-
]
79-
80-
Your `Plug.Router` must be configured with
81-
[`copy_opts_to_assign`](https://hexdocs.pm/plug/Plug.Builder.html#module-options) and you should `use` the rele
82-
83-
defmodule MyApp.Router do
84-
use Plug.Router, copy_opts_to_assign: :options
85-
86-
use Phoenix.Sync.Controller
87-
use Phoenix.Sync.Router
88-
89-
plug :match
90-
plug :dispatch
91-
92-
sync "/shapes/todos", Todos.Todo
93-
94-
get "/shapes/user-todos" do
95-
%{"user_id" => user_id} = conn.params
96-
sync_render(conn, from(t in Todos.Todo, where: t.owner_id == ^user_id)
97-
end
98-
end
99-
"""
10057
@spec plug_opts() :: keyword()
10158
def plug_opts do
10259
config() |> plug_opts()

lib/phoenix/sync/client.ex

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,16 @@
11
defmodule Phoenix.Sync.Client do
2+
@moduledoc """
3+
Low level Elixir client. Converts an `Ecto.Query` into an Elixir `Stream`:
4+
5+
```elixir
6+
stream = Phoenix.Sync.Client.stream(Todos.Todo)
7+
8+
stream =
9+
Ecto.Query.from(t in Todos.Todo, where: t.completed == false)
10+
|> Phoenix.Sync.Client.stream()
11+
```
12+
"""
13+
214
alias Phoenix.Sync.PredefinedShape
315

416
@doc """

lib/phoenix/sync/live_view.ex

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,24 @@
11
defmodule Phoenix.Sync.LiveView do
2+
@moduledoc """
3+
Swap out `Phoenix.LiveView.stream/3` for `Phoenix.Sync.LiveView.sync_stream/4` to
4+
automatically keep a LiveView up-to-date with the state of your Postgres database:
5+
6+
```elixir
7+
defmodule MyWeb.MyLive do
8+
use Phoenix.LiveView
9+
import Phoenix.Sync.LiveView
10+
11+
def mount(_params, _session, socket) do
12+
{:ok, sync_stream(socket, :todos, Todos.Todo)}
13+
end
14+
15+
def handle_info({:sync, event}, socket) do
16+
{:noreply, sync_stream_update(socket, event)}
17+
end
18+
end
19+
```
20+
"""
21+
222
use Phoenix.Component
323

424
alias Electric.Client.Message
@@ -63,9 +83,7 @@ defmodule Phoenix.Sync.LiveView do
6383
{:noreply, Phoenix.Sync.LiveView.sync_stream_update(socket, event)}
6484
end
6585
66-
See the docs for
67-
[`Phoenix.LiveView.stream/4`](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#stream/4)
68-
for details on using LiveView streams.
86+
See the docs for `Phoenix.LiveView.stream/4` for details on using LiveView streams.
6987
7088
## Lifecycle Events
7189

lib/phoenix/sync/plug.ex

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,8 @@ defmodule Phoenix.Sync.Plug do
4242
```
4343
4444
You can add additional authentication/authorization for shapes using
45-
[Phoenix's
46-
pipelines](https://hexdocs.pm/phoenix/Phoenix.Router.html#pipeline/2) or
47-
other [`plug`
48-
calls](https://hexdocs.pm/phoenix/Phoenix.Router.html#plug/2).
45+
[Phoenix's pipelines](https://hexdocs.pm/phoenix/Phoenix.Router.html#pipeline/2)
46+
or other [`plug` calls](https://hexdocs.pm/phoenix/Phoenix.Router.html#plug/2).
4947
5048
## Plug.Router
5149

0 commit comments

Comments
 (0)