Server Implementation Plan For Rocicorp's Zero - Custom Mutators
Published on 2025-08-10
It's been a minute since I wrote anything about Zero but I
finally got back to working on my project that is using it. A lot changed between the time
I took a break, where I was on the 0.11
release, to starting back up and updating to the 0.20
release. In the middle of those was the long awaited release of
0.18 - Custom Mutators. I encourage reading
through the Custom Mutators Documentation to
get acquainted with the details but if this blog post is has caught your attention then I'm
guessing you're already well versed on what Custom Mutators are.
After I finished my upgrade to 0.20
I started investigating how I could use Custom Mutators. The
Zero team has made it extremely easy to use Custom Mutators if the project is fullstack
Typescript, however if the project is using a different language
for the server code there is some work to be done. My project is using
Ruby on Rails so I started my adventure on figuring out how to implement
Custom Mutators on my own; the aforementioned documentation states adventure is doable and even
gives a small example in Go.
The server implementation runs on your server, in your push endpoint, against your database. In principle, it can be written in any language and use any data access library. For example you could have the following Go-based server implementation of the same mutator
While my implementation is in Ruby on Rails, I wanted to share what I learned while doing my implementation in a more language agnostic way. I'm positive the Zero team will have their own write-up or maybe even SDKs at some point. Until then, I hope this helps anyone else who is excited about Custom Mutators and isn't building a fullstack Typescript app. What follows is an explanation, with psuedo-code examples, on how to implement Custom Mutators. Let's dive in!
High Level Abstract#
At a high level the flow of the Custom Mutators server implementation is going to start with receiving an HTTP request with a list of mutations that need to be run on our server. There are a handful of steps we'll need to go through to properly process them so let's turn those steps down into a list.
- HTTP request comes in with mutations
- Validate the request
- Process the list of mutations and capture the results
- For each mutation in the list
- Check if it's the next expected mutation ID for this client
- If out of order, stop and return error (client will retry)
- If duplicate, skip it
- Otherwise, run a mutator and write data to the database
- Update the mutation tracking
- For each mutation in the list
- Return all the results
We're going to break each of these steps out into their own section in this post in an order that I hope makes logical sense from an implementation standpoint. Remember that there isn't any real code being written here but we'll jot down some pseudocode to solidify the concepts.
HTTP Request and Endpoint#
The start of this implementation is to add an endpoint to our server which will receive and process
requests sent from Zero. This endpoint needs to be set up to receive a POST
request and respond back
with a payload indicating which mutations were successfully applied and which mutations failed
to be applied.
PushRequest
schema
Here's a real world example from my implementation to help solidify the structure.
Additional PushRequest
Parameters
One last note on the PushRequest
schema is there is functionality built in to pass any additional
parameters from the client to server.
When configuring Zero on the client, we can add in the push
key and nest the additional parameters under the
queryParams
key. These parameters will show up as top level keys so the structure from above would have
the workspaceID
appended to the end like so.
HTTP Endpoint
How the endpoint is implemented is going to be vastly different depending on which framework or library
is being used. The single requirement for the endpoint is that it accepts a POST
request. I set up
my route to be POST zero/mutations/push
however it does not have to be that nested.
HTTP Endpoint Validation
There are a handful of ways that we can achieve validating the requests that we're receiving from Zero. Let's
start off with the AUTHORIZATION
header. Zero includes the JWT that we're using on the client in these
requests so the first step in validating should be to decode the JWT and ensure that it is
valid. After decoding it, we'll have access to any data that was originally encoded, such as a user ID, so
we'll know who performed the mutation on the client. We'll want to use that user information to ensure relational
integrity of the data being mutated.
The next step in validation is optional, but I think it's an important one as well. In the
zero-cache Config Documentation there is an option to set
ZERO_PUSH_API_KEY
which is a secret key that will be included in the X-API-KEY
header. We can include that
secret as an environment variable in our server and check the header against the environment variable to ensure
the request is coming from our own Zero instance.
PushResponse
Schema
The endpoint needs to respond with a 200
when processing is successful or with a 401
or 403
if you would
like Zero to re-authenticate and then try the mutations again. Any other response code returned and Zero will
wait for 5 seconds and then re-send the request. In the case where everything was successful, we need to return
a response structure that matches the PushResponse
schema.
Here are some real world examples from my implementation to help solidify the structure.
First is a successful result, second is a result with errors where the details
is an error
message I came up with.
Pseudocode Implementation
Processing Mutations#
Now that we've finished setting up our endpoint, the next step is to set up a loop
to process all the mutations that are being sent to us. There isn't a lot to cover here
except for one important piece. While we're looping and processing each individual
mutation, if we ever run into an oooMutation
error, we have to stop processing
the entire batch and return the payload we've built up. Even if an oooMutation
occurs, we still need to return the data as a 200
response.
Pseudocode Implementation
Processing a Single Mutation#
Inside of our loop, we have to process each individual mutation. To do
so there are two steps we have to take. The first is to do a check
that the mutation payload we received is properly ordered and if
everything checks out, we'll then dispatch the mutation to apply
the changes to the record in the database. In this individual
processing we want to catch specific errors thrown, like the previously
mentioned oooMutation
error and construct a response object
that the looping process can check against. If everything processes
fine, then we only need to return an empty object.
Pseudocode Implementation
Validating Mutation Data#
To validate the order of the mutation, there are a few steps we need to go through. The first will be to create a database table which can hold these mutation records. We want to be able to open a transaction and atomically do the required checks; if anything fails we'll throw an error and exit the transactions without applying any changes.
The checks that we're required to do are scoped to the clientID
and the first check
is to see if we've already processed this mutation. This likely happens when an error
occurs and Zero retries a batch of mutations; we don't want to re-apply any changes
that we've already done so it makes sense to skip them! The other required check
is looking for mutations that have come in out of order. The last thing we want
is to apply changes in the wrong order and then our users end up seeing an incorrect
value when the syncing is finished. If both checks pass then we go ahead and update the
mutationID
, save the record, and continue our processing.
Pseudocode Implementation
Dispatching to a Mutator#
Now that everything has been validated, we can dispatch the changes to a mutator
function. Likely the easiest way to get started with this will be to create a
switch
statement with all the different mutators we implement. The name of the mutation will look
like namespace|action
and in the case of these examples it is book|update
.
We'll start off by using a switch
statement and then all we need to do is
pass in the mutation.args
to our mutator.
Pseudocode Implementation
Running a Mutator#
We finally made it; we're ready to change some data in our database! We
passed in args[0]
which if we refer back the example payload was
{ id: 2040, name: "Updated Name" }
. We'll want to use the information from
the AUTHORIZATION
header to ensure referential integrity between the object
being mutated and the authorized user. If it's a valid relation, we'll apply
our changes and then return a result indicating that everything worked.
One thing to remember is to run this whole block in a transaction to allow for rollbacks if any of the data is invalid. Having an open transaction also allows us to do any additional work we desire. The feature is called Custom Mutators for a reason!
Additional Mutator Actions
The additional work we want to do could be anything from sending emails to creating additional records on an audit log table or emitting metrics. Whatever additional work our application desires, we've made it to the point where all that can happen. If there isn't any additional work to be done, that's okay too. The option is always there in the future.
Pseudocode Implementation
Conclusion#
We made it through the whole implementation plan and I hope you're as excited about Custom Mutators as I am. If you write your own implementation in a different language be sure to share your learnings with the Zero community! I'll be following up this post with another post showing my Ruby on Rails implementation.
All My Posts About Zero
- Setting up Rocicorp's Zero with Ruby on Rails
- Using PostgreSQL Functions with Rocicorp's Zero and Ruby on Rails
- Server Implementation Plan For Rocicorp's Zero - Custom Mutators*