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
numberbinds use SQLite’s INTEGER storage class; fractional and larger numericnumbervalues use REAL. Usebigintwhen a larger integer is intended. bigintbinds 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 withVALUE_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
BOOLEANcurrently accept only canonical SQLite integer cells0,1, orNULLon replicated writes. Other integer storage values fail withVALUE_UNSUPPORTEDinstead of being silently normalized to a boolean by the IVM engine. - Results use
bigintintegers by default.intMode: "number"rejects integers outside JavaScript’s safe range, andintMode: "string"returns tagged integers as decimal strings. Infinityand-Infinityuse the v1 tagged JSON representation.NaNis 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.