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+userIDecho) is not parseable byzero-cache1.4 and earlier. Deployzero-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.clientstable - 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
end2. 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
end3. 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 abefore_action) and populatecurrent_userfrom the verified subject. Do not trustuserIDderived from the request body — the gem only echoes what your controller passes viacontext. -
Cookie: …— forwarded ifZERO_MUTATE_FORWARD_COOKIES=true. Use your normal Rails session. -
X-Api-Key: <secret>— set if you configuredZERO_MUTATE_API_KEYon 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_user→userIDomitted. zero-cache falls back to the client-claimed identity (safe legacy behavior). -
context[:current_user]with an id, orcontext[:user_id] = "abc"→userID: "abc". zero-cache pins the client group to that identity and rejects mismatched connections withUnauthorized. -
contextcontaining an explicituser_id: nilkey →userID: null, a server-validated "logged out" assertion. Only passuser_id: nilwhen 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
endConfiguration
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
endTypeScript type generation
ZeroRuby generates TypeScript type definitions from your Ruby mutations. GET requests to your mutate endpoint return the types.
Setup
- Set
ZERO_TYPES_URLenv 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 mutatorsTypes
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.7 → 3
|
"abc", ""
|
Float |
3.14, "3.14", 42 → 42.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
endWith 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