Sirannon keeps each SQLite engine behind its own driver, so install the core package once and then add the driver that matches your runtime. The API stays the same on every runtime. You'll build one file, db.ts, step by step on this page. You'll open a database, create a table, insert parameterised rows, and read them back with a typed query. At each step, the highlighted lines are the ones that changed.
Install the package and a driver
Install the core package and the driver for your runtime. You'll use better-sqlite3 on Node.js throughout this page.
pnpm add -E @delali/sirannon-db
pnpm add -E better-sqlite3Every supported runtime has its own driver, so import the one for the runtime that your code targets.
| Runtime | Driver import |
|---|---|
| Node.js | @delali/sirannon-db/driver/better-sqlite3 |
| Node.js 22+ (built-in SQLite) | @delali/sirannon-db/driver/node |
| Browser (IndexedDB persistence) | @delali/sirannon-db/driver/wa-sqlite |
| Bun | @delali/sirannon-db/driver/bun |
| React Native (Expo) | @delali/sirannon-db/driver/expo |
You call the same API on all five. Three of its features, however, depend on the SQLite engine underneath, and driver.capabilities holds a flag for each of them: multipleConnections, extensions, and steppedCopy. Once a database is open, db.backupCapabilities() returns more detail about backups.
| Driver | multipleConnections | extensions (guide) | steppedCopy (guide) |
|---|---|---|---|
better-sqlite3 | Yes | Yes | Yes |
node | Yes | Yes | Yes, on a build whose node:sqlite exports a backup function |
bun | Yes | Yes | No |
wa-sqlite | No | No | No |
expo | No | No | No |
A driver that reports multipleConnections: false sends every read through the writer under the writer lock. A driver that reports steppedCopy: false fails db.backup with BACKUP_UNSUPPORTED, so copy a browser or device database through the device sync snapshot.
Sirannon writes the database file into a directory that you create yourself, so make that directory before you start any of the code below.
mkdir -p dataOpen a database
Create a driver, pass it to a Sirannon instance, and open a named database at a file path. The first argument is the database id that you use to refer to it later, while the second is the path to the file on disk. Start db.ts here.
import { Sirannon } from '@delali/sirannon-db'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })
const db = await sirannon.open('tasks', './data/tasks.db')open returns a Database handle, which reads and writes through separate connections and keeps on by default. The handle also accepts options such as readPoolSize, walMode, and readOnly for when you need them.
Create a table
With the database open, create the table that your application uses. execute applies a statement that changes data or schema, and it returns the number of changes and the lastInsertRowId. Add a tasks table for a small task tracker.
import { Sirannon } from '@delali/sirannon-db'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })
const db = await sirannon.open('tasks', './data/tasks.db')
await db.execute(
'CREATE TABLE IF NOT EXISTS tasks (id INTEGER PRIMARY KEY, title TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0)'
)CREATE TABLE IF NOT EXISTS lets you repeat the step safely, because SQLite creates the table once and leaves it untouched on every later try.
Insert parameterised rows
Build every statement from a fixed string, and pass every value through a ? placeholder so that the driver binds it safely. Insert three tasks, and pass each task's title and done flag as parameters.
import { Sirannon } from '@delali/sirannon-db'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })
const db = await sirannon.open('tasks', './data/tasks.db')
await db.execute(
'CREATE TABLE IF NOT EXISTS tasks (id INTEGER PRIMARY KEY, title TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0)'
)
await db.execute('INSERT INTO tasks (title, done) VALUES (?, ?)', ['Draft the launch note', 0])
await db.execute('INSERT INTO tasks (title, done) VALUES (?, ?)', ['Review the pull request', 1])
await db.execute('INSERT INTO tasks (title, done) VALUES (?, ?)', ['Ship the release', 0])Three separate calls are easy to read, but inserting a longer list this way sends one statement per row. executeBatch applies the same statement to many parameter sets in a single call. Swap the three inserts for one batch.
import { Sirannon } from '@delali/sirannon-db'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })
const db = await sirannon.open('tasks', './data/tasks.db')
await db.execute(
'CREATE TABLE IF NOT EXISTS tasks (id INTEGER PRIMARY KEY, title TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0)'
)
await db.execute('INSERT INTO tasks (title, done) VALUES (?, ?)', ['Draft the launch note', 0])
await db.execute('INSERT INTO tasks (title, done) VALUES (?, ?)', ['Review the pull request', 1])
await db.execute('INSERT INTO tasks (title, done) VALUES (?, ?)', ['Ship the release', 0])
await db.executeBatch('INSERT INTO tasks (title, done) VALUES (?, ?)', [
['Draft the launch note', 0],
['Review the pull request', 1],
['Ship the release', 0],
])Each inner array is one parameter set, which the driver binds to the placeholders in turn, so a single call inserts all three rows.
Read rows with a typed query
Read the rows back. query<T> returns an array of T, while queryOne<T> returns a single row or undefined. Supply a row type so that TypeScript types the results. Fetch the outstanding tasks in id order.
import { Sirannon } from '@delali/sirannon-db'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })
const db = await sirannon.open('tasks', './data/tasks.db')
await db.execute(
'CREATE TABLE IF NOT EXISTS tasks (id INTEGER PRIMARY KEY, title TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0)'
)
await db.executeBatch('INSERT INTO tasks (title, done) VALUES (?, ?)', [
['Draft the launch note', 0],
['Review the pull request', 1],
['Ship the release', 0],
])
const outstanding = await db.query<{ id: number; title: string }>(
'SELECT id, title FROM tasks WHERE done = ? ORDER BY id',
[0]
)You can make the same calls inside db.transaction(async (tx) => { ... }), which commits when the callback resolves and rolls back when it throws.
Always pass user input through placeholders. Sirannon validates identifiers and refuses paths with traversal segments or control characters, while the driver binds each placeholder's value apart from the SQL text.
Grow into a networked service
Your query code stays the same when you grow beyond an embedded file:
- The package's
serverexport serves databases over HTTP and WebSocket on uWebSockets.js. It executes the reads and writes that you register, so a caller sends a name and arguments with no SQL. - The
clientexport provides a remote database proxy for applications, whileclient/topologyroutes requests across the nodes of a replication group. - The
reactexport wraps a live query in a hook, whilecodegengenerates typed references from your registry. - A primary replicates its changes to other nodes over gRPC, while coordinator mode stores write authority in etcd for automatic failover.
The server runs on uWebSockets.js, a package that the npm registry doesn't list under that name. Install the tagged GitHub release v20.69.0, which is the version in Sirannon's own development dependencies, before you start a server:
pnpm add -E "uWebSockets.js@github:uNetworking/uWebSockets.js#v20.69.0"Yarn 4.16 refuses a Git dependency until you list its repository under approvedGitRepositories in .yarnrc.yml, so in a Yarn project, add https://github.com/uNetworking/uWebSockets.js.git to that list first. Sirannon loads uWebSockets.js when you call server.listen(), so importing @delali/sirannon-db/server works without it. When the process cannot load the package, listen() fails with SERVER_DEPENDENCY_MISSING, and the error message gives the install command above. The gRPC replication transport and the etcd coordinator also load their packages only when they first need them. When a package is missing, they fail with TRANSPORT_DEPENDENCY_MISSING and COORDINATOR_DEPENDENCY_MISSING respectively, and each message gives its own install command.
Where to go next
- Read the Sirannon README for every feature area, including change data capture, migrations, backups, hooks, and metrics.
- Read the benchmarks page for the suite that compares Sirannon against Postgres 17 and for how to reproduce its results.
- Read the changelog for what each release added.