better-auth-firestore 1.1.4 → 1.2.1

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.
package/README.md CHANGED
@@ -448,6 +448,24 @@ const url = generateIndexSetupUrl(process.env.FIREBASE_PROJECT_ID!);
448
448
  console.log(url); // Open this URL to create the index
449
449
  ```
450
450
 
451
+ ## FAQ
452
+
453
+ ### Can I migrate from Auth.js / NextAuth without changing existing Firestore data?
454
+
455
+ Yes. `better-auth-firestore` is designed as a drop-in replacement for the Auth.js Firebase adapter with matching collection names and field shapes by default, so most projects do not need a Firestore data migration. See [Migration from Auth.js/NextAuth](#migration-from-authjsnextauth) for the adapter-specific details.
456
+
457
+ ### What's the difference between `better-auth-firestore` and `better-auth-firebase-auth`?
458
+
459
+ `better-auth-firestore` is a database adapter for storing Better Auth users, sessions, accounts, and verification tokens in Firestore through the Firebase Admin SDK. `better-auth-firebase-auth` is for Firebase Authentication provider integration such as Email/Password, Google sign-in, client/server token generation, and password reset flows. Use the Firestore adapter for data storage and the Firebase Auth plugin when you need Firebase Authentication features.
460
+
461
+ ### Which runtimes are supported?
462
+
463
+ This package supports server-side Node.js runtimes, including Next.js route handlers, Cloud Functions, and Cloud Run, anywhere the Firebase Admin SDK is supported. Edge runtimes such as Vercel Edge Functions and Cloudflare Workers are not supported because the Firestore Admin SDK does not run there. See [Runtime compatibility](#runtime-compatibility) for the current matrix.
464
+
465
+ ### Why is a Firestore composite index required for verification tokens?
466
+
467
+ Better Auth verification token lookups require a Firestore query pattern that depends on a composite index. Without that index, verification-related queries can fail with a missing index error or insufficient permissions message. See [Create Required Firestore Index](#3-create-required-firestore-index) for the exact fields and setup options.
468
+
451
469
  ## Related Links
452
470
 
453
471
  - [Better Auth Documentation](https://www.better-auth.com/docs)
@@ -9,14 +9,60 @@ const MAP_TO_FIRESTORE = {
9
9
  };
10
10
  const MAP_FROM_FIRESTORE = Object.fromEntries(Object.entries(MAP_TO_FIRESTORE).map(([k, v]) => [v, k]));
11
11
  const identity = (x) => x;
12
+ const DB_TO_CANONICAL_FIELD = {
13
+ user_id: "userId",
14
+ session_token: "sessionToken",
15
+ provider_account_id: "providerAccountId",
16
+ email_verified: "emailVerified",
17
+ };
18
+ function canonicalizeFieldName(field) {
19
+ return DB_TO_CANONICAL_FIELD[field] ?? field;
20
+ }
12
21
  function mapFieldsFactory(preferSnakeCase) {
13
22
  if (preferSnakeCase) {
14
23
  return {
15
- toDb: (field) => MAP_TO_FIRESTORE[field] ?? field,
24
+ toDb: (field) => {
25
+ const canonical = canonicalizeFieldName(field);
26
+ return MAP_TO_FIRESTORE[canonical] ?? canonical;
27
+ },
16
28
  fromDb: (field) => MAP_FROM_FIRESTORE[field] ?? field,
17
29
  };
18
30
  }
19
- return { toDb: identity, fromDb: identity };
31
+ return {
32
+ toDb: (field) => canonicalizeFieldName(field),
33
+ fromDb: (field) => MAP_FROM_FIRESTORE[field] ?? field,
34
+ };
35
+ }
36
+ /**
37
+ * Firestore caps the `IN` operator at 30 comparison values. When a `where`
38
+ * clause passes more than this, queries throw with
39
+ * `INVALID_ARGUMENT: 'IN' supports up to 30 comparison values.`
40
+ * We split oversized IN values into chunks of this size in both `deleteMany`
41
+ * and the regular `findMany` path and merge results.
42
+ * https://firebase.google.com/docs/firestore/query-data/queries#query_limitations
43
+ */
44
+ const FIRESTORE_IN_CHUNK_SIZE = 30;
45
+ /**
46
+ * Narrows a where clause to an `in` condition whose value array exceeds
47
+ * Firestore's 30-value cap. Used by query methods to trigger chunked
48
+ * sub-queries.
49
+ */
50
+ function findOversizedInClause(where) {
51
+ return (where ?? []).find((w) => w.operator === "in" &&
52
+ Array.isArray(w.value) &&
53
+ w.value.length > FIRESTORE_IN_CHUNK_SIZE);
54
+ }
55
+ function getChunkedWhereClauses(where) {
56
+ const oversized = findOversizedInClause(where);
57
+ if (!oversized || !where) {
58
+ return [where];
59
+ }
60
+ const chunkedClauses = [];
61
+ for (let i = 0; i < oversized.value.length; i += FIRESTORE_IN_CHUNK_SIZE) {
62
+ const chunk = oversized.value.slice(i, i + FIRESTORE_IN_CHUNK_SIZE);
63
+ chunkedClauses.push(where.map((w) => w === oversized ? { ...w, value: chunk } : w));
64
+ }
65
+ return chunkedClauses;
20
66
  }
21
67
  function resolveDb(config) {
22
68
  if (!config)
@@ -139,6 +185,36 @@ function applyOperator(query, field, operator, value) {
139
185
  return query.where(field, "==", value);
140
186
  }
141
187
  }
188
+ function isDocumentReferenceLike(value) {
189
+ return (typeof value === "object" &&
190
+ value !== null &&
191
+ "id" in value &&
192
+ typeof value.id === "string" &&
193
+ "path" in value &&
194
+ typeof value.path === "string");
195
+ }
196
+ function normalizeSessionWriteData(data) {
197
+ const { user_id, session_token, ...rest } = data;
198
+ const normalized = { ...rest };
199
+ const userIdValue = normalized.userId ?? user_id;
200
+ if (typeof userIdValue === "string") {
201
+ normalized.userId = userIdValue;
202
+ }
203
+ else if (isDocumentReferenceLike(userIdValue)) {
204
+ normalized.userId = userIdValue.id;
205
+ }
206
+ const sessionTokenValue = normalized.sessionToken ?? session_token;
207
+ if (typeof sessionTokenValue === "string") {
208
+ normalized.sessionToken = sessionTokenValue;
209
+ }
210
+ return normalized;
211
+ }
212
+ function normalizeWriteData(model, data) {
213
+ const normalizedModel = model.toLowerCase().replace(/s$/, "");
214
+ if (normalizedModel !== "session")
215
+ return data;
216
+ return normalizeSessionWriteData(data);
217
+ }
142
218
  export const firestoreAdapter = (config = {}) => {
143
219
  const db = resolveDb(config);
144
220
  const { namingStrategy = "default", collections: collectionsOverride = {}, debugLogs = false, } = (config && config.collection
@@ -162,8 +238,9 @@ export const firestoreAdapter = (config = {}) => {
162
238
  create: async ({ model, data }) => {
163
239
  const col = getCollectionRef(db, model, collections);
164
240
  let ref = col.doc();
241
+ const normalizedData = normalizeWriteData(model, data);
165
242
  const docData = {};
166
- for (const [k, v] of Object.entries(data)) {
243
+ for (const [k, v] of Object.entries(normalizedData)) {
167
244
  if (k === "id" && v) {
168
245
  ref = col.doc(v);
169
246
  continue;
@@ -171,17 +248,23 @@ export const firestoreAdapter = (config = {}) => {
171
248
  docData[mapper.toDb(k)] = v;
172
249
  }
173
250
  transaction.set(ref, docData);
174
- return { ...data, id: ref.id };
251
+ return { ...normalizedData, id: ref.id };
175
252
  },
176
253
  update: async ({ model, where, update }) => {
177
254
  const col = getCollectionRef(db, model, collections);
178
- const q = applyWhereClause(col, where, mapper);
179
- const snap = await transaction.get(q.limit(1));
180
- const doc = snap.docs[0];
255
+ let doc;
256
+ for (const whereClause of getChunkedWhereClauses(where)) {
257
+ const q = applyWhereClause(col, whereClause, mapper);
258
+ const snap = await transaction.get(q.limit(1));
259
+ doc = snap.docs[0];
260
+ if (doc)
261
+ break;
262
+ }
181
263
  if (!doc)
182
264
  return null;
265
+ const normalizedUpdate = normalizeWriteData(model, update);
183
266
  const updateData = {};
184
- for (const [k, v] of Object.entries(update)) {
267
+ for (const [k, v] of Object.entries(normalizedUpdate)) {
185
268
  updateData[mapper.toDb(k)] = v;
186
269
  }
187
270
  transaction.update(doc.ref, updateData);
@@ -192,13 +275,18 @@ export const firestoreAdapter = (config = {}) => {
192
275
  result[mapper.fromDb(k)] = v;
193
276
  }
194
277
  }
195
- return { ...result, ...update };
278
+ return { ...result, ...normalizedUpdate };
196
279
  },
197
280
  findOne: async ({ model, where }) => {
198
281
  const col = getCollectionRef(db, model, collections);
199
- const q = applyWhereClause(col, where, mapper);
200
- const snap = await transaction.get(q.limit(1));
201
- const doc = snap.docs[0];
282
+ let doc;
283
+ for (const whereClause of getChunkedWhereClauses(where)) {
284
+ const q = applyWhereClause(col, whereClause, mapper);
285
+ const snap = await transaction.get(q.limit(1));
286
+ doc = snap.docs[0];
287
+ if (doc)
288
+ break;
289
+ }
202
290
  if (!doc)
203
291
  return null;
204
292
  const data = doc.data();
@@ -220,8 +308,9 @@ export const firestoreAdapter = (config = {}) => {
220
308
  create: async ({ model, data }) => {
221
309
  const col = getCollectionRef(db, model, collections);
222
310
  let ref = col.doc();
311
+ const normalizedData = normalizeWriteData(model, data);
223
312
  const docData = {};
224
- for (const [k, v] of Object.entries(data)) {
313
+ for (const [k, v] of Object.entries(normalizedData)) {
225
314
  if (k === "id" && v) {
226
315
  ref = col.doc(v);
227
316
  continue;
@@ -259,14 +348,15 @@ export const firestoreAdapter = (config = {}) => {
259
348
  }
260
349
  if (debugLogs) {
261
350
  console.log(`[Firestore Adapter] CREATE ${model} - returning:`, {
262
- ...data,
351
+ ...normalizedData,
263
352
  ...result,
264
353
  });
265
354
  }
266
- return { ...data, ...result };
355
+ return { ...normalizedData, ...result };
267
356
  },
268
357
  update: async ({ model, where, update }) => {
269
358
  const col = getCollectionRef(db, model, collections);
359
+ const normalizedUpdate = normalizeWriteData(model, update);
270
360
  // Special case: if where clause is just "id eq value", use doc() instead of query
271
361
  if (where &&
272
362
  where.length === 1 &&
@@ -289,7 +379,7 @@ export const firestoreAdapter = (config = {}) => {
289
379
  return null;
290
380
  }
291
381
  const updateData = {};
292
- for (const [k, v] of Object.entries(update)) {
382
+ for (const [k, v] of Object.entries(normalizedUpdate)) {
293
383
  updateData[mapper.toDb(k)] = v;
294
384
  }
295
385
  if (debugLogs) {
@@ -310,7 +400,6 @@ export const firestoreAdapter = (config = {}) => {
310
400
  }
311
401
  return result;
312
402
  }
313
- const q = applyWhereClause(col, where, mapper);
314
403
  if (debugLogs) {
315
404
  console.log(`[Firestore Adapter] UPDATE ${model}:`, {
316
405
  where,
@@ -318,8 +407,14 @@ export const firestoreAdapter = (config = {}) => {
318
407
  collection: collections,
319
408
  });
320
409
  }
321
- const snap = await q.limit(1).get();
322
- const doc = snap.docs[0];
410
+ let doc;
411
+ for (const whereClause of getChunkedWhereClauses(where)) {
412
+ const q = applyWhereClause(col, whereClause, mapper);
413
+ const snap = await q.limit(1).get();
414
+ doc = snap.docs[0];
415
+ if (doc)
416
+ break;
417
+ }
323
418
  if (!doc) {
324
419
  if (debugLogs) {
325
420
  console.log(`[Firestore Adapter] UPDATE ${model} - no document found`);
@@ -327,7 +422,7 @@ export const firestoreAdapter = (config = {}) => {
327
422
  return null;
328
423
  }
329
424
  const updateData = {};
330
- for (const [k, v] of Object.entries(update)) {
425
+ for (const [k, v] of Object.entries(normalizedUpdate)) {
331
426
  updateData[mapper.toDb(k)] = v;
332
427
  }
333
428
  if (debugLogs) {
@@ -350,16 +445,23 @@ export const firestoreAdapter = (config = {}) => {
350
445
  },
351
446
  updateMany: async ({ model, where, update }) => {
352
447
  const col = getCollectionRef(db, model, collections);
353
- const q = applyWhereClause(col, where, mapper);
354
- const snap = await q.get();
355
448
  let count = 0;
449
+ const seenDocIds = new Set();
450
+ const normalizedUpdate = normalizeWriteData(model, update);
356
451
  const updateData = {};
357
- for (const [k, v] of Object.entries(update)) {
452
+ for (const [k, v] of Object.entries(normalizedUpdate)) {
358
453
  updateData[mapper.toDb(k)] = v;
359
454
  }
360
- for (const d of snap.docs) {
361
- await d.ref.update(updateData);
362
- count++;
455
+ for (const whereClause of getChunkedWhereClauses(where)) {
456
+ const q = applyWhereClause(col, whereClause, mapper);
457
+ const snap = await q.get();
458
+ for (const d of snap.docs) {
459
+ if (seenDocIds.has(d.id))
460
+ continue;
461
+ seenDocIds.add(d.id);
462
+ await d.ref.update(updateData);
463
+ count++;
464
+ }
363
465
  }
364
466
  return count;
365
467
  },
@@ -384,14 +486,40 @@ export const firestoreAdapter = (config = {}) => {
384
486
  }
385
487
  return;
386
488
  }
387
- const q = applyWhereClause(col, where, mapper);
388
- const snap = await q.limit(1).get();
389
- const doc = snap.docs[0];
489
+ let doc;
490
+ for (const whereClause of getChunkedWhereClauses(where)) {
491
+ const q = applyWhereClause(col, whereClause, mapper);
492
+ const snap = await q.limit(1).get();
493
+ doc = snap.docs[0];
494
+ if (doc)
495
+ break;
496
+ }
390
497
  if (doc)
391
498
  await doc.ref.delete();
392
499
  },
393
500
  deleteMany: async ({ model, where }) => {
394
501
  const col = getCollectionRef(db, model, collections);
502
+ // Firestore's `IN` operator caps at 30 values. If a single where
503
+ // clause carries more than 30 values, split into sub-queries and
504
+ // sum the deletes so callers (e.g. better-auth's multi-session
505
+ // `deleteSessions(tokens)`) don't blow up once they cross the cap.
506
+ const oversized = findOversizedInClause(where);
507
+ if (oversized) {
508
+ let total = 0;
509
+ for (let i = 0; i < oversized.value.length; i += FIRESTORE_IN_CHUNK_SIZE) {
510
+ const chunk = oversized.value.slice(i, i + FIRESTORE_IN_CHUNK_SIZE);
511
+ const chunkedWhere = where.map((w) => w === oversized
512
+ ? { ...w, value: chunk }
513
+ : w);
514
+ const cq = applyWhereClause(col, chunkedWhere, mapper);
515
+ const csnap = await cq.get();
516
+ for (const d of csnap.docs) {
517
+ await d.ref.delete();
518
+ total++;
519
+ }
520
+ }
521
+ return total;
522
+ }
395
523
  const q = applyWhereClause(col, where, mapper);
396
524
  const snap = await q.get();
397
525
  let count = 0;
@@ -472,7 +600,6 @@ export const firestoreAdapter = (config = {}) => {
472
600
  }
473
601
  return result;
474
602
  }
475
- const q = applyWhereClause(col, where, mapper);
476
603
  if (debugLogs) {
477
604
  console.log(`[Firestore Adapter] FINDONE ${model}:`, {
478
605
  where,
@@ -480,11 +607,21 @@ export const firestoreAdapter = (config = {}) => {
480
607
  collection: collections,
481
608
  });
482
609
  }
483
- const snap = await q.limit(1).get();
610
+ let snapshotSize = 0;
611
+ let snapshotDocsLength = 0;
612
+ let doc;
613
+ for (const whereClause of getChunkedWhereClauses(where)) {
614
+ const q = applyWhereClause(col, whereClause, mapper);
615
+ const snap = await q.limit(1).get();
616
+ snapshotSize = snap.size;
617
+ snapshotDocsLength = snap.docs.length;
618
+ doc = snap.docs[0];
619
+ if (doc)
620
+ break;
621
+ }
484
622
  if (debugLogs) {
485
- console.log(`[Firestore Adapter] FINDONE ${model} - snapshot size:`, snap.size, "docs:", snap.docs.length);
623
+ console.log(`[Firestore Adapter] FINDONE ${model} - snapshot size:`, snapshotSize, "docs:", snapshotDocsLength);
486
624
  }
487
- const doc = snap.docs[0];
488
625
  if (!doc || !doc.exists) {
489
626
  if (debugLogs) {
490
627
  console.log(`[Firestore Adapter] FINDONE ${model} - no document found`);
@@ -772,6 +909,54 @@ export const firestoreAdapter = (config = {}) => {
772
909
  results = results.slice(0, limit);
773
910
  return results;
774
911
  }
912
+ // Firestore's `IN` operator caps at 30 values. If a simple non-ID
913
+ // `in` query carries more than 30 values, split into chunks and
914
+ // merge the results. Any post-filter sort / offset / limit is
915
+ // re-applied after the merge to preserve the caller-visible
916
+ // ordering and pagination.
917
+ const oversizedIn = findOversizedInClause(where);
918
+ if (oversizedIn) {
919
+ const merged = new Map();
920
+ for (let i = 0; i < oversizedIn.value.length; i += FIRESTORE_IN_CHUNK_SIZE) {
921
+ const chunk = oversizedIn.value.slice(i, i + FIRESTORE_IN_CHUNK_SIZE);
922
+ const chunkedWhere = where.map((w) => w === oversizedIn
923
+ ? { ...w, value: chunk }
924
+ : w);
925
+ let cq = applyWhereClause(col, chunkedWhere, mapper);
926
+ if (sortBy?.field) {
927
+ const fieldName = mapper.toDb(sortBy.field);
928
+ const direction = sortBy.direction === "desc" ? "desc" : "asc";
929
+ cq = cq.orderBy(fieldName, direction);
930
+ }
931
+ const csnap = await cq.get();
932
+ for (const d of csnap.docs) {
933
+ const data = d.data();
934
+ const result = { id: d.id };
935
+ for (const [k, v] of Object.entries(data)) {
936
+ result[mapper.fromDb(k)] = convertTimestamp(v);
937
+ }
938
+ merged.set(d.id, result);
939
+ }
940
+ }
941
+ let results = Array.from(merged.values());
942
+ if (sortBy?.field) {
943
+ results.sort((a, b) => {
944
+ const aVal = a[sortBy.field];
945
+ const bVal = b[sortBy.field];
946
+ const dir = sortBy.direction === "desc" ? -1 : 1;
947
+ if (aVal < bVal)
948
+ return -1 * dir;
949
+ if (aVal > bVal)
950
+ return 1 * dir;
951
+ return 0;
952
+ });
953
+ }
954
+ if (offset)
955
+ results = results.slice(offset);
956
+ if (limit)
957
+ results = results.slice(0, limit);
958
+ return results;
959
+ }
775
960
  // Regular query path for non-ID queries
776
961
  let q = applyWhereClause(col, where, mapper);
777
962
  const notInCondition = where?.find((w) => w.operator === "notIn" ||
@@ -924,21 +1109,31 @@ export const firestoreAdapter = (config = {}) => {
924
1109
  }
925
1110
  }
926
1111
  }
927
- let q = applyWhereClause(col, where, mapper);
928
1112
  const notInCondition = where?.find((w) => w.operator === "notIn" ||
929
1113
  w.operator === "not_in");
930
- if (notInCondition) {
931
- const snap = await q.get();
932
- const fieldName = notInCondition.field;
933
- const arr = Array.isArray(notInCondition.value)
934
- ? notInCondition.value
935
- : [notInCondition.value];
936
- return snap.docs.filter((d) => {
937
- const data = d.data();
938
- const value = data[mapper.toDb(fieldName)];
939
- return !arr.includes(value);
940
- }).length;
1114
+ const whereClauses = getChunkedWhereClauses(where);
1115
+ if (notInCondition || whereClauses.length > 1) {
1116
+ const matchingIds = new Set();
1117
+ for (const whereClause of whereClauses) {
1118
+ const q = applyWhereClause(col, whereClause, mapper);
1119
+ const snap = await q.get();
1120
+ for (const d of snap.docs) {
1121
+ if (notInCondition) {
1122
+ const fieldName = notInCondition.field;
1123
+ const arr = Array.isArray(notInCondition.value)
1124
+ ? notInCondition.value
1125
+ : [notInCondition.value];
1126
+ const data = d.data();
1127
+ const value = data[mapper.toDb(fieldName)];
1128
+ if (arr.includes(value))
1129
+ continue;
1130
+ }
1131
+ matchingIds.add(d.id);
1132
+ }
1133
+ }
1134
+ return matchingIds.size;
941
1135
  }
1136
+ const q = applyWhereClause(col, where, mapper);
942
1137
  const snap = await q.count().get();
943
1138
  return snap.data().count ?? 0;
944
1139
  },
@@ -21,4 +21,3 @@
21
21
  ],
22
22
  "fieldOverrides": []
23
23
  }
24
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "better-auth-firestore",
3
- "version": "1.1.4",
3
+ "version": "1.2.1",
4
4
  "private": false,
5
5
  "description": "Firestore adapter for Better Auth (Firebase Admin SDK)",
6
6
  "author": "Slava Yultyyev <yultyyev@gmail.com>",
@@ -59,7 +59,7 @@
59
59
  "typescript": "^5.0.0"
60
60
  },
61
61
  "devDependencies": {
62
- "@biomejs/biome": "^2.4.7",
62
+ "@biomejs/biome": "^2.4.9",
63
63
  "@semantic-release/changelog": "^6.0.3",
64
64
  "@semantic-release/commit-analyzer": "^13.0.1",
65
65
  "@semantic-release/github": "^12.0.6",
@@ -68,6 +68,20 @@
68
68
  "rimraf": "^6.1.3",
69
69
  "semantic-release": "^25.0.3",
70
70
  "typescript": "^5.9.3",
71
- "vitest": "^4.1.0"
71
+ "vitest": "^4.1.2"
72
+ },
73
+ "pnpm": {
74
+ "overrides": {
75
+ "@tootallnate/once@<3.0.1": ">=3.0.1",
76
+ "kysely@<=0.28.13": ">=0.28.14",
77
+ "kysely@>=0.28.12 <=0.28.13": ">=0.28.14",
78
+ "fast-xml-parser@>=4.0.0-beta.3 <=5.5.6": ">=5.5.7",
79
+ "micromatch>picomatch": "^2.3.2",
80
+ "picomatch@<2.3.2": ">=2.3.2",
81
+ "picomatch@>=4.0.0 <4.0.4": ">=4.0.4",
82
+ "brace-expansion@<5.0.5": ">=5.0.5",
83
+ "node-forge@<1.4.0": ">=1.4.0",
84
+ "node-forge@<=1.3.3": ">=1.4.0"
85
+ }
72
86
  }
73
87
  }