zero-rails-adapter 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +150 -0
- data/README.md +178 -22
- data/app/controllers/zero_rails_adapter/mutations_controller.rb +5 -1
- data/examples/nextjs/README.md +4 -2
- data/lib/generators/zero_rails_adapter/install/templates/initializer.rb +28 -9
- data/lib/generators/zero_rails_adapter/mutator/templates/mutator.rb +4 -1
- data/lib/generators/zero_rails_adapter/publication/publication_generator.rb +21 -0
- data/lib/zero_rails_adapter/configuration.rb +13 -17
- data/lib/zero_rails_adapter/crud/dispatcher.rb +12 -6
- data/lib/zero_rails_adapter/errors.rb +2 -0
- data/lib/zero_rails_adapter/mutator.rb +3 -1
- data/lib/zero_rails_adapter/postgresql/publication_generator.rb +36 -0
- data/lib/zero_rails_adapter/processor.rb +35 -7
- data/lib/zero_rails_adapter/published_schema.rb +143 -0
- data/lib/zero_rails_adapter/relationship.rb +129 -0
- data/lib/zero_rails_adapter/type_script/generator.rb +110 -52
- data/lib/zero_rails_adapter/version.rb +1 -1
- data/lib/zero_rails_adapter.rb +3 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ab52c7684cc6471e482808ce5bcdb64b7aa63ccbf8c9ae736feb535683198204
|
|
4
|
+
data.tar.gz: f69fa8c3101e9f8fff279af69103f5980a323dc856a70dd6b436f8bb92a9b37c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a8a68b2c6bc50703669edd95cf46e9b74c0a01c6b4414723a78b07c4158d8b37ef884ae4972e0c0bf5ae684f897c2416f4d2e1b1b8beb1a2d1fd967faa8a36f0
|
|
7
|
+
data.tar.gz: 8690225768276ff9ca61478486674f90b91f3b873ed190d95dc0bb7da5aea2f3a96d32f3c74ef24cc2d21fe54e731f03b136549638d3491dff3a354d1b7fc1d6
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `zero-rails-adapter` are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
|
+
Because the project is still below 1.0, minor releases may contain breaking
|
|
7
|
+
changes; those changes are called out explicitly below.
|
|
8
|
+
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
## [0.3.1] - 2026-07-29
|
|
12
|
+
|
|
13
|
+
### Security
|
|
14
|
+
|
|
15
|
+
- Changed request verification, authentication, and global mutation
|
|
16
|
+
authorization defaults to fail closed. Applications must now configure every
|
|
17
|
+
gate explicitly.
|
|
18
|
+
- Custom mutators without an `authorize_with` callback are rejected instead of
|
|
19
|
+
executing implicitly. The mutator generator scaffolds an explicit
|
|
20
|
+
deny-by-default callback.
|
|
21
|
+
- Generated initializers now require `ZERO_MUTATE_API_KEY` with `ENV.fetch`
|
|
22
|
+
instead of silently disabling request verification when the variable is
|
|
23
|
+
absent.
|
|
24
|
+
- Internal and database `PushFailed` responses no longer expose raw exception
|
|
25
|
+
messages. Full errors remain available to the configured server logger.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- Persist only explicit application and validation failures before advancing
|
|
30
|
+
LMID. Database failures and unexpected Ruby exceptions now roll back the
|
|
31
|
+
mutation completely and return a retry-safe `PushFailed`, allowing the same
|
|
32
|
+
mutation ID to succeed after the underlying problem is fixed.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- Expanded the Zero 1.8 integration contract to use PostgreSQL UUID primary
|
|
37
|
+
keys and foreign keys and to cover request verification, authentication,
|
|
38
|
+
global and mutator authorization, sanitized internal failures, unchanged
|
|
39
|
+
LMID state, and successful same-ID replay.
|
|
40
|
+
- Updated the contract's direct `ws` dependency from 8.18.3 to 8.21.1.
|
|
41
|
+
|
|
42
|
+
### Upgrade notes
|
|
43
|
+
|
|
44
|
+
Configure `request_verifier`, `authenticator`, and `authorizer` explicitly
|
|
45
|
+
before accepting mutation traffic. Add `authorize_with` to every custom
|
|
46
|
+
mutator, including mutators that intentionally rely only on the global
|
|
47
|
+
authorizer. Regenerate the initializer if the application still conditionally
|
|
48
|
+
configures `ZERO_MUTATE_API_KEY`.
|
|
49
|
+
|
|
50
|
+
## [0.3.0] - 2026-07-29
|
|
51
|
+
|
|
52
|
+
### Breaking changes
|
|
53
|
+
|
|
54
|
+
- Changed model and column exposure from permissive to fail-closed. The adapter
|
|
55
|
+
no longer discovers and publishes every Active Record model by default.
|
|
56
|
+
Applications must define `published_schema` with both the models and columns
|
|
57
|
+
that Zero may replicate.
|
|
58
|
+
- Split the former `model_provider` responsibility into `published_schema` for
|
|
59
|
+
replicated data and `crud_model_provider` for generic writes.
|
|
60
|
+
- Made generic CRUD fully opt-in. `crud_model_provider` now defaults to an empty
|
|
61
|
+
list and `crud_authorizer` denies every operation by default. Publishing a
|
|
62
|
+
model does not make it writable.
|
|
63
|
+
- Changed integer-backed Active Record enum generation from a string
|
|
64
|
+
enumeration to `number()`. String-backed Active Record enums generate
|
|
65
|
+
`string()`, while only PostgreSQL native enums generate Zero enumerations.
|
|
66
|
+
|
|
67
|
+
### Upgrade notes
|
|
68
|
+
|
|
69
|
+
Before upgrading an application from 0.2.0:
|
|
70
|
+
|
|
71
|
+
1. Replace `model_provider` with an explicit `published_schema` table-and-column
|
|
72
|
+
allowlist.
|
|
73
|
+
2. If generic CRUD is required, configure both `crud_model_provider` and
|
|
74
|
+
`crud_authorizer`. Keep them closed and use custom mutators for domain
|
|
75
|
+
operations that enforce application invariants.
|
|
76
|
+
3. Regenerate `schema.ts` and `mutators.ts`.
|
|
77
|
+
4. Generate and review the column-limited PostgreSQL publication SQL, apply it
|
|
78
|
+
through the application's normal database process, and point zero-cache at
|
|
79
|
+
that publication.
|
|
80
|
+
5. Confirm that timestamp consumers treat Zero values as Unix epoch
|
|
81
|
+
milliseconds and update any code that assumed string-valued Rails enums.
|
|
82
|
+
|
|
83
|
+
See the [README](README.md#explicit-publication-schema) for configuration
|
|
84
|
+
examples.
|
|
85
|
+
|
|
86
|
+
### Added
|
|
87
|
+
|
|
88
|
+
- Added a table-and-column publication schema with validation for primary keys,
|
|
89
|
+
sensitive credentials, framework-internal tables, unknown columns, and
|
|
90
|
+
unsupported PostgreSQL types.
|
|
91
|
+
- Added a generator for reviewable, column-limited PostgreSQL publication SQL.
|
|
92
|
+
- Added `zero_key` for stable synchronization identifiers that differ from the
|
|
93
|
+
Active Record primary key, including composite keys. Separate keys must be
|
|
94
|
+
published and backed by exact unique, non-null database indexes.
|
|
95
|
+
- Added explicit one-hop and two-hop relationship definitions for delegated,
|
|
96
|
+
polymorphic, through, custom-key, and other associations Rails cannot safely
|
|
97
|
+
infer.
|
|
98
|
+
- Added an exact Zero 1.8 integration contract using PostgreSQL logical
|
|
99
|
+
replication, zero-cache, a Rails custom-mutator endpoint, and a real Zero
|
|
100
|
+
client.
|
|
101
|
+
- Added coverage for replication queries, LMID advancement, duplicate
|
|
102
|
+
mutations, failed-mutation skipping, and `_zero_cleanupResults`.
|
|
103
|
+
|
|
104
|
+
### Changed
|
|
105
|
+
|
|
106
|
+
- Generic update and destroy now locate records through the configured Zero
|
|
107
|
+
key. Generic updates cannot change either the Zero key or the Active Record
|
|
108
|
+
primary key.
|
|
109
|
+
- TypeScript generation now uses `published_schema` for tables and columns and
|
|
110
|
+
generates CRUD mutators only for models returned by `crud_model_provider`.
|
|
111
|
+
- Timestamps are explicitly documented and generated as Unix epoch
|
|
112
|
+
milliseconds on the Zero side.
|
|
113
|
+
- Manual relationships override inferred relationships with the same source
|
|
114
|
+
and name.
|
|
115
|
+
|
|
116
|
+
### Security
|
|
117
|
+
|
|
118
|
+
- Added default rejection for known credential columns such as password hashes
|
|
119
|
+
and token digests, authentication tables, Active Storage internals, and
|
|
120
|
+
Action Mailbox internals.
|
|
121
|
+
- Publication validation rejects unsupported PostgreSQL types, including
|
|
122
|
+
`citext`; applications must exclude those columns or publish a supported safe
|
|
123
|
+
mirror column.
|
|
124
|
+
- Prevented schema generation and PostgreSQL publication generation from
|
|
125
|
+
silently exposing newly discovered Active Record models or columns.
|
|
126
|
+
|
|
127
|
+
## [0.2.0] - 2026-07-28
|
|
128
|
+
|
|
129
|
+
### Added
|
|
130
|
+
|
|
131
|
+
- Initial public release.
|
|
132
|
+
- Added a mountable Rails Engine with Zero mutation endpoints.
|
|
133
|
+
- Added generic Active Record create, update, and destroy operations using
|
|
134
|
+
validations, callbacks, associations, database constraints, and Rails
|
|
135
|
+
transactions.
|
|
136
|
+
- Added explicit custom mutators through a Ruby DSL, base class, registry, and
|
|
137
|
+
Rails generator.
|
|
138
|
+
- Added configurable request verification, authentication, authorization,
|
|
139
|
+
model resolution, writable-attribute policies, and transaction storage.
|
|
140
|
+
- Added atomic Zero LMID tracking, mutation ordering, duplicate detection,
|
|
141
|
+
structured application errors, failed-mutation skipping, and cleanup-result
|
|
142
|
+
handling.
|
|
143
|
+
- Added TypeScript schema and mutator generation from Active Record models.
|
|
144
|
+
- Added Active Support mutation notifications and Rails/PostgreSQL test
|
|
145
|
+
coverage.
|
|
146
|
+
|
|
147
|
+
[Unreleased]: https://github.com/mechiland/zero_rails_adapter/compare/v0.3.1...HEAD
|
|
148
|
+
[0.3.1]: https://github.com/mechiland/zero_rails_adapter/compare/v0.3.0...v0.3.1
|
|
149
|
+
[0.3.0]: https://github.com/mechiland/zero_rails_adapter/releases/tag/v0.3.0
|
|
150
|
+
[0.2.0]: https://rubygems.org/gems/zero-rails-adapter/versions/0.2.0
|
data/README.md
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
`zero-rails-adapter` is a mountable Rails Engine that maps Rocicorp Zero custom
|
|
4
4
|
mutations directly to existing Active Record models.
|
|
5
5
|
|
|
6
|
+
See the [changelog](CHANGELOG.md) for release notes and upgrade instructions.
|
|
7
|
+
|
|
6
8
|
The default CRUD path does not require a Ruby Mutator class for every model:
|
|
7
9
|
|
|
8
10
|
| Zero mutation | Rails call |
|
|
@@ -25,7 +27,7 @@ mass-assignment policies are all configurable callable interfaces.
|
|
|
25
27
|
- Ruby 4.0 or newer
|
|
26
28
|
- Rails 8.0 or newer
|
|
27
29
|
- PostgreSQL, the upstream database supported by Zero
|
|
28
|
-
-
|
|
30
|
+
- `@rocicorp/zero` and `zero-cache` from the same supported release
|
|
29
31
|
|
|
30
32
|
The Zero-managed `schema.clients` and `schema.mutations` tables and the
|
|
31
33
|
application tables must use the same database connection. In a Rails
|
|
@@ -48,6 +50,10 @@ bundle install
|
|
|
48
50
|
bin/rails generate zero_rails_adapter:install
|
|
49
51
|
```
|
|
50
52
|
|
|
53
|
+
The generated initializer reads `ZERO_MUTATE_API_KEY` with `ENV.fetch`, so a
|
|
54
|
+
missing key fails during application boot instead of disabling request
|
|
55
|
+
verification.
|
|
56
|
+
|
|
51
57
|
Mount the Engine:
|
|
52
58
|
|
|
53
59
|
```ruby
|
|
@@ -73,8 +79,64 @@ The adapter uses Zero's validated `schema` parameter to build schema-qualified
|
|
|
73
79
|
Active Record classes for those tables, then writes application data and the
|
|
74
80
|
LMID in the same transaction.
|
|
75
81
|
|
|
82
|
+
## Explicit Publication Schema
|
|
83
|
+
|
|
84
|
+
The adapter fails closed. No Active Record model or column is published, added
|
|
85
|
+
to generated TypeScript, or exposed to generic CRUD by default.
|
|
86
|
+
|
|
87
|
+
Declare both the tables and columns that may be replicated:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
ZeroRailsAdapter.configure do |config|
|
|
91
|
+
config.published_schema = lambda do
|
|
92
|
+
{
|
|
93
|
+
Article => %w[id title body author_id created_at updated_at],
|
|
94
|
+
Comment => %w[id article_id body author_id created_at updated_at]
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The callable is evaluated when code is generated, so it remains safe across
|
|
101
|
+
Rails development reloads. Primary key columns must be present. Unknown
|
|
102
|
+
columns, known credential columns such as `password_hash` and `token_digest`,
|
|
103
|
+
Active Storage and Action Mailbox internal tables, and PostgreSQL types that
|
|
104
|
+
Zero cannot replicate are rejected.
|
|
105
|
+
|
|
106
|
+
By default, a model's Zero key is its Active Record primary key. Applications
|
|
107
|
+
may use a separate stable synchronization key:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
config.zero_key = lambda do |model|
|
|
111
|
+
model == Article ? "sync_id" : model.primary_key
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
A separate Zero key must be included in `published_schema` and backed by an
|
|
116
|
+
exact unique, non-null database index. Composite Zero keys may be returned as
|
|
117
|
+
an array. The database primary key must still be published because PostgreSQL
|
|
118
|
+
uses it as the table's replica identity; it is not silently replaced by the
|
|
119
|
+
Zero key.
|
|
120
|
+
|
|
121
|
+
Generate a reviewable, column-limited PostgreSQL publication:
|
|
122
|
+
|
|
123
|
+
```sh
|
|
124
|
+
bin/rails generate zero_rails_adapter:publication zero_data
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
This writes `db/zero_publication.sql`; it does not execute DDL automatically.
|
|
128
|
+
Apply the SQL through the application's normal migration or operations process,
|
|
129
|
+
then configure:
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
ZERO_APP_PUBLICATIONS=zero_data
|
|
133
|
+
```
|
|
134
|
+
|
|
76
135
|
## Generic Active Record CRUD
|
|
77
136
|
|
|
137
|
+
Generic CRUD is a separate, opt-in capability. Publishing a model never makes
|
|
138
|
+
it writable.
|
|
139
|
+
|
|
78
140
|
This client-side mutation:
|
|
79
141
|
|
|
80
142
|
```ts
|
|
@@ -92,8 +154,9 @@ Article.create!(id: "...", title: "Rails and Zero")
|
|
|
92
154
|
```
|
|
93
155
|
|
|
94
156
|
For `update` and `destroy`, the adapter first loads the record using the
|
|
95
|
-
|
|
96
|
-
`
|
|
157
|
+
configured Zero key, including composite keys, and then calls `update!` or
|
|
158
|
+
`destroy!`. Neither the Zero key nor the Active Record primary key can be
|
|
159
|
+
changed by generic update. The bang methods are intentional: a validation,
|
|
97
160
|
callback, or database-constraint failure rolls back the complete business
|
|
98
161
|
transaction and produces a structured Zero application error.
|
|
99
162
|
|
|
@@ -101,14 +164,16 @@ Configure the models exposed to generic CRUD explicitly:
|
|
|
101
164
|
|
|
102
165
|
```ruby
|
|
103
166
|
ZeroRailsAdapter.configure do |config|
|
|
104
|
-
config.
|
|
167
|
+
config.crud_model_provider = -> { [Article, Comment] }
|
|
105
168
|
end
|
|
106
169
|
```
|
|
107
170
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
171
|
+
Models absent from `crud_model_provider` cannot be resolved by generic CRUD.
|
|
172
|
+
The default provider returns an empty list, and the default `crud_authorizer`
|
|
173
|
+
also rejects every operation. Applications must opt into both model resolution
|
|
174
|
+
and authorization. Applications with aggregate operations, tenant-scoped
|
|
175
|
+
commands, soft deletion, or other domain behavior should leave the provider
|
|
176
|
+
empty and use custom mutators.
|
|
112
177
|
|
|
113
178
|
If a table cannot be resolved through Rails naming conventions, replace the
|
|
114
179
|
resolver:
|
|
@@ -128,14 +193,13 @@ hand:
|
|
|
128
193
|
bin/rails generate zero_rails_adapter:typescript app/javascript/zero
|
|
129
194
|
```
|
|
130
195
|
|
|
131
|
-
The generator reflects on
|
|
132
|
-
runtime and writes:
|
|
196
|
+
The generator reflects on `published_schema` in the Rails runtime and writes:
|
|
133
197
|
|
|
134
|
-
- `schema.ts`, containing tables, columns, nullability,
|
|
198
|
+
- `schema.ts`, containing tables, columns, nullability, Zero keys, and
|
|
135
199
|
safely inferred `belongs_to`, `has_one`, and `has_many` relationships.
|
|
136
|
-
- `mutators.ts`, containing `create`, `update`, and `destroy` for
|
|
137
|
-
using Zero's current `defineMutator` /
|
|
138
|
-
schemas.
|
|
200
|
+
- `mutators.ts`, containing `create`, `update`, and `destroy` only for models
|
|
201
|
+
returned by `crud_model_provider`, using Zero's current `defineMutator` /
|
|
202
|
+
`defineMutators` API and Zod argument schemas.
|
|
139
203
|
|
|
140
204
|
Run the generator again after changing Rails migrations or model associations.
|
|
141
205
|
The output can live in Rails' JavaScript directory or be written directly into
|
|
@@ -145,6 +209,48 @@ an adjacent Next.js application:
|
|
|
145
209
|
bin/rails generate zero_rails_adapter:typescript ../web/src/zero
|
|
146
210
|
```
|
|
147
211
|
|
|
212
|
+
Rails cannot safely infer every Zero relationship. Add delegated-type,
|
|
213
|
+
polymorphic, custom, or through relationships explicitly:
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
config.relationship_provider = lambda do
|
|
217
|
+
[
|
|
218
|
+
{
|
|
219
|
+
source: Recording,
|
|
220
|
+
name: :task,
|
|
221
|
+
kind: :one,
|
|
222
|
+
source_fields: %w[recordable_id],
|
|
223
|
+
destination: Task,
|
|
224
|
+
destination_fields: %w[id]
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
source: Article,
|
|
228
|
+
name: :labels,
|
|
229
|
+
kind: :many,
|
|
230
|
+
through: [
|
|
231
|
+
{
|
|
232
|
+
source_fields: %w[id],
|
|
233
|
+
destination: ArticleLabel,
|
|
234
|
+
destination_fields: %w[article_id]
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
source_fields: %w[label_id],
|
|
238
|
+
destination: Label,
|
|
239
|
+
destination_fields: %w[id]
|
|
240
|
+
}
|
|
241
|
+
]
|
|
242
|
+
}
|
|
243
|
+
]
|
|
244
|
+
end
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Every model and field in a manual relationship must be published. Zero
|
|
248
|
+
supports at most two relationship hops. A polymorphic or delegated-type
|
|
249
|
+
relationship also needs an application invariant or a published discriminator
|
|
250
|
+
or safe mirror column that prevents IDs belonging to another type from
|
|
251
|
+
matching; the adapter does not guess that predicate. A manual definition with
|
|
252
|
+
the same source and name replaces an inferred relationship.
|
|
253
|
+
|
|
148
254
|
The default mappings follow Zero's PostgreSQL type conventions:
|
|
149
255
|
|
|
150
256
|
- string/text/uuid → `string()`
|
|
@@ -152,10 +258,13 @@ The default mappings follow Zero's PostgreSQL type conventions:
|
|
|
152
258
|
- boolean → `boolean()`
|
|
153
259
|
- date/time/datetime/timestamp → `number()`
|
|
154
260
|
- json/jsonb → `json()`
|
|
155
|
-
- Active Record enum → `
|
|
261
|
+
- integer-backed Active Record enum → `number()`
|
|
262
|
+
- string-backed Active Record enum → `string()`
|
|
263
|
+
- PostgreSQL native enum → `enumeration<...>()`
|
|
156
264
|
|
|
157
265
|
Nullable columns use `.optional()`. Rails timestamps use `Date.now()` for the
|
|
158
|
-
optimistic client write and
|
|
266
|
+
optimistic client write and are Unix epoch milliseconds in Zero. They remain
|
|
267
|
+
managed normally by Rails on the server.
|
|
159
268
|
The generator raises a descriptive error for a column that cannot be mapped
|
|
160
269
|
reliably instead of emitting an incorrect type.
|
|
161
270
|
|
|
@@ -180,6 +289,17 @@ and writable-attribute interfaces below.
|
|
|
180
289
|
The generated initializer supports `ZERO_MUTATE_API_KEY` and verifies
|
|
181
290
|
zero-cache's `X-Api-Key` header using a constant-time comparison.
|
|
182
291
|
|
|
292
|
+
All mutation security gates fail closed by default:
|
|
293
|
+
|
|
294
|
+
- `request_verifier` returns `false`.
|
|
295
|
+
- `authenticator` raises `UnauthorizedError`.
|
|
296
|
+
- The global `authorizer` returns `false`.
|
|
297
|
+
- The generic CRUD `crud_authorizer` returns `false`.
|
|
298
|
+
|
|
299
|
+
Applications must configure each gate they use explicitly. An application that
|
|
300
|
+
intentionally permits anonymous identities must still install an authenticator
|
|
301
|
+
that returns `ZeroRailsAdapter::Identity.new`.
|
|
302
|
+
|
|
183
303
|
The authentication interface receives the Rails request. It can integrate with
|
|
184
304
|
Devise/Warden, any JWT library, a session, or an application-specific
|
|
185
305
|
authentication system:
|
|
@@ -204,6 +324,10 @@ authorizer then receives the model class as `target` for create, or the loaded
|
|
|
204
324
|
record for update and destroy:
|
|
205
325
|
|
|
206
326
|
```ruby
|
|
327
|
+
config.authorizer = lambda do |context, mutation|
|
|
328
|
+
MutationPolicy.allowed?(context.current_user, mutation.name)
|
|
329
|
+
end
|
|
330
|
+
|
|
207
331
|
config.crud_authorizer = lambda do |context, action, target, attributes|
|
|
208
332
|
MutationPolicy.new(
|
|
209
333
|
context.current_user,
|
|
@@ -267,6 +391,10 @@ available:
|
|
|
267
391
|
ZeroRailsAdapter.define_mutator "projects.archive" do
|
|
268
392
|
attribute :id, :string
|
|
269
393
|
|
|
394
|
+
authorize_with do |context|
|
|
395
|
+
context.current_user.present?
|
|
396
|
+
end
|
|
397
|
+
|
|
270
398
|
perform do
|
|
271
399
|
Project.find(id).archive!(actor: context.current_user)
|
|
272
400
|
end
|
|
@@ -274,7 +402,9 @@ end
|
|
|
274
402
|
```
|
|
275
403
|
|
|
276
404
|
Mutators use Active Model attributes and validations and share a transaction
|
|
277
|
-
with application writes and LMID tracking.
|
|
405
|
+
with application writes and LMID tracking. A custom mutator without an
|
|
406
|
+
`authorize_with` callback is rejected. To rely intentionally on the global
|
|
407
|
+
authorizer alone, the mutator must still declare `authorize_with { true }`.
|
|
278
408
|
|
|
279
409
|
## Mutation Ordering and Transaction Semantics
|
|
280
410
|
|
|
@@ -286,11 +416,19 @@ For each `(schema, clientGroupID, clientID)`, the adapter:
|
|
|
286
416
|
4. Executes the authorized Active Record operation inside a transaction.
|
|
287
417
|
5. Updates the LMID atomically.
|
|
288
418
|
|
|
289
|
-
When
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
419
|
+
When an explicit `ApplicationError`, argument validation error, or supported
|
|
420
|
+
Active Model/Active Record validation or lifecycle error occurs, the first
|
|
421
|
+
transaction rolls back. A second transaction advances the LMID and stores a
|
|
422
|
+
structured `app` result so that a bad mutation is not retried forever and later
|
|
423
|
+
mutations can continue.
|
|
424
|
+
|
|
425
|
+
Database failures and unexpected Ruby exceptions do not advance LMID or store
|
|
426
|
+
a mutation result. They return a retry-safe `PushFailed`, so the same mutation
|
|
427
|
+
ID can be retried after the underlying problem is fixed. Public database and
|
|
428
|
+
internal failure messages are fixed and sanitized; the configured server
|
|
429
|
+
logger receives the original exception and backtrace. If persisting an
|
|
430
|
+
application failure also fails, the adapter likewise returns `PushFailed`
|
|
431
|
+
without advancing LMID.
|
|
294
432
|
|
|
295
433
|
Each mutation owns an independent transaction. A later failure in the same
|
|
296
434
|
HTTP batch does not roll back earlier committed mutations.
|
|
@@ -333,6 +471,23 @@ Run the complete contract suite against a dedicated PostgreSQL test database:
|
|
|
333
471
|
DATABASE_URL=postgres://localhost/zero_rails_adapter_test bundle exec rake test
|
|
334
472
|
```
|
|
335
473
|
|
|
474
|
+
The repository also locks `@rocicorp/zero` and the `zero-cache` CLI to the
|
|
475
|
+
exact same `1.8.0` package in `test/contract/package-lock.json`. Run the full
|
|
476
|
+
wire and replication contract with Node 24+ and Docker:
|
|
477
|
+
|
|
478
|
+
```sh
|
|
479
|
+
bundle exec rake contract
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
That task compiles the generated TypeScript, starts PostgreSQL with logical
|
|
483
|
+
replication, starts the real zero-cache and Rails mutation endpoint, then uses
|
|
484
|
+
a Zero client to mutate and query replicated rows. The generated fixture uses
|
|
485
|
+
UUID primary and foreign keys and includes a separate Zero key and a two-hop
|
|
486
|
+
relationship. The task also verifies request verification, authentication,
|
|
487
|
+
global and mutator authorization, sanitized retry-safe failures, same-ID
|
|
488
|
+
replay, LMID advancement, duplicate-mutation handling, failure skipping, and
|
|
489
|
+
`_zero_cleanupResults`. The contract runs as its own CI job.
|
|
490
|
+
|
|
336
491
|
See [`examples/nextjs`](examples/nextjs) for a Next.js integration fixture.
|
|
337
492
|
|
|
338
493
|
## References
|
|
@@ -340,5 +495,6 @@ See [`examples/nextjs`](examples/nextjs) for a Next.js integration fixture.
|
|
|
340
495
|
- [Zero custom mutators](https://zero.rocicorp.dev/docs/mutators)
|
|
341
496
|
- [Zero schema](https://zero.rocicorp.dev/docs/schema)
|
|
342
497
|
- [Zero PostgreSQL support](https://zero.rocicorp.dev/docs/postgres-support)
|
|
498
|
+
- [Zero 1.8 release notes](https://zero.rocicorp.dev/docs/release-notes/1.8)
|
|
343
499
|
- [Zero `process-mutations.ts`](https://github.com/rocicorp/mono/blob/main/packages/zero-server/src/process-mutations.ts)
|
|
344
500
|
- [Server implementation plan](https://jeremykreutzbender.com/blog/server-implementation-plan-rocicorp-zero-custom-mutators)
|
|
@@ -27,7 +27,11 @@ module ZeroRailsAdapter
|
|
|
27
27
|
ZeroRailsAdapter.configuration.logger&.error(
|
|
28
28
|
"ZeroRailsAdapter request failed: #{error.class}: #{error.message}"
|
|
29
29
|
)
|
|
30
|
-
render json: push_failed(
|
|
30
|
+
render json: push_failed(
|
|
31
|
+
"internal",
|
|
32
|
+
Processor::INTERNAL_ERROR_MESSAGE,
|
|
33
|
+
[]
|
|
34
|
+
), status: :internal_server_error
|
|
31
35
|
end
|
|
32
36
|
|
|
33
37
|
private
|
data/examples/nextjs/README.md
CHANGED
|
@@ -25,5 +25,7 @@ ZERO_MUTATE_API_KEY=development-secret
|
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
The Rails application must mount the Engine at `/zero`, use the generated API
|
|
28
|
-
key verifier,
|
|
29
|
-
|
|
28
|
+
key verifier, publish explicit `Book` columns through
|
|
29
|
+
`config.published_schema`, and opt `Book` into
|
|
30
|
+
`config.crud_model_provider`. No Ruby mutator class is needed for
|
|
31
|
+
`books.create/update/destroy`.
|
|
@@ -6,11 +6,9 @@ ZeroRailsAdapter.configure do |config|
|
|
|
6
6
|
|
|
7
7
|
# Verify that calls came from your zero-cache instance.
|
|
8
8
|
# Configure zero-cache with ZERO_MUTATE_API_KEY using the same value.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
)
|
|
13
|
-
end
|
|
9
|
+
config.request_verifier = ZeroRailsAdapter::RequestVerifiers::ApiKey.new(
|
|
10
|
+
key: ENV.fetch("ZERO_MUTATE_API_KEY")
|
|
11
|
+
)
|
|
14
12
|
|
|
15
13
|
# Return an Identity from any authentication system (Devise, JWT, cookies, etc.).
|
|
16
14
|
# config.authenticator = lambda do |request|
|
|
@@ -26,10 +24,31 @@ ZeroRailsAdapter.configure do |config|
|
|
|
26
24
|
# Raise ZeroRailsAdapter::ForbiddenError to reject a mutation.
|
|
27
25
|
# config.authorizer = ->(context, mutation) { YourPolicy.authorize!(context, mutation) }
|
|
28
26
|
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
27
|
+
# Fail-closed table and column allowlist used by schema.ts and publication
|
|
28
|
+
# SQL generation. No tables or columns are published by default.
|
|
29
|
+
# config.published_schema = lambda do
|
|
30
|
+
# {
|
|
31
|
+
# Article => %w[id title body author_id created_at updated_at],
|
|
32
|
+
# Comment => %w[id article_id body author_id created_at updated_at]
|
|
33
|
+
# }
|
|
34
|
+
# end
|
|
35
|
+
|
|
36
|
+
# Generic CRUD is independently opt-in. Publishing a model does not make it
|
|
37
|
+
# writable. The default crud_authorizer below also rejects every operation.
|
|
38
|
+
# Keep this empty when writes require domain-specific mutators.
|
|
39
|
+
# config.crud_model_provider = -> { [Article, Comment] }
|
|
40
|
+
|
|
41
|
+
# Zero uses this stable key in generated schemas and CRUD update/destroy
|
|
42
|
+
# lookups. A non-Active Record key must be published and backed by an exact
|
|
43
|
+
# unique, non-null database index. Composite keys may return an array.
|
|
44
|
+
# config.zero_key = lambda do |model|
|
|
45
|
+
# model == Article ? "sync_id" : model.primary_key
|
|
46
|
+
# end
|
|
47
|
+
|
|
48
|
+
# Add relationships that Rails reflection cannot express safely, including
|
|
49
|
+
# polymorphic, delegated-type, and through relationships. Definitions may
|
|
50
|
+
# contain one direct hop or a maximum of two entries under `through`.
|
|
51
|
+
# config.relationship_provider = -> { [] }
|
|
33
52
|
|
|
34
53
|
# Authorize generic CRUD independently of Devise/JWT/Pundit/etc. target is
|
|
35
54
|
# the model class for create, and the loaded record for update/destroy.
|
|
@@ -7,8 +7,11 @@ class <%= class_name %> < ZeroRailsAdapter::Mutator
|
|
|
7
7
|
# attribute :title, :string
|
|
8
8
|
# validates :title, presence: true
|
|
9
9
|
|
|
10
|
+
authorize_with do |_context|
|
|
11
|
+
false
|
|
12
|
+
end
|
|
13
|
+
|
|
10
14
|
def perform
|
|
11
|
-
# Use context.current_user, context.user_id, and context.claims for authorization.
|
|
12
15
|
raise NotImplementedError
|
|
13
16
|
end
|
|
14
17
|
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "zero_rails_adapter"
|
|
5
|
+
|
|
6
|
+
module ZeroRailsAdapter
|
|
7
|
+
module Generators
|
|
8
|
+
class PublicationGenerator < Rails::Generators::Base
|
|
9
|
+
argument :publication_name,
|
|
10
|
+
type: :string,
|
|
11
|
+
default: ZeroRailsAdapter::PostgreSQL::PublicationGenerator::DEFAULT_NAME
|
|
12
|
+
|
|
13
|
+
def create_publication_sql
|
|
14
|
+
sql = ZeroRailsAdapter::PostgreSQL::PublicationGenerator.new(
|
|
15
|
+
name: publication_name
|
|
16
|
+
).sql
|
|
17
|
+
create_file "db/zero_publication.sql", sql
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -5,18 +5,24 @@ module ZeroRailsAdapter
|
|
|
5
5
|
attr_accessor :authenticator, :request_verifier, :authorizer, :logger,
|
|
6
6
|
:transaction_class, :storage_provider, :crud_authorizer,
|
|
7
7
|
:writable_attributes, :generated_attributes, :model_resolver,
|
|
8
|
-
:
|
|
8
|
+
:published_schema, :crud_model_provider, :zero_key,
|
|
9
|
+
:relationship_provider
|
|
9
10
|
|
|
10
11
|
def initialize
|
|
11
|
-
@authenticator =
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@
|
|
12
|
+
@authenticator = lambda do |_request|
|
|
13
|
+
raise UnauthorizedError, "Authentication is not configured"
|
|
14
|
+
end
|
|
15
|
+
@request_verifier = ->(_request) { false }
|
|
16
|
+
@authorizer = ->(_context, _mutation) { false }
|
|
17
|
+
@crud_authorizer = ->(_context, _action, _target, _attributes) { false }
|
|
15
18
|
@logger = defined?(Rails) ? Rails.logger : nil
|
|
16
19
|
@transaction_class = ActiveRecord::Base
|
|
17
|
-
@
|
|
20
|
+
@published_schema = -> { {} }
|
|
21
|
+
@crud_model_provider = -> { [] }
|
|
22
|
+
@zero_key = ->(model) { model.primary_key }
|
|
23
|
+
@relationship_provider = -> { [] }
|
|
18
24
|
@model_resolver = lambda do |resource|
|
|
19
|
-
allowed_models = Array(
|
|
25
|
+
allowed_models = Array(crud_model_provider.call).select do |model|
|
|
20
26
|
active_record_model?(model)
|
|
21
27
|
end
|
|
22
28
|
candidate = resource.to_s.classify.safe_constantize
|
|
@@ -32,16 +38,6 @@ module ZeroRailsAdapter
|
|
|
32
38
|
|
|
33
39
|
private
|
|
34
40
|
|
|
35
|
-
def default_models
|
|
36
|
-
application = Rails.application if defined?(Rails) && Rails.respond_to?(:application)
|
|
37
|
-
application&.eager_load!
|
|
38
|
-
ActiveRecord::Base.descendants.select do |model|
|
|
39
|
-
active_record_model?(model) &&
|
|
40
|
-
model.name.present? &&
|
|
41
|
-
!model.name.start_with?("ZeroRailsAdapter::")
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
|
|
45
41
|
def default_writable_attributes(model)
|
|
46
42
|
model.column_names -
|
|
47
43
|
model.readonly_attributes.to_a -
|