Skip to content

Commit 8f4dd15

Browse files
committed
improve docs, rename & refactor
move adapter to a behaviour and rename the plug api protocol
1 parent a5f0eb6 commit 8f4dd15

File tree

20 files changed

+325
-289
lines changed

20 files changed

+325
-289
lines changed

README.md

Lines changed: 67 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,9 @@ defmodule MyWeb.MyLive do
6969
end
7070
```
7171

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

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

7676
### Sync shapes through your Router
7777

@@ -94,7 +94,7 @@ defmodule MyWeb.Router do
9494
end
9595
```
9696

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

9999
### Sync dynamic shapes from a Controller
100100

@@ -120,46 +120,92 @@ This allows you to define and personalise the shape definition at runtime using
120120

121121
### Consume shapes in the frontend
122122

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

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

127-
```typescript
128-
import { Shape, ShapeStream } from '@electric-sql/client'
127+
```sync_stream_updatescript
128+
import { Shape, ShapeStream } from "@electric-sql/client";
129129
130130
const stream = new ShapeStream({
131-
url: `/shapes/todos`
132-
})
133-
const shape = new Shape(stream)
131+
url: `/shapes/todos`,
132+
});
133+
const shape = new Shape(stream);
134134
135135
// The callback runs every time the data changes.
136-
shape.subscribe(data => console.log(data))
136+
shape.subscribe((data) => console.log(data));
137137
```
138138

139139
Or binding a shape to a component using the [React bindings](https://electric-sql.com/docs/integrations/react):
140140

141141
```tsx
142-
import { useShape } from '@electric-sql/react'
142+
import { useShape } from "@electric-sql/react";
143143

144144
const MyComponent = () => {
145145
const { data } = useShape({
146-
url: `shapes/todos`
147-
})
146+
url: `shapes/todos`,
147+
});
148148

149-
return (
150-
<List todos={data} />
151-
)
152-
}
149+
return <List todos={data} />;
150+
};
153151
```
154152

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

155+
### Shape Definitions
156+
157+
Phoenix.Sync allows shapes to be defined in two ways:
158+
159+
#### As an `Ecto` schema module or `Ecto.Query`
160+
161+
This is the simplest way to
162+
integrate with your existing data model and means you don't have to worry too
163+
much about the lower-level details of the integration.
164+
165+
Examples:
166+
167+
```elixir
168+
169+
sync_render(conn, params, from(t in Todos.Todo, where: t.completed == false))
170+
```
171+
172+
Query support is currently limited to only `where` conditions. Support for more complex queries, including `JOIN`s is planned.
173+
174+
**Note:** The static shapes defined using the `sync/2` or `sync/3` router macros do not accept `Ecto.Query` structs as a shape definition. This is to avoid excessive recompilation caused by having your router having a compile-time dependency on your `Ecto` schemas.
175+
176+
If you want to add a where-clause filter to a static shape in your router, you must add an explicit [`where` clause](https://electric-sql.com/docs/guides/shapes#where-clause) alongside your `Ecto.Schema` module:
177+
178+
```elixir
179+
180+
sync "/incomplete-todos", Todos.Todo, where: "completed = false"
181+
```
182+
183+
You can also include `replica` (see below) in your static shape definitions:
184+
185+
```elixir
186+
187+
sync "/incomplete-todos", Todos.Todo, where: "completed = false", replica: :full
188+
```
189+
190+
#### As a keyword list
191+
192+
At minimum a shape requires a `table`. You can think of shapes defined with
193+
just a table name as the sync-equivalent of `SELECT * FROM table`.
194+
195+
The available options are:
196+
197+
- `table` (required). The Postgres table name. Be aware of casing and [Postgres's handling of unquoted upper-case names](https://wiki.postgresql.org/wiki/Don%27t_Do_This#Don.27t_use_upper_case_table_or_column_names).
198+
- `namespace` (optional). The Postgres namespace that the table belongs to. Defaults to `public`.
199+
- `where` (optional). Filter to apply to the synced data in SQL format, e.g. `where: "amount < 1.23 AND colour in ('red', 'green')`.
200+
- `columns` (optional). The columns to include in the synced data. By default Electric will include all columns in the table. The column list **must** include all primary keys. E.g. `columns: ["id", "title", "amount"]`.
201+
- `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.
202+
157203
## Installation and configuration
158204

159205
`Phoenix.Sync` can be used in two modes:
160206

161207
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)
208+
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)
163209

164210
### Embedded mode
165211

@@ -235,8 +281,6 @@ end
235281
config :phoenix_sync,
236282
mode: :http,
237283
http: [
238-
# https://hexdocs.pm/bandit/Bandit.html#t:options/0
239-
ip: :loopback,
240284
port: 3000,
241285
],
242286
repo: MyApp.Repo,
@@ -280,7 +324,6 @@ config :phoenix_sync,
280324
config :phoenix_sync,
281325
mode: :http,
282326
http: [
283-
ip: :loopback,
284327
port: 3000,
285328
],
286329
repo: MyApp.Repo,
@@ -315,4 +358,5 @@ Phoenix.Sync uses Electric to handle the core concerns of partial replication, f
315358

316359
Electric defines partial replication using [Shapes](https://electric-sql.com/docs/guides/shapes).
317360

318-
Phoenix.Sync maps Ecto queries to shape definitions. This allows you to control what data syncs where using Ecto.Schema and Ecto.Query.
361+
Phoenix.Sync maps Ecto queries to shape definitions. This allows you to control what data syncs where using Ecto.Schema and Ecto.Query.
362+

lib/phoenix/sync.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ defmodule Phoenix.Sync do
7373
@type param_overrides :: [param_override()]
7474

7575
defdelegate plug_opts(), to: Phoenix.Sync.Application
76+
@doc false
7677
defdelegate plug_opts(opts), to: Phoenix.Sync.Application
7778

7879
@doc """

lib/phoenix/sync/adapter.ex

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
defprotocol Phoenix.Sync.Adapter do
1+
defmodule Phoenix.Sync.Adapter do
22
@moduledoc false
33

4-
@spec predefined_shape(t(), Phoenix.Sync.PredefinedShape.t()) :: {:ok, t()} | {:error, term()}
5-
def predefined_shape(api, shape)
6-
7-
@spec call(t(), Plug.Conn.t(), Plug.Conn.params()) :: Plug.Conn.t()
8-
def call(api, conn, params)
4+
@callback children(atom(), keyword()) :: {:ok, [Supervisor.child_spec()]} | {:error, String.t()}
5+
@callback plug_opts(atom(), keyword()) :: keyword() | no_return()
6+
@callback client(keyword()) :: {:ok, struct()} | {:error, String.t()}
97
end
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
defprotocol Phoenix.Sync.Adapter.PlugApi do
2+
@moduledoc false
3+
4+
@spec predefined_shape(t(), Phoenix.Sync.PredefinedShape.t()) :: {:ok, t()} | {:error, term()}
5+
def predefined_shape(api, shape)
6+
7+
@spec call(t(), Plug.Conn.t(), Plug.Conn.params()) :: Plug.Conn.t()
8+
def call(api, conn, params)
9+
end

lib/phoenix/sync/application.ex

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ defmodule Phoenix.Sync.Application do
44
require Logger
55

66
@env Mix.env()
7+
@default_adapter Phoenix.Sync.Electric
78

89
@impl true
910
def start(_type, _args) do
@@ -17,38 +18,101 @@ defmodule Phoenix.Sync.Application do
1718
end
1819
end
1920

21+
@doc false
2022
def config do
2123
Application.get_all_env(:phoenix_sync)
2224
end
2325

26+
@doc false
27+
def adapter do
28+
config() |> adapter()
29+
end
30+
31+
@doc false
32+
def adapter(opts) do
33+
Keyword.get(opts, :adapter, @default_adapter)
34+
end
35+
36+
@doc false
2437
def children do
2538
config() |> children()
2639
end
2740

41+
@doc false
2842
def children(opts) when is_list(opts) do
2943
children(@env, opts)
3044
end
3145

46+
@doc false
3247
def children(env, opts) do
33-
adapter = Keyword.get(opts, :adapter, Phoenix.Sync.Electric)
48+
adapter = adapter(opts)
3449

3550
apply(adapter, :children, [env, opts])
3651
end
3752

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

103+
@doc false
42104
def plug_opts(opts) when is_list(opts) do
43105
plug_opts(@env, opts)
44106
end
45107

108+
@doc false
46109
def plug_opts(env, opts) do
47-
adapter = Keyword.get(opts, :adapter, Phoenix.Sync.Electric)
110+
adapter = adapter(opts)
48111

49112
apply(adapter, :plug_opts, [env, opts])
50113
end
51114

115+
@doc false
52116
def fetch_with_error(opts, key) do
53117
case Keyword.fetch(opts, key) do
54118
{:ok, url} -> {:ok, url}

lib/phoenix/sync/client.ex

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@ defmodule Phoenix.Sync.Client do
55
Create a new sync client based on the `:phoenix_sync` configuration.
66
"""
77
def new do
8-
Phoenix.Sync.Application.config()
9-
|> new()
8+
Phoenix.Sync.Application.config() |> new()
109
end
1110

1211
def new(nil) do
@@ -20,17 +19,23 @@ defmodule Phoenix.Sync.Client do
2019
then this will configure the client to retrieve data using the internal
2120
Elixir APIs.
2221
23-
For the `:http` mode, then you must also configure a URL specifying
24-
an Electric API server:
22+
For the `:http` mode, then you must also configure a URL specifying an
23+
Electric API server:
2524
2625
config :phoenix_sync,
27-
electric: [
28-
mode: :http,
29-
url: "https://api.electric-sql.cloud"
30-
]
26+
mode: :http,
27+
url: "https://api.electric-sql.cloud"
28+
29+
This client can then generate streams for use in your Elixir applications:
30+
31+
client = Phoenix.Sync.Client.new()
32+
stream = Electric.Client.stream(client, Todos.Todo)
33+
for msg <- stream, do: IO.inspect(msg)
34+
35+
Alternatively use `stream/1` which wraps this functionality.
3136
"""
3237
def new(opts) do
33-
adapter = Keyword.get(opts, :adapter, Phoenix.Sync.Electric)
38+
adapter = Phoenix.Sync.Application.adapter(opts)
3439

3540
apply(adapter, :client, [opts])
3641
end
@@ -61,17 +66,27 @@ defmodule Phoenix.Sync.Client do
6166
## Examples
6267
6368
# stream updates for the Todo schema
64-
Phoenix.Sync.Client.stream(MyApp.Todos.Todo)
69+
stream = Phoenix.Sync.Client.stream(MyApp.Todos.Todo)
6570
6671
# stream the results of an ecto query
67-
Phoenix.Sync.Client.stream(from(t in MyApp.Todos.Todo, where: t.completed == true))
72+
stream = Phoenix.Sync.Client.stream(from(t in MyApp.Todos.Todo, where: t.completed == true))
6873
6974
# create a stream based on a shape definition
70-
Phoenix.Sync.Client.stream(
75+
stream = Phoenix.Sync.Client.stream(
7176
table: "todos",
7277
where: "completed = false",
7378
columns: ["id", "title"]
7479
)
80+
81+
# once you have a stream, consume it as usual
82+
Enum.each(stream, &IO.inspect/1)
83+
84+
## Ecto vs keyword shapes
85+
86+
Streams defined using an Ecto query or schema will return data wrapped in
87+
the appropriate schema struct, with values cast to the appropriate
88+
Elixir/Ecto types, rather than raw column data in the form `%{"column_name"
89+
=> "column_value"}`.
7590
"""
7691
@spec stream(Phoenix.Sync.shape_definition(), Electric.Client.stream_options()) :: Enum.t()
7792
def stream(shape, stream_opts \\ [])

0 commit comments

Comments
 (0)