Skip to main content

Troubleshooting & FAQ

This guide covers common issues you might encounter when using Kysera and provides solutions and workarounds.

Troubleshooting

Common Errors

"OFFSET/FETCH NEXT is not supported without ORDER BY" (MSSQL)

Cause: Microsoft SQL Server requires an ORDER BY clause when using offset pagination.

Solution: Always include .orderBy() before calling pagination methods:

// ❌ This will fail on MSSQL
const result = await db
.selectFrom('users')
.selectAll()
.limit(10)
.offset(20)
.execute()

// ✅ This works
const result = await db
.selectFrom('users')
.selectAll()
.orderBy('id', 'asc')
.limit(10)
.offset(20)
.execute()

When using pagination helpers:

import { paginate } from '@kysera/core'

// Always provide orderBy
const result = await paginate(
db.selectFrom('users').selectAll().orderBy('created_at', 'desc'),
{ page: 1, limit: 20 }
)

"Invalid pagination cursor" errors

Cause: The cursor is from a different query or has been corrupted/tampered with.

Solution: Ensure the cursor matches the columns specified in orderBy:

// paginateCursor applies the ordering itself from `orderBy`
const firstPage = await paginateCursor(
db.selectFrom('posts').selectAll(),
{ limit: 20, orderBy: [{ column: 'created_at', direction: 'desc' }] }
)

// Use the cursor from the response
const nextPage = await paginateCursor(
db.selectFrom('posts').selectAll(),
{
limit: 20,
orderBy: [{ column: 'created_at', direction: 'desc' }],
cursor: firstPage.pagination.nextCursor // Must be from the same query
}
)

Important: Cursors are query-specific and cannot be reused across different queries or column orders.


"Cannot find module '@kysera/executor'"

Cause: Missing dependency. The executor package is required for plugin functionality.

Solution: Install the executor package:

pnpm add @kysera/executor

Ensure all packages are updated:

pnpm update @kysera/core @kysera/executor @kysera/repository @kysera/dal

Plugin not intercepting queries

Cause: Using raw Kysely instance instead of the executor.

Solution: Create and use an executor with your plugins:

import { createExecutor } from '@kysera/executor'
import { softDeletePlugin } from '@kysera/soft-delete'

// ❌ Plugins won't work with raw Kysely
const db = new Kysely<Database>({ dialect })
const users = await db.selectFrom('users').selectAll().execute()

// ✅ Use executor for plugin interception
const executor = await createExecutor(db, [softDeletePlugin()])
const users = await executor.selectFrom('users').selectAll().execute()

For Repository pattern:

import { createORM } from '@kysera/repository'

// ORM creates executor internally
const orm = await createORM(db, [softDeletePlugin()])

For DAL pattern:

import { createContext } from '@kysera/dal'

const executor = await createExecutor(db, [softDeletePlugin()])
const ctx = createContext(executor) // Pass executor, not db

Type errors with dialect-specific code

Cause: Boolean and data type handling differs between databases.

Solution: Use dialect-specific values or utilities:

// SQLite uses 1/0 for booleans
const sqliteQuery = db
.selectFrom('users')
.selectAll()
.where('is_active', '=', 1) // Not true

// PostgreSQL/MySQL use true/false
const pgQuery = db
.selectFrom('users')
.selectAll()
.where('is_active', '=', true)

// Use dialect detection for cross-database code
import { detectDialect } from '@kysera/core'

const dialect = detectDialect(db)
const activeValue = dialect === 'sqlite' ? 1 : true

const query = db
.selectFrom('users')
.selectAll()
.where('is_active', '=', activeValue)

Connection timeout with MSSQL

Cause: Database doesn't exist, incorrect connection string, or network issues.

Solution: Verify your Tedious configuration and database exists:

import { Kysely, MssqlDialect } from 'kysely'
import { Tedious } from 'tedious'
import { TediousPool } from 'tarn-tedious'

const db = new Kysely<Database>({
dialect: new MssqlDialect({
tarn: {
...TediousPool,
options: {
min: 0,
max: 10,
},
},
tedious: {
...Tedious,
connectionFactory: () => new Tedious.Connection({
server: 'localhost',
authentication: {
type: 'default',
options: {
userName: 'sa',
password: 'YourStrong@Passw0rd'
}
},
options: {
port: 1433,
database: 'kysera_test', // Ensure this database exists
trustServerCertificate: true,
requestTimeout: 30000, // Increase timeout
connectTimeout: 30000
}
})
}
})
})

Create the database if it doesn't exist:

CREATE DATABASE kysera_test;

Performance Issues

Slow offset pagination on large datasets

Problem: Offset pagination becomes slower as the offset increases because the database must scan and skip rows.

Solution: Use cursor-based pagination for large datasets:

import { paginateCursor } from '@kysera/core'

// ❌ Slow on large offsets
const result = await paginate(
db.selectFrom('posts').selectAll().orderBy('id', 'asc'),
{ page: 1000, limit: 20 } // offset = 19,980
)

// ✅ Fast and consistent performance
const result = await paginateCursor(
db.selectFrom('posts').selectAll(),
{ limit: 20, cursor: lastCursor, orderBy: [{ column: 'id', direction: 'asc' }] }
)

Benchmark: Cursor pagination maintains ~1-2ms query time regardless of position, while offset pagination can degrade to 100ms+ on large offsets.


Memory issues with large result sets

Problem: Loading thousands of rows into memory causes out-of-memory errors.

Solution: Use Kysely's native streaming or limit page sizes:

// ❌ Loads all rows into memory
const allUsers = await db.selectFrom('users').selectAll().execute()

// ✅ Use Kysely's native stream() method
const stream = db.selectFrom('users').selectAll().stream()

for await (const user of stream) {
await processUser(user)
}

// ✅ Or paginate with reasonable limits
import { paginate } from '@kysera/core'

const result = await paginate(
db.selectFrom('users').selectAll().orderBy('id', 'asc'),
{ page: 1, limit: 100 } // Process in batches
)

Note: Kysera does not provide a separate executeStream utility. Use Kysely's built-in .stream() method directly for streaming large datasets.


Slow queries with plugins

Problem: Multiple plugins chained together slow down queries.

Solution: Plugin execution order is fixed by priority (higher runs first: RLS 1000 → soft-delete 500 → timestamps 100 → audit 50) — the order of the array you pass does not matter. Optimize by:

  1. Using database indexes on plugin-filtered columns
  2. Profiling with withDebug from @kysera/debug
import { createExecutor } from '@kysera/executor'
import { softDeletePlugin } from '@kysera/soft-delete'
import { rlsPlugin } from '@kysera/rls'
import { withDebug } from '@kysera/debug'

// Array order is irrelevant — execution order comes from plugin priority
const executor = await createExecutor(db, [
softDeletePlugin(),
rlsPlugin({ schema: rlsSchema })
])

// withDebug wraps a Kysely instance for profiling (it is not an executor plugin)
const debugDb = withDebug(db, { logQuery: true, slowQueryThreshold: 100 })

// Ensure indexes exist on filtered columns
// CREATE INDEX idx_users_tenant_id ON users(tenant_id);
// CREATE INDEX idx_users_deleted_at ON users(deleted_at);

FAQ

General Questions

Q: Can I use Kysera without the executor?

A: Yes, but plugins won't intercept queries. The core utilities (pagination, error parsing, logging) work with raw Kysely:

import { paginate, parseDatabaseError } from '@kysera/core'

// Works without executor
const result = await paginate(
db.selectFrom('users').selectAll().orderBy('id', 'asc'),
{ page: 1, limit: 20 }
)

try {
await db.insertInto('users').values(data).execute()
} catch (error) {
const dbError = parseDatabaseError(error, 'postgres')
// Handle error
}

For full plugin functionality (soft-delete, RLS, audit, etc.), use createExecutor().


Q: Which databases are supported?

A: Kysera supports all databases that Kysely supports:

DatabaseTestedNotes
PostgreSQLPrimary development target
MySQLFull support
SQLiteBoolean values use 1/0
MSSQL (SQL Server)Requires ORDER BY for OFFSET
PlanetScale⚠️MySQL-compatible
CockroachDB⚠️PostgreSQL-compatible
OracleUntested, should work via Kysely

Q: How do I handle different boolean types across databases?

A: Use dialect-specific values or create abstraction utilities:

import { detectDialect } from '@kysera/core'

// Option 1: Detect dialect at runtime
const dialect = detectDialect(db)
const boolValue = (val: boolean) =>
dialect === 'sqlite' ? (val ? 1 : 0) : val

const query = db
.selectFrom('users')
.selectAll()
.where('is_active', '=', boolValue(true))

// Option 2: Use type-safe schema definitions
interface SqliteDatabase {
users: {
id: number
is_active: 0 | 1 // SQLite booleans
}
}

interface PostgresDatabase {
users: {
id: number
is_active: boolean // PostgreSQL booleans
}
}

// Option 3: Create helper functions
const isActive = (dialect: string) =>
dialect === 'sqlite'
? sql<0 | 1>`is_active = 1`
: sql<boolean>`is_active = true`

Q: Can I use multiple plugins together?

A: Yes, pass an array to createExecutor():

import { createExecutor } from '@kysera/executor'
import { softDeletePlugin } from '@kysera/soft-delete'
import { rlsPlugin } from '@kysera/rls'
import { auditPlugin } from '@kysera/audit'
import { timestampsPlugin } from '@kysera/timestamps'

const executor = await createExecutor(db, [
timestampsPlugin(), // Repository-only (extendRepository)
softDeletePlugin(), // Query interceptor: filters soft-deleted rows
rlsPlugin({ schema: rlsSchema }), // Query interceptor: row-level security
auditPlugin({ auditTable: 'audit_log' }) // Repository-only (extendRepository)
])

// Query-interceptor plugins (soft-delete, RLS) apply to executor/DAL queries
const user = await executor
.selectFrom('users')
.selectAll()
.where('id', '=', 1)
.executeTakeFirst()

Note that timestampsPlugin and auditPlugin work through extendRepository only — they do nothing on direct executor or DAL queries. To get automatic timestamps and audit logging, use createORM() and repositories created with orm.createRepository().

Array order does not matter: execution order is resolved from each plugin's priority (RLS 1000 → soft-delete 500 → timestamps 100 → audit 50).


Q: How do I run tests against multiple databases?

A: Use environment variables to enable specific databases:

# Run all database tests
TEST_POSTGRES=true TEST_MYSQL=true TEST_MSSQL=true pnpm test

# Run only PostgreSQL tests
TEST_POSTGRES=true pnpm test

# Use Docker for test databases
pnpm docker:up # Start PostgreSQL/MySQL containers
pnpm test:multi-db # Run multi-database tests
pnpm docker:down # Stop containers

In your test files:

import { describe, it, beforeAll } from 'vitest'

const shouldTestPostgres = process.env.TEST_POSTGRES === 'true'
const shouldTestMysql = process.env.TEST_MYSQL === 'true'

describe.skipIf(!shouldTestPostgres)('PostgreSQL tests', () => {
it('should work with PostgreSQL', async () => {
// Test code
})
})

describe.skipIf(!shouldTestMysql)('MySQL tests', () => {
it('should work with MySQL', async () => {
// Test code
})
})

Q: What's the maximum page size?

A: The maximum is 10,000 items per page. Higher limits are clamped internally — the cap is module-private, so it cannot be imported or configured:

import { paginate } from '@kysera/core'

// Maximum is 10,000
const result = await paginate(
db.selectFrom('users').selectAll().orderBy('id', 'asc'),
{ page: 1, limit: 15000 } // Clamped to 10,000
)

console.log(result.pagination.limit) // 10000

Why this limit?

  • Prevents accidental memory issues
  • Protects database from expensive queries
  • Encourages cursor pagination for large datasets

To override (not recommended):

// Custom implementation without the cap
const customPaginate = async (query, { page, limit }) => {
const offset = (page - 1) * limit
return query.limit(limit).offset(offset).execute()
}

Repository & DAL Questions

Q: Should I use Repository or DAL?

A: Use both together (CQRS-lite pattern):

import type { Transaction } from 'kysely'

const orm = await createORM(db, [softDeletePlugin()])
const userRepo = orm.createRepository(createUserRepository)

await orm.transaction(async ctx => {
// Repository for writes — rebind to the transaction (repositories stay
// bound to the base executor and would otherwise run OUTSIDE it)
const txUserRepo = userRepo.withTransaction(ctx.db as Transaction<Database>)
const user = await txUserRepo.create({
name: 'Alice',
email: 'alice@example.com'
})

// DAL for complex reads (flexible queries)
const stats = await getDashboardStats(ctx, user.id)
})

Use Repository when:

  • CRUD operations
  • Zod validation needed
  • Type-safe mutations
  • Working with single entities

Use DAL when:

  • Complex joins
  • Aggregations
  • Custom queries
  • Read-only operations

Q: How do I share plugins between Repository and DAL?

A: The executor enables plugin sharing:

import { createExecutor } from '@kysera/executor'
import { createORM } from '@kysera/repository'
import { createContext } from '@kysera/dal'

// Create executor once
const executor = await createExecutor(db, [
softDeletePlugin(),
rlsPlugin({ schema: rlsSchema })
])

// Both Repository and DAL use the same executor
const orm = await createORM(executor, [])
const dalCtx = createContext(executor)

// Plugins apply to both
await orm.transaction(async ctx => {
const userRepo = orm.createRepository(createUserRepository)
const user = await userRepo.findById(1) // Soft-delete filter applied

const stats = await getStats(ctx, user.id) // Same plugins
})

Q: Can I disable plugins for specific queries?

A: Yes — bypass all plugins with the raw Kysely instance, or opt out of a single plugin's behavior with per-query metadata:

import { createExecutor } from '@kysera/executor'

const db = new Kysely<Database>({ dialect })
const executor = await createExecutor(db, [softDeletePlugin()])

// Plugins applied
const activeUsers = await executor
.selectFrom('users')
.selectAll()
.execute() // Excludes soft-deleted

// Bypass plugins
const allUsers = await db
.selectFrom('users')
.selectAll()
.execute() // Includes soft-deleted

Alternatively, use per-query plugin metadata — soft-delete skips its filter when includeDeleted is set:

import { withPluginMetadata } from '@kysera/executor'

// Soft-delete filter disabled for queries made through this wrapper;
// every other plugin stays active
const allUsers = await withPluginMetadata(executor, { includeDeleted: true })
.selectFrom('users')
.selectAll()
.execute()

Plugin Questions

Q: How do I create custom plugins?

A: Implement the Plugin interface from @kysera/executor:

import type { Plugin, QueryBuilderContext } from '@kysera/executor'

export function customPlugin(): Plugin {
return {
name: 'custom-plugin',
version: '1.0.0',
priority: 0, // Higher runs first (RLS uses 1000, soft-delete 500)

// Modify query builders before execution
interceptQuery<QB>(qb: QB, context: QueryBuilderContext): QB {
if (context.operation === 'select' && context.table === 'users') {
// Add WHERE clauses, etc. (cast qb to the specific builder type)
}
return qb
}

// Also available:
// extendRepository?(repo) — add methods to repositories (Repository pattern only)
// onInit?(db) / onDestroy?() — lifecycle hooks
}
}

// Use it
const executor = await createExecutor(db, [customPlugin()])

Kysera plugins intercept query builders (interceptQuery), not query ASTs. A transformQuery hook belongs to Kysely's own KyselyPlugin API — if you need AST-level transforms, write a Kysely plugin and attach it with db.withPlugin().

See existing plugins for examples:

  • @kysera/soft-delete - Simple WHERE clause injection
  • @kysera/rls - Tenant-based filtering
  • @kysera/audit - Repository extension methods (audit logging + restore)

Q: Do plugins work with transactions?

A: Yes, plugins are preserved in transactions:

import { withTransaction } from '@kysera/dal'

const executor = await createExecutor(db, [
softDeletePlugin(),
rlsPlugin({ schema: rlsSchema })
])

const ctx = createContext(executor)

await withTransaction(executor, async txCtx => {
// Plugins still active in transaction
const user = await getUserById(txCtx, '1')
const posts = await getUserPosts(txCtx, '1')

// Both queries have soft-delete and RLS filters
})

For Repository:

const orm = await createORM(db, [softDeletePlugin()])
const userRepo = orm.createRepository(createUserRepository)

await orm.transaction(async ctx => {
// Rebind so the write actually joins the transaction
const txUserRepo = userRepo.withTransaction(ctx.db as Transaction<Database>)
await txUserRepo.softDelete(1) // Works in transaction (rebound repo keeps plugins)
})

Additional Resources


Still having issues? Open an issue on GitHub with:

  • Kysera version (@kysera/core, @kysera/executor, etc.)
  • Database type and version
  • Minimal reproduction code
  • Error messages and stack traces