Skip to content

Adapter transactions, the auth-flow suite and the plain-Node anchor - #1251

Merged
borisno2 merged 3 commits into
prisma-8from
claude/issue-1162-adapter-transactions
Sep 6, 2026
Merged

Adapter transactions, the auth-flow suite and the plain-Node anchor#1251
borisno2 merged 3 commits into
prisma-8from
claude/issue-1162-adapter-transactions

Conversation

@borisno2

@borisno2 borisno2 commented Sep 6, 2026

Copy link
Copy Markdown
Member

Implements #1162. Part of #1126. Targets prisma-8, not main.

Builds on #1161 (ec2264e): the adapter, its two lanes, and the internal transaction bracket consumeOne already used are unchanged in shape.

The factory transaction option

opensaasAuthAdapter now builds its factory through a local factoryOn(surface, bracket, factoryTransaction), so the same adapter body can be bound to any lane:

  • Root instance — bound to the request context's Unsafe surface. Its config.transaction is a function that opens the context's own transaction and rebinds a second factory instance to the transaction-bound surface, then hands that to better-auth's callback. This is the shape better-auth's own Kysely and Prisma adapters use, and it is what makes the record's "config.transaction is implemented" bullet true.
  • Transaction-bound instance — ships transaction: false to the factory and brackets consumeOne as the identity on the lane it already holds. Postgres has no nested transaction to open, and better-auth never calls transaction on the adapter it hands a callback (DBTransactionAdapter omits it).

ADR-0042's rule applies unchanged: no isolation level is selectable, and auth transactions run at Read Committed.

Tests

  • tests/adapter-conformance.test.ts gains transactionsTestSuite() and authFlowTestSuite(). 166 passed / 76 skipped, up from 156/76. The transactions suite skips itself when the adapter reports no transaction support; it does not skip here.
  • tests/adapter-signup-atomicity.test.ts (new) drives a real auth.api.signUpEmail against a real betterAuth instance over the adapter, with a check (false) constraint on the account table. It asserts zero user, account and session rows. This pins the mechanism, not the outcome: reverting the factory option to false fails it with expected 1 to be +0 on the user count (verified). The failure is injected at the database rather than by stubbing an adapter method, because better-auth reaches the transaction-bound adapter through its own AsyncLocalStorage — an override on the outer adapter is not what the account write would call.

Neither test is concurrency-shaped, so the in-process Postgres serialising on one connection is not a limitation here.

Where the plain-Node anchor landed, and why

packages/auth/tests/auth-node-anchor.test.ts, not examples/starter-auth. #1138 deleted scripts/node-build-create-user.mjs and e2e/starter-auth/04-node-build.spec.ts rather than re-targeting them, and the examples do not build until the spec's example conversion (#1178) — committing an anchor there would have been a file that cannot execute. The test instead builds the smallest project that can run today: a fixture opensaas.config.ts carrying only authPlugin, generated by the real CLI into a scratch tree inside this package, then a probe run under process.execPath with no flags, no loader, no bundler that calls createAuth(config, rawOpensaasContext) and signUpEmail. The row is then read back over pg.

Two things it does differently from packages/cli/tests/bundle-node-load.test.ts, the nearest prior art:

  • It uses async spawn, not spawnSync. The Dev database is an in-process PGlite served over a socket by this process's event loop; blocking on the child starves the server the child is dialling.
  • It binds the connection by provenance. On the Dev database it writes .opensaas/dev-db.json and passes no DATABASE_URL — only that branch makes the generated bundle bind the single connection pglite-socket needs, and injecting the variable is exactly what ADR-0063 forbids. Under the DATABASE_URL escape it passes the variable, and a real server handles the pool.

Both paths were run: PGlite locally, and a real Postgres 14 via the escape.

Manifest and guidance

  • The @prisma/client peer was already absent from packages/auth/package.json; nothing was removed. @better-auth/test-utils was already a dev dependency. pg / @types/pg are added as dev dependencies at the monorepo's existing versions (^8.22.0 / ^8.20.0) for the two tests that read rows back directly.
  • No prismaAdapter literal remains anywhere under packages/auth or in the root CLAUDE.md. packages/auth/CLAUDE.md gains an "The Auth adapter (ADR-0060)" section and its stale prismaClientConstructor session-provider snippet is corrected; the root guidance's auth "How it works" list names the adapter and its transaction behaviour.
  • ADR-0060 is amended in place, in its dated-amendment style: the new amendment records that config.transaction is now implemented (and how to read The Auth adapter over the Unsafe surface with the conformance suites #1221's amendment, which described the interim state), and that the anchor is back at a different address. The struck plain-Node bullet keeps its strike and gains a pointer.

Out of scope, left alone

#1239 (the uuid7 pin overriding app-level db.idField), #1240 (generateId refused by key presence), #1241 (codecFor coverage for a mapped increment column) and #1222 (deriveAuthLists FK shadowing) are untouched.

Verification

  • pnpm build (11 tasks), pnpm lint (0 errors), pnpm manypkg fix, pnpm format.
  • packages/auth: 23 files, 501 passed / 80 skipped.
  • packages/core: 82 files, 1628 passed / 1 skipped.
  • packages/cli: 36 files, 373 passed.
  • Under the DATABASE_URL escape against a real Postgres, the conformance, atomicity and anchor files: 170 passed / 76 skipped.

The 76 skips are the pre-existing, stated ones: every join test, generateId, the issuer-scoped account key (#986), the nullable FK (#1222), and the suite's hardcoded non-UUID id probes.

🤖 Generated with Claude Code

Implements the factory's transaction option on the Auth adapter by building
its factory per lane: the root instance's `config.transaction` rebinds a
second instance to the transaction-bound Unsafe surface, so sign-up's user,
account and session writes commit or roll back as one. A transaction-bound
instance ships the option off and brackets `consumeOne` on the lane it
already holds, because Postgres has no nested transaction to open.

better-auth's `transactions` and `authFlow` suites join the conformance
harness. A new test drives a real `signUpEmail` with the account table under
a CHECK constraint that refuses every insert, and asserts no user row
survives — the failure is at the database, not a stubbed adapter method,
because better-auth reaches the transaction-bound adapter through its own
AsyncLocalStorage.

The plain-Node anchor returns as `packages/auth/tests/auth-node-anchor.test.ts`
rather than in `examples/starter-auth`, which still does not build (#1178):
it generates a fixture project with `authPlugin` and creates a user through
`createAuth` over the natively loaded bundle under the real `node` binary.

ADR-0060 is amended in place; the auth package's guidance and the root
guidance's auth paragraphs stop naming better-auth's Prisma Client adapter.

Implements #1162
Part of #1126

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@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 11:35pm UTC

@changeset-bot

changeset-bot Bot commented Sep 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4cb3763

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

@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.17% (🎯 65%) 3120 / 3313
🟢 Statements 93.03% (🎯 65%) 3445 / 3703
🟢 Functions 96.6% (🎯 62%) 655 / 678
🟢 Branches 87.65% (🎯 50%) 2378 / 2713
File CoverageNo changed files found.
Generated in workflow #2025 for commit 4cb3763 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 #2025 for commit 4cb3763 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 73.64% 1646 / 2235
🔵 Statements 73.23% 1765 / 2410
🔵 Functions 82.42% 286 / 347
🔵 Branches 60.62% 819 / 1351
File CoverageNo changed files found.
Generated in workflow #2025 for commit 4cb3763 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 91.2% 280 / 307
🔵 Statements 89.94% 313 / 348
🔵 Functions 96.05% 73 / 76
🔵 Branches 82.38% 262 / 318
File CoverageNo changed files found.
Generated in workflow #2025 for commit 4cb3763 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 #2025 for commit 4cb3763 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.38% 397 / 730
🔵 Statements 53.85% 419 / 778
🔵 Functions 64.06% 82 / 128
🔵 Branches 47.25% 198 / 419
File CoverageNo changed files found.
Generated in workflow #2025 for commit 4cb3763 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 #2025 for commit 4cb3763 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 #2025 for commit 4cb3763 by the Vitest Coverage Report Action

Comment thread packages/auth/src/adapter/index.ts Outdated
const bound = (lane: UnsafeSurface): AdapterFactory<BetterAuthOptions> =>
factoryOn(lane, (body) => body(lane), false)

return (betterAuthOptions) =>

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.

Implementing config.transaction opens a lane split the Known-limits block doesn't state.

better-auth swaps in the transaction-bound adapter only through its AsyncLocalStorage store (@better-auth/core context/transaction.mjs: als.run({ adapter: trx, ... }), read back by getCurrentAdapter(adapter) in better-auth/dist/db/with-hooks.mjs). The AuthContext object passed to every databaseHooks.<model>.create.before / update.before hook as its second argument is not swapped — its .adapter is still this root instance, bound to the outer unsafe surface.

Concrete scenario: an app registers databaseHooks.user.create.before that awaits context.adapter.findOne({ model: 'user', ... }) (a common uniqueness/lookup pattern). Before this PR that ran on the same single lane as everything else. Now it runs on the outer lane while the sign-up transaction holds a connection:

  • On the Dev database the generated client binds exactly one connection (ADR-0063, packages/core/src/db/client.ts), and context.transaction is holding it — so the hook's call never acquires a connection and the sign-up hangs until Prisma's acquire timeout. packages/core/src/context/index.ts already names this failure mode ("wait forever for a second connection the dev database's single-connection pool never frees").
  • On a pooled Postgres it silently executes outside the transaction and survives the rollback the rest of sign-up gets.

Worth adding to the Known limits: list on opensaasAuthAdapter (which this PR edits anyway, removing the old "No transaction option" bullet), since it is exactly the kind of thing a reader of that block needs before writing a before hook.

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.

Added as a Known limits: entry on opensaasAuthAdapter (and to the mirrored list in packages/auth/CLAUDE.md), stating the mechanism (runWithTransactionals.run({ adapter: trx }), read back by getCurrentAdapter; the AuthContext.adapter the hook gets is still the root instance on the outer lane), both consequences (hang to the acquire timeout on the single-connection Dev database, ADR-0063; read outside the transaction and surviving rollback on pooled Postgres), and that it is inherited from better-auth's ALS routing. Tracked as #1252, linked from the entry. No behaviour change.

Comment thread packages/auth/src/adapter/index.ts Outdated

return (betterAuthOptions) =>
factoryOn(unsafe, transaction, (callback) =>
transaction(async (lane) => await callback(bound(lane)(betterAuthOptions))),

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 whole adapter factory is rebuilt inside every open transaction.

bound(lane)(betterAuthOptions) is evaluated inside the transaction callback, and it is not cheap: createAdapterFactory(...) normalises the config and the returned factory then runs getAuthTables(options) — the full better-auth schema derivation across every registered plugin — plus initGetFieldName/initGetModelName/etc., on every adapter.transaction() call. That is once per sign-up, per sign-in, and per any other bracketed flow.

Two costs, not one: the derivation is repeated per request, and it happens while the transaction's connection is held. On the Dev database that connection is the only one, and packages/core/src/db/dev-database.ts states that "a connection holding an open transaction holds the whole query queue" — so the extra synchronous work directly lengthens a stall that blocks every other query in the dev app.

The lane is the only thing that varies, so this is hoistable: have factoryOn close over a () => UnsafeSurface (or a small mutable holder) and build the bound factory once outside transaction(...), resolving the lane per call.

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.

Hoisted. factoryOn now takes a () => UnsafeSurface and the lane travels in an AsyncLocalStorage<UnsafeSurface> rather than being closed over, so the transaction-bound instance — createAdapterFactory normalisation plus the getAuthTables derivation — is built once per adapter (bound(betterAuthOptions) in the returned function), outside any transaction. What stays inside transaction(...) is only boundLane.run(lane, () => callback(boundAdapter)): entering the ALS scope and better-auth's own callback. A store rather than a mutable holder because concurrent transactions would race a holder; outside a transaction there is no store and the outer unsafe lane answers, which is exactly the previous behaviour for the root instance.

import { getAuthListRegistry } from '../src/lists/index.js'
import type { NormalizedAuthConfig } from '../src/config/types.js'

const BOOT = 240_000

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.

The hook budget is smaller than the two child budgets it has to cover.

beforeAll does createTestDatabase (PGlite boot + schema apply) plus two run() calls, and each run() gives its child its own 120s SIGKILL timer (line 108). Two children at their own limit already consume the entire 240s hook budget with nothing left for the database boot.

The failure mode is not just "slower CI fails": when the hook times out, generate/probe are never assigned, so the three tests report undefined reads and the probe.output that every assertion passes as its message — the only diagnostic this anchor produces — is lost. A slow runner therefore reports a bare hook timeout instead of the child's stderr.

Either raise BOOT above db boot + 2 × 120s, or lower the per-child timer so the two together fit inside 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 by deriving the budget from the child timer instead of guessing it: const CHILD_TIMEOUT = 120_000 (now used at the setTimeout on line ~113) and const BOOT = 2 * CHILD_TIMEOUT + 120_000 — 360s, i.e. both children at their own SIGKILL limit plus 120s of headroom for createTestDatabase. Both children can now hit their timers, resolve, and still assign probe/generate, so probe.output reaches the assertion messages instead of being lost to a bare hook timeout. The two constants are coupled in one expression, so lowering the child timer keeps the invariant.

Comment thread packages/auth/CLAUDE.md Outdated
better-auth's own sign-in/sign-up/session flows are unaffected: they write
through the raw Prisma client (the driver adapter), bypassing access control
entirely. The runtime `getUser`/`getCurrentUser` helpers resolve through the
through the Auth adapter over the Unsafe surface (see below), marked as

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.

(see below) now points the wrong way — the new "The Auth adapter (ADR-0060)" section this PR adds sits above this paragraph (it was inserted at ~line 164, this is ~line 208). A reader following the pointer scrolls past the access-control and migration sections and finds nothing.

Suggested change
through the Auth adapter over the Unsafe surface (see below), marked as
through the Auth adapter over the Unsafe surface (see above), marked as

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, made explicit rather than just flipping the direction: (see "The Auth adapter (ADR-0060)" above).

@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.

Code review — effort high

Verdict: REQUEST CHANGES — one medium finding, three low. The mechanism is right and the tests are real; the blocker is a gap in the Known limits block on the very behaviour this PR introduces.

Four inline comments are posted at the sites. Summary, by severity:

Medium

packages/auth/src/adapter/index.ts:477 — implementing config.transaction splits the lanes, and Known limits: doesn't say so.

better-auth swaps in the transaction-bound adapter only through its ALS store: runWithTransaction does als.run({ adapter: trx, isTransactionActive: true }, fn), and with-hooks.ts reads it via await getCurrentAdapter(adapter). But the context those same hooks receive is getCurrentAuthContext(), whose .adapter is the root instance on the outer unsafe surface — the ALS store never touches it.

So a databaseHooks.user.create.before that awaits context.adapter.findOne(...) now runs on the outer lane while the sign-up transaction holds a connection. On the Dev database that is the only connection (ADR-0063, core/src/db/client.ts), so the call never acquires one and sign-up hangs to the acquire timeout; on pooled Postgres it silently reads outside the transaction and survives the rollback. Before this PR the option was false, so no transaction was open and the outer lane was harmless — this is newly reachable.

The behaviour is inherited from upstream (Kysely and Prisma adapters split the same way), so I'm not asking for a code change — but the single-connection Dev database turns "reads outside the transaction" into "hangs", and that belongs in the Known limits block on opensaasAuthAdapter alongside the joins / createSchema / error-normalisation entries, and in packages/auth/CLAUDE.md's mirrored list.

Low

  • packages/auth/src/adapter/index.ts:479bound(lane)(betterAuthOptions) builds a whole new createAdapterFactory and runs getAuthTables(options) (full schema derivation across every plugin) inside the open transaction, on every adapter.transaction() — i.e. every sign-up and sign-in. It lengthens exactly the stall dev-database.ts describes as "a connection holding an open transaction holds the whole query queue". Hoistable by having factoryOn close over a () => UnsafeSurface instead of a fixed surface.
  • packages/auth/tests/auth-node-anchor.test.ts:36BOOT = 240_000 has to cover createTestDatabase plus two run() children that each carry their own 120s SIGKILL timer (line 108). The two children alone exhaust it. On a slow runner the hook times out before generate/probe are assigned, so probe.output — the only diagnostic this anchor produces — is never printed.
  • packages/auth/CLAUDE.md:208 — "through the Auth adapter over the Unsafe surface (see below)" points backwards: the new "The Auth adapter (ADR-0060)" section is inserted above it. Suggestion posted.

Checked against the stated risks, and clean

  1. The prismaAdapter criterion was met by fixing, not by rewording. packages/auth/** and root CLAUDE.md carry no prismaAdapter literal; the historical and migration record is intact where it belongs — ADR-0014 line 19 still explains the Proxy wrapping prismaAdapter(context.prisma, { provider }), and ADR-0060's Context and Consequences still name it as what the adapter replaced. Both reworded sentences described the current mechanism and were simply false after #1161; the packages/auth/CLAUDE.md Session Provider snippet it replaces referenced prismaClientConstructor, which no longer exists. Nothing still-true was deleted to clean a grep.
  2. The prisma symlink is sound. prisma@8.0.0-rc.12 is a runtime dependency of @opensaas/stack-cli, which packages/auth declares workspace:* — so packages/cli/node_modules/prisma is guaranteed by pnpm on a clean install and in CI, and a real generated project gets prisma on the same transitive edge. It masks nothing. Two nits, neither blocking: the path is packageRoot/../cli/node_modules/prisma, a hardcoded monorepo directory rather than the declared edge (node_modules/@opensaas/stack-cli/node_modules/prisma); and since projectDir sits under packages/auth, adding prisma to this package's own devDependencies would let ordinary Node walk-up resolution find it and delete the symlink entirely.
  3. The anchor is genuinely plain Node. spawn(process.execPath, ['probe.mjs']) — no flags, no loader, no bundler, NODE_OPTIONS untouched. The probe is .mjs importing ./opensaas.config.ts and ./.opensaas/context.ts, so it fails outright if native type stripping or the generated bundle's module graph stops loading; probe.status is asserted 0 and probe.signal null, and the third test reads the row back over a separate pg client. It is not running inside the Vitest process. Async spawn over spawnSync is correctly justified — the PGlite socket server is this process's event loop.
  4. Factory wiring is correct. bound(lane) passes the transaction lane as surface, so incrementOne and every ORM method on the bound instance use it, and consumeOne threads the same lane into both narrow calls. No nested transaction is reachable: bound ships transaction: false, and independently runWithTransaction short-circuits on store.isTransactionActive before ever calling adapter.transaction. consumeOne's row-count gate survives the identity bracket — the first()/deleteAndCount() pair still runs under one transaction (the caller's, when bound) and still answers only on claimed > 0.
  5. The atomicity test discriminates. check (false) on the account table is a database-level injection that the transaction-bound adapter cannot route around, which is the right call given better-auth reaches that adapter through ALS. The user-row assertion is the discriminator, not the account-row one — with the option reverted to false the user INSERT commits and expected 1 to be +0 is exactly the failure you'd get. Not tautological.
  6. Dependencies. pg ^8.22.0 / @types/pg ^8.20.0 match core, cli and examples/rag-openai-chatbot exactly. devDependencies is right — both are test-only.
  7. ADR-0060. The amendment follows the record's dated > **Amended …** style, sits above the #1221 amendment, accurately describes the shipped code, and disambiguates the earlier "ships the factory option off" sentence as interim rather than leaving it to contradict. The struck plain-Node bullet keeps its strike and gains a forward pointer to the new address — coherent, not a silent un-strike.

Also verified: the unsafesurface rename is complete across all ten adapter methods (no stale outer reference in deleteMany's unconditional path or incrementOne); the factory-option type matches AdapterFactoryConfig['transaction']; changeset present and correctly scoped minor; .gitignore covers the new scratch tree; turbo's test task dependsOn: ["build"], so the anchor's dist/server/index.js precondition holds.

🤖 Generated with Claude Code

…y, anchor budget

- State the `databaseHooks` lane split in `opensaasAuthAdapter`'s Known limits
  and the mirrored list in `packages/auth/CLAUDE.md`: better-auth swaps the
  transaction-bound adapter in only through its ALS store, so a `before` hook
  querying `context.adapter` runs on the outer lane — a hang on the
  single-connection Dev database, a read outside the transaction on pooled
  Postgres (#1252).
- Carry the transaction lane in an AsyncLocalStorage store instead of closing
  over it, so the bound factory (and its `getAuthTables` schema derivation) is
  built once per adapter rather than inside every open transaction.
- Size the anchor's `BOOT` budget off the child SIGKILL timer
  (2 × 120s + 120s) so a slow runner still prints the child's output.
- Fix the backwards "(see below)" pointer in `packages/auth/CLAUDE.md`.

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

borisno2 commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

All four findings addressed in dce38a4 — replies posted on each thread. Summary:

  • Medium, lane split — new Known limits: entry on opensaasAuthAdapter and in packages/auth/CLAUDE.md's mirrored list: the ALS-only swap, the AuthContext.adapter that is never swapped, the Dev-database hang and the pooled-Postgres read-outside-the-transaction, and that it is inherited from better-auth. Linked to databaseHooks run on the outer lane during an auth transaction — hangs on the single-connection Dev database #1252. No behaviour change, as asked.
  • Low, factory inside the transactionfactoryOn now takes () => UnsafeSurface and the lane travels in an AsyncLocalStorage. The bound instance (createAdapterFactory + getAuthTables) is built once per adapter, outside the transaction; only boundLane.run(lane, () => callback(boundAdapter)) remains inside. A store, not a mutable holder, so concurrent transactions do not race.
  • Low, anchor budgetCHILD_TIMEOUT = 120_000 is now the single source for the per-child SIGKILL timer, and BOOT = 2 * CHILD_TIMEOUT + 120_000 (360s), so both children can hit their own limit and probe.output still reaches the assertions.
  • Low, (see below) — now (see "The Auth adapter (ADR-0060)" above).

The changeset was updated in place (no duplicate): the "builds its factory per lane" wording no longer described the code, and it now names the known limit.

Verified: pnpm build clean (tsc across all 11 packages); pnpm lint 0 errors (2 pre-existing warnings, neither in touched files); pnpm manypkg fix and pnpm format clean. Tests — packages/auth 501 passed / 80 skipped (23 files), including the transactions and authFlow conformance suites, adapter-signup-atomicity (1 passed) and auth-node-anchor (3 passed, real child processes); packages/core 1628 passed / 1 skipped; packages/cli 373 passed. Out-of-scope issues (#1239, #1240, #1241, #1222, #1252) left alone.

@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.

Re-review of dce38a40 — effort high

Verdict: APPROVE (posted as a Comment review — the GitHub identity authored the PR, so a formal Approve is blocked). All four findings are addressed, and the unrequested AsyncLocalStorage refactor is sound. Two documentation nits below, neither blocking.

The ALS refactor: no lane can be silently lost

Checked directly, not assumed. A lane cannot escape the scope and silently resolve to the outer unsafe surface. Evidence:

  1. Lane resolution is eager, and the resolved lane is captured by object identity. Every laneOf() call site resolves synchronously, before the first await of the method that uses it: collectionFor/narrow's default parameter is evaluated when narrow(...) is called (the first statement of each ORM method), and deleteMany/incrementOne take const surface = laneOf() at the top. The resolved value is then baked into the object that runs the query — authCollection(lane, …)/authSqlTable(surface, …) (src/adapter/surface.ts:179,190) reach() into that surface's orm/sql lane and return the entity off it. Nothing re-reads the store later.
  2. Lazy results cannot outlive the scope. This was the sharpest risk and it does not materialise: every adapter method awaits to a materialised value before returning — .first(), .all(), .create(), .update(), .deleteAndCount(), .aggregate(), surface.execute(...), surface.query(plan).first(). No LazyQueryResult is handed back across the method boundary, and even if one were, it holds the captured lane (point 1), not a deferred laneOf().
  3. The scope strictly contains better-auth's own routing. transaction(async (lane) => boundLane.run(lane, () => callback(boundAdapter))) wraps better-auth's als.run({ adapter: trx, isTransactionActive: true }, fn) (@better-auth/core/dist/context/transaction.mjs), so every getCurrentAdapter() consumer inside fn is inside boundLane too. ALS context follows async resources created inside run(), so even an un-awaited continuation started in-scope keeps the transaction lane rather than degrading.
  4. The one thing that genuinely runs outside the scope is correct there. runWithTransaction drains pendingHooks (queueAfterTransactionHook) after adapter.transaction(...) resolves. Those run outside better-auth's ALS store too, so getCurrentAdapter(adapter) falls back to the root instance — which this PR pins to () => unsafe. An after-commit hook therefore runs on the outer lane deterministically, which is what an after-commit hook should do.

On the fallback. boundAdapter is reachable only through callback(boundAdapter), i.e. only inside the scope, so ?? unsafe on the bound instance is unreachable in practice rather than a live silent-degradation path. Optional hardening, not asked for: the bound instance could take a laneOf that throws when getStore() is undefined, leaving the fallback only on the root instance's () => unsafe. That would make a future escape loud instead of silent. Fine to leave as is.

The race argument is correct. Because the hoist makes boundAdapter a single instance shared by every concurrent transaction, a mutable holder would be read across interleaved awaits of two concurrent sign-ups and hand one transaction the other's lane. The holder I floated only works if the bound instance is per-transaction — which is exactly what the hoist removes. ALS is the right mechanism for the shape that was asked for.

The hoist is real

Verified against @better-auth/core/dist/db/adapter/factory.mjs:18-35: createAdapterFactory({config, adapter}) returns (options) => {…}, and the cost — config normalisation, getAuthTables(options), customAdapter({…}) — is inside that returned function. It is now called at bound(betterAuthOptions), once per adapter creation, outside any transaction. What remains inside transaction(...) is boundLane.run(lane, () => callback(boundAdapter)) and nothing else. No order-dependence introduced: building the bound instance first has no side effects beyond closure construction (the only module state in the factory is a debug-log transactionId counter), and the shared instance holds no per-call mutable state.

The other three

  • Known limits. Accurate on both mechanism and consequences: the ALS-only swap (runWithTransactionals.run({ adapter: trx }), read back by getCurrentAdapter), the un-swapped AuthContext.adapter, the Dev-database hang on the single connection (ADR-0063) and the pooled-Postgres read that survives rollback. #1252 exists, is open, ready-for-agent, and its title matches the entry. It is a sanctioned Known-limits block and stays within it — it states behaviour and consequence, not design rationale.
  • Anchor budget. BOOT = 2 * CHILD_TIMEOUT + 120_000 (360s) clears both children at their own SIGKILL limit plus headroom for createTestDatabase, and the constants are coupled in one expression so lowering the timer keeps the invariant. The diagnostic survives: a SIGKILLed child still fires close, so run() resolves, probe is assigned, and expect(probe.signal, probe.output).toBe(null) reports the child's output instead of a bare hook timeout.
  • Changeset / cross-reference. silent-moons-gather.md edited in place, no duplicate added (the commit touches four files); "resolves its lane per operation" now matches the shipped code and the known limit is named with the issue link. The packages/auth/CLAUDE.md pointer is now (see "The Auth adapter (ADR-0060)" above).

Nits (non-blocking)

  1. The same stale wording the changeset was corrected for survives in two docblocks. src/adapter/index.ts:138 and packages/auth/CLAUDE.md:174 both still say the option is implemented "by rebinding a second factory instance to the transaction-bound Unsafe surface". After the hoist the second instance is built once and is not rebound — the lane is what varies, through the store. Worth the same one-line correction the changeset got.
  2. The ALS comment above boundLane is four lines, and its second half ("built once per adapter, outside the transaction, instead of on every sign-up and sign-in") is rationale that now lives in the changeset and this thread. The first sentence plus the concurrency warning is the part that stops the obvious wrong edit; the rest could go.

House rules: clean — no any, no casts, no new relative imports in the delta (node:async_hooks is a builtin). Note the test check is still pending at the time of writing.

🤖 Generated with Claude Code

The hoist in dce38a4 builds the bound instance once and travels the
transaction's lane in an AsyncLocalStorage store. The changeset was
corrected for that; the superseded description survived in the
`opensaasAuthAdapter` docblock, `packages/auth/CLAUDE.md` and ADR-0060's
own amendment. Also trims the ALS comment's rationale half, which now
duplicates the changeset.

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

borisno2 commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

Both non-blocking nits from the re-review are addressed in 4cb3763 — docs only, no behaviour, logic or test changes.

  1. Stale "rebinding a second factory instance". Corrected at both sites: the opensaasAuthAdapter docblock (src/adapter/index.ts:138) and packages/auth/CLAUDE.md:174 now say the second factory instance is built once and reads its lane from an AsyncLocalStorage store — the transaction-bound Unsafe surface inside a transaction, the outer surface when there is none.
  2. ALS comment. Trimmed to the first sentence plus the concurrency/fallback warning; the "built once per adapter, outside the transaction, instead of on every sign-up and sign-in" half is gone — it is rationale that lives in the changeset and the review thread.

One more site carried the same stale claim and is corrected in the same pass: ADR-0060's own #1162 amendment, which said "the adapter builds its factory per lane, and the option rebinds a second instance". It now describes the shipped mechanism, and disambiguates the pre-existing "rebinding a second factory instance" consequence below it as naming the second instance rather than a per-transaction rebind — using the same "read the sentence below as" idiom the record already uses, rather than editing an earlier decision in place.

Checked and left alone: the changeset (silent-moons-gather.md) — "resolves its lane per operation" / "runs a second, transaction-bound instance" is accurate as-is, and no duplicate was created — and the Known limits: entry, which was reviewed and is accurate. specs/prisma-8/architecture-spec.md:98 carries a looser "config.transaction rebinds to the transaction-bound Unsafe surface", but that file is untouched by this branch, so it is out of scope here.

Verified: pnpm lint 0 errors (the same 2 pre-existing warnings, neither in touched files), pnpm format clean, pnpm build 11/11 successful, and packages/auth 501 passed / 80 skipped (23 files) — unchanged, as expected for a docs-only change. Out-of-scope issues (#1239, #1240, #1241, #1222, #1252) untouched.

@borisno2
borisno2 merged commit a0ad577 into prisma-8 Sep 6, 2026
6 checks passed
@borisno2
borisno2 deleted the claude/issue-1162-adapter-transactions branch September 6, 2026 23:43
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