Transactions
Kysera provides clean transaction support through the Executor pattern, making it easy to ensure atomic operations.
Basic Transaction Usage
await db.transaction().execute(async (trx) => {
// All operations in this block are atomic
const user = await trx.insertInto('users')
.values({ email: 'john@example.com', name: 'John' })
.returningAll()
.executeTakeFirstOrThrow()
await trx.insertInto('profiles')
.values({ user_id: user.id, bio: 'Hello!' })
.execute()
// If any operation fails, all changes are rolled back
})
The Executor Pattern
The key to Kysera's transaction support is the Executor type:
type Executor<DB> = Kysely<DB> | Transaction<DB>
This allows repository factories to work with both normal database instances and transactions:
export function createUserRepository(executor: Executor<Database>) {
return {
async findById(id: number) {
return executor.selectFrom('users')
.where('id', '=', id)
.selectAll()
.executeTakeFirst()
},
async create(data: CreateUserInput) {
return executor.insertInto('users')
.values(data)
.returningAll()
.executeTakeFirstOrThrow()
}
}
}
Repository Transactions
Method 1: Repository Bundles (Recommended)
const createRepos = createRepositoriesFactory({
users: createUserRepository,
posts: createPostRepository,
comments: createCommentRepository
})
// Normal usage
const repos = createRepos(db)
const users = await repos.users.findAll()
// Transaction usage - same API!
await db.transaction().execute(async (trx) => {
const repos = createRepos(trx)
const user = await repos.users.create({
email: 'jane@example.com',
name: 'Jane'
})
await repos.posts.create({
user_id: user.id,
title: 'First Post',
content: 'Hello World!'
})
// Both operations succeed or both fail
})
Method 2: withTransaction Method
const userRepo = createUserRepository(db)
await db.transaction().execute(async (trx) => {
const txUserRepo = userRepo.withTransaction(trx)
await txUserRepo.create({ email: 'test@example.com', name: 'Test' })
})
Method 3: Repository's transaction Method
await userRepo.transaction(async (trx) => {
// Create transactional repository instances
const txUserRepo = userRepo.withTransaction(trx)
const txPostRepo = postRepo.withTransaction(trx)
// Operations within transaction
const user = await txUserRepo.create({ ... })
await txPostRepo.create({ user_id: user.id, ... })
// Return value becomes the result of transaction()
return user
})
Transaction Best Practices
Keep Transactions Short
Minimize transaction duration to avoid lock contention:
// Good: Preparation outside transaction
const userData = await validateAndPrepareUserData(input)
const profileData = await fetchExternalProfile(input.socialId)
await db.transaction().execute(async (trx) => {
// Quick database operations only
const user = await trx.insertInto('users').values(userData).execute()
await trx.insertInto('profiles').values({ ...profileData, user_id: user.id }).execute()
})
// Bad: Long-running operations inside transaction
await db.transaction().execute(async (trx) => {
const user = await trx.insertInto('users').values(input).execute()
await sendWelcomeEmail(user.email) // External call - avoid!
await updateExternalService(user.id) // External call - avoid!
})
Don't Mix Executors
// Bad: Mixing executors breaks atomicity
await db.transaction().execute(async (trx) => {
const trxRepos = createRepos(trx)
const dbRepos = createRepos(db) // Wrong!
await trxRepos.users.create({ ... }) // In transaction
await dbRepos.audit.log('created') // Outside transaction!
})
// Good: Consistent executor usage
await db.transaction().execute(async (trx) => {
const repos = createRepos(trx) // All use transaction
await repos.users.create({ ... })
await repos.audit.log('created') // Both in same transaction
})
Handle Rollbacks Explicitly
try {
await db.transaction().execute(async (trx) => {
const repos = createRepos(trx)
await repos.users.create({ email, name })
if (someCondition) {
throw new BusinessError('Invalid operation')
}
await repos.audit.log('user_created')
})
} catch (error) {
if (error instanceof BusinessError) {
logger.warn('Transaction rolled back:', error.message)
// Handle business logic error
} else {
logger.error('Transaction failed:', error)
throw error
}
}
Nested Transactions
Kysely supports savepoints for nested transactions:
await db.transaction().execute(async (trx) => {
await repos.users.create({ ... })
try {
// Inner "transaction" uses savepoint
await trx.transaction().execute(async (innerTrx) => {
await innerRepos.posts.create({ ... })
throw new Error('Rollback inner only')
})
} catch (error) {
// Inner operations rolled back, outer continues
}
// User creation still committed
})
Transaction Isolation Levels
Control isolation level when needed:
await db.transaction()
.setIsolationLevel('serializable')
.execute(async (trx) => {
// Operations with serializable isolation
})
Available levels:
read uncommittedread committed(default for most databases)repeatable readserializable
Testing with Transactions
Use transaction rollback for fast, isolated tests:
import { testInTransaction } from '@kysera/testing'
describe('User Repository', () => {
it('should create user', async () => {
await testInTransaction(db, async (trx) => {
const repos = createRepos(trx)
const user = await repos.users.create({
email: 'test@example.com',
name: 'Test User'
})
expect(user.id).toBeDefined()
expect(user.email).toBe('test@example.com')
// Transaction auto-rolls back - no cleanup needed!
})
})
})