Networked access

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.

Table of Contents

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 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, 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 covers in full.

client.ts
import { operationRef } from '@delali/sirannon-db'
import { SirannonClient } from '@delali/sirannon-db/client'
 
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))

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

client.ts
import { operationRef } from '@delali/sirannon-db'
import { SirannonClient } from '@delali/sirannon-db/client'
 
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' })
 
console.log(JSON.stringify(pending, (k, v) => (typeof v === 'number' ? Number(v.toFixed(2)) : v), 2))
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))

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

client.ts
import { operationRef } from '@delali/sirannon-db'
import { SirannonClient } from '@delali/sirannon-db/client'
 
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' })
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))
 
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 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.

client.ts
import { operationRef } from '@delali/sirannon-db'
import { SirannonClient } from '@delali/sirannon-db/client'
 
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' })
 
await db.on('orders').subscribe((event) => {
  console.log(`order ${event.row.id} was ${event.type}ed, status ${event.row.status}`)
})
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.

statements.ts
import { SirannonClient } from '@delali/sirannon-db/client'
 
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 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 documents loadAll, its batchSize and durability options, and the lower-level single-batch load.

Ask for a fresher read

A read against a replicated 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.

fresh-read.ts
import { operationRef } from '@delali/sirannon-db'
import { SirannonClient } from '@delali/sirannon-db/client'
 
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 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.

refused.ts
import { operationRef } from '@delali/sirannon-db'
import { RemoteError, SirannonClient } from '@delali/sirannon-db/client'
 
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()

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

OptionDefaultDescription
transport'websocket'Choose 'websocket' or 'http'.
headersnoneSend custom HTTP headers, such as an Authorization bearer token. Node sends them on the WebSocket upgrade as well; a browser sends none.
webSocketProtocolsnoneOffer 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.
autoReconnecttrueReconnect the WebSocket transport automatically after a disconnect.
reconnectInterval1000Set the reconnect delay in milliseconds.
requestTimeout30000Per-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, 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.

subprotocols.ts
import { SirannonClient } from '@delali/sirannon-db/client'
 
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 }))
  }
}

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 applies the same two checks to its own webSocketProtocols and headers, and it refuses a malformed ticket at construction too.

The security guide covers authentication patterns for both transports, including the browser WebSocket credential flow.