Ferry
Examples

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 databaseWriteSize
SQLite or Postgres, through a library Ferry does not shipA SqlDriverAbout 30 lines
Something else (a document store, a key-value service)A ServerStoreA 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:

Server
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, or BEGIN when readOnly: one writer at a time.
  • Postgres transactions are READ COMMITTED, or REPEATABLE READ READ ONLY when readOnly.
  • 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:

libsql.tsServer
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:

sync-server.tsServer
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:

libsql.test.tsServer
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.

On this page