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.
@@ -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
- for (const [k, v] of Object.entries(normalizedData)) {
244
- if (k === "id" && v) {
245
- ref = col.doc(v);
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
- 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;
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
- const normalizedUpdate = normalizeWriteData(model, update);
266
- const updateData = {};
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
- for (const [k, v] of Object.entries(existing)) {
275
- result[mapper.fromDb(k)] = v;
276
- }
469
+ Object.assign(existing.docData, updateData);
470
+ Object.assign(existing.appData, normalizedUpdate);
471
+ return { ...existing.appData };
277
472
  }
278
- return { ...result, ...normalizedUpdate };
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
- 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
- }
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
- const result = { id: doc.id };
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
- return run(txAdapter);
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
- for (const [k, v] of Object.entries(normalizedData)) {
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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "better-auth-firestore",
3
- "version": "1.2.4",
3
+ "version": "1.2.6",
4
4
  "private": false,
5
5
  "description": "Firestore adapter for Better Auth (Firebase Admin SDK)",
6
6
  "author": "Slava Yultyyev <yultyyev@gmail.com>",