fix(kysely-adapter): report native transaction support for auto-detected dialects (#10622)

This commit is contained in:
Gustavo Valverde
2026-08-03 12:52:06 -04:00
committed by GitHub
parent 59c4c832fc
commit ecd83daa01
7 changed files with 144 additions and 27 deletions
@@ -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.
@@ -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.
@@ -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",
);
});
});
@@ -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<BetterAuthOptions>,
baseAdapter: DBAdapter<BetterAuthOptions>,
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<Jwk>({
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<Jwk>({
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<Jwk>({
model: "jwks",
@@ -78,7 +82,9 @@ export const getJwksAdapter = (
) => {
const candidates = options?.adapter?.getJwks
? await options.adapter.getJwks(ctx)
: await adapter.findMany<Jwk>({ model: "jwks" });
: await (await getCurrentAdapter(baseAdapter)).findMany<Jwk>({
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<Omit<Jwk, "id">, Jwk>({
model: "jwks",
data: {
@@ -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 () => {
@@ -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<void> {}
async acquireConnection(): Promise<never> {
throw new Error("not implemented");
}
async beginTransaction(): Promise<void> {}
async commitTransaction(): Promise<void> {}
async rollbackTransaction(): Promise<void> {}
async releaseConnection(): Promise<void> {}
async destroy(): Promise<void> {}
}
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<any>;
const { transaction } = await createKyselyAdapter({
database: {
db: fakeKysely,
type: "sqlite",
transaction: false,
},
});
expect(transaction).toBe(false);
});
});
+10 -1
View File
@@ -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<any>({ dialect }) : null,
databaseType,
transaction: undefined,
transaction,
};
};