firestore-batch-updater 1.0.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/dist/index.mjs ADDED
@@ -0,0 +1,616 @@
1
+ // src/utils/logger.ts
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ function getTimestamp() {
5
+ return (/* @__PURE__ */ new Date()).toISOString();
6
+ }
7
+ function generateLogFilename(operation) {
8
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
9
+ return `${operation}-${timestamp}.log`;
10
+ }
11
+ function ensureLogDirectory(logPath) {
12
+ if (!fs.existsSync(logPath)) {
13
+ fs.mkdirSync(logPath, { recursive: true });
14
+ }
15
+ }
16
+ function formatOperationLog(log) {
17
+ const lines = [];
18
+ const separator = "=".repeat(60);
19
+ lines.push(separator);
20
+ lines.push(`FIRESTORE BATCH OPERATION LOG`);
21
+ lines.push(separator);
22
+ lines.push("");
23
+ lines.push(`Operation: ${log.operation.toUpperCase()}`);
24
+ lines.push(`Collection: ${log.collection}`);
25
+ lines.push(`Started: ${log.startedAt}`);
26
+ lines.push(`Completed: ${log.completedAt}`);
27
+ lines.push("");
28
+ if (log.conditions && log.conditions.length > 0) {
29
+ lines.push("Conditions:");
30
+ for (const condition of log.conditions) {
31
+ lines.push(` - ${condition.field} ${condition.operator} ${formatValue(condition.value)}`);
32
+ }
33
+ lines.push("");
34
+ }
35
+ if (log.updateData) {
36
+ lines.push("Update Data:");
37
+ lines.push(` ${JSON.stringify(log.updateData, null, 2).replace(/\n/g, "\n ")}`);
38
+ lines.push("");
39
+ }
40
+ lines.push(separator);
41
+ lines.push("SUMMARY");
42
+ lines.push(separator);
43
+ lines.push(`Total: ${log.summary.totalCount}`);
44
+ lines.push(`Success: ${log.summary.successCount}`);
45
+ lines.push(`Failure: ${log.summary.failureCount}`);
46
+ lines.push("");
47
+ if (log.entries.length > 0) {
48
+ lines.push(separator);
49
+ lines.push("DETAILS");
50
+ lines.push(separator);
51
+ lines.push("");
52
+ for (const entry of log.entries) {
53
+ const statusLabel = entry.status === "success" ? "[SUCCESS]" : "[FAILURE]";
54
+ lines.push(`${entry.timestamp} ${statusLabel} ${entry.documentId}`);
55
+ if (entry.error) {
56
+ lines.push(` Error: ${entry.error}`);
57
+ }
58
+ }
59
+ }
60
+ lines.push("");
61
+ lines.push(separator);
62
+ lines.push("END OF LOG");
63
+ lines.push(separator);
64
+ return lines.join("\n");
65
+ }
66
+ function formatValue(value) {
67
+ if (value instanceof Date) {
68
+ return value.toISOString();
69
+ }
70
+ if (typeof value === "string") {
71
+ return `"${value}"`;
72
+ }
73
+ return String(value);
74
+ }
75
+ function writeOperationLog(log, options) {
76
+ const logPath = options.path || "./logs";
77
+ const filename = options.filename || generateLogFilename(log.operation);
78
+ const fullPath = path.join(logPath, filename);
79
+ ensureLogDirectory(logPath);
80
+ const content = formatOperationLog(log);
81
+ fs.writeFileSync(fullPath, content, "utf-8");
82
+ return fullPath;
83
+ }
84
+ function createLogCollector(operation, collection, conditions, updateData) {
85
+ const startedAt = getTimestamp();
86
+ const entries = [];
87
+ return {
88
+ addEntry(documentId, status, error) {
89
+ entries.push({
90
+ timestamp: getTimestamp(),
91
+ documentId,
92
+ status,
93
+ error
94
+ });
95
+ },
96
+ getLog() {
97
+ const successCount = entries.filter((e) => e.status === "success").length;
98
+ const failureCount = entries.filter((e) => e.status === "failure").length;
99
+ return {
100
+ operation,
101
+ collection,
102
+ startedAt,
103
+ completedAt: getTimestamp(),
104
+ conditions,
105
+ updateData,
106
+ summary: {
107
+ totalCount: entries.length,
108
+ successCount,
109
+ failureCount
110
+ },
111
+ entries
112
+ };
113
+ },
114
+ finalize(options) {
115
+ const log = this.getLog();
116
+ return writeOperationLog(log, options);
117
+ }
118
+ };
119
+ }
120
+
121
+ // src/utils/index.ts
122
+ function calculateProgress(current, total) {
123
+ const percentage = total === 0 ? 0 : Math.round(current / total * 100);
124
+ return {
125
+ current,
126
+ total,
127
+ percentage
128
+ };
129
+ }
130
+ function getAffectedFields(updateData) {
131
+ return Object.keys(updateData);
132
+ }
133
+ function mergeUpdateData(existingData, updateData) {
134
+ return {
135
+ ...existingData,
136
+ ...updateData
137
+ };
138
+ }
139
+ function isValidUpdateData(value) {
140
+ return value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0;
141
+ }
142
+ function formatError(error, context) {
143
+ const errorMessage = error instanceof Error ? error.message : String(error);
144
+ return context ? `Error at ${context}: ${errorMessage}` : errorMessage;
145
+ }
146
+
147
+ // src/core/batch-updater.ts
148
+ var BatchUpdater = class {
149
+ /**
150
+ * Create a new BatchUpdater instance
151
+ * @param firestore - Initialized Firestore instance from firebase-admin
152
+ */
153
+ constructor(firestore) {
154
+ this.conditions = [];
155
+ this.firestore = firestore;
156
+ }
157
+ /**
158
+ * Select a collection to operate on
159
+ * @param path - Collection path
160
+ * @returns This instance for chaining
161
+ */
162
+ collection(path2) {
163
+ this.collectionPath = path2;
164
+ this.conditions = [];
165
+ return this;
166
+ }
167
+ /**
168
+ * Add a where condition to filter documents
169
+ * @param field - Field path
170
+ * @param operator - Comparison operator
171
+ * @param value - Value to compare
172
+ * @returns This instance for chaining
173
+ */
174
+ where(field, operator, value) {
175
+ this.conditions.push({ field, operator, value });
176
+ return this;
177
+ }
178
+ /**
179
+ * Preview changes before executing update
180
+ * @param updateData - Data to update
181
+ * @returns Preview result with affected count and samples
182
+ */
183
+ async preview(updateData) {
184
+ this.validateSetup();
185
+ if (!isValidUpdateData(updateData)) {
186
+ throw new Error("Update data must be a non-empty object");
187
+ }
188
+ const query = this.buildQuery();
189
+ const snapshot = await query.get();
190
+ const affectedCount = snapshot.size;
191
+ const affectedFields = getAffectedFields(updateData);
192
+ const samples = [];
193
+ const sampleDocs = snapshot.docs.slice(0, 10);
194
+ for (const doc of sampleDocs) {
195
+ const before = doc.data();
196
+ const after = mergeUpdateData(before, updateData);
197
+ samples.push({
198
+ id: doc.id,
199
+ before,
200
+ after
201
+ });
202
+ }
203
+ return {
204
+ affectedCount,
205
+ samples,
206
+ affectedFields
207
+ };
208
+ }
209
+ /**
210
+ * Execute batch update operation
211
+ * @param updateData - Data to update
212
+ * @param options - Update options (e.g., progress callback, log options, batchSize for pagination)
213
+ * @returns Update result with success/failure counts and optional log file path
214
+ */
215
+ async update(updateData, options = {}) {
216
+ this.validateSetup();
217
+ if (!isValidUpdateData(updateData)) {
218
+ throw new Error("Update data must be a non-empty object");
219
+ }
220
+ const logCollector = options.log?.enabled ? createLogCollector("update", this.collectionPath, this.conditions, updateData) : null;
221
+ let successCount = 0;
222
+ let failureCount = 0;
223
+ let totalCount = 0;
224
+ const failedDocIds = [];
225
+ if (options.batchSize && options.batchSize > 0) {
226
+ const countQuery = this.buildQuery();
227
+ const countSnapshot = await countQuery.count().get();
228
+ totalCount = countSnapshot.data().count;
229
+ if (totalCount === 0) {
230
+ const result2 = {
231
+ successCount: 0,
232
+ failureCount: 0,
233
+ totalCount: 0
234
+ };
235
+ if (logCollector && options.log) {
236
+ result2.logFilePath = logCollector.finalize(options.log);
237
+ }
238
+ return result2;
239
+ }
240
+ let processedCount = 0;
241
+ let lastDoc = null;
242
+ while (true) {
243
+ let paginatedQuery = this.buildQuery().limit(options.batchSize);
244
+ if (lastDoc) {
245
+ paginatedQuery = paginatedQuery.startAfter(lastDoc);
246
+ }
247
+ const snapshot = await paginatedQuery.get();
248
+ if (snapshot.empty) {
249
+ break;
250
+ }
251
+ const bulkWriter = this.firestore.bulkWriter();
252
+ const docIdMap = /* @__PURE__ */ new Map();
253
+ for (const doc of snapshot.docs) {
254
+ docIdMap.set(doc.ref.path, doc.id);
255
+ }
256
+ bulkWriter.onWriteResult((ref) => {
257
+ successCount++;
258
+ processedCount++;
259
+ const docId = docIdMap.get(ref.path) || ref.id;
260
+ logCollector?.addEntry(docId, "success");
261
+ if (options.onProgress) {
262
+ const progress = calculateProgress(processedCount, totalCount);
263
+ options.onProgress(progress);
264
+ }
265
+ });
266
+ bulkWriter.onWriteError((error) => {
267
+ failureCount++;
268
+ processedCount++;
269
+ const docId = error.documentRef?.id || "unknown";
270
+ failedDocIds.push(docId);
271
+ logCollector?.addEntry(docId, "failure", error.message);
272
+ if (options.onProgress) {
273
+ const progress = calculateProgress(processedCount, totalCount);
274
+ options.onProgress(progress);
275
+ }
276
+ return false;
277
+ });
278
+ for (const doc of snapshot.docs) {
279
+ bulkWriter.update(doc.ref, updateData);
280
+ }
281
+ await bulkWriter.close();
282
+ lastDoc = snapshot.docs[snapshot.docs.length - 1];
283
+ if (snapshot.docs.length < options.batchSize) {
284
+ break;
285
+ }
286
+ }
287
+ } else {
288
+ const query = this.buildQuery();
289
+ const snapshot = await query.get();
290
+ totalCount = snapshot.size;
291
+ if (totalCount === 0) {
292
+ const result2 = {
293
+ successCount: 0,
294
+ failureCount: 0,
295
+ totalCount: 0
296
+ };
297
+ if (logCollector && options.log) {
298
+ result2.logFilePath = logCollector.finalize(options.log);
299
+ }
300
+ return result2;
301
+ }
302
+ const bulkWriter = this.firestore.bulkWriter();
303
+ let processedCount = 0;
304
+ const docIdMap = /* @__PURE__ */ new Map();
305
+ for (const doc of snapshot.docs) {
306
+ docIdMap.set(doc.ref.path, doc.id);
307
+ }
308
+ bulkWriter.onWriteResult((ref) => {
309
+ successCount++;
310
+ processedCount++;
311
+ const docId = docIdMap.get(ref.path) || ref.id;
312
+ logCollector?.addEntry(docId, "success");
313
+ if (options.onProgress) {
314
+ const progress = calculateProgress(processedCount, totalCount);
315
+ options.onProgress(progress);
316
+ }
317
+ });
318
+ bulkWriter.onWriteError((error) => {
319
+ failureCount++;
320
+ processedCount++;
321
+ const docId = error.documentRef?.id || "unknown";
322
+ failedDocIds.push(docId);
323
+ logCollector?.addEntry(docId, "failure", error.message);
324
+ if (options.onProgress) {
325
+ const progress = calculateProgress(processedCount, totalCount);
326
+ options.onProgress(progress);
327
+ }
328
+ return false;
329
+ });
330
+ for (const doc of snapshot.docs) {
331
+ bulkWriter.update(doc.ref, updateData);
332
+ }
333
+ await bulkWriter.close();
334
+ }
335
+ const result = {
336
+ successCount,
337
+ failureCount,
338
+ totalCount,
339
+ failedDocIds: failedDocIds.length > 0 ? failedDocIds : void 0
340
+ };
341
+ if (logCollector && options.log) {
342
+ result.logFilePath = logCollector.finalize(options.log);
343
+ }
344
+ return result;
345
+ }
346
+ /**
347
+ * Get specific field values from matching documents
348
+ * @param fieldPath - Field path to retrieve
349
+ * @returns Array of field values with document IDs
350
+ */
351
+ async getFields(fieldPath) {
352
+ this.validateSetup();
353
+ const query = this.buildQuery();
354
+ const snapshot = await query.get();
355
+ const results = [];
356
+ for (const doc of snapshot.docs) {
357
+ const data = doc.data();
358
+ const value = this.getNestedValue(data, fieldPath);
359
+ results.push({
360
+ id: doc.id,
361
+ value
362
+ });
363
+ }
364
+ return results;
365
+ }
366
+ /**
367
+ * Create multiple documents in batch
368
+ * @param documents - Array of documents to create
369
+ * @param options - Create options (e.g., progress callback, log options)
370
+ * @returns Create result with success/failure counts, created IDs, and optional log file path
371
+ */
372
+ async create(documents, options = {}) {
373
+ this.validateSetup();
374
+ if (!Array.isArray(documents) || documents.length === 0) {
375
+ throw new Error("Documents array must be non-empty");
376
+ }
377
+ for (const doc of documents) {
378
+ if (!isValidUpdateData(doc.data)) {
379
+ throw new Error("Each document must have valid data");
380
+ }
381
+ }
382
+ const totalCount = documents.length;
383
+ let successCount = 0;
384
+ let failureCount = 0;
385
+ const createdIds = [];
386
+ const failedDocIds = [];
387
+ const logCollector = options.log?.enabled ? createLogCollector("create", this.collectionPath) : null;
388
+ const bulkWriter = this.firestore.bulkWriter();
389
+ const collection = this.firestore.collection(this.collectionPath);
390
+ let processedCount = 0;
391
+ bulkWriter.onWriteResult((ref) => {
392
+ successCount++;
393
+ processedCount++;
394
+ createdIds.push(ref.id);
395
+ logCollector?.addEntry(ref.id, "success");
396
+ if (options.onProgress) {
397
+ const progress = calculateProgress(processedCount, totalCount);
398
+ options.onProgress(progress);
399
+ }
400
+ });
401
+ bulkWriter.onWriteError((error) => {
402
+ failureCount++;
403
+ processedCount++;
404
+ const docId = error.documentRef?.id || "unknown";
405
+ failedDocIds.push(docId);
406
+ logCollector?.addEntry(docId, "failure", error.message);
407
+ if (options.onProgress) {
408
+ const progress = calculateProgress(processedCount, totalCount);
409
+ options.onProgress(progress);
410
+ }
411
+ return false;
412
+ });
413
+ for (const doc of documents) {
414
+ const docRef = doc.id ? collection.doc(doc.id) : collection.doc();
415
+ bulkWriter.create(docRef, doc.data);
416
+ }
417
+ await bulkWriter.close();
418
+ const result = {
419
+ successCount,
420
+ failureCount,
421
+ totalCount,
422
+ createdIds,
423
+ failedDocIds: failedDocIds.length > 0 ? failedDocIds : void 0
424
+ };
425
+ if (logCollector && options.log) {
426
+ result.logFilePath = logCollector.finalize(options.log);
427
+ }
428
+ return result;
429
+ }
430
+ /**
431
+ * Upsert documents matching query conditions
432
+ * Updates existing documents or creates them if they don't exist
433
+ * @param updateData - Data to set/merge
434
+ * @param options - Upsert options (e.g., progress callback, log options, batchSize for pagination)
435
+ * @returns Upsert result with success/failure counts and optional log file path
436
+ */
437
+ async upsert(updateData, options = {}) {
438
+ this.validateSetup();
439
+ if (!isValidUpdateData(updateData)) {
440
+ throw new Error("Update data must be a non-empty object");
441
+ }
442
+ const logCollector = options.log?.enabled ? createLogCollector("upsert", this.collectionPath, this.conditions, updateData) : null;
443
+ let successCount = 0;
444
+ let failureCount = 0;
445
+ let totalCount = 0;
446
+ const failedDocIds = [];
447
+ if (options.batchSize && options.batchSize > 0) {
448
+ const countQuery = this.buildQuery();
449
+ const countSnapshot = await countQuery.count().get();
450
+ totalCount = countSnapshot.data().count;
451
+ if (totalCount === 0) {
452
+ const result2 = {
453
+ successCount: 0,
454
+ failureCount: 0,
455
+ totalCount: 0
456
+ };
457
+ if (logCollector && options.log) {
458
+ result2.logFilePath = logCollector.finalize(options.log);
459
+ }
460
+ return result2;
461
+ }
462
+ let processedCount = 0;
463
+ let lastDoc = null;
464
+ while (true) {
465
+ let paginatedQuery = this.buildQuery().limit(options.batchSize);
466
+ if (lastDoc) {
467
+ paginatedQuery = paginatedQuery.startAfter(lastDoc);
468
+ }
469
+ const snapshot = await paginatedQuery.get();
470
+ if (snapshot.empty) {
471
+ break;
472
+ }
473
+ const bulkWriter = this.firestore.bulkWriter();
474
+ const docIdMap = /* @__PURE__ */ new Map();
475
+ for (const doc of snapshot.docs) {
476
+ docIdMap.set(doc.ref.path, doc.id);
477
+ }
478
+ bulkWriter.onWriteResult((ref) => {
479
+ successCount++;
480
+ processedCount++;
481
+ const docId = docIdMap.get(ref.path) || ref.id;
482
+ logCollector?.addEntry(docId, "success");
483
+ if (options.onProgress) {
484
+ const progress = calculateProgress(processedCount, totalCount);
485
+ options.onProgress(progress);
486
+ }
487
+ });
488
+ bulkWriter.onWriteError((error) => {
489
+ failureCount++;
490
+ processedCount++;
491
+ const docId = error.documentRef?.id || "unknown";
492
+ failedDocIds.push(docId);
493
+ logCollector?.addEntry(docId, "failure", error.message);
494
+ if (options.onProgress) {
495
+ const progress = calculateProgress(processedCount, totalCount);
496
+ options.onProgress(progress);
497
+ }
498
+ return false;
499
+ });
500
+ for (const doc of snapshot.docs) {
501
+ bulkWriter.set(doc.ref, updateData, { merge: true });
502
+ }
503
+ await bulkWriter.close();
504
+ lastDoc = snapshot.docs[snapshot.docs.length - 1];
505
+ if (snapshot.docs.length < options.batchSize) {
506
+ break;
507
+ }
508
+ }
509
+ } else {
510
+ const query = this.buildQuery();
511
+ const snapshot = await query.get();
512
+ totalCount = snapshot.size;
513
+ if (totalCount === 0) {
514
+ const result2 = {
515
+ successCount: 0,
516
+ failureCount: 0,
517
+ totalCount: 0
518
+ };
519
+ if (logCollector && options.log) {
520
+ result2.logFilePath = logCollector.finalize(options.log);
521
+ }
522
+ return result2;
523
+ }
524
+ const bulkWriter = this.firestore.bulkWriter();
525
+ let processedCount = 0;
526
+ const docIdMap = /* @__PURE__ */ new Map();
527
+ for (const doc of snapshot.docs) {
528
+ docIdMap.set(doc.ref.path, doc.id);
529
+ }
530
+ bulkWriter.onWriteResult((ref) => {
531
+ successCount++;
532
+ processedCount++;
533
+ const docId = docIdMap.get(ref.path) || ref.id;
534
+ logCollector?.addEntry(docId, "success");
535
+ if (options.onProgress) {
536
+ const progress = calculateProgress(processedCount, totalCount);
537
+ options.onProgress(progress);
538
+ }
539
+ });
540
+ bulkWriter.onWriteError((error) => {
541
+ failureCount++;
542
+ processedCount++;
543
+ const docId = error.documentRef?.id || "unknown";
544
+ failedDocIds.push(docId);
545
+ logCollector?.addEntry(docId, "failure", error.message);
546
+ if (options.onProgress) {
547
+ const progress = calculateProgress(processedCount, totalCount);
548
+ options.onProgress(progress);
549
+ }
550
+ return false;
551
+ });
552
+ for (const doc of snapshot.docs) {
553
+ bulkWriter.set(doc.ref, updateData, { merge: true });
554
+ }
555
+ await bulkWriter.close();
556
+ }
557
+ const result = {
558
+ successCount,
559
+ failureCount,
560
+ totalCount,
561
+ failedDocIds: failedDocIds.length > 0 ? failedDocIds : void 0
562
+ };
563
+ if (logCollector && options.log) {
564
+ result.logFilePath = logCollector.finalize(options.log);
565
+ }
566
+ return result;
567
+ }
568
+ /**
569
+ * Validate that collection is set
570
+ * @private
571
+ */
572
+ validateSetup() {
573
+ if (!this.collectionPath) {
574
+ throw new Error("Collection path is required. Call .collection() first.");
575
+ }
576
+ }
577
+ /**
578
+ * Build Firestore query with all conditions
579
+ * @private
580
+ */
581
+ buildQuery() {
582
+ let query = this.firestore.collection(
583
+ this.collectionPath
584
+ );
585
+ for (const condition of this.conditions) {
586
+ query = query.where(condition.field, condition.operator, condition.value);
587
+ }
588
+ return query;
589
+ }
590
+ /**
591
+ * Get nested value from object using dot notation
592
+ * @private
593
+ */
594
+ getNestedValue(obj, path2) {
595
+ const keys = path2.split(".");
596
+ let current = obj;
597
+ for (const key of keys) {
598
+ if (current === null || current === void 0) {
599
+ return void 0;
600
+ }
601
+ current = current[key];
602
+ }
603
+ return current;
604
+ }
605
+ };
606
+ export {
607
+ BatchUpdater,
608
+ calculateProgress,
609
+ createLogCollector,
610
+ formatError,
611
+ formatOperationLog,
612
+ getAffectedFields,
613
+ isValidUpdateData,
614
+ mergeUpdateData,
615
+ writeOperationLog
616
+ };
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "firestore-batch-updater",
3
+ "version": "1.0.0",
4
+ "description": "Batch update Firestore documents with query-based filtering and preview",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "require": "./dist/index.js",
12
+ "import": "./dist/index.mjs"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsup src/index.ts --format cjs,esm --dts --clean",
20
+ "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
21
+ "lint": "eslint src --ext .ts",
22
+ "format": "prettier --write \"src/**/*.ts\"",
23
+ "prepublishOnly": "npm run build"
24
+ },
25
+ "keywords": [
26
+ "firebase",
27
+ "firestore",
28
+ "batch",
29
+ "update",
30
+ "bulk",
31
+ "query"
32
+ ],
33
+ "author": "exiivy98",
34
+ "license": "MIT",
35
+ "engines": {
36
+ "node": ">=18.0.0"
37
+ },
38
+ "peerDependencies": {
39
+ "firebase-admin": "^13.0.0"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^25.0.3",
43
+ "@typescript-eslint/eslint-plugin": "^8.50.1",
44
+ "@typescript-eslint/parser": "^8.50.1",
45
+ "eslint": "^9.39.2",
46
+ "firebase-admin": "^13.6.0",
47
+ "prettier": "^3.7.4",
48
+ "tsup": "^8.5.1",
49
+ "typescript": "^5.9.3"
50
+ },
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "https://github.com/exiivy98/firestore-batch-updater.git"
54
+ }
55
+ }