Aug 10, 2023 by
Alan Hughes
We're releasing an alpha version of the expo-sqlite
library that includes a proof-of-concept iOS integration with CR-SQLite, an SQLite extension that "allows merging different SQLite databases together that have taken independent writes" that makes up part of the Vulcan toolchain.
We're also releasing an example project that uses CR-SQLite with TinyBase, a reactive datastore for local first apps. It demonstrates what is known as local-first architecture. For a comprehensive look at this approach and why we think it matters, check out Johannes Schickling's "Local-first app development" talk at App.js 2023, the Ink & Switch "Local-first software" essay that coined the term, and the local-first community at LFW.dev.
CR-SQLite is a run-time loadable extension for SQLite and libSQL. It allows merging different SQLite databases together that have taken independent writes.
With CR-SQLite, your users can all write to their own app's local SQLite database while offline. They can then come online and merge their databases together, without conflict.
In other (much more technical) words, CR-SQLite adds multi-master replication and partition tolerance to SQLite via conflict-free replicated data types (CRDTs) and/or causally ordered event logs.
For more information, check out the CR-SQLite README.
CR-SQLite adds functionality that allows us to request a set of changes from an SQLite database, and then we can insert those changes to another copy of the SQLite database, likely running on another device.
It works in two steps:
CRRs
(conflict-free replicated relations) with the following command:
SELECT crsql_as_crr('todo');
SELECT * FROM crsql_changes
The results can be merged in any order (they are commutative) and the databases will always converge on the same state given the same set of operations.
The example project is a todo list: expo/todo-sync-example. There are two parts:
expo-sqlite
and TinyBase to store the user's todo list.To run the example project, clone the example repo, check out the initial-poc
branch, and run yarn
to install its dependencies (this project is a Yarn Classic workspace).
After that, start the server:
cd apps/server
yarn start
Then, start the app:
cd apps/mobile
npx expo run:ios
We now have the app running in the iOS simulator. Let's start a second instance of the app on another simulator so that we can see the sync in action:
npx expo run:ios -d # Select another device from the prompt
Make some changes, add and delete todos, mark them as complete, or delete everything. No matter which device you use, you will see both stay in sync.
The first step is to set up the TinyBase persister.
// App.tsx
import { useCreatePersister } from 'tinybase/lib/ui-react';
import { createExpoSqlitePersister } from './app/store';
function TodoList() {
// ...
useCreatePersister(
store,
(store) =>
createExpoSqlitePersister(store, db, {
mode: 'tabular',
tables: {
load: { todo: { tableId: 'todo', rowIdColumnName: 'id' } },
save: { todo: { tableName: 'todo', rowIdColumnName: 'id' } },
},
}),
[db],
async (persister) => {
await persister.startAutoLoad();
await persister.startAutoSave();
}
);
}
The createExpoSqlitePersister()
function allows TinyBase to interact with the underlying data store, which is expo-sqlite
in this case. As we make changes to our store, changes will be persisted in the local SQLite database. This is everything we need to set up persisting our data locally.
Next, we need to notify the server of our changes. We'll use the useSync()
hook and the onDatabaseChange()
listener provided by expo-sqlite
.
First, create a socket:
// apps/mobile/app/useSync.ts
import { useEffect, useRef } from 'react';
import PartySocket from 'partysocket';
export function useSync() {
const socket = useRef(createPartySocket()).current;
// ...
}
Then let's connect our server:
// apps/mobile/app/useSync.ts
export function useSync() {
// ...
useEffect(() => {
const handleMessage = (e: MessageEvent<string>) => {
if (!syncEnabled) return;
handleMessageAsync(e);
};
socket.addEventListener('message', handleMessage);
if (syncEnabled) {
// Send an init message to get the latest changes
socket.send('init');
}
return () => {
socket.removeEventListener('message', handleMessage);
};
}, [socket, syncEnabled]);
}
async function handleMessageAsync(e: MessageEvent<string>) {
const data = JSON.parse(e.data);
const rows = data[0].rows;
for (const row of rows) {
const { pk, ...rest } = row;
const sql = `INSERT INTO crsql_changes ("table", 'pk', 'cid', 'val', 'col_version', 'db_version', 'site_id') VALUES (?, ${pk}, ?, ?, ?, ?, ?)`;
try {
await db.execAsync(
[
{
sql,
args: Object.values(rest),
},
],
false
);
} catch (e) {
console.log(e);
}
}
}
We register a handler for the message
event, and when we receive it, we insert the results into our database. Finally, we use another effect to set up the onDatabaseChange()
event listener so that we are notified when the database has changed. When we receive an update event, we request our changes from the crsql_changes
table and send the results. Allowing enabling and disabling of the sync is optional. Also, note that the queries used here will be improved in future versions so users won't have to know about these details.
// apps/mobile/app/useSync.ts
export function useSync() {
// ...
useEffect(() => {
const maybeSendChanges = async () => {
if (syncEnabled) {
const changes = await requestChanges();
socket.send(JSON.stringify(changes));
}
};
// Subscribe to changes
const subscription = db.onDatabaseChange(async (result) => {
if (result.tableName.includes('__crsql_')) return;
maybeSendChanges();
});
// Also maybe send them right away, in case changes happened while sync was
// disabled
maybeSendChanges();
return () => subscription.remove();
}, [syncEnabled]);
}
async function requestChanges() {
return await db.execAsync(
[
{
sql: `SELECT "table", quote(pk) as pk, cid, val, col_version, db_version, site_id FROM crsql_changes WHERE db_version > -1`,
args: [],
},
],
false
);
}
Implementation note: a real-world application would rarely want to use WHERE db_version > -1
because this will select the entire set of changes from the crsql_changes
table, rather than only the changes that have been applied since the most recent sync (for example: WHERE db_version > ?last_sent_version
). We also left out WHERE site_id IS NULL
, which is likely be used in order to ensure we only select changes that occurred on the local client, rather than re-sending changes received from a recent sync. The code is simplified here for the sake of this proof of concept, where we have not set up an state persistence on the sync server. We'll continue to iterate on the main
branch to create an example that better represents a real app.
We plan to begin investing heavily in our SQLite bindings, expo-sqlite
, working with Matt Wonlaw on integrating seamlessly with CR-SQLite, and coordinating with James Pearce on a TinyBase persister. We're big believers in local-first architecture, and you should expect to see more work from us in this space in the future.
We'll continue to evolve this example to address many of the current limitations and to push more of the generic implementation details into related libraries. In particular, we plan to update it to:
Send us your feedback on Discord, @expo, Threads, or Bluesky.