Skip to content

Commit 22c39b9

Browse files
himself65jslno
andauthored
feat: database transaction support (#4414)
Co-authored-by: Joel Solano <solano.joel@gmx.de>
1 parent 61b6a87 commit 22c39b9

File tree

16 files changed

+647
-228
lines changed

16 files changed

+647
-228
lines changed

docs/content/docs/adapters/mongo.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const client = new MongoClient("mongodb://localhost:27017/database");
2020
const db = client.db();
2121

2222
export const auth = betterAuth({
23-
database: mongodbAdapter(db),
23+
database: mongodbAdapter(db, { client }),
2424
});
2525
```
2626

docs/content/docs/guides/create-a-db-adapter.mdx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,16 @@ const adapter = myAdapter({
452452
});
453453
```
454454

455+
### `transaction`
456+
457+
Whether the adapter supports transactions. If `false`, operations run sequentially; otherwise provide a function that executes a callback with a `TransactionAdapter`.
458+
459+
<Callout type="warn">
460+
If your database does not support transactions, the error handling and rollback
461+
will not be as robust. We recommend using a database that supports transactions
462+
for better data integrity.
463+
</Callout>
464+
455465
### `debugLogs`
456466

457467
Used to enable debug logs for the adapter. You can pass in a boolean, or an object with the following keys: `create`, `update`, `updateMany`, `findOne`, `findMany`, `delete`, `deleteMany`, `count`.

packages/better-auth/src/__snapshots__/init.test.ts.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@ exports[`init > should match config 1`] = `
2020
"supportsDates": false,
2121
"supportsJSON": false,
2222
"supportsNumericIds": true,
23+
"transaction": [Function],
2324
"usePlural": undefined,
2425
},
2526
"debugLogs": false,
2627
"type": "sqlite",
2728
},
29+
"transaction": [Function],
2830
"update": [Function],
2931
"updateMany": [Function],
3032
},

packages/better-auth/src/adapters/create-adapter/index.ts

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import { safeJSONParse } from "../../utils/json";
22
import { withApplyDefault } from "../../adapters/utils";
33
import { getAuthTables } from "../../db/get-tables";
4-
import type { Adapter, BetterAuthOptions, Where } from "../../types";
4+
import type {
5+
Adapter,
6+
BetterAuthOptions,
7+
TransactionAdapter,
8+
Where,
9+
} from "../../types";
510
import { generateId as defaultGenerateId, logger } from "../../utils";
611
import type {
7-
AdapterConfig,
12+
CreateAdapterOptions,
813
AdapterTestDebugLogs,
914
CleanedWhere,
10-
CreateCustomAdapter,
1115
} from "./types";
1216
import type { FieldAttribute } from "../../db";
1317
export * from "./types";
@@ -45,14 +49,13 @@ const colors = {
4549
},
4650
};
4751

52+
const createAsIsTransaction =
53+
(adapter: Adapter) =>
54+
<R>(fn: (trx: TransactionAdapter) => Promise<R>) =>
55+
fn(adapter);
56+
4857
export const createAdapter =
49-
({
50-
adapter,
51-
config: cfg,
52-
}: {
53-
config: AdapterConfig;
54-
adapter: CreateCustomAdapter;
55-
}) =>
58+
({ adapter: customAdapter, config: cfg }: CreateAdapterOptions) =>
5659
(options: BetterAuthOptions): Adapter => {
5760
const config = {
5861
...cfg,
@@ -315,7 +318,7 @@ export const createAdapter =
315318
return fields[defaultFieldName];
316319
};
317320

318-
const adapterInstance = adapter({
321+
const adapterInstance = customAdapter({
319322
options,
320323
schema,
321324
debugLog,
@@ -560,7 +563,24 @@ export const createAdapter =
560563
}) as any;
561564
};
562565

563-
return {
566+
let lazyLoadTransaction: Adapter["transaction"] | null = null;
567+
const adapter: Adapter = {
568+
transaction: async (cb) => {
569+
if (!lazyLoadTransaction) {
570+
if (!config.transaction) {
571+
logger.warn(
572+
`[${config.adapterName}] - Transactions are not supported. Executing operations sequentially.`,
573+
);
574+
lazyLoadTransaction = createAsIsTransaction(adapter);
575+
} else {
576+
logger.debug(
577+
`[${config.adapterName}] - Using provided transaction implementation.`,
578+
);
579+
lazyLoadTransaction = config.transaction;
580+
}
581+
}
582+
return lazyLoadTransaction(cb);
583+
},
564584
create: async <T extends Record<string, any>, R = T>({
565585
data: unsafeData,
566586
model: unsafeModel,
@@ -1016,6 +1036,7 @@ export const createAdapter =
10161036
}
10171037
: {}),
10181038
};
1039+
return adapter;
10191040
};
10201041

10211042
function formatTransactionId(transactionId: number) {

packages/better-auth/src/adapters/create-adapter/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { BetterAuthDbSchema } from "../../db/get-tables";
33
import type {
44
AdapterSchemaCreation,
55
BetterAuthOptions,
6+
TransactionAdapter,
67
Where,
78
} from "../../types";
89
import type { Prettify } from "../../types/helper";
@@ -32,6 +33,11 @@ export type AdapterDebugLogs =
3233
isRunningAdapterTests: boolean;
3334
};
3435

36+
export type CreateAdapterOptions = {
37+
config: AdapterConfig;
38+
adapter: CreateCustomAdapter;
39+
};
40+
3541
export interface AdapterConfig {
3642
/**
3743
* Use plural table names.
@@ -89,6 +95,15 @@ export interface AdapterConfig {
8995
* @default true
9096
*/
9197
supportsBooleans?: boolean;
98+
/**
99+
* Execute multiple operations in a transaction.
100+
*
101+
* If the database doesn't support transactions, set this to `false` and operations will be executed sequentially.
102+
*
103+
*/
104+
transaction?:
105+
| false
106+
| (<R>(callback: (trx: TransactionAdapter) => Promise<R>) => Promise<R>);
92107
/**
93108
* Disable id generation for the `create` method.
94109
*

packages/better-auth/src/adapters/drizzle-adapter/drizzle-adapter.ts

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,13 @@ import {
1717
SQL,
1818
} from "drizzle-orm";
1919
import { BetterAuthError } from "../../error";
20-
import type { Where } from "../../types";
21-
import { createAdapter, type AdapterDebugLogs } from "../create-adapter";
20+
import type { Adapter, BetterAuthOptions, Where } from "../../types";
21+
import {
22+
createAdapter,
23+
type AdapterDebugLogs,
24+
type CreateAdapterOptions,
25+
type CreateCustomAdapter,
26+
} from "../create-adapter";
2227

2328
export interface DB {
2429
[key: string]: any;
@@ -52,17 +57,21 @@ export interface DrizzleAdapterConfig {
5257
* @default false
5358
*/
5459
camelCase?: boolean;
60+
/**
61+
* Whether to execute multiple operations in a transaction.
62+
*
63+
* If the database doesn't support transactions,
64+
* set this to `false` and operations will be executed sequentially.
65+
* @default true
66+
*/
67+
transaction?: boolean;
5568
}
5669

57-
export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) =>
58-
createAdapter({
59-
config: {
60-
adapterId: "drizzle",
61-
adapterName: "Drizzle Adapter",
62-
usePlural: config.usePlural ?? false,
63-
debugLogs: config.debugLogs ?? false,
64-
},
65-
adapter: ({ getFieldName, debugLog }) => {
70+
export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => {
71+
let lazyOptions: BetterAuthOptions | null = null;
72+
const createCustomAdapter =
73+
(db: DB): CreateCustomAdapter =>
74+
({ getFieldName, debugLog }) => {
6675
function getSchema(model: string) {
6776
const schema = config.schema || db._.fullSchema;
6877
if (!schema) {
@@ -343,5 +352,31 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) =>
343352
},
344353
options: config,
345354
};
355+
};
356+
let adapterOptions: CreateAdapterOptions | null = null;
357+
adapterOptions = {
358+
config: {
359+
adapterId: "drizzle",
360+
adapterName: "Drizzle Adapter",
361+
usePlural: config.usePlural ?? false,
362+
debugLogs: config.debugLogs ?? false,
363+
transaction:
364+
(config.transaction ?? true)
365+
? (cb) =>
366+
db.transaction((tx: DB) => {
367+
const adapter = createAdapter({
368+
config: adapterOptions!.config,
369+
adapter: createCustomAdapter(tx),
370+
})(lazyOptions!);
371+
return cb(adapter);
372+
})
373+
: false,
346374
},
347-
});
375+
adapter: createCustomAdapter(db),
376+
};
377+
const adapter = createAdapter(adapterOptions);
378+
return (options: BetterAuthOptions): Adapter => {
379+
lazyOptions = options;
380+
return adapter(options);
381+
};
382+
};

packages/better-auth/src/adapters/kysely-adapter/kysely-adapter.ts

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1-
import { createAdapter, type AdapterDebugLogs } from "../create-adapter";
2-
import type { Where } from "../../types";
1+
import {
2+
createAdapter,
3+
type AdapterDebugLogs,
4+
type CreateCustomAdapter,
5+
type CreateAdapterOptions,
6+
} from "../create-adapter";
7+
import type { Adapter, BetterAuthOptions, Where } from "../../types";
38
import type { KyselyDatabaseType } from "./types";
49
import type { InsertQueryBuilder, Kysely, UpdateQueryBuilder } from "kysely";
510

@@ -20,26 +25,23 @@ interface KyselyAdapterConfig {
2025
* @default false
2126
*/
2227
usePlural?: boolean;
28+
/**
29+
* Whether to execute multiple operations in a transaction.
30+
*
31+
* If the database doesn't support transactions,
32+
* set this to `false` and operations will be executed sequentially.
33+
* @default true
34+
*/
35+
transaction?: boolean;
2336
}
2437

25-
export const kyselyAdapter = (db: Kysely<any>, config?: KyselyAdapterConfig) =>
26-
createAdapter({
27-
config: {
28-
adapterId: "kysely",
29-
adapterName: "Kysely Adapter",
30-
usePlural: config?.usePlural,
31-
debugLogs: config?.debugLogs,
32-
supportsBooleans:
33-
config?.type === "sqlite" || config?.type === "mssql" || !config?.type
34-
? false
35-
: true,
36-
supportsDates:
37-
config?.type === "sqlite" || config?.type === "mssql" || !config?.type
38-
? false
39-
: true,
40-
supportsJSON: false,
41-
},
42-
adapter: ({ getFieldName, schema }) => {
38+
export const kyselyAdapter = (
39+
db: Kysely<any>,
40+
config?: KyselyAdapterConfig,
41+
) => {
42+
let lazyOptions: BetterAuthOptions | null = null;
43+
const createCustomAdapter = (db: Kysely<any>): CreateCustomAdapter => {
44+
return ({ getFieldName, schema }) => {
4345
const withReturning = async (
4446
values: Record<string, any>,
4547
builder:
@@ -315,5 +317,43 @@ export const kyselyAdapter = (db: Kysely<any>, config?: KyselyAdapterConfig) =>
315317
},
316318
options: config,
317319
};
320+
};
321+
};
322+
let adapterOptions: CreateAdapterOptions | null = null;
323+
adapterOptions = {
324+
config: {
325+
adapterId: "kysely",
326+
adapterName: "Kysely Adapter",
327+
usePlural: config?.usePlural,
328+
debugLogs: config?.debugLogs,
329+
supportsBooleans:
330+
config?.type === "sqlite" || config?.type === "mssql" || !config?.type
331+
? false
332+
: true,
333+
supportsDates:
334+
config?.type === "sqlite" || config?.type === "mssql" || !config?.type
335+
? false
336+
: true,
337+
supportsJSON: false,
338+
transaction:
339+
(config?.transaction ?? true)
340+
? (cb) =>
341+
db.transaction().execute((trx) => {
342+
const adapter = createAdapter({
343+
config: adapterOptions!.config,
344+
adapter: createCustomAdapter(trx),
345+
})(lazyOptions!);
346+
return cb(adapter);
347+
})
348+
: false,
318349
},
319-
});
350+
adapter: createCustomAdapter(db),
351+
};
352+
353+
const adapter = createAdapter(adapterOptions);
354+
355+
return (options: BetterAuthOptions): Adapter => {
356+
lazyOptions = options;
357+
return adapter(options);
358+
};
359+
};

packages/better-auth/src/adapters/memory-adapter/adapter.memory.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ describe("adapter test", async () => {
2424
...customOptions,
2525
});
2626
},
27+
disableTests: {
28+
SHOULD_ROLLBACK_FAILING_TRANSACTION: true,
29+
SHOULD_RETURN_TRANSACTION_RESULT: true,
30+
},
2731
});
2832
});
2933

packages/better-auth/src/adapters/memory-adapter/memory-adapter.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
type AdapterDebugLogs,
55
type CleanedWhere,
66
} from "../create-adapter";
7+
import type { BetterAuthOptions } from "../../types";
78

89
export interface MemoryDB {
910
[key: string]: any[];
@@ -13,8 +14,9 @@ export interface MemoryAdapterConfig {
1314
debugLogs?: AdapterDebugLogs;
1415
}
1516

16-
export const memoryAdapter = (db: MemoryDB, config?: MemoryAdapterConfig) =>
17-
createAdapter({
17+
export const memoryAdapter = (db: MemoryDB, config?: MemoryAdapterConfig) => {
18+
let lazyOptions: BetterAuthOptions | null = null;
19+
let adapterCreator = createAdapter({
1820
config: {
1921
adapterId: "memory",
2022
adapterName: "Memory Adapter",
@@ -30,6 +32,18 @@ export const memoryAdapter = (db: MemoryDB, config?: MemoryAdapterConfig) =>
3032
}
3133
return props.data;
3234
},
35+
transaction: async (cb) => {
36+
let clone = structuredClone(db);
37+
try {
38+
return cb(adapterCreator(lazyOptions!));
39+
} catch {
40+
// Rollback changes
41+
Object.keys(db).forEach((key) => {
42+
db[key] = clone[key];
43+
});
44+
throw new Error("Transaction failed, rolling back changes");
45+
}
46+
},
3347
},
3448
adapter: ({ getFieldName, options, debugLog }) => {
3549
function convertWhereClause(where: CleanedWhere[], model: string) {
@@ -147,3 +161,8 @@ export const memoryAdapter = (db: MemoryDB, config?: MemoryAdapterConfig) =>
147161
};
148162
},
149163
});
164+
return (options: BetterAuthOptions) => {
165+
lazyOptions = options;
166+
return adapterCreator(options);
167+
};
168+
};

0 commit comments

Comments
 (0)