Hooks / Middleware

Hooks / Middleware

PawQL supports lifecycle hooks that run before and after database operations. Use hooks to implement cross-cutting concerns like timestamping, logging, auditing, validation, and access control.

Quick Start

import { createDB } from 'pawql';

const db = createDB(schema, adapter);

// Auto-add timestamps
db.hook('users', 'beforeInsert', (ctx) => {
  if (ctx.data && !Array.isArray(ctx.data)) {
    ctx.data.createdAt = new Date();
  }
});

// Log all inserts
db.hook('*', 'afterInsert', (ctx) => {
  console.log(`✓ Inserted into ${ctx.table}`);
});

// Now all inserts on "users" will have createdAt automatically set
await db.query('users').insert({ id: 1, name: 'Alice' }).execute();

Supported Events

EventWhen it fires
beforeInsertBefore INSERT is executed
afterInsertAfter INSERT completes
beforeUpdateBefore UPDATE is executed
afterUpdateAfter UPDATE completes
beforeDeleteBefore DELETE is executed
afterDeleteAfter DELETE completes
beforeSelectBefore SELECT is executed
afterSelectAfter SELECT completes

Registering Hooks

Per-table

db.hook('users', 'beforeInsert', (ctx) => {
  // Only fires for the "users" table
});

Global (all tables)

db.hook('*', 'afterInsert', (ctx) => {
  // Fires for every table
  console.log(`Inserted into ${ctx.table}`);
});

Multiple hooks on the same event

Hooks are executed in registration order:

db.hook('users', 'beforeInsert', (ctx) => {
  console.log('First hook');
});

db.hook('users', 'beforeInsert', (ctx) => {
  console.log('Second hook');
});

// Output: "First hook", then "Second hook"

Hook Context

Every hook receives a HookContext object:

interface HookContext<T = any> {
  table: string;           // The table name
  operation: string;       // "INSERT" | "UPDATE" | "DELETE" | "SELECT"
  data?: Partial<T>;       // The data being written (before* hooks for INSERT/UPDATE)
  result?: T[];            // The rows returned (after* hooks)
}

Mutating Data in Hooks

before* hooks for INSERT and UPDATE can modify the ctx.data object:

// Auto-set timestamps
db.hook('users', 'beforeInsert', (ctx) => {
  if (ctx.data && !Array.isArray(ctx.data)) {
    ctx.data.createdAt = new Date();
    ctx.data.updatedAt = new Date();
  }
});

db.hook('users', 'beforeUpdate', (ctx) => {
  if (ctx.data && !Array.isArray(ctx.data)) {
    ctx.data.updatedAt = new Date();
  }
});

Aborting Operations

Throwing an error inside a before* hook aborts the operation:

db.hook('users', 'beforeDelete', (ctx) => {
  throw new Error('Deleting users is not allowed!');
});

// This will throw: "Deleting users is not allowed!"
await db.query('users').delete().where({ id: 1 }).execute();

Async Hooks

Hooks can be asynchronous:

db.hook('orders', 'afterInsert', async (ctx) => {
  await sendNotification(`New order created: ${ctx.table}`);
});

Async hooks are properly awaited before proceeding.

Removing Hooks

// Remove a specific event for a table
db.unhook('users', 'beforeInsert');

// Remove ALL hooks for a table
db.unhook('users');

// Clear all hooks everywhere
db.hookRegistry.clear();

Transactions

Hooks are automatically shared with transaction scopes:

db.hook('users', 'beforeInsert', (ctx) => {
  console.log('This fires inside the transaction too!');
});

await db.transaction(async (tx) => {
  // Hook fires here
  await tx.query('users').insert({ id: 1, name: 'Alice' }).execute();
});

HookRegistry (Advanced)

For more advanced use cases, access the HookRegistry directly:

import { HookRegistry } from 'pawql';

const registry = new HookRegistry();

// Register
registry.on('users', 'beforeInsert', (ctx) => { ... });

// Check if hooks exist
registry.has('users', 'beforeInsert'); // true

// Inspect all hooks
console.log(registry.hooks); // ReadonlyArray<HookEntry>

// Remove specific event
registry.off('users', 'beforeInsert');

// Clear everything
registry.clear();

Use Cases

Automatic Timestamps

db.hook('*', 'beforeInsert', (ctx) => {
  if (ctx.data && !Array.isArray(ctx.data)) {
    ctx.data.createdAt = new Date();
  }
});

db.hook('*', 'beforeUpdate', (ctx) => {
  if (ctx.data && !Array.isArray(ctx.data)) {
    ctx.data.updatedAt = new Date();
  }
});

Audit Logging

db.hook('*', 'afterInsert', async (ctx) => {
  await db.query('audit_logs').insert({
    table: ctx.table, action: 'INSERT', timestamp: new Date(),
  }).execute();
});

Input Sanitization

db.hook('users', 'beforeInsert', (ctx) => {
  if (ctx.data && !Array.isArray(ctx.data) && typeof ctx.data.email === 'string') {
    ctx.data.email = ctx.data.email.toLowerCase().trim();
  }
});

Access Control

db.hook('admin_settings', 'beforeUpdate', (ctx) => {
  if (!currentUser.isAdmin) {
    throw new Error('Only admins can modify settings');
  }
});

API Summary

MethodDescription
db.hook(table, event, callback)Register a lifecycle hook
db.unhook(table, event?)Remove hooks
db.hookRegistryAccess the HookRegistry directly
TypeDescription
HookEventUnion of all lifecycle events
HookCallbackThe hook function type
HookContextContext object passed to hooks
HookRegistryThe registry class for advanced usage

See the API Reference for full type signatures.