Reference

Rindle SQL (@rindle/sql-client)

Use Rindle as an ordinary SQL database over HTTP — a fetch-native client, typed interactive transactions, session read-your-writes, and a Drizzle adapter.

View as Markdown

Rindle SQL is the plain-SQL face of a Rindle deployment. Instead of registering queries and receiving incremental deltas, you send SQL statements over HTTP and get rows back — the shape every existing ORM, migration tool and script already expects. It is the adoption path into the rest of the product: the same database can serve incrementally-maintained queries to a synced app at the same time.

@rindle/sql-client is the client. It runs anywhere with WinterCG fetch and has no runtime dependencies.

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

const sql = createSqlClient({
  url: process.env.RINDLE_REPLICATOR_URL ?? "http://127.0.0.1:7611",
  authToken: process.env.RINDLE_DATABASE_TOKEN!,
});

const inserted = await sql.execute({
  sql: "insert into user(name) values (?) returning id",
  args: ["Ada"],
});

await sql.withTransaction(async (tx) => {
  await tx.execute({ sql: "update account set balance = balance - ? where id = ?", args: [10, 1] });
  await tx.execute({ sql: "update account set balance = balance + ? where id = ?", args: [10, 2] });
});

Authentication

The initial server auth mode treats authToken as a trusted, database-wide server credential. Do not expose it to browsers or unrelated tenants; principal-bound read/write/DDL capability tokens are a separate fleet-auth follow-up.

The public SQL surface has its own credential, separate from the private replication/control plane — a SQL token can never be replayed against the internal routes.

Synced-app mutations

The API server uses this same transport internally for authoritative optimistic mutations. In the normal full-featured setup, pass database configuration and keep the daemon client for named queries, materializations, and rooms:

const api = createRindleApiServer({
  daemon,
  database: {
    url: process.env.RINDLE_REPLICATOR_URL ?? "http://127.0.0.1:7611",
    authToken: process.env.RINDLE_DATABASE_TOKEN!,
  },
  schema,
  queries,
  mutators,
});

This keeps routine application setup to the API-server and daemon-client packages. Server-only mutator code can use tx.sql to execute or query raw SQL in the mutation transaction, and a scoped mutator can use scope.sql for deliberate work outside that transaction. Import createSqlClient directly when ordinary SQL is itself the operation — scripts, migrations, ORM integration, admin work, or unrelated service code. The advanced API-server sql option still accepts an already-created, caller-owned session for testing or custom lifecycle management.

Pure-write mutators stay on a one-request path. A mutator that reads lazily opens an interactive mutation transaction at its first read, so its accumulated write prefix, reads, later writes, and watermark commit share one transaction. A thrown business rule rolls that transaction back before the server commits the watermark alone, allowing the browser’s optimistic queue to advance.

The underlying trusted-server methods are executeMutation, beginMutation, and rejectMutation. Each accepts { clientId, mid } from the browser mutation envelope. The client does not accept lmid: that is authoritative server state, returned in MutationReceipt only after the mutation effects (or an lmid-only rejection) commit. These explicit methods are separate from ordinary execute/begin, so generic SQL never accidentally enters the optimistic mutation protocol.

Consistency and read-your-writes

Reads default to session consistency. Persist getSessionCursor() with an end-user session and seed the next request with client.session(cursor) to preserve read-your-writes across serverless invocations without mixing different users’ fences.

const session = sql.session(cookie.rindleCursor ?? null);
await session.execute({ sql: "update user set name = ? where id = ?", args: ["Grace", 1] });
cookie.rindleCursor = session.getSessionCursor();

The client-wide consistency setting is a read default; it never turns writes into consistency errors. A per-call consistency option is explicit read intent, so the server rejects it when its own SQL classifier finds a write or DDL statement. The current server routes reads to the master: session and strong validate the canonical w:<seq> fence against the current head, while eventual deliberately skips the fence. Durable timeline ancestry across restore events is not yet encoded in this sequence-only cursor format.

Transactions and retries

Interactive transaction callbacks are not replayed unless explicitly requested. Use withTransactionRetry when the callback is safe to re-run after an OCC conflict.

One-shot writes keep one idempotency key across the client’s bounded automatic transport retries. If those attempts are all exhausted, the returned TRANSPORT_ERROR is outcome-unknown; invoking the method again creates a new logical operation and a new key. An exhausted server-declared request retry is likewise downgraded at this API boundary because the one-shot key is not exposed for a later invocation. Use a declared migration identity or the typed transaction API when an application must recover an operation across a longer outage.

Inside a typed transaction, an exhausted request-scope statement or commit keeps its operation ID: retry the same statement/batch or commit() call. A different statement or commit is refused while a statement retry is pending; rollback() remains available to abandon the transaction safely.

Drizzle

@rindle/sql-client/drizzle supplies the small structural client consumed by drizzle-orm/libsql 0.44.7; it is not a general @libsql/client implementation. The supported Drizzle peer is pinned exactly because this is a runtime structural seam, not a libSQL wire promise. Drizzle’s 0.44.7 libsql entry point itself imports its optional @libsql/client peer eagerly, so an application using that entry point must install @libsql/client even though Rindle does not use its transport.

import { drizzle } from "drizzle-orm/libsql";
import { createDrizzleClient } from "@rindle/sql-client/drizzle";

const db = drizzle(createDrizzleClient({ url, authToken }));

Top-level write, deferred, and read transactions are mapped to Rindle’s typed transaction API. Savepoints/nested transactions, embedded replica sync, and the libSQL wire protocol are not supported. The facade uses number-mode integer results because Drizzle’s SQLite mappers require numbers; unsafe integers fail rather than round. An injected, preconfigured SqlClient must not use intMode: "string".

Declared v1 value bounds

  • Safe integral number binds use SQLite’s INTEGER storage class; fractional and larger numeric number values use REAL. Use bigint when a larger integer is intended.
  • bigint binds are encoded losslessly across SQLite’s full signed-i64 wire range, including for predicates that read a previously returned key. Until exact-i64 capture lands, a mutation that would persist an integer outside the engine’s exact round-trip range fails at the capture boundary with VALUE_UNSUPPORTED; the bind itself remains legal. Because every write goes through that boundary, a table can never come to hold such a cell in the first place — so a supported database cannot expose a stored key that its next UPDATE or DELETE would be unable to capture.
  • Binary/Blob bind values fail locally with VALUE_UNSUPPORTED; no byte-to-text coercion occurs.
  • Columns declared BOOLEAN currently accept only canonical SQLite integer cells 0, 1, or NULL on replicated writes. Other integer storage values fail with VALUE_UNSUPPORTED instead of being silently normalized to a boolean by the IVM engine.
  • Results use bigint integers by default. intMode: "number" rejects integers outside JavaScript’s safe range, and intMode: "string" returns tagged integers as decimal strings.
  • Infinity and -Infinity use the v1 tagged JSON representation. NaN is unsupported.

Schema

Rindle SQL writes go through the same replicated schema envelope as the rest of the engine, so a table must have a declared primary key and use supported column types — see the schema page for the DDL subset. id TEXT PRIMARY KEY and Drizzle’s table-level primaryKey({ columns }) are both accepted; what is rejected is an actual NULL primary-key cell.

Call close() to reject new work and abort outstanding fetches. The platform owns connection pooling and keep-alive behavior.