Zero Rails Adapter
zero-rails-adapter is a mountable Rails Engine that maps Rocicorp Zero custom
mutations directly to existing Active Record models.
See the changelog for release notes and upgrade instructions.
The default CRUD path does not require a Ruby Mutator class for every model:
| Zero mutation | Rails call |
|---|---|
articles.create / articles.insert |
Article.create!(attributes) |
articles.update |
Article.find(...).update!(attributes) |
articles.destroy / articles.delete |
Article.find(...).destroy! |
Existing Active Record validations, callbacks, associations, after_commit
jobs, database constraints, and transaction behavior therefore work without
adapter-specific wrappers. Explicit Mutators are reserved for non-CRUD domain
commands, aggregate operations, and other custom behavior.
The gem does not depend on Devise, a JWT implementation, Pundit, or another authorization framework. Authentication, authorization, model exposure, and mass-assignment policies are all configurable callable interfaces.
Requirements
- Ruby 4.0 or newer
- Rails 8.0 or newer
- PostgreSQL, the upstream database supported by Zero
@rocicorp/zeroandzero-cachefrom the same supported release
The Zero-managed schema.clients and schema.mutations tables and the
application tables must use the same database connection. In a Rails
multi-database application, configure transaction_class with the abstract
Active Record base class connected to that PostgreSQL database.
Installation
Add the gem:
# Gemfile
gem "zero-rails-adapter"
Install the bundle and initializer:
bundle install
bin/rails generate zero_rails_adapter:install
The generated initializer reads ZERO_MUTATE_API_KEY with ENV.fetch, so a
missing key fails during application boot instead of disabling request
verification.
Mount the Engine:
# config/routes.rb
Rails.application.routes.draw do
mount ZeroRailsAdapter::Engine => "/zero"
end
Configure zero-cache:
ZERO_MUTATE_URL="https://api.example.com/zero/mutate"
ZERO_MUTATE_API_KEY="replace-with-a-long-random-secret"
The Engine accepts POST /mutate, POST /push, and POST / beneath its mount
point. /mutate is the recommended endpoint.
The gem intentionally installs no tracking-table migration. zero-cache creates
the clients and mutations tables in its shard schema, such as zero_0.
The adapter uses Zero's validated schema parameter to build schema-qualified
Active Record classes for those tables, then writes application data and the
LMID in the same transaction.
Explicit Publication Schema
The adapter fails closed. No Active Record model or column is published, added to generated TypeScript, or exposed to generic CRUD by default.
Declare both the tables and columns that may be replicated:
ZeroRailsAdapter.configure do |config|
config.published_schema = lambda do
{
Article => %w[id title body author_id created_at updated_at],
Comment => %w[id article_id body author_id created_at updated_at]
}
end
end
The callable is evaluated when code is generated, so it remains safe across
Rails development reloads. Primary key columns must be present. Unknown
columns, known credential columns such as password_hash and token_digest,
Active Storage and Action Mailbox internal tables, and PostgreSQL types that
Zero cannot replicate are rejected.
By default, a model's Zero key is its Active Record primary key. Applications may use a separate stable synchronization key:
config.zero_key = lambda do |model|
model == Article ? "sync_id" : model.primary_key
end
A separate Zero key must be included in published_schema and backed by an
exact unique, non-null database index. Composite Zero keys may be returned as
an array. The database primary key must still be published because PostgreSQL
uses it as the table's replica identity; it is not silently replaced by the
Zero key.
Generate a reviewable, column-limited PostgreSQL publication:
bin/rails generate zero_rails_adapter:publication zero_data
This writes db/zero_publication.sql; it does not execute DDL automatically.
Apply the SQL through the application's normal migration or operations process,
then configure:
ZERO_APP_PUBLICATIONS=zero_data
Generic Active Record CRUD
Generic CRUD is a separate, opt-in capability. Publishing a model never makes it writable.
This client-side mutation:
await mutators.articles.create({
id: crypto.randomUUID(),
title: 'Rails and Zero',
})
reaches the Engine as articles.create. The adapter resolves Article from
the table name, applies the writable-attribute policy, and calls:
Article.create!(id: "...", title: "Rails and Zero")
For update and destroy, the adapter first loads the record using the
configured Zero key, including composite keys, and then calls update! or
destroy!. Neither the Zero key nor the Active Record primary key can be
changed by generic update. The bang methods are intentional: a validation,
callback, or database-constraint failure rolls back the complete business
transaction and produces a structured Zero application error.
Configure the models exposed to generic CRUD explicitly:
ZeroRailsAdapter.configure do |config|
config.crud_model_provider = -> { [Article, Comment] }
end
Models absent from crud_model_provider cannot be resolved by generic CRUD.
The default provider returns an empty list, and the default crud_authorizer
also rejects every operation. Applications must opt into both model resolution
and authorization. Applications with aggregate operations, tenant-scoped
commands, soft deletion, or other domain behavior should leave the provider
empty and use custom mutators.
If a table cannot be resolved through Rails naming conventions, replace the resolver:
config.model_resolver = ->(table_name) { LegacyModels.fetch(table_name) }
The resolver must return a non-abstract Active Record class.
Generate Zero TypeScript from Rails Models
The frontend schema and generic CRUD mutators do not need to be maintained by hand:
bin/rails generate zero_rails_adapter:typescript app/javascript/zero
The generator reflects on published_schema in the Rails runtime and writes:
schema.ts, containing tables, columns, nullability, Zero keys, and safely inferredbelongs_to,has_one, andhas_manyrelationships.mutators.ts, containingcreate,update, anddestroyonly for models returned bycrud_model_provider, using Zero's currentdefineMutator/defineMutatorsAPI and Zod argument schemas.
Run the generator again after changing Rails migrations or model associations. The output can live in Rails' JavaScript directory or be written directly into an adjacent Next.js application:
bin/rails generate zero_rails_adapter:typescript ../web/src/zero
Rails cannot safely infer every Zero relationship. Add delegated-type, polymorphic, custom, or through relationships explicitly:
config.relationship_provider = lambda do
[
{
source: Recording,
name: :task,
kind: :one,
source_fields: %w[recordable_id],
destination: Task,
destination_fields: %w[id]
},
{
source: Article,
name: :labels,
kind: :many,
through: [
{
source_fields: %w[id],
destination: ArticleLabel,
destination_fields: %w[article_id]
},
{
source_fields: %w[label_id],
destination: Label,
destination_fields: %w[id]
}
]
}
]
end
Every model and field in a manual relationship must be published. Zero supports at most two relationship hops. A polymorphic or delegated-type relationship also needs an application invariant or a published discriminator or safe mirror column that prevents IDs belonging to another type from matching; the adapter does not guess that predicate. A manual definition with the same source and name replaces an inferred relationship.
The default mappings follow Zero's PostgreSQL type conventions:
- string/text/uuid →
string() - integer/bigint/float/decimal →
number() - boolean →
boolean() - date/time/datetime/timestamp →
number() - json/jsonb →
json() - integer-backed Active Record enum →
number() - string-backed Active Record enum →
string() - PostgreSQL native enum →
enumeration<...>()
Nullable columns use .optional(). Rails timestamps use Date.now() for the
optimistic client write and are Unix epoch milliseconds in Zero. They remain
managed normally by Rails on the server.
The generator raises a descriptive error for a column that cannot be mapped
reliably instead of emitting an incorrect type.
generated_attributes controls which fields appear in generated create and
update argument schemas:
config.generated_attributes = lambda do |model, action|
case model.name
when "User" then %w[id name avatar_url]
else model.column_names - %w[admin internal_state]
end
end
This is a static code-generation policy and must not depend on the current user. Runtime permissions must still be enforced through the authorization and writable-attribute interfaces below.
Authentication, Authorization, and Attribute Policies
The generated initializer supports ZERO_MUTATE_API_KEY and verifies
zero-cache's X-Api-Key header using a constant-time comparison.
All mutation security gates fail closed by default:
request_verifierreturnsfalse.authenticatorraisesUnauthorizedError.- The global
authorizerreturnsfalse. - The generic CRUD
crud_authorizerreturnsfalse.
Applications must configure each gate they use explicitly. An application that
intentionally permits anonymous identities must still install an authenticator
that returns ZeroRailsAdapter::Identity.new.
The authentication interface receives the Rails request. It can integrate with Devise/Warden, any JWT library, a session, or an application-specific authentication system:
ZeroRailsAdapter.configure do |config|
config.authenticator = lambda do |request|
user = request.env["warden"]&.user
raise ZeroRailsAdapter::UnauthorizedError, "Sign in required" unless user
ZeroRailsAdapter::Identity.new(
user_id: user.id.to_s,
current_user: user,
claims: {"role" => user.role}
)
end
end
The global authorizer runs first inside every mutation transaction. The CRUD
authorizer then receives the model class as target for create, or the loaded
record for update and destroy:
config. = lambda do |context, mutation|
MutationPolicy.allowed?(context.current_user, mutation.name)
end
config. = lambda do |context, action, target, attributes|
MutationPolicy.new(
context.current_user,
action,
target,
attributes
).allowed?
end
The server-side mass-assignment allowlist can vary by model, action, and authenticated identity:
config.writable_attributes = lambda do |model, action, context|
MutationPolicy.new(context.current_user, action, model).permitted_attributes
end
The default excludes readonly attributes, the STI inheritance column, and Rails timestamps. Attributes outside the allowlist are not assigned. Client mutation arguments must never be treated as identity or trusted authorization data.
Return false or raise ZeroRailsAdapter::ForbiddenError to reject a
mutation. Raise UnauthorizedError for an authentication failure. A request
verifier that returns false also produces HTTP 401.
Custom Domain Mutators
Use an explicit Mutator only when ordinary model CRUD cannot express the operation, such as publishing an article with an audit event, issuing a command across multiple aggregates, or invoking an external service:
bin/rails generate zero_rails_adapter:mutator articles.publish
class Articles::PublishMutator < ZeroRailsAdapter::Mutator
mutation_name "articles.publish"
attribute :id, :string
validates :id, presence: true
do |context|
context.current_user.present?
end
def perform
ArticlePublishing.call(Article.find(id), actor: context.current_user)
end
end
An explicitly registered Mutator takes precedence over generic CRUD, so an
application may intentionally override articles.create. The Ruby DSL is also
available:
ZeroRailsAdapter.define_mutator "projects.archive" do
attribute :id, :string
do |context|
context.current_user.present?
end
perform do
Project.find(id).archive!(actor: context.current_user)
end
end
Mutators use Active Model attributes and validations and share a transaction
with application writes and LMID tracking. A custom mutator without an
authorize_with callback is rejected. To rely intentionally on the global
authorizer alone, the mutator must still declare authorize_with { true }.
Mutation Ordering and Transaction Semantics
For each (schema, clientGroupID, clientID), the adapter:
- Locks or creates the Zero client tracking row.
- Rejects a mutation ID above the next expected ID.
- Skips a mutation ID that has already been processed.
- Executes the authorized Active Record operation inside a transaction.
- Updates the LMID atomically.
When an explicit ApplicationError, argument validation error, or supported
Active Model/Active Record validation or lifecycle error occurs, the first
transaction rolls back. A second transaction advances the LMID and stores a
structured app result so that a bad mutation is not retried forever and later
mutations can continue.
Database failures and unexpected Ruby exceptions do not advance LMID or store
a mutation result. They return a retry-safe PushFailed, so the same mutation
ID can be retried after the underlying problem is fixed. Public database and
internal failure messages are fixed and sanitized; the configured server
logger receives the original exception and backtrace. If persisting an
application failure also fails, the adapter likewise returns PushFailed
without advancing LMID.
Each mutation owns an independent transaction. A later failure in the same
HTTP batch does not roll back earlier committed mutations.
_zero_cleanupResults removes acknowledged error results without advancing
the LMID.
Observability
Every non-internal mutation emits an Active Support notification:
ActiveSupport::Notifications.subscribe("mutation.zero_rails_adapter") do |event|
Rails.logger.info(
name: event.payload[:name],
client_id: event.payload[:client_id],
duration_ms: event.duration
)
end
The payload also contains mutation_id, client_group_id, app_id, and
schema.
Development and Testing
bundle install
bundle exec rake test
bundle exec gem build zero-rails-adapter.gemspec
The suite covers the mountable Engine endpoint, Zero protocol ordering and error semantics, dynamic CRUD dispatch, Active Record validations, callbacks, dependent destroy behavior, authorization hooks, TypeScript generation, and a PostgreSQL contract test using Zero's exact tracking-table structure.
Run the complete contract suite against a dedicated PostgreSQL test database:
DATABASE_URL=postgres://localhost/zero_rails_adapter_test bundle exec rake test
The repository also locks @rocicorp/zero and the zero-cache CLI to the
exact same 1.8.0 package in test/contract/package-lock.json. Run the full
wire and replication contract with Node 24+ and Docker:
bundle exec rake contract
That task compiles the generated TypeScript, starts PostgreSQL with logical
replication, starts the real zero-cache and Rails mutation endpoint, then uses
a Zero client to mutate and query replicated rows. The generated fixture uses
UUID primary and foreign keys and includes a separate Zero key and a two-hop
relationship. The task also verifies request verification, authentication,
global and mutator authorization, sanitized retry-safe failures, same-ID
replay, LMID advancement, duplicate-mutation handling, failure skipping, and
_zero_cleanupResults. The contract runs as its own CI job.
See examples/nextjs for a Next.js integration fixture.