Repository navigation
fix(orm): clients derived from a transaction client use their own plugins and auth - #2899
evgenovalov wants to merge 2 commits into
Conversation
…gins and auth Since zenstackhq#2497, a client derived from a transaction client ($use, $unuse, $unuseAll, $setAuth, $setOptions) reused the transaction's Kysely instance. Its executor is bound to the original transaction client, so Kysely-level and entity mutation hooks, executor options and the auth handed to the hooks were the transaction client's: tx.$unuse('policy') still enforced access policies, tx.$use(plugin) never ran the plugin's onKyselyQuery/onEntityMutation hooks, and tx.$setAuth(user) evaluated policies with the old auth. Give the derived client its own executor, which takes its connection from the transaction's executor, inside a Kysely Transaction so it stays in the transaction. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RwcqsRcacQHX18hXKbJN4n
…nded A client derived from a transaction client took the transaction's connection through Kysely's transaction executor used as a connection provider. That bypasses Kysely's "already committed / rolled back" check, which only guards executeQuery and stream, so a derived client kept past the transaction ran on a connection that had gone back to the pool instead of throwing. $transaction now gives its transaction client a connection provider that derived clients share. It throws Kysely's errors once the transaction callback has settled, before Kysely commits or rolls back and releases the connection. Also type the $use test like the other transaction tests: $use is not part of TransactionClientContract, so the e2e type check rejected it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RwcqsRcacQHX18hXKbJN4n
|
No actionable comments were generated in the recent review.
|
| Layer / File(s) | Summary |
|---|---|
Transaction-scoped connection lifecycle packages/orm/src/client/client-impl.ts |
A connection provider captures the transaction executor during the callback, tracks completion, and rejects connection requests before the transaction starts or after it ends. Interactive transactions run through the provider. |
Derived-client execution and behavior packages/orm/src/client/client-impl.ts, tests/e2e/orm/client-api/transaction.test.ts |
Derived transaction clients use the base client’s provider and construct their own Transaction executor. Tests cover policy and auth behavior, plugin hooks, and query rejection after commit or rollback. |
Estimated code review effort: 3 (Moderate) | ~25 minutes
Change: Bug fix
Possibly related PRs
- zenstackhq/zenstack#2895: Changes the entity-mutation hook contract and selection. This PR tests mutation hooks installed on a derived transaction client.
Suggested reviewers: ymc9
Merge Risk: 
Minimal · up to be8ab
-
No actionable merge-blocking issue remains after normal checks.
Pre-merge checks: 
5
-
Full details: Docstring Coverage
-
Explanation
No eligible functions found in the changed files. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 0 functions across 2 files.
-
Finishing Touches
-
Generate unit tests (beta)
-
- Create a new PR
-
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
Problem
Inside
db.$transaction(async (tx) => ...), a client derived fromtxruns in the transaction (since #2497), but its queries still go through the transaction client's Kysely-level machinery:tx.$unuse('policy')andtx.$unuseAll()still enforce access policies, and still run every otheronKyselyQuery/onEntityMutationhook oftx.tx.$use(plugin)never runs the new plugin'sonKyselyQuery/onEntityMutationhooks.tx.$setAuth(user)evaluates policies withtx's auth instead ofuser. This applies both ways: switching to a narrower user inside a transaction keeps the wider user's access.$setOptionsare not applied by the executor (e.g.diagnostics,useCompactAliasNames).ORM-level hooks (
onQuery,onProcedure), plugin client members and ext results already worked, because they are read from the derived client itself.Cause
#2497 made the
ClientImplconstructor reusebaseClient.$qbwhen the base client is a transaction (client-impl.ts:159-165). That KyselyTransactionruns on theZenStackQueryExecutorthat was built for the original transaction client. The executor readsonKyselyQueryhooks (zenstack-query-executor.ts:209), entity mutation hooks (:358,:399) and options from the client it is bound to, and passes that client, with its$auth, to the hooks (:220,:273). The executor that the constructor builds for the derived client was never used.The #2497 tests only check rollback, and use
$unuse('nonexistent'), so they could not catch this.Fix
A client derived from a transaction client keeps its own
ZenStackQueryExecutor, bound to itself, and is wrapped in a KyselyTransaction. SoisTransactionstaystrue, and nested$transactionor internalsafeTransactioncalls do not open a new transaction.The executor gets the transaction's connection from a small
TransactionConnectionProvider, created by$transactionand held by its transaction client:SingleConnectionProvider).$transactioncallback has settled, it throwsTransaction is already committed/Transaction is already rolled back, like Kysely's own transaction executor. It records the outcome before Kysely commits and releases the connection, and switches to "rolled back" if the commit fails (Kysely then rolls back). So a derived client kept past the transaction fails loudly instead of running on a connection that is back in the pool.Clients derived from a derived client reach the same provider through their base's executor. This covers
$use,$unuse,$unuseAll,$setAuth,$setOptionsand$setInputValidation, chains of them, and nested or sequential$transactioncalls on a derived client.withExecutorand the clients built for hooks bycreateClientForConnectionare unchanged.Alternatives considered:
baseClient.$qband look up the issuing client per query: the executor is shared, and Kysely has no per-query context to tell it which client issued a query.NotCommittedOrRolledBackAssertingExecutoris internal and not exported. ItswithConnectionProvider/withPluginkeep the state, but they delegate toZenStackQueryExecutormethods that bind the new executor to a clone of the original client.$transactiontostartTransaction()to readControlledTransaction.isCommitted/isRolledBack: this rewrites how transactions are started and ended for a state flag.Tests
tests/e2e/orm/client-api/transaction.test.ts, next to the #2497 tests:tx.$unuse('policy')creates a row that the policy rejects. The row is visible inside the transaction and rolled back with it, andtxitself still rejects the same create.tx.$setAuth(u2)createsu2's row and is rejected foru1's row, whiletx(authu1) can still createu1's row.tx.$use(plugin)runs the plugin'sonKyselyQueryandonEntityMutationhooks, andtxitself does not.Transaction is already committedafter a commit andTransaction is already rolled backafter a rollback.The first three fail on
dev. The last one passes ondev, and fails without the provider's check: the derived client then reads from the released connection.Results:
transaction.test.tspasses on SQLite, PostgreSQL and MySQL 8.4.tests/regression,packages/serverandpackages/plugins/soft-deletepass on SQLite and PostgreSQL.tests/e2epasses on SQLite and PostgreSQL apart from failures unrelated to this change:orm/client-api/timezone/mysql-timezone.test.tsneeds a MySQL server even on the SQLite and PostgreSQL runs, and failed when none was running.packages/auth-adapters/better-auth:test/cli-generate.test.tsfails the same way ondev, because thebetter-authCLI is not installed locally.packages/serveron PostgreSQL: thebeforeAlloftest/openapi/*-openapi.test.tssometimes fails withdatabase ... is being accessed by other users. It reads a stalecurrentTestName, so consecutive suites drop the database the previous suite's still-open client uses. The file passes on re-run, on this branch and ondev.🤖 Generated with Claude Code
https://claude.ai/code/session_01RwcqsRcacQHX18hXKbJN4n
Summary by CodeRabbit