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.
- package/dist/firebase-adapter.js +207 -32
- package/package.json +1 -1
package/dist/firebase-adapter.js
CHANGED
|
@@ -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
|
-
|
|
277
|
-
return { ...
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
292
|
-
const
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
}
|
|
469
|
+
Object.assign(existing.docData, updateData);
|
|
470
|
+
Object.assign(existing.appData, normalizedUpdate);
|
|
471
|
+
return { ...existing.appData };
|
|
300
472
|
}
|
|
301
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
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
|
-
|
|
498
|
+
const result = await run(txAdapter);
|
|
499
|
+
buffer.flush(transaction);
|
|
500
|
+
return result;
|
|
326
501
|
});
|
|
327
502
|
},
|
|
328
503
|
},
|