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/LICENSE +21 -0
- package/README.ko.md +334 -0
- package/README.md +334 -0
- package/dist/index.d.mts +316 -0
- package/dist/index.d.ts +316 -0
- package/dist/index.js +661 -0
- package/dist/index.mjs +616 -0
- package/package.json +55 -0
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
|
+
}
|