🌏 The Global Version Strategy
A single global version
is stored in the database and incremented on each push. Entities have a lastModifiedVersion
field which is the global version the entity was last modified at.
The global version is returned as the cookie to Replicache in each pull, and sent in the request of the next pull. Using this we can find all entities that have changed since the last pull and calculate the correct patch.
While simple, the Global Version Strategy does have concurrency limits because all pushes server-wide are serialized, and it doesn't support advanced features like incremental sync and read authorization as easily as row versioning.
You may wonder why not use a timestamp for the version instead of a counter. While this would scale much better, it is not possible to implement correctly on most servers due to unreliable clocks.
Schema
The schema builds on the schema for the Reset Strategy, and adds a few things to support the global version concept.
// Tracks the current global version of the database. There is only one of
// these system-wide.
type ReplicacheSpace = {
version: number;
};
type ReplicacheClientGroup = {
// Same as Reset Strategy.
id: string;
userID: any;
};
type ReplicacheClient = {
// Same as Reset Strategy.
id: string;
clientGroupID: string;
lastMutationID: number;
// The global version this client was last modified at.
lastModifiedVersion: number;
};
// Each of your domain entities will have two extra fields.
type Todo = {
// ... fields needed for your application (id, title, complete, etc)
// The global version this entity was last modified at.
lastModifiedVersion: number;
// "Soft delete" for marking whether this entity has been deleted.
deleted: boolean;
};
Push
The push handler is the same as the Reset Strategy, but with changes to annotate entities with the version they were changed at.
- Create a new
ReplicacheClientGroup
if necessary. - Verify that the requesting user owns the specified
ReplicacheClientGroup
.
Then, for each mutation described in the PushRequest
:
- Create the
ReplicacheClient
if necessary. - Validate that the
ReplicacheClient
is part of the requestedReplicacheClientGroup
. - Validate that the received mutation ID is the next expected mutation ID from this client.
- Increment the global version.
- Run the applicable business logic to apply the mutation.
- For each domain entity that is changed or deleted, update its
lastModifiedVersion
to the current global version. - For each domain entity that is deleted, set its
deleted
field to true.
- For each domain entity that is changed or deleted, update its
- Update the
lastMutationID
of the client to store that the mutation was processed. - Update the
lastModifiedVersion
of the client to the current global version.
As with the Reset Strategy, it's important that each mutation is processed within a serializable transaction.
Pull
- Verify that requesting user owns the requested
ReplicacheClientGroup
. - Return a
PullResponse
with:- The current global version as the cookie.
- The
lastMutatationID
for each client that has changed since the requesting cookie. - A patch with:
put
ops for every entity created or changed since the request cookie.del
ops for every entity deleted since the request cookie.
Example
See todo-nextjs for an example of this strategy.
Challenges
Performance
GlobalVersion
functions as a global lock. This limits possible concurrency of your backend: if each push takes 20ms then the maximum number of pushes per second for your server is 50.
Soft Deletes
Soft Deletes are annoying to maintain. All queries to the database need to be aware of the deleted
column and filter appropriately. There are other ways to implement soft deletes (see below), but they are all at least a little annoying.
Read Authorization
In many applications, users only have access to a subset of the total data. If a user gains access to an entity they didn't previously have access to, pull should reflect that change. But that won't happen using just the logic described above, because the entity itself didn't change, and therefore its lastModifiedVersion
field won't change.
To correctly implement auth changes with this strategy, you also need to track those auth changes somehow — either by having those changes bump the lastModifiedVersion
fields of affected docs, or else by tracking changes to the auth rules themselves with their own lastModifiedVersion
fields.
Variations
Early Exit, Batch Size
Just as in the Reset strategy, you can early exit the push handler or process mutations in smaller batches.
Alternative Soft Delete
There are other ways to implement soft deletes. For example for each entity in your system you can have a separate collection of just deleted entities:
type Monster = {
// other fields ...
// note: no `deleted` here
// The version of the database this entity was last changed during.
replicacheVersion: number;
};
type MonsterDeleted = {
// The version of the db the monster was deleted at
replicacheVersion: number;
};
This makes read queries more natural (can just query Monsters collection as normal). But deletes are still weird (must upsert into the MonstersDeleted
collection).