DataFn
Plugins

Custom Plugins

Build your own DataFn plugins.

This guide walks through building a custom DataFn plugin from scratch.

Plugin Structure

A plugin is an object implementing the DatafnPlugin interface:

import type { DatafnPlugin, DatafnHookContext } from "@datafn/core";

const myPlugin: DatafnPlugin = {
  name: "my-plugin",
  runsOn: ["client", "server"],

  beforeQuery(ctx, query) { /* ... */ },
  afterQuery(ctx, query, result) { /* ... */ },
  beforeMutation(ctx, mutation) { /* ... */ },
  afterMutation(ctx, mutation, result) { /* ... */ },
  beforeSync(ctx, payload, ...args) { /* ... */ },
  afterSync(ctx, payload, result, ...args) { /* ... */ },
};

All hooks are optional. Implement only the ones you need.

Before Hook Return Values

Before hooks can either pass through, transform, or reject:

// Pass through (return the input unchanged)
beforeQuery(ctx, query) {
  return query;
}

// Transform (return modified input)
beforeQuery(ctx, query) {
  return { ...query, limit: Math.min(query.limit ?? 100, 1000) };
}

// Reject (throw an error)
beforeMutation(ctx, mutation) {
  if (mutation.resource === "settings" && ctx.env === "client") {
    throw new Error("Settings can only be modified on the server");
  }
  return mutation;
}

BeforeHookResult Type

The core hook runner uses a result type internally:

type BeforeHookResult =
  | { ok: true; value: unknown }
  | { ok: false; error: HookError };

type HookError = {
  code: string;
  message: string;
  pluginName?: string;
};

Before hooks are fail-closed: the first error stops the chain and the operation is rejected.

After Hook Behavior

After hooks are fail-open: errors are logged but do not affect the operation result.

afterMutation(ctx, mutation, result) {
  // This error is caught and logged, not propagated
  sendAnalyticsEvent(mutation);
}

On the server, afterQuery hooks can transform the result:

afterQuery(ctx, query, result) {
  // Redact sensitive fields from the result
  if (Array.isArray(result)) {
    return result.map(record => {
      const { ssn, ...rest } = record;
      return rest;
    });
  }
  return result;
}

Hook Utilities

DataFn provides runBeforeHook and runAfterHook utilities in @datafn/core:

import { runBeforeHook, runAfterHook } from "@datafn/core";

// Run before hooks with environment filtering
const result = await runBeforeHook(
  plugins,
  "server",          // Environment filter
  "beforeQuery",     // Hook name
  hookContext,        // DatafnHookContext
  queryPayload,      // Input
);

if (!result.ok) {
  console.error("Hook rejected:", result.error);
}

// Run after hooks (fail-open)
await runAfterHook(
  plugins,
  "server",
  "afterQuery",
  hookContext,
  queryPayload,
  queryResult,
);

Example: Audit Logging Plugin

A plugin that logs all mutations to an audit trail:

import type { DatafnPlugin, DatafnHookContext } from "@datafn/core";

interface AuditEntry {
  timestamp: string;
  resource: string;
  operation: string;
  recordId: string;
  userId?: string;
}

export function createAuditPlugin(options: {
  onAudit: (entry: AuditEntry) => void;
}): DatafnPlugin {
  return {
    name: "audit-log",
    runsOn: ["server"],

    afterMutation(ctx: DatafnHookContext, mutation: unknown, result: unknown) {
      const m = mutation as Record<string, unknown>;
      options.onAudit({
        timestamp: new Date().toISOString(),
        resource: m.resource as string,
        operation: m.operation as string,
        recordId: m.id as string,
      });
    },
  };
}

// Usage
const auditPlugin = createAuditPlugin({
  onAudit: (entry) => db.insert("audit_log", entry),
});

Example: Field Transformation Plugin

A plugin that normalizes email addresses to lowercase on mutations:

export function createEmailNormalizerPlugin(options: {
  resources: string[];
  emailField?: string;
}): DatafnPlugin {
  const resourceSet = new Set(options.resources);
  const field = options.emailField ?? "email";

  return {
    name: "email-normalizer",
    runsOn: ["client", "server"],

    beforeMutation(ctx: DatafnHookContext, mutation: unknown) {
      const m = mutation as Record<string, unknown>;

      if (!resourceSet.has(m.resource as string)) return mutation;

      const record = m.record as Record<string, unknown> | undefined;
      if (!record || typeof record[field] !== "string") return mutation;

      return {
        ...m,
        record: {
          ...record,
          [field]: (record[field] as string).toLowerCase().trim(),
        },
      };
    },
  };
}

Example: Query Limit Plugin

A plugin that enforces a maximum query limit to prevent accidental full-table scans:

export function createMaxLimitPlugin(maxLimit: number = 1000): DatafnPlugin {
  return {
    name: "max-limit",
    runsOn: ["server"],

    beforeQuery(ctx: DatafnHookContext, query: unknown) {
      const q = query as Record<string, unknown>;
      const currentLimit = q.limit as number | undefined;

      if (!currentLimit || currentLimit > maxLimit) {
        return { ...q, limit: maxLimit };
      }

      return query;
    },
  };
}

Registration Order

Plugins execute in the order they are registered. Place plugins that modify data before plugins that validate data:

const client = createDatafnClient({
  schema,
  plugins: [
    createEmailNormalizerPlugin({ resources: ["users"] }),  // Transform first
    createMaxLimitPlugin(500),                               // Then enforce limits
    createSoftDeletePlugin({ resources: ["todos"] }),        // Then soft-delete
  ],
});