Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions .changeset/silent-moons-gather.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,6 @@ commit or roll back as one — a failing account write now leaves no user row.
That instance ships the option off and brackets `consumeOne` on the lane it
already holds.

One known limit comes with it, stated on `opensaasAuthAdapter`: better-auth
swaps the transaction-bound adapter in only through its AsyncLocalStorage
store, so a `databaseHooks` `before` hook that queries `context.adapter`
directly runs outside the transaction
([#1252](https://github.com/OpenSaasAU/stack/issues/1252)).

No isolation level is selectable and auth transactions run at Read Committed
(ADR-0042), which is unchanged.

Expand Down
5 changes: 5 additions & 0 deletions .changeset/silent-otters-hunt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@opensaas/stack-auth': patch
---

Fix a `databaseHooks` hook (e.g. `user.create.before`) reading or writing through `context.context.adapter` during sign-up hanging on the Dev database's single connection, or reading outside the transaction on pooled Postgres. The Auth adapter's root instance now shares the transaction-bound instance's lane for the life of the transaction.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Prisma 8 deliberately drops three constructs the generator emits today: `@default(cuid())`, `@updatedAt`, and implicit many-to-many. None is a rename — each is a behaviour change — and ADR-0040 settled the artifact they have to be expressed against (a TypeScript `defineContract` module) while explicitly leaving what they _become_ open. This record closes that.

> **Amended 2026-09-13 by [authPlugin's `idField` escape hatch](0070-the-auth-plugins-id-strategy-is-config-not-a-hardcoded-pin.md)** ([#1239](https://github.com/OpenSaasAU/stack/issues/1239)). The "Plugin-injected lists pin their own strategy" bullet below is no longer literally true: `authPlugin` no longer hardcodes `uuid7` unconditionally. It resolves the Auth lists' `idField` from an explicit `authPlugin({ idField })`, else the app's own `db.idField` default, else `uuid7` — the same fallback chain any other list gets — and refuses (at config time) a resolution of `'int autoincrement'`, since the Auth adapter treats every id as a string. Read "the pin is `uuid7`" in the sentence below as the _default_, not a hardcoded constant an app-level default cannot reach — that was the defect #1239 reported (it silently defeated `db.idField: 'cuid2'`, the strategy an app adopting a live nanoid/text better-auth install would set to match it). ADR-0070 has the full decision.
> **Amended 2026-09-13 by [authPlugin's `idField` escape hatch](0071-the-auth-plugins-id-strategy-is-config-not-a-hardcoded-pin.md)** ([#1239](https://github.com/OpenSaasAU/stack/issues/1239)). The "Plugin-injected lists pin their own strategy" bullet below is no longer literally true: `authPlugin` no longer hardcodes `uuid7` unconditionally. It resolves the Auth lists' `idField` from an explicit `authPlugin({ idField })`, else the app's own `db.idField` default, else `uuid7` — the same fallback chain any other list gets — and refuses (at config time) a resolution of `'int autoincrement'`, since the Auth adapter treats every id as a string. Read "the pin is `uuid7`" in the sentence below as the _default_, not a hardcoded constant an app-level default cannot reach — that was the defect #1239 reported (it silently defeated `db.idField: 'cuid2'`, the strategy an app adopting a live nanoid/text better-auth install would set to match it). ADR-0071 has the full decision.

## Decisions

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Status: accepted

> **Amended 2026-09-13 by [authPlugin's `idField` escape hatch](0070-the-auth-plugins-id-strategy-is-config-not-a-hardcoded-pin.md)** ([#1239](https://github.com/OpenSaasAU/stack/issues/1239)). The "ORM mints auth ids" bullet below understates the pin: `authPlugin` hardcoded `db.idField: 'uuid7'` on every list it injected, with no escape hatch, which silently defeated an app's own `db.idField` default and blocked adopting a live better-auth install whose `id` columns are already a text strategy (not `uuid7`). The pin is now a resolution — `authPlugin({ idField })`, else the app's own `db.idField` default, else `uuid7`, refusing `'int autoincrement'` — and the adapter's `supportsUUIDs`/`supportsNumericIds` (previously hardcoded `true`/`false`) are derived from that same resolved strategy via `authIdCapabilities`, so they cannot declare a capability the emitted column doesn't have. ADR-0070 has the full decision; ADR-0048's id section carries the matching note.
> **Amended 2026-09-13 by [authPlugin's `idField` escape hatch](0071-the-auth-plugins-id-strategy-is-config-not-a-hardcoded-pin.md)** ([#1239](https://github.com/OpenSaasAU/stack/issues/1239)). The "ORM mints auth ids" bullet below understates the pin: `authPlugin` hardcoded `db.idField: 'uuid7'` on every list it injected, with no escape hatch, which silently defeated an app's own `db.idField` default and blocked adopting a live better-auth install whose `id` columns are already a text strategy (not `uuid7`). The pin is now a resolution — `authPlugin({ idField })`, else the app's own `db.idField` default, else `uuid7`, refusing `'int autoincrement'` — and the adapter's `supportsUUIDs`/`supportsNumericIds` (previously hardcoded `true`/`false`) are derived from that same resolved strategy via `authIdCapabilities`, so they cannot declare a capability the emitted column doesn't have. ADR-0071 has the full decision; ADR-0048's id section carries the matching note.
>
> **Amended 2026-09-07 by [Adapter transactions, the auth-flow suite and the plain-Node anchor](https://github.com/OpenSaasAU/stack/issues/1162)** ([#1162](https://github.com/OpenSaasAU/stack/issues/1162)). Two of the bullets below are now true that were not before. **`config.transaction` is implemented**: the adapter builds a second factory instance once and that instance reads its lane from an `AsyncLocalStorage` store — the transaction-bound Unsafe surface inside a transaction, the outer surface when there is none — so sign-up's user, account and session writes commit or roll back as one; the bound instance ships the option off and brackets `consumeOne` on the lane it already holds, because Postgres has no nested transaction to open. Read the "rebinding a second factory instance to the transaction-bound Unsafe surface" consequence below as naming the second instance, not a per-transaction rebind: the instance is built once, and the lane is what varies. Read the previous amendment's closing sentence — "the adapter ships the factory option off" — as a statement about #1161's interim state, not about the shipped adapter. **The plain-Node anchor is back**, as `packages/auth/tests/auth-node-anchor.test.ts` rather than in `examples/starter-auth`: it generates a fixture project with `authPlugin`, then creates a user through `createAuth` over the natively loaded bundle under the real `node` binary. It could not live in the example, which still does not build (#1178). The conformance suites now include `transactions` and `authFlow`.
>
Expand Down
16 changes: 1 addition & 15 deletions docs/content/reference/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -673,7 +673,7 @@ the plain object a real instance returns synchronously). If your app reads

better-auth talks to the database through a stack-authored adapter built over
`context.unsafe` (ADR-0060), deliberately outside the Access Filter — auth's own
bookkeeping is not application data. The adapter carries five known limits, and
bookkeeping is not application data. The adapter carries four known limits, and
this is the whole set.

**The adapter implements no joins.** `betterAuthOptions.advanced.database.joins`
Expand Down Expand Up @@ -703,20 +703,6 @@ stops two concurrent sign-ins through the same issuer identity from creating two
them. If duplicate accounts would corrupt your linking logic, guard it in your
own code rather than relying on the schema.

**A `databaseHooks` `before` hook runs outside the sign-up transaction.**
better-auth swaps the transaction-bound adapter in only through its
AsyncLocalStorage store, and the `AuthContext` a `databaseHooks` callback
receives still carries the root adapter on the outer lane. So a hook that awaits
`context.adapter.findOne(...)` queries the outer lane while the sign-up
transaction is holding a connection. On the Dev database that is the only
connection, so the hook waits out Prisma's acquire timeout and **sign-up hangs**;
on pooled Postgres the read happens outside the transaction and survives the
rollback the rest of sign-up gets. This is inherited from better-auth's own ALS
routing — its Kysely and Prisma adapters split the same way — and is tracked in
[#1252](https://github.com/OpenSaasAU/stack/issues/1252). It applies to the
[`databaseHooks`](#betterauthoptions) shown above: keep a `before` hook to pure
in-memory work, and do database reads in an `after` hook or outside auth.

## Client Setup

Create a client for reading the session in client components. The pre-built forms
Expand Down
23 changes: 13 additions & 10 deletions packages/auth/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,16 +191,19 @@ refused in `assertNoUnsupportedPassthroughKeys`), no `createSchema` (so
better-auth's CLI is unsupported — the generator emits the contract), and no
error normalisation (the Unsafe surface is excluded, ADR-0042).

One more, newly reachable now that the transaction option is implemented: a
`databaseHooks.<model>.create.before` hook receives the `AuthContext`, whose
`.adapter` better-auth never swaps — only its AsyncLocalStorage store carries
the transaction-bound adapter. A hook that awaits `context.adapter.findOne(...)`
therefore runs on the **outer** lane while the sign-up transaction holds a
connection: on the Dev database that is the only connection (ADR-0063), so
sign-up hangs to the acquire timeout; on pooled Postgres the read happens
outside the transaction and survives its rollback. Inherited from better-auth
(its Kysely and Prisma adapters split the same way);
[#1252](https://github.com/OpenSaasAU/stack/issues/1252) tracks it.
A `databaseHooks.<model>.create.before` hook's second argument is a
`GenericEndpointContext`, whose `.context.adapter` (the `AuthContext`)
better-auth never swaps — only its own AsyncLocalStorage store carries the
transaction-bound adapter (`runWithTransaction` → `getCurrentAdapter`), and
hooks read `context.context.adapter` instead. The adapter closes this itself
([#1252](https://github.com/OpenSaasAU/stack/issues/1252)) rather than relying
on better-auth's routing: the ROOT instance reads its lane from the same
AsyncLocalStorage store the transaction-bound instance does, and both run
inside the same store-setting call for the life of the transaction, so a hook
that awaits `context.context.adapter.findOne(...)` during sign-up reaches the
transaction-bound lane — no hang on the Dev database's single connection, and
no read outside the transaction on pooled Postgres. Outside a transaction the
store is empty and the outer lane answers, unchanged.

Conformance is better-auth's own suites — `@better-auth/test-utils`' normal,
uuid, caseInsensitive, transactions and authFlow — over the Test context in
Expand Down
43 changes: 23 additions & 20 deletions packages/auth/src/adapter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,21 @@ function renameKeys(
* roll back as one. ADR-0042's rule applies unchanged: no isolation level is
* selectable, and auth transactions run at Read Committed.
*
* The ROOT factory instance (what `opensaasAuthAdapter` itself returns, and
* therefore what better-auth's `AuthContext.adapter` is — reached via a
* `databaseHooks` `before`/`after` hook's `GenericEndpointContext.context.adapter`,
* as opposed to the transaction-bound instance that `getCurrentAdapter` hands
* `internalAdapter`) reads its lane from the SAME AsyncLocalStorage store as
* the transaction-bound instance, not a closed-over `unsafe`. Both instances
* run inside the same `boundLane.run(lane, …)` call for the life of the
* transaction — the outer `config.transaction` callback IS that call — so a
* hook awaiting `context.context.adapter.findOne(...)` during sign-up reaches
* the transaction-bound lane exactly like a call through `getCurrentAdapter`
* would, without better-auth's own ALS routing (`runWithTransaction` →
* `getCurrentAdapter`, which the root instance is never registered with)
* needing to change. Outside a transaction `boundLane` holds nothing and the
* outer lane answers, as before. See #1252.
*
* Known limits:
* - **No joins.** `advanced.database.joins` is refused at config time rather
* than left to the factory's silent per-model fallback.
Expand All @@ -173,20 +188,6 @@ function renameKeys(
* database stops two concurrent sign-ins through the same issuer identity
* from creating two accounts; better-auth's own existence check is all that
* stands between them.
* - **A `databaseHooks` `before` hook runs outside the transaction.**
* better-auth swaps the transaction-bound adapter in only through its
* AsyncLocalStorage store (`runWithTransaction` → `als.run({ adapter: trx })`,
* read back by `getCurrentAdapter`). The `AuthContext` those hooks receive
* comes from `getCurrentAuthContext()`, and its `.adapter` is still the root
* instance on the outer lane. So a hook that awaits
* `context.adapter.findOne(...)` queries the outer lane while the sign-up
* transaction holds a connection: on the Dev database that is the only
* connection (ADR-0063), so the hook waits out Prisma's acquire timeout and
* sign-up hangs; on pooled Postgres it reads outside the transaction and
* survives the rollback the rest of sign-up gets. Inherited from
* better-auth's ALS routing — its own Kysely and Prisma adapters split the
* same way — and tracked in
* [#1252](https://github.com/OpenSaasAU/stack/issues/1252).
* - Errors arrive as the driver's own: the Unsafe surface is excluded from the
* stack's error normalisation (ADR-0042).
*/
Expand Down Expand Up @@ -510,7 +511,12 @@ export function opensaasAuthAdapter(
// The lane is the only thing a transaction-bound instance varies, so it
// travels in an AsyncLocalStorage store rather than being closed over:
// concurrent transactions each read their own store, and outside one there is
// none, so the outer lane answers.
// none, so the outer lane answers. The ROOT instance (returned below) reads
// the same store, not a closed-over `unsafe` — that is what closes #1252: a
// `databaseHooks` hook reaches the root instance via better-auth's own
// `AuthContext.adapter`, never the transaction-bound one `getCurrentAdapter`
// hands out, but both instances execute inside the same `boundLane.run` call
// for the duration of the transaction, so both see the same store.
const boundLane = new AsyncLocalStorage<UnsafeSurface>()
const laneOf = (): UnsafeSurface => boundLane.getStore() ?? unsafe

Expand All @@ -522,11 +528,8 @@ export function opensaasAuthAdapter(

return (betterAuthOptions) => {
const boundAdapter = bound(betterAuthOptions)
return factoryOn(
() => unsafe,
transaction,
(callback) =>
transaction(async (lane) => await boundLane.run(lane, () => callback(boundAdapter))),
return factoryOn(laneOf, transaction, (callback) =>
transaction(async (lane) => await boundLane.run(lane, () => callback(boundAdapter))),
)(betterAuthOptions)
}
}
Loading
Loading