@kysera/repository
v0.5.1
Published
Repository pattern for Kysera ORM with smart validation
Downloads
152
Maintainers
Readme
@kysera/repository
Type-safe repository pattern for Kysely with smart Zod validation, transaction dependency injection, and zero-config multi-database support.
📦 Package Information
| Metric | Value | |--------|-------| | Version | 0.5.1 | | Bundle Size | ~12 KB (minified) | | Test Coverage | 127 tests passing | | Dependencies | @kysera/core (workspace) | | Peer Dependencies | kysely >=0.28.8, zod ^4.1.13 | | Target Runtimes | Node.js 20+, Bun 1.0+, Deno | | Module System | ESM only | | Database Support | PostgreSQL, MySQL, SQLite |
🎯 Features
- ✅ Repository Pattern - Clean separation of data access logic
- ✅ Type-Safe Factory - Fully typed repository creation with inference
- ✅ Smart Validation - Zod schemas with development/production modes
- ✅ Transaction DI - Dependency injection via
Executor<DB>type - ✅ Batch Operations -
bulkCreate,bulkUpdate,bulkDeletewith validation - ✅ Multi-Database - PostgreSQL, MySQL, SQLite with unified API
- ✅ Plugin System - Extensible architecture for custom behaviors
- ✅ Zero Config - Works out of the box with sensible defaults
- ✅ Production Ready - Battle-tested, optimized, fully typed
📥 Installation
# npm
npm install @kysera/repository @kysera/core kysely zod
# pnpm
pnpm add @kysera/repository @kysera/core kysely zod
# bun
bun add @kysera/repository @kysera/core kysely zod
# deno
import * as repo from "npm:@kysera/repository"🚀 Quick Start
import { Kysely, PostgresDialect, Generated } from 'kysely'
import { Pool } from 'pg'
import { createRepositoryFactory } from '@kysera/repository'
import { z } from 'zod'
// 1. Define database schema
interface Database {
users: {
id: Generated<number>
email: string
name: string
created_at: Generated<Date>
}
}
// 2. Define validation schemas
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1)
})
const UpdateUserSchema = CreateUserSchema.partial()
// 3. Define domain entity
interface User {
id: number
email: string
name: string
created_at: Date
}
// 4. Create database connection
const db = new Kysely<Database>({
dialect: new PostgresDialect({
pool: new Pool({
host: 'localhost',
database: 'myapp'
})
})
})
// 5. Create repository factory
const factory = createRepositoryFactory(db)
// 6. Create typed repository
const userRepo = factory.create<'users', User>({
tableName: 'users',
mapRow: (row) => ({
id: row.id,
email: row.email,
name: row.name,
created_at: row.created_at
}),
schemas: {
create: CreateUserSchema,
update: UpdateUserSchema
}
})
// 7. Use repository (fully typed!)
const user = await userRepo.create({
email: '[email protected]',
name: 'Alice'
})
const foundUser = await userRepo.findById(user.id)
const allUsers = await userRepo.findAll()
await userRepo.update(user.id, { name: 'Alice Smith' })
await userRepo.delete(user.id)
// 8. Use in transaction
await db.transaction().execute(async (trx) => {
const txRepo = userRepo.withTransaction(trx)
const user1 = await txRepo.create({ email: '[email protected]', name: 'Bob' })
const user2 = await txRepo.create({ email: '[email protected]', name: 'Charlie' })
})📚 Table of Contents
- Repository Factory Pattern
- CRUD Operations
- Batch Operations
- Validation
- Transaction Support
- Query Operations
- Type Utilities
- Plugin System
- Multi-Database Support
- API Reference
- Best Practices
- Performance
- Migration Guide
🏭 Repository Factory Pattern
The repository factory provides type-safe repository creation with full TypeScript inference.
Basic Usage
import { createRepositoryFactory } from '@kysera/repository'
// Create factory from database connection
const factory = createRepositoryFactory(db)
// Create repository with type parameters
const userRepo = factory.create<'users', User>({
tableName: 'users',
mapRow: (row) => ({
id: row.id,
email: row.email,
name: row.name,
created_at: row.created_at
}),
schemas: {
create: CreateUserSchema,
update: UpdateUserSchema
}
})Type Inference
The factory provides full type inference:
// ✅ Type-safe: tableName must be keyof Database
const userRepo = factory.create<'users', User>({
tableName: 'users', // ✅ Valid
// ...
})
// ❌ Type error: 'invalid' is not a valid table name
const invalidRepo = factory.create<'invalid', User>({
tableName: 'invalid', // ❌ TypeScript error
// ...
})
// ✅ mapRow receives correctly typed row
const userRepo = factory.create<'users', User>({
tableName: 'users',
mapRow: (row) => {
// row is typed as Selectable<Database['users']>
// Full autocomplete and type checking!
return {
id: row.id, // ✅ number
email: row.email, // ✅ string
name: row.name // ✅ string
}
},
schemas: { create: CreateUserSchema }
})Repository Configuration
interface RepositoryConfig<Table, Entity> {
// Table name (must be keyof Database)
tableName: string
// Map database row to domain entity
mapRow: (row: Selectable<Table>) => Entity
// Zod validation schemas
schemas: {
entity?: z.ZodType<Entity> // Optional entity validation
create: z.ZodType // Required for create operations
update?: z.ZodType // Optional for update (defaults to create.partial())
}
// Validate database results (default: NODE_ENV === 'development')
validateDbResults?: boolean
// Validation strategy (default: 'strict')
validationStrategy?: 'none' | 'strict'
}Configuration Examples
Minimal Configuration:
const userRepo = factory.create<'users', User>({
tableName: 'users',
mapRow: (row) => row as User,
schemas: {
create: CreateUserSchema
}
})Full Configuration:
const userRepo = factory.create<'users', User>({
tableName: 'users',
mapRow: (row) => ({
id: row.id,
email: row.email,
name: row.name,
created_at: new Date(row.created_at) // Type conversion
}),
schemas: {
entity: UserSchema, // Validate results
create: CreateUserSchema, // Validate create input
update: UpdateUserSchema // Validate update input
},
validateDbResults: true, // Always validate results
validationStrategy: 'strict' // Throw on validation errors
})📝 CRUD Operations
All repositories implement a consistent CRUD interface with full type safety.
Create
// Single create
const user = await userRepo.create({
email: '[email protected]',
name: 'Alice'
})
// Returns: User { id: 1, email: '[email protected]', name: 'Alice', ... }
// Validation happens automatically
await userRepo.create({
email: 'invalid-email', // ❌ Throws ZodError
name: 'Bob'
})
await userRepo.create({
email: '[email protected]',
name: '' // ❌ Throws ZodError (min length 1)
})Read
findById
const user = await userRepo.findById(1)
// Returns: User | null
if (user) {
console.log(user.email) // ✅ Type-safe access
}findAll
const users = await userRepo.findAll()
// Returns: User[]
users.forEach(user => {
console.log(user.name) // ✅ Fully typed
})findByIds
const users = await userRepo.findByIds([1, 2, 3])
// Returns: User[]
console.log(users.length) // Could be 0-3 depending on what existsfind (with conditions)
// Find by single condition
const activeUsers = await userRepo.find({
where: { active: true }
})
// Find by multiple conditions (AND logic)
const specificUsers = await userRepo.find({
where: {
role: 'admin',
active: true,
department: 'engineering'
}
})
// Find all (no conditions)
const allUsers = await userRepo.find()findOne
// Find first matching record
const admin = await userRepo.findOne({
where: { role: 'admin' }
})
// Returns: User | null
// Find first record (no conditions)
const anyUser = await userRepo.findOne()Update
// Update single record
const updatedUser = await userRepo.update(1, {
name: 'Alice Smith'
})
// Returns: User (throws if not found)
// Validation happens automatically
await userRepo.update(1, {
email: 'invalid-email' // ❌ Throws ZodError
})
// Partial updates work
await userRepo.update(1, {
name: 'New Name' // Only updates name, email unchanged
})
// Update not found
await userRepo.update(999, { name: 'Test' })
// ❌ Throws: "Record with id 999 not found"Delete
// Delete single record
const deleted = await userRepo.delete(1)
// Returns: boolean (true if deleted, false if not found)
if (deleted) {
console.log('User deleted successfully')
}
// Delete non-existent record
const result = await userRepo.delete(999)
// Returns: false (no error thrown)📦 Batch Operations
Efficient bulk operations with validation and type safety.
Bulk Create
// Create multiple records at once
const users = await userRepo.bulkCreate([
{ email: '[email protected]', name: 'Alice' },
{ email: '[email protected]', name: 'Bob' },
{ email: '[email protected]', name: 'Charlie' }
])
// Returns: User[] (array of created users)
console.log(users.length) // 3
users.forEach(user => {
console.log(`Created user ${user.id}: ${user.name}`)
})
// Each item is validated independently
await userRepo.bulkCreate([
{ email: '[email protected]', name: 'Valid' },
{ email: 'invalid-email', name: 'Invalid' } // ❌ Throws on second item
])
// Empty array returns empty array
const result = await userRepo.bulkCreate([])
// Returns: []Bulk Update
// Update multiple records
const updated = await userRepo.bulkUpdate([
{ id: 1, data: { name: 'Alice Updated' } },
{ id: 2, data: { name: 'Bob Updated' } },
{ id: 3, data: { email: '[email protected]' } }
])
// Returns: User[] (array of updated users)
// Throws if any record not found
await userRepo.bulkUpdate([
{ id: 1, data: { name: 'Valid' } },
{ id: 999, data: { name: 'Invalid' } } // ❌ Throws: "Failed to update record with id 999: Record not found"
])
// Validation applied to each update
await userRepo.bulkUpdate([
{ id: 1, data: { email: 'invalid-email' } } // ❌ Throws ZodError
])Bulk Update Performance
// Updates are executed in parallel for performance
// This is equivalent to:
const promises = updates.map(({ id, data }) =>
userRepo.update(id, data)
)
const results = await Promise.all(promises)
// For atomic updates, wrap in transaction:
await db.transaction().execute(async (trx) => {
const txRepo = userRepo.withTransaction(trx)
await txRepo.bulkUpdate(updates)
// All updates committed together or all rolled back
})Bulk Delete
// Delete multiple records by IDs
const deletedCount = await userRepo.bulkDelete([1, 2, 3])
// Returns: number (count of deleted records)
console.log(`Deleted ${deletedCount} users`) // e.g., "Deleted 3 users"
// Returns 0 for non-existent IDs (no error)
const count = await userRepo.bulkDelete([999, 1000])
// Returns: 0
// Empty array returns 0
const result = await userRepo.bulkDelete([])
// Returns: 0✅ Validation
Smart validation with Zod schemas, configurable per environment.
Input Validation
Input validation always happens (cannot be disabled):
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
age: z.number().int().positive().optional()
})
const userRepo = factory.create<'users', User>({
tableName: 'users',
mapRow: (row) => row as User,
schemas: {
create: CreateUserSchema
}
})
// ✅ Valid input
await userRepo.create({
email: '[email protected]',
name: 'Alice',
age: 30
})
// ❌ Invalid email
await userRepo.create({
email: 'not-an-email',
name: 'Bob'
})
// Throws: ZodError with details
// ❌ Name too long
await userRepo.create({
email: '[email protected]',
name: 'B'.repeat(101)
})
// Throws: ZodError
// ❌ Missing required field
await userRepo.create({
email: '[email protected]'
// name missing
})
// Throws: ZodErrorResult Validation
Result validation is optional and controlled by environment:
const UserSchema = z.object({
id: z.number(),
email: z.string().email(),
name: z.string(),
created_at: z.date()
})
const userRepo = factory.create<'users', User>({
tableName: 'users',
mapRow: (row) => ({
id: row.id,
email: row.email,
name: row.name,
created_at: new Date(row.created_at)
}),
schemas: {
entity: UserSchema, // Enable result validation
create: CreateUserSchema
},
validateDbResults: true // Explicitly enable (default: NODE_ENV === 'development')
})
// Database results are validated against UserSchema
const user = await userRepo.findById(1)
// If database returns invalid data, throws ZodErrorWhen to Use Result Validation
Enable in development:
- Catch schema mismatches early
- Detect data type issues
- Validate date conversions
- Debug data integrity issues
Disable in production:
- Better performance (no validation overhead)
- Trust your database schema
- Validation already happened on input
Validation Strategies
Control validation behavior:
// Strategy: 'strict' (default)
// Throws on validation errors
const strictRepo = factory.create<'users', User>({
tableName: 'users',
mapRow: (row) => row as User,
schemas: {
create: CreateUserSchema
},
validationStrategy: 'strict'
})
await strictRepo.create({ email: 'invalid' })
// ❌ Throws ZodError
// Strategy: 'none'
// Skips input validation (not recommended!)
const unsafeRepo = factory.create<'users', User>({
tableName: 'users',
mapRow: (row) => row as User,
schemas: {
create: CreateUserSchema
},
validationStrategy: 'none'
})
await unsafeRepo.create({ email: 'invalid' })
// ⚠️ No validation, passes invalid data to databaseEnvironment Variables
Control validation globally via environment variables:
# Priority order (highest to lowest):
# 1. KYSERA_VALIDATION_MODE (recommended)
export KYSERA_VALIDATION_MODE=always # Validate in all environments
export KYSERA_VALIDATION_MODE=never # Never validate results
export KYSERA_VALIDATION_MODE=development # Validate only in dev
export KYSERA_VALIDATION_MODE=production # Never validate
# 2. KYSERA_VALIDATE (backward compatibility)
export KYSERA_VALIDATE=always
export KYSERA_VALIDATE=never
# 3. VALIDATE_DB_RESULTS (legacy)
export VALIDATE_DB_RESULTS=always
export VALIDATE_DB_RESULTS=never
# 4. NODE_ENV (default behavior)
export NODE_ENV=development # Enables result validation
export NODE_ENV=production # Disables result validationValidation Configuration Examples
Development (default):
NODE_ENV=development
# Result validation: ✅ Enabled
# Input validation: ✅ Always enabledProduction (default):
NODE_ENV=production
# Result validation: ❌ Disabled (performance)
# Input validation: ✅ Always enabledForce validation in production:
NODE_ENV=production
KYSERA_VALIDATION_MODE=always
# Result validation: ✅ Enabled
# Input validation: ✅ Always enabledDisable validation in development:
NODE_ENV=development
KYSERA_VALIDATION_MODE=never
# Result validation: ❌ Disabled
# Input validation: ✅ Always enabled (cannot disable)🔄 Transaction Support
Repositories support transaction dependency injection via the Executor<DB> pattern.
Executor Pattern
The Executor<DB> type accepts both Kysely<DB> and Transaction<DB>:
import type { Executor } from '@kysera/repository'
// Executor type = Kysely<DB> | Transaction<DB>
function createUserRepository(executor: Executor<Database>) {
const factory = createRepositoryFactory(executor)
return factory.create<'users', User>({ /* ... */ })
}
// Works with database instance
const repo1 = createUserRepository(db)
await repo1.findAll()
// Works with transaction
await db.transaction().execute(async (trx) => {
const repo2 = createUserRepository(trx)
await repo2.create({ email: '[email protected]', name: 'Test' })
})withTransaction Method
Every repository has a withTransaction method:
// Create repository with db
const userRepo = factory.create<'users', User>({ /* ... */ })
// Use in transaction
await db.transaction().execute(async (trx) => {
const txRepo = userRepo.withTransaction(trx)
const user1 = await txRepo.create({ email: '[email protected]', name: 'Alice' })
const user2 = await txRepo.create({ email: '[email protected]', name: 'Bob' })
// Both commits together or both rollback on error
})Repository Bundle Pattern
Create multiple repositories at once for cleaner transaction code:
import { createRepositoriesFactory } from '@kysera/repository'
// Define repository factories
const createUserRepo = (executor: Executor<Database>) => {
const factory = createRepositoryFactory(executor)
return factory.create<'users', User>({ /* ... */ })
}
const createPostRepo = (executor: Executor<Database>) => {
const factory = createRepositoryFactory(executor)
return factory.create<'posts', Post>({ /* ... */ })
}
const createCommentRepo = (executor: Executor<Database>) => {
const factory = createRepositoryFactory(executor)
return factory.create<'comments', Comment>({ /* ... */ })
}
// Create bundle factory
const createRepositories = createRepositoriesFactory({
users: createUserRepo,
posts: createPostRepo,
comments: createCommentRepo
})
// Use with database
const repos = createRepositories(db)
await repos.users.findAll()
await repos.posts.findById(1)
// Use in transaction (clean one-liner!)
await db.transaction().execute(async (trx) => {
const repos = createRepositories(trx)
// All operations in same transaction
const user = await repos.users.create({ email: '[email protected]', name: 'Alice' })
const post = await repos.posts.create({ userId: user.id, title: 'Hello World' })
const comment = await repos.comments.create({ postId: post.id, text: 'Great post!' })
})Complex Transaction Example
async function createUserWithProfile(
email: string,
name: string,
profileData: ProfileData
): Promise<{ user: User, profile: Profile }> {
return db.transaction().execute(async (trx) => {
const repos = createRepositories(trx)
// Create user
const user = await repos.users.create({ email, name })
// Create profile
const profile = await repos.profiles.create({
userId: user.id,
...profileData
})
// Create welcome notification
await repos.notifications.create({
userId: user.id,
message: 'Welcome to our platform!'
})
return { user, profile }
})
}
// All operations committed atomically
const result = await createUserWithProfile(
'[email protected]',
'Alice',
{ bio: 'Developer', website: 'https://alice.dev' }
)🔍 Query Operations
Advanced query operations beyond basic CRUD.
Find Methods
find (with where conditions)
// Find with single condition
const admins = await userRepo.find({
where: { role: 'admin' }
})
// Find with multiple conditions (AND logic)
const results = await userRepo.find({
where: {
role: 'admin',
active: true,
department: 'engineering'
}
})
// Find all
const all = await userRepo.find()findOne
// Find first match
const admin = await userRepo.findOne({
where: { role: 'admin' }
})
if (admin) {
console.log(`First admin: ${admin.name}`)
}
// Find first record
const anyUser = await userRepo.findOne()Count and Exists
// Count all records
const total = await userRepo.count()
console.log(`Total users: ${total}`)
// Count with conditions
const activeCount = await userRepo.count({
where: { active: true }
})
console.log(`Active users: ${activeCount}`)
// Check existence
const hasUsers = await userRepo.exists()
console.log(`Has users: ${hasUsers}`)
// Check existence with conditions
const hasAdmins = await userRepo.exists({
where: { role: 'admin' }
})
console.log(`Has admins: ${hasAdmins}`)Pagination
Offset-based Pagination
// Page 1 (first 20 records)
const page1 = await userRepo.paginate({
limit: 20,
offset: 0,
orderBy: 'created_at',
orderDirection: 'desc'
})
console.log(`Items: ${page1.items.length}`)
console.log(`Total: ${page1.total}`)
console.log(`Limit: ${page1.limit}`)
console.log(`Offset: ${page1.offset}`)
// Page 2 (next 20 records)
const page2 = await userRepo.paginate({
limit: 20,
offset: 20,
orderBy: 'created_at',
orderDirection: 'desc'
})
// Calculate total pages
const totalPages = Math.ceil(page1.total / page1.limit)Cursor-based Pagination (Keyset Pagination)
True keyset pagination with O(1) performance and cursor stability.
// First page (default: ordered by 'id' ascending)
const page1 = await userRepo.paginateCursor({
limit: 20
})
console.log(`Items: ${page1.items.length}`) // 20
console.log(`Has more: ${page1.hasMore}`) // true/false
console.log(`Next cursor:`, page1.nextCursor) // { value: 20, id: 20 }
// Next page
if (page1.nextCursor) {
const page2 = await userRepo.paginateCursor({
limit: 20,
cursor: page1.nextCursor
})
}
// Custom ordering (e.g., by created_at descending)
const recentUsers = await userRepo.paginateCursor({
limit: 10,
orderBy: 'created_at',
orderDirection: 'desc'
})
// Continue pagination
const nextPage = await userRepo.paginateCursor({
limit: 10,
cursor: recentUsers.nextCursor,
orderBy: 'created_at',
orderDirection: 'desc'
})Keyset vs Offset Pagination:
| Feature | Keyset (Cursor) | Offset | |---------|----------------|--------| | Performance | O(1) constant | O(N) degrades with offset | | Cursor Stability | ✅ Stable across inserts/deletes | ❌ Shifts with data changes | | Deep Pagination | ✅ Fast at any depth | ❌ Slow at large offsets | | Random Access | ❌ Sequential only | ✅ Jump to any page | | Use Case | Infinite scroll, feeds | Admin tables, reports |
How it works:
Keyset pagination uses WHERE clauses instead of OFFSET:
-- Offset pagination (slow for large offsets)
SELECT * FROM users ORDER BY id LIMIT 20 OFFSET 1000; -- Scans 1020 rows
-- Keyset pagination (always fast)
SELECT * FROM users
WHERE id > 1000 -- Uses index
ORDER BY id
LIMIT 20; -- Scans 20 rowsTie-breaking with duplicate values:
When ordering by non-unique columns, id is used as a secondary sort for consistent pagination:
// Users with duplicate names are paginated consistently
const page1 = await userRepo.paginateCursor({
limit: 2,
orderBy: 'name',
orderDirection: 'asc'
})
// Even with duplicates, pagination is stable and complete
// Example: Alice (id:1), Alice (id:2), Bob (id:3)
// Page 1: Alice (id:1), Alice (id:2)
// Page 2: Bob (id:3)🎨 Type Utilities
Useful type utilities for working with repositories.
Type Extraction
import type {
Unwrap,
DomainType,
EntityType,
CreateInput,
UpdateInput
} from '@kysera/repository'
interface UsersTable {
id: Generated<number>
email: string
name: string
created_at: Generated<Date>
}
// Remove Generated<> wrapper
type Id = Unwrap<UsersTable['id']> // number
type CreatedAt = Unwrap<UsersTable['created_at']> // Date
// Convert table type to domain type
type User = DomainType<UsersTable>
// { id: number, email: string, name: string, created_at: Date }
// Extract selectable fields
type UserEntity = EntityType<UsersTable>
// Same as Selectable<UsersTable>
// Extract create input (omit generated fields)
type UserCreate = CreateInput<UsersTable>
// { email: string, name: string }
// Extract update input (all optional)
type UserUpdate = UpdateInput<UsersTable>
// { email?: string, name?: string }Database Schema Constraint
import type { DatabaseSchema } from '@kysera/repository'
// Ensure all tables have an id field
interface MyDatabase extends DatabaseSchema {
users: {
id: Generated<number>
email: string
// ...
}
posts: {
id: Generated<number>
title: string
// ...
}
}
// Type error if table missing id:
interface InvalidDatabase extends DatabaseSchema {
users: {
// ❌ Error: missing 'id' field
email: string
}
}Repository Type Extraction
import type { Repository } from '@kysera/repository'
// Extract repository type
const userRepo = factory.create<'users', User>({ /* ... */ })
type UserRepository = typeof userRepo
// Repository<User, Database>
// Use in function signatures
async function findUserByEmail(
repo: UserRepository,
email: string
): Promise<User | null> {
return repo.findOne({ where: { email } })
}🔌 Plugin System
Extend repository behavior with plugins.
Plugin Interface
interface Plugin {
name: string
version: string
// Lifecycle hooks
onInit?<DB>(executor: Kysely<DB>): Promise<void> | void
// Query interceptors (modify queries)
interceptQuery?<QB>(qb: QB, context: QueryBuilderContext): QB
// Result interceptors (post-execution)
afterQuery?(context: QueryContext, result: unknown): Promise<unknown> | unknown
onError?(context: QueryContext, error: unknown): Promise<void> | void
// Repository extensions
extendRepository?<T>(repo: T): T
}Creating a Plugin
import { Plugin } from '@kysera/repository'
const auditPlugin: Plugin = {
name: 'audit-plugin',
version: '1.0.0',
async onInit(executor) {
console.log('Audit plugin initialized')
},
interceptQuery(qb, context) {
console.log(`Query: ${context.operation} on ${context.table}`)
return qb
},
async afterQuery(context, result) {
console.log(`Result: ${JSON.stringify(result)}`)
return result
},
async onError(context, error) {
console.error(`Error in ${context.operation}:`, error)
},
extendRepository(repo) {
// Add custom methods
return {
...repo,
customMethod() {
console.log('Custom method called')
}
}
}
}Using Plugins
import { createORM } from '@kysera/repository'
// Create ORM with plugins
const orm = await createORM(db, [auditPlugin, softDeletePlugin])
// Create repository with plugin support
const userRepo = orm.createRepository((executor, applyPlugins) => {
const factory = createRepositoryFactory(executor)
return factory.create<'users', User>({ /* ... */ })
})
// All operations now go through plugins
await userRepo.create({ email: '[email protected]', name: 'Test' })
// Logs: "Query: insert on users"
// Logs: "Result: {...}"withPlugins Helper
import { withPlugins } from '@kysera/repository'
// Simpler plugin integration
const userRepo = await withPlugins(
(executor) => {
const factory = createRepositoryFactory(executor)
return factory.create<'users', User>({ /* ... */ })
},
db,
[auditPlugin, softDeletePlugin]
)🗄️ Multi-Database Support
Unified API across PostgreSQL, MySQL, and SQLite with automatic adapter detection.
PostgreSQL
import { Kysely, PostgresDialect } from 'kysely'
import { Pool } from 'pg'
const db = new Kysely<Database>({
dialect: new PostgresDialect({
pool: new Pool({
host: 'localhost',
database: 'myapp'
})
})
})
const factory = createRepositoryFactory(db)
// ✅ Full support for RETURNING clause
// ✅ Optimal performance for bulk operationsMySQL
import { Kysely, MysqlDialect } from 'kysely'
import { createPool } from 'mysql2'
const db = new Kysely<Database>({
dialect: new MysqlDialect({
pool: createPool({
host: 'localhost',
database: 'myapp'
})
})
})
const factory = createRepositoryFactory(db)
// ✅ Automatic fallback for operations without RETURNING
// ✅ Uses insertId and additional SELECT when neededSQLite
import { Kysely, SqliteDialect } from 'kysely'
import Database from 'better-sqlite3'
const db = new Kysely<Database>({
dialect: new SqliteDialect({
database: new Database('myapp.db')
})
})
const factory = createRepositoryFactory(db)
// ✅ Full support for RETURNING clause
// ✅ Works with in-memory databasesDatabase-Specific Handling
The repository automatically detects the database type and adapts behavior:
| Operation | PostgreSQL | MySQL | SQLite |
|-----------|-----------|-------|--------|
| INSERT | RETURNING * | insertId + SELECT | RETURNING * |
| UPDATE | RETURNING * | UPDATE + SELECT | RETURNING * |
| Bulk INSERT | Single query with RETURNING * | Loop with insertId + SELECT | Single query with RETURNING * |
| Boolean Type | true/false | 1/0 | 1/0 |
Multi-Database Testing
// Test suite runs against all databases
import { describeAllDatabases } from './test-utils'
describeAllDatabases('User Repository', (getDb) => {
it('should create user', async () => {
const db = getDb()
const factory = createRepositoryFactory(db)
const userRepo = factory.create<'users', User>({ /* ... */ })
const user = await userRepo.create({
email: '[email protected]',
name: 'Test User'
})
expect(user.email).toBe('[email protected]')
// ✅ Works on PostgreSQL, MySQL, SQLite
})
})📖 API Reference
createRepositoryFactory
function createRepositoryFactory<DB>(
executor: Executor<DB>
): RepositoryFactory<DB>Creates a factory for building type-safe repositories.
Parameters:
executor: Executor<DB>- Kysely instance or Transaction
Returns: Factory object with create method
RepositoryFactory.create
create<TableName extends keyof DB & string, Entity>(
config: RepositoryConfig<DB[TableName], Entity>
): Repository<Entity, DB>Creates a typed repository for a specific table.
Type Parameters:
TableName- Table name from database schemaEntity- Domain entity type
Parameters:
config: RepositoryConfig- Repository configuration
Returns: Fully typed repository instance
Repository Interface
interface Repository<Entity, DB> {
// Properties
readonly executor: Executor<DB>
readonly tableName: string
// Transaction support
withTransaction(trx: Transaction<DB>): Repository<Entity, DB>
transaction<R>(fn: (trx: Transaction<DB>) => Promise<R>): Promise<R>
// Single record operations
findById(id: number): Promise<Entity | null>
findAll(): Promise<Entity[]>
create(input: unknown): Promise<Entity>
update(id: number, input: unknown): Promise<Entity>
delete(id: number): Promise<boolean>
// Batch operations
findByIds(ids: number[]): Promise<Entity[]>
bulkCreate(inputs: unknown[]): Promise<Entity[]>
bulkUpdate(updates: { id: number, data: unknown }[]): Promise<Entity[]>
bulkDelete(ids: number[]): Promise<number>
// Query operations
find(options?: { where?: Record<string, unknown> }): Promise<Entity[]>
findOne(options?: { where?: Record<string, unknown> }): Promise<Entity | null>
count(options?: { where?: Record<string, unknown> }): Promise<number>
exists(options?: { where?: Record<string, unknown> }): Promise<boolean>
// Pagination
paginate(options: PaginateOptions): Promise<PaginatedResult<Entity>>
paginateCursor(options: CursorOptions): Promise<CursorResult<Entity>>
}createRepositoriesFactory
function createRepositoriesFactory<DB, Repos>(
factories: RepositoryFactoryMap<DB, Repos>
): (executor: Executor<DB>) => ReposCreates a bundle factory for multiple repositories.
Parameters:
factories: RepositoryFactoryMap- Map of repository factory functions
Returns: Function that creates all repositories from an executor
Example:
const createRepos = createRepositoriesFactory({
users: createUserRepo,
posts: createPostRepo
})
const repos = createRepos(db)
// repos.users: UserRepository
// repos.posts: PostRepositoryValidation Functions
getValidationMode
function getValidationMode(): 'always' | 'never' | 'development' | 'production'Gets current validation mode from environment variables.
shouldValidate
function shouldValidate(options?: ValidationOptions): booleanDetermines if validation should be enabled.
Parameters:
options?: ValidationOptions- Optional validation options
Returns: true if validation should be enabled
createValidator
function createValidator<T>(
schema: z.ZodType<T>,
options?: ValidationOptions
): Validator<T>Creates a validator wrapper with multiple validation methods.
Returns:
interface Validator<T> {
validate(data: unknown): T // Throws on error
validateSafe(data: unknown): T | null // Returns null on error
isValid(data: unknown): boolean // Returns boolean
validateConditional(data: unknown): T // Validates based on mode
}Plugin Functions
createORM
async function createORM<DB>(
executor: Kysely<DB>,
plugins: Plugin[]
): Promise<PluginOrm<DB>>Creates an ORM instance with plugin support.
withPlugins
async function withPlugins<DB, T>(
factory: (executor: Kysely<DB>) => T,
executor: Kysely<DB>,
plugins: Plugin[]
): Promise<T>Helper to reduce boilerplate when using plugins.
Type Utilities
// Remove Generated<> wrapper
type Unwrap<T> = T extends Generated<infer U> ? U : T
// Convert table to domain type
type DomainType<Table> = { [K in keyof Table]: Unwrap<Table[K]> }
// Selectable fields
type EntityType<Table> = Selectable<Table>
// Create input (omit generated)
type CreateInput<Table> = { [K in keyof Table as ...]: Table[K] }
// Update input (partial create)
type UpdateInput<Table> = Partial<CreateInput<Table>>
// Database schema constraint
type DatabaseSchema = { [K: string]: { id: ..., [key: string]: unknown } }
// Executor type
type Executor<DB> = Kysely<DB> | Transaction<DB>✨ Best Practices
1. Use Factory Pattern for Consistency
// ✅ Good: Centralized repository creation
function createUserRepository(executor: Executor<Database>) {
const factory = createRepositoryFactory(executor)
return factory.create<'users', User>({
tableName: 'users',
mapRow: (row) => ({ /* ... */ }),
schemas: { create: CreateUserSchema, update: UpdateUserSchema }
})
}
// Use everywhere
const userRepo = createUserRepository(db)
await db.transaction().execute(async (trx) => {
const txUserRepo = createUserRepository(trx)
})
// ❌ Bad: Duplicated configuration
const userRepo1 = factory.create<'users', User>({ /* config */ })
const userRepo2 = factory.create<'users', User>({ /* duplicate config */ })2. Always Define Update Schemas
// ✅ Good: Explicit update schema
const UpdateUserSchema = z.object({
email: z.string().email().optional(),
name: z.string().min(1).optional()
})
const userRepo = factory.create<'users', User>({
tableName: 'users',
mapRow: (row) => row as User,
schemas: {
create: CreateUserSchema,
update: UpdateUserSchema // ✅ Explicit
}
})
// ❌ Bad: Relying on automatic partial()
// May not work correctly with Zod refinements3. Use Repository Bundles for Transactions
// ✅ Good: Clean transaction code
const createRepos = createRepositoriesFactory({
users: createUserRepo,
posts: createPostRepo,
comments: createCommentRepo
})
await db.transaction().execute(async (trx) => {
const repos = createRepos(trx)
const user = await repos.users.create({ /* ... */ })
const post = await repos.posts.create({ /* ... */ })
})
// ❌ Bad: Verbose transaction code
await db.transaction().execute(async (trx) => {
const userRepo = createUserRepository(trx)
const postRepo = createPostRepository(trx)
const commentRepo = createCommentRepository(trx)
// ...
})4. Enable Result Validation in Development
// .env.development
NODE_ENV=development
# Result validation automatically enabled
// .env.production
NODE_ENV=production
# Result validation automatically disabled for performance5. Use Batch Operations for Performance
// ✅ Good: Single batch operation
const users = await userRepo.bulkCreate([
{ email: '[email protected]', name: 'User 1' },
{ email: '[email protected]', name: 'User 2' },
{ email: '[email protected]', name: 'User 3' }
])
// ❌ Bad: Loop with individual creates
for (const userData of usersData) {
await userRepo.create(userData) // N database queries!
}6. Type-Safe Row Mapping
// ✅ Good: Explicit mapping with type conversions
mapRow: (row: Selectable<Database['users']>) => ({
id: row.id,
email: row.email,
name: row.name,
created_at: new Date(row.created_at), // Convert string to Date
deleted_at: row.deleted_at ? new Date(row.deleted_at) : null
})
// ❌ Bad: Type assertion without conversion
mapRow: (row) => row as User7. Atomic Bulk Updates
// ✅ Good: Wrap in transaction for atomicity
await db.transaction().execute(async (trx) => {
const txRepo = userRepo.withTransaction(trx)
await txRepo.bulkUpdate(updates)
// All updates committed together or all rolled back
})
// ⚠️ OK for non-critical updates: Parallel execution
await userRepo.bulkUpdate(updates)
// Updates execute in parallel (faster but not atomic)⚡ Performance
Bundle Size
| Module | Size (minified) | Exports | |--------|----------------|---------| | repository | ~2 KB | Factory, Repository interface | | base-repository | ~1.5 KB | CRUD operations | | table-operations | ~1 KB | Low-level database operations | | validation | ~0.5 KB | Validation utilities | | plugin | ~0.5 KB | Plugin system | | helpers | ~0.3 KB | Repository bundle factory | | Full Package | 4.93 KB | All modules |
Operation Performance
Benchmarked on PostgreSQL with 1000 records:
| Operation | Time | Notes | |-----------|------|-------| | create | ~2ms | Single INSERT with RETURNING | | findById | ~1ms | Indexed SELECT | | findAll | ~15ms | Full table scan | | update | ~2ms | Single UPDATE with RETURNING | | delete | ~1ms | Single DELETE | | bulkCreate (10) | ~5ms | Single INSERT with 10 rows | | bulkCreate (100) | ~20ms | Single INSERT with 100 rows | | bulkUpdate (10) | ~15ms | 10 parallel UPDATE queries | | bulkDelete (10) | ~3ms | Single DELETE with IN clause |
Database-Specific Performance
PostgreSQL (Fastest)
- ✅ Native
RETURNING *support - ✅ Optimal bulk inserts (single query)
- ✅ Efficient bulk updates (single query)
SQLite (Fast)
- ✅ Native
RETURNING *support - ✅ Optimal bulk inserts (single query)
- ⚠️ In-memory mode extremely fast
MySQL (Good)
- ⚠️ No
RETURNINGsupport (extra SELECT needed) - ⚠️ Bulk inserts require loop + SELECT per row
- ⚠️ ~20% slower than PostgreSQL for bulk operations
Validation Overhead
| Mode | Overhead per Operation | |------|----------------------| | Input validation only | +0.1ms (always enabled) | | Input + Result validation | +0.3ms (development) | | No result validation | +0.1ms (production) |
Optimization Tips
- Batch Operations: Use
bulkCreate,bulkUpdate,bulkDeleteinstead of loops - Disable Result Validation: Set
NODE_ENV=productionfor ~2x faster reads - Use Indexes: Ensure
idand foreign keys are indexed - PostgreSQL First: Best performance and feature support
- Transaction Batching: Group multiple operations in transactions
🔄 Migration Guide
From Raw Kysely
Before
// Manual CRUD operations
const user = await db
.selectFrom('users')
.selectAll()
.where('id', '=', 1)
.executeTakeFirst()
await db
.insertInto('users')
.values({ email: '[email protected]', name: 'Test' })
.execute()
await db
.updateTable('users')
.set({ name: 'Updated' })
.where('id', '=', 1)
.execute()
// No validation
// No type safety for domain entities
// Verbose queriesAfter
import { createRepositoryFactory } from '@kysera/repository'
const factory = createRepositoryFactory(db)
const userRepo = factory.create<'users', User>({
tableName: 'users',
mapRow: (row) => row as User,
schemas: {
create: CreateUserSchema,
update: UpdateUserSchema
}
})
// Clean, validated, type-safe operations
const user = await userRepo.findById(1)
await userRepo.create({ email: '[email protected]', name: 'Test' })
await userRepo.update(1, { name: 'Updated' })From TypeORM
// TypeORM
@Entity()
class User {
@PrimaryGeneratedColumn()
id: number
@Column()
email: string
@Column()
name: string
}
const userRepo = dataSource.getRepository(User)
const user = await userRepo.findOne({ where: { id: 1 } })
// Kysera Repository
const userRepo = factory.create<'users', User>({
tableName: 'users',
mapRow: (row) => row as User,
schemas: { create: CreateUserSchema }
})
const user = await userRepo.findById(1)From Prisma
// Prisma
const user = await prisma.user.findUnique({ where: { id: 1 } })
await prisma.user.create({ data: { email: '[email protected]', name: 'Test' } })
// Kysera Repository
const user = await userRepo.findById(1)
await userRepo.create({ email: '[email protected]', name: 'Test' })
// Key differences:
// - Kysera: More control, less magic, type-safe
// - Prisma: Auto-generated client, migrations included
// - Kysera: Bring your own migrations, use Kysely's query builder🤝 Contributing
Contributions are welcome! This package follows strict development principles:
- ✅ Minimal dependencies (@kysera/core only)
- ✅ 100% type safe (TypeScript strict mode)
- ✅ 95%+ test coverage (99+ tests)
- ✅ Multi-database tested (PostgreSQL, MySQL, SQLite)
- ✅ ESM only (no CommonJS)
See CLAUDE.md for development guidelines.
📄 License
MIT © Kysera
🔗 Links
Built with ❤️ for clean, type-safe database access
