Skip to content
Contents

Architecture

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.

The client talks only to your API server The client calls your API server: one route opens the Rindle stream, and the others are ordinary endpoints, such as one that watches the user's notes and one that shares a note. Your API server calls the Rindle server to connect, to add a query, and to execute writes. Rindle's stream flows back to the API server, which passes it through to the client. CLIENT web, Node, mobile API SERVER Vercel function, Cloudflare Worker, … RINDLE SERVER /api/rindle-connect stream (proxied) /api/notes /api/notes/7/share /connect stream /add-query /exec

How it works

  1. 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.
  2. Each query is an endpoint. Framework-specific bindings like useLiveQuery() call your endpoints. The endpoint checks the user, picks the SQL, and calls rindle.addQuery. Rindle runs the query and sends its rows down the stream the page already has open.
  3. 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.
One query and one write, step by step The browser opens a stream through your server, and Rindle's stream comes back through it. The browser then calls a query endpoint, which checks the user, picks the SQL, and attaches it with rindle.addQuery. Rows arrive on the stream. Later the browser calls a write endpoint, which runs an UPDATE with an ownership check, and every affected query updates on the stream. CLIENT API SERVER RINDLE SERVER open the stream · /api/rindle-connect /connect · rindle.connect() stream (proxied) /api/notes check user · pick SQL /add-query · rindle.addQuery(sql, [user.id]) rows arrive on the open stream /api/notes/7/share /exec · INSERT … WHERE you own note 7 every affected query updates, for every user
Nothing goes back to the browser before a query starts. The endpoint attaches it, and rows arrive on the stream.

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 react

Create 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 writes

The stream route

One route per app. It authenticates the request and passes Rindle’s stream through.

  • clientID names one live client (each createReactiveSqlClient has its own), and queryID names one of its queries. The page sends them in the Rindle-Client and Rindle-Query headers. Each client has its own stream and its own queries.
  • userID is optional. Rindle stores it with the client and rejects any later call for that client with a different userID. Without it, knowing the clientID is 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_URL in your server’s environment.
  • Vercel: set maxDuration on 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 read RINDLE_URL.