Skip to content

Writing Plugins

Plugins let you intercept and modify database operations before they reach the storage adapter. They're perfect for logging, caching, validation, or adding custom behavior across all operations.

Plugin Interface

A plugin is an object with properties:

interface ValtheraPlugin {
  name: string;
  init?: (db: ValtheraClass) => void;
  execute(ctx: PluginContext): Promise<any>;
  collections?: string[];
}
  • name: Unique identifier for the plugin
  • init (optional): Called once when the plugin is registered
  • execute: Called for every database operation
  • collections (optional): When set, the plugin only runs for operations on these collections

Context Object

The execute method receives a context object:

interface PluginContext {
  op: string;        // Operation name: "find", "add", "update", etc.
  kind: PluginOpKind; // Operation kind: "read" | "write" | "delete" | "collection" | "index" | "meta" | "unknown" | "control"
  collection?: string; // Collection name (for string-only ops, this is the query itself)
  query: any;        // The query object (or collection name string for some ops)
  next: () => Promise<any>; // Call the next plugin or adapter
  setOp(op: string, query?: any): void; // Rewrite the operation (and optionally the query)
  setQuery(query: any): void;           // Rewrite the query for the current operation
}

The kind field groups operations, so plugins can branch without hard-coding name lists (e.g., ctx.kind === "write" covers add, bulkAdd, update, updateOne, updateOneOrAdd, toggleOne). Unknown operation names get kind: "unknown" and are rejected with QueryValidationError before they reach the adapter, unless a plugin rewrites them with setOp() first.

The op field can be any of:

  • Collection management (kind: "collection"): ensureCollection, issetCollection, removeCollection
  • Index management (kind: "index"): createIndex, removeIndex
  • CRUD (kind: "read", "write", "delete"): add, bulkAdd, find, findOne, update, updateOne, remove, removeOne, count, updateOneOrAdd, toggleOne
  • Metadata (kind: "meta"): getCollections

Note: capabilities() and transaction control operations (beginTransaction, commitTransaction, rollbackTransaction) bypass the plugin chain.

Operation Helpers

@wxn0brp/db-core/utils/opKind exports the operation table and predicates, so plugins do not have to hard-code names:

import {
  OP_KINDS,
  getOpKind,
  isKnownOp,
  isCollectionOp,
  isDbOp,
} from "@wxn0brp/db-core/utils/opKind";
import type { DbOp, OpName } from "@wxn0brp/db-core/utils/opKind";

getOpKind("find");                  // "read"
getOpKind("createIndex");           // "index"
getOpKind("custom");                // "unknown"
isKnownOp("custom");                // false
isCollectionOp("find");             // true  - db[collection][op]
isCollectionOp("ensureCollection"); // false - collection management
isDbOp("getCollections");           // true  - db[op], but not control
  • OP_KINDS maps every known operation name to its kind; OpName is the union of its keys.
  • getOpKind(op) returns the kind, or "unknown" for names outside the contract.
  • isKnownOp(op) checks whether the name belongs to the adapter contract.
  • isCollectionOp(op) is true for collection-scoped operations: read, write, delete, and index.
  • isDbOp(op) is true for known non-control operations. Use getOpKind(op) === "collection" when you specifically mean ensureCollection / issetCollection / removeCollection.
  • DbOp is the subset routed through execute(): everything except beginTransaction, commitTransaction, rollbackTransaction, and capabilities.

Do not treat every non-read operation as a mutation: unknown operations are not mutations. Check for "write" or "delete" explicitly.

Basic Plugin Structure

Here's the minimal plugin structure:

import { ValtheraPlugin } from "@wxn0brp/db";

const myPlugin: ValtheraPlugin = {
  name: "my-plugin",

  async execute(ctx) {
    // Before the operation
    console.log(`About to execute: ${ctx.op}`);

    // Call next() to continue the chain
    const result = await ctx.next();

    // After the operation
    console.log(`Finished: ${ctx.op}`);

    return result;
  }
};

Registering Plugins

Use the plugin() method to register:

const db = ValtheraCreate("./my-db");

const unregister = db.plugin(myPlugin);

// Later, to remove the plugin:
unregister();

The plugin() method returns a function that removes the plugin when called.

Filtering by Collection

Pass collection names as the second argument to run the plugin only for those collections:

// The plugin only runs for operations on "users" and "posts"
const unregister = db.plugin(myPlugin, ["users", "posts"]);

Execution Order

Plugins form a chain. When you call a database operation:

  1. The first plugin's execute is called
  2. It can call ctx.next() to invoke the second plugin
  3. The second plugin calls ctx.next() to invoke the third
  4. ...and so on until the adapter is reached
  5. Results flow back through the chain

Example with multiple plugins:

db.plugin(pluginA);
db.plugin(pluginB);
db.plugin(pluginC);

await db.collection("users").find({});
// Execution order: A -> B -> C -> adapter -> C -> B -> A

Practical Examples

Logging Plugin

Log all operations with timing:

const loggingPlugin: ValtheraPlugin = {
  name: "logger",

  async execute(ctx) {
    const start = Date.now();
    console.log(`[START] ${ctx.op}`, ctx.query);

    const result = await ctx.next();

    const duration = Date.now() - start;
    console.log(`[END] ${ctx.op} (${duration}ms)`);

    return result;
  }
};

Operation Blocker

Prevent certain operations:

const readOnlyPlugin: ValtheraPlugin = {
  name: "read-only",

  async execute(ctx) {
    if (ctx.kind === "write" || ctx.kind === "delete") {
      throw new Error(`Operation ${ctx.op} is not allowed in read-only mode`);
    }

    return ctx.next();
  }
};

Modifying Queries

Plugins can modify queries before they reach the adapter:

const softDeletePlugin: ValtheraPlugin = {
  name: "soft-delete",

  async execute(ctx) {
    // Hide soft-deleted documents from all read operations
    if (ctx.op === "find" || ctx.op === "findOne") {
      ctx.query.search ||= {};
      ctx.query.search.$ne = { deleted: true };
    }

    return ctx.next();
  }
};

Rewriting Operations

Use setOp() / setQuery() to rewrite the operation or its query before it reaches the adapter. kind and collection are updated automatically:

const aliasPlugin: ValtheraPlugin = {
  name: "alias",

  async execute(ctx) {
    // Redirect reads on "user" to the "users" collection
    if (ctx.query?.collection === "user") {
      ctx.setQuery({ ...ctx.query, collection: "users" });
    }

    // Re-route all "remove" calls to "removeOne"
    if (ctx.op === "remove") {
      ctx.setOp("removeOne");
    }

    return ctx.next();
  }
};

Modifying Results

Transform results after the adapter returns them:

const transformPlugin: ValtheraPlugin = {
  name: "transformer",

  async execute(ctx) {
    const result = await ctx.next();

    if (ctx.op === "find" && Array.isArray(result)) {
      return result.map(item => ({
        ...item,
        fullName: `${item.firstName} ${item.lastName}`
      }));
    }

    return result;
  }
};