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 +18 -0
- package/dist/firebase-adapter.js +178 -33
- package/firestore.indexes.json +0 -1
- package/package.json +17 -3
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)
|
package/dist/firebase-adapter.js
CHANGED
|
@@ -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
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
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
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
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
|
-
|
|
322
|
-
const
|
|
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
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
388
|
-
const
|
|
389
|
-
|
|
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
|
-
|
|
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:`,
|
|
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
|
-
|
|
931
|
-
|
|
932
|
-
const
|
|
933
|
-
const
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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
|
},
|
package/firestore.indexes.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "better-auth-firestore",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
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.
|
|
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
|
}
|