High-concurrency runtime

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_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 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.

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.