@kysera/repository
v0.8.5
Published
Repository pattern implementation for Kysely with smart validation
Maintainers
Readme
@kysera/repository
Repository 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.
Overview
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
Installation
pnpm add @kysera/repository kyselyOptional validation libraries:
# For Zod validation
pnpm add zod
# For Valibot validation
pnpm add valibot
# For TypeBox validation
pnpm add @sinclair/typeboxQuick Start
Basic Repository
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<Database> = /* ... */;
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: '[email protected]' });
const found = await userRepo.findById(user.id);
const updated = await userRepo.update(user.id, { name: 'Alice Smith' });
await userRepo.delete(user.id);With Zod Validation
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
createORM
Create a plugin container with unified plugin management via @kysera/executor.
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:
interface PluginOrm<DB> {
// Plugin-aware executor (Kysely instance with plugin interception)
executor: Kysely<DB>
// Create a repository with plugin support
createRepository<T>(factory: (executor: Kysely<DB>, applyPlugins: ApplyPluginsFunction) => T): T
// Apply plugin interceptors to query builders
applyPlugins<QB>(qb: QB, operation: string, table: string, metadata?: Record<string, unknown>): QB
// Registered plugins in resolved dependency order
plugins: readonly Plugin[]
// Create a DAL context with plugins
createContext(): DbContext<DB>
// Execute a transaction with both Repository and DAL patterns
transaction<T>(fn: (ctx: DbContext<DB>) => Promise<T>): Promise<T>
}createRepositoryFactory
Create a factory for building type-safe repositories.
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<CreateUserInput>(),
update: nativeAdapter<UpdateUserInput>()
}
})Plugin Integration
Plugins work by intercepting queries and extending repository interfaces. The plugin container uses @kysera/executor internally for unified plugin management.
Using Plugins
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<CreateUserInput>()
}
})
})
// Plugin methods are automatically available
await userRepo.create({ name: 'Alice', email: '[email protected]' })
await userRepo.softDelete(1) // Added by soft-delete plugin
await userRepo.restore(1) // Added by soft-delete pluginPlugin Lifecycle
- Validation - Plugins validated for conflicts and dependencies
- Resolution - Plugins sorted by priority and dependencies
- Initialization -
plugin.onInit()called (if defined) - Interception -
plugin.interceptQuery()applied to all queries - Extension -
plugin.extendRepository()adds methods to repositories
Manual Plugin Application
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.
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<CreateUserInput>() }
})
})
// Write: Create user via Repository
const user = await userRepo.create({
name: 'Alice',
email: '[email protected]'
})
// 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:
Core Operations
// Create
const user = await repo.create({ name: 'Alice', email: '[email protected]' })
// 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: '[email protected]' } })
// Update
const updated = await repo.update(1, { name: 'Alice Smith' })
// Delete
const deleted = await repo.delete(1) // Returns true if deletedBulk Operations
// Bulk create
const users = await repo.bulkCreate([
{ name: 'Alice', email: '[email protected]' },
{ name: 'Bob', email: '[email protected]' }
])
// 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])Queries
// Count
const total = await repo.count()
const filtered = await repo.count({ where: { active: true } })
// Exists
const exists = await repo.exists({ where: { email: '[email protected]' } })
// Find by IDs
const users = await repo.findByIds([1, 2, 3])Pagination
Offset-based pagination:
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) // 0Cursor-based pagination:
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'
})Transactions
await repo.transaction(async trx => {
const user = await trx
.insertInto('users')
.values({ name: 'Alice', email: '[email protected]' })
.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.
Zod Adapter
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())
}
})Valibot Adapter
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)
}
})TypeBox Adapter
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)
}
})Native Adapter (No Validation)
import { nativeAdapter } from '@kysera/repository'
const repo = factory.create({
tableName: 'users',
mapRow: row => row,
schemas: {
create: nativeAdapter<CreateUserInput>()
}
})Custom Adapter
import { customAdapter } from '@kysera/repository'
const isPositiveNumber = customAdapter<number>(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.
Default (id: number)
const repo = factory.create({
tableName: 'users',
// primaryKey defaults to 'id'
// primaryKeyType defaults to 'number'
mapRow: row => row,
schemas: { create: nativeAdapter() }
})Custom Column Name
const repo = factory.create({
tableName: 'users',
primaryKey: 'user_id',
primaryKeyType: 'number',
mapRow: row => row,
schemas: { create: nativeAdapter() }
})UUID Primary Key
const repo = factory.create({
tableName: 'users',
primaryKey: 'id',
primaryKeyType: 'uuid',
mapRow: row => row,
schemas: { create: nativeAdapter() }
})Composite Primary Key
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:
import { ContextAwareRepository } from '@kysera/repository'
import type { Executor } from '@kysera/core'
class UserRepository extends ContextAwareRepository<Database, 'users'> {
async create(data: { email: string; name: string }): Promise<User> {
return this.db.insertInto(this.tableName).values(data).returningAll().executeTakeFirstOrThrow()
}
async findById(id: number): Promise<User | null> {
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: '[email protected]', name: 'Test' })
await txPostRepo.create({ userId: user.id, title: 'Hello' })
})Benefits:
- Clean API: No
executorparameter in every method - Type-safe:
withExecutor()returns same repository type - Preserves instance: Custom properties preserved
Upsert Helpers
Functions for INSERT ... ON CONFLICT DO UPDATE operations:
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: '[email protected]',
name: 'Alice Updated',
role: 'admin'
},
{
conflictColumns: ['email'],
updateColumns: ['name'] // Only update name, not role
}
)UpsertOptions:
conflictColumns: Columns defining the conflict constraintupdateColumns: Columns to update (default: all except conflictColumns)returning: Whether to return upserted record(s)
Helper Functions
withPlugins
Create a repository with plugins in one step:
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()]
)createSimpleRepository
Create a repository without schemas (useful for plugins):
import { createSimpleRepository } from '@kysera/repository'
const userRepo = createSimpleRepository(db, 'users', row => row, {
primaryKey: 'id',
primaryKeyType: 'number'
})createRepositoriesFactory
Create a bundle of repositories for use in transactions:
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 instancesKey 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
interceptQueryandextendRepositoryapplied to repositories - Transaction Support - Plugins automatically propagate through transactions
Best Practices
- Use createORM for plugin management - Let @kysera/executor handle plugin lifecycle
- Prefer validation - Use Zod or similar for runtime safety
- Use transactions - Wrap related operations in transactions
- Leverage CQRS-lite - Use Repository for writes, DAL for complex reads
- Bulk operations - Use bulkCreate/bulkUpdate/bulkDelete for efficiency
- Cursor pagination - Prefer cursor-based pagination for large datasets
Related Packages
- @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
License
MIT
