Setting up Rocicorp's Zero with Ruby on Rails

Published on 2025-02-04

In December 2024, Rocicorp opened sourced their new project Zero. I had been patiently waiting for this to happen and dove right into adding it to a project I had recently started working on. If you've used tools like Linear or Superhuman, they are powered sync engines, which in layman's terms means that data to be displayed in the UI is synced from the database to a client datastore in the browser. In doing so, the UI becomes extremely fast because there is no latency in waiting for API calls to fetch data as you navigate from page to page. There are more than a handful of solutions we as application builders can choose from in this space and if you want to know more about those, I suggest taking a look at The Spectrum of Local First Libraries. However, we're here to talk about Zero and specifically using Ruby on Rails to manage our Zero setup.

One thing to note is that Zero is a library that excels in the Single Page Application space. There are currently two integrations written by Rocicorp, React and Solid, as well as two community implementations for Svelte and Vue. I've been using the React implementation with a Next.js UI and using Rails as a backend to manage the databases and other business logic. I'm not currently familiar enough with vite-ruby to know if Zero would work with that setup, but I may try to explore this route in the future.

With all that said, there won't be a lot of UI code here as there are examples on the Zero Samples documentation page, but there will be a few places where I sprinkle in a bit to tie together why we're setting things up the way we are in Rails.

Databases Setup#

Yes that is plural databases, Zero requires three databases to operate. If you haven't looked at the Rails guide for Multiple Databases with Active Record before I would recommend familiarizing yourself with it before continuing to read here. It covers everything we need to know about setting up the multiple databases needed and then some. The first is the primary database where our Rails migrations and ActiveRecord Models will operate, however Zero refers to it as the upstream database. The second is the Change View Records (CVR) database which contains information about what has been synced to clients. The third and final is the Change Database (CDB) which stores the replication log from the primary database. All we need to do is create the CVR and CDB databases and Zero will handle the rest, but we'll cover a bit more information about them.

One final note is that Zero is only capable of working with PostgreSQL at the moment. The Zero Connecting to Postgres documentation notes this fact.

In the future, Zero will work with many different backend databases. Today only Postgres is supported. Specifically, Zero requires Postgres v15.0 or higher, and support for logical replication.

For now, let's get started on creating the databases.

Primary Database

The following database.yml is very similar to the default that is generated by Rails for use with PostgreSQL.

database.yml
default: &default
adapter: postgresql
encoding: unicode
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
host: <%= ENV.fetch('DATABASE_HOST') { "localhost" } %>
username: <%= ENV.fetch('DATABASE_USER') { "localuser" } %>
password: <%= ENV.fetch('DATABASE_PASSWORD') { "localpw" } %>

development:
primary:
<<: *default
database: blog_application
default: &default
adapter: postgresql
encoding: unicode
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
host: <%= ENV.fetch('DATABASE_HOST') { "localhost" } %>
username: <%= ENV.fetch('DATABASE_USER') { "localuser" } %>
password: <%= ENV.fetch('DATABASE_PASSWORD') { "localpw" } %>

development:
primary:
<<: *default
database: blog_application

I have changed the default username and password variables and named the application blog_application. The main change here is that we've added the primary key as a name for this database. If you recall from the Multiple Databases documention, it noted that primary is a special key.

If a primary configuration key is provided, it will be used as the "default" configuration. If there is no configuration named primary, Rails will use the first configuration as default for each environment.

We'll interact with this database in the same manner as we have with any other Rails application. We're not going to cover creating any Models here, but we would create migrations the same way we always have and they will apply to this primary database.

CVR Database

As mentioned before, the CVR database holds information about what data has been synced to the clients. We're not going to get into how all that works because it goes over my head. At the end of this section, we'll take a look at the tables that have been created.

database.yml
# This is nested as sibling to 'primary'
zero_cvr:
<<: *default
database: blog_application_cvr
migrations_paths: db/cvr_migrate
# This is nested as sibling to 'primary'
zero_cvr:
<<: *default
database: blog_application_cvr
migrations_paths: db/cvr_migrate

There isn't much to it, we use the default configuration, and change the name to blog_application_cvr. Zero will manage the initial setup of the schemas and tables for us so we won't need to make any migrations at the moment. We do need to set the migrations_paths or else our migrations in db/migrate will be applied to this database. If you recall from the Multiple Databases documentation, it states that we need to tell Rails where to find the migrations.

Migrations for multiple databases should live in their own folders prefixed with the name of the database key in the configuration.

You also need to set migrations_paths in the database configurations to tell Rails where to find the migrations.

For example the animals database would look for migrations in the db/animals_migrate directory and primary would look in db/migrate.

Change Database

The Change Database holds the replication log from the primary database so that Zero can apply the change deltas to individual rows. Again, we'll skip over exactly what is stored here because it goes over my head.

database.yml
# This is nested as sibling to 'primary'
zero_cdb:
<<: *default
database: blog_application_cdb
migrations_paths: db/cdb_migrate
# This is nested as sibling to 'primary'
zero_cdb:
<<: *default
database: blog_application_cdb
migrations_paths: db/cdb_migrate

This setup is exactly the same as the CVR database, just replacing cvr with cdb.

Viewing Zero's Tables

Now we're able create all three databases via rails db:create and ensure that all the databases have the same username and password to avoid any confusion when trying to connect to them. Those values will be needed in the next section when we set up our UI to talk to these databases.

$ rails db:create
Created database 'blog_application'
Created database 'blog_application_cvr'
Created database 'blog_application_cdb'
$ rails db:create
Created database 'blog_application'
Created database 'blog_application_cvr'
Created database 'blog_application_cdb'

Now that we've created the databases, I want to show the tables that Zero will create; all we've done so far is create a blank database, once the UI application starts and runs Zero, the tables will be populated. As an end user of Zero we most likely won't ever be interacting with these tables, but it is nice to know what exists.

$ psql
# \c blog_application_cvr

# \dnS
List of schemas
Name | Owner
------------------
cvr | localuser

# \dt cvr.*
List of relations
Schema | Name | Type | Owner
--------+----------------+-------+----------
cvr | clients | table | localuser
cvr | desires | table | localuser
cvr | instances | table | localuser
cvr | queries | table | localuser
cvr | rows | table | localuser
cvr | rowsVersion | table | localuser
cvr | versionHistory | table | localuser

# \c blog_application_cdb

# \dnS
List of schemas
Name | Owner
------------------
cdc | localuser

# \dt cdc.*
List of relations
Schema | Name | Type | Owner
--------+-------------------+-------+----------
cdc | changeLog | table | localuser
cdc | replicationConfig | table | localuser
cdc | versionHistory | table | localuser
$ psql
# \c blog_application_cvr

# \dnS
List of schemas
Name | Owner
------------------
cvr | localuser

# \dt cvr.*
List of relations
Schema | Name | Type | Owner
--------+----------------+-------+----------
cvr | clients | table | localuser
cvr | desires | table | localuser
cvr | instances | table | localuser
cvr | queries | table | localuser
cvr | rows | table | localuser
cvr | rowsVersion | table | localuser
cvr | versionHistory | table | localuser

# \c blog_application_cdb

# \dnS
List of schemas
Name | Owner
------------------
cdc | localuser

# \dt cdc.*
List of relations
Schema | Name | Type | Owner
--------+-------------------+-------+----------
cdc | changeLog | table | localuser
cdc | replicationConfig | table | localuser
cdc | versionHistory | table | localuser

PostgreSQL Configuration

The final piece to setting up the databases are a few changes to PostgreSQL itself. Please read and follow the steps in the Zero PostgreSQL Configuration documentation before proceeding.

UI Environment Variables#

The environment variables needed for the UI are shown in the hello-zero README along with explanations for each one. I've copied them and updated the values to represent our setup.

.env.local
# Your application's data
ZERO_UPSTREAM_DB="postgresql://localuser:localpw@127.0.0.1/blog_application"

# A Postgres database Zero can use for storing Client View Records (information
# about what has been synced to which clients). Can be same as above db, but
# nice to keep separate for cleanliness and so that it can scale separately
# when needed.
ZERO_CVR_DB="postgresql://localuser:localpw@127.0.0.1/blog_application_cvr"

# A Postgres database Zero can use for storing its own replication log. Can be
# same as either of above, but nice to keep separate for same reason as cvr db.
ZERO_CHANGE_DB="postgresql://localuser:localpw@127.0.0.1/blog_application_cdb"

# Secret to decode auth token.
ZERO_AUTH_SECRET="zerosecretkey"

# Place to store sqlite replica file.
ZERO_REPLICA_FILE="/tmp/zstart_replica.db"

# Where UI will connect to zero-cache.
NEXT_PUBLIC_ZERO_CACHE_PUBLIC_SERVER="http://localhost:4848"
# Your application's data
ZERO_UPSTREAM_DB="postgresql://localuser:localpw@127.0.0.1/blog_application"

# A Postgres database Zero can use for storing Client View Records (information
# about what has been synced to which clients). Can be same as above db, but
# nice to keep separate for cleanliness and so that it can scale separately
# when needed.
ZERO_CVR_DB="postgresql://localuser:localpw@127.0.0.1/blog_application_cvr"

# A Postgres database Zero can use for storing its own replication log. Can be
# same as either of above, but nice to keep separate for same reason as cvr db.
ZERO_CHANGE_DB="postgresql://localuser:localpw@127.0.0.1/blog_application_cdb"

# Secret to decode auth token.
ZERO_AUTH_SECRET="zerosecretkey"

# Place to store sqlite replica file.
ZERO_REPLICA_FILE="/tmp/zstart_replica.db"

# Where UI will connect to zero-cache.
NEXT_PUBLIC_ZERO_CACHE_PUBLIC_SERVER="http://localhost:4848"

Since we're managing all the databases with Rails, I thought it was important to detail these environment variables since they'll live in a separate portion of the application project, whether that is a different folder for a monorepo or an entirely different repository.

For all the *_DB values, we're using the default values from database.yml to fill in the username and password sections (localuser:localpw). And then we're binding the address to localhost IP address (127.0.0.1). ZERO_AUTH_SECRET can be any value you wish to use, I've chosen zerosecretkey which is good for this blog post, but I wouldn't recommend it for production. All of ZERO_* environment variables are used by the Zero Cache server and started via a command like dev:zero-cache.

The only environment variable we need to expose to our application's UI is where to talk to the Zero Cache server, which in our case is the default value from Zero of http://localhost:4848. That port can be configured, as well as many other options. I've exposed this value as NEXT_PUBLIC_ZERO_CACHE_PUBLIC_SERVER since I'm using Next.js, however if you're using Vite, like hello-zero, then you would use something like VITE_PUBLIC_SERVER. We'll reference this environment variable later on in this walk through.

Authentication#

While it is possible to use Zero in an unauthenticated manner, we set up our ZERO_AUTH_SECRET environment variable to ensure that the data being accessed and mutated is performed and authorized by an authenticated user. To that end, I suggest using whatever authentication solution you are most familiar with for Rails. The application I've been building is using the new Rails 8 authentication generator, which provides session based authentication. It is important to note that it is session based authentication because Zero currently has a requirement to use a JWT for authentication. This doesn't mean we need to swap our Rails authentication from session based to JWT based though; we can generate a JWT from an API call and encode the currently logged in user.

JWT Creation in Rails

Let's get started by adding the jwt gem to our Gemfile.

Gemfile
gem "jwt"
gem "jwt"

Next we're going to build a class that will take in a User and create the JWT.

app/models/zero_jwt.rb
class ZeroJwt
attr_reader :user
def initialize(user)
@user = user
end

def token
# https://zero.rocicorp.dev/docs/auth
# > When you set the auth option you must set the userID option to the same value that
# > is present in the sub field of the token.
# NOTE: the 'sub' field must be string
token = JWT.encode({
exp: Time.now.to_i + 7.days.to_i,
sub: user.id.to_s
}, ENV["ZERO_SECRET_KEY"],)
end
end
class ZeroJwt
attr_reader :user
def initialize(user)
@user = user
end

def token
# https://zero.rocicorp.dev/docs/auth
# > When you set the auth option you must set the userID option to the same value that
# > is present in the sub field of the token.
# NOTE: the 'sub' field must be string
token = JWT.encode({
exp: Time.now.to_i + 7.days.to_i,
sub: user.id.to_s
}, ENV["ZERO_SECRET_KEY"],)
end
end

We need to encode a sub field, as noted in the Zero Authentication documentation as well as an optional exp field for when the JWT will expire. Zero has a built in mechanism to refetch a new JWT when the expiration occurs and we'll cover setting that up after finishing the Rails setup. Finally, Zero has documentation on Permissions which can read additional data from the JWT. We could encode things like an organization_id, or a team_id, and potentially role information to indicate what actions the user should be able to perform. I'm not covering any of that as that implementation will be highly dependent on each application but I wanted to note the ability to do it.

Finally, the encoding process needs a key to generate the encryption. ENV["ZERO_SECRET_KEY"] needs to be the same value that we set for ZERO_AUTH_SECRET in the previous section; in terms of this article that value is zerosecretkey. Now that we can generate our JWT, we need to create an endpoint that will return the JWT to the UI.

config/routes.rb
resources :tokens, only: [ :new ]
resources :tokens, only: [ :new ]
app/controllers/tokens_controller.rb
class TokensController < ApplicationController
def new
render json: {
token: ZeroJwt.new(Current.user).token
}
end
end
class TokensController < ApplicationController
def new
render json: {
token: ZeroJwt.new(Current.user).token
}
end
end

A few things to note are that the ApplicationController is enforcing authentication for this route, which is required because we need access to an authenticated user to properly create the JWT. The second thing is the use of Current.user; this is how the Rails 8 authentication allows access to the currently authenticated user, but if we were using say devise, then it would be current_user.

That's all we need on the Rails end for our JWT so let's dive into a bit of UI code for fetching this JWT. As noted before, Zero can refresh the token when it expires as per the Authentication Refresh documentation.

async function getToken() {
const response = await apiClient.get("/tokens/new");
return response.data.token;
}

export default function ZeroClientProvider({
userId,
}) {
const z = new Zero({
userID: userId,
auth: () => getToken(),
server: process.env.NEXT_PUBLIC_ZERO_CACHE_PUBLIC_SERVER,
schema,
kvStore: "mem", // or "idb" for IndexedDB persistence
});

return <ZeroProvider zero={z} />;
}
async function getToken() {
const response = await apiClient.get("/tokens/new");
return response.data.token;
}

export default function ZeroClientProvider({
userId,
}) {
const z = new Zero({
userID: userId,
auth: () => getToken(),
server: process.env.NEXT_PUBLIC_ZERO_CACHE_PUBLIC_SERVER,
schema,
kvStore: "mem", // or "idb" for IndexedDB persistence
});

return <ZeroProvider zero={z} />;
}

In this setup, we have a function getToken that will call the API we set up get the JWT token and return the JWT; you should use whichever fetching library you are most familiar with in this case. We pass an anonymous function () => getToken() to the auth arg, which tells Zero to use that function anytime the it needs to authenticate, whether on first load or JWT expiration. This ties into the optional expiration that was we set and allows Zero to automatically re-authenticate.

The other requirement is passing in a userId which matches the sub field we encoded into the JWT. In this example, since we require an authenticated user to perform the getToken call we will have access to the userId; how the application provides the userId to this function is out of scope for this walk through.

That wraps up setting up the authentication Zero, everything should be set up to start using Zero in our UI.

Conclusion#

I hope the explanations and code samples here helped you get a better understanding of Zero and how it can be set up and managed by Rails. I'm extremely excited to keep working on my project and I'll continue to post updates as I learn new approaches to solving problems. You can find all the posts related to this topic below.

All My Posts About Zero