Rindle fits into your architecture like any other database. Clients typically don’t talk to it directly. Access goes through your API server, which enforces business logic and authorization.
The only difference is that Rindle has sessions. A session is a single streaming connection,
shared by all of a client’s open queries. addQuery and removeQuery change which queries are
synced over it.
How it works
- The page opens one stream. The browser opens a stream to your server, at
/api/rindle-connect. Your server checks the user with your normal auth and returns Rindle’s stream unchanged. Platforms like Vercel and Cloudflare pipe it through without your code touching each byte. - Each query is an endpoint. Framework-specific bindings like
useLiveQuery()call your endpoints. The endpoint checks the user, picks the SQL, and callsrindle.addQuery. Rindle runs the query and sends its rows down the stream the page already has open. - Writes are endpoints too. They check the user and run ordinary SQL. Every live query the write affects updates on its own, for every user who can see it.
Setup
Your server needs one setting, RINDLE_URL, which rindle up prints and writes to .env.
Locally it’s just an address. In production it also carries a password, like a Postgres URL
(https://:password@db.example.com), so keep it in your server’s environment and never send it to
the browser.
A complete example, for both Next.js and Cloudflare Workers, is in
apps/notes.
Install Rindle in your app:
gh release download dev -R rocicorp/rindle -p install.sh -O - | sh -s -- server reactCreate the server’s clients once, and import them from your routes:
// lib/rindle.ts — server only
import { createRindleSessions } from "@rindle/daemon-client";
import { createReactiveSqlClient } from "@rindle/reactive-sql-client";
export const rindle = createRindleSessions(); // reads RINDLE_URL
export const db = await createReactiveSqlClient({ rindle: {} }); // for writesThe stream route
One route per app. It authenticates the request and passes Rindle’s stream through.
clientIDnames one live client (eachcreateReactiveSqlClienthas its own), andqueryIDnames one of its queries. The page sends them in theRindle-ClientandRindle-Queryheaders. Each client has its own stream and its own queries.userIDis optional. Rindle stores it with the client and rejects any later call for that client with a differentuserID. Without it, knowing theclientIDis enough to change its queries, so pass it whenever there is a signed-in user.
Next.js on Vercel (app/api/rindle-connect/route.ts):
import { rindle } from "@/lib/rindle";
import { getUser } from "@/lib/auth"; // your auth
export const maxDuration = 800; // the stream reconnects when Vercel ends the function
export async function GET(request: Request) {
const user = await getUser(request);
if (!user) return new Response(null, { status: 401 });
const clientID = request.headers.get("Rindle-Client");
return rindle.connect({ clientID, userID: user.id });
}
export async function DELETE(request: Request) {
const user = await getUser(request);
if (!user) return new Response(null, { status: 401 });
// the page stopped using a query
const clientID = request.headers.get("Rindle-Client");
const queryID = request.headers.get("Rindle-Query");
return rindle.removeQuery({ clientID, queryID, userID: user.id });
}Cloudflare Workers (worker/index.ts):
import { rindle } from "../lib/rindle";
import { getUser } from "../lib/auth"; // your auth
export default {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const user = await getUser(request);
if (url.pathname === "/api/rindle-connect") {
if (!user) return new Response(null, { status: 401 });
const clientID = request.headers.get("Rindle-Client");
const queryID = request.headers.get("Rindle-Query");
if (request.method === "DELETE") {
return rindle.removeQuery({ clientID, queryID, userID: user.id });
}
return rindle.connect({ clientID, userID: user.id });
}
// …your query and write routes, below
return new Response("Not found", { status: 404 });
},
};A query endpoint
A query endpoint looks like any other endpoint: authenticate, then decide what this user may see.
The difference is that it returns rindle.addQuery(...) instead of rows. The rows go down the stream.
This one returns the notes a user owns, plus the notes shared with them:
// app/api/notes/route.ts
import { rindle } from "@/lib/rindle";
import { getUser } from "@/lib/auth";
export async function GET(request: Request) {
const user = await getUser(request);
if (!user) return new Response(null, { status: 401 });
const clientID = request.headers.get("Rindle-Client");
const queryID = request.headers.get("Rindle-Query");
return rindle.addQuery({ clientID, queryID, userID: user.id }, `
SELECT * FROM note
WHERE owner = ?
OR EXISTS (SELECT 1 FROM share WHERE share.note_id = note.id AND share.user_id = ?)
ORDER BY id DESC
LIMIT 50
`, [user.id, user.id]);
}Read arguments from the request the way you normally would, and pass them as parameters:
// app/api/notes/[id]/comments/route.ts
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const user = await getUser(request);
if (!user) return new Response(null, { status: 401 });
const { id } = await params;
const clientID = request.headers.get("Rindle-Client");
const queryID = request.headers.get("Rindle-Query");
return rindle.addQuery({ clientID, queryID, userID: user.id }, `
SELECT comment.* FROM comment
WHERE note_id = ?
AND EXISTS (SELECT 1 FROM note WHERE note.id = comment.note_id AND note.owner = ?)
ORDER BY id
`, [Number(id), user.id]);
}If the SQL is not something Rindle can keep live, rindle.addQuery returns a 400 with the
compiler’s message, and the page sees it as the query’s error.
Use it in the page
Create one live client per signed-in page, and close it on sign-out:
"use client";
import { useEffect, useState } from "react";
import { createReactiveSqlClient, type ReactiveStreamClient } from "@rindle/reactive-sql-client";
import { useLiveQuery } from "@rindle/react";
function useStreamClient(): ReactiveStreamClient | null {
const [db, setDb] = useState<ReactiveStreamClient | null>(null);
useEffect(() => {
let client: ReactiveStreamClient | undefined;
let closed = false;
void createReactiveSqlClient({ stream: "/api/rindle-connect" }).then((c) => {
if (closed) return c.close();
client = c;
setDb(c);
});
return () => {
closed = true;
client?.close();
};
}, []);
return db;
}
type Note = { id: number; title: string; owner: string };
export function Notes() {
const db = useStreamClient();
const { rows, status, error } = useLiveQuery<Note>(db, "/api/notes");
if (error) return <p>Couldn't load notes: {error.message}</p>;
if (status === "loading") return <p>Loading…</p>;
return <ul>{rows.map((n) => <li key={n.id}>{n.title}</li>)}</ul>;
}The client opens the stream the first time a component asks for a query, and one stream carries
every query on the page. Render the signed-in part of the page with key={user.id}, so a
different user gets a fresh client. If your endpoint answers 401 or 403, that status shows up as error.
Unmounting the component removes the query.
A write endpoint
Writes are ordinary endpoints that run SQL with your server’s client. Put the ownership check in the SQL, so a user can never change a row they don’t own, then check how many rows changed:
// app/api/notes/[id]/share/route.ts
import { db } from "@/lib/rindle";
import { getUser } from "@/lib/auth";
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const user = await getUser(request);
if (!user) return new Response(null, { status: 401 });
const { id } = await params;
const { userId } = await request.json();
const { result } = await db.sql.execute({
sql: `INSERT INTO share (note_id, user_id)
SELECT id, ? FROM note WHERE id = ? AND owner = ?`,
args: [userId, Number(id), user.id],
});
if (result.rowsAffected === 0) return new Response(null, { status: 403 });
return new Response(null, { status: 204 });
}The moment that row lands, the note appears in the other user’s /api/notes results, on any page
they have open. You don’t notify anyone; the query does it.
Authorization / Permissions
You already know how to implement authorization – it’s just an API endpoint that adds a query. There is no separate permission system, and no need for one. Your endpoints use ordinary business logic to decide which query to run:
// app/api/notes/route.ts
export async function GET(request: Request) {
const user = await getUser(request);
if (!user) return new Response(null, { status: 401 });
const ids = {
clientID: request.headers.get("Rindle-Client"),
queryID: request.headers.get("Rindle-Query"),
userID: user.id,
};
if (user.isAdmin) {
return rindle.addQuery(ids, `SELECT * FROM note ORDER BY id DESC`, []);
}
return rindle.addQuery(ids, `SELECT * FROM note WHERE owner = ? ORDER BY id DESC`, [user.id]);
}More authorization examples
| Rule | SQL |
|---|---|
| Only your own rows | WHERE owner = ? |
| Rows shared with you | OR EXISTS (SELECT 1 FROM share WHERE share.note_id = note.id AND share.user_id = ?) |
| Rows in your teams | WHERE EXISTS (SELECT 1 FROM member WHERE member.team_id = doc.team_id AND member.user_id = ?) |
| Hide a column | Select only the columns this user may see: SELECT id, title FROM …, not SELECT *. Only the selected columns reach the browser. |
| Different rules per role | Pick a different query in the endpoint: if (user.isAdmin) … |
Always pass the user’s id as a parameter from your server. Never take it from the request body or URL.
Authorization is Live
Note that because authorization in Rindle is based on live queries, the resulting authorization
logic is also live. When a note is unshared, it disappears from the other user’s open page right away, because their query no longer matches it.
The same holds for anything the rule can see: team membership, a role column, an archived flag.
Some rules live outside your data: a user logs out, gets banned, or has a role your auth provider holds. For those, end the user’s streams:
await rindle.disconnect({ userID: user.id });That stops every query for that user, in every tab. Their pages reconnect, your stream route checks
them again, and anything they are no longer allowed to see stays gone. rindle.disconnect({ userID, clientID })
disconnects one client.
Deploying
- Keep Rindle private. Only your servers need its address. Keep
RINDLE_URLin your server’s environment. - Vercel: set
maxDurationon the stream route. When Vercel ends the function, the stream reconnects and the page’s queries resume without calling your endpoints again. - Cloudflare: the stream route returns Rindle’s response as is, so the Worker uses almost no CPU
while a page is open. Add
"compatibility_flags": ["nodejs_compat"]to your wrangler config so the Worker can readRINDLE_URL.