Skip to content
View as Markdown

Custom Plugins (Beta)

Beta Feature: The custom plugin API is in beta and may change in future releases.

Create your own plugins by implementing the Plugin interface.

Requirements

Plugins must use default export:

typescript
// plugin.ts
const myPlugin: Plugin = {
  id: "@my-company/my-plugin",
  // ...
};

export default myPlugin; // Required: must be default export

This is required so that other plugins and generation-time hooks can use plugin-generated TailorDB tables via getGeneratedType().

Plugin Interface

typescript
interface Plugin<TypeConfig = unknown, PluginConfig = unknown> {
  readonly id: string;
  readonly description: string;
  readonly importPath?: string;
  readonly typeConfigRequired?: boolean | ((pluginConfig?: PluginConfig) => boolean);
  readonly pluginConfig?: PluginConfig;

  // Definition-time hooks
  onTypeLoaded?(
    context: PluginProcessContext<TypeConfig, PluginConfig>,
  ): TypePluginOutput | Promise<TypePluginOutput>;
  onNamespaceLoaded?(
    context: PluginNamespaceProcessContext<PluginConfig>,
  ): PluginOutput | Promise<PluginOutput>;

  // Generation-time hooks
  onTailorDBReady?(
    context: TailorDBReadyContext<PluginConfig>,
  ): GeneratorResult | Promise<GeneratorResult>;
  onResolverReady?(
    context: ResolverReadyContext<PluginConfig>,
  ): GeneratorResult | Promise<GeneratorResult>;
  onExecutorReady?(
    context: ExecutorReadyContext<PluginConfig>,
  ): GeneratorResult | Promise<GeneratorResult>;
}
PropertyRequiredDescription
idYesUnique plugin identifier (e.g., "@my-company/soft-delete")
descriptionYesHuman-readable description
importPathWhen using definition-time hooksPath resolvable from tailor.config.ts directory. Used for import statements in generated code.
typeConfigRequiredNoWhether per-table config is required when attaching via .plugin(). Default: optional.
pluginConfigNoPlugin-level config passed via definePlugins(). Set via factory function.

Hook Reference

onTypeLoaded

Trigger: Called once for each TailorDB table that has .plugin({ pluginId: config }) attached.

Context (PluginProcessContext):

FieldTypeDescription
typeTailorAnyDBTypeThe TailorDB table being processed
typeConfigTypeConfigPer-table config from .plugin()
pluginConfigPluginConfigPlugin-level config from definePlugins()
namespacestringNamespace of the TailorDB table

Returns (TypePluginOutput):

FieldTypeDescription
typesRecord<string, TailorAnyDBType>Additional TailorDB tables to generate (keyed by kind)
resolversPluginGeneratedResolver[]Additional resolvers to generate
executorsPluginGeneratedExecutor[]Additional executors to generate
extends{ fields?: Record<string, TailorAnyDBField> }Fields to add to the source table

Use cases:

  • Generate derived tables (e.g., archive tables, history tables) from user-defined tables
  • Add fields to existing tables (e.g., deletedAt for soft delete)
  • Generate executors triggered by record events on the source table
typescript
onTypeLoaded(context) {
  const { type, typeConfig, namespace } = context;
  return {
    types: { archive: db.table(`Deleted_${type.name}`, { ... }) },
    extends: { fields: { deletedAt: db.datetime({ optional: true }) } },
    executors: [{ name: `${type.name}-on-delete`, resolve: async () => await import("./on-delete"), context: { sourceType: type, namespace } }],
  };
},

onNamespaceLoaded

Trigger: Called once per namespace for plugins that do not require a source table.

Context (PluginNamespaceProcessContext):

FieldTypeDescription
pluginConfigPluginConfigPlugin-level config from definePlugins()
namespacestringTarget namespace

Returns (PluginOutput):

Same as TypePluginOutput but without extends (namespace plugins cannot extend a source table).

Use cases:

  • Generate tables that don't derive from a specific user table (e.g., audit log, settings table)
typescript
onNamespaceLoaded(context) {
  return {
    types: { auditLog: db.table("AuditLog", { action: db.string(), ... }) },
  };
},

onTailorDBReady

Trigger: Called once after all TailorDB tables are loaded and auth is resolved.

Context (TailorDBReadyContext):

FieldTypeDescription
tailordbTailorDBNamespaceData[]All namespaces with tables, source info, and plugin attachments
authGeneratorAuthInput?Auth configuration (machine users, OAuth2 clients, etc.)
baseDirstringOutput directory for generated files
configPathstringPath to tailor.config.ts
pluginConfigPluginConfigPlugin-level config from definePlugins()

TailorDBNamespaceData contains:

FieldTypeDescription
namespacestringNamespace name
typesRecord<string, TailorDBType>All finalized tables in the namespace
sourceInfoReadonlyMap<string, TypeSourceInfoEntry>Source file info for each table
pluginAttachmentsReadonlyMap<string, PluginAttachment[]>Plugin configs attached to each table

Returns (GeneratorResult):

FieldTypeDescription
filesArray<{ path, content, skipIfExists?, executable? }>Files to write to disk
errorsstring[]?Error messages to report

Use cases:

  • Generate table definitions (e.g., Kysely types, enum constants)
  • Generate seed data scaffolding from table schemas
  • Generate table lists or metadata files
typescript
onTailorDBReady(ctx) {
  const allTypes = ctx.tailordb.flatMap((ns) =>
    Object.values(ns.types).map((t) => t.name),
  );
  return {
    files: [{ path: `${ctx.baseDir}/types.ts`, content: `export const types = ${JSON.stringify(allTypes)};\n` }],
  };
},

onResolverReady

Trigger: Called once after all resolvers are loaded, for plugins that do not implement onExecutorReady.

Context (ResolverReadyContext):

All fields from TailorDBReadyContext, plus:

FieldTypeDescription
resolversResolverNamespaceData[]All namespaces with their resolvers

ResolverNamespaceData contains:

FieldTypeDescription
namespacestringNamespace name
resolversRecord<string, Resolver>All resolvers in the namespace

Returns: GeneratorResult (same as onTailorDBReady)

Use cases:

  • Generate API client code from resolver definitions
  • Generate documentation that includes resolver endpoints
typescript
onResolverReady(ctx) {
  const resolverNames = ctx.resolvers.flatMap((ns) =>
    Object.values(ns.resolvers).map((r) => r.name),
  );
  return {
    files: [{ path: `${ctx.baseDir}/api.ts`, content: generateApiClient(resolverNames) }],
  };
},

onExecutorReady

Trigger: Called once after all executors are loaded.

Context (ExecutorReadyContext):

All fields from ResolverReadyContext, plus:

FieldTypeDescription
executorsRecord<string, Executor>All executors (keyed by executor name)

Returns: GeneratorResult (same as onTailorDBReady)

Use cases:

  • Generate dashboards or reports that need the full application topology
  • Generate configuration files that reference all services
typescript
onExecutorReady(ctx) {
  const summary = {
    types: ctx.tailordb.flatMap((ns) => Object.keys(ns.types)),
    resolvers: ctx.resolvers.flatMap((ns) => Object.keys(ns.resolvers)),
    executors: Object.keys(ctx.executors),
  };
  return {
    files: [{ path: `${ctx.baseDir}/app-summary.json`, content: JSON.stringify(summary, null, 2) }],
  };
},

Hook Scheduling Rules

Each generation-time hook runs at its own pipeline phase, regardless of what other hooks the same plugin implements:

HookRuns afterData provided
onTailorDBReadyTailorDB loadedtailordb + auth
onResolverReadyResolvers loadedtailordb + resolvers + auth
onExecutorReadyExecutors loadedtailordb + resolvers + executors + auth

A plugin implementing multiple hooks (e.g., both onTailorDBReady and onResolverReady) will have each hook called at its natural phase. This ensures that files generated by onTailorDBReady are available when resolvers are loaded, before onResolverReady runs.

Import Types

All context and result types are exported from @tailor-platform/sdk:

typescript
import type {
  Plugin,
  PluginProcessContext,
  TypePluginOutput,
  PluginOutput,
  TailorDBReadyContext,
  ResolverReadyContext,
  ExecutorReadyContext,
  TailorDBNamespaceData,
  ResolverNamespaceData,
  GeneratorResult,
} from "@tailor-platform/sdk";

getGeneratedType Helper

The SDK provides an async getGeneratedType() helper function to retrieve plugin-generated TailorDB tables. This enables plugins and other tools to work with tables generated by plugins.

typescript
import { join } from "node:path";
import { getGeneratedType } from "@tailor-platform/sdk/plugin";
import { customer } from "./tailordb/customer";

const configPath = join(import.meta.dirname, "./tailor.config.ts");

// Table-attached plugin
const DeletedCustomer = await getGeneratedType(
  configPath,
  "@example/soft-delete",
  customer,
  "archive",
);

// Namespace plugin (pass null as sourceType)
const AuditLog = await getGeneratedType(configPath, "@example/audit-log", null, "auditLog");

Parameters:

  • configPath: Path to tailor.config.ts (absolute or relative to cwd)
  • pluginId: The plugin's unique identifier (e.g., "@example/soft-delete")
  • sourceType: The TailorDB table that the plugin is attached to (null for namespace plugins)
  • kind: The generated table kind (e.g., "archive", "auditLog")

How it works:

  1. Loads and caches the config from the given path
  2. Finds the plugin by ID from definePlugins() exports
  3. Auto-resolves the namespace from config
  4. Calls the plugin's onTypeLoaded() or onNamespaceLoaded() method
  5. Caches the result to avoid redundant processing
  6. Returns the generated table matching the specified kind

Examples

Definition-time Plugin (Soft Delete)

A plugin that adds soft delete functionality via onTypeLoaded:

typescript
// plugins/soft-delete/plugin.ts
import { db } from "@tailor-platform/sdk";
import type { Plugin, PluginProcessContext, TypePluginOutput } from "@tailor-platform/sdk";

interface SoftDeleteConfig {
  archiveReason?: boolean;
  retentionDays?: number;
}

interface SoftDeletePluginConfig {
  archiveTablePrefix?: string;
  defaultRetentionDays?: number;
  requireTypeConfig?: boolean;
}

function processSoftDelete(
  context: PluginProcessContext<SoftDeleteConfig, SoftDeletePluginConfig>,
): TypePluginOutput {
  const { type, typeConfig, pluginConfig, namespace } = context;
  const prefix = pluginConfig?.archiveTablePrefix ?? "Deleted_";

  // Generate archive table
  const archiveType = db
    .table(`${prefix}${type.name}`, {
      originalId: db.uuid().description("ID of the deleted record"),
      originalData: db.string().description("JSON snapshot of deleted record"),
      deletedAt: db.datetime().description("When the record was deleted"),
      deletedBy: db.uuid().description("User who deleted the record"),
      ...(typeConfig.archiveReason && {
        reason: db.string({ optional: true }).description("Reason for deletion"),
      }),
      ...db.fields.timestamps(),
    })
    .description(`Archive for deleted ${type.name} records`);

  // Extend source table with deletedAt field
  const extendFields = {
    deletedAt: db.datetime({ optional: true }).description("Soft delete timestamp"),
  };

  return {
    types: { archive: archiveType },
    extends: { fields: extendFields },
    executors: [
      {
        name: `${type.name.toLowerCase()}-on-delete`,
        resolve: async () => await import("./executors/on-delete"),
        context: {
          sourceType: type,
          archiveType,
          namespace,
        },
      },
    ],
  };
}

function createSoftDeletePlugin(
  pluginConfig?: SoftDeletePluginConfig,
): Plugin<SoftDeleteConfig, SoftDeletePluginConfig> {
  return {
    id: "@example/soft-delete",
    description: "Adds soft delete with archive functionality",
    importPath: "./plugins/soft-delete",
    pluginConfig,
    typeConfigRequired: (config) => config?.requireTypeConfig === true,
    onTypeLoaded: processSoftDelete,
  };
}

export default createSoftDeletePlugin();

Executor with Context

typescript
// plugins/soft-delete/executors/on-delete.ts
import { createExecutor, recordDeletedTrigger } from "@tailor-platform/sdk";
import type { TailorAnyDBType } from "@tailor-platform/sdk";
import { withPluginContext } from "@tailor-platform/sdk/plugin";
import { getDB } from "generated/tailordb";

interface SoftDeleteContext {
  sourceType: TailorAnyDBType;
  archiveType: TailorAnyDBType;
  namespace: string;
}

export default withPluginContext((ctx: SoftDeleteContext) => {
  const { sourceType, archiveType, namespace } = ctx;

  return createExecutor({
    name: `${sourceType.name.toLowerCase()}-on-delete`,
    description: `Archives deleted ${sourceType.name} records`,
    trigger: recordDeletedTrigger({ type: sourceType }),
    operation: {
      kind: "function",
      body: async ({ oldRecord, user }) => {
        const db = getDB(namespace as "tailordb");
        await db
          .insertInto(archiveType.name)
          .values({
            originalId: oldRecord.id,
            originalData: JSON.stringify(oldRecord),
            deletedAt: new Date(),
            deletedBy: user?.id ?? "system",
          })
          .execute();
      },
    },
  });
});

Usage

typescript
// tailor.config.ts
import { definePlugins } from "@tailor-platform/sdk";
import softDeletePlugin from "./plugins/soft-delete";

export const plugins = definePlugins(
  softDeletePlugin({
    archiveTablePrefix: "Deleted_",
    defaultRetentionDays: 90,
  }),
);

// tailordb/customer.ts
export const customer = db
  .table("Customer", {
    name: db.string(),
    email: db.string(),
  })
  .plugin({
    "@example/soft-delete": {
      archiveReason: true,
    },
  });

Generation-only Plugin (Type List)

A plugin that only uses onTailorDBReady to generate output files:

typescript
import type { Plugin, GeneratorResult } from "@tailor-platform/sdk";

const typeListPlugin: Plugin = {
  id: "@example/type-list",
  description: "Generates a list of all TailorDB table names",

  onTailorDBReady(ctx): GeneratorResult {
    const allTypes = ctx.tailordb.flatMap((ns) =>
      Object.entries(ns.types).map(([_, type]) => ({
        name: type.name,
        fieldCount: Object.keys(type.fields).length,
        namespace: ns.namespace,
      })),
    );
    const content = `// Generated type list\nexport const types = ${JSON.stringify(allTypes, null, 2)} as const;\n`;
    return {
      files: [{ path: `${ctx.baseDir}/types.ts`, content }],
    };
  },
};

Hybrid Plugin (Definition + Generation)

A plugin that uses both definition-time and generation-time hooks:

typescript
const plugin: Plugin = {
  id: "@example/hybrid",
  description: "Generates derived tables and produces output files",
  importPath: "./plugins/hybrid",

  // Definition-time: Generate additional tables from attached source tables
  onTypeLoaded(context) {
    return { types: { derived: createDerivedType(context.type) } };
  },

  // Generation-time: Generate output files from all finalized tables
  onTailorDBReady(ctx) {
    const allTypes = ctx.tailordb.flatMap((ns) => Object.values(ns.types).map((t) => t.name));
    return {
      files: [{ path: `${ctx.baseDir}/output.ts`, content: generateCode(allTypes) }],
    };
  },
};

Adding Type Safety

Plugin-level type safety (TypeConfig / PluginConfig)

Use TypeScript type parameters on Plugin<TypeConfig, PluginConfig> to get type-safe config in onTypeLoaded and onNamespaceLoaded methods:

typescript
interface MyTypeConfig {
  archiveReason?: boolean;
}

interface MyPluginConfig {
  prefix?: string;
}

const plugin: Plugin<MyTypeConfig, MyPluginConfig> = {
  id: "@example/my-plugin",
  // ...
  onTypeLoaded(context) {
    // context.typeConfig is MyTypeConfig
    // context.pluginConfig is MyPluginConfig
  },
};

Per-table .plugin() type safety (declaration merging)

To enable type checking when users attach plugins via .plugin(), provide a declaration merge for the PluginConfigs interface. Plugin authors should ship this in their package's type definitions:

typescript
// your-plugin/types.d.ts (shipped with your plugin package)
declare module "@tailor-platform/sdk" {
  interface PluginConfigs<Fields extends string> {
    "@example/soft-delete": {
      archiveReason?: boolean;
      retentionDays?: number;
    };
  }
}

The Fields type parameter provides field names from the table being configured, enabling field-aware configurations:

typescript
declare module "@tailor-platform/sdk" {
  interface PluginConfigs<Fields extends string> {
    "@example/i18n": {
      labels: Partial<Record<Fields, { ja: string; en: string }>>;
    };
  }
}