Three cross-cutting systems observe and control every database a Sirannon instance owns. Hooks intercept operations, metrics callbacks report timings and events, and the lifecycle manager opens and closes database handles for you. This page builds one file, tenants.ts, step by step. You will start from a driver and a bare instance, register hooks on it, and then grow the constructor with a metrics block and a lifecycle block. Every step marks the lines that changed.
Each tenant database is a file in a directory you create yourself, so make one before you run the code below.
mkdir -p data/tenantsConstruct the instance
Start tenants.ts with a driver and a Sirannon instance. Everything else on this page attaches to this instance.
import { Sirannon } from '@delali/sirannon-db'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })Register hooks
Hooks run before or after key operations, so a before-hook that throws denies the operation with HOOK_DENIED. Register them on the instance you built. A before-query hook can reject statements, an after-query hook can log timings, and a database-open hook can record which tenant databases open.
import { Sirannon } from '@delali/sirannon-db'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })
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 cover onBeforeQuery, onAfterQuery, onBeforeConnect, onDatabaseOpen, and onDatabaseClose. The context object exposes databaseId, sql, durationMs, and path, each one where it applies.
The ctx.sql.includes('DROP') pattern above illustrates the hook and nothing more. Substring matching lets
different casing, comments, and concatenated SQL straight through, so it can never work as a SQL firewall. For
real access control, combine onBeforeQuery with an allowlist of query patterns or a SQL parser.
Add metrics callbacks
Hooks give you a place to run code; metrics give you the numbers. Pass a metrics block to the constructor to collect query timing, connection events, and CDC activity, and feed each callback straight into your histogram, gauge, and counter types. Keep the constructor call you already have and extend it, so that the hooks you registered above still apply. The histogram, gauge, and counter values here are minimal stand-ins so that the file runs; swap in your real metrics client such as Prometheus or StatsD.
import { Sirannon } from '@delali/sirannon-db'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })
const histogram = { observe: (ms: number) => {} }
const gauge = { inc: (labels: { db: string }) => {}, dec: (labels: { db: string }) => {} }
const counter = { inc: (labels: { table: string; op: string }) => {} }
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 }),
},
})
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}`)
})Each callback receives one argument. onQueryComplete receives durationMs, onConnectionOpen and onConnectionClose receive databaseId, and onCDCEvent receives table and operation. Hooks can deny an operation, but metrics only observe, so a slow or failing metrics callback never blocks a query.
Manage multi-tenant lifecycle
For multi-tenant setups, the lifecycle manager handles auto-opening, idle timeouts, and least-recently-used eviction, so your application calls resolve and leaves every handle to the manager. Add a lifecycle block alongside metrics in the same constructor. The autoOpen.resolver maps a tenant id to a file path, idleTimeout closes handles after inactivity, and maxOpen limits how many are open at once.
import { Sirannon } from '@delali/sirannon-db'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
const driver = betterSqlite3()
const histogram = { observe: (ms: number) => {} }
const gauge = { inc: (labels: { db: string }) => {}, dec: (labels: { db: string }) => {} }
const counter = { inc: (labels: { table: string; op: string }) => {} }
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: {
autoOpen: {
resolver: (id) => ({ path: `./data/tenants/${id}.db` }),
},
idleTimeout: 300_000,
maxOpen: 50,
},
})
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}`)
})With autoOpen configured, resolve opens a tenant database on first access through the resolver and reuses the handle after that. Call it with a tenant id wherever your request handler uses the database.
import { Sirannon } from '@delali/sirannon-db'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
const driver = betterSqlite3()
const histogram = { observe: (ms: number) => {} }
const gauge = { inc: (labels: { db: string }) => {}, dec: (labels: { db: string }) => {} }
const counter = { inc: (labels: { table: string; op: string }) => {} }
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: {
autoOpen: {
resolver: (id) => ({ path: `./data/tenants/${id}.db` }),
},
idleTimeout: 300_000,
maxOpen: 50,
},
})
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}`)
})
const db = await sirannon.resolve('tenant-42')The hooks and metrics you configured apply to this handle like any other. Opening it invokes onDatabaseOpen, and each query on it invokes onBeforeQuery and onAfterQuery. When tenant-42 is idle past idleTimeout, the manager closes it; when the open count reaches maxOpen, it evicts the least-recently-used database to make room. Reaching the cap with every handle still active raises MAX_DATABASES.
The registry also migrates a tenant as the manager auto-opens it. A migrations set on the same constructor migrates every tenant the first time the resolver opens it, before resolve returns the handle, so a tenant added long after the last schema change still opens on the current schema. The migrations page covers the registry set, loading it from a function, and how a failed migration leaves a tenant unregistered so that the next open retries it.