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:
// plugin.ts
const myPlugin: Plugin = {
id: "@my-company/my-plugin",
// ...
};
export default myPlugin; // Required: must be default exportThis is required so that other plugins and generation-time hooks can use plugin-generated TailorDB tables via getGeneratedType().
Plugin Interface
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>;
}| Property | Required | Description |
|---|---|---|
id | Yes | Unique plugin identifier (e.g., "@my-company/soft-delete") |
description | Yes | Human-readable description |
importPath | When using definition-time hooks | Path resolvable from tailor.config.ts directory. Used for import statements in generated code. |
typeConfigRequired | No | Whether per-table config is required when attaching via .plugin(). Default: optional. |
pluginConfig | No | Plugin-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):
| Field | Type | Description |
|---|---|---|
type | TailorAnyDBType | The TailorDB table being processed |
typeConfig | TypeConfig | Per-table config from .plugin() |
pluginConfig | PluginConfig | Plugin-level config from definePlugins() |
namespace | string | Namespace of the TailorDB table |
Returns (TypePluginOutput):
| Field | Type | Description |
|---|---|---|
types | Record<string, TailorAnyDBType> | Additional TailorDB tables to generate (keyed by kind) |
resolvers | PluginGeneratedResolver[] | Additional resolvers to generate |
executors | PluginGeneratedExecutor[] | 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.,
deletedAtfor soft delete) - Generate executors triggered by record events on the source table
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):
| Field | Type | Description |
|---|---|---|
pluginConfig | PluginConfig | Plugin-level config from definePlugins() |
namespace | string | Target 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)
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):
| Field | Type | Description |
|---|---|---|
tailordb | TailorDBNamespaceData[] | All namespaces with tables, source info, and plugin attachments |
auth | GeneratorAuthInput? | Auth configuration (machine users, OAuth2 clients, etc.) |
baseDir | string | Output directory for generated files |
configPath | string | Path to tailor.config.ts |
pluginConfig | PluginConfig | Plugin-level config from definePlugins() |
TailorDBNamespaceData contains:
| Field | Type | Description |
|---|---|---|
namespace | string | Namespace name |
types | Record<string, TailorDBType> | All finalized tables in the namespace |
sourceInfo | ReadonlyMap<string, TypeSourceInfoEntry> | Source file info for each table |
pluginAttachments | ReadonlyMap<string, PluginAttachment[]> | Plugin configs attached to each table |
Returns (GeneratorResult):
| Field | Type | Description |
|---|---|---|
files | Array<{ path, content, skipIfExists?, executable? }> | Files to write to disk |
errors | string[]? | 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
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:
| Field | Type | Description |
|---|---|---|
resolvers | ResolverNamespaceData[] | All namespaces with their resolvers |
ResolverNamespaceData contains:
| Field | Type | Description |
|---|---|---|
namespace | string | Namespace name |
resolvers | Record<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
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:
| Field | Type | Description |
|---|---|---|
executors | Record<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
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:
| Hook | Runs after | Data provided |
|---|---|---|
onTailorDBReady | TailorDB loaded | tailordb + auth |
onResolverReady | Resolvers loaded | tailordb + resolvers + auth |
onExecutorReady | Executors loaded | tailordb + 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:
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.
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 totailor.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 (nullfor namespace plugins)kind: The generated table kind (e.g.,"archive","auditLog")
How it works:
- Loads and caches the config from the given path
- Finds the plugin by ID from
definePlugins()exports - Auto-resolves the namespace from config
- Calls the plugin's
onTypeLoaded()oronNamespaceLoaded()method - Caches the result to avoid redundant processing
- Returns the generated table matching the specified kind
Examples
Definition-time Plugin (Soft Delete)
A plugin that adds soft delete functionality via onTypeLoaded:
// 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
// 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
// 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:
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:
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:
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:
// 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:
declare module "@tailor-platform/sdk" {
interface PluginConfigs<Fields extends string> {
"@example/i18n": {
labels: Partial<Record<Fields, { ja: string; en: string }>>;
};
}
}