From ecd83daa01ec482d31667019737cb6697f03da0b Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Mon, 3 Aug 2026 12:52:06 -0400 Subject: [PATCH] fix(kysely-adapter): report native transaction support for auto-detected dialects (#10622) --- ...cookie-cache-transaction-scoped-adapter.md | 5 ++ .../kysely-auto-detect-native-transactions.md | 7 ++ packages/better-auth/src/context/init.test.ts | 11 +++ .../better-auth/src/plugins/jwt/adapter.ts | 11 ++- .../organization/organization-hook.test.ts | 40 ++++----- packages/kysely-adapter/src/dialect.test.ts | 86 +++++++++++++++++++ packages/kysely-adapter/src/dialect.ts | 11 ++- 7 files changed, 144 insertions(+), 27 deletions(-) create mode 100644 .changeset/jwt-cookie-cache-transaction-scoped-adapter.md create mode 100644 .changeset/kysely-auto-detect-native-transactions.md create mode 100644 packages/kysely-adapter/src/dialect.test.ts diff --git a/.changeset/jwt-cookie-cache-transaction-scoped-adapter.md b/.changeset/jwt-cookie-cache-transaction-scoped-adapter.md new file mode 100644 index 0000000000..fbc1a4013f --- /dev/null +++ b/.changeset/jwt-cookie-cache-transaction-scoped-adapter.md @@ -0,0 +1,5 @@ +--- +"better-auth": patch +--- + +Sign-up no longer deadlocks when session cookie caching uses the JWT strategy on a single-connection SQLite database with native transactions enabled. JWKS key lookups and creation now resolve the transaction-scoped adapter instead of always querying the root connection, so minting a signing key during sign-up joins the surrounding transaction instead of racing it for the only available connection. On multi-connection databases (Postgres, MySQL) this also fixes a silent atomicity gap where a JWKS key created mid-transaction could commit independently of the transaction it was minted in. diff --git a/.changeset/kysely-auto-detect-native-transactions.md b/.changeset/kysely-auto-detect-native-transactions.md new file mode 100644 index 0000000000..14d87e07b1 --- /dev/null +++ b/.changeset/kysely-auto-detect-native-transactions.md @@ -0,0 +1,7 @@ +--- +"@better-auth/kysely-adapter": minor +--- + +Raw database instances (better-sqlite3, `node:sqlite`, `bun:sqlite`, `mysql2`, `pg`) passed directly as `database` now get native adapter transactions automatically, matching the behavior of the explicit `{ db }`/`{ dialect }` config shapes. This unblocks plugins that require native transactions (such as `@better-auth/scim`) when the database is provided in the quickstart `database: new Database(...)` shape. + +Cloudflare D1 still reports no native transaction support, since D1 has no interactive transactions. diff --git a/packages/better-auth/src/context/init.test.ts b/packages/better-auth/src/context/init.test.ts index 4ec2563f51..3ea8bd99d5 100644 --- a/packages/better-auth/src/context/init.test.ts +++ b/packages/better-auth/src/context/init.test.ts @@ -56,4 +56,15 @@ describe("init (with Kysely)", () => { `Invalid base URL: ws://localhost:6969. URL must include 'http://' or 'https://'`, ); }); + + it("should support native adapter transactions when given a raw database instance", async () => { + const res = await init({ + baseURL: "http://localhost:3000", + database, + }); + + expect(typeof res.adapter.options?.adapterConfig?.transaction).toBe( + "function", + ); + }); }); diff --git a/packages/better-auth/src/plugins/jwt/adapter.ts b/packages/better-auth/src/plugins/jwt/adapter.ts index 9b6e11d6fa..c7832942a0 100644 --- a/packages/better-auth/src/plugins/jwt/adapter.ts +++ b/packages/better-auth/src/plugins/jwt/adapter.ts @@ -2,11 +2,12 @@ import type { BetterAuthOptions, GenericEndpointContext, } from "@better-auth/core"; +import { getCurrentAdapter } from "@better-auth/core/context"; import type { DBAdapter } from "@better-auth/core/db/adapter"; import type { Jwk, JwtOptions } from "./types"; export const getJwksAdapter = ( - adapter: DBAdapter, + baseAdapter: DBAdapter, options?: JwtOptions, ) => { return { @@ -14,6 +15,7 @@ export const getJwksAdapter = ( if (options?.adapter?.getJwks) { return await options.adapter.getJwks(ctx); } + const adapter = await getCurrentAdapter(baseAdapter); return await adapter.findMany({ model: "jwks", }); @@ -30,6 +32,7 @@ export const getJwksAdapter = ( ?.filter(isLive) .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0]; } + const adapter = await getCurrentAdapter(baseAdapter); const keys = await adapter.findMany({ model: "jwks", }); @@ -50,6 +53,7 @@ export const getJwksAdapter = ( const keys = await options.adapter.getJwks(ctx); return keys?.find((k) => k.id === id); } + const adapter = await getCurrentAdapter(baseAdapter); return ( (await adapter.findOne({ model: "jwks", @@ -78,7 +82,9 @@ export const getJwksAdapter = ( ) => { const candidates = options?.adapter?.getJwks ? await options.adapter.getJwks(ctx) - : await adapter.findMany({ model: "jwks" }); + : await (await getCurrentAdapter(baseAdapter)).findMany({ + model: "jwks", + }); if (!candidates) return undefined; const configAlg = options?.jwks?.keyPairConfig?.alg ?? "EdDSA"; const now = new Date(); @@ -91,6 +97,7 @@ export const getJwksAdapter = ( if (options?.adapter?.createJwk) { return await options.adapter.createJwk(webKey, ctx); } + const adapter = await getCurrentAdapter(baseAdapter); const jwk = await adapter.create, Jwk>({ model: "jwks", data: { diff --git a/packages/better-auth/src/plugins/organization/organization-hook.test.ts b/packages/better-auth/src/plugins/organization/organization-hook.test.ts index 279fc70772..1625eb70f1 100644 --- a/packages/better-auth/src/plugins/organization/organization-hook.test.ts +++ b/packages/better-auth/src/plugins/organization/organization-hook.test.ts @@ -92,11 +92,8 @@ describe("organization creation in database hooks", async () => { }); }); - it("should handle errors gracefully when organization creation fails in hook", async ({ - skip, - }) => { + it("keeps the committed user and surfaces a post-commit hook failure as the sign-up response", async () => { let firstUserCreated = false; - let errorOnSecondUser: any = null; const { auth, client, db } = await getTestInstance({ plugins: [organization()], @@ -125,12 +122,6 @@ describe("organization creation in database hooks", async () => { }, }); - if (!db.options?.adapterConfig.transaction) { - skip( - "Skipping since transactions are enabled and will rollback automatically", - ); - } - // First user should succeed const result1 = await client.signUp.email({ email: "user1-hook@example.com", @@ -141,18 +132,18 @@ describe("organization creation in database hooks", async () => { expect(result1.data?.user?.email).toBe("user1-hook@example.com"); expect(firstUserCreated).toBe(true); - // Second user should fail due to duplicate org slug - try { - await client.signUp.email({ - email: "user2-hook@example.com", - password: "password123", - name: "User 2", - }); - } catch (error) { - errorOnSecondUser = error; - } - - expect(errorOnSecondUser).toBeDefined(); + // The user.create.after hook is queued until after the sign-up + // transaction commits, so the second user's row is already durable by + // the time its hook's duplicate-slug org creation fails. That failure + // re-throws out of runWithTransaction and becomes the sign-up + // response itself rather than rolling anything back. + const result2 = await client.signUp.email({ + email: "user2-hook@example.com", + password: "password123", + name: "User 2", + }); + expect(result2.data).toBeNull(); + expect(result2.error?.code).toBe("ORGANIZATION_ALREADY_EXISTS"); // Verify only one organization with our test slug was created const orgs = await db.findMany({ @@ -166,7 +157,8 @@ describe("organization creation in database hooks", async () => { }); expect(orgs).toHaveLength(1); - // Verify only the first user exists (transaction should have rolled back for second user) + // The second user's row committed before its hook ran, so it exists + // despite the hook's later failure. const users = await db.findMany({ model: "user", where: [ @@ -176,7 +168,7 @@ describe("organization creation in database hooks", async () => { }, ], }); - expect(users).toHaveLength(0); + expect(users).toHaveLength(1); }); it("should work with multiple async operations in the hook", async () => { diff --git a/packages/kysely-adapter/src/dialect.test.ts b/packages/kysely-adapter/src/dialect.test.ts new file mode 100644 index 0000000000..106822ad36 --- /dev/null +++ b/packages/kysely-adapter/src/dialect.test.ts @@ -0,0 +1,86 @@ +import { DatabaseSync } from "node:sqlite"; +import type { D1Database } from "@cloudflare/workers-types"; +import type { + DatabaseIntrospector, + Dialect, + DialectAdapter, + Driver, + Kysely as KyselyInstance, + QueryCompiler, +} from "kysely"; +import { describe, expect, it } from "vitest"; +import { createKyselyAdapter } from "./dialect"; + +class StubDriver implements Driver { + async init(): Promise {} + async acquireConnection(): Promise { + throw new Error("not implemented"); + } + async beginTransaction(): Promise {} + async commitTransaction(): Promise {} + async rollbackTransaction(): Promise {} + async releaseConnection(): Promise {} + async destroy(): Promise {} +} + +class UnknownDialect implements Dialect { + createDriver(): Driver { + return new StubDriver(); + } + createQueryCompiler(): QueryCompiler { + return {} as QueryCompiler; + } + createAdapter(): DialectAdapter { + return {} as DialectAdapter; + } + createIntrospector(): DatabaseIntrospector { + return {} as DatabaseIntrospector; + } +} + +function fakeD1Database() { + return { + batch: () => Promise.resolve([]), + exec: () => Promise.resolve({}), + prepare: () => ({}), + } as unknown as D1Database; +} + +describe("createKyselyAdapter transaction detection", () => { + it("reports native transaction support for a raw node:sqlite database", async () => { + const sqlite = new DatabaseSync(":memory:"); + const { transaction, databaseType } = await createKyselyAdapter({ + database: sqlite, + }); + expect(transaction).toBe(true); + expect(databaseType).toBe("sqlite"); + }); + + it("does not report native transaction support for a Cloudflare D1 database", async () => { + const { transaction, databaseType } = await createKyselyAdapter({ + database: fakeD1Database(), + }); + expect(transaction).toBe(false); + expect(databaseType).toBe("sqlite"); + }); + + it("leaves transaction support unspecified for a caller-supplied dialect of unknown capability", async () => { + const { transaction, databaseType } = await createKyselyAdapter({ + database: new UnknownDialect(), + }); + expect(transaction).toBeUndefined(); + expect(databaseType).toBeNull(); + }); + + it("still honors an explicit transaction: false override on a { db } config", async () => { + const fakeKysely = {} as unknown as KyselyInstance; + const { transaction } = await createKyselyAdapter({ + database: { + db: fakeKysely, + type: "sqlite", + transaction: false, + }, + }); + expect(transaction).toBe(false); + }); +}); diff --git a/packages/kysely-adapter/src/dialect.ts b/packages/kysely-adapter/src/dialect.ts index 489757b598..ff7fccb68f 100644 --- a/packages/kysely-adapter/src/dialect.ts +++ b/packages/kysely-adapter/src/dialect.ts @@ -83,10 +83,12 @@ export const createKyselyAdapter = async (config: BetterAuthOptions) => { } let dialect: Dialect | undefined = undefined; + let transaction: boolean | undefined = undefined; const databaseType = getKyselyDatabaseType(db); if ("createDriver" in db) { + // Caller-supplied dialects have unverified transaction capability; leave undefined. dialect = db; } @@ -94,17 +96,20 @@ export const createKyselyAdapter = async (config: BetterAuthOptions) => { dialect = new SqliteDialect({ database: db, }); + transaction = true; } if ("getConnection" in db) { // @ts-expect-error - mysql2/promise dialect = new MysqlDialect(db); + transaction = true; } if ("connect" in db) { dialect = new PostgresDialect({ pool: db, }); + transaction = true; } if ("fileControl" in db) { @@ -112,6 +117,7 @@ export const createKyselyAdapter = async (config: BetterAuthOptions) => { dialect = new BunSqliteDialect({ database: db, }); + transaction = true; } if ("createSession" in db) { @@ -141,6 +147,7 @@ export const createKyselyAdapter = async (config: BetterAuthOptions) => { dialect = new NodeSqliteDialect({ database: db, }); + transaction = true; } } @@ -150,11 +157,13 @@ export const createKyselyAdapter = async (config: BetterAuthOptions) => { dialect = new D1SqliteDialect({ database: db, }); + // D1 has no interactive transactions; only its batch() API. + transaction = false; } return { kysely: dialect ? new Kysely({ dialect }) : null, databaseType, - transaction: undefined, + transaction, }; };