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

**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](/docs/replica-and-views) 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.

```ts
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:

```ts
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.

```ts
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.

```ts
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](/docs/schema) 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.

---

[View this page on Rindle](https://rindle.sh/docs/sql-client)
