Summary
QueryNameMapper is constructed per query-executor instance, and ZenStackQueryExecutor.withConnectionProvider() creates a new executor for every connection scope. Because entering a transaction goes through that path, each $transaction(...) rebuilds the mapper — and the mapper's constructor walks every model and every field, twice.
On a large schema this dominates the transaction. Measured on a 1054-model / 11754-field Postgres schema, a paginated read plus a follow-up query:
|
Latency |
| outside a transaction |
4.53 ms |
inside $transaction |
995.64 ms |
A no-op through a fresh connection scope isolates it: $qb.connection().execute(noop) → 488 ms, which is one rebuild.
The isolation level is irrelevant — any transaction scope pays it.
Where
packages/orm/dist/index.mjs (built output, v3.7.2 and v3.8.3 are identical here):
// executor constructor
if (client.$schema.provider.type === "postgresql" || this.schemaHasMappedNames(client.$schema))
this.nameMapper = new QueryNameMapper(client); // 3.7.2:6262 · 3.8.3:6328
// new executor per connection scope
withConnectionProvider(connectionProvider) { // 3.8.3:6684
const newExecutor = new ZenStackQueryExecutor(this.client, this.driver, this.compiler,
this.adapter, connectionProvider, ...);
ClientImpl then constructs a second one, so a transaction scope pays the cost twice.
The mapper's constructor iterates all models × all fields, with a second pass computing many-to-many join tables.
Scaling
Same code path, trimmed schemas — the cost grows superlinearly with schema size:
| Models |
Fields |
Rebuild |
| 100 |
1 187 |
4.28 ms |
| 500 |
5 673 |
98.24 ms |
| 1 054 |
11 754 |
479 ms |
Why it matters
The cost is invisible at the call site — nothing suggests that wrapping two queries in a transaction multiplies latency by ~220×. It also grows as a project's schema grows, so it degrades quietly rather than failing.
Practically, it makes $transaction unusable on read paths for large schemas. In our case it ruled out an otherwise-correct fix: we needed a snapshot-consistent read (page query + a follow-up projection in one snapshot) and had to abandon it, keeping a documented race instead.
Suggested direction
The mapper appears to depend only on client.$schema, which does not change across connection scopes — so it looks cacheable per client (or per schema) rather than per executor. If there is a reason it must be per-executor that I have missed, I would genuinely like to know, since that would change how we work around it.
Environment
@zenstackhq/orm 3.7.2, and verified unchanged in 3.8.3 (latest at time of writing)
- PostgreSQL 18.4
- Bun 1.3.14
- Postgres dialect, schema with
@@map/@map throughout
Happy to put together a standalone reproduction if the scaling table above is not sufficient — the measurements come from a private schema, but the shape reproduces with any large generated one.
Summary
QueryNameMapperis constructed per query-executor instance, andZenStackQueryExecutor.withConnectionProvider()creates a new executor for every connection scope. Because entering a transaction goes through that path, each$transaction(...)rebuilds the mapper — and the mapper's constructor walks every model and every field, twice.On a large schema this dominates the transaction. Measured on a 1054-model / 11754-field Postgres schema, a paginated read plus a follow-up query:
$transactionA no-op through a fresh connection scope isolates it:
$qb.connection().execute(noop)→ 488 ms, which is one rebuild.The isolation level is irrelevant — any transaction scope pays it.
Where
packages/orm/dist/index.mjs(built output, v3.7.2 and v3.8.3 are identical here):ClientImplthen constructs a second one, so a transaction scope pays the cost twice.The mapper's constructor iterates all models × all fields, with a second pass computing many-to-many join tables.
Scaling
Same code path, trimmed schemas — the cost grows superlinearly with schema size:
Why it matters
The cost is invisible at the call site — nothing suggests that wrapping two queries in a transaction multiplies latency by ~220×. It also grows as a project's schema grows, so it degrades quietly rather than failing.
Practically, it makes
$transactionunusable on read paths for large schemas. In our case it ruled out an otherwise-correct fix: we needed a snapshot-consistent read (page query + a follow-up projection in one snapshot) and had to abandon it, keeping a documented race instead.Suggested direction
The mapper appears to depend only on
client.$schema, which does not change across connection scopes — so it looks cacheable per client (or per schema) rather than per executor. If there is a reason it must be per-executor that I have missed, I would genuinely like to know, since that would change how we work around it.Environment
@zenstackhq/orm3.7.2, and verified unchanged in 3.8.3 (latest at time of writing)@@map/@mapthroughoutHappy to put together a standalone reproduction if the scaling table above is not sufficient — the measurements come from a private schema, but the shape reproduces with any large generated one.