Skip to content

The Unsafe surface: context.unsafe, its builders, executors, ORM proxy and transaction-bound shape - #1204

Merged
borisno2 merged 3 commits into
prisma-8from
claude/issue-1144-unsafe-surface
Sep 6, 2026
Merged

The Unsafe surface: context.unsafe, its builders, executors, ORM proxy and transaction-bound shape#1204
borisno2 merged 3 commits into
prisma-8from
claude/issue-1144-unsafe-surface

Conversation

@borisno2

@borisno2 borisno2 commented Sep 6, 2026

Copy link
Copy Markdown
Member

Implements #1144. Part of #1122.

context.prisma becomes context.unsafe — the Unsafe surface (ADR-0038, ADR-0056, ADR-0059, ADR-0062).

The identifier

unsafe. ADR-0038 left it to this effort and suggested unsafeRaw "or similar"; raw is wrong now that the surface's largest lane is the ORM proxy, not SQL. unsafe is the word CONTEXT.md's glossary already uses for the concept ("the Unsafe surface"), so the code and the ubiquitous language agree, and it reads as a warning at every call site it appears in — context.unsafe.orm.public.Post.all(), context.unsafe.query(plan). It is short enough that nobody aliases it away, which is the property a name meant to be visible in review needs.

What it is

  • sql and raw are Prisma's typed builder and raw tag, handed over untouched. No curated subset.
  • query(plan) returns Prisma's lazy AsyncIterableResult through The origin module: ambient Engine stamp, tripwire and refusal error #1140's preserveOrigin, so a bulk re-embed can consume rows with a cursor long after the scope closed; execute(plan) returns statistics through withOrigin. runtime() is resolved per execution, never captured and never handed out.
  • orm is a transparent Proxy: every method call runs inside originStore.run('unsafe', …), every returned object is re-proxied, and every returned lazy result is scope-preserved. No method list to chase.
  • Neither the bare client nor prepare/runtime is reachable, in the type or in the value.
  • A transaction-bound shape (createUnsafeTransactionSurface) keeps the client's contract-scoped raw lane — Prisma's tx has none — and binds the executors to tx. context.transaction(fn) hands fn a context whose unsafe is that shape, so a script inside a transaction need not close over the outer client.
  • No raw guardrails installed. Errors are raw driver errors: nothing is normalised on this path.

Keeping the ORM proxy type-safe without a cast

new Proxy<T>(target, handler) is typed T, so the proxy's static view is Prisma's own — the caller sees the real collection types and nothing is asserted. The trap is declared get(target: T, key: string | symbol): unknown; unknown is assignable to ProxyHandler<T>['get']'s any return, so the handler satisfies the interface while every value inside the trap stays unknown and is narrowed by predicate (isLazyResult, isThenable) before use. Reflect.apply is the only any in the chain and its result is bound straight to an unknown.

Two details the implementation had to get right:

  • Reads go through Reflect.get(target, key) with the target as receiver, not the proxy. Prisma's collections are class instances with #private fields, and a private read against the proxy throws.
  • A non-writable, non-configurable own data property is handed back untouched (isInvariantProperty), because returning a wrapper for one is a TypeError under the proxy invariants.

The surface's own type projects each lane off the client it was built from (TClient['sql']), so a caller constructing it from a concretely-typed client keeps full precision, while the context's own surface — built through the structural UnsafeCapableClient, which is what lets a client typed by the app's emitted contract cross the seam without a cast — is structural until the generated bundle instantiates it over the contract (ADR-0052, spec 3). That limit is stated in the surface's Known limits.

Proving prepare/runtime unreachable

  • Value: the surface is a plain object literal. Object.keys(unsafe).sort() is asserted to be exactly ['execute','orm','query','raw','sql'], and Reflect.get for prepare, runtime, transaction, connect, close is asserted undefined.
  • Type: a test uses // @ts-expect-error on unsafe.prepare and unsafe.runtime. Core's tsconfig includes src/**/*, so those colocated tests are type-checked by pnpm build — the assertion fails the build if either member ever appears on the type.

Tests

packages/core/src/unsafe.test.ts, on #1143's Test context with the tripwire in throw mode, asserting plan shape and origin via createPlanRecorder() and never SQL text. 18 tests: the sixteen statement shapes the spike measured (single-statement reads and creates, two-statement update/delete, updateAll/deleteAll+include, three-statement nested create and nested connect, two terminals in one transaction, a DSL plan and a raw plan through the executors, streaming); a foreign query throwing UnmarkedQueryError; a plan built inside a scope and run outside throwing; a lazy result held across an await still covered; interleaved engine/unsafe/foreign calls over the single-connection pool, keyed by plan lane so each origin is checked against its own calls; raw driver errors proved by comparing the prototype of the error from the surface with the one from the client; and the two context-level tests for context.unsafe and the transaction-bound shape.

Scope notes for the reviewer

  • AccessContext.prisma — the engine's internal ORM handle, which is what @opensaas/stack-auth's better-auth wiring and @opensaas/stack-rag's vector search actually consume — is deliberately untouched. Adding unsafe to AccessContext requires one field in an object literal in packages/core/src/context/write-pipeline.ts, which is Export the access-filter builder and delete the write pipeline's transaction-absence branch #1145's file. Recommend that follow-up land with Export the access-filter builder and delete the write pipeline's transaction-absence branch #1145 or spec 3, along with docs/content/how-to/write-a-plugin.md and docs/content/concepts/hooks.md, whose ctx.prisma references are that internal handle.
  • packages/auth/tests/*-e2e.test.ts still call context.prisma.$disconnect(). Those suites are describe.skipIf-gated, are written against the Prisma 6 client that no longer exists on this branch, and are spec 6's to rewrite; changing the name there would be guessing at their replacement.
  • getContext takes the client as a new trailing option, and the generated context and the test harness both pass it. A context built from a hand-made ORM double gets unavailableUnsafeSurface(), whose every member throws UnsafeSurfaceUnavailableError by name — chosen over typing context.unsafe as possibly absent, which would push a null check onto every real caller.
  • CONTEXT.md's Unsafe surface entry now names the identifier; docs/content/reference/context-api.md's prisma section is rewritten as the surface's recipe.

Verification

pnpm build, pnpm lint (0 errors), pnpm manypkg check, pnpm format all pass. packages/core 1517 passed / 1 skipped and packages/cli 332 passed, both with DATABASE_URL unset (PGlite); the core suite passes identically against local PostgreSQL 14 at postgres://joshcalder@localhost:5432/postgres, with no opensaas_test_% databases left behind. The contract fixture regenerates byte-identical as CI does.

🤖 Generated with Claude Code

…y and transaction-bound shape

`context.prisma` is replaced by `context.unsafe`, a name that states the
bypass. The surface carries Prisma's `sql` and `raw` builders untouched, owns
`query(plan)` (Prisma's streaming result, through the scope-preserving
wrapper) and `execute(plan)` (statistics), and proxies the ORM lane so every
call enters the unsafe origin and every returned collection is re-proxied.
Neither the bare client nor `prepare()`/`runtime()` is reachable, in the type
or in the runtime value. The transaction context carries a transaction-bound
shape that keeps the client's contract-scoped raw lane and runs its plans
through the transaction's executor.

Implements #1144. Part of #1122.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Sep 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e289fc8

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
@opensaas/stack-core Minor
@opensaas/stack-cli Minor
@opensaas/stack-auth Minor
@opensaas/stack-rag Minor
@opensaas/stack-storage Minor
@opensaas/stack-tiptap Minor
@opensaas/stack-ui Minor
@opensaas/stack-storage-s3 Minor
@opensaas/stack-storage-vercel Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@vercel

vercel Bot commented Sep 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
stack-docs Ready Ready Preview Sep 6, 2026 8:43am UTC

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Core Package Coverage (./packages/core)

Status Category Percentage Covered / Total
🟢 Lines 94.56% (🎯 65%) 2647 / 2799
🟢 Statements 93.15% (🎯 65%) 2885 / 3097
🟢 Functions 97.34% (🎯 62%) 514 / 528
🟢 Branches 87.86% (🎯 50%) 2041 / 2323
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/core/src/unsafe.ts 97.72% 96.66% 100% 97.29% 160
packages/core/src/testing/context.ts 86.44% 85.71% 82.75% 89.18% 223-226, 243, 289, 315, 327-332, 448, 452, 460-463
Generated in workflow #1988 for commit e289fc8 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for UI Package Coverage (./packages/ui)

Status Category Percentage Covered / Total
🔵 Lines 78.45% 244 / 311
🔵 Statements 77.95% 251 / 322
🔵 Functions 69.81% 74 / 106
🔵 Branches 66.94% 160 / 239
File CoverageNo changed files found.
Generated in workflow #1988 for commit e289fc8 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for CLI Package Coverage (./packages/cli)

Status Category Percentage Covered / Total
🔵 Lines 74.86% 1251 / 1671
🔵 Statements 75.16% 1344 / 1788
🔵 Functions 86.56% 219 / 253
🔵 Branches 66.56% 639 / 960
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/cli/src/generator/context.ts 100% 100% 100% 100%
Generated in workflow #1988 for commit e289fc8 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Auth Package Coverage (./packages/auth)

Status Category Percentage Covered / Total
🔵 Lines 99.48% 195 / 196
🔵 Statements 98.13% 210 / 214
🔵 Functions 100% 44 / 44
🔵 Branches 90.77% 187 / 206
File CoverageNo changed files found.
Generated in workflow #1988 for commit e289fc8 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Storage Package Coverage (./packages/storage)

Status Category Percentage Covered / Total
🔵 Lines 79.66% 235 / 295
🔵 Statements 81.17% 263 / 324
🔵 Functions 87.91% 80 / 91
🔵 Branches 77.46% 220 / 284
File CoverageNo changed files found.
Generated in workflow #1988 for commit e289fc8 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for RAG Package Coverage (./packages/rag)

Status Category Percentage Covered / Total
🔵 Lines 54.35% 399 / 734
🔵 Statements 53.83% 421 / 782
🔵 Functions 64.06% 82 / 128
🔵 Branches 47.25% 198 / 419
File CoverageNo changed files found.
Generated in workflow #1988 for commit e289fc8 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Storage S3 Package Coverage (./packages/storage-s3)

Status Category Percentage Covered / Total
🔵 Lines 100% 40 / 40
🔵 Statements 100% 40 / 40
🔵 Functions 100% 9 / 9
🔵 Branches 100% 19 / 19
File CoverageNo changed files found.
Generated in workflow #1988 for commit e289fc8 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Storage Vercel Package Coverage (./packages/storage-vercel)

Status Category Percentage Covered / Total
🔵 Lines 100% 68 / 68
🔵 Statements 100% 71 / 71
🔵 Functions 100% 15 / 15
🔵 Branches 97.87% 46 / 47
File CoverageNo changed files found.
Generated in workflow #1988 for commit e289fc8 by the Vitest Coverage Report Action

Comment thread packages/core/src/context/index.ts Outdated
// `context.transaction` to reach `tx` without closing over the client
// (ADR-0056).
if (client !== undefined && _unsafeTransaction === undefined) {
return client.transaction((tx) => fn(child(prisma, tx)))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

txContext.db.* now runs outside the transaction this branch opens — and deadlocks on a small pool.

This branch opens a real Prisma 8 interactive transaction, which checks a connection out of the pool for the whole callback, but the child context is built with prisma (the outer ORM handle) as its db binding. So every txContext.db.* call inside fn goes to a different connection.

Concrete failure: the harness in packages/core/src/testing/context.ts builds its pool with max: 1. Once the secured terminals land (spec 3 / #1145), await context.transaction(async (tx) => { await tx.db.post.create({...}) }) blocks forever — the transaction holds the single connection and the db write waits for one that will never free. This is exactly the trap the 1068 spike recorded ("db.transaction() deadlocks on PGlite and on any max:1 pool"). Anything reached through the outer context.unsafe or through a plugin/hook's AccessContext.prisma inside the callback hits the same wall today.

On a larger pool it fails the other way: the db writes auto-commit outside the transaction, so a throw later in fn rolls back only the unsafe work — while StackContext.transaction's own TSDoc still promises "a throw anywhere rolls the whole transaction back (ADR-0012)".

The previous code ran fn directly with no transaction at all, so this PR is what introduces the held lock. Worth either gating this branch until the ORM handle can be transaction-bound, or narrowing the doc contract and noting the pool requirement.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the engine handle is now rebound to the transaction.

runTransactionBody resolves an OrmClient off tx.orm (ormHandleFor(config, tx.orm): orm.<namespace>.<Model> by list key, namespace from listConfig.db?.schema, reads through Reflect.get because the namespaces are get-trap Proxies) and hands that to child(...), so txContext.db.* runs on the transaction's own connection. The same resolver runs against client.orm first as a probe: when it comes back undefined — a hand-built double with no collections — no transaction is opened at all and the callback runs directly, exactly as before this PR. If the transaction's own collections then fail to resolve where the client's succeeded, TransactionOrmHandleError rolls it back rather than binding db to a half-built map.

packages/core/tests/transaction-orm-binding.test.ts covers both failure shapes you named, over a Prisma-8-shaped double that stages writes and models the pool:

  • spare pool: a write in a callback that then throws leaves no committed row. Fails on the previous code — the row auto-commits on the outer handle.
  • starved pool (the max: 1 case): the outer collection refuses while a transaction is open. The callback completes. Fails on the previous code with PoolExhausted, which is the deadlock made deterministic rather than a hanging test.

Also added to unsafe.test.ts against the real harness: a tx.unsafe write followed by a throw leaves no row.

The pool requirement is now on StackContext.transaction's TSDoc — work reached through the OUTER context inside the callback still asks for a second connection, and that is inherent to holding an interactive transaction on a one-connection pool.

Left alone deliberately: runInTransaction's $transaction probe (#1205).

fn: (txContext: StackContext<AccessControlledDB>) => Promise<T>,
registry: TransactionRegistry,
ormClient: TransactionCapable,
options?: TransactionOptions,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

options is silently dropped on the Prisma 8 path.

transaction() still accepts TransactionOptions (isolationLevel, maxWait, timeout) and runTransactionBody still takes it, but it is forwarded only on the legacy $transaction branch below. On the branch that actually runs for a generated context (client.transaction(...)) the parameter is unused.

So await context.transaction(fn, { isolationLevel: 'Serializable' }) — the documented way to protect a read-modify-write today — now runs at Read Committed with no error and no warning, and the caller has no way to notice. A lost-update the caller thought was closed is silently reopened.

Spec 3 plans to delete these options (ADR-0042), but until then this should either throw on a non-empty options here or drop the parameter from the public signature, rather than accept-and-ignore.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, by failing loudly. Non-empty options on the Prisma 8 path now rejects with TransactionOptionsUnsupportedError naming the keys, rather than accepting and ignoring them. The Prisma 7 $transaction branch still forwards them verbatim. TransactionOptions' own TSDoc says so now, and the refusal is tested both over the double and over the real harness.

)

if (typeof ormClient.$transaction === 'function') {
return ormClient.$transaction((tx) => fn(child(tx)), options) as Promise<T>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch hands fn a context whose unsafe is not transaction-bound.

child(tx) passes undefined for unsafeTransaction, so the child's surface is built by createUnsafeSurface(client) over the outer client. Any tx.unsafe.orm/query/execute inside the callback therefore executes on a different connection, outside the transaction the $transaction call just opened — silently non-atomic, and the opposite of what the branch below does.

Unreachable today (neither the Prisma 8 client nor the harness's ORM map carries $transaction), but it becomes live the moment spec 3 makes the engine's ORM handle transaction-capable — at which point this branch takes precedence over the one below and the transaction-bound surface this PR adds stops being used. Worth deriving the UnsafeTransactionScope here too, or asserting the branch is unreachable.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left as-is, with the condition named. The branch now carries a Known limits block saying exactly what you did: it is unreachable while $transaction is a name no Prisma 8 object carries, and the moment the engine's handle becomes transaction-capable and takes this branch, an UnsafeTransactionScope has to be derived here too or tx.unsafe executes outside the transaction it opened. A Prisma 7 tx carries no sql/orm lanes, so there is nothing to derive one from today.

* anything other than the target's own value for a non-writable,
* non-configurable data property is a `TypeError`.
*/
function isInvariantProperty(target: object, key: string | symbol): boolean {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A frozen namespace turns the whole ORM lane into refused queries.

The invariant guard is correct as written, but it is checked for every property, not only for values the trap would otherwise replace. If Prisma ever hands out its collections as own non-writable, non-configurable properties — a plain Object.freeze({ Post, User }) behind the namespace proxy is the obvious shape — isInvariantProperty returns true and unsafe.orm.public.Post is handed back unproxied. Every call through it then runs with no origin in scope and UnmarkedQueryError refuses it, i.e. the entire ORM lane stops working on an ORM patch bump, with an error message that points at the tripwire rather than at here.

rc.8 evidently isn't frozen (the tests pass), which is exactly why this is worth a line in the Known limits block on markCalls — or a narrower guard that only bails when the replacement value would actually differ from value.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Narrowed as suggested — the guard is now consulted only when the marked value differs from the raw one — but I think the finding overstates what that buys, and I would rather say so than imply I closed something I did not.

The guard is the Proxy invariant: for a non-configurable, non-writable own data property, returning anything but the target's own value from a get trap is a TypeError. So in every case where the old code bailed and the new code also bails, the value is handed back unwrapped either way; where the marked value equals the raw one (primitives, thenables) the bail was already a no-op. The narrowing is therefore behaviour-preserving by construction, and I could not write a test that fails before and passes after it — a frozen namespace still comes back unproxied, because JS will not let it come back any other way.

What I did instead: the residual is now a Known limits line on markCalls naming the shape (Object.freeze({ Post })), the consequence (calls through it carry no origin and the tripwire refuses them), and why rc.8 does not hit it. unsafe.test.ts pins the invariant with an identity assertion on a non-writable, non-configurable own property.

function markResult(value: unknown): unknown {
if (isLazyResult(value)) return preserveOrigin('unsafe', value)
if (isThenable(value)) return value
if (typeof value === 'object' && value !== null) return markCalls(value)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A returned function escapes marking.

The get trap wraps function-valued properties, but markResult does not: typeof value === 'object' is false for a function, so it falls through to return value on the last line and is handed back raw. A method on the ORM lane that returns a callable — a curried/partially-applied builder, a disposer, a queryFn-style closure — is then outside the proxy, and a query issued through it compiles with no origin in scope and is refused by the tripwire.

The asymmetry looks unintentional given the trap already handles the callable case; wrapping a returned function the same way (originStore.run + markResult) would close it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. markResult now wraps a returned callable the same way the get trap wraps a read one — both go through a shared markApply(apply), which takes the application rather than the function so the receiver stays the caller's business (the target for the trap, nothing for a returned callable).

Test: unsafe.test.ts builds a surface over a stand-in orm whose method returns a closure that issues a real query. The closure is invoked outside any scope; the recorder sees origin: "unsafe". It fails on the previous code with UnmarkedQueryError.

Comment thread packages/core/src/access/types.ts Outdated
/**
* The engine's own ORM handle, reached through {@link ormModel}. Internal
* plumbing rather than the application's escape hatch — that is
* {@link AccessContext.unsafe}.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{@link AccessContext.unsafe} resolves to nothing — AccessContext gains no unsafe member in this PR (that is the #1145 follow-up the description calls out). TypeDoc will emit a broken reference, and a reader following it from a hook concludes context.unsafe is available inside hooks when it is not.

Suggest pointing at StackBaseContext.unsafe (or plain prose) until the field actually lands on AccessContext.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the link pointed at a member AccessContext does not have. It now says plainly that the escape hatch is unsafe on the request context (StackBaseContext.unsafe) and is not a member of this type, with no {@link} to a non-existent member. AccessContext.prisma itself is unchanged, as the description says.

@borisno2 borisno2 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST CHANGES

Reviewed at effort high, strictly from the diff. Six inline comments are posted; this is the summary and the ordering by severity. (Submitted as a Comment review rather than a formal Request-changes because the reviewing identity cannot block on this PR — treat the verdict as stated here.)

Proxy containment — the headline assessment

The containment design is sound and, for the shapes the tests exercise, it holds. markCalls enters originStore.run('unsafe', …) around every method application, re-proxies returned objects, and preserveOrigins the lazy streaming result — the sixteen statement shapes in unsafe.test.ts run against a real client with the tripwire in throw mode and every one records origin: 'unsafe'. Statement counts are asserted (two for a one-row update, three for a nested create), not just "at least one marked", so a silently-unmarked second statement would fail the suite. The Reflect.get(target, key) receiver choice is correct and does not leak: the value returned is still routed through markResult/the function wrapper, so the target-as-receiver is only about #private reads, not about handing back a bare object. isLazyResult's five-member structural test (including Symbol.asyncIterator) correctly refuses to treat a bare Promise as a lazy result. The interleaving test would catch cross-contamination — it asserts per-lane origin sets, so a leaked mark shows up as an extra member rather than passing by construction, and the raw-driver-error test really is prototype identity against the client's own error, not an instanceof Error tautology.

Two holes remain, both narrow but both of the "escapes unmarked" class rather than style:

  1. markResult does not wrap functions (unsafe.ts:138). A method that returns a callable is handed back bare; a query issued through it compiles with no origin and the tripwire refuses it. Asymmetric with the get trap, which does wrap callables. Prisma's current DSL returns builders (objects), so this is latent, not live — but it is exactly the shape an ORM minor bump could introduce.
  2. isInvariantProperty is consulted for every property, not only where the trap would substitute (unsafe.ts:125). If a collection is ever exposed as a frozen own property, unsafe.orm.public.Post comes back unproxied and the whole lane starts throwing at the tripwire. Narrow the check to the substitution path.

prepare/runtime unreachability — the claim is weaker than stated

The doc comment says "Neither the bare client nor prepare()/runtime() is reachable". What the tests actually establish is not among the surface's own keys: Object.keys(unsafe).sort() plus Reflect.get(unsafe, 'prepare') === undefined. That is a real and useful property (no prototype-chain escape from the surface literal, runtime() resolved per call inside a closure and never handed out, executors returns only query/execute), but it says nothing about prepare reachable through the orm proxy on a Prisma collection, which is where a prepared statement would actually be minted. Either add a test that a prepare reached through the ORM lane is refused, or soften the prose to the claim the code establishes. As written a reader takes "unreachable" for the strong form.

Transaction-bound shape — the most severe finding

context/index.ts:840: the Prisma 8 branch does client.transaction((tx) => fn(child(prisma, tx))) — it opens a real interactive transaction, holding a pooled connection, while the child context's db stays bound to the outer ORM handle. So inside context.transaction:

  • tx.db.* writes execute outside the transaction that is open, auto-committing, while StackContext.transaction's own contract says a throw anywhere rolls the whole thing back. That is a silent atomicity break, not a loud one.
  • On a single-connection pool it deadlocks instead.

The prior code opened no transaction on this path at all, so the held lock is introduced here. The inline comment acknowledges the deferral to spec 3, but the consequence is not stated on StackContext.transaction's TSDoc where a caller would see it. At minimum this needs the contract documented; better, don't hold a connection you cannot enrol the secured writes into.

Two related, smaller ones: options (isolationLevel, maxWait, timeout) is forwarded only on the legacy $transaction branch and silently ignored on the Prisma 8 path generated contexts actually take (:812) — { isolationLevel: 'Serializable' } quietly degrades to Read Committed; and the $transaction branch calls child(tx) with no unsafeTransaction (:831), so tx.unsafe there would run on the outer client — dead today, live the moment the engine's handle becomes transaction-capable.

Type safety

Clean on the stated claim: no as casts and no any in unsafe.ts, the get trap returns unknown narrowed by predicates, lanes are projected off TClient['sql' | 'raw' | 'orm'], and new Proxy<T> carries the target's type. The residual risk is the one you'd expect from a structural stand-in: UnsafeCapableClient declares the three lanes as object, so a real client whose transaction signature or lane shape diverges still compiles at the seam. The tests build the surface over a real client, which mitigates it — worth keeping that coupling deliberate.

Tests

Honest. Real database, real statements, plan-shape and origin assertions only (no SQL text), no skipIf hiding the shapes, and both negative tests fail for the right reason (foreign query → UnmarkedQueryError by type; plan-built-in-scope-run-outside asserts the recorder saw nothing before the throw).

Migration completeness

One missed call site, outside the diff so not commentable inline: examples/rag-openai-chatbot/scripts/install-pgvector.ts:11 still reads (await rawOpensaasContext).prisma, a property this PR removes from StackContext — the script now takes its "Prisma client not found in context" throw. It was already Prisma 6-shaped on this branch, so this is a gap in the rename pass rather than a new break, but it should not ship half-renamed.

Also: access/types.ts:313 carries a TSDoc {@link AccessContext.unsafe} pointing at a member AccessContext does not have (that is the #1145 follow-up) — a broken link that actively tells hook authors context.unsafe exists inside hooks. The deliberate deferral of AccessContext.prisma itself is defensible (it is the engine's internal handle, and auth/rag consume it), but the deferral is only legible if the docs don't claim otherwise.

Conventions otherwise check out: ESM .js extensions throughout, changeset present and scoped, Known limits block used as sanctioned, and the public TSDoc on UnsafeSurface states the bypass plainly — "Everything the secured surface does, this one skips" is the right register for this surface.

To unblock

  1. Fix or explicitly document the transaction/atomicity behaviour at context/index.ts:840, and stop silently dropping options at :812.
  2. Wrap functions in markResult; narrow isInvariantProperty to the substitution path.
  3. Soften the prepare/runtime prose to the claim the tests establish, or test the ORM-lane path.
  4. Fix the install-pgvector.ts call site and the broken {@link}.

borisno2 and others added 2 commits September 6, 2026 18:40
`context.transaction` over a Prisma 8 client opened a real interactive
transaction — holding a pooled connection — while the child context's `db`
stayed on the outer ORM handle, so its writes committed outside the open
transaction or waited for a connection a single-connection pool never frees.
The engine's handle is now rebuilt from the transaction's own collections, and
no transaction is opened over a client that has none to bind.

Transaction `options` a Prisma 8 client cannot honour are refused with
`TransactionOptionsUnsupportedError` rather than silently downgraded to the
server's default isolation level.

Two ORM-proxy containment holes: `markResult` now wraps a returned callable the
way the `get` trap already wrapped a read one, so a query issued through it is
still marked; the Proxy-invariant guard is consulted only where the trap would
substitute, with the residual (a frozen own-property collection) stated as a
`Known limits` note.

Also narrows the `prepare()`/`runtime()` unreachability claim to what the tests
establish, points `AccessContext.prisma`'s TSDoc at a member that exists, and
takes `install-pgvector.ts` off the removed `context.prisma`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts:
#	packages/core/src/context/index.ts
#	packages/core/src/index.ts
@borisno2

borisno2 commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

Review addressed — e289fc86 (fix cd928fc4, then origin/prisma-8 merged in)

Each inline thread has its own reply. Summary, plus the findings that had no inline anchor:

Transaction atomicity (context/index.ts, most severe). The engine ORM handle is now rebound to the transaction own collections, so txContext.db.* runs inside the transaction the branch opens instead of auto-committing beside it or waiting on a connection a max: 1 pool will not free. A client whose collections do not resolve — a hand-built double — gets no transaction opened over it at all, which is the pre-PR behaviour. Two new tests in packages/core/tests/transaction-orm-binding.test.ts cover the throw-leaves-no-row case and the single-connection case; both fail against the previous code, the second with a deterministic PoolExhausted rather than a hanging test. StackContext.transaction TSDoc now states the connection the callback holds, and that work reached through the outer context inside it still needs a second one.

options. Refused with TransactionOptionsUnsupportedError on the Prisma 8 path rather than silently downgraded; still forwarded on the Prisma 7 branch.

Proxy holes. markResult now wraps a returned callable (the test fails before with UnmarkedQueryError). The invariant guard is narrowed to the substitution path — but see that thread: the narrowing is behaviour-preserving by construction, because the guard is the Proxy invariant, so I documented the residual as a Known limits line rather than claiming a fix I did not make.

prepare/runtime — softened. The TSDoc and the changeset now claim what the tests establish: the surface hands out neither, and they are not among its own members. A Known limits line says out loud that this is a statement about the surface members and not a claim that nothing reachable through the ORM lane can prepare — the lane is Prisma collections whole, so a prepare-shaped member on one of them is proxied like any other call, which marks the preparation and not the executions that follow. Closing that needs Prisma own middleware, not this proxy.

install-pgvector.ts. Rewritten. It no longer reaches for the removed context.prisma, and it does not go through context.unsafe either: unsafe.raw is typed object on the structural UnsafeCapableClient, so any use of the raw tag from an example is untypeable without a cast. It now opens its own pg client on DATABASE_URL (pg is already a dependency of that example), which is also the honest shape for a CREATE EXTENSION bootstrap that runs before the contract is applied.

Not in this PR, as agreed: #1205 (the constant-false $transaction probe in runInTransaction) and the AccessContext.prisma rename.

Verification. pnpm build, pnpm lint (0 errors; the 2 pre-existing warnings unchanged), pnpm manypkg check and pnpm format all clean. packages/core 1527 passed / 1 skipped and packages/cli 332 passed, run both with DATABASE_URL unset (PGlite) and against a local Postgres — no test databases left behind on either. The contract fixture regenerates byte-identical. origin/prisma-8 had moved (#1203); merged in rather than rebased — the conflicts were the transaction body in context/index.ts (kept this branch runTransactionBody refactor over the pre-PR inline shape) and adjacent export blocks in core/src/index.ts (kept both).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant