Core features

Hooks, metrics, and lifecycle

Intercept operations with before and after hooks, feed timings into your metrics system, and let the lifecycle manager own multi-tenant database handles.

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/tenants

Construct the instance

Start tenants.ts with a driver and a Sirannon instance. Everything else on this page attaches to this instance.

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

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

Sirannon calls all five synchronously, so write each one as a plain function. When onBeforeQuery or onBeforeConnect returns a promise, Sirannon fails the statement or the open, because it would have to go ahead before the promise settled. The other three only observe, so Sirannon ignores a throw or a returned promise from any of them. It finishes the operation either way, and it still calls the hooks registered after that one. Where a hook has to start slow work, such as writing an audit record, have it push the work onto a queue of your own and return straight away.

Three more hooks guard the server, and you pass them in the constructor's hooks option: onBeforeSubscribe, onBeforeSnapshot, and onBeforePush. The server waits for each of them, so they may be async, and the security guide shows what each one checks. A server with acceptSql off serves a subscription to a table's changes only when the registry has onBeforeSubscribe.

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.

tenants.ts
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 object. onQueryComplete receives the databaseId, the sql, and the durationMs of every statement, with rowsReturned for a read, changes for a write, and error: true for a statement that threw. onConnectionOpen and onConnectionClose receive the databaseId, the path, and the readerCount of the pool. onCDCEvent receives the table, the operation, and the subscriberCount for every change that Sirannon delivers. It fires whether the subscriber called db.on in your own process or subscribed through the server.

Hooks can deny an operation, but metrics only observe, so Sirannon ignores an error that a metrics callback throws and returns the query's result as usual. Sirannon calls every callback synchronously, however, and it calls onQueryComplete before the query returns, so keep each callback as fast as the counter increments above.

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.

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

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