Skip to content

Commit 255e31f

Browse files
committed
docs: update the Writer moduledoc.
1 parent 546949f commit 255e31f

File tree

1 file changed

+102
-64
lines changed

1 file changed

+102
-64
lines changed

lib/phoenix/sync/writer.ex

Lines changed: 102 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,107 @@
11
defmodule Phoenix.Sync.Writer do
22
@moduledoc """
3-
Provides [optimistic write](https://electric-sql.com/docs/guides/writes)
4-
support for Phoenix- or Plug-based apps.
3+
Provides [write-path sync](https://electric-sql.com/docs/guides/writes) support for
4+
Phoenix- or Plug-based apps.
55
6-
Clients collect local transactional updates and then
7-
submit these transactions as a list of database operations to the Phoenix/Plug
8-
application.
6+
Imagine you're building an application on sync. You've used the
7+
[read-path sync utilities](https://hexdocs.pm/phoenix_sync/readme.md#read-path-sync)
8+
to sync data into the front-end. If the client then changes the data locally, these
9+
writes can be batched up and sent back to the server.
910
11+
`#{inspect(__MODULE__)}` provides a principled way of ingesting these local writes
12+
and applying them to Postgres. In a way that works-with and re-uses your existing
13+
authorization logic and your existing `Ecto.Schema`s and `Ecto.Changeset` validation
14+
functions.
15+
16+
This allows you to build instant, offline-capable applications that work with
17+
[local optimistic state](https://electric-sql.com/docs/guides/writes).
18+
19+
For example, take a project management app that's using
20+
[@TanStack/optimistic](https://github.com/TanStack/optimistic) to batch up local
21+
optimistic writes and POST them to the `Phoenix.Controller` below:
1022
1123
defmodule MutationController do
1224
use Phoenix.Controller, formats: [:json]
1325
14-
alias #{inspect(__MODULE__)}
26+
alias Phoenix.Sync.Writer
27+
alias Phoenix.Sync.Writer.Format
1528
1629
def mutate(conn, %{"transaction" => transaction} = _params) do
1730
user_id = conn.assigns.user_id
1831
1932
{:ok, txid, _changes} =
20-
Phoenix.Sync.Writer.new(format: Phoenix.Sync.Writer.Format.TanstackOptimistic)
33+
Phoenix.Sync.Writer.new(format: Format.TanstackOptimistic)
2134
|> Phoenix.Sync.Writer.allow(
2235
Projects.Project,
23-
# check writes against a Project just as your controller code would
24-
check: &Projects.check_project(&1, conn.assigns),
25-
# you can re-use your existing changeset/2 functions
36+
check: reject_invalid_params/2,
37+
load: &Projects.load_for_user(&1, user_id),
2638
validate: &Projects.Project.changeset/2
2739
)
2840
|> Phoenix.Sync.Writer.allow(
2941
Projects.Issue,
30-
check: &Projects.check_issue(&1, conn.assigns),
31-
# validate: defaults to Projects.Issue.changeset/2
42+
# Use the sensible defaults:
43+
# load: Ecto.Repo.get_by(Projects.Issue, id: ^issue_id)
44+
# validate: Projects.Issue.changeset/2
45+
# etc.
3246
)
3347
|> Phoenix.Sync.Writer.apply(transaction, Repo)
3448
3549
render(conn, :mutations, txid: txid)
3650
end
3751
end
3852
53+
The controller constructs a `#{inspect(__MODULE__)}` instance and pipes it
54+
through a series of [`Writer.allow/3`](https://hexdocs.pm/phoenix_sync/Phoenix.Sync.Writer.html#allow/3)
55+
calls, registering functions against `Ecto.Schema`s (in this case `Projects.Project`
56+
and `Projects.Issue`) to validate and authorize each of these mutation operations
57+
before applying them as a single transaction.
58+
3959
Local client writes are sent to the server as a list of mutations — a series
4060
of `INSERT`, `UPDATE` and `DELETE` operations and their associated data —
4161
with a single transaction represented by a single list of mutations.
4262
43-
Using `#{inspect(__MODULE__)}` your application can then validate each of
44-
these mutation operations against its authentication and
45-
authorization logic and then apply them as a single transaction.
46-
47-
The Postgres transaction id is returned to the client so that its optimistic
48-
update system can watch the Electric stream and match on the arrival of this
49-
transaction id.
50-
51-
When the client receives this transaction id back through it's Electric sync
52-
stream then the client knows that it's up-to-date with the server.
53-
54-
`#{inspect(__MODULE__)}` uses `Ecto.Multi`'s transaction update mechanism
55-
under the hood, which means that either all the operations in a client
56-
transaction are accepted or none are. See `apply/2` for how you can hook
57-
into the `Ecto.Multi` after applying your change data.
58-
5963
> #### Warning {: .warning}
6064
>
61-
> The mutation operations received from clients should be considered as **untrusted**.
65+
> The mutation operations received from clients MUST be considered as **untrusted**.
6266
>
6367
> Though the HTTP operation that uploaded them will have been authenticated and
6468
> authorized by your existing Plug middleware as usual, the actual content of the
6569
> request that is turned into writes against your database needs to be validated
6670
> very carefully against the privileges of the current user.
71+
>
72+
> That's what `#{inspect(__MODULE__)}` is for: specifying which resources can be
73+
> updated and registering functions to authorize and validate the mutation payload.
74+
75+
## Transactions
76+
77+
The `{:ok, txid, changes}` return value from `Phoenix.Sync.Writer.apply/3`
78+
allows the Postgres transaction ID to be returned to the client in the response data.
79+
80+
This allows clients to monitor the read-path sync stream and match on the
81+
arrival of the same transaction id. When the client receives this transaction id
82+
back through it's sync, it knows that it can discard the optimistic state for that
83+
transaction.
84+
85+
This is a more robust way of managing optimistic state that just matching on
86+
instant IDs, as it allows for local changes to be rebased on concurrent changes
87+
to the same date from other users that stream through before the local transaction.
88+
89+
`#{inspect(__MODULE__)}` uses `Ecto.Multi`'s transaction update mechanism
90+
under the hood, which means that either all the operations in a client
91+
transaction are accepted or none are. See `apply/2` for how you can hook
92+
into the `Ecto.Multi` after applying your change data.
6793
6894
## Client Libraries
6995
70-
`#{inspect(__MODULE__)}` does not require any particular client-side
71-
implementation, see Electric's [write pattern guides and example
72-
code](https://electric-sql.com/docs/guides/writes) for implementation
73-
strategies and examples.
96+
`#{inspect(__MODULE__)}` is not coupled to any particular client-side implementation.
97+
See Electric's [write pattern guides and example code](https://electric-sql.com/docs/guides/writes)
98+
for implementation strategies and examples.
99+
100+
Instead, `#{inspect(__MODULE__)}` provides an adapter pattern where you can register
101+
a `format` adapter to parse the expected payload format from a client side library
102+
into the struct that `#{inspect(__MODULE__)}` expects.
74103
75-
### Existing libraries
104+
The currently supported format adapters are:
76105
77106
- [TanStack/optimistic](https://github.com/TanStack/optimistic) "A library
78107
for creating fast optimistic updates with flexible backend support that pairs
@@ -84,32 +113,38 @@ defmodule Phoenix.Sync.Writer do
84113
85114
## Usage
86115
87-
Much as every controller action must be both authenticated and authorized to
88-
prevent users affecting data that they do not have permission to modify,
89-
mutations **MUST** be validated for both correctness — are the given values
90-
valid? — and permissions: is the current user allowed to perform the given
91-
mutation?
92-
93-
This dual verification, data and permissions, is performed by the combination
94-
of 5 application-defined callbacks for every model that you allow writes to:
95-
96-
- [`check`](#module-check-function) - a function that tests operations against the
97-
application's authentication/authorization logic.
98-
- [`load`](#module-load-function) - a function that takes the original data and returns the
99-
existing model from the database.
100-
- [`validate`](#module-validate-function) - create and validate an `Ecto.Changeset` from the
101-
source data and mutation changes.
102-
- [`pre_apply` and `post_apply`](#module-pre_apply-and-post_apply-callbacks) - add arbitrary
103-
`Ecto.Multi` operations to the transaction based on the current operation.
104-
105-
See `apply/2` for how the transaction is processed internally and so how best
106-
to use these callback functions to express your apps authoriziation
116+
Much as every controller action must be authenticated, authorized and validated
117+
to prevent users writing invalid data or data that they do not have permission
118+
to modify, mutations **MUST** be validated for both correctness (are the given
119+
values valid?) and permissions (is the current user allowed to apply the given
120+
mutation?).
121+
122+
This dual verification -- of data and permissions -- is performed by a pipeline
123+
of application-defined callbacks for every model that you allow writes to:
124+
125+
- [`check`](#module-check-function) - a function that performs a "pre-flight"
126+
sanity check of the user-provided data in the mutation; this should just
127+
validate the data and not usually hit the database; checks are performed on
128+
all operations in a transaction before proceeding to the next steps in the
129+
pipeline; this allows for fast rejection of invalid data before performing
130+
more expensive operations
131+
- [`load`](#module-load-function) - a function that takes the original data
132+
and returns the existing model from the database, if it exists, for an update
133+
or delete operation
134+
- [`validate`](#module-validate-function) - create and validate an `Ecto.Changeset`
135+
from the source data and mutation changes; this is intended to be compatible with
136+
using existing schema changeset functions; note that, as per any changeset function,
137+
the validate function can perform both authorization and validation
138+
- [`pre_apply` and `post_apply`](#module-pre_apply-and-post_apply-callbacks) - add
139+
arbitrary `Ecto.Multi` operations to the transaction based on the current operation
140+
141+
See `apply/2` for how the transaction is processed internally and how best to
142+
use these callback functions to express your app's authorization and validation
107143
requirements.
108144
109145
Calling `new/1` creates an empty writer configuration with the given mutation
110-
parser. But this alone does not permit any mutations. In order to allow
111-
writes from clients you must call `allow/3` with a schema module and some
112-
callback functions.
146+
parser. But this alone does not permit any mutations. In order to allow writes
147+
from clients you must call `allow/3` with a schema module and some callback functions.
113148
114149
# create an empty writer configuration which accepts writes in the given format
115150
writer = #{inspect(__MODULE__)}.new(format: #{inspect(__MODULE__.Format.TanstackOptimistic)})
@@ -119,8 +154,8 @@ defmodule Phoenix.Sync.Writer do
119154
# touching the database
120155
writer = #{inspect(__MODULE__)}.allow(writer, Todos.Todo, check: &Todos.check_mutation/1)
121156
122-
If the table name on the client differs from the Postgres table, then you add
123-
a `table` option that specifies the client table name that this `allow/3`
157+
If the table name on the client differs from the Postgres table, then you can
158+
add a `table` option that specifies the client table name that this `allow/3`
124159
call applies to:
125160
126161
# `client_todos` is the name of the `todos` table on the clients
@@ -150,15 +185,18 @@ defmodule Phoenix.Sync.Writer do
150185
quick check of the data from the clients before any reads or writes to the
151186
database.
152187
188+
Note that the writer pipeline checks all the operations before proceeding to
189+
load, validate and apply each operation in turn.
190+
153191
def check(%#{inspect(__MODULE__.Operation)}{} = operation) do
154192
# :ok or {:error, "..."}
155193
end
156194
157195
### Load
158196
159-
The `load` callback takes the `data` in `update` or `delete` mutations, that is the original
160-
data before changes, and uses it to retreive the original `Ecto.Struct` model
161-
from the database.
197+
The `load` callback takes the `data` in `update` or `delete` mutations (i.e.:
198+
the original data before changes), and uses it to retrieve the original
199+
`Ecto.Struct` model from the database.
162200
163201
It can be a 1- or 2-arity function. The 1-arity version receives just the
164202
`data` parameters. The 2-arity version receives the `Ecto.Repo` that the
@@ -177,8 +215,8 @@ defmodule Phoenix.Sync.Writer do
177215
If not provided defaults to using `c:Ecto.Repo.get_by/3` using the primary
178216
key(s) defined on the model.
179217
180-
For `insert` operations this load function is not used, the original struct
181-
is created by calling the `__struct__/0` function on the `Ecto.Schema`
218+
For `insert` operations this load function is not used. Instead, the original
219+
struct is created by calling the `__struct__/0` function on the `Ecto.Schema`
182220
module.
183221
184222
### Validate

0 commit comments

Comments
 (0)