Skip to content

fix(auth): route databaseHooks context.adapter through the transaction lane - #1554

Merged
borisno2 merged 4 commits into
mainfrom
claude/jolly-turing-r8b68v
Sep 14, 2026
Merged

borisno2 merged 4 commits into
mainfrom
claude/jolly-turing-r8b68v

Conversation

@borisno2

Copy link
Copy Markdown
Member

Summary

better-auth hands a databaseHooks.<model>.create.before/after hook a GenericEndpointContext whose .context.adapter is the Auth adapter's root instance — better-auth swaps in the transaction-bound instance only through its own AsyncLocalStorage (runWithTransactiongetCurrentAdapter), which hooks never go through. A hook that queried context.context.adapter during sign-up therefore ran on the outer lane while the sign-up transaction held the database's only connection:

  • On the Dev database (single connection, ADR-0063) this hangs until the pool's acquire timeout.
  • On pooled Postgres the read/write happens outside the transaction and can survive its rollback.

This was a stated known limit on opensaasAuthAdapter since #1251 (adapter transactions). This PR closes it.

Fix

The root factory instance (what opensaasAuthAdapter itself returns, and therefore what AuthContext.adapter is) now reads its lane from the same AsyncLocalStorage store the transaction-bound instance already used, instead of a closed-over unsafe. Both instances execute inside the same boundLane.run(lane, …) call for the life of the transaction — the outer config.transaction callback is that call — so a hook reaching through context.context.adapter now lands on the transaction-bound connection, exactly like a call through better-auth's own getCurrentAdapter would. Outside a transaction the store is empty and the outer lane answers, unchanged.

This is a one-line change (packages/auth/src/adapter/index.ts): the root instance's factoryOn(...) call now passes the shared laneOf instead of () => unsafe.

  • packages/auth/src/adapter/index.ts's doc comment and packages/auth/CLAUDE.md's "Known limits" section are updated to describe the new (fixed) behaviour instead of the limit.

Test plan

  • New test packages/auth/tests/adapter-databasehooks-transaction.test.ts:
    • A databaseHooks.user.create.before hook that reads via context.context.adapter.findOne(...) during a successful sign-up completes without hanging.
    • A databaseHooks.user.create.before hook that writes via context.context.adapter.create(...), where the enclosing sign-up later fails (a CHECK (false) constraint on account, same technique as adapter-signup-atomicity.test.ts), rolls that write back together with the rest of sign-up — proving the hook's work is atomic with the transaction, not merely non-hanging.
    • Both tests bound their own timeout (5s) well under the harness pool's 10s acquire timeout, so a regression is reported as a timed-out test rather than a slow failure.
    • Verified both tests fail (time out) against the pre-fix code and pass against the fix.
  • pnpm build (auth package)
  • pnpm test in packages/auth: 525 passed / 79 skipped (including the new tests)
  • pnpm test in packages/core: 1694 passed / 7 skipped (unaffected)
  • pnpm lint, pnpm manypkg fix, pnpm format

Closes #1252

🤖 Generated with Claude Code

https://claude.ai/code/session_01HEEWCWcKXFFYY3PXE2zBH6


Generated by Claude Code

…n lane

better-auth hands a databaseHooks before/after hook a GenericEndpointContext
whose .context.adapter is the Auth adapter's ROOT instance — better-auth
never swaps that reference for the transaction-bound one (only its own
AsyncLocalStorage, read via getCurrentAdapter, does). A hook that queried
through it during sign-up therefore ran on the outer lane while the sign-up
transaction held the database's only connection: a hang on the Dev database,
and a read outside the transaction (surviving a rollback) on pooled Postgres.

The root instance now reads its lane from the same AsyncLocalStorage store
the transaction-bound instance does. Both instances run inside the same
boundLane.run(...) call for the life of the transaction, so a hook reaching
through context.context.adapter lands on the transaction-bound connection
without needing to touch better-auth's own ALS routing.

Closes #1252

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HEEWCWcKXFFYY3PXE2zBH6
@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 13, 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 13, 2026 10:08pm UTC

@changeset-bot

changeset-bot Bot commented Sep 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a1078a7

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-auth Minor
@opensaas/stack-cli Minor
@opensaas/stack-core Minor
@opensaas/stack-rag Minor
@opensaas/stack-storage-s3 Minor
@opensaas/stack-storage-vercel Minor
@opensaas/stack-storage Minor
@opensaas/stack-tiptap Minor
@opensaas/stack-ui 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

…llision

0070-the-auth-plugins-id-strategy-is-config-not-a-hardcoded-pin.md (#1550)
and 0070-the-generated-clients-identity-is-a-process-wide-registry-entry.md
(#1548) landed within a minute of each other and both claimed 0070,
tripping check:adr-duplicates on every PR against main.

Renumbered the id-strategy ADR to 0071 (2 filename-link updates in ADR-0048
and ADR-0060) rather than the client-identity one, which has 7+ scattered
bare "(ADR-0070)" comment references across packages/core, packages/cli and
packages/auth that would otherwise need updating instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HEEWCWcKXFFYY3PXE2zBH6

Copy link
Copy Markdown
Member Author

The test check failed on the first CI run, but not from anything in this PR's diff: pnpm check:adr-duplicates (an early step in that job) was already red on main at the commit this branch forked from — two ADRs both claim number 0070 (0070-the-auth-plugins-id-strategy-is-config-not-a-hardcoded-pin.md from #1550 and 0070-the-generated-clients-identity-is-a-process-wide-registry-entry.md from #1548, merged about a minute apart). This PR touches nothing under docs/adr/, and reproducing pnpm check:adr-duplicates locally against origin/main shows the same failure.

Opened #1555 to fix it (renumbers the id-strategy ADR to 0071, the lower-blast-radius rename — the other file has 7+ scattered bare (ADR-0070) comment references across packages/core/cli/auth that would need updating instead) and ported the identical commit into this branch (04f66ff..3eb1e9c) so this PR is green without waiting on #1555 to merge first.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

Review: PR #1554 — databaseHooks / transaction-lane fix

High-effort review against the actual diff (pull_request_read.get_diff), plus independent verification against the vendored better-auth@1.7.1 / @better-auth/core@1.7.1 source in node_modules, and independent test execution on this branch. No blocking issues found — the fix is correct and the mechanism is verified. Two doc/changeset follow-ups below (non-blocking).

1. AsyncLocalStorage scoping / concurrency — no regression found

Traced the whole path through the real runtime: dispatchAuthEndpoint (better-auth/dist/api/dispatch.mjs) → runWithEndpointContextsign-up.mjs's runWithTransaction(ctx.context.adapter, fn)@better-auth/core's transaction.mjs runWithTransaction, which calls adapter.transaction(cb). adapter.transaction resolves (via @better-auth/core/db/adapter/factory.mjs) to exactly config.transaction, i.e. this adapter's factoryTransaction — confirming cb really does reach factoryOn's third argument as documented.

With the fix, the root instance's factoryTransaction is (callback) => transaction(async (lane) => await boundLane.run(lane, () => callback(boundAdapter))). Since cb (better-auth's own trx => als.run({adapter: trx, isTransactionActive: true, ...}, fn)) only resolves once fn — the entire rest of sign-up, databaseHooks.before included — has finished, boundLane's store is active for the full duration a databaseHooks hook can observe it. Outside any transaction, boundLane.getStore() is empty and laneOf() falls back to unsafe, unchanged from before.

For concurrency: boundLane is a single AsyncLocalStorage closed over per opensaasAuthAdapter(...) call (i.e. once per process, since createAuth's adapter factory is a singleton), but Node's ALS scoping is per async-continuation, not a shared mutable global — two overlapping/concurrent sign-ups each get their own boundLane.run(lane, …) frame, and a call outside that frame (a totally separate request) correctly sees getStore() === undefined. I don't see a way for one request's transaction lane to leak into another's. I also checked the after-hook path (queueAfterTransactionHook): those hooks run from the pendingHooks loop after adapter.transaction(cb) has already resolved, i.e. after boundLane.run(...)'s scope has closed — so an after hook still (correctly, by design) reaches the outer/post-commit lane, exactly matching the documented semantics for post-commit hooks. No behavioral change there, and no regression.

2. GenericEndpointContext.context.adapter — confirmed accurate

Verified directly against the vendored 1.7.1 build (not from memory): dispatch.mjs line ~191 constructs internalContext = {...input, context: {...input.context, ...}}, and with-hooks.mjs's createWithHooks calls toRun(actualData, context) where context = await getCurrentAuthContext() — the exact internalContext object set by runWithEndpointContext. So context.context.adapter in a databaseHooks hook is indeed internalContext.context.adapter, i.e. the resolved AuthContext.adapter — the root factory instance. The doc comments in packages/auth/src/adapter/index.ts and packages/auth/CLAUDE.md describe this correctly.

3. Independently re-verified the test claims

Built core+auth on this branch and ran the new test file — both pass:

Test Files  1 passed (1)
     Tests  2 passed (2)

Then swapped only packages/auth/src/adapter/index.ts back to its pre-fix content (the parent commit, 2c000b1), rebuilt auth, and reran the same file — both tests fail exactly as claimed, via the 5s hang-guard timeout (not a different assertion failure):

× a databaseHooks before hook reading through context.context.adapter does not hang sign-up  5009ms
× a databaseHooks before hook's write through context.context.adapter is atomic with the rest of sign-up  5003ms
Error: Test timed out in 5000ms.

Restored the fixed file and reran the full packages/auth suite on the fix: 525 passed / 79 skipped, matching the PR description. The two new tests pin the fix — a revert of the one-line change reintroduces the hang deterministically.

4. Code quality / doc & changeset scope

  • The fix itself (factoryOn(laneOf, transaction, …) replacing factoryOn(() => unsafe, transaction, …)) is minimal and correct; I don't see a simpler formulation.
  • docs/content/reference/auth.md (lines 706–718) is stale. It still documents the pre-fix limitation verbatim ("A databaseHooks before hook runs outside the sign-up transaction… sign-up hangs… keep a before hook to pure in-memory work"), while packages/auth/src/adapter/index.ts and packages/auth/CLAUDE.md were updated to describe the fixed behavior. This is the stack's own public reference doc (stack.opensaas.au), so as written it tells a reader the limitation still exists. Worth updating in this PR or a fast follow.
  • .changeset/silent-moons-gather.md is now self-contradictory with the new changeset. It's a still-unreleased changeset (from the earlier adapter-transactions PR) that asserts the same limitation as a permanent fact ("a databaseHooks before hook that queries context.adapter directly runs outside the transaction (databaseHooks run on the outer lane during an auth transaction — hangs on the single-connection Dev database #1252)"). Both changesets are pending in .changeset/ and will land in the same @opensaas/stack-auth version bump, producing two contradictory paragraphs in one release's changelog. Worth trimming that paragraph out of silent-moons-gather.md (or noting it's superseded) alongside adding silent-otters-hunt.md.
  • Minor, non-blocking: the second commit (3eb1e9c, renumbering ADR-0070→0071) is unrelated to the databaseHooks fix — looks like a rebase collision fix against main rather than PR scope creep, but flagging in case it wasn't intentional to bundle into this PR.
  • One doc-comment phrasing nit in packages/auth/src/adapter/index.ts (new paragraph): "...as opposed to the transaction-bound instance getCurrentAdapter hands internalAdapter" reads a little tersely (missing a "that"/"to"); purely cosmetic, verified the substance is correct.

Verdict

Core fix and its mechanism are correct and independently verified against real better-auth internals and by reverting/re-testing. Nothing here blocks merge; the doc/changeset staleness in point 4 is the only thing I'd actually want addressed before or shortly after landing, since a reader of the public docs would otherwise be told a now-fixed bug is still a hard limitation.


Generated by Claude Code

docs/content/reference/auth.md's "Known limits" section and the pending
#1251 changeset (.changeset/silent-moons-gather.md) both still described
the databaseHooks-hook-runs-outside-the-transaction limit this PR closes.
Left unedited, both would have shipped in the same release as this PR's
own changeset claiming it fixed — a contradiction in the same changelog
entry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HEEWCWcKXFFYY3PXE2zBH6
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HEEWCWcKXFFYY3PXE2zBH6

Copy link
Copy Markdown
Member Author

Thanks for the thorough review. Addressed both non-blocking findings:

  • docs/content/reference/auth.md: removed the stale "A databaseHooks before hook runs outside the sign-up transaction" known-limit paragraph (and updated "five known limits" → "four") — the reference doc no longer tells readers a now-fixed bug is a hard limitation.
  • .changeset/silent-moons-gather.md: trimmed the same stale paragraph so it no longer contradicts silent-otters-hunt.md in the next release's changelog.
  • Also fixed the phrasing nit in the doc comment ("the transaction-bound instance that getCurrentAdapter hands internalAdapter").

Re the bundled ADR-0070→0071 commit: correct guess, that's not scope creep — check:adr-duplicates was already red on main (two ADRs both claimed 0070, from #1550 and #1548 landing a minute apart) and blocking this PR's test check on an unrelated failure. Opened #1555 to fix it on main and ported the identical commit here so this PR didn't have to wait on it — noted in the comment above.

All pushed (f690c53, a1078a7). pnpm build/pnpm test (525 passed/79 skipped) and pnpm lint/pnpm format all clean on the current head.


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 93.84% (🎯 81%) 3523 / 3754
🟢 Statements 92.09% (🎯 76%) 3986 / 4328
🟢 Functions 95.48% (🎯 78%) 803 / 841
🟢 Branches 86.96% (🎯 71%) 2623 / 3016
File CoverageNo changed files found.
Generated in workflow #2511 for commit a1078a7 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 78.7% 244 / 310
🔵 Statements 78.43% 251 / 320
🔵 Functions 69.81% 74 / 106
🔵 Branches 67.51% 160 / 237
File CoverageNo changed files found.
Generated in workflow #2511 for commit a1078a7 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 82.28% 1992 / 2421
🔵 Statements 82% 2142 / 2612
🔵 Functions 87.91% 342 / 389
🔵 Branches 75.17% 1066 / 1418
File CoverageNo changed files found.
Generated in workflow #2511 for commit a1078a7 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 91.42% 288 / 315
🔵 Statements 90.16% 321 / 356
🔵 Functions 96.1% 74 / 77
🔵 Branches 83.38% 276 / 331
File CoverageNo changed files found.
Generated in workflow #2511 for commit a1078a7 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 90.11% 301 / 334
🔵 Statements 90.27% 334 / 370
🔵 Functions 96.9% 94 / 97
🔵 Branches 86.44% 319 / 369
File CoverageNo changed files found.
Generated in workflow #2511 for commit a1078a7 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 91.98% 597 / 649
🔵 Statements 91.35% 655 / 717
🔵 Functions 97.54% 119 / 122
🔵 Branches 85.65% 424 / 495
File CoverageNo changed files found.
Generated in workflow #2511 for commit a1078a7 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 #2511 for commit a1078a7 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% 68 / 68
🔵 Statements 100% 71 / 71
🔵 Functions 100% 15 / 15
🔵 Branches 97.87% 46 / 47
File CoverageNo changed files found.
Generated in workflow #2511 for commit a1078a7 by the Vitest Coverage Report Action

@borisno2
borisno2 merged commit 73b8231 into main Sep 14, 2026
8 checks passed
@borisno2
borisno2 deleted the claude/jolly-turing-r8b68v branch September 14, 2026 06:14
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.

databaseHooks run on the outer lane during an auth transaction — hangs on the single-connection Dev database

2 participants