Your own database
The server keeps its data through a store. For a SQL database, you do not write a store: you write a driver, a few lines that teach Ferry's SQLite or Postgres store to talk to your library. For anything else, you write a store, and the conformance kit tells you when it is right.
| Your database | Write | Size |
|---|---|---|
| SQLite or Postgres, through a library Ferry does not ship | A SqlDriver | About 30 lines |
| Something else (a document store, a key-value service) | A ServerStore | A few hundred |
Ferry ships drivers for pg, postgres.js, PGlite, Prisma, Drizzle, node:sqlite, bun:sqlite,
better-sqlite3 and a Durable Object's SQLite, and a store of its own for Cloudflare D1. This page
writes a driver for libSQL, the client of Turso.
The contract
A driver says which SQL it speaks, and runs work in a transaction:
import type { SqlConnection, SqlTransactionOptions } from 'ferrysync/server';
interface SqlDriver {
/** `?` placeholders and SQLite's locking, or `$1…` and Postgres'. */
readonly dialect: 'sqlite' | 'postgres';
/** Commits when `work` resolves, rolls back when it throws, and rethrows. */
transaction<T>(
work: (connection: SqlConnection) => Promise<T>,
options: SqlTransactionOptions,
): Promise<T>;
}
// The connection runs one statement and answers its rows as plain objects, keyed by column.
type Query = SqlConnection['query']; // (sql, params) => Promise<Record<string, unknown>[]>- SQLite transactions start with
BEGIN IMMEDIATE, orBEGINwhenreadOnly: one writer at a time. - Postgres transactions are
READ COMMITTED, orREPEATABLE READ READ ONLYwhenreadOnly. - The store sends one statement per
query, and creates its tables itself on first use.
A driver for libSQL
libSQL's 'write' transactions are SQLite's BEGIN IMMEDIATE, and its 'read' ones are read-only,
so the driver only maps calls and rows:
import type { Client } from '@libsql/client';
import type { SqlConnection, SqlDriver } from 'ferrysync/server';
/** A driver over libSQL (Turso): `sqliteStore(libsql(createClient({ url, authToken })))`. */
export const libsql = (client: Client): SqlDriver<never, 'sqlite'> => ({
dialect: 'sqlite',
transaction: async (work, { readOnly }) => {
const tx = await client.transaction(readOnly ? 'read' : 'write');
const connection: SqlConnection = {
query: async (sql, params) => {
const { columns, rows } = await tx.execute({ sql, args: [...params] });
return rows.map((row) =>
Object.fromEntries(columns.map((column, index) => [column, row[index]])),
);
},
};
try {
const result = await work(connection);
await (readOnly ? tx.rollback() : tx.commit());
return result;
} catch (error) {
await tx.rollback().catch(() => undefined);
throw error;
} finally {
tx.close();
}
},
});Then it is a store like any other:
import { createClient } from '@libsql/client';
import { createSyncServer, sqliteStore } from 'ferrysync/server';
import { libsql } from './libsql';
import { schema } from './schema';
export const syncServer = createSyncServer({
schema,
store: sqliteStore(
libsql(createClient({ url: process.env.TURSO_URL ?? '', authToken: process.env.TURSO_TOKEN })),
),
auth: async (request) => (await getSession(request))?.userId ?? null,
});Prove it
serverStoreConformanceCases are the tests Ferry's own stores pass: transactions, rollbacks,
locking, the log's order, retention. Run them over a fresh database each time:
import { createClient } from '@libsql/client';
import { sqliteStore } from 'ferrysync/server';
import { canRunServerStoreCase, serverStoreConformanceCases } from 'ferrysync/testing';
import { describe, it } from 'vitest';
import { libsql } from './libsql';
describe('libsql', () => {
for (const testCase of serverStoreConformanceCases) {
const target = {
create: async () => sqliteStore(libsql(createClient({ url: ':memory:' }))),
};
it.skipIf(!canRunServerStoreCase(target, testCase))(testCase.name, () => testCase.run(target));
}
});When they pass, the driver is done: the store over it behaves as Ferry's own do.
A store for another kind of database
When the database is not SQL, implement ServerStore from ferrysync/server: transaction(work)
and read(work), around a reader and a writer for the clients, memberships, records, the log and
the push results. Its contract is written on each method, and Ferry's memory store is a complete
reference in a few hundred lines. The same conformance kit proves it.
Two rules matter more than the rest:
- Sync ids become visible in commit order. A device that read id 42 must never later find a 41.
- A write transaction sees and locks what it reads, so two pushes never both claim the same space or the same row.
Two writer methods are optional. pruneClients(time, now) lets
retention forget devices that stopped syncing:
without it the store keeps every device. deleteUser(userId, privateGroup) erases a user for
account deletion: without it deleteUser throws. Either way, the
conformance cases about the one it lacks pass.