11defmodule 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