Project

zero_ruby

0.0
The project is in a healthy, maintained state
Handle Zero mutations
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 2.5
~> 0.6
~> 13.3
~> 3.13
~> 1.51

Runtime

 Project Readme

zero_ruby

A Ruby gem for handling Zero mutations with type safety, validation, and full protocol support. Compatible with Zero 1.5 – 1.8+ (verified against the zero/v1.8.0 reference implementation; the push protocol is unchanged through Zero 1.8).

Rollout note: the 1.5 response shape (MutateResponse + userID echo) is not parseable by zero-cache 1.4 and earlier. Deploy zero-cache@1.5+ before upgrading this gem.

Features

  • Type coercion & validation - Built on dry-types with String, Integer, Float, Boolean, ID, ISO8601Date, ISO8601DateTime
  • Type generation - Generates TypeScript types for your frontend mutators
  • LMID tracking - Duplicate and out-of-order mutation detection using Zero's zero_0.clients table
  • Push protocol - Version validation, transaction wrapping, retry logic

Installation

Add to your Gemfile:

gem 'zero_ruby'

Usage

1. Define mutations

By default, the entire execute method runs inside a transaction with LMID (Last Mutation ID) tracking. Use skip_auto_transaction to manually control what runs inside the LMID transaction.

# app/zero/mutations/post_update.rb
module Mutations
  class PostUpdate < ApplicationMutation
    argument :id, Types::ID
    argument :post_input, Types::PostInput

    def execute(id:, post_input:)
      post = current_user.posts.find(id)
      authorize! post, to: :update?
      post.update!(**post_input)
    end
  end
end

2. Register mutations in schema

# app/zero/app_schema.rb
# The mutation names should match the names used in your Zero client:
#   mutators.posts.update({ id: "...", post_input: { title: "..." } })
#   -> maps to "posts.update"
class ZeroSchema < ZeroRuby::Schema
  mutation "posts.update", handler: Mutations::PostUpdate
end

3. Add zero_controller and route

# app/controllers/zero_controller.rb
class ZeroController < ApplicationController
  # Skip CSRF for API endpoint
  # skip_before_action :verify_authenticity_token

  def mutate
    if request.get?
      # GET requests return TypeScript type definitions
      render plain: ZeroSchema.to_typescript, content_type: "text/plain; charset=utf-8"
    else
      # POST requests process mutations
      body = JSON.parse(request.body.read)

      # Build context hash. The gem reads three special keys:
      #   :current_user      - used to derive userID echoed in MutateResponse
      #   :user_id           - takes precedence over current_user when set
      #   :upstream_schema   - Postgres schema owned by zero-cache (?schema= param)
      # Everything else passes through verbatim as ctx[...] for your mutations.
      context = {
        current_user: current_user,
        upstream_schema: params[:schema]
      }

      result = ZeroSchema.execute(body, context: context)
      render json: result
    end
  rescue JSON::ParserError => e
    render json: {
      kind: "PushFailed",
      origin: "server",
      reason: "parse",
      message: "Invalid JSON: #{e.message}",
      mutationIDs: []
    }, status: :bad_request
  end
end
# config/routes.rb
# The path is up to you - point ZERO_MUTATE_URL at it.
match '/zero/mutate', to: 'zero#mutate', via: [:get, :post]

Authenticating zero-cache requests

zero-cache sends three classes of credentials to your endpoint:

  • Authorization: Bearer <token> — the client's auth token, if any. Verify it in your controller (or via a before_action) and populate current_user from the verified subject. Do not trust userID derived from the request body — the gem only echoes what your controller passes via context.
  • Cookie: … — forwarded if ZERO_MUTATE_FORWARD_COOKIES=true. Use your normal Rails session.
  • X-Api-Key: <secret> — set if you configured ZERO_MUTATE_API_KEY on zero-cache. Validate it (constant-time compare) to ensure the request really came from your zero-cache.

Query params: zero-cache appends ?schema=<upstream_schema>&appID=<appID> on every request. The schema value goes into context[:upstream_schema] so the gem can locate the correct <schema>.clients / <schema>.mutations tables.

userID echo semantics

The userID field of a MutateResponse drives zero-cache's authenticated client groups (Zero 1.5+):

  • Context without :user_id / resolvable :current_useruserID omitted. zero-cache falls back to the client-claimed identity (safe legacy behavior).
  • context[:current_user] with an id, or context[:user_id] = "abc"userID: "abc". zero-cache pins the client group to that identity and rejects mismatched connections with Unauthorized.
  • context containing an explicit user_id: nil key → userID: null, a server-validated "logged out" assertion. Only pass user_id: nil when you have positively verified the request is unauthenticated — zero-cache will terminate connections whose clients claim a user.

HTTP status codes

Always respond 200 OK with the returned body — including PushFailed bodies; zero-cache parses the failure out of the JSON. Reserve 401/403 for authentication failures (bad Authorization token / X-Api-Key): a non-OK status makes zero-cache treat the push as an HTTP error and retry or surface it, and auth failures invalidate the client connection so it can re-authenticate.

Define custom input types (optional)

# app/zero/types/post_input.rb
module Types
  class PostInput < Types::BaseInputObject
    argument :title, Types::String.constrained(min_size: 1, max_size: 200)
    argument :body, Types::String.optional
    argument :published, Types::Boolean.default(false)
  end
end

Configuration

Create an initializer to customize settings:

# config/initializers/zero_ruby.rb
ZeroRuby.configure do |config|
  # Storage backend (:active_record is the only built-in option)
  config.lmid_store = :active_record

  # Retry attempts for transient errors (default: 1)
  config.max_retry_attempts = 1

  # Push protocol version (reject requests with different version)
  config.supported_push_version = 1
end

TypeScript type generation

ZeroRuby generates TypeScript type definitions from your Ruby mutations. GET requests to your mutate endpoint return the types.

Setup

  • Set ZERO_TYPES_URL env var to your host's mutate endpoint, e.g. http://example.com/zero/mutate
  • npm install ts-to-zod --save-dev
  • Add the following script to generate types and zod schemas
{
  "scripts": {
    "zero:types": "mkdir -p lib/zero/__generated__ && curl -s $ZERO_TYPES_URL > lib/zero/__generated__/zero-types.ts && npx ts-to-zod lib/zero/__generated__/zero-types.ts lib/zero/__generated__/zero-schemas.ts"
  }
}

Use with Zero Mutators

import { defineMutator, defineMutators } from '@rocicorp/zero'
import {
  postsCreateArgsSchema,
  postsUpdateArgsSchema,
} from './zero/__generated__/zero-schemas'

export const mutators = defineMutators({
  posts: {
    update: defineMutator(postsUpdateArgsSchema, async ({ tx, args }) => {
      await tx.mutate.posts.update({
        id: args.id,
        title: args.postInput.title,
        updatedAt: Date.now(),
      })
    }),
  },
})

export type Mutators = typeof mutators

Types

ZeroRuby provides types built on dry-types. When you inherit from ZeroRuby::Mutation or ZeroRuby::InputObject, types are available via the Types module:

# Basic types
argument :name, Types::String
argument :count, Types::Integer
argument :price, Types::Float
argument :active, Types::Boolean
argument :id, Types::ID               # Non-empty string
argument :date, Types::ISO8601Date
argument :timestamp, Types::ISO8601DateTime

# Optional types (accepts nil)
argument :nickname, Types::String.optional

# Default values
argument :status, Types::String.default("draft")
argument :enabled, Types::Boolean.default(false)

Validation with Constraints

Use dry-types constraints for validation:

# Length constraints
argument :title, Types::String.constrained(min_size: 1, max_size: 200)
argument :code, Types::String.constrained(size: 6)  # Exact size

# Numeric constraints
argument :age, Types::Integer.constrained(gt: 0, lt: 150)
argument :quantity, Types::Integer.constrained(gteq: 1, lteq: 100)

# Format (regex)
argument :slug, Types::String.constrained(format: /\A[a-z0-9-]+\z/)

# Inclusion
argument :status, Types::String.constrained(included_in: %w[draft published archived])

# Exclusion
argument :username, Types::String.constrained(excluded_from: %w[admin root system])

# Non-empty (filled)
argument :email, Types::String.constrained(filled: true)

# Combine constraints
argument :name, Types::String.constrained(min_size: 1, max_size: 100, format: /\A[a-zA-Z ]+\z/)

Available Constraints

Constraint Description Example
min_size Minimum length min_size: 1
max_size Maximum length max_size: 200
size Exact length size: 6
gt Greater than gt: 0
gteq Greater than or equal gteq: 1
lt Less than lt: 100
lteq Less than or equal lteq: 99
format Regex pattern format: /\A\d+\z/
included_in Value must be in list included_in: %w[a b c]
excluded_from Value must not be in list excluded_from: %w[x y]
filled Non-empty string filled: true

Type coercion

Types automatically coerce compatible values:

Type Accepts Rejects
String "hello" nil
Integer 42, "42", 3.73 "abc", ""
Float 3.14, "3.14", 4242.0 "abc", ""
Boolean true, false, "true", "false" "yes", 1, 0
ID "abc" "" (empty string)
ISO8601Date "2025-01-15"Date "invalid", ""
ISO8601DateTime "2025-01-15T10:30:00Z"DateTime "invalid", ""

Manual transaction control

By default, the entire execute method runs inside a transaction in order to atomically commit database changes with the LMID update. Use skip_auto_transaction when you need to run code before or after the transaction:

class PostUpdate < ApplicationMutation
  skip_auto_transaction

  argument :id, Types::ID
  argument :post_input, Types::PostInput

  def execute(id:, post_input:)
    # 1. Pre-transaction (LMID incremented on error)
    post = current_user.posts.find(id)
    authorize! post, to: :update?

    # 2. Transaction (Transaction rolled back, LMID incremented on error)
    transact do
      post.update!(**post_input)
    end

    # 3. Post-commit - only runs if transact succeeded
    NotificationService.notify_update(id)
  end
end

With skip_auto_transaction, you must call transact { } or TransactNotCalledError is raised.

LMID behavior by phase

Phase On Error
Pre-transaction LMID advanced in separate transaction
Transaction LMID advanced in separate transaction (original tx rolled back)
Post-commit LMID already committed with transaction

If LMID advancement itself fails (DB connection drop mid-batch, etc.), the entire push aborts with PushFailed{reason: "database"} and the remaining mutation IDs are returned as unprocessed. This matches the TS reference behavior and keeps the client and server LMIDs in sync.

Structured application errors

Throw ZeroRuby::Error.new(message, details: {...}) from a mutation to surface structured metadata to the client. The error is serialized into the mutation response as:

{ id: {...}, result: { error: "app", message: "...", details: {...} } }

This is equivalent to throwing ApplicationError in the TypeScript reference. The details value must be JSON-serializable.

class PostCreate < ApplicationMutation
  argument :title, Types::String

  def execute(title:)
    if RateLimiter.exceeded?(current_user)
      raise ZeroRuby::Error.new(
        "Rate limit exceeded",
        details: {code: "RATE_LIMITED", retryAfter: 60}
      )
    end
    Post.create!(title: title, user: current_user)
  end
end

References