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.

Table of Contents

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 manages database handles for you. This page builds one file, tenants.ts, step by step. You start from a driver and a bare instance, register hooks on it, then grow the constructor with a metrics block and a lifecycle block. Each step highlights exactly what changed.

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, and throwing from a before-hook 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 carries databaseId, sql, durationMs, and path where each applies.

The ctx.sql.includes('DROP') pattern above is illustration only. Substring matching is not a production SQL firewall, because casing, comments, and concatenated SQL bypass it. For real access control, combine onBeforeQuery with an allowlist of query patterns or a proper 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. Extend the constructor call instead of replacing it, so the hooks you registered above still apply. The histogram, gauge, and counter values here are minimal stand-ins so 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 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 gets durationMs, onConnectionOpen and onConnectionClose get databaseId, and onCDCEvent gets 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 does not manage database handles directly. 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 needs 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.