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/zero and zero-cache from 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 inferred belongs_to, has_one, and has_many relationships.
  • mutators.ts, containing create, update, and destroy only for models returned by crud_model_provider, using Zero's current defineMutator / defineMutators API 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_verifier returns false.
  • authenticator raises UnauthorizedError.
  • The global authorizer returns false.
  • The generic CRUD crud_authorizer returns false.

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.authorizer = lambda do |context, mutation|
  MutationPolicy.allowed?(context.current_user, mutation.name)
end

config.crud_authorizer = 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

  authorize_with 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

  authorize_with 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:

  1. Locks or creates the Zero client tracking row.
  2. Rejects a mutation ID above the next expected ID.
  3. Skips a mutation ID that has already been processed.
  4. Executes the authorized Active Record operation inside a transaction.
  5. 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.

References