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_URL!,
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] });
});
rindle dev injects both variables locally. Rindle Cloud’s Connect panel supplies
the same pair in production. The URL is the application ingress; callers do not need to
know which process owns the write master.
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.
RINDLE_DATABASE_TOKEN is the one application-facing server credential. The unified edge uses it
for trusted SQL and API-server traffic; private replication credentials remain an infrastructure
detail and are not application configuration.
Synced-app mutations
The API server uses this same transport internally for authoritative optimistic mutations. In the normal full-featured setup, pass the unified Rindle connection; the API server derives its SQL and read/control clients from it:
const api = createRindleApiServer({
rindle: {
url: process.env.RINDLE_URL!,
token: process.env.RINDLE_DATABASE_TOKEN!,
},
schema,
queries,
mutators,
});
This keeps routine application setup to one URL and one token. 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
The authoritative master executes interactive transactions on its HCTree connection pool. Multiple
transactions can run concurrently while successful commits still receive one total journal order.
Disjoint work can commit in parallel; withTransactionRetry is the opt-in callback replay surface
for an OCC conflict.
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.
Use rindle migrate apply for versioned deploy migrations. Migration files may contain pure DDL or
pure DML, but never both in one file; the CLI and master bind their kind and checksum to the
apply-once identity. The public SqlClient.migrate() method remains a DDL-only primitive. See
Schema & migrations for destructive DDL, data backfills, and limits.
For ad-hoc work, the CLI is the same client contract without application code:
rindle sql "select count(*) from issue"
rindle sql --file scripts/seed.sql
Call close() to reject new work and abort outstanding fetches. The platform owns connection
pooling and keep-alive behavior.