better-auth-firestore 1.2.5 → 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.
@@ -248,6 +248,174 @@ function buildFirestoreWriteData(data, mapper) {
248
248
  }
249
249
  return { docData, idOverride };
250
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
+ }
251
419
  export const firestoreAdapter = (config = {}) => {
252
420
  const db = resolveDb(config);
253
421
  const { namingStrategy = "default", collections: collectionsOverride = {}, debugLogs = false, } = (config && config.collection
@@ -267,62 +435,69 @@ export const firestoreAdapter = (config = {}) => {
267
435
  debugLogs,
268
436
  transaction: async (run) => {
269
437
  return await db.runTransaction(async (transaction) => {
438
+ const buffer = new TxBuffer();
270
439
  const txAdapter = {
271
440
  create: async ({ model, data }) => {
272
441
  const col = getCollectionRef(db, model, collections);
273
442
  const normalizedData = normalizeWriteData(model, data);
274
443
  const { docData, idOverride } = buildFirestoreWriteData(normalizedData, mapper);
275
444
  const ref = idOverride ? col.doc(idOverride) : col.doc();
276
- transaction.set(ref, docData);
277
- return { ...normalizedData, id: ref.id };
445
+ const entry = buffer.stageCreate(model, ref, docData, normalizedData);
446
+ return { ...entry.appData };
278
447
  },
279
448
  update: async ({ model, where, update }) => {
280
449
  const col = getCollectionRef(db, model, collections);
281
- let doc;
282
- for (const whereClause of getChunkedWhereClauses(where)) {
283
- const q = applyWhereClause(col, whereClause, mapper);
284
- const snap = await transaction.get(q.limit(1));
285
- doc = snap.docs[0];
286
- if (doc)
287
- 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 };
288
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);
289
464
  if (!doc)
290
465
  return null;
291
- const normalizedUpdate = normalizeWriteData(model, update);
292
- const { docData: updateData } = buildFirestoreWriteData(normalizedUpdate, mapper);
293
- transaction.update(doc.ref, updateData);
294
- const existing = doc.data();
295
- const result = { id: doc.id };
466
+ // 3. Same ref may already have an update staged; merge.
467
+ const existing = buffer.getByPath(doc.ref.path);
296
468
  if (existing) {
297
- for (const [k, v] of Object.entries(existing)) {
298
- result[mapper.fromDb(k)] = v;
299
- }
469
+ Object.assign(existing.docData, updateData);
470
+ Object.assign(existing.appData, normalizedUpdate);
471
+ return { ...existing.appData };
300
472
  }
301
- 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 };
302
477
  },
303
478
  findOne: async ({ model, where }) => {
304
479
  const col = getCollectionRef(db, model, collections);
305
- let doc;
306
- for (const whereClause of getChunkedWhereClauses(where)) {
307
- const q = applyWhereClause(col, whereClause, mapper);
308
- const snap = await transaction.get(q.limit(1));
309
- doc = snap.docs[0];
310
- if (doc)
311
- break;
312
- }
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);
313
486
  if (!doc)
314
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 };
315
492
  const data = doc.data();
316
493
  if (!data)
317
494
  return null;
318
- const result = { id: doc.id };
319
- for (const [k, v] of Object.entries(data)) {
320
- result[mapper.fromDb(k)] = convertTimestamp(v);
321
- }
322
- return result;
495
+ return { id: doc.id, ...dbDataToAppData(data, mapper) };
323
496
  },
324
497
  };
325
- return run(txAdapter);
498
+ const result = await run(txAdapter);
499
+ buffer.flush(transaction);
500
+ return result;
326
501
  });
327
502
  },
328
503
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "better-auth-firestore",
3
- "version": "1.2.5",
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>",