rxdb-supabase: Offline Support for Supabase
RxDB is a client-side, offline-first database that supports various storage layers including IndexedDB. Supabase is an open-source Firebase alternative that stores data in a Postgres database with row level security. This library uses RxDB's replication logic to enable a two-way sync of your client-side RxDB database with a remote Supabase table, while allowing you to define custom conflict resolution strategies.
How it works
RxDB is an offline-first database, so all reads and writes are performed against the client-side RxDB database, which gets synced with the corresponding Supabase table in the background. Put another way, it stores a full copy of the Supabase table locally (or more specifically, the subset of rows accessible to the user after row-level security is applied). Everything is configured on a per-table basis though, so you could enable offline support for some tables while querying other tables using the SupabaseClient only when online.
Most of the replication and conflict resolution is handled by RxDB's replication protocol. It works similar to git by always pulling all changes from Supabase before merging changes locally and then pushing them to Supabase. When you start the replication (e.g. when the user opens your web app), these three stages are executed in order:
- Pull changes from Supabase: As the Supabase table might have been changed since the last sync on this particular client, we need to fetch all rows that were modified in the meantime. In order for this to be possible with Supabase, some restrictions apply to the table you want to sync:
_modified
field: Your table needs a field with the timestamp of the last modification. This is easy to implement in Supabase, see the Getting Started guide below._deleted
field: You can't actually delete rows from Supabase unless you are sure all clients have replicated the deletion locally. Instead, you need a boolean field that indicates whether the row has been deleted. You won't have to deal with this on the client-side though, as RxDB will handle this for you transparently.
- Push changes to Supabase: Next, we fire INSERT and UPDATE queries to Supabase with all local writes. By default, rows are only updated if they have not changed in the meantime, i.e. all fields of the row need to have the value that they had when the local write was performed. Otherwise, RxDB's conflict handler is invoked, which you can customize to build your own strategy for merging changes.
- Watch Supabase changes in realtime: After the initial sync is complete, we use Supabase's realtime feature to subscribe to any changes of the table. Note that this will miss any changes if the client is offline intermittendly, so you might want to call
reSync()
on the replication object whenever your app comes back online.
Getting Started
Install
npm install rxdb-supabase rxdb @supabase/supabase-js --save
Create your RxDB
If you're new to RxDB, read the Quickstart guide for more details.
import { createRxDatabase } from "rxdb"
import { getRxStorageDexie } from "rxdb/plugins/storage-dexie"
const myDatabase = await createRxDatabase({
name: "humans",
storage: getRxStorageDexie(),
})
const mySchema = {
title: "human schema",
version: 0,
primaryKey: "id",
type: "object",
properties: {
id: {
type: "string",
maxLength: 100,
},
name: {
type: "string",
},
age: {
description: "age in years",
type: "integer",
},
},
required: ["id", "name", "age"],
indexes: ["age"],
}
const myCollections = await db.addCollections({
humans: {
schema: mySchema,
},
})
Use RxDB's functions for reading and writing the database. For example:
const myCollection = myCollections.humans
myCollection.find({}).$.subscribe((documents) => {
console.log("query has found " + documents.length + " documents")
})
const doc = await myCollection.insert({ id: "1", name: "Alice" })
await doc.patch({ age: 21 })
await doc.remove()
Create your Supabase table
As stated above, your table needs a _modified
timestamp and a _deleted
field in order
for the replication to be able to detect which rows changed in Supabase. You can configure a different name for these fields with the lastModifiedField
and deletedField
options.
CREATE TABLE public.humans (
id text NOT NULL,
name text NOT NULL,
age smallint,
_deleted boolean DEFAULT false NOT NULL,
_modified timestamp with time zone DEFAULT now() NOT NULL
);
ALTER TABLE ONLY public.humans ADD CONSTRAINT humans_pkey PRIMARY KEY (id);
Create a trigger that keeps the _modified
field updated:
CREATE TRIGGER update_modified_datetime BEFORE UPDATE ON public.humans FOR EACH ROW
EXECUTE FUNCTION extensions.moddatetime('_modified');
Start the Replication
Make sure you've initialized your SupabaseClient and, if you're using row level security, that the client is authenticated.
const replication = new SupabaseReplication({
supabaseClient: supabaseClient,
collection: myCollection,
replicationIdentifier: "myId" + SUPABASE_URL,
pull: {},
push: {},
})
That's it, your replication is now running! Any errors can be observed with
replication.errors$
and you can stop the replication again with replication.cancel()
.
To ensure you don't miss any changes in Supabase, you might want to listen to the
network status and call replication.reSync()
when the
client gets back online.
Options
These are all the available options, including the options inherited from RxDB.
collection: RxCollection<RxDocType, any, any, any>
supabaseClient: SupabaseClient
table?: string
primaryKey?: string
pull?: {
realtimePostgresChanges?: boolean
lastModifiedField?: string
batchSize?: number
modifier?: (docData: any) => Promise<WithDeleted<RxDocType>> | WithDeleted<RxDocType>
initialCheckpoint?: SupabaseReplicationCheckpoint
}
push?: {
updateHandler?: (row: RxReplicationWriteToMasterRow<RxDocType>) => Promise<boolean>
modifier?: (docData: WithDeleted<RxDocType>) => Promise<any> | any
initialCheckpoint?: SupabaseReplicationCheckpoint
}
replicationIdentifier: string
deletedField?: "_deleted" | string
live?: boolean
retryTime?: number
autoStart?: boolean
}
Notes
- JSON fields require a custom
updateHandler
. This is because the default update handler tries to check that all fields of a row have the expected value, but the supabase client doesn't currently have a simple way to add an equality check for JSON fields. - If you delete rows frequently, you might want to enable RxDB's cleanup plugin to clear deleted rows from the local database after they were deleted. There's no recommended way for cleaning up those rows in Supabase yet.
Future work
While the offline-first paradigm comes with many advantages, there are also downsides, most notably that the entire table needs to be downloaded to the client. Here are a few ideas for how this project could mitigate that in the future:
- #4 Support "partitions", i.e. replicating subsets of the Supabase table, similar to subcollections in FireStore
- #5 Add better support for a "push-only" mode
- #6 Support using RxDB as a offline cache rather than a offline-first database
Development
Build: npm run build
Unit tests: npm run test
or npm run test:watch
Unit test coverage: npm run test:coverage
(Not working yet!)
Integration tests: We also run integration tests against a real supabase instance:
- Set up a Supabase project and use
src/__tests__humans.sql
to create the table used in tests. It does not use row level security, so that should be disabled for the table. - It requires the environment variables
TEST_SUPABASE_URL
and TEST_SUPABASE_API_KEY
(the public API key) to be set npm run integration-test
Format code: npm run format
(checked as part of the workflow, run for pull requests please :)
Lint: npm run lint
(not passing or required for pull requests yet)
Spell check: npm run spell:check
(not passing or required for pull requests yet)
Release checklist:
- Bump version
- Update dependencies (
ncu -u
) npm i
- Build, test, format
npm publish
- Commit
- Create GitHub release
TODO: Set up semantic-releases workflow