mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-19 11:24:57 -05:00
fix(kysely-adapter): report native transaction support for auto-detected dialects (#10622)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user