Skip to content
Contents

Bring Your Own Database

Keep your existing database as the source of truth

Preview. This page describes an API that is not in a release yet. It is here for review.

By default, Rindle is your database. In follow mode it sits in front of one you already have. Your app keeps writing to Postgres, Rindle follows its changes, and live queries update as those writes land. Zero works the same way.

Use follow mode when you can’t move off your database, or when other systems write to it too: cron jobs, admin tools, other services. If you’re starting fresh, let Rindle be the database. There are fewer moving parts, and no replication lag.

How it works

  1. Rindle copies your tables. On first start it takes a snapshot of the tables you publish.
  2. Rindle follows the replication stream. Each committed transaction in your database becomes one transaction in Rindle, in the same order.
  3. Queries work as usual. Your endpoints call rindle.addQuery, exactly as in Architecture. Rows stream to the client and update when your database changes.
  4. Writes go to your database. Your write endpoints keep using your existing database client. Rindle never writes to your database.

Postgres

Requires Postgres 15 or newer with wal_level = logical, a direct connection (not a pooler), and a role with REPLICATION.

Start Rindle in follow mode:

./rindle up --follow postgres://rindle@localhost/app

Rindle creates a publication and a replication slot, copies the tables, then follows. It prints RINDLE_URL and RINDLE_DATABASE_TOKEN as usual.

To follow only some tables:

./rindle up --follow postgres://rindle@localhost/app --tables issue,comment

What gets copied

Every published table, its primary key, and its plain b-tree indexes. Each table needs a primary key, or a unique index on NOT NULL columns.

Postgres Rindle
bool boolean
smallint, integer, bigint integer
real, double precision float
numeric string (exact decimal text)
text, varchar, uuid, enums string
json, jsonb, arrays json
date, timestamp, timestamptz string (Postgres text form)

Schema changes

Migrate Postgres with whatever tool you use today. Rindle picks up the change from the stream.

Managed providers

Neon, Supabase, RDS, Aurora, Cloud SQL and Azure work. Two things trip people up:

  • Poolers. Connect to the primary directly. Neon’s -pooler host and Supabase’s port 6543 are refused.
  • Row-level security. The role Rindle connects as needs BYPASSRLS, or it copies only the rows its policies allow. Supabase turns RLS on by default.

Reading your own writes

Rindle lags your database by replication delay. A write your endpoint just made can be missing from a query result for that long. Rindle tells the client when it has caught up to a write, so optimistic updates don’t flicker.

Other databases

Future work. Postgres is the only upstream today. This section describes where we’re going.

Rindle doesn’t need to know about your database. Anything that can produce an ordered stream of committed changes can be an upstream. You write a small adapter that speaks Rindle’s change protocol, and Rindle handles the copy, the live queries, and recovery.

Rindle connects to your adapter, over WebSocket, and asks for changes after the last position it has. Your adapter provides:

  1. A snapshot: every row of every table, as of some position.
  2. Committed transactions after that position, in commit order.
  3. Resume: given a position Rindle has acknowledged, continue from there.
type Position = string; // opaque to Rindle, ordered by you

interface Adapter {
  // First start, or when Rindle's position is too old to resume from.
  snapshot(): AsyncIterable<SnapshotMessage>;
  // Every later connect, from the last position Rindle acknowledged.
  changes(from: Position): AsyncIterable<ChangeMessage>;
  // Rindle has applied everything up to here. You can release it.
  ack(position: Position): void;
}

type SnapshotMessage =
  | { type: "schema"; tables: Table[] }
  | { type: "rows"; table: string; rows: Row[] }
  | { type: "done"; position: Position };

type ChangeMessage =
  | { type: "begin" }
  | { type: "insert"; table: string; row: Row }
  | { type: "update"; table: string; key: Key; row: Row }
  | { type: "delete"; table: string; key: Key }
  | { type: "truncate"; table: string }
  | { type: "schema"; tables: Table[] }
  | { type: "commit"; position: Position };

type Table = {
  name: string;
  columns: { name: string; type: "integer" | "float" | "boolean" | "string" | "json" }[];
  primaryKey: string[];
};

The rules:

  • Send only committed changes. Nothing between begin and commit is visible until commit.
  • Positions only go up.
  • Every table has a primary key.
  • If you can’t resume from a position, close the stream. Rindle takes a fresh snapshot.