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
- Rindle copies your tables. On first start it takes a snapshot of the tables you publish.
- Rindle follows the replication stream. Each committed transaction in your database becomes one transaction in Rindle, in the same order.
- Queries work as usual. Your endpoints call
rindle.addQuery, exactly as in Architecture. Rows stream to the client and update when your database changes. - 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/appRindle 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,commentWhat 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
-poolerhost 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:
- A snapshot: every row of every table, as of some position.
- Committed transactions after that position, in commit order.
- 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
beginandcommitis visible untilcommit. - 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.