> Complete content index: https://sirannon.sondelali.com/llms.txt

# Client SDK

> Call a remote Sirannon server from the browser or Node.js by name, with typed rows, live queries, automatic reconnection, and subscription restore.

URL: https://sirannon.sondelali.com/docs/client-sdk
Section: Networked access
Version: 0.3 (@delali/sirannon-db@0.3.0, latest)

The client SDK mirrors the core `Database` API with async methods. It provides HTTP and WebSocket transports, and the WebSocket transport reconnects on its own and restores active subscriptions after a drop. This page builds one file, `client.ts`, step by step. Open a connection, call a [registered read](/docs/registered-operations) and a registered write, watch a table, keep a result current, and then send a statement once the server accepts one.

This file connects to the server from the [server guide](/docs/server), which opens an `orders` database and registers `ordersByStatus` and `placeOrder`.

## Connect and read

A client connects to one server URL and picks a transport. `operationRef` names a registered operation and holds its argument and row types, and passing one to `query` sends that name with its arguments.

Give the client an `http://` or `https://` URL, whichever transport you pick, because it derives the WebSocket URL from that one. Under Node the client attaches `headers` to the WebSocket upgrade as well as to every HTTP request, which is how the bearer token below reaches the server's `authenticate` hook on both transports. A browser attaches no header to a WebSocket handshake, and a browser client therefore sends a short-lived ticket in `webSocketProtocols`, which the [security guide](/docs/security#authenticate-websocket-upgrades) covers in full.

```ts title="client.ts"

const ordersByStatus = operationRef<{ status: string }, { id: number; total: number; status: string }>('ordersByStatus')

const client = new SirannonClient('http://localhost:9876', {
  transport: 'websocket',
  autoReconnect: true,
  headers: { Authorization: 'Bearer local-dev-token' },
})

const db = client.database('orders')

const pending = await db.query(ordersByStatus, { status: 'pending' })

console.log(JSON.stringify(pending, (k, v) => (typeof v === 'number' ? Number(v.toFixed(2)) : v), 2))
```

```json result
[
  {
    "id": 1,
    "total": 4999,
    "status": "pending"
  },
  {
    "id": 2,
    "total": 12500,
    "status": "pending"
  }
]
```

`query` returns every matching row as an array, typed as the row the reference declares. When you expect a single row, read the first element and check for `undefined`.

Writing a reference by hand is fine for a handful of operations, and [code generation](/docs/code-generation) emits them from the registry once there are more.

## Run a write

`execute` takes a reference the same way. A registered write runs every one of its statements in one server-side transaction, so it answers with one result per statement.

```ts title="client.ts"

const ordersByStatus = operationRef<{ status: string }, { id: number; total: number; status: string }>('ordersByStatus')
const placeOrder = operationRef<{ total: number }>('placeOrder') // [!code ++]

const client = new SirannonClient('http://localhost:9876', {
  transport: 'websocket',
  autoReconnect: true,
  headers: { Authorization: 'Bearer local-dev-token' },
})

const db = client.database('orders')

const pending = await db.query(ordersByStatus, { status: 'pending' })
// [!code --:2]

console.log(JSON.stringify(pending, (k, v) => (typeof v === 'number' ? Number(v.toFixed(2)) : v), 2))
// [!code ++:3]
const results = await db.execute(placeOrder, { total: 2400 })

console.log(JSON.stringify({ pending: pending.length, results }, (k, v) => (typeof v === 'number' ? Number(v.toFixed(2)) : v), 2))
```

```json result
{
  "pending": 2,
  "results": [
    {
      "changes": 1,
      "lastInsertRowId": 4
    },
    {
      "changes": 1,
      "lastInsertRowId": 4
    }
  ]
}
```

`placeOrder` runs an insert and a counter update, so two results come back. The second is the update, which reports its own change count and repeats the row id the connection last inserted.

An argument the operation never declared fails with `ARGUMENT_NOT_ALLOWED`, and a missing one fails with `MISSING_ARGUMENT`, both before anything reaches SQLite. An argument the server fills from your identity is not yours to send at all.

## Subscribe to changes

`on(table).subscribe(callback)` opens a feed for a table and returns a handle you call `unsubscribe` on when you are done. It delivers the same row-level events as local [change data capture](/docs/change-data-capture), dispatched by the server as changes commit. The server has to be watching that table, and `await db.watch('orders')` there is what starts it.

The write below is what triggers the event, and the server dispatches it over the same socket while the client stays connected.

```ts title="client.ts"

const ordersByStatus = operationRef<{ status: string }, { id: number; total: number; status: string }>('ordersByStatus')
const placeOrder = operationRef<{ total: number }>('placeOrder')

const client = new SirannonClient('http://localhost:9876', {
  transport: 'websocket',
  autoReconnect: true,
  headers: { Authorization: 'Bearer local-dev-token' },
})

const db = client.database('orders')

const pending = await db.query(ordersByStatus, { status: 'pending' })
// [!code --:3]
const results = await db.execute(placeOrder, { total: 2400 })

console.log(JSON.stringify({ pending: pending.length, results }, (k, v) => (typeof v === 'number' ? Number(v.toFixed(2)) : v), 2))
// [!code ++:6]

await db.on('orders').subscribe((event) => {
  console.log(`order ${event.row.id} was ${event.type}ed, status ${event.row.status}`)
})

await db.execute(placeOrder, { total: 2400 })
```

The callback runs once for that write, printing `order 4 was inserted, status pending`.

The WebSocket transport tracks this subscription, and where the connection drops and `autoReconnect` brings it back, the server resumes the feed without you registering it again. Call `unsubscribe()` on the handle that `subscribe` returns to close the feed, and `client.close()` to shut the connection when the client is finished.

## Keep a result current

A subscription reports the rows that changed, and you decide what that means for what is on screen. A [live query](/docs/live-queries) reports the answer, because the server holds the result of a registered read and sends the client only what it needs to keep its copy current.

```ts title="client.ts"

const ordersByStatus = operationRef<{ status: string }, { id: number; total: number; status: string }>('ordersByStatus')
const placeOrder = operationRef<{ total: number }>('placeOrder')

const client = new SirannonClient('http://localhost:9876', {
  transport: 'websocket',
  autoReconnect: true,
  headers: { Authorization: 'Bearer local-dev-token' },
})

const db = client.database('orders')

// [!code --:5]
const pending = await db.query(ordersByStatus, { status: 'pending' })

await db.on('orders').subscribe((event) => {
  console.log(`order ${event.row.id} was ${event.type}ed, status ${event.row.status}`)
})
// [!code ++:6]
const pending = await db.live(ordersByStatus, { status: 'pending' })

pending.subscribe(() => {
  const state = pending.getState()
  if (state.status === 'ready') console.log(`${state.rows.length} pending orders`)
})

await db.execute(placeOrder, { total: 2400 })
```

The live query starts with the same two pending orders the read returns, and the write takes it to three without a second read of the table. The listener therefore prints `3 pending orders`. Both subscriptions and live queries need the WebSocket transport and fail with `TRANSPORT_ERROR` over HTTP.

## Send a statement

A server running with `acceptSql: true` also accepts statements, transactions, batches, and loads. Those methods take SQL text in place of a reference, and `statements.ts` keeps them in a file of their own because a server that only serves registered operations refuses every one of them.

```ts title="statements.ts"

const client = new SirannonClient('http://localhost:9876', {
  transport: 'websocket',
  headers: { Authorization: 'Bearer local-dev-token' },
})

const db = client.database('orders')

const users = await db.query<{ id: number; name: string }>('SELECT id, name FROM users WHERE active = ?', [1])

console.log(`${users.length} active users`)

await db.transaction([
  { sql: 'UPDATE accounts SET balance = balance - 50 WHERE id = ?', params: [1] },
  { sql: 'UPDATE accounts SET balance = balance + 50 WHERE id = ?', params: [2] },
])

await db.batch('INSERT INTO tags (label) VALUES (?)', [['sqlite'], ['realtime']])

client.close()
```

The client reads `GET /capabilities` once per server and caches the answer. When that answer omits `query.sql`, every one of these fails with `SQL_NOT_ACCEPTED` before it leaves the process, which keeps every call the server would refuse off the network. When the endpoint answers 404 the client refuses as well, because it cannot confirm what the server accepts.

The client sends the whole transaction in one request, and the server commits or rolls it back as a unit, which leaves the client out of the loop between statements. A registered write behaves the same way, and it sends no SQL.

## Import a large dataset

For an import too large for one request, `db.loadAll(sql, rows, options?)` streams any iterable or async iterable of parameter sets to the server in batches and returns the summed `{ rowsLoaded, changes }`. It sends each batch through the server's load route, restores durability after every batch, and performs the one fsyncing <Tooltip tip="A WAL checkpoint copies committed changes from SQLite's write-ahead log into the main database file, and fsync makes the operating system flush those bytes to the physical disk.">WAL checkpoint</Tooltip> after the final batch. Each batch must fit under the server's `maxBodyBytes`. It sends SQL, and it therefore needs `acceptSql: true` too. The [bulk load guide](/docs/bulk-load) documents `loadAll`, its `batchSize` and `durability` options, and the lower-level single-batch `load`.

## Ask for a fresher read

A read against a [replicated](/docs/distributed-replication) database can pass a `readConcern` that says how current the answer has to be. The HTTP and WebSocket transports send that per-call value to the server, which enforces it or fails with `READ_CONCERN_ERROR`.

```ts title="fresh-read.ts"

const ordersByStatus = operationRef<{ status: string }, { id: number; total: number; status: string }>('ordersByStatus')

const client = new SirannonClient('http://localhost:9876', {
  transport: 'websocket',
  headers: { Authorization: 'Bearer local-dev-token' },
})

const db = client.database('orders')

const fresh = await db.query(ordersByStatus, { status: 'pending' }, { readConcern: { level: 'linearizable' } })

console.log(`${fresh.length} pending orders`)

client.close()
```

[Topology-aware routing](/docs/topology-routing) works the other way around, because it applies one client-wide level when it chooses which node to read from, and it fails a per-call value with `INVALID_ARGUMENT`. Reach for that client when you have several nodes to route between, and keep it out of browser bundles.

## When the server refuses your credential

No WebSocket client can read the status of a refused handshake. A server that turns a credential away therefore closes the connection with an application close code: 4401 when it cannot identify the caller, and 4403 when it identifies the caller but does not permit them. The client raises `UNAUTHORIZED` or `FORBIDDEN` and uses the server's own code and message as the reason.

```ts title="refused.ts"

const ordersByStatus = operationRef<{ status: string }, { id: number; total: number; status: string }>('ordersByStatus')

const client = new SirannonClient('http://localhost:9876', {
  transport: 'websocket',
  headers: { Authorization: 'Bearer expired-token' },
})

try {
  await client.database('orders').query(ordersByStatus, { status: 'pending' })
} catch (error) {
  if (error instanceof RemoteError) console.log(`${error.code}: ${error.message}`)
}

client.close()
```

```txt result
UNAUTHORIZED: The server refused the WebSocket connection: UNAUTHORIZED: Invalid or missing token
```

A refused connection stays closed. `autoReconnect` skips every close code from 4000 to 4099, and each later call on that client fails with the same error, because the server refuses that credential every time. Issue a fresh credential and build a new client. Any other close code reports a network fault, and the client therefore raises `CONNECTION_ERROR` and reconnects while subscriptions remain.

## Client options

| Option | Default | Description |
| --- | --- | --- |
| `transport` | `'websocket'` | Choose `'websocket'` or `'http'`. |
| `headers` | none | Send custom HTTP headers, such as an `Authorization` bearer token. Node sends them on the WebSocket upgrade as well; a browser sends none. |
| `webSocketProtocols` | none | Offer WebSocket subprotocols during the upgrade, which is how a browser sends a credential. The client offers the plain `sirannon.v1` identifier ahead of your values, and the server selects that one. |
| `autoReconnect` | `true` | Reconnect the WebSocket transport automatically after a disconnect. |
| `reconnectInterval` | `1000` | Set the reconnect delay in milliseconds. |
| `requestTimeout` | `30000` | Per-request timeout in milliseconds on the WebSocket transport. Raise it for a large batch or load, and set it to 0 to wait indefinitely. |

Passing a routing option such as `endpoints` or `readPreference` to `SirannonClient` fails with `INVALID_ARGUMENT` and names the [topology entry point](/docs/topology-routing), which keeps node addresses out of a browser bundle.

Building a WebSocket-transport client with `headers` alone fails with `INVALID_ARGUMENT` in a browser, because a browser handshake sends no header and that credential would never reach the server. The client reports it at construction, before the first connection. Node attaches the headers to the upgrade, and the same client works there. A browser client that needs both passes `webSocketProtocols` alongside `headers`, where the headers reach every HTTP request and the ticket authenticates the socket.

A subprotocol also has to be a value a handshake header can hold, and a ticket built from a token your own system minted may therefore fail on the characters its encoding uses. The client checks every entry at construction, which puts the failure at the line that built the client. The file below offers four tickets and prints the outcome of each.

```ts title="subprotocols.ts"

for (const webSocketProtocols of [['ticket-abc'], ['ticket abc'], ['ticket-abc', 'ticket-abc'], ['']]) {
  try {
    const client = new SirannonClient('http://localhost:4000', { webSocketProtocols })
    console.log(JSON.stringify({ webSocketProtocols, outcome: 'accepted' }))
    await client.close()
  } catch (err) {
    const failure = err as { code?: string; message?: string }
    console.log(JSON.stringify({ webSocketProtocols, code: failure.code, message: failure.message }))
  }
}
```

```text result
{"webSocketProtocols":["ticket-abc"],"outcome":"accepted"}
{"webSocketProtocols":["ticket abc"],"code":"INVALID_ARGUMENT","message":"Entry 0 of 'webSocketProtocols' cannot be offered, because a subprotocol is one or more of the characters a header token allows, so it carries no space, comma, or quotation mark and is never empty. The entry carries a credential, so it is left out of this message."}
{"webSocketProtocols":["ticket-abc","ticket-abc"],"code":"INVALID_ARGUMENT","message":"Entry 1 of 'webSocketProtocols' cannot be offered, because a handshake refuses an offer that repeats a subprotocol. The entry carries a credential, so it is left out of this message."}
{"webSocketProtocols":[""],"code":"INVALID_ARGUMENT","message":"Entry 0 of 'webSocketProtocols' cannot be offered, because a subprotocol is one or more of the characters a header token allows, so it carries no space, comma, or quotation mark and is never empty. The entry carries a credential, so it is left out of this message."}
```

Sirannon names the position of the offending entry in every refusal and leaves the entry itself out, because that entry is a credential and a message quoting it would reach your logs. Standard base64 breaks the rule, because its `/` and `=` characters fall outside what a header token allows. Encode a ticket with the URL-safe alphabet and drop the padding, which leaves `A-Z`, `a-z`, `0-9`, `-`, and `_`, every one of which a token permits.

The [`SyncController`](/docs/device-sync#how-a-browser-device-sends-its-credential) applies the same two checks to its own `webSocketProtocols` and `headers`, and it refuses a malformed ticket at construction too.

The [security guide](/docs/security) covers authentication patterns for both transports, including the browser WebSocket credential flow.
