@kysera/executor
Unified Execution Layer for Kysera - plugin-aware Kysely wrapper that enables plugins to work seamlessly with both Repository and DAL patterns.
Overview
@kysera/executor is the foundation package for Kysera's plugin system. It provides transparent query interception that allows plugins to modify queries before execution without changing your code. This enables features like soft deletes, row-level security, audit logging, and more.
Key Features:
- Zero Overhead Path - No performance penalty when no plugins or interceptors are used
- Minimal Overhead - <15% overhead with 1-3 interceptor plugins in production workloads
- Type Safe - Full TypeScript support with all Kysely types preserved
- Transaction Support - Plugins automatically propagate through transactions
- Plugin Validation - Detects conflicts, missing dependencies, and circular dependencies
- Cross-Pattern - Works with both Repository and DAL patterns
Installation
pnpm add @kysera/executor kysely
Quick Start
Basic Usage
import { createExecutor } from '@kysera/executor'
import { softDeletePlugin } from '@kysera/soft-delete'
import { Kysely, PostgresDialect } from 'kysely'
const db = new Kysely<Database>({
dialect: new PostgresDialect({
})
})
const executor = await createExecutor(db, [softDeletePlugin()])
const users = await executor.selectFrom('users').selectAll().execute()
With DAL Pattern
import { createExecutor } from '@kysera/executor'
import { createContext, createQuery, withTransaction } from '@kysera/dal'
import { softDeletePlugin } from '@kysera/soft-delete'
const executor = await createExecutor(db, [softDeletePlugin()])
const ctx = createContext(executor)
const getUsers = createQuery(ctx => ctx.db.selectFrom('users').selectAll().execute())
const getUser = createQuery((ctx, id: string) =>
ctx.db.selectFrom('users').where('id', '=', id).selectAll().executeTakeFirst()
)
const users = await getUsers(ctx)
const user = await getUser(ctx, 'user-123')
With Transactions
import { withTransaction } from '@kysera/dal'
await withTransaction(executor, async ctx => {
const user = await ctx.db
.insertInto('users')
.values({ name: 'Alice' })
.returningAll()
.executeTakeFirst()
const post = await ctx.db
.insertInto('posts')
.values({ user_id: user.id, title: 'Hello' })
.returningAll()
.executeTakeFirst()
})
API Reference
Core Functions
createExecutor(db, plugins?, config?)
Creates a plugin-aware executor with async plugin initialization.
async function createExecutor<DB>(
db: Kysely<DB>,
plugins?: readonly Plugin[],
config?: ExecutorConfig
): Promise<KyseraExecutor<DB>>
Parameters:
db - Kysely database instance
plugins - Array of plugins to apply (default: [])
config.enabled - Enable/disable plugin interception (default: true)
Returns: Plugin-aware executor
Example:
const executor = await createExecutor(db, [softDeletePlugin(), auditPlugin()])
Performance:
- Zero overhead when
plugins = [] or enabled = false
- Zero overhead when plugins have no
interceptQuery hook
- Minimal overhead with interceptor plugins (<15% for 1-3 plugins)
createExecutorSync(db, plugins?, config?)
Synchronous version of createExecutor that skips async plugin initialization.
function createExecutorSync<DB>(
db: Kysely<DB>,
plugins?: readonly Plugin[],
config?: ExecutorConfig
): KyseraExecutor<DB>
Use Case: When you need synchronous executor creation or plugins don't require onInit.
Example:
const executor = createExecutorSync(db, [softDeletePlugin()])
isKyseraExecutor(value)
Type guard to check if a value is a KyseraExecutor.
function isKyseraExecutor<DB>(value: Kysely<DB> | KyseraExecutor<DB>): value is KyseraExecutor<DB>
Example:
if (isKyseraExecutor(db)) {
const plugins = getPlugins(db)
console.log(`${plugins.length} plugins registered`)
}
getPlugins(executor)
Get the array of registered plugins from an executor.
function getPlugins<DB>(executor: KyseraExecutor<DB>): readonly Plugin[]
Returns: Plugins in execution order (sorted by priority and dependencies)
Example:
const plugins = getPlugins(executor)
console.log(plugins.map(p => `${p.name}@${p.version}`))
getRawDb(executor)
Get the raw Kysely instance, bypassing all plugin interceptors.
function getRawDb<DB>(executor: Kysely<DB>): Kysely<DB>
Use Case: For internal plugin operations that should not trigger other plugins.
Example:
const rawDb = getRawDb(executor)
const deletedUser = await rawDb
.selectFrom('users')
.where('id', '=', userId)
.where('deleted_at', 'is not', null)
.selectAll()
.executeTakeFirst()
Important: Use with caution. Bypassing plugins can lead to inconsistent behavior.
destroyExecutor(executor)
Destroy an executor and call the onDestroy hook for all registered plugins.
async function destroyExecutor<DB>(executor: KyseraExecutor<DB>): Promise<void>
Parameters:
executor - KyseraExecutor instance to destroy
Use Case: Cleanup when shutting down application or when executor is no longer needed.
Example:
const executor = await createExecutor(db, [
{
name: 'connection-pool',
version: '1.0.0',
async onInit(db) {
console.log('Initializing connection pool')
},
async onDestroy(db) {
console.log('Closing connection pool')
}
}
])
await destroyExecutor(executor)
Important: After calling destroyExecutor, the executor should not be used for further queries.
wrapTransaction(trx, plugins)
Wrap a Kysely transaction with plugins.
function wrapTransaction<DB>(
trx: Transaction<DB>,
plugins: readonly Plugin[]
): KyseraTransaction<DB>
Parameters:
trx - Kysely transaction instance
plugins - Plugins to apply to the transaction
Returns: Plugin-aware transaction
Use Case: Manual transaction wrapping when not using withTransaction.
Example:
await db.transaction().execute(async trx => {
const wrappedTrx = wrapTransaction(trx, [softDeletePlugin()])
const users = await wrappedTrx.selectFrom('users').selectAll().execute()
})
applyPlugins(qb, plugins, context)
Manually apply plugins to a query builder.
function applyPlugins<QB>(qb: QB, plugins: readonly Plugin[], context: QueryBuilderContext): QB
Parameters:
qb - Query builder instance
plugins - Plugins to apply
context - Query context (operation, table, metadata)
Returns: Modified query builder
Use Case: Complex queries that bypass normal interception or custom plugin composition.
Example:
const qb = db.selectFrom('users').selectAll()
const context: QueryBuilderContext = {
operation: 'select',
table: 'users',
metadata: {}
}
const modifiedQb = applyPlugins(qb, [softDeletePlugin()], context)
const users = await modifiedQb.execute()
validatePlugins(plugins)
Validate plugins for conflicts, duplicates, missing dependencies, and circular dependencies.
function validatePlugins(plugins: readonly Plugin[]): void
Throws: PluginValidationError if validation fails
Validation checks:
- Duplicate plugin names
- Missing dependencies
- Conflicting plugins
- Circular dependencies
Example:
try {
validatePlugins([
{ name: 'a', version: '1.0.0', dependencies: ['b'] },
{ name: 'b', version: '1.0.0', dependencies: ['a'] }
])
} catch (error) {
if (error instanceof PluginValidationError) {
console.error(error.type, error.details)
}
}
resolvePluginOrder(plugins)
Resolve plugin execution order using topological sort with priority.
function resolvePluginOrder(plugins: readonly Plugin[]): Plugin[]
Returns: Sorted plugins in execution order
Sorting rules:
- Dependencies must run before dependents
- Higher priority runs first (default priority: 0)
- Alphabetical by name when priority is equal
Example:
const plugins = [
{ name: 'audit', version: '1.0.0', priority: 50 },
{ name: 'soft-delete', version: '1.0.0', priority: 100 },
{ name: 'rls', version: '1.0.0', priority: 90 }
]
const sorted = resolvePluginOrder(plugins)
Types
Plugin
Plugin interface - unified for both Repository and DAL patterns.
interface Plugin {
readonly name: string
readonly version: string
readonly dependencies?: readonly string[]
readonly priority?: number
readonly conflictsWith?: readonly string[]
onInit?<DB>(executor: Kysely<DB>): Promise<void> | void
onDestroy?<DB>(executor: Kysely<DB>): Promise<void> | void
interceptQuery?<QB>(qb: QB, context: QueryBuilderContext): QB
extendRepository?<T extends object>(repo: T): T
}
Available Hooks:
onInit - Plugin initialization (async)
onDestroy - Plugin cleanup/teardown (async)
interceptQuery - Query interception (most common)
extendRepository - Repository pattern only
QueryBuilderContext
Context passed to interceptQuery hook.
interface QueryBuilderContext {
readonly operation: 'select' | 'insert' | 'update' | 'delete'
readonly table: string
readonly metadata: Record<string, unknown>
}
Example:
const plugin: Plugin = {
name: 'my-plugin',
version: '1.0.0',
interceptQuery<QB>(qb: QB, context: QueryBuilderContext): QB {
console.log(`${context.operation} on ${context.table}`)
return qb
}
}
KyseraExecutor<DB>
Plugin-aware Kysely wrapper type.
type KyseraExecutor<DB> = Kysely<DB> & {
readonly __kysera: true
readonly __plugins: readonly Plugin[]
readonly __rawDb: Kysely<DB>
}
Usage: Use KyseraExecutor<DB> instead of Kysely<DB> when you need to ensure plugins are available.
KyseraTransaction<DB>
Plugin-aware Transaction wrapper type.
type KyseraTransaction<DB> = Transaction<DB> & {
readonly __kysera: true
readonly __plugins: readonly Plugin[]
readonly __rawDb: Kysely<DB>
}
Usage: Returned by wrapTransaction and used internally by the executor's transaction handling.
ExecutorConfig
Configuration options for executor creation.
interface ExecutorConfig {
readonly enabled?: boolean
}
Example:
const executor = await createExecutor(db, plugins, { enabled: false })
PluginValidationError
Error thrown when plugin validation fails.
class PluginValidationError extends Error {
readonly type: PluginValidationErrorType
readonly details: PluginValidationDetails
}
type PluginValidationErrorType =
| 'DUPLICATE_NAME'
| 'MISSING_DEPENDENCY'
| 'CONFLICT'
| 'CIRCULAR_DEPENDENCY'
interface PluginValidationDetails {
readonly pluginName: string
readonly missingDependency?: string
readonly conflictingPlugin?: string
readonly cycle?: readonly string[]
}
Creating Custom Plugins
Basic Plugin
import type { Plugin, QueryBuilderContext } from '@kysera/executor'
export function myPlugin(): Plugin {
return {
name: '@myorg/my-plugin',
version: '1.0.0',
priority: 50,
interceptQuery<QB>(qb: QB, context: QueryBuilderContext): QB {
if (context.operation === 'select' && context.table === 'users') {
return (qb as any).where('active', '=', true)
}
return qb
}
}
}
Plugin with Initialization
export function cachePlugin(redisClient: Redis): Plugin {
return {
name: '@myorg/cache',
version: '1.0.0',
async onInit(db) {
await redisClient.ping()
console.log('Cache plugin initialized')
},
interceptQuery<QB>(qb: QB, context: QueryBuilderContext): QB {
context.metadata.cacheKey = `${context.table}:${context.operation}`
return qb
}
}
}
Plugin with Dependencies
export function auditPlugin(): Plugin {
return {
name: '@kysera/audit',
version: '1.0.0',
priority: 40,
dependencies: ['@kysera/soft-delete'],
interceptQuery<QB>(qb: QB, context: QueryBuilderContext): QB {
return qb
}
}
}
Plugin with Conflicts
export function hardDeletePlugin(): Plugin {
return {
name: '@myorg/hard-delete',
version: '1.0.0',
conflictsWith: ['@kysera/soft-delete'],
interceptQuery<QB>(qb: QB, context: QueryBuilderContext): QB {
return qb
}
}
}
Accessing Raw Database in Plugins
import { getRawDb } from '@kysera/executor'
export function softDeletePlugin(): Plugin {
return {
name: '@kysera/soft-delete',
version: '1.0.0',
interceptQuery<QB>(qb: QB, context: QueryBuilderContext): QB {
if (context.operation === 'select') {
return (qb as any).where('deleted_at', 'is', null)
}
return qb
},
extendRepository<T extends { executor: any }>(repo: T): T {
return {
...repo,
async restore(id: string) {
const rawDb = getRawDb(repo.executor)
return rawDb
.updateTable('users')
.where('id', '=', id)
.set({ deleted_at: null })
.returningAll()
.executeTakeFirst()
}
}
}
}
}
Performance
The executor is designed for production workloads with minimal overhead.
Benchmark Results
Based on benchmark tests with SQLite (in-memory):
| Pure Kysely (baseline) | ~100,000 | 0% |
| Executor (no plugins) | ~95,000 | <5% (zero overhead path) |
| Executor (1 plugin) | ~90,000 | <15% |
| Executor (3 plugins) | ~80,000 | <25% |
| Executor (5 plugins) | ~70,000 | <35% |
Optimization Strategies
- Zero Overhead Path: When no plugins have
interceptQuery, the executor takes a fast path with zero overhead
- Method Caching: Intercepted methods are cached to avoid repeated creation
- Set-based Lookups: O(1) lookups instead of array iterations
- Lazy Proxy Creation: Proxies are only created when needed
Performance Tips
- Only enable plugins you need
- Use
createExecutorSync when plugins don't need initialization
- Consider plugin priority - critical filters should run first
- Use
getRawDb for internal queries that don't need interception
- Disable executor in development:
createExecutor(db, plugins, { enabled: false })
Integration with DAL
The executor seamlessly integrates with @kysera/dal for functional query composition:
import { createExecutor } from '@kysera/executor'
import { createContext, createQuery, withTransaction } from '@kysera/dal'
const executor = await createExecutor(db, [
softDeletePlugin(),
rlsPlugin({ tenantIdColumn: 'tenant_id' })
])
const ctx = createContext(executor)
const getUser = createQuery((ctx, id: string) =>
ctx.db.selectFrom('users').where('id', '=', id).selectAll().executeTakeFirst()
)
const updateUser = createQuery((ctx, id: string, data: Partial<User>) =>
ctx.db.updateTable('users').where('id', '=', id).set(data).returningAll().executeTakeFirst()
)
const user = await getUser(ctx, 'user-123')
await withTransaction(executor, async txCtx => {
await updateUser(txCtx, 'user-123', { name: 'Updated' })
})
Integration with Repository
The executor also powers the Repository pattern via @kysera/repository:
import { createORM } from '@kysera/repository'
import { softDeletePlugin } from '@kysera/soft-delete'
import { auditPlugin } from '@kysera/audit'
const orm = await createORM(db, [softDeletePlugin(), auditPlugin()])
const userRepo = orm.createRepository(createUserRepository)
const users = await userRepo.findAll()
const user = await userRepo.create({ name: 'Alice' })
The repository internally uses createExecutor to power plugin functionality.
Best Practices
1. Plugin Naming
Use namespaced names to avoid conflicts:
{ name: '@kysera/soft-delete', version: '1.0.0' }
{ name: '@myorg/custom-plugin', version: '1.0.0' }
{ name: 'soft-delete', version: '1.0.0' }
{ name: 'plugin', version: '1.0.0' }
2. Plugin Priority
Reserve priority ranges for different concerns:
- 100-199: Core data filters (soft-delete, RLS)
- 50-99: Middleware (audit, logging)
- 0-49: Post-processing (caching, enrichment)
3. Use Dependencies
Declare dependencies explicitly to ensure correct load order:
{
name: '@kysera/audit',
version: '1.0.0',
dependencies: ['@kysera/soft-delete'],
priority: 40
}
4. Avoid Conflicts
Use conflictsWith to prevent incompatible plugins:
{
name: '@myorg/hard-delete',
version: '1.0.0',
conflictsWith: ['@kysera/soft-delete']
}
5. Type Safety
Always maintain type safety when modifying query builders:
interceptQuery<QB>(qb: QB, context: QueryBuilderContext): QB {
if (context.operation === 'select') {
return (qb as any).where('deleted_at', 'is', null) as QB;
}
return qb;
}
6. Testing Plugins
Test plugins in isolation before composing them:
import { applyPlugins } from '@kysera/executor'
it('should filter deleted records', async () => {
const qb = db.selectFrom('users').selectAll()
const context = { operation: 'select', table: 'users', metadata: {} }
const filtered = applyPlugins(qb, [softDeletePlugin()], context)
const users = await filtered.execute()
expect(users.every(u => u.deleted_at === null)).toBe(true)
})
Query Interception Details
Intercepted Methods
The executor intercepts the following Kysely query builder methods to apply plugins:
selectFrom - SELECT queries
insertInto - INSERT queries
updateTable - UPDATE queries
deleteFrom - DELETE queries
replaceInto - REPLACE queries (MySQL)
mergeInto - MERGE queries (SQL Server, Oracle)
Example:
await executor.selectFrom('users').selectAll().execute()
await executor.insertInto('users').values(data).execute()
await executor.updateTable('users').set(data).execute()
await executor.deleteFrom('users').where('id', '=', 1).execute()
Schema Support (withSchema)
The withSchema() method now maintains the plugin proxy with caching for optimal performance:
const publicUsers = await executor
.withSchema('public')
.selectFrom('users')
.selectAll()
.execute()
const archiveUsers = await executor
.withSchema('archive')
.selectFrom('users')
.selectAll()
.execute()
Performance Note: Schema proxies are cached, so repeated calls to withSchema('public') return the same proxy instance.
Limitations
SQL Template Tag Bypasses Plugins
The Kysely sql template tag bypasses plugin interception:
import { sql } from 'kysely'
const users = await executor
.selectFrom(sql`users`.as('users'))
.selectAll()
.execute()
const users = await executor
.selectFrom('users')
.selectAll()
.execute()
Why this happens: The sql template tag creates raw SQL fragments that bypass the query builder chain where plugins are applied.
When you need raw SQL:
const users = await executor
.selectFrom('users')
.where(sql`jsonb_array_length(metadata->'tags') > 5`)
.selectAll()
.execute()
CTE Limitations (with/withRecursive)
Common Table Expressions (CTEs) created with with() or withRecursive() have limited plugin support:
const result = await executor
.with('active_users', db =>
db.selectFrom('users').selectAll()
)
.selectFrom('active_users')
.selectAll()
.execute()
const result = await executor
.with('active_users', db =>
db
.selectFrom('users')
.where('deleted_at', 'is', null)
.selectAll()
)
.selectFrom('active_users')
.selectAll()
.execute()
Why this limitation exists: CTEs are defined before the main query executes, and the CTE callback receives the raw db instance, not the plugin-wrapped executor.
Future improvement: This may be addressed in a future version by wrapping the callback parameter.
Architecture & Design Decisions
Type System Constraints
The executor implementation uses several type assertions (as unknown as) due to Kysely's complex type system. These are intentional and documented inline in the source code. Here's why they're necessary:
1. Unconstrained Plugin Generic
The Plugin.interceptQuery method has an unconstrained generic parameter QB:
interface Plugin {
interceptQuery?<QB>(qb: QB, context: QueryBuilderContext): QB
}
Why it's unconstrained:
Kysely's query builders (SelectQueryBuilder, InsertQueryBuilder, UpdateQueryBuilder, DeleteQueryBuilder) don't share a common base interface that includes query modification methods like where(), and(), etc. Each builder has a unique interface with different generic parameters.
How plugins handle this:
Plugins must handle type safety internally by:
- Checking the operation type from context
- Casting to the appropriate specific builder
- Using type assertions when necessary
Example:
interceptQuery<QB>(qb: QB, context: QueryBuilderContext): QB {
if (context.operation === 'select') {
type GenericSelect = SelectQueryBuilder<Record<string, unknown>, string, Record<string, unknown>>;
return (qb as unknown as GenericSelect)
.where('deleted_at', 'is', null) as QB;
}
return qb;
}
2. Transaction Wrapping
Transaction wrapping requires explicit type casts because:
Transaction<DB> extends Kysely<DB>, but TypeScript requires explicit casts for the proxy system
- The proxy creation function expects
Kysely<DB>, not Transaction<DB>
- The wrapped result must be cast back to
Transaction<DB> for user callbacks
Example:
const wrappedTrx = createProxy(
trx as unknown as Kysely<DB>,
interceptors,
allPlugins
)
return fn(wrappedTrx as unknown as Transaction<DB>)
3. Dynamic Method Access
Methods like selectFrom, insertInto, etc. must be accessed dynamically:
const originalMethod = (db as unknown as Record<string, (t: string) => unknown>)[method]
Why: TypeScript doesn't allow dynamic property access on Kysely<DB> without an index signature. The cast is safe because we validate the method exists in INTERCEPTED_METHODS.
4. Object.assign Type Assertions
Object.assign returns intersection types that must be cast to union types:
return Object.assign(db, {
__kysera: true as const,
__plugins: plugins,
__rawDb: db
}) as KyseraExecutor<DB>
Why: KyseraExecutor<DB> = Kysely<DB> & KyseraExecutorMarker<DB>, and we're adding exactly those marker properties.
Transaction API Limitation
The wrapped transaction only exposes the .execute() method, not .setIsolationLevel() or other transaction builder methods.
Why this limitation exists:
- Simplicity: Keeps the plugin system simple and focused on query interception
- Intent: Isolation level should be set before plugin interception begins
- Common case: Most applications use default isolation levels
Escape Hatch: Using __rawDb
You can access the raw database instance via executor.__rawDb or ctx.db.__rawDb to bypass plugins when needed:
import { withTransaction } from '@kysera/dal'
await withTransaction(executor, async ctx => {
const users = await ctx.db.selectFrom('users').selectAll().execute()
if ('__rawDb' in ctx.db) {
const rawDb = ctx.db.__rawDb
const deletedUsers = await rawDb
.selectFrom('users')
.where('deleted_at', 'is not', null)
.selectAll()
.execute()
}
return users
})
Workaround for advanced use cases:
await executor.__rawDb
.transaction()
.setIsolationLevel('serializable')
.execute(async trx => {
})
Alternative pattern:
await executor.__rawDb
.transaction()
.setIsolationLevel('serializable')
.execute(async trx => {
const wrappedTrx = wrapTransaction(trx, getPlugins(executor))
})
Type Safety Philosophy
The executor prioritizes:
- Runtime Safety: All type assertions are validated at runtime where possible
- Developer Experience: Full TypeScript support without forcing users to use casts
- Kysely Compatibility: Works with all Kysely types and features
- Documentation: All type assertions are documented inline with explanations
These architectural decisions allow the executor to provide a seamless plugin system while maintaining full type safety and Kysely compatibility.
Troubleshooting
Plugin Not Applied
Problem: Plugin's interceptQuery is not being called.
Solutions:
- Verify plugin has
interceptQuery hook defined
- Check if executor is disabled:
createExecutor(db, plugins, { enabled: true })
- Ensure you're using the executor, not raw
db
Circular Dependency Error
Problem: PluginValidationError: Circular dependency: a -> b -> a
Solution: Review plugin dependencies and remove circular references.
Performance Degradation
Problem: Queries are slower with plugins.
Solutions:
- Profile plugins individually to find bottlenecks
- Reduce number of plugins
- Optimize plugin logic (avoid expensive operations in
interceptQuery)
- Consider using
getRawDb for internal queries
Type Errors with Query Builders
Problem: TypeScript errors when modifying query builders in plugins.
Solution: Use type assertions carefully:
interceptQuery<QB>(qb: QB, context: QueryBuilderContext): QB {
return (qb as any).where('column', '=', value) as QB;
}
License
MIT
Related Packages