@better-auth/mongo-adapter 1.5.0-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+
2
+ > @better-auth/mongo-adapter@1.5.0-beta.9 build /home/runner/work/better-auth/better-auth/packages/mongo-adapter
3
+ > tsdown
4
+
5
+ ℹ tsdown v0.19.0 powered by rolldown v1.0.0-beta.59
6
+ ℹ config file: /home/runner/work/better-auth/better-auth/packages/mongo-adapter/tsdown.config.ts
7
+ ℹ entry: src/index.ts
8
+ ℹ tsconfig: tsconfig.json
9
+ ℹ Build start
10
+ ℹ dist/index.mjs 12.70 kB │ gzip: 3.08 kB
11
+ ℹ dist/index.d.mts  1.20 kB │ gzip: 0.55 kB
12
+ ℹ 2 files, total: 13.90 kB
13
+ ✔ Build complete in 8298ms
package/LICENSE.md ADDED
@@ -0,0 +1,20 @@
1
+ The MIT License (MIT)
2
+ Copyright (c) 2024 - present, Bereket Engida
3
+
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
5
+ this software and associated documentation files (the “Software”), to deal in
6
+ the Software without restriction, including without limitation the rights to
7
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
8
+ the Software, and to permit persons to whom the Software is furnished to do so,
9
+ subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all
12
+ copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
16
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
17
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
18
+ DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
19
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20
+ DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,37 @@
1
+ import { DBAdapter, DBAdapterDebugLogOption } from "@better-auth/core/db/adapter";
2
+ import { Db, MongoClient } from "mongodb";
3
+ import { BetterAuthOptions } from "@better-auth/core";
4
+
5
+ //#region src/mongodb-adapter.d.ts
6
+ interface MongoDBAdapterConfig {
7
+ /**
8
+ * MongoDB client instance
9
+ * If not provided, Database transactions won't be enabled.
10
+ */
11
+ client?: MongoClient | undefined;
12
+ /**
13
+ * Enable debug logs for the adapter
14
+ *
15
+ * @default false
16
+ */
17
+ debugLogs?: DBAdapterDebugLogOption | undefined;
18
+ /**
19
+ * Use plural table names
20
+ *
21
+ * @default false
22
+ */
23
+ usePlural?: boolean | undefined;
24
+ /**
25
+ * Whether to execute multiple operations in a transaction.
26
+ *
27
+ * ⚠️ Important:
28
+ * - Defaults to `true` when a MongoDB client is provided.
29
+ * - If your MongoDB instance does not support transactions
30
+ * (e.g. standalone server without a replica set),
31
+ * you must explicitly set `transaction: false`.
32
+ */
33
+ transaction?: boolean | undefined;
34
+ }
35
+ declare const mongodbAdapter: (db: Db, config?: MongoDBAdapterConfig | undefined) => (options: BetterAuthOptions) => DBAdapter<BetterAuthOptions>;
36
+ //#endregion
37
+ export { MongoDBAdapterConfig, mongodbAdapter };
package/dist/index.mjs ADDED
@@ -0,0 +1,398 @@
1
+ import { createAdapterFactory } from "@better-auth/core/db/adapter";
2
+ import { ObjectId } from "mongodb";
3
+
4
+ //#region src/mongodb-adapter.ts
5
+ var MongoAdapterError = class extends Error {
6
+ constructor(code, message) {
7
+ super(message);
8
+ this.code = code;
9
+ this.name = "MongoAdapterError";
10
+ }
11
+ };
12
+ const mongodbAdapter = (db, config) => {
13
+ let lazyOptions;
14
+ const getCustomIdGenerator = (options) => {
15
+ const generator = options.advanced?.database?.generateId;
16
+ if (typeof generator === "function") return generator;
17
+ };
18
+ const createCustomAdapter = (db$1, session) => ({ getFieldAttributes, getFieldName, schema, getDefaultModelName, options }) => {
19
+ const customIdGen = getCustomIdGenerator(options);
20
+ function serializeID({ field, value, model }) {
21
+ if (customIdGen) return value;
22
+ model = getDefaultModelName(model);
23
+ if (field === "id" || field === "_id" || schema[model].fields[field]?.references?.field === "id") {
24
+ if (value === null || value === void 0) return value;
25
+ if (typeof value !== "string") {
26
+ if (value instanceof ObjectId) return value;
27
+ if (Array.isArray(value)) return value.map((v) => {
28
+ if (v === null || v === void 0) return v;
29
+ if (typeof v === "string") try {
30
+ return new ObjectId(v);
31
+ } catch {
32
+ return v;
33
+ }
34
+ if (v instanceof ObjectId) return v;
35
+ throw new MongoAdapterError("INVALID_ID", "Invalid id value");
36
+ });
37
+ throw new MongoAdapterError("INVALID_ID", "Invalid id value");
38
+ }
39
+ try {
40
+ return new ObjectId(value);
41
+ } catch {
42
+ return value;
43
+ }
44
+ }
45
+ return value;
46
+ }
47
+ function convertWhereClause({ where, model }) {
48
+ if (!where.length) return {};
49
+ const conditions = where.map((w) => {
50
+ const { field: field_, value, operator = "eq", connector = "AND" } = w;
51
+ let condition;
52
+ let field = getFieldName({
53
+ model,
54
+ field: field_
55
+ });
56
+ if (field === "id") field = "_id";
57
+ switch (operator.toLowerCase()) {
58
+ case "eq":
59
+ condition = { [field]: serializeID({
60
+ field,
61
+ value,
62
+ model
63
+ }) };
64
+ break;
65
+ case "in":
66
+ condition = { [field]: { $in: Array.isArray(value) ? value.map((v) => serializeID({
67
+ field,
68
+ value: v,
69
+ model
70
+ })) : [serializeID({
71
+ field,
72
+ value,
73
+ model
74
+ })] } };
75
+ break;
76
+ case "not_in":
77
+ condition = { [field]: { $nin: Array.isArray(value) ? value.map((v) => serializeID({
78
+ field,
79
+ value: v,
80
+ model
81
+ })) : [serializeID({
82
+ field,
83
+ value,
84
+ model
85
+ })] } };
86
+ break;
87
+ case "gt":
88
+ condition = { [field]: { $gt: serializeID({
89
+ field,
90
+ value,
91
+ model
92
+ }) } };
93
+ break;
94
+ case "gte":
95
+ condition = { [field]: { $gte: serializeID({
96
+ field,
97
+ value,
98
+ model
99
+ }) } };
100
+ break;
101
+ case "lt":
102
+ condition = { [field]: { $lt: serializeID({
103
+ field,
104
+ value,
105
+ model
106
+ }) } };
107
+ break;
108
+ case "lte":
109
+ condition = { [field]: { $lte: serializeID({
110
+ field,
111
+ value,
112
+ model
113
+ }) } };
114
+ break;
115
+ case "ne":
116
+ condition = { [field]: { $ne: serializeID({
117
+ field,
118
+ value,
119
+ model
120
+ }) } };
121
+ break;
122
+ case "contains":
123
+ condition = { [field]: { $regex: `.*${escapeForMongoRegex(value)}.*` } };
124
+ break;
125
+ case "starts_with":
126
+ condition = { [field]: { $regex: `^${escapeForMongoRegex(value)}` } };
127
+ break;
128
+ case "ends_with":
129
+ condition = { [field]: { $regex: `${escapeForMongoRegex(value)}$` } };
130
+ break;
131
+ default: throw new MongoAdapterError("UNSUPPORTED_OPERATOR", `Unsupported operator: ${operator}`);
132
+ }
133
+ return {
134
+ condition,
135
+ connector
136
+ };
137
+ });
138
+ if (conditions.length === 1) return conditions[0].condition;
139
+ const andConditions = conditions.filter((c) => c.connector === "AND").map((c) => c.condition);
140
+ const orConditions = conditions.filter((c) => c.connector === "OR").map((c) => c.condition);
141
+ let clause = {};
142
+ if (andConditions.length) clause = {
143
+ ...clause,
144
+ $and: andConditions
145
+ };
146
+ if (orConditions.length) clause = {
147
+ ...clause,
148
+ $or: orConditions
149
+ };
150
+ return clause;
151
+ }
152
+ return {
153
+ async create({ model, data: values }) {
154
+ return {
155
+ _id: (await db$1.collection(model).insertOne(values, { session })).insertedId.toString(),
156
+ ...values
157
+ };
158
+ },
159
+ async findOne({ model, where, select, join }) {
160
+ const pipeline = [where ? { $match: convertWhereClause({
161
+ where,
162
+ model
163
+ }) } : { $match: {} }];
164
+ if (join) for (const [joinedModel, joinConfig] of Object.entries(join)) {
165
+ const localField = getFieldName({
166
+ field: joinConfig.on.from,
167
+ model
168
+ });
169
+ const foreignField = getFieldName({
170
+ field: joinConfig.on.to,
171
+ model: joinedModel
172
+ });
173
+ const localFieldName = localField === "id" ? "_id" : localField;
174
+ const foreignFieldName = foreignField === "id" ? "_id" : foreignField;
175
+ const isUnique = (schema[getDefaultModelName(joinedModel)]?.fields[joinConfig.on.to])?.unique === true;
176
+ const shouldLimit = !isUnique && joinConfig.limit !== void 0;
177
+ const limit = joinConfig.limit ?? options.advanced?.database?.defaultFindManyLimit ?? 100;
178
+ if (shouldLimit && limit > 0) {
179
+ const foreignFieldRef = `$${foreignFieldName}`;
180
+ pipeline.push({ $lookup: {
181
+ from: joinedModel,
182
+ let: { localFieldValue: `$${localFieldName}` },
183
+ pipeline: [{ $match: { $expr: { $eq: [foreignFieldRef, "$$localFieldValue"] } } }, { $limit: limit }],
184
+ as: joinedModel
185
+ } });
186
+ } else pipeline.push({ $lookup: {
187
+ from: joinedModel,
188
+ localField: localFieldName,
189
+ foreignField: foreignFieldName,
190
+ as: joinedModel
191
+ } });
192
+ if (isUnique) pipeline.push({ $unwind: {
193
+ path: `$${joinedModel}`,
194
+ preserveNullAndEmptyArrays: true
195
+ } });
196
+ }
197
+ if (select) {
198
+ const projection = {};
199
+ select.forEach((field) => {
200
+ projection[getFieldName({
201
+ field,
202
+ model
203
+ })] = 1;
204
+ });
205
+ if (join) for (const joinedModel of Object.keys(join)) projection[joinedModel] = 1;
206
+ pipeline.push({ $project: projection });
207
+ }
208
+ pipeline.push({ $limit: 1 });
209
+ const res = await db$1.collection(model).aggregate(pipeline, { session }).toArray();
210
+ if (!res || res.length === 0) return null;
211
+ return res[0];
212
+ },
213
+ async findMany({ model, where, limit, offset, sortBy, join }) {
214
+ const pipeline = [where ? { $match: convertWhereClause({
215
+ where,
216
+ model
217
+ }) } : { $match: {} }];
218
+ if (join) for (const [joinedModel, joinConfig] of Object.entries(join)) {
219
+ const localField = getFieldName({
220
+ field: joinConfig.on.from,
221
+ model
222
+ });
223
+ const foreignField = getFieldName({
224
+ field: joinConfig.on.to,
225
+ model: joinedModel
226
+ });
227
+ const localFieldName = localField === "id" ? "_id" : localField;
228
+ const foreignFieldName = foreignField === "id" ? "_id" : foreignField;
229
+ const isUnique = getFieldAttributes({
230
+ model: joinedModel,
231
+ field: joinConfig.on.to
232
+ })?.unique === true;
233
+ const shouldLimit = joinConfig.relation !== "one-to-one" && joinConfig.limit !== void 0;
234
+ const limit$1 = joinConfig.limit ?? options.advanced?.database?.defaultFindManyLimit ?? 100;
235
+ if (shouldLimit && limit$1 > 0) {
236
+ const foreignFieldRef = `$${foreignFieldName}`;
237
+ pipeline.push({ $lookup: {
238
+ from: joinedModel,
239
+ let: { localFieldValue: `$${localFieldName}` },
240
+ pipeline: [{ $match: { $expr: { $eq: [foreignFieldRef, "$$localFieldValue"] } } }, { $limit: limit$1 }],
241
+ as: joinedModel
242
+ } });
243
+ } else pipeline.push({ $lookup: {
244
+ from: joinedModel,
245
+ localField: localFieldName,
246
+ foreignField: foreignFieldName,
247
+ as: joinedModel
248
+ } });
249
+ if (isUnique) pipeline.push({ $unwind: {
250
+ path: `$${joinedModel}`,
251
+ preserveNullAndEmptyArrays: true
252
+ } });
253
+ }
254
+ if (sortBy) pipeline.push({ $sort: { [getFieldName({
255
+ field: sortBy.field,
256
+ model
257
+ })]: sortBy.direction === "desc" ? -1 : 1 } });
258
+ if (offset) pipeline.push({ $skip: offset });
259
+ if (limit) pipeline.push({ $limit: limit });
260
+ return await db$1.collection(model).aggregate(pipeline, { session }).toArray();
261
+ },
262
+ async count({ model, where }) {
263
+ const pipeline = [where ? { $match: convertWhereClause({
264
+ where,
265
+ model
266
+ }) } : { $match: {} }, { $count: "total" }];
267
+ const res = await db$1.collection(model).aggregate(pipeline, { session }).toArray();
268
+ if (!res || res.length === 0) return 0;
269
+ return res[0]?.total ?? 0;
270
+ },
271
+ async update({ model, where, update: values }) {
272
+ const clause = convertWhereClause({
273
+ where,
274
+ model
275
+ });
276
+ const doc = (await db$1.collection(model).findOneAndUpdate(clause, { $set: values }, {
277
+ session,
278
+ returnDocument: "after",
279
+ includeResultMetadata: true
280
+ }))?.value ?? null;
281
+ if (!doc) return null;
282
+ return doc;
283
+ },
284
+ async updateMany({ model, where, update: values }) {
285
+ const clause = convertWhereClause({
286
+ where,
287
+ model
288
+ });
289
+ return (await db$1.collection(model).updateMany(clause, { $set: values }, { session })).modifiedCount;
290
+ },
291
+ async delete({ model, where }) {
292
+ const clause = convertWhereClause({
293
+ where,
294
+ model
295
+ });
296
+ await db$1.collection(model).deleteOne(clause, { session });
297
+ },
298
+ async deleteMany({ model, where }) {
299
+ const clause = convertWhereClause({
300
+ where,
301
+ model
302
+ });
303
+ return (await db$1.collection(model).deleteMany(clause, { session })).deletedCount;
304
+ }
305
+ };
306
+ };
307
+ let lazyAdapter = null;
308
+ let adapterOptions = null;
309
+ adapterOptions = {
310
+ config: {
311
+ adapterId: "mongodb-adapter",
312
+ adapterName: "MongoDB Adapter",
313
+ usePlural: config?.usePlural ?? false,
314
+ debugLogs: config?.debugLogs ?? false,
315
+ mapKeysTransformInput: { id: "_id" },
316
+ mapKeysTransformOutput: { _id: "id" },
317
+ supportsArrays: true,
318
+ supportsNumericIds: false,
319
+ transaction: config?.client && (config?.transaction ?? true) ? async (cb) => {
320
+ if (!config.client) return cb(lazyAdapter(lazyOptions));
321
+ const session = config.client.startSession();
322
+ try {
323
+ session.startTransaction();
324
+ const result = await cb(createAdapterFactory({
325
+ config: adapterOptions.config,
326
+ adapter: createCustomAdapter(db, session)
327
+ })(lazyOptions));
328
+ await session.commitTransaction();
329
+ return result;
330
+ } catch (err) {
331
+ await session.abortTransaction();
332
+ throw err;
333
+ } finally {
334
+ await session.endSession();
335
+ }
336
+ } : false,
337
+ customTransformInput({ action, data, field, fieldAttributes, schema, model, options }) {
338
+ const customIdGen = getCustomIdGenerator(options);
339
+ if (field === "_id" || fieldAttributes.references?.field === "id") {
340
+ if (customIdGen) return data;
341
+ if (action !== "create") return data;
342
+ if (Array.isArray(data)) return data.map((v) => {
343
+ if (typeof v === "string") try {
344
+ return new ObjectId(v);
345
+ } catch {
346
+ return v;
347
+ }
348
+ return v;
349
+ });
350
+ if (typeof data === "string") try {
351
+ return new ObjectId(data);
352
+ } catch {
353
+ return data;
354
+ }
355
+ if (fieldAttributes?.references?.field === "id" && !fieldAttributes?.required && data === null) return null;
356
+ return new ObjectId();
357
+ }
358
+ return data;
359
+ },
360
+ customTransformOutput({ data, field, fieldAttributes }) {
361
+ if (field === "id" || fieldAttributes.references?.field === "id") {
362
+ if (data instanceof ObjectId) return data.toHexString();
363
+ if (Array.isArray(data)) return data.map((v) => {
364
+ if (v instanceof ObjectId) return v.toHexString();
365
+ return v;
366
+ });
367
+ return data;
368
+ }
369
+ return data;
370
+ },
371
+ customIdGenerator() {
372
+ return new ObjectId().toString();
373
+ }
374
+ },
375
+ adapter: createCustomAdapter(db)
376
+ };
377
+ lazyAdapter = createAdapterFactory(adapterOptions);
378
+ return (options) => {
379
+ lazyOptions = options;
380
+ return lazyAdapter(options);
381
+ };
382
+ };
383
+ /**
384
+ * Safely escape user input for use in a MongoDB regex.
385
+ * This ensures the resulting pattern is treated as literal text,
386
+ * and not as a regex with special syntax.
387
+ *
388
+ * @param input - The input string to escape. Any type that isn't a string will be converted to an empty string.
389
+ * @param maxLength - The maximum length of the input string to escape. Defaults to 256. This is to prevent DOS attacks.
390
+ * @returns The escaped string.
391
+ */
392
+ function escapeForMongoRegex(input, maxLength = 256) {
393
+ if (typeof input !== "string") return "";
394
+ return input.slice(0, maxLength).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
395
+ }
396
+
397
+ //#endregion
398
+ export { mongodbAdapter };
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@better-auth/mongo-adapter",
3
+ "version": "1.5.0-beta.9",
4
+ "description": "Mongo adapter for Better Auth",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/better-auth/better-auth.git",
9
+ "directory": "packages/mongo-adapter"
10
+ },
11
+ "main": "./dist/index.mjs",
12
+ "module": "./dist/index.mjs",
13
+ "types": "./dist/index.d.mts",
14
+ "exports": {
15
+ ".": {
16
+ "dev-source": "./src/index.ts",
17
+ "types": "./dist/index.d.mts",
18
+ "default": "./dist/index.mjs"
19
+ }
20
+ },
21
+ "peerDependencies": {
22
+ "@better-auth/utils": "^0.3.0",
23
+ "mongodb": "^6.0.0 || ^7.0.0",
24
+ "@better-auth/core": "1.5.0-beta.9"
25
+ },
26
+ "devDependencies": {
27
+ "@better-auth/utils": "^0.3.0",
28
+ "mongodb": "^7.0.0",
29
+ "tsdown": "^0.19.0",
30
+ "typescript": "^5.9.3",
31
+ "@better-auth/core": "1.5.0-beta.9"
32
+ },
33
+ "scripts": {
34
+ "build": "tsdown",
35
+ "dev": "tsdown --watch",
36
+ "test": "vitest",
37
+ "typecheck": "tsc --noEmit"
38
+ }
39
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./mongodb-adapter";
@@ -0,0 +1,12 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { mongodbAdapter } from "./mongodb-adapter";
3
+
4
+ describe("mongodb-adapter", () => {
5
+ it("should create mongodb adapter", () => {
6
+ const db = {
7
+ collection: vi.fn(),
8
+ } as any;
9
+ const adapter = mongodbAdapter(db);
10
+ expect(adapter).toBeDefined();
11
+ });
12
+ });
@@ -0,0 +1,701 @@
1
+ import type { BetterAuthOptions } from "@better-auth/core";
2
+ import type {
3
+ AdapterFactoryCustomizeAdapterCreator,
4
+ AdapterFactoryOptions,
5
+ DBAdapter,
6
+ DBAdapterDebugLogOption,
7
+ Where,
8
+ } from "@better-auth/core/db/adapter";
9
+ import { createAdapterFactory } from "@better-auth/core/db/adapter";
10
+ import type { ClientSession, Db, MongoClient } from "mongodb";
11
+ import { ObjectId } from "mongodb";
12
+
13
+ class MongoAdapterError extends Error {
14
+ constructor(
15
+ public code: "INVALID_ID" | "UNSUPPORTED_OPERATOR",
16
+ message: string,
17
+ ) {
18
+ super(message);
19
+ this.name = "MongoAdapterError";
20
+ }
21
+ }
22
+
23
+ export interface MongoDBAdapterConfig {
24
+ /**
25
+ * MongoDB client instance
26
+ * If not provided, Database transactions won't be enabled.
27
+ */
28
+ client?: MongoClient | undefined;
29
+ /**
30
+ * Enable debug logs for the adapter
31
+ *
32
+ * @default false
33
+ */
34
+ debugLogs?: DBAdapterDebugLogOption | undefined;
35
+ /**
36
+ * Use plural table names
37
+ *
38
+ * @default false
39
+ */
40
+ usePlural?: boolean | undefined;
41
+ /**
42
+ * Whether to execute multiple operations in a transaction.
43
+ *
44
+ * ⚠️ Important:
45
+ * - Defaults to `true` when a MongoDB client is provided.
46
+ * - If your MongoDB instance does not support transactions
47
+ * (e.g. standalone server without a replica set),
48
+ * you must explicitly set `transaction: false`.
49
+ */
50
+ transaction?: boolean | undefined;
51
+ }
52
+
53
+ export const mongodbAdapter = (
54
+ db: Db,
55
+ config?: MongoDBAdapterConfig | undefined,
56
+ ) => {
57
+ let lazyOptions: BetterAuthOptions | null;
58
+
59
+ const getCustomIdGenerator = (options: BetterAuthOptions) => {
60
+ const generator = options.advanced?.database?.generateId;
61
+ if (typeof generator === "function") {
62
+ return generator;
63
+ }
64
+ return undefined;
65
+ };
66
+
67
+ const createCustomAdapter =
68
+ (
69
+ db: Db,
70
+ session?: ClientSession | undefined,
71
+ ): AdapterFactoryCustomizeAdapterCreator =>
72
+ ({
73
+ getFieldAttributes,
74
+ getFieldName,
75
+ schema,
76
+ getDefaultModelName,
77
+ options,
78
+ }) => {
79
+ const customIdGen = getCustomIdGenerator(options);
80
+
81
+ function serializeID({
82
+ field,
83
+ value,
84
+ model,
85
+ }: {
86
+ field: string;
87
+ value: any;
88
+ model: string;
89
+ }) {
90
+ if (customIdGen) {
91
+ return value;
92
+ }
93
+ model = getDefaultModelName(model);
94
+ if (
95
+ field === "id" ||
96
+ field === "_id" ||
97
+ schema[model]!.fields[field]?.references?.field === "id"
98
+ ) {
99
+ if (value === null || value === undefined) {
100
+ return value;
101
+ }
102
+ if (typeof value !== "string") {
103
+ if (value instanceof ObjectId) {
104
+ return value;
105
+ }
106
+ if (Array.isArray(value)) {
107
+ return value.map((v) => {
108
+ if (v === null || v === undefined) {
109
+ return v;
110
+ }
111
+ if (typeof v === "string") {
112
+ try {
113
+ return new ObjectId(v);
114
+ } catch {
115
+ return v;
116
+ }
117
+ }
118
+ if (v instanceof ObjectId) {
119
+ return v;
120
+ }
121
+ throw new MongoAdapterError("INVALID_ID", "Invalid id value");
122
+ });
123
+ }
124
+ throw new MongoAdapterError("INVALID_ID", "Invalid id value");
125
+ }
126
+ try {
127
+ return new ObjectId(value);
128
+ } catch {
129
+ return value;
130
+ }
131
+ }
132
+ return value;
133
+ }
134
+
135
+ function convertWhereClause({
136
+ where,
137
+ model,
138
+ }: {
139
+ where: Where[];
140
+ model: string;
141
+ }) {
142
+ if (!where.length) return {};
143
+ const conditions = where.map((w) => {
144
+ const {
145
+ field: field_,
146
+ value,
147
+ operator = "eq",
148
+ connector = "AND",
149
+ } = w;
150
+ let condition: any;
151
+ let field = getFieldName({ model, field: field_ });
152
+ if (field === "id") field = "_id";
153
+ switch (operator.toLowerCase()) {
154
+ case "eq":
155
+ condition = {
156
+ [field]: serializeID({
157
+ field,
158
+ value,
159
+ model,
160
+ }),
161
+ };
162
+ break;
163
+ case "in":
164
+ condition = {
165
+ [field]: {
166
+ $in: Array.isArray(value)
167
+ ? value.map((v) => serializeID({ field, value: v, model }))
168
+ : [serializeID({ field, value, model })],
169
+ },
170
+ };
171
+ break;
172
+ case "not_in":
173
+ condition = {
174
+ [field]: {
175
+ $nin: Array.isArray(value)
176
+ ? value.map((v) => serializeID({ field, value: v, model }))
177
+ : [serializeID({ field, value, model })],
178
+ },
179
+ };
180
+ break;
181
+ case "gt":
182
+ condition = {
183
+ [field]: {
184
+ $gt: serializeID({
185
+ field,
186
+ value,
187
+ model,
188
+ }),
189
+ },
190
+ };
191
+ break;
192
+ case "gte":
193
+ condition = {
194
+ [field]: {
195
+ $gte: serializeID({
196
+ field,
197
+ value,
198
+ model,
199
+ }),
200
+ },
201
+ };
202
+ break;
203
+ case "lt":
204
+ condition = {
205
+ [field]: {
206
+ $lt: serializeID({
207
+ field,
208
+ value,
209
+ model,
210
+ }),
211
+ },
212
+ };
213
+ break;
214
+ case "lte":
215
+ condition = {
216
+ [field]: {
217
+ $lte: serializeID({
218
+ field,
219
+ value,
220
+ model,
221
+ }),
222
+ },
223
+ };
224
+ break;
225
+ case "ne":
226
+ condition = {
227
+ [field]: {
228
+ $ne: serializeID({
229
+ field,
230
+ value,
231
+ model,
232
+ }),
233
+ },
234
+ };
235
+ break;
236
+ case "contains":
237
+ condition = {
238
+ [field]: {
239
+ $regex: `.*${escapeForMongoRegex(value as string)}.*`,
240
+ },
241
+ };
242
+ break;
243
+ case "starts_with":
244
+ condition = {
245
+ [field]: { $regex: `^${escapeForMongoRegex(value as string)}` },
246
+ };
247
+ break;
248
+ case "ends_with":
249
+ condition = {
250
+ [field]: { $regex: `${escapeForMongoRegex(value as string)}$` },
251
+ };
252
+ break;
253
+ default:
254
+ throw new MongoAdapterError(
255
+ "UNSUPPORTED_OPERATOR",
256
+ `Unsupported operator: ${operator}`,
257
+ );
258
+ }
259
+ return { condition, connector };
260
+ });
261
+ if (conditions.length === 1) {
262
+ return conditions[0]!.condition;
263
+ }
264
+ const andConditions = conditions
265
+ .filter((c) => c.connector === "AND")
266
+ .map((c) => c.condition);
267
+ const orConditions = conditions
268
+ .filter((c) => c.connector === "OR")
269
+ .map((c) => c.condition);
270
+
271
+ let clause = {};
272
+ if (andConditions.length) {
273
+ clause = { ...clause, $and: andConditions };
274
+ }
275
+ if (orConditions.length) {
276
+ clause = { ...clause, $or: orConditions };
277
+ }
278
+ return clause;
279
+ }
280
+
281
+ return {
282
+ async create({ model, data: values }) {
283
+ const res = await db.collection(model).insertOne(values, { session });
284
+ const insertedData = { _id: res.insertedId.toString(), ...values };
285
+ return insertedData as any;
286
+ },
287
+ async findOne({ model, where, select, join }) {
288
+ const matchStage = where
289
+ ? { $match: convertWhereClause({ where, model }) }
290
+ : { $match: {} };
291
+ const pipeline: any[] = [matchStage];
292
+
293
+ if (join) {
294
+ for (const [joinedModel, joinConfig] of Object.entries(join)) {
295
+ const localField = getFieldName({
296
+ field: joinConfig.on.from,
297
+ model,
298
+ });
299
+ const foreignField = getFieldName({
300
+ field: joinConfig.on.to,
301
+ model: joinedModel,
302
+ });
303
+
304
+ const localFieldName = localField === "id" ? "_id" : localField;
305
+ const foreignFieldName =
306
+ foreignField === "id" ? "_id" : foreignField;
307
+
308
+ // Only unwind if the foreign field has a unique constraint (one-to-one relationship)
309
+ const joinedModelSchema =
310
+ schema[getDefaultModelName(joinedModel)];
311
+ const foreignFieldAttribute =
312
+ joinedModelSchema?.fields[joinConfig.on.to];
313
+ const isUnique = foreignFieldAttribute?.unique === true;
314
+
315
+ // For unique relationships, limit is ignored (as per JoinConfig type)
316
+ // For non-unique relationships, apply limit if specified
317
+ const shouldLimit = !isUnique && joinConfig.limit !== undefined;
318
+ const limit =
319
+ joinConfig.limit ??
320
+ options.advanced?.database?.defaultFindManyLimit ??
321
+ 100;
322
+ if (shouldLimit && limit > 0) {
323
+ // Use pipeline syntax to support limit
324
+ // Construct the field reference string for the foreign field
325
+ const foreignFieldRef = `$${foreignFieldName}`;
326
+ pipeline.push({
327
+ $lookup: {
328
+ from: joinedModel,
329
+ let: { localFieldValue: `$${localFieldName}` },
330
+ pipeline: [
331
+ {
332
+ $match: {
333
+ $expr: {
334
+ $eq: [foreignFieldRef, "$$localFieldValue"],
335
+ },
336
+ },
337
+ },
338
+ { $limit: limit },
339
+ ],
340
+ as: joinedModel,
341
+ },
342
+ });
343
+ } else {
344
+ // Use simple syntax when no limit is needed
345
+ pipeline.push({
346
+ $lookup: {
347
+ from: joinedModel,
348
+ localField: localFieldName,
349
+ foreignField: foreignFieldName,
350
+ as: joinedModel,
351
+ },
352
+ });
353
+ }
354
+
355
+ if (isUnique) {
356
+ // For one-to-one relationships, unwind to flatten to a single object
357
+ pipeline.push({
358
+ $unwind: {
359
+ path: `$${joinedModel}`,
360
+ preserveNullAndEmptyArrays: true,
361
+ },
362
+ });
363
+ }
364
+ // For one-to-many, keep as array - no unwind
365
+ }
366
+ }
367
+
368
+ if (select) {
369
+ const projection: any = {};
370
+ select.forEach((field) => {
371
+ projection[getFieldName({ field, model })] = 1;
372
+ });
373
+
374
+ // Include joined collections in projection
375
+ if (join) {
376
+ for (const joinedModel of Object.keys(join)) {
377
+ projection[joinedModel] = 1;
378
+ }
379
+ }
380
+
381
+ pipeline.push({ $project: projection });
382
+ }
383
+
384
+ pipeline.push({ $limit: 1 });
385
+
386
+ const res = await db
387
+ .collection(model)
388
+ .aggregate(pipeline, { session })
389
+ .toArray();
390
+
391
+ if (!res || res.length === 0) return null;
392
+ return res[0] as any;
393
+ },
394
+ async findMany({ model, where, limit, offset, sortBy, join }) {
395
+ const matchStage = where
396
+ ? { $match: convertWhereClause({ where, model }) }
397
+ : { $match: {} };
398
+ const pipeline: any[] = [matchStage];
399
+
400
+ if (join) {
401
+ for (const [joinedModel, joinConfig] of Object.entries(join)) {
402
+ const localField = getFieldName({
403
+ field: joinConfig.on.from,
404
+ model,
405
+ });
406
+ const foreignField = getFieldName({
407
+ field: joinConfig.on.to,
408
+ model: joinedModel,
409
+ });
410
+
411
+ const localFieldName = localField === "id" ? "_id" : localField;
412
+ const foreignFieldName =
413
+ foreignField === "id" ? "_id" : foreignField;
414
+
415
+ // Only unwind if the foreign field has a unique constraint (one-to-one relationship)
416
+ const foreignFieldAttribute = getFieldAttributes({
417
+ model: joinedModel,
418
+ field: joinConfig.on.to,
419
+ });
420
+ const isUnique = foreignFieldAttribute?.unique === true;
421
+
422
+ // For unique relationships, limit is ignored (as per JoinConfig type)
423
+ // For non-unique relationships, apply limit if specified
424
+ const shouldLimit =
425
+ joinConfig.relation !== "one-to-one" &&
426
+ joinConfig.limit !== undefined;
427
+
428
+ const limit =
429
+ joinConfig.limit ??
430
+ options.advanced?.database?.defaultFindManyLimit ??
431
+ 100;
432
+ if (shouldLimit && limit > 0) {
433
+ // Use pipeline syntax to support limit
434
+ // Construct the field reference string for the foreign field
435
+ const foreignFieldRef = `$${foreignFieldName}`;
436
+ pipeline.push({
437
+ $lookup: {
438
+ from: joinedModel,
439
+ let: { localFieldValue: `$${localFieldName}` },
440
+ pipeline: [
441
+ {
442
+ $match: {
443
+ $expr: {
444
+ $eq: [foreignFieldRef, "$$localFieldValue"],
445
+ },
446
+ },
447
+ },
448
+ { $limit: limit },
449
+ ],
450
+ as: joinedModel,
451
+ },
452
+ });
453
+ } else {
454
+ // Use simple syntax when no limit is needed
455
+ pipeline.push({
456
+ $lookup: {
457
+ from: joinedModel,
458
+ localField: localFieldName,
459
+ foreignField: foreignFieldName,
460
+ as: joinedModel,
461
+ },
462
+ });
463
+ }
464
+
465
+ if (isUnique) {
466
+ // For one-to-one relationships, unwind to flatten to a single object
467
+ pipeline.push({
468
+ $unwind: {
469
+ path: `$${joinedModel}`,
470
+ preserveNullAndEmptyArrays: true,
471
+ },
472
+ });
473
+ }
474
+ // For one-to-many, keep as array - no unwind
475
+ }
476
+ }
477
+
478
+ if (sortBy) {
479
+ pipeline.push({
480
+ $sort: {
481
+ [getFieldName({ field: sortBy.field, model })]:
482
+ sortBy.direction === "desc" ? -1 : 1,
483
+ },
484
+ });
485
+ }
486
+
487
+ if (offset) {
488
+ pipeline.push({ $skip: offset });
489
+ }
490
+
491
+ if (limit) {
492
+ pipeline.push({ $limit: limit });
493
+ }
494
+
495
+ const res = await db
496
+ .collection(model)
497
+ .aggregate(pipeline, { session })
498
+ .toArray();
499
+
500
+ return res as any;
501
+ },
502
+ async count({ model, where }) {
503
+ const matchStage = where
504
+ ? { $match: convertWhereClause({ where, model }) }
505
+ : { $match: {} };
506
+ const pipeline: any[] = [matchStage, { $count: "total" }];
507
+
508
+ const res = await db
509
+ .collection(model)
510
+ .aggregate(pipeline, { session })
511
+ .toArray();
512
+
513
+ if (!res || res.length === 0) return 0;
514
+ return res[0]?.total ?? 0;
515
+ },
516
+ async update({ model, where, update: values }) {
517
+ const clause = convertWhereClause({ where, model });
518
+
519
+ const res = await db.collection(model).findOneAndUpdate(
520
+ clause,
521
+ { $set: values as any },
522
+ {
523
+ session,
524
+ returnDocument: "after",
525
+ includeResultMetadata: true,
526
+ },
527
+ );
528
+ const doc = (res as any)?.value ?? null;
529
+ if (!doc) return null;
530
+ return doc as any;
531
+ },
532
+ async updateMany({ model, where, update: values }) {
533
+ const clause = convertWhereClause({ where, model });
534
+
535
+ const res = await db.collection(model).updateMany(
536
+ clause,
537
+ {
538
+ $set: values as any,
539
+ },
540
+ { session },
541
+ );
542
+ return res.modifiedCount;
543
+ },
544
+ async delete({ model, where }) {
545
+ const clause = convertWhereClause({ where, model });
546
+ await db.collection(model).deleteOne(clause, { session });
547
+ },
548
+ async deleteMany({ model, where }) {
549
+ const clause = convertWhereClause({ where, model });
550
+ const res = await db
551
+ .collection(model)
552
+ .deleteMany(clause, { session });
553
+ return res.deletedCount;
554
+ },
555
+ };
556
+ };
557
+
558
+ let lazyAdapter:
559
+ | ((options: BetterAuthOptions) => DBAdapter<BetterAuthOptions>)
560
+ | null = null;
561
+ let adapterOptions: AdapterFactoryOptions | null = null;
562
+ adapterOptions = {
563
+ config: {
564
+ adapterId: "mongodb-adapter",
565
+ adapterName: "MongoDB Adapter",
566
+ usePlural: config?.usePlural ?? false,
567
+ debugLogs: config?.debugLogs ?? false,
568
+ mapKeysTransformInput: {
569
+ id: "_id",
570
+ },
571
+ mapKeysTransformOutput: {
572
+ _id: "id",
573
+ },
574
+ supportsArrays: true,
575
+ supportsNumericIds: false,
576
+ transaction:
577
+ config?.client && (config?.transaction ?? true)
578
+ ? async (cb) => {
579
+ if (!config.client) {
580
+ return cb(lazyAdapter!(lazyOptions!));
581
+ }
582
+
583
+ const session = config.client.startSession();
584
+
585
+ try {
586
+ session.startTransaction();
587
+
588
+ const adapter = createAdapterFactory({
589
+ config: adapterOptions!.config,
590
+ adapter: createCustomAdapter(db, session),
591
+ })(lazyOptions!);
592
+
593
+ const result = await cb(adapter);
594
+
595
+ await session.commitTransaction();
596
+ return result;
597
+ } catch (err) {
598
+ await session.abortTransaction();
599
+ throw err;
600
+ } finally {
601
+ await session.endSession();
602
+ }
603
+ }
604
+ : false,
605
+ customTransformInput({
606
+ action,
607
+ data,
608
+ field,
609
+ fieldAttributes,
610
+ schema,
611
+ model,
612
+ options,
613
+ }) {
614
+ const customIdGen = getCustomIdGenerator(options);
615
+ if (field === "_id" || fieldAttributes.references?.field === "id") {
616
+ if (customIdGen) {
617
+ return data;
618
+ }
619
+ if (action !== "create") {
620
+ return data;
621
+ }
622
+ if (Array.isArray(data)) {
623
+ return data.map((v) => {
624
+ if (typeof v === "string") {
625
+ try {
626
+ const oid = new ObjectId(v);
627
+ return oid;
628
+ } catch {
629
+ return v;
630
+ }
631
+ }
632
+ return v;
633
+ });
634
+ }
635
+ if (typeof data === "string") {
636
+ try {
637
+ const oid = new ObjectId(data);
638
+ return oid;
639
+ } catch {
640
+ return data;
641
+ }
642
+ }
643
+ if (
644
+ fieldAttributes?.references?.field === "id" &&
645
+ !fieldAttributes?.required &&
646
+ data === null
647
+ ) {
648
+ return null;
649
+ }
650
+ const oid = new ObjectId();
651
+ return oid;
652
+ }
653
+ return data;
654
+ },
655
+ customTransformOutput({ data, field, fieldAttributes }) {
656
+ if (field === "id" || fieldAttributes.references?.field === "id") {
657
+ if (data instanceof ObjectId) {
658
+ return data.toHexString();
659
+ }
660
+ if (Array.isArray(data)) {
661
+ return data.map((v) => {
662
+ if (v instanceof ObjectId) {
663
+ return v.toHexString();
664
+ }
665
+ return v;
666
+ });
667
+ }
668
+ return data;
669
+ }
670
+ return data;
671
+ },
672
+ customIdGenerator() {
673
+ return new ObjectId().toString();
674
+ },
675
+ },
676
+ adapter: createCustomAdapter(db),
677
+ };
678
+ lazyAdapter = createAdapterFactory(adapterOptions);
679
+
680
+ return (options: BetterAuthOptions): DBAdapter<BetterAuthOptions> => {
681
+ lazyOptions = options;
682
+ return lazyAdapter(options);
683
+ };
684
+ };
685
+
686
+ /**
687
+ * Safely escape user input for use in a MongoDB regex.
688
+ * This ensures the resulting pattern is treated as literal text,
689
+ * and not as a regex with special syntax.
690
+ *
691
+ * @param input - The input string to escape. Any type that isn't a string will be converted to an empty string.
692
+ * @param maxLength - The maximum length of the input string to escape. Defaults to 256. This is to prevent DOS attacks.
693
+ * @returns The escaped string.
694
+ */
695
+ function escapeForMongoRegex(input: string, maxLength = 256): string {
696
+ if (typeof input !== "string") return "";
697
+
698
+ // Escape all PCRE special characters
699
+ // Source: PCRE docs — https://www.pcre.org/original/doc/html/pcrepattern.html
700
+ return input.slice(0, maxLength).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
701
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "include": ["./src"],
4
+ "references": [
5
+ {
6
+ "path": "../core/tsconfig.json"
7
+ }
8
+ ]
9
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "tsdown";
2
+
3
+ export default defineConfig({
4
+ dts: { build: true, incremental: true },
5
+ format: ["esm"],
6
+ entry: ["./src/index.ts"],
7
+ });
@@ -0,0 +1,3 @@
1
+ import { defineProject } from "vitest/config";
2
+
3
+ export default defineProject({});