Skip to content
Contents

TypeScript API

@rindle/reactive-sql-client

Everything is in @rindle/reactive-sql-client:

Runs on For
createReactiveSqlClient({ rindle }) Your server, scripts SQL, migrations, live queries
createReactiveSqlClient({ stream }) The browser Live queries through your endpoints
createRindleSessions() Your server The stream route and query endpoints
createSSRClient() Your server Server-side rendering

Server client

import { createReactiveSqlClient } from "@rindle/reactive-sql-client";

const db = await createReactiveSqlClient({ rindle: {} });

Has full access to the database. Keep it on the server.

Option
rindle.url The server’s address. Defaults to RINDLE_URL. A password in the URL is the token.
rindle.token Defaults to RINDLE_DATABASE_TOKEN.
rindle.fetch A custom fetch.
reveal "committed" (default) shows a write once the server commits it. "immediate" shows it as soon as it arrives.
onQueryError (query, error) => void. Called when a live query fails after it started.

Nothing is fetched until the first call.

db.materialize(sql, params?)

const issues = await db.materialize<Issue>(
  `SELECT * FROM issue WHERE closed = ? ORDER BY id DESC LIMIT 50`,
  [0],
);

Starts a live query. sql is Rindle SQL; params are strings, numbers, booleans and null. Rejects with SqlCompileError if the query can’t be kept live.

Returns a LiveQuery<Row>:

data The current rows.
resultType "unknown" until the first rows arrive, then "complete". Tells “no rows yet” from “no rows”.
subscribe(listener) Calls listener(rows) now and after every change. Returns an unsubscribe function.
destroy() Stops the query.

The same SQL and params, materialized twice, share one query on the server.

db.readOnce(sql, params?)

Runs a query once and returns its rows. Same SQL as materialize.

db.migrate(name, sql)

await db.migrate("0001_init", `
  CREATE TABLE issue (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, closed INTEGER);
`);

Applies a schema change once, by name. Calling it again with the same name does nothing and returns { applied: false }. A different script under a name that’s already applied throws.

Takes CREATE/DROP TABLE, ADD/DROP COLUMN and CREATE/DROP INDEX. Every table needs a primary key.

db.sql.execute(statement)

const { result } = await db.sql.execute({
  sql: `INSERT INTO issue (title, closed) VALUES (?, ?)`,
  args: ["first", 0],
});
result.lastInsertRowid; // "1"

Runs one statement. statement is a string, or { sql, args }, where args is an array for ? or an object for :name. Schema changes throw; use migrate.

result has:

columns { name, decltype }[]
rows Arrays in column order.
rowsAffected
lastInsertRowid A string, or null.

Retries are safe: each write carries a key, and the server applies it once.

db.sql.batch(statements)

Runs statements in one transaction. Returns { results }, one per statement.

db.sql.withTransaction(fn)

await db.sql.withTransaction(async (tx) => {
  const { rows } = await tx.execute({ sql: `SELECT stock FROM item WHERE id = ?`, args: [id] });
  if (rows[0][0] === 0) throw new Error("sold out");
  await tx.execute({ sql: `UPDATE item SET stock = stock - 1 WHERE id = ?`, args: [id] });
});

Commits when fn resolves and rolls back when it throws. Inside, tx.execute and tx.batch return results directly, without the { result } wrapper. withTransactionRetry takes the same fn and reruns it when another write conflicts.

db.close()

Closes the client and its live queries.

Browser client

const db = await createReactiveSqlClient({ stream: "/api/rindle-connect" });

Talks only to your API server. It has no SQL; your query endpoints pick the SQL.

Option
stream Your stream route.
headers Sent with every request. An object, or a function that returns one.
credentials Default "same-origin".
fetch The fetch this client calls for every request, including the stream. By default the client uses the global fetch, except that on Safari and iOS the stream uses XMLHttpRequest, to work around WebKit bug 322545. If you pass your own fetch, be aware of that WebKit bug.
hydrate true to pick up queries from a server-rendered page. See SSR.

db.watch(endpoint)

const notes = await db.watch<Note>("/api/notes");

Calls your endpoint with the Rindle-Client and Rindle-Query headers. Rows arrive on the stream. If the endpoint answers with an error, rejects with a LiveQueryError whose status is that code.

Returns the same data, resultType, subscribe and destroy as materialize, plus error. destroy() tells your stream route to remove the query.

In React, use useLiveQuery instead.

db.close()

Closes the stream.

createRindleSessions()

import { createRindleSessions } from "@rindle/reactive-sql-client";

const rindle = createRindleSessions(); // reads RINDLE_URL

What your stream route and query endpoints call. Each method returns a Response to return from your route unchanged.

connect(ids) Opens the client’s stream.
addQuery(ids, sql, params?) Starts a query on the client’s stream. 400 with the compiler’s message if the SQL can’t be kept live.
removeQuery(ids) Stops a query.
disconnect({ userID?, clientID? }) Ends streams for a user or a client. Returns how many.

ids is { clientID, queryID, userID? }, read from the request’s Rindle-Client and Rindle-Query headers and your auth. connect needs no queryID.

Options: url (default RINDLE_URL), token, fetch.

createSSRClient()

const db = createSSRClient({ rindle, request, userID: user?.id });

A client for one server render. See Server-Side Rendering.

load(endpoint) Calls the endpoint and waits for its rows. Never rejects.
peek(endpoint) The rows loaded so far, as { rows, status, error }.
dehydrateScript() A script for the page that hands the queries to the browser.