@delali/sirannon-db
v0.1.4
Published
A production-grade library that turns SQLite databases into a networked data layer with real-time subscriptions.
Downloads
244
Maintainers
Readme
sirannon-db
Turn any SQLite database into a networked data layer with real-time subscriptions. One library gives you connection pooling, change data capture, migrations, scheduled backups, and a client SDK that talks over HTTP or WebSocket.
sirannon means 'gate-stream' in Sindarin.
Install
pnpm add @delali/sirannon-dbThen install the SQLite driver for your platform:
pnpm add better-sqlite3 # Node.js
pnpm add wa-sqlite # Browser (IndexedDB persistence)
pnpm add expo-sqlite # React Native (Expo)
# Node 22+ built-in sqlite and Bun need no extra packageQuick start
Node.js
pnpm add @delali/sirannon-db better-sqlite3import { 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('app', './data/app.db')
await db.execute('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)')
await db.execute('INSERT INTO users (name, email) VALUES (?, ?)', ['Ada', '[email protected]'])
const users = await db.query<{ id: number; name: string }>('SELECT * FROM users')Node.js 22+ users can skip the extra dependency by using the built-in node:sqlite module (requires the --experimental-sqlite flag):
import { nodeSqlite } from '@delali/sirannon-db/driver/node'
const driver = nodeSqlite()Browser
pnpm add @delali/sirannon-db wa-sqliteThe browser driver persists data to IndexedDB through a WebAssembly SQLite build. Use Database.create directly since Sirannon registries are designed for server-side use.
import { Database } from '@delali/sirannon-db'
import { waSqlite } from '@delali/sirannon-db/driver/wa-sqlite'
const driver = waSqlite({ vfs: 'IDBBatchAtomicVFS' })
const db = await Database.create('app', '/app.db', driver, {
readPoolSize: 1,
walMode: false,
})
await db.execute('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)')
await db.execute('INSERT INTO users (name, email) VALUES (?, ?)', ['Ada', '[email protected]'])
const users = await db.query<{ id: number; name: string }>('SELECT * FROM users')React Native (Expo)
pnpm add @delali/sirannon-db expo-sqliteimport { Sirannon } from '@delali/sirannon-db'
import { expoSqlite } from '@delali/sirannon-db/driver/expo'
const driver = expoSqlite()
const sirannon = new Sirannon({ driver })
const db = await sirannon.open('app', 'app.db', {
readPoolSize: 1,
})
await db.execute('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)')
await db.execute('INSERT INTO users (name, email) VALUES (?, ?)', ['Ada', '[email protected]'])
const users = await db.query<{ id: number; name: string }>('SELECT * FROM users')Bun
No extra dependency needed since Bun ships bun:sqlite built in.
import { Sirannon } from '@delali/sirannon-db'
import { bunSqlite } from '@delali/sirannon-db/driver/bun'
const driver = bunSqlite()
const sirannon = new Sirannon({ driver })
const db = await sirannon.open('app', './data/app.db')Standalone databases
You can create databases without a Sirannon registry on any platform:
const db = await Database.create('app', './data/app.db', driver)Pluggable drivers
Sirannon-db separates the database engine from the library. You pick the driver that fits your runtime, and the rest of the API stays the same.
| Driver | Import | Runtime | Install |
| --- | --- | --- | --- |
| better-sqlite3 | @delali/sirannon-db/driver/better-sqlite3 | Node.js | pnpm add better-sqlite3 |
| Node built-in | @delali/sirannon-db/driver/node | Node.js >= 22 | None (use --experimental-sqlite flag) |
| wa-sqlite | @delali/sirannon-db/driver/wa-sqlite | Browser | pnpm add wa-sqlite |
| Bun | @delali/sirannon-db/driver/bun | Bun | None (uses bun:sqlite) |
| Expo | @delali/sirannon-db/driver/expo | React Native | pnpm add expo-sqlite |
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
const driver = betterSqlite3()
// or for Node 22's built-in sqlite:
import { nodeSqlite } from '@delali/sirannon-db/driver/node'
const driver = nodeSqlite()
// or for browser with IndexedDB persistence:
import { waSqlite } from '@delali/sirannon-db/driver/wa-sqlite'
const driver = waSqlite({ vfs: 'IDBBatchAtomicVFS' })Package exports
The package ships independent exports so you only bundle what you need:
| Import | What you get |
| --- | --- |
| @delali/sirannon-db | Core library: queries, transactions, CDC, migrations, backups, hooks, metrics, lifecycle |
| @delali/sirannon-db/driver/* | SQLite driver adapters (see table above) |
| @delali/sirannon-db/file-migrations | Load .up.sql / .down.sql files from a directory |
| @delali/sirannon-db/server | HTTP + WebSocket server powered by uWebSockets.js |
| @delali/sirannon-db/client | Browser/Node.js client SDK with auto-reconnect and subscription restore |
Core features
Queries and transactions
const row = await db.queryOne<{ count: number }>('SELECT count(*) as count FROM users')
const result = await db.execute(
'INSERT INTO users (name, email) VALUES (?, ?)',
['Grace', '[email protected]'],
)
// result.changes === 1, result.lastInsertRowId === 2
await db.executeBatch('INSERT INTO tags (label) VALUES (?)', [
['typescript'],
['sqlite'],
['realtime'],
])
const total = await db.transaction(async tx => {
await tx.execute('UPDATE accounts SET balance = balance - 100 WHERE id = ?', [1])
await tx.execute('UPDATE accounts SET balance = balance + 100 WHERE id = ?', [2])
const [row] = await tx.query<{ balance: number }>('SELECT balance FROM accounts WHERE id = ?', [2])
return row
})Connection pooling
Every database opens with 1 dedicated write connection and N read connections (default 4). WAL mode is enabled by default, allowing concurrent reads during writes.
const db = await sirannon.open('analytics', './data/analytics.db', {
readPoolSize: 8,
walMode: true,
})Change data capture (CDC)
Watch tables for INSERT, UPDATE, and DELETE events in real time. The CDC system installs SQLite triggers that record changes into a tracking table, then polls at a configurable interval.
await db.watch('orders')
const subscription = db
.on('orders')
.filter({ status: 'shipped' })
.subscribe(event => {
// event.type: 'insert' | 'update' | 'delete'
// event.row: the current row
// event.oldRow: previous row (updates and deletes)
// event.seq: monotonic sequence number
console.log(`Order ${event.row.id} was ${event.type}d`)
})
// Stop listening:
subscription.unsubscribe()
// Stop tracking entirely:
await db.unwatch('orders')Migrations
Place numbered SQL files in a directory using the .up.sql / .down.sql convention. Each migration runs inside a transaction and is tracked in a _sirannon_migrations table so it only applies once. Down files are optional; rollback throws if a down file is missing for a version being rolled back.
migrations/
001_create_users.up.sql
001_create_users.down.sql
002_add_email_index.up.sql
003_create_orders.up.sql
003_create_orders.down.sqlTimestamp-based versioning works the same way:
migrations/
1709312400_create_users.up.sql
1709312400_create_users.down.sqlFile-based migrations
import { loadMigrations } from '@delali/sirannon-db/file-migrations'
const migrations = loadMigrations('./migrations')
const result = await db.migrate(migrations)
// result.applied: entries that ran this time
// result.skipped: number of entries already appliedRollback
const migrations = loadMigrations('./migrations')
await db.rollback(migrations) // undo the last applied migration
await db.rollback(migrations, 2) // undo all migrations after version 2
await db.rollback(migrations, 0) // undo everythingProgrammatic migrations
Pass an array of migration objects instead of loading from files:
const migrations = [
{
version: 1,
name: 'create_users',
up: 'CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)',
down: 'DROP TABLE users',
},
]
await db.migrate(migrations)
await db.rollback(migrations) // undo last migration
await db.rollback(migrations, 0) // undo everythingBackups
One-shot backups use VACUUM INTO for a consistent snapshot. Scheduled backups run on a cron expression with automatic file rotation.
await db.backup('./backups/snapshot.db')
db.scheduleBackup({
cron: '0 */6 * * *', // every 6 hours
destDir: './backups',
maxFiles: 10, // keep the 10 most recent
onError: err => console.error('Backup failed:', err),
})Hooks
Hooks run before or after key operations. Throwing from a before-hook denies the operation.
sirannon.onBeforeQuery(ctx => {
if (ctx.sql.includes('DROP')) {
throw new Error('DROP statements are not allowed')
}
})
sirannon.onAfterQuery(ctx => {
console.log(`[${ctx.databaseId}] ${ctx.sql} took ${ctx.durationMs}ms`)
})
sirannon.onDatabaseOpen(ctx => {
console.log(`Opened ${ctx.databaseId} at ${ctx.path}`)
})Global hooks on the Sirannon instance: onBeforeQuery, onAfterQuery, onBeforeConnect, onDatabaseOpen, onDatabaseClose. The onBeforeSubscribe hook is available through the HookConfig constructor option. Query hooks (onBeforeQuery, onAfterQuery) can also be registered locally on individual Database instances.
Note: The ctx.sql.includes('DROP') pattern shown above is for illustration only. Simple string matching is not a production SQL firewall because casing, comments, Unicode tricks, and concatenated SQL can bypass it. For real access control, combine onBeforeQuery with an allow-list of query patterns or a proper SQL parser.
Metrics
Plug in callbacks to collect query timing, connection events, and CDC activity.
const sirannon = new Sirannon({
driver,
metrics: {
onQueryComplete: m => histogram.observe(m.durationMs),
onConnectionOpen: m => gauge.inc({ db: m.databaseId }),
onConnectionClose: m => gauge.dec({ db: m.databaseId }),
onCDCEvent: m => counter.inc({ table: m.table, op: m.operation }),
},
})Lifecycle management
For multi-tenant setups, the lifecycle manager handles auto-opening, idle timeouts, and LRU eviction so you don't have to manage database handles yourself.
const sirannon = new Sirannon({
driver,
lifecycle: {
autoOpen: {
resolver: id => ({ path: `/data/tenants/${id}.db` }),
},
idleTimeout: 300_000, // close after 5 minutes of inactivity
maxOpen: 50, // evict least-recently-used when full
},
})
// Databases resolve on first access:
const db = await sirannon.resolve('tenant-42') // opens /data/tenants/tenant-42.dbServer
Expose any Sirannon instance over HTTP and WebSocket with a single function call. The server uses uWebSockets.js for high throughput.
import { Sirannon } from '@delali/sirannon-db'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
import { createServer } from '@delali/sirannon-db/server'
const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })
await sirannon.open('app', './data/app.db')
const server = createServer(sirannon, { port: 9876 })
await server.listen()See the Security section for authentication, TLS, and CORS configuration.
HTTP routes
| Method | Path | Description |
| --- | --- | --- |
| POST | /db/:id/query | Execute a SELECT, returns { rows } |
| POST | /db/:id/execute | Execute a mutation, returns { changes, lastInsertRowId } |
| POST | /db/:id/transaction | Execute a batch of statements atomically, returns { results } |
| GET | /health | Liveness check |
| GET | /health/ready | Readiness check with per-database status |
WebSocket protocol
Connect to ws://host:port/db/:id and send JSON messages for queries, executions, and CDC subscriptions. The server dispatches change events to subscribers in real time.
Client SDK
The client SDK mirrors the core Database API with async methods. It supports both HTTP and WebSocket transports, with automatic reconnection and subscription restoration on the WebSocket transport.
import { SirannonClient } from '@delali/sirannon-db/client'
const client = new SirannonClient('http://localhost:9876', {
transport: 'websocket',
autoReconnect: true,
reconnectInterval: 1000,
})
const db = client.database('app')
const users = await db.query<{ id: number; name: string }>('SELECT * FROM users')
await db.execute('INSERT INTO users (name) VALUES (?)', ['Turing'])
const sub = db.subscribe('users', event => {
console.log('User changed:', event)
})
// Cleanup:
sub.unsubscribe()
client.close()Transactions use the HTTP transport:
const httpClient = new SirannonClient('http://localhost:9876', {
transport: 'http',
})
const httpDb = httpClient.database('app')
await httpDb.transaction([
{ sql: 'UPDATE accounts SET balance = balance - 50 WHERE id = ?', params: [1] },
{ sql: 'UPDATE accounts SET balance = balance + 50 WHERE id = ?', params: [2] },
])
httpClient.close()Security
Sirannon-db is designed to be secure by default in its core operations. This section covers what the library handles for you and what you're responsible for when deploying to production.
Built-in protections
- Parameterized queries - All SQL execution uses parameter binding through the driver layer, preventing SQL injection. User input never touches query strings directly.
- Identifier validation - CDC table and column names are validated against a strict allowlist regex (
/^[a-zA-Z_][a-zA-Z0-9_]*$/), and identifiers are escaped with double-quote wrapping. - Path traversal prevention - Migration and backup paths reject null bytes,
..segments, and control characters before any filesystem access. - Request size limits - HTTP bodies and WebSocket payloads are capped at 1 MB, preventing memory exhaustion from oversized requests.
- Error isolation - Errors returned to clients contain a machine-readable code and message. Stack traces and internal details are never leaked.
- Connection isolation - Read and write operations use separate connection pools. Read-only databases enforce immutability at the connection level.
Authentication and authorization
The server accepts arbitrary SQL from clients. When you expose it beyond localhost, always use the onRequest hook to authenticate and authorize requests.
const server = createServer(sirannon, {
port: 9876,
onRequest: ({ headers, path, method, remoteAddress }) => {
if (headers.authorization !== `Bearer ${process.env.API_TOKEN}`) {
return { status: 401, code: 'UNAUTHORIZED', message: 'Invalid or missing token' }
}
},
})The hook runs before every database route (HTTP and WebSocket upgrade). Return void to allow the request, or return a { status, code, message } object to deny it. Health endpoints (/health, /health/ready) bypass this hook.
TLS and transport security
Warning: The built-in server binds plain HTTP and WebSocket without TLS. When you serve traffic outside a trusted network, terminate TLS upstream with a reverse proxy (nginx, Caddy, a cloud load balancer) or your clients' bearer tokens and query payloads will travel in cleartext.
Once TLS is in place, update your client URLs to use https:// and wss://:
const client = new SirannonClient('https://db.example.com', {
transport: 'websocket',
headers: { Authorization: `Bearer ${token}` },
})CORS
CORS is disabled by default. Enable it only if browser clients need direct access, and restrict origins to trusted domains:
const server = createServer(sirannon, {
port: 9876,
cors: {
origin: ['https://app.example.com'],
},
})Passing cors: true allows all origins, which is fine for local development but should be avoided in production.
Error handling
All errors extend SirannonError with a machine-readable code property:
| Error | Code | When |
| --- | --- | --- |
| DatabaseNotFoundError | DATABASE_NOT_FOUND | Database ID not in registry |
| DatabaseAlreadyExistsError | DATABASE_ALREADY_EXISTS | Duplicate database ID |
| ReadOnlyError | READ_ONLY | Write attempted on read-only database |
| QueryError | QUERY_ERROR | SQL execution failure |
| TransactionError | TRANSACTION_ERROR | Transaction commit/rollback failure |
| MigrationError | MIGRATION_ERROR | Migration step failure |
| HookDeniedError | HOOK_DENIED | Before-hook rejected the operation |
| CDCError | CDC_ERROR | Change tracking pipeline failure |
| BackupError | BACKUP_ERROR | Backup operation failure |
| ConnectionPoolError | CONNECTION_POOL_ERROR | Pool closed or misconfigured |
| MaxDatabasesError | MAX_DATABASES | Capacity limit reached |
| ExtensionError | EXTENSION_ERROR | SQLite extension load failure |
import { QueryError } from '@delali/sirannon-db'
try {
await db.execute('INSERT INTO users (id) VALUES (?)', [1])
} catch (err) {
if (err instanceof QueryError) {
console.error(`SQL failed [${err.code}]: ${err.message}`)
console.error(`Statement: ${err.sql}`)
}
}Configuration reference
SirannonOptions
| Option | Type | Required | Description |
| --- | --- | --- | --- |
| driver | SQLiteDriver | Yes | The SQLite driver adapter to use |
| hooks | HookConfig | No | Before/after hooks for queries, connections, subscriptions |
| metrics | MetricsConfig | No | Callbacks for query timing, connection events, CDC activity |
| lifecycle | LifecycleConfig | No | Auto-open resolver, idle timeout, max open databases |
DatabaseOptions
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| readOnly | boolean | false | Open in read-only mode |
| readPoolSize | number | 4 | Number of read connections |
| walMode | boolean | true | Enable WAL mode |
| cdcPollInterval | number | 50 | CDC polling interval in ms |
| cdcRetention | number | 3_600_000 | CDC retention period in ms (1 hour) |
ServerOptions
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| host | string | '127.0.0.1' | Bind address |
| port | number | 9876 | Listen port |
| cors | boolean \| CorsOptions | false | CORS configuration |
| onRequest | OnRequestHook | - | Middleware hook for auth, rate limiting, and request validation |
ClientOptions
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| transport | 'websocket' \| 'http' | 'websocket' | Transport protocol |
| headers | Record<string, string> | - | Custom HTTP headers |
| autoReconnect | boolean | true | Reconnect on WebSocket disconnect |
| reconnectInterval | number | 1000 | Reconnect delay in ms |
Examples
Self-contained example projects live in examples/ and cover every runtime target:
| Example | Runtime | Driver | What it demonstrates |
| --- | --- | --- | --- |
| node-better-sqlite3 | Node.js | better-sqlite3 | All core features: schema, migrations, CRUD, transactions, CDC, connection pools, metrics, multi-tenant, hooks, backup, shutdown |
| node-native | Node.js >= 22 | built-in node:sqlite | Same features as above using the zero-dependency Node driver |
| web-wa-sqlite | Browser (Vite) | wa-sqlite + IndexedDB | CRUD, transactions, CDC subscriptions in the browser |
| web-client | Browser + Node.js | better-sqlite3 (server) | Client SDK connecting to a Sirannon server over HTTP and WebSocket |
Running the examples
From the repository root:
pnpm install
pnpm --filter @delali/sirannon-db buildThen pick an example:
# Node.js with better-sqlite3
cd packages/ts/examples/node-better-sqlite3
pnpm start
# Node.js with built-in sqlite
cd packages/ts/examples/node-native
pnpm start
# Browser with wa-sqlite (opens Vite dev server)
cd packages/ts/examples/web-wa-sqlite
pnpm dev
# Client-server (starts both Sirannon server and Vite client)
cd packages/ts/examples/web-client
pnpm startBenchmarks
The benchmark suite compares Sirannon's embedded SQLite performance against Postgres 17 across micro-operations, YCSB, TPC-C, and concurrency scaling. All benchmarks support driver switching via the BENCH_DRIVER environment variable (better-sqlite3 or node). See benchmarks/BENCHMARKS.md for setup instructions, configuration, Docker-based fair comparisons, and statistical analysis methodology.
Development
pnpm install
pnpm build
pnpm test
pnpm typecheck
pnpm lintLicense
Apache-2.0
