From ce30ea2911171aa7f10c1304faf3bca4b2ab01f6 Mon Sep 17 00:00:00 2001 From: Pavel Kudinov Date: Wed, 18 Mar 2026 20:12:17 -0700 Subject: [PATCH 1/3] fix: preserve transaction state in $use, $unuse, and $unuseAll When calling $use(), $unuse(), or $unuseAll() on a transaction client, the returned client would escape the active transaction because the constructor always creates a fresh Kysely instance from kyselyProps. Propagate the transaction Kysely instance to the new client when the current client is inside a transaction. Fixes #2494 --- packages/orm/src/client/client-impl.ts | 12 ++++++ tests/e2e/orm/client-api/transaction.test.ts | 39 ++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index 439735d49..e2a44f4f8 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -378,6 +378,10 @@ export class ClientImpl { newClient.inputValidator = new InputValidator(newClient as any, { enabled: newOptions.validateInput !== false, }); + // preserve transaction state so the new client stays in the same transaction + if (this.kysely.isTransaction) { + newClient.kysely = this.kysely; + } return newClient; } @@ -399,6 +403,10 @@ export class ClientImpl { newClient.inputValidator = new InputValidator(newClient as any, { enabled: newClient.$options.validateInput !== false, }); + // preserve transaction state so the new client stays in the same transaction + if (this.kysely.isTransaction) { + newClient.kysely = this.kysely; + } return newClient; } @@ -414,6 +422,10 @@ export class ClientImpl { newClient.inputValidator = new InputValidator(newClient as any, { enabled: newOptions.validateInput !== false, }); + // preserve transaction state so the new client stays in the same transaction + if (this.kysely.isTransaction) { + newClient.kysely = this.kysely; + } return newClient; } diff --git a/tests/e2e/orm/client-api/transaction.test.ts b/tests/e2e/orm/client-api/transaction.test.ts index e4f2192e8..980e5bee9 100644 --- a/tests/e2e/orm/client-api/transaction.test.ts +++ b/tests/e2e/orm/client-api/transaction.test.ts @@ -101,6 +101,45 @@ describe('Client raw query tests', () => { await expect(client.user.findMany()).toResolveWithLength(0); }); + + it('$unuseAll preserves transaction isolation', async () => { + await expect( + client.$transaction(async (tx) => { + await tx.$unuseAll().user.create({ + data: { email: 'u1@test.com' }, + }); + throw new Error('rollback'); + }), + ).rejects.toThrow('rollback'); + + await expect(client.user.findMany()).toResolveWithLength(0); + }); + + it('$unuse preserves transaction isolation', async () => { + await expect( + client.$transaction(async (tx) => { + await tx.$unuse('nonexistent').user.create({ + data: { email: 'u1@test.com' }, + }); + throw new Error('rollback'); + }), + ).rejects.toThrow('rollback'); + + await expect(client.user.findMany()).toResolveWithLength(0); + }); + + it('$use preserves transaction isolation', async () => { + await expect( + client.$transaction(async (tx) => { + await (tx as any).$use({ id: 'noop', handle: (_node: any, proceed: any) => proceed(_node) }).user.create({ + data: { email: 'u1@test.com' }, + }); + throw new Error('rollback'); + }), + ).rejects.toThrow('rollback'); + + await expect(client.user.findMany()).toResolveWithLength(0); + }); }); describe('sequential transaction', () => { From abeac6fbd57f78c80c11b4ef20d5baf9f5a400d9 Mon Sep 17 00:00:00 2001 From: Pavel Kudinov Date: Fri, 20 Mar 2026 15:56:47 -0700 Subject: [PATCH 2/3] fix(orm): preserve cloned clients inside transactions --- packages/orm/src/client/client-impl.ts | 15 ++------- tests/e2e/orm/client-api/transaction.test.ts | 34 +++++++++++++++++++- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index e2a44f4f8..94bba1c76 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -129,6 +129,9 @@ export class ClientImpl { } this.kysely = new Kysely(this.kyselyProps); + if (!executor && baseClient?.isTransaction) { + this.kysely = baseClient.$qb; + } this.inputValidator = baseClient?.inputValidator ?? new InputValidator(this as any, { enabled: this.$options.validateInput !== false }); @@ -378,10 +381,6 @@ export class ClientImpl { newClient.inputValidator = new InputValidator(newClient as any, { enabled: newOptions.validateInput !== false, }); - // preserve transaction state so the new client stays in the same transaction - if (this.kysely.isTransaction) { - newClient.kysely = this.kysely; - } return newClient; } @@ -403,10 +402,6 @@ export class ClientImpl { newClient.inputValidator = new InputValidator(newClient as any, { enabled: newClient.$options.validateInput !== false, }); - // preserve transaction state so the new client stays in the same transaction - if (this.kysely.isTransaction) { - newClient.kysely = this.kysely; - } return newClient; } @@ -422,10 +417,6 @@ export class ClientImpl { newClient.inputValidator = new InputValidator(newClient as any, { enabled: newOptions.validateInput !== false, }); - // preserve transaction state so the new client stays in the same transaction - if (this.kysely.isTransaction) { - newClient.kysely = this.kysely; - } return newClient; } diff --git a/tests/e2e/orm/client-api/transaction.test.ts b/tests/e2e/orm/client-api/transaction.test.ts index 980e5bee9..84e267aad 100644 --- a/tests/e2e/orm/client-api/transaction.test.ts +++ b/tests/e2e/orm/client-api/transaction.test.ts @@ -131,7 +131,39 @@ describe('Client raw query tests', () => { it('$use preserves transaction isolation', async () => { await expect( client.$transaction(async (tx) => { - await (tx as any).$use({ id: 'noop', handle: (_node: any, proceed: any) => proceed(_node) }).user.create({ + await (tx as any) + .$use({ + id: 'noop', + onQuery: async ({ args, proceed }: { args: unknown; proceed: (args: unknown) => Promise }) => + proceed(args), + }) + .user.create({ + data: { email: 'u1@test.com' }, + }); + throw new Error('rollback'); + }), + ).rejects.toThrow('rollback'); + + await expect(client.user.findMany()).toResolveWithLength(0); + }); + + it('$setAuth preserves transaction isolation', async () => { + await expect( + client.$transaction(async (tx) => { + await tx.$setAuth(undefined).user.create({ + data: { email: 'u1@test.com' }, + }); + throw new Error('rollback'); + }), + ).rejects.toThrow('rollback'); + + await expect(client.user.findMany()).toResolveWithLength(0); + }); + + it('$setOptions preserves transaction isolation', async () => { + await expect( + client.$transaction(async (tx) => { + await (tx as any).$setOptions((tx as any).$options).user.create({ data: { email: 'u1@test.com' }, }); throw new Error('rollback'); From 0a46d48c3fbb7d7eb5849719d324e7b7917730fc Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 20 Mar 2026 20:54:13 -0700 Subject: [PATCH 3/3] refactor: local refactor --- packages/orm/src/client/client-impl.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index 94bba1c76..dff7f9c27 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -128,10 +128,14 @@ export class ClientImpl { }); } - this.kysely = new Kysely(this.kyselyProps); - if (!executor && baseClient?.isTransaction) { + if (baseClient?.isTransaction && !executor) { + // if we're creating a derived client from a transaction client and not replacing + // the executor, reuse the current kysely instance to retain the transaction context this.kysely = baseClient.$qb; + } else { + this.kysely = new Kysely(this.kyselyProps); } + this.inputValidator = baseClient?.inputValidator ?? new InputValidator(this as any, { enabled: this.$options.validateInput !== false }); @@ -959,12 +963,7 @@ function collectExtResultFieldDefs( * - Injects `needs` fields into `select` when ext result fields are explicitly selected * - Recurses into `include` and `select` for nested relation fields */ -function prepareArgsForExtResult( - args: unknown, - model: string, - schema: SchemaDef, - plugins: AnyPlugin[], -): unknown { +function prepareArgsForExtResult(args: unknown, model: string, schema: SchemaDef, plugins: AnyPlugin[]): unknown { if (!args || typeof args !== 'object') { return args; }