Repository pattern implementation for Kysely with smart validation
npm install @kysera/repositoryRepository pattern implementation with unified plugin support for Kysera.
Note: The createORM function creates a plugin container for the Repository pattern - not a traditional ORM with entity mapping, Unit of Work, or Identity Map.



The Repository package provides a repository pattern interface for Kysely with full plugin support via @kysera/executor. It supports flexible validation adapters (Zod, Valibot, TypeBox, or custom), CQRS-lite patterns, and works seamlessly with plugins like soft-delete and RLS.
Key Features:
- Repository pattern with CRUD operations
- Unified plugin system via @kysera/executor
- Flexible validation adapters (Zod, Valibot, TypeBox, custom)
- Primary key flexibility (single, composite, UUID)
- Bulk operations and pagination
- CQRS-lite support (Repository writes + DAL reads)
- Full TypeScript type safety
``bash`
pnpm add @kysera/repository kysely
Optional validation libraries:
`bashFor Zod validation
pnpm add zod
Quick Start
$3
`typescript
import { createRepositoryFactory, nativeAdapter } from '@kysera/repository';
import { Kysely } from 'kysely';interface User {
id: number;
name: string;
email: string;
}
interface Database {
users: User;
}
const db: Kysely = / ... /;
const factory = createRepositoryFactory(db);
const userRepo = factory.create({
tableName: 'users',
mapRow: (row) => row,
schemas: {
create: nativeAdapter<{ name: string; email: string }>(),
},
});
// CRUD operations
const user = await userRepo.create({ name: 'Alice', email: 'alice@example.com' });
const found = await userRepo.findById(user.id);
const updated = await userRepo.update(user.id, { name: 'Alice Smith' });
await userRepo.delete(user.id);
`$3
`typescript
import { createRepositoryFactory, zodAdapter } from '@kysera/repository'
import { z } from 'zod'const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email()
})
const userRepo = factory.create({
tableName: 'users',
mapRow: row => row,
schemas: {
create: zodAdapter(CreateUserSchema),
update: zodAdapter(CreateUserSchema.partial())
}
})
// Validation happens automatically
const user = await userRepo.create({
name: 'Bob',
email: 'invalid-email' // Throws validation error
})
`Core API
$3
Create a plugin container with unified plugin management via @kysera/executor.
`typescript
import { createORM } from '@kysera/repository'
import { softDeletePlugin } from '@kysera/soft-delete'
import { rlsPlugin } from '@kysera/rls'const orm = await createORM(db, [softDeletePlugin(), rlsPlugin({ schema: rlsSchema })])
`Plugin Container Interface:
`typescript
interface PluginOrm {
// Plugin-aware executor (Kysely instance with plugin interception)
executor: Kysely // Create a repository with plugin support
createRepository(factory: (executor: Kysely, applyPlugins: ApplyPluginsFunction) => T): T
// Apply plugin interceptors to query builders
applyPlugins(qb: QB, operation: string, table: string, metadata?: Record): QB
// Registered plugins in resolved dependency order
plugins: readonly Plugin[]
// Create a DAL context with plugins
createContext(): DbContext
// Execute a transaction with both Repository and DAL patterns
transaction(fn: (ctx: DbContext) => Promise): Promise
}
`$3
Create a factory for building type-safe repositories.
`typescript
import { createRepositoryFactory } from '@kysera/repository'const factory = createRepositoryFactory(db)
const userRepo = factory.create({
tableName: 'users',
primaryKey: 'id', // Optional, default: 'id'
primaryKeyType: 'number', // Optional, default: 'number'
mapRow: row => row,
schemas: {
create: nativeAdapter(),
update: nativeAdapter()
}
})
`Plugin Integration
Plugins work by intercepting queries and extending repository interfaces. The plugin container uses @kysera/executor internally for unified plugin management.
$3
`typescript
import { createORM } from '@kysera/repository'
import { softDeletePlugin } from '@kysera/soft-delete'const orm = await createORM(db, [softDeletePlugin()])
// Create repository with plugin extensions
const userRepo = orm.createRepository((executor, applyPlugins) => {
const factory = createRepositoryFactory(executor)
return factory.create({
tableName: 'users',
mapRow: row => row,
schemas: {
create: nativeAdapter()
}
})
})
// Plugin methods are automatically available
await userRepo.create({ name: 'Alice', email: 'alice@example.com' })
await userRepo.softDelete(1) // Added by soft-delete plugin
await userRepo.restore(1) // Added by soft-delete plugin
`$3
1. Validation - Plugins validated for conflicts and dependencies
2. Resolution - Plugins sorted by priority and dependencies
3. Initialization -
plugin.onInit() called (if defined)
4. Interception - plugin.interceptQuery() applied to all queries
5. Extension - plugin.extendRepository() adds methods to repositories$3
`typescript
const orm = await createORM(db, [softDeletePlugin()])// Manually apply plugins to custom queries
let query = orm.executor.selectFrom('users').selectAll()
query = orm.applyPlugins(query, 'select', 'users', {
customMetadata: 'value'
})
const users = await query.execute()
`CQRS-lite Pattern
Combine Repository writes with DAL reads in the same transaction with shared plugins.
`typescript
import { createORM } from '@kysera/repository'
import { createQuery } from '@kysera/dal'
import { softDeletePlugin } from '@kysera/soft-delete'const orm = await createORM(db, [softDeletePlugin()])
// Define DAL query for complex reads
const getUserStats = createQuery((ctx, userId: number) =>
ctx.db
.selectFrom('users')
.leftJoin('posts', 'posts.user_id', 'users.id')
.select(['users.id', 'users.name', eb => eb.fn.count('posts.id').as('postCount')])
.where('users.id', '=', userId)
.groupBy(['users.id', 'users.name'])
.executeTakeFirst()
)
// Use in transaction with Repository
const result = await orm.transaction(async ctx => {
// Create repository for writes
const userRepo = orm.createRepository(executor => {
const factory = createRepositoryFactory(executor)
return factory.create({
tableName: 'users',
mapRow: row => row,
schemas: { create: nativeAdapter() }
})
})
// Write: Create user via Repository
const user = await userRepo.create({
name: 'Alice',
email: 'alice@example.com'
})
// Read: Get stats via DAL (plugins automatically applied)
const stats = await getUserStats(ctx, user.id)
return { user, stats }
})
`Benefits:
- Separation of concerns (Repository for writes, DAL for complex reads)
- Shared transaction context
- Plugins apply to both patterns
- Full type safety
Repository API
All repositories implement the
BaseRepository interface:$3
`typescript
// Create
const user = await repo.create({ name: 'Alice', email: 'alice@example.com' })// Read
const found = await repo.findById(1)
const all = await repo.findAll()
const filtered = await repo.find({ where: { name: 'Alice' } })
const one = await repo.findOne({ where: { email: 'alice@example.com' } })
// Update
const updated = await repo.update(1, { name: 'Alice Smith' })
// Delete
const deleted = await repo.delete(1) // Returns true if deleted
`$3
`typescript
// Bulk create
const users = await repo.bulkCreate([
{ name: 'Alice', email: 'alice@example.com' },
{ name: 'Bob', email: 'bob@example.com' }
])// Bulk update
const updated = await repo.bulkUpdate([
{ id: 1, data: { name: 'Alice Smith' } },
{ id: 2, data: { name: 'Bob Jones' } }
])
// Bulk delete
const deletedCount = await repo.bulkDelete([1, 2, 3])
`$3
`typescript
// Count
const total = await repo.count()
const filtered = await repo.count({ where: { active: true } })// Exists
const exists = await repo.exists({ where: { email: 'alice@example.com' } })
// Find by IDs
const users = await repo.findByIds([1, 2, 3])
`$3
Offset-based pagination:
`typescript
const result = await repo.paginate({
limit: 10,
offset: 0,
orderBy: 'created_at',
orderDirection: 'desc'
})console.log(result.items) // Array of entities
console.log(result.total) // Total count
console.log(result.limit) // 10
console.log(result.offset) // 0
`Cursor-based pagination:
`typescript
const result = await repo.paginateCursor({
limit: 10,
orderBy: 'created_at',
orderDirection: 'desc'
})console.log(result.items) // Array of entities
console.log(result.nextCursor) // { value: Date, id: number }
console.log(result.hasMore) // boolean
// Next page
const nextPage = await repo.paginateCursor({
limit: 10,
cursor: result.nextCursor,
orderBy: 'created_at',
orderDirection: 'desc'
})
`$3
`typescript
await repo.transaction(async trx => {
const user = await trx
.insertInto('users')
.values({ name: 'Alice', email: 'alice@example.com' })
.returningAll()
.executeTakeFirstOrThrow() await trx.insertInto('profiles').values({ user_id: user.id, bio: 'Hello!' }).execute()
})
`Validation Adapters
The repository supports multiple validation libraries through a unified adapter interface.
$3
`typescript
import { z } from 'zod'
import { zodAdapter } from '@kysera/repository'const UserSchema = z.object({
name: z.string().min(1),
email: z.string().email()
})
const repo = factory.create({
tableName: 'users',
mapRow: row => row,
schemas: {
create: zodAdapter(UserSchema),
update: zodAdapter(UserSchema.partial())
}
})
`$3
`typescript
import * as v from 'valibot'
import { valibotAdapter } from '@kysera/repository'const UserSchema = v.object({
name: v.string([v.minLength(1)]),
email: v.string([v.email()])
})
const repo = factory.create({
tableName: 'users',
mapRow: row => row,
schemas: {
create: valibotAdapter(UserSchema, v)
}
})
`$3
`typescript
import { Type } from '@sinclair/typebox'
import { Value } from '@sinclair/typebox/value'
import { typeboxAdapter } from '@kysera/repository'const UserSchema = Type.Object({
name: Type.String({ minLength: 1 }),
email: Type.String({ format: 'email' })
})
const repo = factory.create({
tableName: 'users',
mapRow: row => row,
schemas: {
create: typeboxAdapter(UserSchema, Value)
}
})
`$3
`typescript
import { nativeAdapter } from '@kysera/repository'const repo = factory.create({
tableName: 'users',
mapRow: row => row,
schemas: {
create: nativeAdapter()
}
})
`$3
`typescript
import { customAdapter } from '@kysera/repository'const isPositiveNumber = customAdapter(data => {
if (typeof data !== 'number' || data <= 0) {
throw new Error('Must be a positive number')
}
return data
})
`Primary Key Configuration
Repositories support flexible primary key configurations.
$3
`typescript
const repo = factory.create({
tableName: 'users',
// primaryKey defaults to 'id'
// primaryKeyType defaults to 'number'
mapRow: row => row,
schemas: { create: nativeAdapter() }
})
`$3
`typescript
const repo = factory.create({
tableName: 'users',
primaryKey: 'user_id',
primaryKeyType: 'number',
mapRow: row => row,
schemas: { create: nativeAdapter() }
})
`$3
`typescript
const repo = factory.create({
tableName: 'users',
primaryKey: 'id',
primaryKeyType: 'uuid',
mapRow: row => row,
schemas: { create: nativeAdapter() }
})
`$3
`typescript
const repo = factory.create({
tableName: 'user_roles',
primaryKey: ['user_id', 'role_id'],
primaryKeyType: 'number',
mapRow: row => row,
schemas: { create: nativeAdapter() }
})// Usage with composite key
const userRole = await repo.findById({ user_id: 1, role_id: 2 })
await repo.delete({ user_id: 1, role_id: 2 })
`ContextAwareRepository
Abstract base class for repositories that need clean transaction handling via executor switching:
`typescript
import { ContextAwareRepository } from '@kysera/repository'
import type { Executor } from '@kysera/core'class UserRepository extends ContextAwareRepository {
async create(data: { email: string; name: string }): Promise {
return this.db.insertInto(this.tableName).values(data).returningAll().executeTakeFirstOrThrow()
}
async findById(id: number): Promise {
return (
this.db.selectFrom(this.tableName).selectAll().where('id', '=', id).executeTakeFirst() ?? null
)
}
}
// Normal usage
const userRepo = new UserRepository(db, 'users')
const user = await userRepo.findById(1)
// Transaction usage - switch executor cleanly
await db.transaction().execute(async trx => {
const txUserRepo = userRepo.withExecutor(trx)
const txPostRepo = postRepo.withExecutor(trx)
const user = await txUserRepo.create({ email: 'test@example.com', name: 'Test' })
await txPostRepo.create({ userId: user.id, title: 'Hello' })
})
`Benefits:
- Clean API: No
executor parameter in every method
- Type-safe: withExecutor() returns same repository type
- Preserves instance: Custom properties preservedUpsert Helpers
Functions for INSERT ... ON CONFLICT DO UPDATE operations:
`typescript
import { upsert, upsertMany } from '@kysera/repository'// Single record upsert
const wallet = await upsert(
db,
'wallets',
{
name: 'Main Wallet',
balance: 1000
},
{
conflictColumns: ['name'],
returning: true
}
)
// Batch upsert
const prices = await upsertMany(
db,
'price_history',
[
{ pair: 'BTC/USD', timestamp: now, price: 50000 },
{ pair: 'ETH/USD', timestamp: now, price: 3000 }
],
{
conflictColumns: ['pair', 'timestamp'],
updateColumns: ['price'],
returning: true
}
)
// Upsert with specific update columns
await upsert(
db,
'users',
{
email: 'alice@example.com',
name: 'Alice Updated',
role: 'admin'
},
{
conflictColumns: ['email'],
updateColumns: ['name'] // Only update name, not role
}
)
`UpsertOptions:
-
conflictColumns: Columns defining the conflict constraint
- updateColumns: Columns to update (default: all except conflictColumns)
- returning: Whether to return upserted record(s)Helper Functions
$3
Create a repository with plugins in one step:
`typescript
import { withPlugins } from '@kysera/repository'
import { softDeletePlugin } from '@kysera/soft-delete'const userRepo = await withPlugins(
(executor, applyPlugins) => {
const factory = createRepositoryFactory(executor)
return factory.create({
tableName: 'users',
mapRow: row => row,
schemas: { create: nativeAdapter() }
})
},
db,
[softDeletePlugin()]
)
`$3
Create a repository without schemas (useful for plugins):
`typescript
import { createSimpleRepository } from '@kysera/repository'const userRepo = createSimpleRepository(db, 'users', row => row, {
primaryKey: 'id',
primaryKeyType: 'number'
})
`$3
Create a bundle of repositories for use in transactions:
`typescript
import { createRepositoriesFactory } from '@kysera/repository'const createRepositories = createRepositoriesFactory({
users: executor => createUserRepository(executor),
posts: executor => createPostRepository(executor),
comments: executor => createCommentRepository(executor)
})
// Use with database instance
const repos = createRepositories(db)
await repos.users.findById(1)
// Use within transaction
await db.transaction().execute(async trx => {
const repos = createRepositories(trx)
await repos.users.create({ name: 'Alice' })
await repos.posts.create({ userId: 1, title: 'Hello' })
})
`Architecture
The repository package architecture in v0.7.0:
`
@kysera/repository
├── createORM
│ └── uses @kysera/executor internally
│ ├── Validates, resolves, and initializes plugins
│ ├── Creates plugin-aware executor (Kysely instance)
│ └── Provides applyPlugins function
├── createRepositoryFactory
│ ├── Creates table operations (selectById, insert, update, etc.)
│ └── Creates base repository (CRUD + pagination + validation)
└── Plugin integration
├── interceptQuery: Modifies queries before execution
└── extendRepository: Adds methods to repository instances
`Key design principles:
- Unified Execution Layer - @kysera/executor provides plugin interception for both Repository and DAL
- Type Safety - Full TypeScript support with strict typing
- Plugin Compatibility - Both
interceptQuery and extendRepository` applied to repositories1. Use createORM for plugin management - Let @kysera/executor handle plugin lifecycle
2. Prefer validation - Use Zod or similar for runtime safety
3. Use transactions - Wrap related operations in transactions
4. Leverage CQRS-lite - Use Repository for writes, DAL for complex reads
5. Bulk operations - Use bulkCreate/bulkUpdate/bulkDelete for efficiency
6. Cursor pagination - Prefer cursor-based pagination for large datasets
- @kysera/executor - Plugin execution layer (used internally)
- @kysera/dal - Functional Data Access Layer for complex queries
- @kysera/soft-delete - Soft delete plugin
- @kysera/audit - Audit logging plugin
- @kysera/timestamps - Automatic timestamp plugin
- @kysera/rls - Row-Level Security plugin
MIT