Adapter transactions, the auth-flow suite and the plain-Node anchor - #1251
Conversation
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>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🦋 Changeset detectedLatest commit: 4cb3763 The changes in this PR will be included in the next version bump. This PR includes changesets to release 9 packages
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 |
Coverage Report for Core Package Coverage (./packages/core)
File CoverageNo changed files found. |
Coverage Report for UI Package Coverage (./packages/ui)
File CoverageNo changed files found. |
Coverage Report for CLI Package Coverage (./packages/cli)
File CoverageNo changed files found. |
Coverage Report for Auth Package Coverage (./packages/auth)
File CoverageNo changed files found. |
Coverage Report for Storage Package Coverage (./packages/storage)
File CoverageNo changed files found. |
Coverage Report for RAG Package Coverage (./packages/rag)
File CoverageNo changed files found. |
Coverage Report for Storage S3 Package Coverage (./packages/storage-s3)
File CoverageNo changed files found. |
Coverage Report for Storage Vercel Package Coverage (./packages/storage-vercel)
File CoverageNo changed files found. |
| const bound = (lane: UnsafeSurface): AdapterFactory<BetterAuthOptions> => | ||
| factoryOn(lane, (body) => body(lane), false) | ||
|
|
||
| return (betterAuthOptions) => |
There was a problem hiding this comment.
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), andcontext.transactionis 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.tsalready 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.
There was a problem hiding this comment.
Added as a Known limits: entry on opensaasAuthAdapter (and to the mirrored list in packages/auth/CLAUDE.md), stating the mechanism (runWithTransaction → als.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.
|
|
||
| return (betterAuthOptions) => | ||
| factoryOn(unsafe, transaction, (callback) => | ||
| transaction(async (lane) => await callback(bound(lane)(betterAuthOptions))), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
(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.
| through the Auth adapter over the Unsafe surface (see below), marked as | |
| through the Auth adapter over the Unsafe surface (see above), marked as |
There was a problem hiding this comment.
Fixed, made explicit rather than just flipping the direction: (see "The Auth adapter (ADR-0060)" above).
borisno2
left a comment
There was a problem hiding this comment.
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:479—bound(lane)(betterAuthOptions)builds a whole newcreateAdapterFactoryand runsgetAuthTables(options)(full schema derivation across every plugin) inside the open transaction, on everyadapter.transaction()— i.e. every sign-up and sign-in. It lengthens exactly the stalldev-database.tsdescribes as "a connection holding an open transaction holds the whole query queue". Hoistable by havingfactoryOnclose over a() => UnsafeSurfaceinstead of a fixed surface.packages/auth/tests/auth-node-anchor.test.ts:36—BOOT = 240_000has to covercreateTestDatabaseplus tworun()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 beforegenerate/probeare assigned, soprobe.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
- The
prismaAdaptercriterion was met by fixing, not by rewording.packages/auth/**and rootCLAUDE.mdcarry noprismaAdapterliteral; the historical and migration record is intact where it belongs — ADR-0014 line 19 still explains theProxywrappingprismaAdapter(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; thepackages/auth/CLAUDE.mdSession Provider snippet it replaces referencedprismaClientConstructor, which no longer exists. Nothing still-true was deleted to clean a grep. - The
prismasymlink is sound.prisma@8.0.0-rc.12is a runtime dependency of@opensaas/stack-cli, whichpackages/authdeclaresworkspace:*— sopackages/cli/node_modules/prismais guaranteed by pnpm on a clean install and in CI, and a real generated project getsprismaon the same transitive edge. It masks nothing. Two nits, neither blocking: the path ispackageRoot/../cli/node_modules/prisma, a hardcoded monorepo directory rather than the declared edge (node_modules/@opensaas/stack-cli/node_modules/prisma); and sinceprojectDirsits underpackages/auth, addingprismato this package's own devDependencies would let ordinary Node walk-up resolution find it and delete the symlink entirely. - The anchor is genuinely plain Node.
spawn(process.execPath, ['probe.mjs'])— no flags, no loader, no bundler,NODE_OPTIONSuntouched. The probe is.mjsimporting./opensaas.config.tsand./.opensaas/context.ts, so it fails outright if native type stripping or the generated bundle's module graph stops loading;probe.statusis asserted0andprobe.signalnull, and the third test reads the row back over a separatepgclient. It is not running inside the Vitest process. AsyncspawnoverspawnSyncis correctly justified — the PGlite socket server is this process's event loop. - Factory wiring is correct.
bound(lane)passes the transaction lane assurface, soincrementOneand every ORM method on the bound instance use it, andconsumeOnethreads the samelaneinto bothnarrowcalls. No nested transaction is reachable:boundshipstransaction: false, and independentlyrunWithTransactionshort-circuits onstore.isTransactionActivebefore ever callingadapter.transaction.consumeOne's row-count gate survives the identity bracket — thefirst()/deleteAndCount()pair still runs under one transaction (the caller's, when bound) and still answers only onclaimed > 0. - 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 tofalsethe user INSERT commits andexpected 1 to be +0is exactly the failure you'd get. Not tautological. - Dependencies.
pg ^8.22.0/@types/pg ^8.20.0matchcore,cliandexamples/rag-openai-chatbotexactly. devDependencies is right — both are test-only. - 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 unsafe → surface 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>
|
All four findings addressed in dce38a4 — replies posted on each thread. Summary:
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: |
borisno2
left a comment
There was a problem hiding this comment.
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:
- Lane resolution is eager, and the resolved lane is captured by object identity. Every
laneOf()call site resolves synchronously, before the firstawaitof the method that uses it:collectionFor/narrow's default parameter is evaluated whennarrow(...)is called (the first statement of each ORM method), anddeleteMany/incrementOnetakeconst 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'sorm/sqllane and return the entity off it. Nothing re-reads the store later. - 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(). NoLazyQueryResultis handed back across the method boundary, and even if one were, it holds the captured lane (point 1), not a deferredlaneOf(). - The scope strictly contains better-auth's own routing.
transaction(async (lane) => boundLane.run(lane, () => callback(boundAdapter)))wraps better-auth'sals.run({ adapter: trx, isTransactionActive: true }, fn)(@better-auth/core/dist/context/transaction.mjs), so everygetCurrentAdapter()consumer insidefnis insideboundLanetoo. ALS context follows async resources created insiderun(), so even an un-awaited continuation started in-scope keeps the transaction lane rather than degrading. - The one thing that genuinely runs outside the scope is correct there.
runWithTransactiondrainspendingHooks(queueAfterTransactionHook) afteradapter.transaction(...)resolves. Those run outside better-auth's ALS store too, sogetCurrentAdapter(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 (
runWithTransaction→als.run({ adapter: trx }), read back bygetCurrentAdapter), the un-swappedAuthContext.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 forcreateTestDatabase, and the constants are coupled in one expression so lowering the timer keeps the invariant. The diagnostic survives: a SIGKILLed child still firesclose, sorun()resolves,probeis assigned, andexpect(probe.signal, probe.output).toBe(null)reports the child's output instead of a bare hook timeout. - Changeset / cross-reference.
silent-moons-gather.mdedited 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. Thepackages/auth/CLAUDE.mdpointer is now(see "The Auth adapter (ADR-0060)" above).
Nits (non-blocking)
- The same stale wording the changeset was corrected for survives in two docblocks.
src/adapter/index.ts:138andpackages/auth/CLAUDE.md:174both 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. - The ALS comment above
boundLaneis 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>
|
Both non-blocking nits from the re-review are addressed in 4cb3763 — docs only, no behaviour, logic or test changes.
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 ( Verified: |
Implements #1162. Part of #1126. Targets
prisma-8, notmain.Builds on #1161 (ec2264e): the adapter, its two lanes, and the internal
transactionbracketconsumeOnealready used are unchanged in shape.The factory transaction option
opensaasAuthAdapternow builds its factory through a localfactoryOn(surface, bracket, factoryTransaction), so the same adapter body can be bound to any lane:config.transactionis 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.transactionis implemented" bullet true.transaction: falseto the factory and bracketsconsumeOneas the identity on the lane it already holds. Postgres has no nested transaction to open, and better-auth never callstransactionon the adapter it hands a callback (DBTransactionAdapteromits it).ADR-0042's rule applies unchanged: no isolation level is selectable, and auth transactions run at Read Committed.
Tests
tests/adapter-conformance.test.tsgainstransactionsTestSuite()andauthFlowTestSuite(). 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 realauth.api.signUpEmailagainst a realbetterAuthinstance over the adapter, with acheck (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 tofalsefails it withexpected 1 to be +0on 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, notexamples/starter-auth. #1138 deletedscripts/node-build-create-user.mjsande2e/starter-auth/04-node-build.spec.tsrather 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 fixtureopensaas.config.tscarrying onlyauthPlugin, generated by the real CLI into a scratch tree inside this package, then a probe run underprocess.execPathwith no flags, no loader, no bundler that callscreateAuth(config, rawOpensaasContext)andsignUpEmail. The row is then read back overpg.Two things it does differently from
packages/cli/tests/bundle-node-load.test.ts, the nearest prior art:spawn, notspawnSync. 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..opensaas/dev-db.jsonand passes noDATABASE_URL— only that branch makes the generated bundle bind the single connectionpglite-socketneeds, and injecting the variable is exactly what ADR-0063 forbids. Under theDATABASE_URLescape 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
@prisma/clientpeer was already absent frompackages/auth/package.json; nothing was removed.@better-auth/test-utilswas already a dev dependency.pg/@types/pgare 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.prismaAdapterliteral remains anywhere underpackages/author in the rootCLAUDE.md.packages/auth/CLAUDE.mdgains an "The Auth adapter (ADR-0060)" section and its staleprismaClientConstructorsession-provider snippet is corrected; the root guidance's auth "How it works" list names the adapter and its transaction behaviour.config.transactionis 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
uuid7pin overriding app-leveldb.idField), #1240 (generateIdrefused by key presence), #1241 (codecForcoverage for a mapped increment column) and #1222 (deriveAuthListsFK 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.DATABASE_URLescape 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