Skip to content

feat(core): add context.transaction interactive hook-firing transaction#616

Merged
borisno2 merged 2 commits into
mainfrom
claude/triage-614-pb50y6
Jun 28, 2026
Merged

feat(core): add context.transaction interactive hook-firing transaction#616
borisno2 merged 2 commits into
mainfrom
claude/triage-614-pb50y6

Conversation

@borisno2

Copy link
Copy Markdown
Member

Summary

Adds context.transaction(fn, options) to the stack Context — an interactive, hook-firing transaction. The txContext.db.* operations are access-checked and run list/field hooks exactly like the request context, but persist against one Prisma interactive transaction, so every write in the callback is atomic (a throw anywhere rolls it all back). This is the secured alternative to raw prisma.$transaction, which bypasses access control and hooks.

It enables concurrency-sensitive invariants (e.g. a capacity/quota gate) to be enforced atomically without dropping the access/hook boundary — the gap reported in #614 (Keystone 6's context.transaction had no stack equivalent).

const result = await context.transaction(
  async (tx) => {
    const count = await tx.db.booking.count({ where: { slotId } })
    if (count >= capacity) return { booked: false }
    return { booked: true, item: await tx.db.booking.create({ data: { slotId } }) }
  },
  { isolationLevel: 'Serializable' },
)

What changed

  • context.transaction(fn, options?) added to getContext's returned context.
    • Reuses the ADR-0010 rebind primitive (buildDbDelegate via the getContext factory) to bind the secured context to the transaction client — no new persistence logic.
    • options (isolationLevel incl. 'Serializable', maxWait, timeout) pass through to Prisma.
    • Serialization failures (P2034) propagate to the caller — retry is caller-owned (matching Keystone). Built-in retry is intentionally out of scope.
    • Nested context.db writes join the outer transaction via the Write Pipeline's existing "no $transaction on a tx client → run directly" fallback; calling transaction() on a client with no $transaction runs fn directly with identical hook/access semantics.
    • Plugin runtimes are reused, not re-run, for the tx context (new internal _sharedPlugins path).
  • Exports new types StackContext, TransactionOptions, TransactionIsolationLevel from @opensaas/stack-core.
  • Adds ADR-0012 (cross-referencing ADR-0010), core + root CLAUDE.md docs, and a minor changeset.

Test plan

  • New tests/interactive-transaction.test.ts (10 tests): method presence, returns callback result, atomic rollback on throw, isolationLevel passthrough, access control enforced (denied create → null), list hooks fire, serialization-failure propagation, session/sudo() on the tx context, and the no-$transaction fallback.
  • A Serializable capacity-gate concurrency test: 4 racers against capacity-2 commit exactly 2 (deterministic predicate-conflict emulation + caller retry).
  • Full core suite green (706 + 10 tests), pnpm build (all 11 tasks), pnpm lint (0 errors), pnpm format, pnpm manypkg fix.

Closes #614

🤖 Generated with Claude Code


Generated by Claude Code

Adds `context.transaction(fn, options)` to the stack Context — an interactive
transaction whose `txContext.db.*` operations are access-checked and hook-firing
(identical to the request context) but persist against one Prisma interactive
transaction, so every write in the callback is atomic. This is the secured
alternative to raw `prisma.$transaction`, which bypasses access control and
hooks.

- Reuses the ADR-0010 rebind primitive (buildDbDelegate via getContext) to bind
  the secured context to the transaction client; plugin runtimes are reused
  (not re-run) for the tx context.
- `options` (isolationLevel/maxWait/timeout) pass through to Prisma; serialization
  failures (P2034) propagate so the caller owns the retry loop.
- Nested context.db writes join the outer transaction via the existing
  no-$transaction fallback; falls back to running directly when the client has
  no $transaction.
- Exports StackContext, TransactionOptions, TransactionIsolationLevel.
- Adds ADR-0012, docs, and a changeset.

Closes #614

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHLArNoEjHjGZnHpSxmu5V
@changeset-bot

changeset-bot Bot commented Jun 28, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4608965

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-auth Minor
@opensaas/stack-cli 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

@vercel

vercel Bot commented Jun 28, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
stack-docs Ready Ready Preview, Comment Jun 28, 2026 1:25am

…ite rollback test

Addresses review nits on #614:
- transaction() docstring + ADR-0012 now explicitly warn that plugin runtime
  services stay bound to the top-level client, so a writing plugin service
  would escape the transaction and survive a rollback (use txContext.db).
- Adds a test asserting sudo() writes inside a transaction commit on success
  and roll back with the transaction (confirming sudo binds to the tx client).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHLArNoEjHjGZnHpSxmu5V

Copy link
Copy Markdown
Member Author

Code review (subagent) — verdict: approve-with-nits → nits addressed

An independent review pass checked the implementation adversarially across atomicity, nesting, TDZ, error propagation, sudo binding, and type safety. Summary:

Concern Result
Atomicity & nesting ✅ Correct. Inside prisma.$transaction, the rebuilt txContext.db.* writes reach runInTransaction(tx, …); a Prisma tx client has no $transaction, so the existing fallback runs them directly on tx — they join the outer transaction. No double-transaction, no lost rollback.
No-$transaction fallback (return fn(returned)) ✅ Safe — transaction is a hoisted decl, returned is only dereferenced at call time (after getContext returns). No TDZ. Plugin runtimes receive the internal AccessContext, which has no transaction method.
Serialization-failure propagation ✅ Confirmed. Nothing in the tx path converts a throw to null; only access-denial short-circuits return null.
sudo() inside a tx ✅ Stays bound to tx (sudo writes commit/roll back with the transaction).
Type safety ✅ Clean. The two internal casts are localized; exported types (StackContext/TransactionOptions/TransactionIsolationLevel) contain no any.

Nits raised (both addressed in 4608965)

  1. Plugin services are not tx-bound (the one substantive caveat). Plugin runtime() services stay bound to the top-level client by design (ADR-0012). The reviewer noted the write-escape consequence was understated: a plugin service that writes would escape the transaction and survive a rollback. → Added an explicit caveat to the transaction() docstring and ADR-0012: use txContext.db for writes that must be atomic. (No shipped plugin writes from runtime() today; this is forward-looking.)
  2. Sudo-write coverage was thin — the sudo test only checked _isSudo. → Added a test asserting a sudo() write inside a transaction commits on success and rolls back on throw.

Core suite now 11/11 in the new file (707 total green locally).


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

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

Status Category Percentage Covered / Total
🔵 Lines 91.51% (🎯 65%) 841 / 919
🔵 Statements 90.72% (🎯 65%) 871 / 960
🔵 Functions 97.93% (🎯 62%) 142 / 145
🔵 Branches 79.05% (🎯 50%) 551 / 697
File CoverageNo changed files found.
Generated in workflow #1213 for commit 4608965 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor

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

Status Category Percentage Covered / Total
🔵 Lines 76.03% 92 / 121
🔵 Statements 75.39% 95 / 126
🔵 Functions 75.6% 31 / 41
🔵 Branches 65.78% 75 / 114
File CoverageNo changed files found.
Generated in workflow #1213 for commit 4608965 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor

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

Status Category Percentage Covered / Total
🔵 Lines 79.21% 1490 / 1881
🔵 Statements 78.92% 1550 / 1964
🔵 Functions 84.45% 201 / 238
🔵 Branches 67.25% 653 / 971
File CoverageNo changed files found.
Generated in workflow #1213 for commit 4608965 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor

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

Status Category Percentage Covered / Total
🔵 Lines 74.64% 159 / 213
🔵 Statements 69.74% 166 / 238
🔵 Functions 83.11% 64 / 77
🔵 Branches 70.67% 94 / 133
File CoverageNo changed files found.
Generated in workflow #1213 for commit 4608965 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor

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

Status Category Percentage Covered / Total
🔵 Lines 73.22% 186 / 254
🔵 Statements 75% 207 / 276
🔵 Functions 83.33% 65 / 78
🔵 Branches 70.73% 174 / 246
File CoverageNo changed files found.
Generated in workflow #1213 for commit 4608965 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor

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

Status Category Percentage Covered / Total
🔵 Lines 47.97% 355 / 740
🔵 Statements 48.14% 377 / 783
🔵 Functions 54.26% 70 / 129
🔵 Branches 42.55% 180 / 423
File CoverageNo changed files found.
Generated in workflow #1213 for commit 4608965 by the Vitest Coverage Report Action

@github-actions

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 #1213 for commit 4608965 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor

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

Status Category Percentage Covered / Total
🔵 Lines 100% 38 / 38
🔵 Statements 100% 38 / 38
🔵 Functions 100% 8 / 8
🔵 Branches 100% 22 / 22
File CoverageNo changed files found.
Generated in workflow #1213 for commit 4608965 by the Vitest Coverage Report Action

@borisno2 borisno2 merged commit 322d5b6 into main Jun 28, 2026
6 checks passed
@borisno2 borisno2 deleted the claude/triage-614-pb50y6 branch June 28, 2026 01:33
@github-actions github-actions Bot mentioned this pull request Jun 28, 2026
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.

Context API has no interactive (hook-firing) transaction — can't atomically enforce concurrency-sensitive constraints

2 participants