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 plugininit(optional): Called once when the plugin is registeredexecute: Called for every database operationcollections(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_KINDSmaps every known operation name to its kind;OpNameis 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, andindex.isDbOp(op)is true for known non-control operations. UsegetOpKind(op) === "collection"when you specifically meanensureCollection/issetCollection/removeCollection.DbOpis the subset routed throughexecute(): everything exceptbeginTransaction,commitTransaction,rollbackTransaction, andcapabilities.
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:
- The first plugin's
executeis called - It can call
ctx.next()to invoke the second plugin - The second plugin calls
ctx.next()to invoke the third - ...and so on until the adapter is reached
- 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;
}
};