zero-rails-adapter 0.2.0

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.
Files changed (30) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +344 -0
  4. data/app/controllers/zero_rails_adapter/mutations_controller.rb +74 -0
  5. data/config/routes.rb +7 -0
  6. data/examples/nextjs/README.md +29 -0
  7. data/examples/nextjs/src/zero/mutators.ts +37 -0
  8. data/examples/nextjs/src/zero/schema.ts +20 -0
  9. data/lib/generators/zero_rails_adapter/install/install_generator.rb +15 -0
  10. data/lib/generators/zero_rails_adapter/install/templates/initializer.rb +52 -0
  11. data/lib/generators/zero_rails_adapter/mutator/mutator_generator.rb +31 -0
  12. data/lib/generators/zero_rails_adapter/mutator/templates/mutator.rb +14 -0
  13. data/lib/generators/zero_rails_adapter/typescript/typescript_generator.rb +18 -0
  14. data/lib/zero_rails_adapter/configuration.rb +57 -0
  15. data/lib/zero_rails_adapter/context.rb +19 -0
  16. data/lib/zero_rails_adapter/crud/dispatcher.rb +121 -0
  17. data/lib/zero_rails_adapter/engine.rb +7 -0
  18. data/lib/zero_rails_adapter/errors.rb +50 -0
  19. data/lib/zero_rails_adapter/identity.rb +9 -0
  20. data/lib/zero_rails_adapter/mutation.rb +50 -0
  21. data/lib/zero_rails_adapter/mutator.rb +70 -0
  22. data/lib/zero_rails_adapter/processor.rb +178 -0
  23. data/lib/zero_rails_adapter/registry.rb +51 -0
  24. data/lib/zero_rails_adapter/request.rb +93 -0
  25. data/lib/zero_rails_adapter/request_verifiers/api_key.rb +21 -0
  26. data/lib/zero_rails_adapter/storage/zero_schema.rb +95 -0
  27. data/lib/zero_rails_adapter/type_script/generator.rb +421 -0
  28. data/lib/zero_rails_adapter/version.rb +5 -0
  29. data/lib/zero_rails_adapter.rb +50 -0
  30. metadata +89 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 15ffa084c77f3af139ecfc0e284d2b2d41265357ec3b3edafda78d5c7378c8b8
4
+ data.tar.gz: a6aaafe3bb4cca20cfd6544ad6fd63f49bca923ebd225edb0811069a31c3c778
5
+ SHA512:
6
+ metadata.gz: cdb046e65aa33933cdb5e33ede0f484275e7734ad637cbe87d298846a5636d79c4a1b5188d390551224c9d660c7dc8ab3bd4a48dfd54571f61c54520a895a1f0
7
+ data.tar.gz: b3b4149d75a0519b307d7464fe48da60c77eca56a71283791f858151d518f164245e3027310347c342d57d18f03729afb9d2fc66a5a0e20d947ea2807d1d5195
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zero Rails Adapter contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,344 @@
1
+ # Zero Rails Adapter
2
+
3
+ `zero-rails-adapter` is a mountable Rails Engine that maps Rocicorp Zero custom
4
+ mutations directly to existing Active Record models.
5
+
6
+ The default CRUD path does not require a Ruby Mutator class for every model:
7
+
8
+ | Zero mutation | Rails call |
9
+ | --- | --- |
10
+ | `articles.create` / `articles.insert` | `Article.create!(attributes)` |
11
+ | `articles.update` | `Article.find(...).update!(attributes)` |
12
+ | `articles.destroy` / `articles.delete` | `Article.find(...).destroy!` |
13
+
14
+ Existing Active Record validations, callbacks, associations, `after_commit`
15
+ jobs, database constraints, and transaction behavior therefore work without
16
+ adapter-specific wrappers. Explicit Mutators are reserved for non-CRUD domain
17
+ commands, aggregate operations, and other custom behavior.
18
+
19
+ The gem does not depend on Devise, a JWT implementation, Pundit, or another
20
+ authorization framework. Authentication, authorization, model exposure, and
21
+ mass-assignment policies are all configurable callable interfaces.
22
+
23
+ ## Requirements
24
+
25
+ - Ruby 4.0 or newer
26
+ - Rails 8.0 or newer
27
+ - PostgreSQL, the upstream database supported by Zero
28
+ - Current `@rocicorp/zero` and `zero-cache` releases
29
+
30
+ The Zero-managed `schema.clients` and `schema.mutations` tables and the
31
+ application tables must use the same database connection. In a Rails
32
+ multi-database application, configure `transaction_class` with the abstract
33
+ Active Record base class connected to that PostgreSQL database.
34
+
35
+ ## Installation
36
+
37
+ Add the gem:
38
+
39
+ ```ruby
40
+ # Gemfile
41
+ gem "zero-rails-adapter"
42
+ ```
43
+
44
+ Install the bundle and initializer:
45
+
46
+ ```sh
47
+ bundle install
48
+ bin/rails generate zero_rails_adapter:install
49
+ ```
50
+
51
+ Mount the Engine:
52
+
53
+ ```ruby
54
+ # config/routes.rb
55
+ Rails.application.routes.draw do
56
+ mount ZeroRailsAdapter::Engine => "/zero"
57
+ end
58
+ ```
59
+
60
+ Configure zero-cache:
61
+
62
+ ```sh
63
+ ZERO_MUTATE_URL="https://api.example.com/zero/mutate"
64
+ ZERO_MUTATE_API_KEY="replace-with-a-long-random-secret"
65
+ ```
66
+
67
+ The Engine accepts `POST /mutate`, `POST /push`, and `POST /` beneath its mount
68
+ point. `/mutate` is the recommended endpoint.
69
+
70
+ The gem intentionally installs no tracking-table migration. zero-cache creates
71
+ the `clients` and `mutations` tables in its shard schema, such as `zero_0`.
72
+ The adapter uses Zero's validated `schema` parameter to build schema-qualified
73
+ Active Record classes for those tables, then writes application data and the
74
+ LMID in the same transaction.
75
+
76
+ ## Generic Active Record CRUD
77
+
78
+ This client-side mutation:
79
+
80
+ ```ts
81
+ await mutators.articles.create({
82
+ id: crypto.randomUUID(),
83
+ title: 'Rails and Zero',
84
+ })
85
+ ```
86
+
87
+ reaches the Engine as `articles.create`. The adapter resolves `Article` from
88
+ the table name, applies the writable-attribute policy, and calls:
89
+
90
+ ```ruby
91
+ Article.create!(id: "...", title: "Rails and Zero")
92
+ ```
93
+
94
+ For `update` and `destroy`, the adapter first loads the record using the
95
+ model's primary key, including composite primary keys, and then calls
96
+ `update!` or `destroy!`. The bang methods are intentional: a validation,
97
+ callback, or database-constraint failure rolls back the complete business
98
+ transaction and produces a structured Zero application error.
99
+
100
+ Configure the models exposed to generic CRUD explicitly:
101
+
102
+ ```ruby
103
+ ZeroRailsAdapter.configure do |config|
104
+ config.model_provider = -> { [Article, Comment, Project] }
105
+ end
106
+ ```
107
+
108
+ `model_provider` is both the CRUD allowlist and the source used by the
109
+ TypeScript generator. By default, the adapter eager-loads the Rails application
110
+ and returns every named, non-abstract Active Record model outside the adapter
111
+ itself. An explicit allowlist is recommended in production.
112
+
113
+ If a table cannot be resolved through Rails naming conventions, replace the
114
+ resolver:
115
+
116
+ ```ruby
117
+ config.model_resolver = ->(table_name) { LegacyModels.fetch(table_name) }
118
+ ```
119
+
120
+ The resolver must return a non-abstract Active Record class.
121
+
122
+ ## Generate Zero TypeScript from Rails Models
123
+
124
+ The frontend schema and generic CRUD mutators do not need to be maintained by
125
+ hand:
126
+
127
+ ```sh
128
+ bin/rails generate zero_rails_adapter:typescript app/javascript/zero
129
+ ```
130
+
131
+ The generator reflects on the models returned by `model_provider` in the Rails
132
+ runtime and writes:
133
+
134
+ - `schema.ts`, containing tables, columns, nullability, primary keys, and
135
+ safely inferred `belongs_to`, `has_one`, and `has_many` relationships.
136
+ - `mutators.ts`, containing `create`, `update`, and `destroy` for every table,
137
+ using Zero's current `defineMutator` / `defineMutators` API and Zod argument
138
+ schemas.
139
+
140
+ Run the generator again after changing Rails migrations or model associations.
141
+ The output can live in Rails' JavaScript directory or be written directly into
142
+ an adjacent Next.js application:
143
+
144
+ ```sh
145
+ bin/rails generate zero_rails_adapter:typescript ../web/src/zero
146
+ ```
147
+
148
+ The default mappings follow Zero's PostgreSQL type conventions:
149
+
150
+ - string/text/uuid → `string()`
151
+ - integer/bigint/float/decimal → `number()`
152
+ - boolean → `boolean()`
153
+ - date/time/datetime/timestamp → `number()`
154
+ - json/jsonb → `json()`
155
+ - Active Record enum → `enumeration<...>()`
156
+
157
+ Nullable columns use `.optional()`. Rails timestamps use `Date.now()` for the
158
+ optimistic client write and remain managed normally by Rails on the server.
159
+ The generator raises a descriptive error for a column that cannot be mapped
160
+ reliably instead of emitting an incorrect type.
161
+
162
+ `generated_attributes` controls which fields appear in generated create and
163
+ update argument schemas:
164
+
165
+ ```ruby
166
+ config.generated_attributes = lambda do |model, action|
167
+ case model.name
168
+ when "User" then %w[id name avatar_url]
169
+ else model.column_names - %w[admin internal_state]
170
+ end
171
+ end
172
+ ```
173
+
174
+ This is a static code-generation policy and must not depend on the current
175
+ user. Runtime permissions must still be enforced through the authorization
176
+ and writable-attribute interfaces below.
177
+
178
+ ## Authentication, Authorization, and Attribute Policies
179
+
180
+ The generated initializer supports `ZERO_MUTATE_API_KEY` and verifies
181
+ zero-cache's `X-Api-Key` header using a constant-time comparison.
182
+
183
+ The authentication interface receives the Rails request. It can integrate with
184
+ Devise/Warden, any JWT library, a session, or an application-specific
185
+ authentication system:
186
+
187
+ ```ruby
188
+ ZeroRailsAdapter.configure do |config|
189
+ config.authenticator = lambda do |request|
190
+ user = request.env["warden"]&.user
191
+ raise ZeroRailsAdapter::UnauthorizedError, "Sign in required" unless user
192
+
193
+ ZeroRailsAdapter::Identity.new(
194
+ user_id: user.id.to_s,
195
+ current_user: user,
196
+ claims: {"role" => user.role}
197
+ )
198
+ end
199
+ end
200
+ ```
201
+
202
+ The global `authorizer` runs first inside every mutation transaction. The CRUD
203
+ authorizer then receives the model class as `target` for create, or the loaded
204
+ record for update and destroy:
205
+
206
+ ```ruby
207
+ config.crud_authorizer = lambda do |context, action, target, attributes|
208
+ MutationPolicy.new(
209
+ context.current_user,
210
+ action,
211
+ target,
212
+ attributes
213
+ ).allowed?
214
+ end
215
+ ```
216
+
217
+ The server-side mass-assignment allowlist can vary by model, action, and
218
+ authenticated identity:
219
+
220
+ ```ruby
221
+ config.writable_attributes = lambda do |model, action, context|
222
+ MutationPolicy.new(context.current_user, action, model).permitted_attributes
223
+ end
224
+ ```
225
+
226
+ The default excludes readonly attributes, the STI inheritance column, and
227
+ Rails timestamps. Attributes outside the allowlist are not assigned. Client
228
+ mutation arguments must never be treated as identity or trusted authorization
229
+ data.
230
+
231
+ Return `false` or raise `ZeroRailsAdapter::ForbiddenError` to reject a
232
+ mutation. Raise `UnauthorizedError` for an authentication failure. A request
233
+ verifier that returns `false` also produces HTTP 401.
234
+
235
+ ## Custom Domain Mutators
236
+
237
+ Use an explicit Mutator only when ordinary model CRUD cannot express the
238
+ operation, such as publishing an article with an audit event, issuing a command
239
+ across multiple aggregates, or invoking an external service:
240
+
241
+ ```sh
242
+ bin/rails generate zero_rails_adapter:mutator articles.publish
243
+ ```
244
+
245
+ ```ruby
246
+ class Articles::PublishMutator < ZeroRailsAdapter::Mutator
247
+ mutation_name "articles.publish"
248
+
249
+ attribute :id, :string
250
+ validates :id, presence: true
251
+
252
+ authorize_with do |context|
253
+ context.current_user.present?
254
+ end
255
+
256
+ def perform
257
+ ArticlePublishing.call(Article.find(id), actor: context.current_user)
258
+ end
259
+ end
260
+ ```
261
+
262
+ An explicitly registered Mutator takes precedence over generic CRUD, so an
263
+ application may intentionally override `articles.create`. The Ruby DSL is also
264
+ available:
265
+
266
+ ```ruby
267
+ ZeroRailsAdapter.define_mutator "projects.archive" do
268
+ attribute :id, :string
269
+
270
+ perform do
271
+ Project.find(id).archive!(actor: context.current_user)
272
+ end
273
+ end
274
+ ```
275
+
276
+ Mutators use Active Model attributes and validations and share a transaction
277
+ with application writes and LMID tracking.
278
+
279
+ ## Mutation Ordering and Transaction Semantics
280
+
281
+ For each `(schema, clientGroupID, clientID)`, the adapter:
282
+
283
+ 1. Locks or creates the Zero client tracking row.
284
+ 2. Rejects a mutation ID above the next expected ID.
285
+ 3. Skips a mutation ID that has already been processed.
286
+ 4. Executes the authorized Active Record operation inside a transaction.
287
+ 5. Updates the LMID atomically.
288
+
289
+ When a business validation or callback fails, the first transaction rolls
290
+ back. A second transaction advances the LMID and stores a structured `app`
291
+ result so that a bad mutation is not retried forever and later mutations can
292
+ continue. If the second transaction also fails, the adapter returns a
293
+ retry-safe `PushFailed` response.
294
+
295
+ Each mutation owns an independent transaction. A later failure in the same
296
+ HTTP batch does not roll back earlier committed mutations.
297
+ `_zero_cleanupResults` removes acknowledged error results without advancing
298
+ the LMID.
299
+
300
+ ## Observability
301
+
302
+ Every non-internal mutation emits an Active Support notification:
303
+
304
+ ```ruby
305
+ ActiveSupport::Notifications.subscribe("mutation.zero_rails_adapter") do |event|
306
+ Rails.logger.info(
307
+ name: event.payload[:name],
308
+ client_id: event.payload[:client_id],
309
+ duration_ms: event.duration
310
+ )
311
+ end
312
+ ```
313
+
314
+ The payload also contains `mutation_id`, `client_group_id`, `app_id`, and
315
+ `schema`.
316
+
317
+ ## Development and Testing
318
+
319
+ ```sh
320
+ bundle install
321
+ bundle exec rake test
322
+ bundle exec gem build zero-rails-adapter.gemspec
323
+ ```
324
+
325
+ The suite covers the mountable Engine endpoint, Zero protocol ordering and
326
+ error semantics, dynamic CRUD dispatch, Active Record validations, callbacks,
327
+ dependent destroy behavior, authorization hooks, TypeScript generation, and a
328
+ PostgreSQL contract test using Zero's exact tracking-table structure.
329
+
330
+ Run the complete contract suite against a dedicated PostgreSQL test database:
331
+
332
+ ```sh
333
+ DATABASE_URL=postgres://localhost/zero_rails_adapter_test bundle exec rake test
334
+ ```
335
+
336
+ See [`examples/nextjs`](examples/nextjs) for a Next.js integration fixture.
337
+
338
+ ## References
339
+
340
+ - [Zero custom mutators](https://zero.rocicorp.dev/docs/mutators)
341
+ - [Zero schema](https://zero.rocicorp.dev/docs/schema)
342
+ - [Zero PostgreSQL support](https://zero.rocicorp.dev/docs/postgres-support)
343
+ - [Zero `process-mutations.ts`](https://github.com/rocicorp/mono/blob/main/packages/zero-server/src/process-mutations.ts)
344
+ - [Server implementation plan](https://jeremykreutzbender.com/blog/server-implementation-plan-rocicorp-zero-custom-mutators)
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZeroRailsAdapter
4
+ class MutationsController < ActionController::API
5
+ def create
6
+ verify_request!
7
+ identity = authenticate!
8
+ zero_request = Request.parse(parsed_body, query: request.query_parameters)
9
+ context = Context.new(identity:, request: zero_request, rack_request: request)
10
+
11
+ render json: Processor.new(request: zero_request, context:).call, status: :ok
12
+ rescue JSON::ParserError => error
13
+ render json: push_failed("parse", "Failed to parse push body: #{error.message}", []),
14
+ status: :ok
15
+ rescue ParseError => error
16
+ subject = error.source == :query ? "push query parameters" : "push body"
17
+ render json: push_failed("parse", "Failed to parse #{subject}: #{error.message}", error.mutation_ids),
18
+ status: :ok
19
+ rescue UnsupportedPushVersionError => error
20
+ render json: push_failed("unsupportedPushVersion", error.message, error.mutation_ids),
21
+ status: :ok
22
+ rescue UnauthorizedError => error
23
+ render json: authentication_error(error), status: :unauthorized
24
+ rescue ForbiddenError => error
25
+ render json: authentication_error(error), status: :forbidden
26
+ rescue StandardError => error
27
+ ZeroRailsAdapter.configuration.logger&.error(
28
+ "ZeroRailsAdapter request failed: #{error.class}: #{error.message}"
29
+ )
30
+ render json: push_failed("internal", error.message, []), status: :internal_server_error
31
+ end
32
+
33
+ private
34
+
35
+ def parsed_body
36
+ JSON.parse(request.raw_post)
37
+ end
38
+
39
+ def verify_request!
40
+ verified = ZeroRailsAdapter.configuration.request_verifier.call(request)
41
+ raise UnauthorizedError, "Zero request verification failed" unless verified
42
+ end
43
+
44
+ def authenticate!
45
+ value = ZeroRailsAdapter.configuration.authenticator.call(request)
46
+ return value if value.is_a?(Identity)
47
+
48
+ attributes = value.respond_to?(:to_h) ? value.to_h.symbolize_keys : {}
49
+ Identity.new(
50
+ user_id: attributes[:user_id],
51
+ current_user: attributes[:current_user],
52
+ claims: attributes[:claims] || {}
53
+ )
54
+ end
55
+
56
+ def push_failed(reason, message, mutation_ids)
57
+ {
58
+ "kind" => "PushFailed",
59
+ "origin" => "server",
60
+ "reason" => reason,
61
+ "message" => message,
62
+ "mutationIDs" => mutation_ids
63
+ }
64
+ end
65
+
66
+ def authentication_error(error)
67
+ {
68
+ "kind" => "Unauthorized",
69
+ "origin" => "server",
70
+ "message" => error.message
71
+ }
72
+ end
73
+ end
74
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ ZeroRailsAdapter::Engine.routes.draw do
4
+ post "/", to: "mutations#create"
5
+ post "/mutate", to: "mutations#create"
6
+ post "/push", to: "mutations#create"
7
+ end
@@ -0,0 +1,29 @@
1
+ # Next.js integration fixture
2
+
3
+ This fixture shows the generated client half of Rails-backed generic CRUD.
4
+ Install the current `@rocicorp/zero` and `zod` releases in a Next.js app, or
5
+ generate equivalent files directly from the Rails models:
6
+
7
+ ```sh
8
+ bin/rails generate zero_rails_adapter:typescript ../web/src/zero
9
+ ```
10
+
11
+ Register the exported `schema` and `mutators` with the app's Zero client.
12
+
13
+ Configure:
14
+
15
+ ```env
16
+ NEXT_PUBLIC_ZERO_CACHE_URL=http://localhost:4848
17
+ NEXT_PUBLIC_ZERO_MUTATE_URL=http://localhost:3000/zero/mutate
18
+ ```
19
+
20
+ Configure zero-cache with the same Rails URL:
21
+
22
+ ```sh
23
+ ZERO_MUTATE_URL=http://localhost:3000/zero/mutate
24
+ ZERO_MUTATE_API_KEY=development-secret
25
+ ```
26
+
27
+ The Rails application must mount the Engine at `/zero`, use the generated API
28
+ key verifier, and expose `Book` through `config.model_provider`. No Ruby
29
+ mutator class is needed for `books.create/update/destroy`.
@@ -0,0 +1,37 @@
1
+ import {defineMutator, defineMutators} from '@rocicorp/zero'
2
+ import {z} from 'zod'
3
+
4
+ export const mutators = defineMutators({
5
+ books: {
6
+ create: defineMutator(
7
+ z.object({
8
+ id: z.string(),
9
+ title: z.string().min(1).max(200),
10
+ owner_id: z.string(),
11
+ }),
12
+ async ({tx, args}) => {
13
+ const now = Date.now()
14
+ await tx.mutate.books.insert({
15
+ ...args,
16
+ created_at: now,
17
+ updated_at: now,
18
+ })
19
+ },
20
+ ),
21
+ update: defineMutator(
22
+ z.object({
23
+ id: z.string(),
24
+ title: z.string().min(1).max(200).optional(),
25
+ }),
26
+ async ({tx, args}) => {
27
+ await tx.mutate.books.update({...args, updated_at: Date.now()})
28
+ },
29
+ ),
30
+ destroy: defineMutator(
31
+ z.object({id: z.string()}),
32
+ async ({tx, args}) => {
33
+ await tx.mutate.books.delete(args)
34
+ },
35
+ ),
36
+ },
37
+ })
@@ -0,0 +1,20 @@
1
+ import {createSchema, number, string, table} from '@rocicorp/zero'
2
+
3
+ const books = table('books')
4
+ .columns({
5
+ id: string(),
6
+ title: string(),
7
+ owner_id: string(),
8
+ created_at: number(),
9
+ updated_at: number(),
10
+ })
11
+ .primaryKey('id')
12
+
13
+ export const schema = createSchema({tables: [books]})
14
+ export type Schema = typeof schema
15
+
16
+ declare module '@rocicorp/zero' {
17
+ interface DefaultTypes {
18
+ schema: Schema
19
+ }
20
+ }
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module ZeroRailsAdapter
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ def copy_initializer
11
+ template "initializer.rb", "config/initializers/zero_rails_adapter.rb"
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ ZeroRailsAdapter.configure do |config|
4
+ # zero-cache owns the schema.clients and schema.mutations tracking tables,
5
+ # so this gem does not install a migration for them.
6
+
7
+ # Verify that calls came from your zero-cache instance.
8
+ # Configure zero-cache with ZERO_MUTATE_API_KEY using the same value.
9
+ if ENV["ZERO_MUTATE_API_KEY"].present?
10
+ config.request_verifier = ZeroRailsAdapter::RequestVerifiers::ApiKey.new(
11
+ key: ENV.fetch("ZERO_MUTATE_API_KEY")
12
+ )
13
+ end
14
+
15
+ # Return an Identity from any authentication system (Devise, JWT, cookies, etc.).
16
+ # config.authenticator = lambda do |request|
17
+ # user = YourAuthentication.call(request)
18
+ # ZeroRailsAdapter::Identity.new(
19
+ # user_id: user&.id&.to_s,
20
+ # current_user: user,
21
+ # claims: {}
22
+ # )
23
+ # end
24
+
25
+ # This runs once per mutation, inside its database transaction.
26
+ # Raise ZeroRailsAdapter::ForbiddenError to reject a mutation.
27
+ # config.authorizer = ->(context, mutation) { YourPolicy.authorize!(context, mutation) }
28
+
29
+ # Generic CRUD resolves "articles.create", "articles.update", and
30
+ # "articles.destroy" against this allowlist. The default is every named,
31
+ # non-abstract Active Record model; an explicit list is recommended.
32
+ # config.model_provider = -> { [Article, Comment] }
33
+
34
+ # Authorize generic CRUD independently of Devise/JWT/Pundit/etc. target is
35
+ # the model class for create, and the loaded record for update/destroy.
36
+ # config.crud_authorizer = lambda do |context, action, target, attributes|
37
+ # ApplicationPolicy.new(context.current_user, target).public_send("#{action}?")
38
+ # end
39
+
40
+ # Server-side mass-assignment policy. It is always enforced after auth.
41
+ # config.writable_attributes = lambda do |model, action, context|
42
+ # ModelMutationPolicy.new(context.current_user, model, action).attributes
43
+ # end
44
+
45
+ # Static field allowlist used by the TypeScript generator. Keep
46
+ # user-dependent decisions in writable_attributes/crud_authorizer.
47
+ # config.generated_attributes = ->(model, action) { model.column_names - %w[admin] }
48
+
49
+ # For Rails multi-database apps, use an abstract base class connected to the
50
+ # same PostgreSQL database as zero-cache's upstream schema.
51
+ # config.transaction_class = PrimaryApplicationRecord
52
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module ZeroRailsAdapter
6
+ module Generators
7
+ class MutatorGenerator < Rails::Generators::Base
8
+ argument :name, type: :string, banner: "namespace.action"
9
+
10
+ source_root File.expand_path("templates", __dir__)
11
+
12
+ def create_mutator
13
+ template "mutator.rb", File.join("app/mutators", "#{file_path}_mutator.rb")
14
+ end
15
+
16
+ private
17
+
18
+ def name_parts
19
+ @name_parts ||= name.split(/[|.]/)
20
+ end
21
+
22
+ def class_name
23
+ "#{name_parts.map(&:camelize).join('::')}Mutator"
24
+ end
25
+
26
+ def file_path
27
+ name_parts.map(&:underscore).join("/")
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= class_name %> < ZeroRailsAdapter::Mutator
4
+ mutation_name "<%= name %>"
5
+
6
+ # Arguments are Active Model attributes and support normal Rails validations.
7
+ # attribute :title, :string
8
+ # validates :title, presence: true
9
+
10
+ def perform
11
+ # Use context.current_user, context.user_id, and context.claims for authorization.
12
+ raise NotImplementedError
13
+ end
14
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "zero_rails_adapter"
5
+
6
+ module ZeroRailsAdapter
7
+ module Generators
8
+ class TypeScriptGenerator < Rails::Generators::Base
9
+ argument :destination, type: :string, default: "app/javascript/zero"
10
+
11
+ def generate_typescript
12
+ generator = ZeroRailsAdapter::TypeScript::Generator.new
13
+ create_file File.join(destination, "schema.ts"), generator.schema
14
+ create_file File.join(destination, "mutators.ts"), generator.mutators
15
+ end
16
+ end
17
+ end
18
+ end