better-auth-firestore 1.1.4 → 1.2.0

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)
@@ -18,6 +18,37 @@ function mapFieldsFactory(preferSnakeCase) {
18
18
  }
19
19
  return { toDb: identity, fromDb: identity };
20
20
  }
21
+ /**
22
+ * Firestore caps the `IN` operator at 30 comparison values. When a `where`
23
+ * clause passes more than this, queries throw with
24
+ * `INVALID_ARGUMENT: 'IN' supports up to 30 comparison values.`
25
+ * We split oversized IN values into chunks of this size in both `deleteMany`
26
+ * and the regular `findMany` path and merge results.
27
+ * https://firebase.google.com/docs/firestore/query-data/queries#query_limitations
28
+ */
29
+ const FIRESTORE_IN_CHUNK_SIZE = 30;
30
+ /**
31
+ * Narrows a where clause to an `in` condition whose value array exceeds
32
+ * Firestore's 30-value cap. Used by query methods to trigger chunked
33
+ * sub-queries.
34
+ */
35
+ function findOversizedInClause(where) {
36
+ return (where ?? []).find((w) => w.operator === "in" &&
37
+ Array.isArray(w.value) &&
38
+ w.value.length > FIRESTORE_IN_CHUNK_SIZE);
39
+ }
40
+ function getChunkedWhereClauses(where) {
41
+ const oversized = findOversizedInClause(where);
42
+ if (!oversized || !where) {
43
+ return [where];
44
+ }
45
+ const chunkedClauses = [];
46
+ for (let i = 0; i < oversized.value.length; i += FIRESTORE_IN_CHUNK_SIZE) {
47
+ const chunk = oversized.value.slice(i, i + FIRESTORE_IN_CHUNK_SIZE);
48
+ chunkedClauses.push(where.map((w) => w === oversized ? { ...w, value: chunk } : w));
49
+ }
50
+ return chunkedClauses;
51
+ }
21
52
  function resolveDb(config) {
22
53
  if (!config)
23
54
  return initFirestore();
@@ -175,9 +206,14 @@ export const firestoreAdapter = (config = {}) => {
175
206
  },
176
207
  update: async ({ model, where, update }) => {
177
208
  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];
209
+ let doc;
210
+ for (const whereClause of getChunkedWhereClauses(where)) {
211
+ const q = applyWhereClause(col, whereClause, mapper);
212
+ const snap = await transaction.get(q.limit(1));
213
+ doc = snap.docs[0];
214
+ if (doc)
215
+ break;
216
+ }
181
217
  if (!doc)
182
218
  return null;
183
219
  const updateData = {};
@@ -196,9 +232,14 @@ export const firestoreAdapter = (config = {}) => {
196
232
  },
197
233
  findOne: async ({ model, where }) => {
198
234
  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];
235
+ let doc;
236
+ for (const whereClause of getChunkedWhereClauses(where)) {
237
+ const q = applyWhereClause(col, whereClause, mapper);
238
+ const snap = await transaction.get(q.limit(1));
239
+ doc = snap.docs[0];
240
+ if (doc)
241
+ break;
242
+ }
202
243
  if (!doc)
203
244
  return null;
204
245
  const data = doc.data();
@@ -310,7 +351,6 @@ export const firestoreAdapter = (config = {}) => {
310
351
  }
311
352
  return result;
312
353
  }
313
- const q = applyWhereClause(col, where, mapper);
314
354
  if (debugLogs) {
315
355
  console.log(`[Firestore Adapter] UPDATE ${model}:`, {
316
356
  where,
@@ -318,8 +358,14 @@ export const firestoreAdapter = (config = {}) => {
318
358
  collection: collections,
319
359
  });
320
360
  }
321
- const snap = await q.limit(1).get();
322
- const doc = snap.docs[0];
361
+ let doc;
362
+ for (const whereClause of getChunkedWhereClauses(where)) {
363
+ const q = applyWhereClause(col, whereClause, mapper);
364
+ const snap = await q.limit(1).get();
365
+ doc = snap.docs[0];
366
+ if (doc)
367
+ break;
368
+ }
323
369
  if (!doc) {
324
370
  if (debugLogs) {
325
371
  console.log(`[Firestore Adapter] UPDATE ${model} - no document found`);
@@ -350,16 +396,22 @@ export const firestoreAdapter = (config = {}) => {
350
396
  },
351
397
  updateMany: async ({ model, where, update }) => {
352
398
  const col = getCollectionRef(db, model, collections);
353
- const q = applyWhereClause(col, where, mapper);
354
- const snap = await q.get();
355
399
  let count = 0;
400
+ const seenDocIds = new Set();
356
401
  const updateData = {};
357
402
  for (const [k, v] of Object.entries(update)) {
358
403
  updateData[mapper.toDb(k)] = v;
359
404
  }
360
- for (const d of snap.docs) {
361
- await d.ref.update(updateData);
362
- count++;
405
+ for (const whereClause of getChunkedWhereClauses(where)) {
406
+ const q = applyWhereClause(col, whereClause, mapper);
407
+ const snap = await q.get();
408
+ for (const d of snap.docs) {
409
+ if (seenDocIds.has(d.id))
410
+ continue;
411
+ seenDocIds.add(d.id);
412
+ await d.ref.update(updateData);
413
+ count++;
414
+ }
363
415
  }
364
416
  return count;
365
417
  },
@@ -384,14 +436,40 @@ export const firestoreAdapter = (config = {}) => {
384
436
  }
385
437
  return;
386
438
  }
387
- const q = applyWhereClause(col, where, mapper);
388
- const snap = await q.limit(1).get();
389
- const doc = snap.docs[0];
439
+ let doc;
440
+ for (const whereClause of getChunkedWhereClauses(where)) {
441
+ const q = applyWhereClause(col, whereClause, mapper);
442
+ const snap = await q.limit(1).get();
443
+ doc = snap.docs[0];
444
+ if (doc)
445
+ break;
446
+ }
390
447
  if (doc)
391
448
  await doc.ref.delete();
392
449
  },
393
450
  deleteMany: async ({ model, where }) => {
394
451
  const col = getCollectionRef(db, model, collections);
452
+ // Firestore's `IN` operator caps at 30 values. If a single where
453
+ // clause carries more than 30 values, split into sub-queries and
454
+ // sum the deletes so callers (e.g. better-auth's multi-session
455
+ // `deleteSessions(tokens)`) don't blow up once they cross the cap.
456
+ const oversized = findOversizedInClause(where);
457
+ if (oversized) {
458
+ let total = 0;
459
+ for (let i = 0; i < oversized.value.length; i += FIRESTORE_IN_CHUNK_SIZE) {
460
+ const chunk = oversized.value.slice(i, i + FIRESTORE_IN_CHUNK_SIZE);
461
+ const chunkedWhere = where.map((w) => w === oversized
462
+ ? { ...w, value: chunk }
463
+ : w);
464
+ const cq = applyWhereClause(col, chunkedWhere, mapper);
465
+ const csnap = await cq.get();
466
+ for (const d of csnap.docs) {
467
+ await d.ref.delete();
468
+ total++;
469
+ }
470
+ }
471
+ return total;
472
+ }
395
473
  const q = applyWhereClause(col, where, mapper);
396
474
  const snap = await q.get();
397
475
  let count = 0;
@@ -472,7 +550,6 @@ export const firestoreAdapter = (config = {}) => {
472
550
  }
473
551
  return result;
474
552
  }
475
- const q = applyWhereClause(col, where, mapper);
476
553
  if (debugLogs) {
477
554
  console.log(`[Firestore Adapter] FINDONE ${model}:`, {
478
555
  where,
@@ -480,11 +557,21 @@ export const firestoreAdapter = (config = {}) => {
480
557
  collection: collections,
481
558
  });
482
559
  }
483
- const snap = await q.limit(1).get();
560
+ let snapshotSize = 0;
561
+ let snapshotDocsLength = 0;
562
+ let doc;
563
+ for (const whereClause of getChunkedWhereClauses(where)) {
564
+ const q = applyWhereClause(col, whereClause, mapper);
565
+ const snap = await q.limit(1).get();
566
+ snapshotSize = snap.size;
567
+ snapshotDocsLength = snap.docs.length;
568
+ doc = snap.docs[0];
569
+ if (doc)
570
+ break;
571
+ }
484
572
  if (debugLogs) {
485
- console.log(`[Firestore Adapter] FINDONE ${model} - snapshot size:`, snap.size, "docs:", snap.docs.length);
573
+ console.log(`[Firestore Adapter] FINDONE ${model} - snapshot size:`, snapshotSize, "docs:", snapshotDocsLength);
486
574
  }
487
- const doc = snap.docs[0];
488
575
  if (!doc || !doc.exists) {
489
576
  if (debugLogs) {
490
577
  console.log(`[Firestore Adapter] FINDONE ${model} - no document found`);
@@ -772,6 +859,54 @@ export const firestoreAdapter = (config = {}) => {
772
859
  results = results.slice(0, limit);
773
860
  return results;
774
861
  }
862
+ // Firestore's `IN` operator caps at 30 values. If a simple non-ID
863
+ // `in` query carries more than 30 values, split into chunks and
864
+ // merge the results. Any post-filter sort / offset / limit is
865
+ // re-applied after the merge to preserve the caller-visible
866
+ // ordering and pagination.
867
+ const oversizedIn = findOversizedInClause(where);
868
+ if (oversizedIn) {
869
+ const merged = new Map();
870
+ for (let i = 0; i < oversizedIn.value.length; i += FIRESTORE_IN_CHUNK_SIZE) {
871
+ const chunk = oversizedIn.value.slice(i, i + FIRESTORE_IN_CHUNK_SIZE);
872
+ const chunkedWhere = where.map((w) => w === oversizedIn
873
+ ? { ...w, value: chunk }
874
+ : w);
875
+ let cq = applyWhereClause(col, chunkedWhere, mapper);
876
+ if (sortBy?.field) {
877
+ const fieldName = mapper.toDb(sortBy.field);
878
+ const direction = sortBy.direction === "desc" ? "desc" : "asc";
879
+ cq = cq.orderBy(fieldName, direction);
880
+ }
881
+ const csnap = await cq.get();
882
+ for (const d of csnap.docs) {
883
+ const data = d.data();
884
+ const result = { id: d.id };
885
+ for (const [k, v] of Object.entries(data)) {
886
+ result[mapper.fromDb(k)] = convertTimestamp(v);
887
+ }
888
+ merged.set(d.id, result);
889
+ }
890
+ }
891
+ let results = Array.from(merged.values());
892
+ if (sortBy?.field) {
893
+ results.sort((a, b) => {
894
+ const aVal = a[sortBy.field];
895
+ const bVal = b[sortBy.field];
896
+ const dir = sortBy.direction === "desc" ? -1 : 1;
897
+ if (aVal < bVal)
898
+ return -1 * dir;
899
+ if (aVal > bVal)
900
+ return 1 * dir;
901
+ return 0;
902
+ });
903
+ }
904
+ if (offset)
905
+ results = results.slice(offset);
906
+ if (limit)
907
+ results = results.slice(0, limit);
908
+ return results;
909
+ }
775
910
  // Regular query path for non-ID queries
776
911
  let q = applyWhereClause(col, where, mapper);
777
912
  const notInCondition = where?.find((w) => w.operator === "notIn" ||
@@ -924,21 +1059,31 @@ export const firestoreAdapter = (config = {}) => {
924
1059
  }
925
1060
  }
926
1061
  }
927
- let q = applyWhereClause(col, where, mapper);
928
1062
  const notInCondition = where?.find((w) => w.operator === "notIn" ||
929
1063
  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;
1064
+ const whereClauses = getChunkedWhereClauses(where);
1065
+ if (notInCondition || whereClauses.length > 1) {
1066
+ const matchingIds = new Set();
1067
+ for (const whereClause of whereClauses) {
1068
+ const q = applyWhereClause(col, whereClause, mapper);
1069
+ const snap = await q.get();
1070
+ for (const d of snap.docs) {
1071
+ if (notInCondition) {
1072
+ const fieldName = notInCondition.field;
1073
+ const arr = Array.isArray(notInCondition.value)
1074
+ ? notInCondition.value
1075
+ : [notInCondition.value];
1076
+ const data = d.data();
1077
+ const value = data[mapper.toDb(fieldName)];
1078
+ if (arr.includes(value))
1079
+ continue;
1080
+ }
1081
+ matchingIds.add(d.id);
1082
+ }
1083
+ }
1084
+ return matchingIds.size;
941
1085
  }
1086
+ const q = applyWhereClause(col, where, mapper);
942
1087
  const snap = await q.count().get();
943
1088
  return snap.data().count ?? 0;
944
1089
  },
@@ -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.0",
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
  }