better-auth-firestore 1.2.4 → 1.2.6
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/dist/firebase-adapter.js +247 -65
- package/package.json +1 -1
package/dist/firebase-adapter.js
CHANGED
|
@@ -215,6 +215,207 @@ function normalizeWriteData(model, data) {
|
|
|
215
215
|
return data;
|
|
216
216
|
return normalizeSessionWriteData(data);
|
|
217
217
|
}
|
|
218
|
+
/**
|
|
219
|
+
* Build a Firestore-safe write payload from a normalized data object.
|
|
220
|
+
*
|
|
221
|
+
* - Maps app field names to their Firestore-side names via `mapper`.
|
|
222
|
+
* - Skips `undefined` values. The Firestore Admin SDK rejects writes that
|
|
223
|
+
* contain `undefined` unless the client was initialized with
|
|
224
|
+
* `ignoreUndefinedProperties: true`, and better-auth routinely emits
|
|
225
|
+
* optional-undefined fields (e.g. `image` on email/password sign-up).
|
|
226
|
+
* Stripping here lets the adapter work with any user-supplied Firestore
|
|
227
|
+
* instance, not just ones we initialize.
|
|
228
|
+
* - Routes a string `id` field out as `idOverride` so create callers can
|
|
229
|
+
* set it on the document reference instead of writing it into the body.
|
|
230
|
+
* For updates the caller can simply ignore `idOverride` — you cannot
|
|
231
|
+
* change a Firestore document ID by writing to a field.
|
|
232
|
+
*
|
|
233
|
+
* `null` is preserved deliberately: Firestore accepts null and callers may
|
|
234
|
+
* use it to explicitly clear a field.
|
|
235
|
+
*/
|
|
236
|
+
function buildFirestoreWriteData(data, mapper) {
|
|
237
|
+
const docData = {};
|
|
238
|
+
let idOverride;
|
|
239
|
+
for (const [k, v] of Object.entries(data)) {
|
|
240
|
+
if (v === undefined)
|
|
241
|
+
continue;
|
|
242
|
+
if (k === "id") {
|
|
243
|
+
if (typeof v === "string" && v)
|
|
244
|
+
idOverride = v;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
docData[mapper.toDb(k)] = v;
|
|
248
|
+
}
|
|
249
|
+
return { docData, idOverride };
|
|
250
|
+
}
|
|
251
|
+
class TxBuffer {
|
|
252
|
+
constructor() {
|
|
253
|
+
this.byPath = new Map();
|
|
254
|
+
}
|
|
255
|
+
/** Stage a create. Returns the entry so the caller can read back `appData.id`. */
|
|
256
|
+
stageCreate(model, ref, docData, appNormalized) {
|
|
257
|
+
const entry = {
|
|
258
|
+
op: "create",
|
|
259
|
+
model,
|
|
260
|
+
ref,
|
|
261
|
+
docData: { ...docData },
|
|
262
|
+
appData: { ...appNormalized, id: ref.id },
|
|
263
|
+
};
|
|
264
|
+
this.byPath.set(ref.path, entry);
|
|
265
|
+
return entry;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Stage an update on `ref`. If a prior entry exists for the same ref —
|
|
269
|
+
* regardless of whether it was a create or update — merge in place so
|
|
270
|
+
* the flush emits one combined write. `baseAppData` provides the
|
|
271
|
+
* pre-update fields needed to build a full app-visible state for
|
|
272
|
+
* subsequent read overlays.
|
|
273
|
+
*/
|
|
274
|
+
stageUpdate(model, ref, updateDocData, updateAppData, baseAppData) {
|
|
275
|
+
const existing = this.byPath.get(ref.path);
|
|
276
|
+
if (existing) {
|
|
277
|
+
Object.assign(existing.docData, updateDocData);
|
|
278
|
+
Object.assign(existing.appData, updateAppData);
|
|
279
|
+
return existing;
|
|
280
|
+
}
|
|
281
|
+
const entry = {
|
|
282
|
+
op: "update",
|
|
283
|
+
model,
|
|
284
|
+
ref,
|
|
285
|
+
docData: { ...updateDocData },
|
|
286
|
+
appData: { ...baseAppData, ...updateAppData, id: ref.id },
|
|
287
|
+
};
|
|
288
|
+
this.byPath.set(ref.path, entry);
|
|
289
|
+
return entry;
|
|
290
|
+
}
|
|
291
|
+
/** First buffered create for `model` whose appData satisfies `where`. */
|
|
292
|
+
findCreateMatching(model, where) {
|
|
293
|
+
for (const entry of this.byPath.values()) {
|
|
294
|
+
if (entry.op !== "create" || entry.model !== model)
|
|
295
|
+
continue;
|
|
296
|
+
if (matchesWhere(entry.appData, where))
|
|
297
|
+
return entry;
|
|
298
|
+
}
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
/** Buffered entry (create or update) for a specific ref path, if any. */
|
|
302
|
+
getByPath(refPath) {
|
|
303
|
+
return this.byPath.get(refPath);
|
|
304
|
+
}
|
|
305
|
+
/** Replay every staged write onto the transaction in insertion order. */
|
|
306
|
+
flush(transaction) {
|
|
307
|
+
for (const entry of this.byPath.values()) {
|
|
308
|
+
if (entry.op === "create")
|
|
309
|
+
transaction.set(entry.ref, entry.docData);
|
|
310
|
+
else
|
|
311
|
+
transaction.update(entry.ref, entry.docData);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Evaluates a where clause against an in-memory app-side doc. AND-within-
|
|
317
|
+
* group, OR-between-groups (a new group starts at every `connector: "OR"`).
|
|
318
|
+
* Unknown operators fall back to equality — see TxBuffer header for the
|
|
319
|
+
* supported set.
|
|
320
|
+
*/
|
|
321
|
+
function matchesWhere(appData, where) {
|
|
322
|
+
if (!where || where.length === 0)
|
|
323
|
+
return true;
|
|
324
|
+
const groups = [];
|
|
325
|
+
let current = [];
|
|
326
|
+
for (const cond of where) {
|
|
327
|
+
if (cond.connector === "OR" && current.length > 0) {
|
|
328
|
+
groups.push(current);
|
|
329
|
+
current = [cond];
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
current.push(cond);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (current.length > 0)
|
|
336
|
+
groups.push(current);
|
|
337
|
+
return groups.some((group) => group.every((cond) => matchesCondition(appData, cond)));
|
|
338
|
+
}
|
|
339
|
+
function matchesCondition(appData, cond) {
|
|
340
|
+
const val = appData[cond.field];
|
|
341
|
+
const op = (cond.operator || "eq");
|
|
342
|
+
switch (op) {
|
|
343
|
+
case "eq":
|
|
344
|
+
case "==":
|
|
345
|
+
return val === cond.value;
|
|
346
|
+
case "ne":
|
|
347
|
+
case "!=":
|
|
348
|
+
return val !== cond.value;
|
|
349
|
+
case "in":
|
|
350
|
+
return Array.isArray(cond.value)
|
|
351
|
+
? cond.value.includes(val)
|
|
352
|
+
: val === cond.value;
|
|
353
|
+
case "notIn":
|
|
354
|
+
case "not_in":
|
|
355
|
+
return Array.isArray(cond.value)
|
|
356
|
+
? !cond.value.includes(val)
|
|
357
|
+
: val !== cond.value;
|
|
358
|
+
case "gt":
|
|
359
|
+
return val > cond.value;
|
|
360
|
+
case "gte":
|
|
361
|
+
return val >= cond.value;
|
|
362
|
+
case "lt":
|
|
363
|
+
return val < cond.value;
|
|
364
|
+
case "lte":
|
|
365
|
+
return val <= cond.value;
|
|
366
|
+
case "contains":
|
|
367
|
+
case "array-contains":
|
|
368
|
+
if (Array.isArray(val))
|
|
369
|
+
return val.includes(cond.value);
|
|
370
|
+
if (typeof val === "string")
|
|
371
|
+
return val.includes(String(cond.value));
|
|
372
|
+
return false;
|
|
373
|
+
case "startsWith":
|
|
374
|
+
case "starts-with":
|
|
375
|
+
case "starts_with":
|
|
376
|
+
return typeof val === "string" && val.startsWith(String(cond.value));
|
|
377
|
+
case "endsWith":
|
|
378
|
+
case "ends-with":
|
|
379
|
+
case "ends_with":
|
|
380
|
+
return typeof val === "string" && val.endsWith(String(cond.value));
|
|
381
|
+
default:
|
|
382
|
+
return val === cond.value;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Look up a single doc inside a transaction, mirroring the non-tx
|
|
387
|
+
* findOne/update path. Special-cases `id eq value` to use `col.doc(id)`
|
|
388
|
+
* because Firestore document IDs are metadata, not fields — they can't
|
|
389
|
+
* be queried with `.where("id", ...)`. Returns undefined when nothing
|
|
390
|
+
* matches.
|
|
391
|
+
*/
|
|
392
|
+
async function lookupTxDoc(transaction, col, where, mapper) {
|
|
393
|
+
if (where &&
|
|
394
|
+
where.length === 1 &&
|
|
395
|
+
where[0]?.field === "id" &&
|
|
396
|
+
(where[0]?.operator === "eq" || !where[0]?.operator)) {
|
|
397
|
+
const docRef = col.doc(where[0].value);
|
|
398
|
+
const snap = await transaction.get(docRef);
|
|
399
|
+
return snap.exists ? snap : undefined;
|
|
400
|
+
}
|
|
401
|
+
for (const whereClause of getChunkedWhereClauses(where)) {
|
|
402
|
+
const q = applyWhereClause(col, whereClause, mapper);
|
|
403
|
+
const snap = await transaction.get(q.limit(1));
|
|
404
|
+
if (snap.docs[0])
|
|
405
|
+
return snap.docs[0];
|
|
406
|
+
}
|
|
407
|
+
return undefined;
|
|
408
|
+
}
|
|
409
|
+
/** Convert a Firestore document body (db-side keys, Timestamps) to app shape. */
|
|
410
|
+
function dbDataToAppData(data, mapper) {
|
|
411
|
+
const result = {};
|
|
412
|
+
for (const [k, v] of Object.entries(data)) {
|
|
413
|
+
if (k === "__name__")
|
|
414
|
+
continue;
|
|
415
|
+
result[mapper.fromDb(k)] = convertTimestamp(v);
|
|
416
|
+
}
|
|
417
|
+
return result;
|
|
418
|
+
}
|
|
218
419
|
export const firestoreAdapter = (config = {}) => {
|
|
219
420
|
const db = resolveDb(config);
|
|
220
421
|
const { namingStrategy = "default", collections: collectionsOverride = {}, debugLogs = false, } = (config && config.collection
|
|
@@ -234,72 +435,69 @@ export const firestoreAdapter = (config = {}) => {
|
|
|
234
435
|
debugLogs,
|
|
235
436
|
transaction: async (run) => {
|
|
236
437
|
return await db.runTransaction(async (transaction) => {
|
|
438
|
+
const buffer = new TxBuffer();
|
|
237
439
|
const txAdapter = {
|
|
238
440
|
create: async ({ model, data }) => {
|
|
239
441
|
const col = getCollectionRef(db, model, collections);
|
|
240
|
-
let ref = col.doc();
|
|
241
442
|
const normalizedData = normalizeWriteData(model, data);
|
|
242
|
-
const docData =
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
continue;
|
|
247
|
-
}
|
|
248
|
-
docData[mapper.toDb(k)] = v;
|
|
249
|
-
}
|
|
250
|
-
transaction.set(ref, docData);
|
|
251
|
-
return { ...normalizedData, id: ref.id };
|
|
443
|
+
const { docData, idOverride } = buildFirestoreWriteData(normalizedData, mapper);
|
|
444
|
+
const ref = idOverride ? col.doc(idOverride) : col.doc();
|
|
445
|
+
const entry = buffer.stageCreate(model, ref, docData, normalizedData);
|
|
446
|
+
return { ...entry.appData };
|
|
252
447
|
},
|
|
253
448
|
update: async ({ model, where, update }) => {
|
|
254
449
|
const col = getCollectionRef(db, model, collections);
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
450
|
+
const normalizedUpdate = normalizeWriteData(model, update);
|
|
451
|
+
const { docData: updateData } = buildFirestoreWriteData(normalizedUpdate, mapper);
|
|
452
|
+
// 1. Overlay: target may already be staged as a create in
|
|
453
|
+
// this same transaction. Mutate it in place so the flush
|
|
454
|
+
// emits one combined `set`.
|
|
455
|
+
const stagedCreate = buffer.findCreateMatching(model, where);
|
|
456
|
+
if (stagedCreate) {
|
|
457
|
+
Object.assign(stagedCreate.docData, updateData);
|
|
458
|
+
Object.assign(stagedCreate.appData, normalizedUpdate);
|
|
459
|
+
return { ...stagedCreate.appData };
|
|
262
460
|
}
|
|
461
|
+
// 2. Otherwise read from Firestore (still safe — no writes
|
|
462
|
+
// have been flushed yet) to locate the target doc.
|
|
463
|
+
const doc = await lookupTxDoc(transaction, col, where, mapper);
|
|
263
464
|
if (!doc)
|
|
264
465
|
return null;
|
|
265
|
-
|
|
266
|
-
const
|
|
267
|
-
for (const [k, v] of Object.entries(normalizedUpdate)) {
|
|
268
|
-
updateData[mapper.toDb(k)] = v;
|
|
269
|
-
}
|
|
270
|
-
transaction.update(doc.ref, updateData);
|
|
271
|
-
const existing = doc.data();
|
|
272
|
-
const result = { id: doc.id };
|
|
466
|
+
// 3. Same ref may already have an update staged; merge.
|
|
467
|
+
const existing = buffer.getByPath(doc.ref.path);
|
|
273
468
|
if (existing) {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
469
|
+
Object.assign(existing.docData, updateData);
|
|
470
|
+
Object.assign(existing.appData, normalizedUpdate);
|
|
471
|
+
return { ...existing.appData };
|
|
277
472
|
}
|
|
278
|
-
|
|
473
|
+
// 4. Stage a fresh update.
|
|
474
|
+
const baseAppData = dbDataToAppData(doc.data() ?? {}, mapper);
|
|
475
|
+
const entry = buffer.stageUpdate(model, doc.ref, updateData, normalizedUpdate, baseAppData);
|
|
476
|
+
return { ...entry.appData };
|
|
279
477
|
},
|
|
280
478
|
findOne: async ({ model, where }) => {
|
|
281
479
|
const col = getCollectionRef(db, model, collections);
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
break;
|
|
289
|
-
}
|
|
480
|
+
// 1. Overlay: return any matching staged create directly.
|
|
481
|
+
const stagedCreate = buffer.findCreateMatching(model, where);
|
|
482
|
+
if (stagedCreate)
|
|
483
|
+
return { ...stagedCreate.appData };
|
|
484
|
+
// 2. Real read.
|
|
485
|
+
const doc = await lookupTxDoc(transaction, col, where, mapper);
|
|
290
486
|
if (!doc)
|
|
291
487
|
return null;
|
|
488
|
+
// 3. Layer any pending update on top of the snapshot.
|
|
489
|
+
const staged = buffer.getByPath(doc.ref.path);
|
|
490
|
+
if (staged)
|
|
491
|
+
return { ...staged.appData };
|
|
292
492
|
const data = doc.data();
|
|
293
493
|
if (!data)
|
|
294
494
|
return null;
|
|
295
|
-
|
|
296
|
-
for (const [k, v] of Object.entries(data)) {
|
|
297
|
-
result[mapper.fromDb(k)] = convertTimestamp(v);
|
|
298
|
-
}
|
|
299
|
-
return result;
|
|
495
|
+
return { id: doc.id, ...dbDataToAppData(data, mapper) };
|
|
300
496
|
},
|
|
301
497
|
};
|
|
302
|
-
|
|
498
|
+
const result = await run(txAdapter);
|
|
499
|
+
buffer.flush(transaction);
|
|
500
|
+
return result;
|
|
303
501
|
});
|
|
304
502
|
},
|
|
305
503
|
},
|
|
@@ -307,16 +505,9 @@ export const firestoreAdapter = (config = {}) => {
|
|
|
307
505
|
return {
|
|
308
506
|
create: async ({ model, data }) => {
|
|
309
507
|
const col = getCollectionRef(db, model, collections);
|
|
310
|
-
let ref = col.doc();
|
|
311
508
|
const normalizedData = normalizeWriteData(model, data);
|
|
312
|
-
const docData =
|
|
313
|
-
|
|
314
|
-
if (k === "id" && v) {
|
|
315
|
-
ref = col.doc(v);
|
|
316
|
-
continue;
|
|
317
|
-
}
|
|
318
|
-
docData[mapper.toDb(k)] = v;
|
|
319
|
-
}
|
|
509
|
+
const { docData, idOverride } = buildFirestoreWriteData(normalizedData, mapper);
|
|
510
|
+
const ref = idOverride ? col.doc(idOverride) : col.doc();
|
|
320
511
|
if (debugLogs) {
|
|
321
512
|
console.log(`[Firestore Adapter] CREATE ${model}:`, {
|
|
322
513
|
input: data,
|
|
@@ -378,10 +569,7 @@ export const firestoreAdapter = (config = {}) => {
|
|
|
378
569
|
}
|
|
379
570
|
return null;
|
|
380
571
|
}
|
|
381
|
-
const updateData =
|
|
382
|
-
for (const [k, v] of Object.entries(normalizedUpdate)) {
|
|
383
|
-
updateData[mapper.toDb(k)] = v;
|
|
384
|
-
}
|
|
572
|
+
const { docData: updateData } = buildFirestoreWriteData(normalizedUpdate, mapper);
|
|
385
573
|
if (debugLogs) {
|
|
386
574
|
console.log(`[Firestore Adapter] UPDATE ${model} - updateData:`, updateData);
|
|
387
575
|
}
|
|
@@ -421,10 +609,7 @@ export const firestoreAdapter = (config = {}) => {
|
|
|
421
609
|
}
|
|
422
610
|
return null;
|
|
423
611
|
}
|
|
424
|
-
const updateData =
|
|
425
|
-
for (const [k, v] of Object.entries(normalizedUpdate)) {
|
|
426
|
-
updateData[mapper.toDb(k)] = v;
|
|
427
|
-
}
|
|
612
|
+
const { docData: updateData } = buildFirestoreWriteData(normalizedUpdate, mapper);
|
|
428
613
|
if (debugLogs) {
|
|
429
614
|
console.log(`[Firestore Adapter] UPDATE ${model} - updateData:`, updateData);
|
|
430
615
|
}
|
|
@@ -448,10 +633,7 @@ export const firestoreAdapter = (config = {}) => {
|
|
|
448
633
|
let count = 0;
|
|
449
634
|
const seenDocIds = new Set();
|
|
450
635
|
const normalizedUpdate = normalizeWriteData(model, update);
|
|
451
|
-
const updateData =
|
|
452
|
-
for (const [k, v] of Object.entries(normalizedUpdate)) {
|
|
453
|
-
updateData[mapper.toDb(k)] = v;
|
|
454
|
-
}
|
|
636
|
+
const { docData: updateData } = buildFirestoreWriteData(normalizedUpdate, mapper);
|
|
455
637
|
for (const whereClause of getChunkedWhereClauses(where)) {
|
|
456
638
|
const q = applyWhereClause(col, whereClause, mapper);
|
|
457
639
|
const snap = await q.get();
|