Skip to main content

@kysera/core

Minimal core utilities for database operations with Kysely.

Version: 0.9.0

Installation

npm install @kysera/core

Overview

Dependencies: @kysera/executor (declared as a runtime dependency, but used for type imports only — it adds no runtime code paths) (peer: kysely >=0.29.0) Database Support: PostgreSQL, MySQL, SQLite, MSSQL

Exports

// Error handling
export * from './errors'
export * from './error-codes'

// Pagination
export * from './pagination'

// Query Helpers
export * from './helpers'

// Types and Logger
export * from './types'
export * from './logger'

// Cursor Security (only the type is re-exported from main entry)
export type { CursorSecurityOptions } from './cursor-crypto'
// Functions available via '@kysera/core/cursor-crypto' subpath import

// Dialect Detection
export * from './dialect-detection'

// Plugin Base Utilities
export * from './plugin-base'

// Version
export * from './version'

:::info Modules Moved to Separate Packages The following modules have been moved to dedicated packages for better tree-shaking and separation of concerns:

Modules

Errors

Multi-database error parsing with typed errors.

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

const error = parseDatabaseError(rawError, 'postgres')
if (error instanceof UniqueConstraintError) {
console.log(error.columns) // ['email']
}

The unified error-code system is also importable:

import { ErrorCodes, isValidErrorCode, getErrorCategory, type ErrorCode } from '@kysera/core'

ErrorCodes.VALIDATION_UNIQUE_VIOLATION // 'VALIDATION_UNIQUE_VIOLATION'
isValidErrorCode('DB_TIMEOUT') // true (type guard narrowing to ErrorCode)
getErrorCategory('DB_TIMEOUT') // 'DB'
  • ErrorCodes - Const object with every unified error code (DB_*, VALIDATION_*, RESOURCE_*, MIGRATION_*, PLUGIN_*, ...)
  • ErrorCode - Union type of all code strings
  • isValidErrorCode(code) - Type guard for unknown strings
  • getErrorCategory(code) - Returns the code's category prefix (e.g. 'DB', 'VALIDATION'; 'UNKNOWN' if unprefixed)

Pagination

Offset and cursor-based pagination with configurable bounds.

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

// Offset pagination
const page = await paginate(query, { page: 1, limit: 20 })

// Cursor pagination
const result = await paginateCursor(query, {
orderBy: [{ column: 'created_at', direction: 'desc' }],
limit: 20
})

Pagination Bounds (internal constants — enforced automatically, not exported):

  • Maximum page number: 1,000,000
  • Maximum items per page: 10,000
  • Default limit: 20 items
  • limit: 0 is honored as a special case: no rows are fetched (data: []); paginate() still runs the COUNT query and reports totalPages: 0
  • These bounds prevent excessive database load and memory usage

Query Helpers

Lightweight utility functions for common query patterns.

import { applyOffset, applyDateRange } from '@kysera/core'

// Lightweight offset pagination (without COUNT(*))
const users = await applyOffset(db.selectFrom('users').selectAll().orderBy('id'), {
limit: 20,
offset: 0
}).execute()

// Date range filtering
const posts = await applyDateRange(db.selectFrom('posts').selectAll(), 'created_at', {
from: new Date('2024-01-01'),
to: new Date('2024-12-31')
}).execute()

// Combine helpers for paginated date-filtered results
const analytics = await applyOffset(
applyDateRange(db.selectFrom('events').selectAll().orderBy('created_at', 'desc'), 'created_at', {
from: startDate,
to: endDate
}),
{ limit: 100, offset: 0 }
).execute()

Logger

Configurable logging interface.

import { consoleLogger, silentLogger, createPrefixedLogger } from '@kysera/core'

const myLogger = createPrefixedLogger('[myapp]', consoleLogger)

Cursor Security

Cryptographic functions for securing pagination cursors with HMAC signing and AES-256-GCM encryption.

:::caution Secret length requirement All cursor-crypto functions throw if the secret is shorter than 32 characters. Generate a strong secret (e.g. openssl rand -hex 32) and provide it via configuration. :::

import { signCursor, verifyCursor, encryptCursor, decryptCursor } from '@kysera/core/cursor-crypto'

// Secret must be at least 32 characters (throws otherwise)
const secret = process.env.CURSOR_SECRET! // e.g. 64 hex chars from `openssl rand -hex 32`

// Sign a cursor with HMAC
const signed = signCursor(cursor, secret)

// Verify and extract cursor
const original = verifyCursor(signed, secret)

// Encrypt cursor with AES-256-GCM
const encrypted = encryptCursor(cursor, secret)

// Decrypt cursor
const decrypted = decryptCursor(encrypted, secret)

Exports:

  • signCursor(cursor, secret, algorithm?) - Sign cursor with HMAC
  • verifyCursor(signedCursor, secret, algorithm?) - Verify and extract cursor
  • encryptCursor(cursor, secret) - Encrypt cursor with AES-256-GCM
  • decryptCursor(encryptedCursor, secret) - Decrypt cursor
  • CursorSecurityOptions - Security options type

CursorSecurityOptions (accepted by paginateCursor via the security option):

interface CursorSecurityOptions {
/** Secret key for signing/encryption - MUST be at least 32 characters (functions throw otherwise) */
secret: string
/** Enable AES-256-GCM encryption in addition to HMAC signing (default: false) */
encrypt?: boolean
/** HMAC algorithm for signing (default: 'sha256') */
algorithm?: 'sha256' | 'sha384' | 'sha512'
}

Dialect Detection

Automatic database dialect detection from Kysely instances.

import { detectDialect } from '@kysera/core'

const dialect = detectDialect(db)
// Returns: 'postgres' | 'mysql' | 'sqlite' | 'mssql'

// Use for dialect-specific logic
if (dialect === 'postgres') {
// PostgreSQL-specific code
}

Exports:

  • detectDialect(executor) - Detect database dialect from Kysely instance
  • Dialect - Type for supported dialects

Version

Package version information.

import { VERSION } from '@kysera/core'

console.log(VERSION) // '0.9.0'

Exports:

  • VERSION - Current package version constant

Types

Executor

type Executor<DB> = Kysely<DB> | Transaction<DB>

AnyExecutor

type AnyExecutor<DB> = Kysely<DB> | Transaction<DB> | (Kysely<DB> & KyseraExecutorMarker<DB>)

The correct parameter type for functions that should accept plugin-aware executors as well as plain Kysely instances and transactions. Use it instead of Executor<DB> whenever a KyseraExecutor (from @kysera/executor) may be passed in:

import type { AnyExecutor } from '@kysera/core'

async function findUser(db: AnyExecutor<Database>, userId: number) {
return db.selectFrom('users').where('id', '=', userId).selectAll().executeTakeFirst()
}

Common Interfaces

interface Timestamps {
created_at: Date
updated_at?: Date
}

interface SoftDelete {
deleted_at: Date | null
}

interface AuditFields {
created_by?: number
updated_by?: number
}

Logger Interface

interface KyseraLogger {
trace(message: string, ...args: unknown[]): void
debug(message: string, ...args: unknown[]): void
info(message: string, ...args: unknown[]): void
warn(message: string, ...args: unknown[]): void
error(message: string, ...args: unknown[]): void
fatal(message: string, ...args: unknown[]): void
}

Query Helper Interfaces

interface OffsetOptions {
/** Maximum rows to return (max: 100). No default - omitting it applies no LIMIT */
limit?: number
/** Rows to skip (default: 0) */
offset?: number
/** Database dialect - lets the SQLite OFFSET workaround be skipped for other databases */
dialect?: Dialect
}

interface DateRangeOptions {
/** Start of date range (inclusive) */
from?: Date
/** End of date range (inclusive) */
to?: Date
}

Query Helpers API

applyOffset

Apply limit/offset to a query without counting total. Lightweight alternative to paginate().

function applyOffset<DB, TB, O>(
query: SelectQueryBuilder<DB, TB, O>,
options?: OffsetOptions
): SelectQueryBuilder<DB, TB, O>

Features:

  • No COUNT(*) query (~50% faster than paginate on large tables)
  • No default limit — omitting limit leaves the query unlimited; when provided, it is clamped to 1-100 (prevents accidental large queries)
  • Offset must be non-negative
  • SQLite compatible: when offset is used without limit, a large LIMIT is auto-added because SQLite requires LIMIT with OFFSET. This workaround also applies when the dialect is unknown (safe default) — pass dialect to skip it for known non-SQLite databases

Use cases: Infinite scroll, "Load More" buttons, simple lists without total count.

formatTimestampForDb

Format a Date as a database-compatible timestamp string.

function formatTimestampForDb(date?: Date, dialect?: Dialect): string

Dialect-specific output:

  • mysql / mssql: YYYY-MM-DD HH:MM:SS.mmm — these databases reject ISO 8601's T separator and Z suffix in DATETIME/TIMESTAMP columns
  • postgres / sqlite (and the default): ISO 8601 (YYYY-MM-DDTHH:MM:SS.mmmZ)

Example:

import { formatTimestampForDb } from '@kysera/core'

formatTimestampForDb() // '2024-01-15T10:30:00.000Z' (defaults: now, ISO 8601)
formatTimestampForDb(new Date(), 'mysql') // '2024-01-15 10:30:00.000'
formatTimestampForDb(new Date(), 'postgres') // '2024-01-15T10:30:00.000Z'

Use this instead of new Date().toISOString() when writing timestamps manually, so the format stays correct across dialects.

applyDateRange

Apply date range filter to a query.

function applyDateRange<DB, TB, O>(
query: SelectQueryBuilder<DB, TB, O>,
column: string,
options?: DateRangeOptions
): SelectQueryBuilder<DB, TB, O>

Features:

  • Both boundaries inclusive (>= and <=)
  • Handles Date objects (converts to ISO string)
  • Returns unchanged query if neither from nor to provided

executeCount

Execute a count query and return the numeric result.

async function executeCount<DB, TB extends keyof DB, O>(
query: SelectQueryBuilder<DB, TB, O>
): Promise<number>

Example:

import { executeCount } from '@kysera/core'

// Count all active users
const count = await executeCount(db.selectFrom('users').where('status', '=', 'active'))
console.log(`Active users: ${count}`)

executeGroupedCount

Execute a grouped count query and return counts by group.

async function executeGroupedCount<DB, TB extends keyof DB, O>(
query: SelectQueryBuilder<DB, TB, O>,
groupColumn: string
): Promise<Record<string, number>>

Example:

import { executeGroupedCount } from '@kysera/core'

// Count users by status
const countsByStatus = await executeGroupedCount(db.selectFrom('users'), 'status')
// { active: 150, inactive: 23, pending: 12 }

paginateCursorSimple

Simple cursor-based pagination that uses id column in ascending order. A convenience wrapper around paginateCursor.

async function paginateCursorSimple<DB, TB extends keyof DB, O>(
query: SelectQueryBuilder<DB, TB, O>,
options?: PaginationOptions
): Promise<PaginatedResult<O>>

Example:

import { paginateCursorSimple } from '@kysera/core'

const result = await paginateCursorSimple(db.selectFrom('posts').selectAll(), {
limit: 20,
cursor: lastCursor
})
// { data: [...], pagination: { limit: 20, hasNext: true, hasPrev: true, nextCursor: '...', prevCursor: '...' } }

Plugin Base Utilities

Core provides base abstractions for creating Kysera plugins, reducing boilerplate and ensuring consistent behavior across the plugin ecosystem.

BasePluginOptions

Common options shared by all Kysera plugins.

import type { BasePluginOptions, BasePluginOptionsWithPrimaryKey } from '@kysera/core'

// Define plugin-specific options by extending base options
interface MyPluginOptions extends BasePluginOptions {
customOption: string
anotherOption?: number
}

// With primary key support
interface AuditPluginOptions extends BasePluginOptionsWithPrimaryKey {
auditTable?: string
captureOldValues?: boolean
}

BasePluginOptions Interface:

interface BasePluginOptions extends TableFilterConfig {
/** Logger for plugin operations. @default silentLogger */
logger?: KyseraLogger
/** Tables to apply plugin to (whitelist) */
tables?: string[]
/** Tables to exclude from plugin processing */
excludeTables?: string[]
}

interface BasePluginOptionsWithPrimaryKey extends BasePluginOptions {
/** Primary key column name. @default 'id' */
primaryKeyColumn?: string
}

createPluginConfig()

Creates a resolved plugin configuration with defaults applied.

import { createPluginConfig, type BasePluginOptionsWithPrimaryKey } from '@kysera/core'

interface SoftDeleteOptions extends BasePluginOptionsWithPrimaryKey {
deletedAtColumn?: string
}

export function softDeletePlugin(options: SoftDeleteOptions = {}): Plugin {
const config = createPluginConfig('soft-delete', options)

// Access resolved configuration
config.logger.debug('Initializing soft-delete plugin')
console.log(config.primaryKeyColumn) // 'id' (default)
console.log(config.excludeTables) // [] (default)

// Use the exported shouldApplyToTable() helper to honor tables/excludeTables:
// if (!shouldApplyToTable(tableName, config)) return qb
}

Returns:

interface ResolvedPluginConfig {
readonly name: string
readonly logger: KyseraLogger
readonly tables: string[] | undefined
readonly excludeTables: string[]
readonly primaryKeyColumn: string
}

shouldApplyToTable()

Check whether a plugin should process a given table, honoring whitelist/blacklist configuration. Use this instead of hand-rolling table filtering in custom plugins.

function shouldApplyToTable(tableName: string, config: TableFilterConfig): boolean

interface TableFilterConfig {
/** Tables to apply to (whitelist). When non-empty, takes precedence over excludeTables */
tables?: string[]
/** Tables to exclude (blacklist). Only consulted when no whitelist is set */
excludeTables?: string[]
}

Rules: a non-empty tables whitelist wins (only listed tables match, excludeTables is ignored); otherwise a non-empty excludeTables blacklist rejects its entries; with neither set (or empty arrays), every table matches.

Example:

import { shouldApplyToTable, type TableFilterConfig } from '@kysera/core'

const config: TableFilterConfig = { excludeTables: ['migrations', 'audit_logs'] }

shouldApplyToTable('users', config) // true
shouldApplyToTable('migrations', config) // false

// In a plugin's interceptQuery:
interceptQuery(qb, context) {
if (!shouldApplyToTable(context.table, config)) return qb
// ... apply plugin logic
}

Because BasePluginOptions extends TableFilterConfig, the config object produced by createPluginConfig() can be passed straight in.

createPluginMetadata()

Creates plugin metadata with optional defaults.

import { createPluginMetadata, PLUGIN_PRIORITIES } from '@kysera/core'

const metadata = createPluginMetadata('soft-delete', '0.9.0', {
priority: PLUGIN_PRIORITIES.FILTER,
conflictsWith: ['hard-delete-only'],
dependencies: ['timestamps']
})

Returns:

interface PluginMetadata {
name: string
version: string
dependencies?: readonly string[]
priority?: number
conflictsWith?: readonly string[]
}

PLUGIN_PRIORITIES

Recommended priority values for different plugin types. Higher priority = runs first.

import { PLUGIN_PRIORITIES, type PluginPriority } from '@kysera/core'

const priorities = {
CONTEXT: 1100, // Schema routing, request scoping - run before security
SECURITY: 1000, // RLS, auth filters - run first among enforcement tiers
FILTER: 500, // Soft delete, tenant isolation
TRANSFORM: 100, // Timestamps, data transformation
AUDIT: 50, // Audit logging, change tracking
DEFAULT: 0, // Default priority
DEBUG: -100 // Query logging, profiling - run last
}

// Type for priority values
type PluginPriority = 1100 | 1000 | 500 | 100 | 50 | 0 | -100

Execution Order:

  1. CONTEXT (1100) - Schema routing, request scoping (e.g. @kysera/executor's schemaPlugin) — runs before security so security plugins can read the resolved context (such as metadata.__resolvedSchema)
  2. SECURITY (1000) - RLS, authentication filters
  3. FILTER (500) - Soft delete, tenant isolation
  4. TRANSFORM (100) - Timestamps, data transformation
  5. AUDIT (50) - Audit logging, change tracking
  6. DEFAULT (0) - Plugins without explicit priority
  7. DEBUG (-100) - Query logging, profiling

Example:

import { PLUGIN_PRIORITIES, createPluginMetadata } from '@kysera/core'

// Security plugin - runs first
const rlsMetadata = createPluginMetadata('rls', '1.0.0', {
priority: PLUGIN_PRIORITIES.SECURITY
})

// Audit plugin - runs after transforms
const auditMetadata = createPluginMetadata('audit', '1.0.0', {
priority: PLUGIN_PRIORITIES.AUDIT
})

Migration Guide

If you're upgrading from an earlier version where these utilities were in @kysera/core:

// Before (deprecated)
import { checkDatabaseHealth, withRetry, testInTransaction } from '@kysera/core'

// After
import { checkDatabaseHealth, withRetry, CircuitBreaker } from '@kysera/infra'
import { testInTransaction, createFactory } from '@kysera/testing'
import { withDebug, QueryProfiler } from '@kysera/debug'