firestore-batch-updater 1.1.0 → 1.3.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/README.ko.md CHANGED
@@ -14,7 +14,12 @@
14
14
  - 진행 상황 추적 - 실시간 진행률 콜백
15
15
  - 일괄 생성/Upsert/삭제 - 여러 문서를 한 번에 생성, upsert 또는 삭제
16
16
  - 정렬 및 제한 - `orderBy()`와 `limit()`으로 정밀한 제어
17
- - FieldValue 지원 - `increment()`, `arrayUnion()`, `serverTimestamp()` 사용 가능
17
+ - 필드 선택 - `select()`로 필요한 필드만 로드 (메모리 비용 절약)
18
+ - 단일 문서 조회 - `findOne()`으로 효율적인 단일 문서 검색
19
+ - FieldValue 지원 - `increment()`, `arrayUnion()`, `delete()`, `serverTimestamp()` 등 사용 가능
20
+ - 서브컬렉션 & 컬렉션 그룹 - 서브컬렉션 쿼리 또는 동일 이름의 모든 컬렉션 쿼리
21
+ - Dry Run 모드 - 실제 변경 없이 작업 시뮬레이션
22
+ - 문서 개수 조회 - 문서를 로드하지 않고 빠르게 개수 확인
18
23
  - 로그 파일 생성 - 감사를 위한 상세 작업 로그 (선택사항)
19
24
 
20
25
  ## 설치
@@ -75,10 +80,14 @@ console.log(`${result.successCount}개 문서 업데이트 완료`);
75
80
 
76
81
  | 메서드 | 설명 | 반환값 |
77
82
  |--------|------|--------|
78
- | `collection(path)` | 작업할 컬렉션 선택 | `this` |
83
+ | `collection(path)` | 작업할 컬렉션 선택 (서브컬렉션 경로 지원) | `this` |
84
+ | `collectionGroup(id)` | 동일 ID의 모든 컬렉션 쿼리 | `this` |
79
85
  | `where(field, op, value)` | 필터 조건 추가 (체이닝 가능) | `this` |
80
86
  | `orderBy(field, direction?)` | 정렬 추가 (체이닝 가능) | `this` |
81
87
  | `limit(count)` | 문서 수 제한 (체이닝 가능) | `this` |
88
+ | `select(...fields)` | 특정 필드만 조회 (체이닝 가능) | `this` |
89
+ | `count()` | 매칭되는 문서 개수 조회 | `CountResult` |
90
+ | `findOne()` | 첫 번째 매칭 문서 조회 | `{ id, data } \| null` |
82
91
  | `preview(data)` | 업데이트 전 미리보기 | `PreviewResult` |
83
92
  | `update(data, options?)` | 매칭되는 문서 업데이트 | `UpdateResult` |
84
93
  | `create(docs, options?)` | 새 문서 생성 | `CreateResult` |
@@ -95,6 +104,7 @@ console.log(`${result.successCount}개 문서 업데이트 완료`);
95
104
  onProgress?: (progress: ProgressInfo) => void;
96
105
  log?: LogOptions;
97
106
  batchSize?: number; // update/upsert/delete 전용
107
+ dryRun?: boolean; // update/upsert/delete 전용 - 실제 쓰기 없이 시뮬레이션
98
108
  }
99
109
 
100
110
  // ProgressInfo
@@ -116,10 +126,15 @@ console.log(`${result.successCount}개 문서 업데이트 완료`);
116
126
  - 미설정: 모든 문서를 메모리에 한 번에 로드 (소규모 컬렉션에 적합)
117
127
  - 설정 시 (예: `batchSize: 1000`): 커서 페이지네이션을 사용하여 배치 단위로 처리 (대규모 컬렉션의 메모리 문제 방지)
118
128
 
129
+ **dryRun 옵션:**
130
+ - `true` 설정 시: 실제 변경 없이 `DryRunResult` 반환 (`wouldAffect` 개수와 `sampleIds` 포함)
131
+
119
132
  ### 반환 타입
120
133
 
121
134
  | 타입 | 필드 |
122
135
  |------|------|
136
+ | `CountResult` | `count` |
137
+ | `DryRunResult` | `wouldAffect`, `sampleIds[]`, `operation` |
123
138
  | `PreviewResult` | `affectedCount`, `samples[]`, `affectedFields[]` |
124
139
  | `UpdateResult` | `successCount`, `failureCount`, `totalCount`, `failedDocIds?`, `logFilePath?` |
125
140
  | `CreateResult` | `successCount`, `failureCount`, `totalCount`, `createdIds[]`, `failedDocIds?`, `logFilePath?` |
@@ -285,6 +300,124 @@ await updater
285
300
  .collection("users")
286
301
  .where("status", "==", "active")
287
302
  .update({ updatedAt: FieldValue.serverTimestamp() });
303
+
304
+ // 필드 삭제
305
+ await updater
306
+ .collection("users")
307
+ .where("status", "==", "inactive")
308
+ .update({ temporaryData: FieldValue.delete() });
309
+ ```
310
+
311
+ ### 문서 개수 조회
312
+
313
+ ```typescript
314
+ // 문서를 로드하지 않고 빠르게 개수 조회
315
+ const result = await updater
316
+ .collection("users")
317
+ .where("status", "==", "inactive")
318
+ .count();
319
+
320
+ console.log(`${result.count}명의 비활성 사용자 발견`);
321
+ ```
322
+
323
+ ### 특정 필드만 조회
324
+
325
+ ```typescript
326
+ // name, email 필드만 로드 (메모리 및 읽기 비용 절약)
327
+ const result = await updater
328
+ .collection("users")
329
+ .select("name", "email")
330
+ .where("status", "==", "active")
331
+ .findOne();
332
+
333
+ console.log(result?.data); // { name, email }만 포함
334
+
335
+ // 모든 작업에서 사용 가능 - 문서에 선택된 필드만 포함됨
336
+ const emails = await updater
337
+ .collection("users")
338
+ .select("email")
339
+ .where("verified", "==", true)
340
+ .getFields("email");
341
+ ```
342
+
343
+ ### 단일 문서 조회
344
+
345
+ ```typescript
346
+ // 첫 번째 매칭 문서 찾기
347
+ const user = await updater
348
+ .collection("users")
349
+ .where("email", "==", "user@example.com")
350
+ .findOne();
351
+
352
+ if (user) {
353
+ console.log("사용자 발견:", user.id);
354
+ console.log("사용자 데이터:", user.data);
355
+ } else {
356
+ console.log("사용자를 찾을 수 없음");
357
+ }
358
+
359
+ // select와 함께 사용하여 효율적인 조회
360
+ const profile = await updater
361
+ .collection("users")
362
+ .select("name", "avatar", "tier")
363
+ .where("username", "==", "johndoe")
364
+ .findOne();
365
+ ```
366
+
367
+ ### Dry Run 모드
368
+
369
+ ```typescript
370
+ // 실제 변경 없이 작업 시뮬레이션
371
+ const simulation = await updater
372
+ .collection("users")
373
+ .where("status", "==", "inactive")
374
+ .update(
375
+ { status: "archived" },
376
+ { dryRun: true }
377
+ );
378
+
379
+ console.log(`${simulation.wouldAffect}개 문서가 영향을 받을 예정`);
380
+ console.log("샘플 ID:", simulation.sampleIds);
381
+
382
+ // 삭제에도 사용 가능
383
+ const deleteSimulation = await updater
384
+ .collection("logs")
385
+ .where("createdAt", "<", thirtyDaysAgo)
386
+ .delete({ dryRun: true });
387
+
388
+ console.log(`${deleteSimulation.wouldAffect}개 문서가 삭제될 예정`);
389
+ ```
390
+
391
+ ### 서브컬렉션
392
+
393
+ ```typescript
394
+ // 특정 서브컬렉션 경로 쿼리
395
+ const result = await updater
396
+ .collection("users/user-123/orders")
397
+ .where("status", "==", "pending")
398
+ .update({ status: "cancelled" });
399
+
400
+ // 동적 경로 사용
401
+ const userId = "user-123";
402
+ await updater
403
+ .collection(`users/${userId}/notifications`)
404
+ .where("read", "==", false)
405
+ .delete();
406
+ ```
407
+
408
+ ### 컬렉션 그룹 쿼리
409
+
410
+ ```typescript
411
+ // 모든 사용자의 "orders" 서브컬렉션을 한 번에 쿼리
412
+ const result = await updater
413
+ .collectionGroup("orders")
414
+ .where("status", "==", "pending")
415
+ .where("createdAt", "<", thirtyDaysAgo)
416
+ .update({ status: "expired" });
417
+
418
+ console.log(`${result.successCount}개 주문 업데이트 완료`);
419
+
420
+ // 참고: collectionGroup은 쿼리 필드에 대한 Firestore 인덱스가 필요합니다
288
421
  ```
289
422
 
290
423
  > **참고:** 서로 다른 필드에 여러 `where()` 조건을 사용하거나, `where()`와 `orderBy()`를 다른 필드에 사용할 경우, Firestore에서 [복합 인덱스](https://firebase.google.com/docs/firestore/query-data/indexing)가 필요할 수 있습니다. `FAILED_PRECONDITION` 오류가 발생하면 오류 메시지의 링크를 통해 필요한 인덱스를 생성하세요.
package/README.md CHANGED
@@ -14,7 +14,12 @@ English | [한국어](./README.ko.md)
14
14
  - Progress tracking - Real-time progress callbacks
15
15
  - Batch create/upsert/delete - Create, upsert, or delete multiple documents at once
16
16
  - Sorting and limiting - Use `orderBy()` and `limit()` for precise control
17
- - FieldValue support - Use `increment()`, `arrayUnion()`, `serverTimestamp()`, etc.
17
+ - Field selection - Use `select()` to load only needed fields (saves memory and costs)
18
+ - Find single document - Use `findOne()` for efficient single-document retrieval
19
+ - FieldValue support - Use `increment()`, `arrayUnion()`, `delete()`, `serverTimestamp()`, etc.
20
+ - Subcollection & Collection Group - Query subcollections or all collections with the same name
21
+ - Dry run mode - Simulate operations without making changes
22
+ - Count documents - Quickly count matching documents without loading them
18
23
  - Log file generation - Optional detailed operation logs for auditing
19
24
 
20
25
  ## Installation
@@ -75,10 +80,14 @@ console.log(`Updated ${result.successCount} documents`);
75
80
 
76
81
  | Method | Description | Returns |
77
82
  |--------|-------------|---------|
78
- | `collection(path)` | Select collection to operate on | `this` |
83
+ | `collection(path)` | Select collection to operate on (supports subcollection paths) | `this` |
84
+ | `collectionGroup(id)` | Query all collections with the same ID | `this` |
79
85
  | `where(field, op, value)` | Add filter condition (chainable) | `this` |
80
86
  | `orderBy(field, direction?)` | Add sorting (chainable) | `this` |
81
87
  | `limit(count)` | Limit number of documents (chainable) | `this` |
88
+ | `select(...fields)` | Select specific fields to retrieve (chainable) | `this` |
89
+ | `count()` | Count matching documents | `CountResult` |
90
+ | `findOne()` | Find first matching document | `{ id, data } \| null` |
82
91
  | `preview(data)` | Preview changes before update | `PreviewResult` |
83
92
  | `update(data, options?)` | Update matching documents | `UpdateResult` |
84
93
  | `create(docs, options?)` | Create new documents | `CreateResult` |
@@ -95,6 +104,7 @@ All write operations support an optional `options` parameter:
95
104
  onProgress?: (progress: ProgressInfo) => void;
96
105
  log?: LogOptions;
97
106
  batchSize?: number; // For update/upsert/delete
107
+ dryRun?: boolean; // For update/upsert/delete - simulate without writing
98
108
  }
99
109
 
100
110
  // ProgressInfo
@@ -116,10 +126,15 @@ All write operations support an optional `options` parameter:
116
126
  - When not set: All documents are loaded into memory at once (suitable for small collections)
117
127
  - When set (e.g., `batchSize: 1000`): Documents are processed in batches using cursor pagination (suitable for large collections to prevent memory issues)
118
128
 
129
+ **dryRun option:**
130
+ - When `true`: Returns `DryRunResult` with `wouldAffect` count and `sampleIds` without making any changes
131
+
119
132
  ### Return Types
120
133
 
121
134
  | Type | Fields |
122
135
  |------|--------|
136
+ | `CountResult` | `count` |
137
+ | `DryRunResult` | `wouldAffect`, `sampleIds[]`, `operation` |
123
138
  | `PreviewResult` | `affectedCount`, `samples[]`, `affectedFields[]` |
124
139
  | `UpdateResult` | `successCount`, `failureCount`, `totalCount`, `failedDocIds?`, `logFilePath?` |
125
140
  | `CreateResult` | `successCount`, `failureCount`, `totalCount`, `createdIds[]`, `failedDocIds?`, `logFilePath?` |
@@ -284,6 +299,124 @@ await updater
284
299
  .collection("users")
285
300
  .where("status", "==", "active")
286
301
  .update({ lastSeen: FieldValue.serverTimestamp() });
302
+
303
+ // Delete a field
304
+ await updater
305
+ .collection("users")
306
+ .where("status", "==", "inactive")
307
+ .update({ temporaryData: FieldValue.delete() });
308
+ ```
309
+
310
+ ### Count Documents
311
+
312
+ ```typescript
313
+ // Quickly count matching documents without loading them
314
+ const result = await updater
315
+ .collection("users")
316
+ .where("status", "==", "inactive")
317
+ .count();
318
+
319
+ console.log(`Found ${result.count} inactive users`);
320
+ ```
321
+
322
+ ### Select Specific Fields
323
+
324
+ ```typescript
325
+ // Only load name and email fields (reduces memory and read costs)
326
+ const result = await updater
327
+ .collection("users")
328
+ .select("name", "email")
329
+ .where("status", "==", "active")
330
+ .findOne();
331
+
332
+ console.log(result?.data); // Only contains { name, email }
333
+
334
+ // Works with all operations - documents will only have selected fields
335
+ const emails = await updater
336
+ .collection("users")
337
+ .select("email")
338
+ .where("verified", "==", true)
339
+ .getFields("email");
340
+ ```
341
+
342
+ ### Find Single Document
343
+
344
+ ```typescript
345
+ // Find first matching document
346
+ const user = await updater
347
+ .collection("users")
348
+ .where("email", "==", "user@example.com")
349
+ .findOne();
350
+
351
+ if (user) {
352
+ console.log("Found user:", user.id);
353
+ console.log("User data:", user.data);
354
+ } else {
355
+ console.log("User not found");
356
+ }
357
+
358
+ // Combine with select for efficient lookup
359
+ const profile = await updater
360
+ .collection("users")
361
+ .select("name", "avatar", "tier")
362
+ .where("username", "==", "johndoe")
363
+ .findOne();
364
+ ```
365
+
366
+ ### Dry Run Mode
367
+
368
+ ```typescript
369
+ // Simulate an operation without making any changes
370
+ const simulation = await updater
371
+ .collection("users")
372
+ .where("status", "==", "inactive")
373
+ .update(
374
+ { status: "archived" },
375
+ { dryRun: true }
376
+ );
377
+
378
+ console.log(`Would affect ${simulation.wouldAffect} documents`);
379
+ console.log("Sample IDs:", simulation.sampleIds);
380
+
381
+ // Also works with delete
382
+ const deleteSimulation = await updater
383
+ .collection("logs")
384
+ .where("createdAt", "<", thirtyDaysAgo)
385
+ .delete({ dryRun: true });
386
+
387
+ console.log(`Would delete ${deleteSimulation.wouldAffect} documents`);
388
+ ```
389
+
390
+ ### Subcollections
391
+
392
+ ```typescript
393
+ // Query a specific subcollection path
394
+ const result = await updater
395
+ .collection("users/user-123/orders")
396
+ .where("status", "==", "pending")
397
+ .update({ status: "cancelled" });
398
+
399
+ // Or use dynamic paths
400
+ const userId = "user-123";
401
+ await updater
402
+ .collection(`users/${userId}/notifications`)
403
+ .where("read", "==", false)
404
+ .delete();
405
+ ```
406
+
407
+ ### Collection Group Queries
408
+
409
+ ```typescript
410
+ // Query ALL "orders" subcollections across all users
411
+ const result = await updater
412
+ .collectionGroup("orders")
413
+ .where("status", "==", "pending")
414
+ .where("createdAt", "<", thirtyDaysAgo)
415
+ .update({ status: "expired" });
416
+
417
+ console.log(`Updated ${result.successCount} orders across all users`);
418
+
419
+ // Note: collectionGroup requires a Firestore index on the queried fields
287
420
  ```
288
421
 
289
422
  ### Error Handling
package/dist/index.d.mts CHANGED
@@ -12,6 +12,9 @@ interface ProgressInfo {
12
12
  current: number;
13
13
  total: number;
14
14
  percentage: number;
15
+ elapsedTime: number;
16
+ docsPerSecond: number;
17
+ eta: number;
15
18
  }
16
19
  /**
17
20
  * Options for update operations
@@ -32,6 +35,19 @@ interface UpdateOptions {
32
35
  * When not set, all documents are loaded at once
33
36
  */
34
37
  batchSize?: number;
38
+ /**
39
+ * Dry run mode - simulate the operation without actually writing
40
+ * Returns what would happen without making any changes
41
+ */
42
+ dryRun?: boolean;
43
+ /**
44
+ * Maximum number of retry attempts for failed operations (default: 0)
45
+ */
46
+ retries?: number;
47
+ /**
48
+ * Delay between retry attempts in milliseconds (default: 1000)
49
+ */
50
+ retryDelay?: number;
35
51
  }
36
52
  /**
37
53
  * Result of batch update operation
@@ -130,6 +146,19 @@ interface UpsertOptions {
130
146
  * When not set, all documents are loaded at once
131
147
  */
132
148
  batchSize?: number;
149
+ /**
150
+ * Dry run mode - simulate the operation without actually writing
151
+ * Returns what would happen without making any changes
152
+ */
153
+ dryRun?: boolean;
154
+ /**
155
+ * Maximum number of retry attempts for failed operations (default: 0)
156
+ */
157
+ retries?: number;
158
+ /**
159
+ * Delay between retry attempts in milliseconds (default: 1000)
160
+ */
161
+ retryDelay?: number;
133
162
  }
134
163
  /**
135
164
  * Result of batch upsert operation
@@ -159,6 +188,19 @@ interface DeleteOptions {
159
188
  * When not set, all documents are loaded at once
160
189
  */
161
190
  batchSize?: number;
191
+ /**
192
+ * Dry run mode - simulate the operation without actually writing
193
+ * Returns what would happen without making any changes
194
+ */
195
+ dryRun?: boolean;
196
+ /**
197
+ * Maximum number of retry attempts for failed operations (default: 0)
198
+ */
199
+ retries?: number;
200
+ /**
201
+ * Delay between retry attempts in milliseconds (default: 1000)
202
+ */
203
+ retryDelay?: number;
162
204
  }
163
205
  /**
164
206
  * Result of batch delete operation
@@ -170,6 +212,20 @@ interface DeleteResult {
170
212
  deletedIds: string[];
171
213
  failedDocIds?: string[];
172
214
  }
215
+ /**
216
+ * Result of count operation
217
+ */
218
+ interface CountResult {
219
+ count: number;
220
+ }
221
+ /**
222
+ * Result of dry run operation
223
+ */
224
+ interface DryRunResult {
225
+ wouldAffect: number;
226
+ sampleIds: string[];
227
+ operation: "update" | "upsert" | "delete";
228
+ }
173
229
  /**
174
230
  * Log options for batch operations
175
231
  */
@@ -215,9 +271,11 @@ interface OperationLog {
215
271
  declare class BatchUpdater {
216
272
  private firestore;
217
273
  private collectionPath?;
274
+ private isCollectionGroup;
218
275
  private conditions;
219
276
  private orderByConditions;
220
277
  private limitCount?;
278
+ private selectedFields?;
221
279
  /**
222
280
  * Create a new BatchUpdater instance
223
281
  * @param firestore - Initialized Firestore instance from firebase-admin
@@ -225,10 +283,17 @@ declare class BatchUpdater {
225
283
  constructor(firestore: Firestore);
226
284
  /**
227
285
  * Select a collection to operate on
286
+ * Supports subcollection paths like "users/userId/orders"
228
287
  * @param path - Collection path
229
288
  * @returns This instance for chaining
230
289
  */
231
290
  collection(path: string): this;
291
+ /**
292
+ * Select a collection group to operate on (queries across all subcollections with the same name)
293
+ * @param collectionId - Collection ID (not a path, just the collection name)
294
+ * @returns This instance for chaining
295
+ */
296
+ collectionGroup(collectionId: string): this;
232
297
  /**
233
298
  * Add a where condition to filter documents
234
299
  * @param field - Field path
@@ -250,6 +315,25 @@ declare class BatchUpdater {
250
315
  * @returns This instance for chaining
251
316
  */
252
317
  limit(count: number): this;
318
+ /**
319
+ * Select specific fields to retrieve (reduces memory usage and read costs)
320
+ * @param fields - Field paths to retrieve
321
+ * @returns This instance for chaining
322
+ */
323
+ select(...fields: string[]): this;
324
+ /**
325
+ * Count documents matching the query conditions
326
+ * @returns Count result with number of matching documents
327
+ */
328
+ count(): Promise<CountResult>;
329
+ /**
330
+ * Find the first document matching the query conditions
331
+ * @returns First matching document with id and data, or null if not found
332
+ */
333
+ findOne(): Promise<{
334
+ id: string;
335
+ data: Record<string, any>;
336
+ } | null>;
253
337
  /**
254
338
  * Preview changes before executing update
255
339
  * @param updateData - Data to update
@@ -259,12 +343,12 @@ declare class BatchUpdater {
259
343
  /**
260
344
  * Execute batch update operation
261
345
  * @param updateData - Data to update
262
- * @param options - Update options (e.g., progress callback, log options, batchSize for pagination)
263
- * @returns Update result with success/failure counts and optional log file path
346
+ * @param options - Update options (e.g., progress callback, log options, batchSize for pagination, dryRun)
347
+ * @returns Update result with success/failure counts and optional log file path, or DryRunResult if dryRun is true
264
348
  */
265
- update(updateData: Record<string, any>, options?: UpdateOptions): Promise<UpdateResult & {
349
+ update(updateData: Record<string, any>, options?: UpdateOptions): Promise<(UpdateResult & {
266
350
  logFilePath?: string;
267
- }>;
351
+ }) | DryRunResult>;
268
352
  /**
269
353
  * Get specific field values from matching documents
270
354
  * @param fieldPath - Field path to retrieve
@@ -273,6 +357,7 @@ declare class BatchUpdater {
273
357
  getFields(fieldPath: string): Promise<FieldValueResult[]>;
274
358
  /**
275
359
  * Create multiple documents in batch
360
+ * Note: This method does not work with collectionGroup()
276
361
  * @param documents - Array of documents to create
277
362
  * @param options - Create options (e.g., progress callback, log options)
278
363
  * @returns Create result with success/failure counts, created IDs, and optional log file path
@@ -284,20 +369,20 @@ declare class BatchUpdater {
284
369
  * Upsert documents matching query conditions
285
370
  * Updates existing documents or creates them if they don't exist
286
371
  * @param updateData - Data to set/merge
287
- * @param options - Upsert options (e.g., progress callback, log options, batchSize for pagination)
288
- * @returns Upsert result with success/failure counts and optional log file path
372
+ * @param options - Upsert options (e.g., progress callback, log options, batchSize for pagination, dryRun)
373
+ * @returns Upsert result with success/failure counts and optional log file path, or DryRunResult if dryRun is true
289
374
  */
290
- upsert(updateData: Record<string, any>, options?: UpsertOptions): Promise<UpsertResult & {
375
+ upsert(updateData: Record<string, any>, options?: UpsertOptions): Promise<(UpsertResult & {
291
376
  logFilePath?: string;
292
- }>;
377
+ }) | DryRunResult>;
293
378
  /**
294
379
  * Delete documents matching query conditions
295
- * @param options - Delete options (e.g., progress callback, log options, batchSize for pagination)
296
- * @returns Delete result with success/failure counts, deleted IDs, and optional log file path
380
+ * @param options - Delete options (e.g., progress callback, log options, batchSize for pagination, dryRun)
381
+ * @returns Delete result with success/failure counts, deleted IDs, and optional log file path, or DryRunResult if dryRun is true
297
382
  */
298
- delete(options?: DeleteOptions): Promise<DeleteResult & {
383
+ delete(options?: DeleteOptions): Promise<(DeleteResult & {
299
384
  logFilePath?: string;
300
- }>;
385
+ }) | DryRunResult>;
301
386
  /**
302
387
  * Validate that collection is set
303
388
  * @private
@@ -341,12 +426,13 @@ declare function createLogCollector(operation: "update" | "create" | "upsert" |
341
426
  */
342
427
 
343
428
  /**
344
- * Calculate progress information
429
+ * Calculate progress information with timing details
345
430
  * @param current - Number of documents processed so far
346
431
  * @param total - Total number of documents to process
347
- * @returns Progress information with percentage
432
+ * @param startTime - Start time of the operation (from Date.now())
433
+ * @returns Progress information with percentage, timing, and ETA
348
434
  */
349
- declare function calculateProgress(current: number, total: number): ProgressInfo;
435
+ declare function calculateProgress(current: number, total: number, startTime?: number): ProgressInfo;
350
436
  /**
351
437
  * Extract field names from update data
352
438
  * @param updateData - Data to be updated
@@ -374,4 +460,4 @@ declare function isValidUpdateData(value: any): value is Record<string, any>;
374
460
  */
375
461
  declare function formatError(error: unknown, context?: string): string;
376
462
 
377
- export { BatchUpdater, type CreateDocumentInput, type CreateOptions, type CreateResult, type DeleteOptions, type DeleteResult, type DocumentSnapshot, type FieldValueResult, type LogEntry, type LogOptions, type OperationLog, type OrderByCondition, type PreviewResult, type ProgressInfo, type UpdateOptions, type UpdateResult, type UpsertOptions, type UpsertResult, type WhereCondition, calculateProgress, createLogCollector, formatError, formatOperationLog, getAffectedFields, isValidUpdateData, mergeUpdateData, writeOperationLog };
463
+ export { BatchUpdater, type CountResult, type CreateDocumentInput, type CreateOptions, type CreateResult, type DeleteOptions, type DeleteResult, type DocumentSnapshot, type DryRunResult, type FieldValueResult, type LogEntry, type LogOptions, type OperationLog, type OrderByCondition, type PreviewResult, type ProgressInfo, type UpdateOptions, type UpdateResult, type UpsertOptions, type UpsertResult, type WhereCondition, calculateProgress, createLogCollector, formatError, formatOperationLog, getAffectedFields, isValidUpdateData, mergeUpdateData, writeOperationLog };
package/dist/index.d.ts CHANGED
@@ -12,6 +12,9 @@ interface ProgressInfo {
12
12
  current: number;
13
13
  total: number;
14
14
  percentage: number;
15
+ elapsedTime: number;
16
+ docsPerSecond: number;
17
+ eta: number;
15
18
  }
16
19
  /**
17
20
  * Options for update operations
@@ -32,6 +35,19 @@ interface UpdateOptions {
32
35
  * When not set, all documents are loaded at once
33
36
  */
34
37
  batchSize?: number;
38
+ /**
39
+ * Dry run mode - simulate the operation without actually writing
40
+ * Returns what would happen without making any changes
41
+ */
42
+ dryRun?: boolean;
43
+ /**
44
+ * Maximum number of retry attempts for failed operations (default: 0)
45
+ */
46
+ retries?: number;
47
+ /**
48
+ * Delay between retry attempts in milliseconds (default: 1000)
49
+ */
50
+ retryDelay?: number;
35
51
  }
36
52
  /**
37
53
  * Result of batch update operation
@@ -130,6 +146,19 @@ interface UpsertOptions {
130
146
  * When not set, all documents are loaded at once
131
147
  */
132
148
  batchSize?: number;
149
+ /**
150
+ * Dry run mode - simulate the operation without actually writing
151
+ * Returns what would happen without making any changes
152
+ */
153
+ dryRun?: boolean;
154
+ /**
155
+ * Maximum number of retry attempts for failed operations (default: 0)
156
+ */
157
+ retries?: number;
158
+ /**
159
+ * Delay between retry attempts in milliseconds (default: 1000)
160
+ */
161
+ retryDelay?: number;
133
162
  }
134
163
  /**
135
164
  * Result of batch upsert operation
@@ -159,6 +188,19 @@ interface DeleteOptions {
159
188
  * When not set, all documents are loaded at once
160
189
  */
161
190
  batchSize?: number;
191
+ /**
192
+ * Dry run mode - simulate the operation without actually writing
193
+ * Returns what would happen without making any changes
194
+ */
195
+ dryRun?: boolean;
196
+ /**
197
+ * Maximum number of retry attempts for failed operations (default: 0)
198
+ */
199
+ retries?: number;
200
+ /**
201
+ * Delay between retry attempts in milliseconds (default: 1000)
202
+ */
203
+ retryDelay?: number;
162
204
  }
163
205
  /**
164
206
  * Result of batch delete operation
@@ -170,6 +212,20 @@ interface DeleteResult {
170
212
  deletedIds: string[];
171
213
  failedDocIds?: string[];
172
214
  }
215
+ /**
216
+ * Result of count operation
217
+ */
218
+ interface CountResult {
219
+ count: number;
220
+ }
221
+ /**
222
+ * Result of dry run operation
223
+ */
224
+ interface DryRunResult {
225
+ wouldAffect: number;
226
+ sampleIds: string[];
227
+ operation: "update" | "upsert" | "delete";
228
+ }
173
229
  /**
174
230
  * Log options for batch operations
175
231
  */
@@ -215,9 +271,11 @@ interface OperationLog {
215
271
  declare class BatchUpdater {
216
272
  private firestore;
217
273
  private collectionPath?;
274
+ private isCollectionGroup;
218
275
  private conditions;
219
276
  private orderByConditions;
220
277
  private limitCount?;
278
+ private selectedFields?;
221
279
  /**
222
280
  * Create a new BatchUpdater instance
223
281
  * @param firestore - Initialized Firestore instance from firebase-admin
@@ -225,10 +283,17 @@ declare class BatchUpdater {
225
283
  constructor(firestore: Firestore);
226
284
  /**
227
285
  * Select a collection to operate on
286
+ * Supports subcollection paths like "users/userId/orders"
228
287
  * @param path - Collection path
229
288
  * @returns This instance for chaining
230
289
  */
231
290
  collection(path: string): this;
291
+ /**
292
+ * Select a collection group to operate on (queries across all subcollections with the same name)
293
+ * @param collectionId - Collection ID (not a path, just the collection name)
294
+ * @returns This instance for chaining
295
+ */
296
+ collectionGroup(collectionId: string): this;
232
297
  /**
233
298
  * Add a where condition to filter documents
234
299
  * @param field - Field path
@@ -250,6 +315,25 @@ declare class BatchUpdater {
250
315
  * @returns This instance for chaining
251
316
  */
252
317
  limit(count: number): this;
318
+ /**
319
+ * Select specific fields to retrieve (reduces memory usage and read costs)
320
+ * @param fields - Field paths to retrieve
321
+ * @returns This instance for chaining
322
+ */
323
+ select(...fields: string[]): this;
324
+ /**
325
+ * Count documents matching the query conditions
326
+ * @returns Count result with number of matching documents
327
+ */
328
+ count(): Promise<CountResult>;
329
+ /**
330
+ * Find the first document matching the query conditions
331
+ * @returns First matching document with id and data, or null if not found
332
+ */
333
+ findOne(): Promise<{
334
+ id: string;
335
+ data: Record<string, any>;
336
+ } | null>;
253
337
  /**
254
338
  * Preview changes before executing update
255
339
  * @param updateData - Data to update
@@ -259,12 +343,12 @@ declare class BatchUpdater {
259
343
  /**
260
344
  * Execute batch update operation
261
345
  * @param updateData - Data to update
262
- * @param options - Update options (e.g., progress callback, log options, batchSize for pagination)
263
- * @returns Update result with success/failure counts and optional log file path
346
+ * @param options - Update options (e.g., progress callback, log options, batchSize for pagination, dryRun)
347
+ * @returns Update result with success/failure counts and optional log file path, or DryRunResult if dryRun is true
264
348
  */
265
- update(updateData: Record<string, any>, options?: UpdateOptions): Promise<UpdateResult & {
349
+ update(updateData: Record<string, any>, options?: UpdateOptions): Promise<(UpdateResult & {
266
350
  logFilePath?: string;
267
- }>;
351
+ }) | DryRunResult>;
268
352
  /**
269
353
  * Get specific field values from matching documents
270
354
  * @param fieldPath - Field path to retrieve
@@ -273,6 +357,7 @@ declare class BatchUpdater {
273
357
  getFields(fieldPath: string): Promise<FieldValueResult[]>;
274
358
  /**
275
359
  * Create multiple documents in batch
360
+ * Note: This method does not work with collectionGroup()
276
361
  * @param documents - Array of documents to create
277
362
  * @param options - Create options (e.g., progress callback, log options)
278
363
  * @returns Create result with success/failure counts, created IDs, and optional log file path
@@ -284,20 +369,20 @@ declare class BatchUpdater {
284
369
  * Upsert documents matching query conditions
285
370
  * Updates existing documents or creates them if they don't exist
286
371
  * @param updateData - Data to set/merge
287
- * @param options - Upsert options (e.g., progress callback, log options, batchSize for pagination)
288
- * @returns Upsert result with success/failure counts and optional log file path
372
+ * @param options - Upsert options (e.g., progress callback, log options, batchSize for pagination, dryRun)
373
+ * @returns Upsert result with success/failure counts and optional log file path, or DryRunResult if dryRun is true
289
374
  */
290
- upsert(updateData: Record<string, any>, options?: UpsertOptions): Promise<UpsertResult & {
375
+ upsert(updateData: Record<string, any>, options?: UpsertOptions): Promise<(UpsertResult & {
291
376
  logFilePath?: string;
292
- }>;
377
+ }) | DryRunResult>;
293
378
  /**
294
379
  * Delete documents matching query conditions
295
- * @param options - Delete options (e.g., progress callback, log options, batchSize for pagination)
296
- * @returns Delete result with success/failure counts, deleted IDs, and optional log file path
380
+ * @param options - Delete options (e.g., progress callback, log options, batchSize for pagination, dryRun)
381
+ * @returns Delete result with success/failure counts, deleted IDs, and optional log file path, or DryRunResult if dryRun is true
297
382
  */
298
- delete(options?: DeleteOptions): Promise<DeleteResult & {
383
+ delete(options?: DeleteOptions): Promise<(DeleteResult & {
299
384
  logFilePath?: string;
300
- }>;
385
+ }) | DryRunResult>;
301
386
  /**
302
387
  * Validate that collection is set
303
388
  * @private
@@ -341,12 +426,13 @@ declare function createLogCollector(operation: "update" | "create" | "upsert" |
341
426
  */
342
427
 
343
428
  /**
344
- * Calculate progress information
429
+ * Calculate progress information with timing details
345
430
  * @param current - Number of documents processed so far
346
431
  * @param total - Total number of documents to process
347
- * @returns Progress information with percentage
432
+ * @param startTime - Start time of the operation (from Date.now())
433
+ * @returns Progress information with percentage, timing, and ETA
348
434
  */
349
- declare function calculateProgress(current: number, total: number): ProgressInfo;
435
+ declare function calculateProgress(current: number, total: number, startTime?: number): ProgressInfo;
350
436
  /**
351
437
  * Extract field names from update data
352
438
  * @param updateData - Data to be updated
@@ -374,4 +460,4 @@ declare function isValidUpdateData(value: any): value is Record<string, any>;
374
460
  */
375
461
  declare function formatError(error: unknown, context?: string): string;
376
462
 
377
- export { BatchUpdater, type CreateDocumentInput, type CreateOptions, type CreateResult, type DeleteOptions, type DeleteResult, type DocumentSnapshot, type FieldValueResult, type LogEntry, type LogOptions, type OperationLog, type OrderByCondition, type PreviewResult, type ProgressInfo, type UpdateOptions, type UpdateResult, type UpsertOptions, type UpsertResult, type WhereCondition, calculateProgress, createLogCollector, formatError, formatOperationLog, getAffectedFields, isValidUpdateData, mergeUpdateData, writeOperationLog };
463
+ export { BatchUpdater, type CountResult, type CreateDocumentInput, type CreateOptions, type CreateResult, type DeleteOptions, type DeleteResult, type DocumentSnapshot, type DryRunResult, type FieldValueResult, type LogEntry, type LogOptions, type OperationLog, type OrderByCondition, type PreviewResult, type ProgressInfo, type UpdateOptions, type UpdateResult, type UpsertOptions, type UpsertResult, type WhereCondition, calculateProgress, createLogCollector, formatError, formatOperationLog, getAffectedFields, isValidUpdateData, mergeUpdateData, writeOperationLog };
package/dist/index.js CHANGED
@@ -164,12 +164,20 @@ function createLogCollector(operation, collection, conditions, updateData) {
164
164
  }
165
165
 
166
166
  // src/utils/index.ts
167
- function calculateProgress(current, total) {
167
+ function calculateProgress(current, total, startTime) {
168
168
  const percentage = total === 0 ? 0 : Math.round(current / total * 100);
169
+ const now = Date.now();
170
+ const elapsedTime = startTime ? now - startTime : 0;
171
+ const docsPerSecond = elapsedTime > 0 ? Math.round(current / elapsedTime * 1e3 * 100) / 100 : 0;
172
+ const remaining = total - current;
173
+ const eta = docsPerSecond > 0 ? Math.round(remaining / docsPerSecond * 100) / 100 : 0;
169
174
  return {
170
175
  current,
171
176
  total,
172
- percentage
177
+ percentage,
178
+ elapsedTime,
179
+ docsPerSecond,
180
+ eta
173
181
  };
174
182
  }
175
183
  function getAffectedFields(updateData) {
@@ -196,20 +204,38 @@ var BatchUpdater = class {
196
204
  * @param firestore - Initialized Firestore instance from firebase-admin
197
205
  */
198
206
  constructor(firestore) {
207
+ this.isCollectionGroup = false;
199
208
  this.conditions = [];
200
209
  this.orderByConditions = [];
201
210
  this.firestore = firestore;
202
211
  }
203
212
  /**
204
213
  * Select a collection to operate on
214
+ * Supports subcollection paths like "users/userId/orders"
205
215
  * @param path - Collection path
206
216
  * @returns This instance for chaining
207
217
  */
208
218
  collection(path2) {
209
219
  this.collectionPath = path2;
220
+ this.isCollectionGroup = false;
210
221
  this.conditions = [];
211
222
  this.orderByConditions = [];
212
223
  this.limitCount = void 0;
224
+ this.selectedFields = void 0;
225
+ return this;
226
+ }
227
+ /**
228
+ * Select a collection group to operate on (queries across all subcollections with the same name)
229
+ * @param collectionId - Collection ID (not a path, just the collection name)
230
+ * @returns This instance for chaining
231
+ */
232
+ collectionGroup(collectionId) {
233
+ this.collectionPath = collectionId;
234
+ this.isCollectionGroup = true;
235
+ this.conditions = [];
236
+ this.orderByConditions = [];
237
+ this.limitCount = void 0;
238
+ this.selectedFields = void 0;
213
239
  return this;
214
240
  }
215
241
  /**
@@ -242,6 +268,44 @@ var BatchUpdater = class {
242
268
  this.limitCount = count;
243
269
  return this;
244
270
  }
271
+ /**
272
+ * Select specific fields to retrieve (reduces memory usage and read costs)
273
+ * @param fields - Field paths to retrieve
274
+ * @returns This instance for chaining
275
+ */
276
+ select(...fields) {
277
+ this.selectedFields = fields;
278
+ return this;
279
+ }
280
+ /**
281
+ * Count documents matching the query conditions
282
+ * @returns Count result with number of matching documents
283
+ */
284
+ async count() {
285
+ this.validateSetup();
286
+ const query = this.buildQuery();
287
+ const snapshot = await query.count().get();
288
+ return {
289
+ count: snapshot.data().count
290
+ };
291
+ }
292
+ /**
293
+ * Find the first document matching the query conditions
294
+ * @returns First matching document with id and data, or null if not found
295
+ */
296
+ async findOne() {
297
+ this.validateSetup();
298
+ const query = this.buildQuery().limit(1);
299
+ const snapshot = await query.get();
300
+ if (snapshot.empty) {
301
+ return null;
302
+ }
303
+ const doc = snapshot.docs[0];
304
+ return {
305
+ id: doc.id,
306
+ data: doc.data()
307
+ };
308
+ }
245
309
  /**
246
310
  * Preview changes before executing update
247
311
  * @param updateData - Data to update
@@ -276,14 +340,24 @@ var BatchUpdater = class {
276
340
  /**
277
341
  * Execute batch update operation
278
342
  * @param updateData - Data to update
279
- * @param options - Update options (e.g., progress callback, log options, batchSize for pagination)
280
- * @returns Update result with success/failure counts and optional log file path
343
+ * @param options - Update options (e.g., progress callback, log options, batchSize for pagination, dryRun)
344
+ * @returns Update result with success/failure counts and optional log file path, or DryRunResult if dryRun is true
281
345
  */
282
346
  async update(updateData, options = {}) {
283
347
  this.validateSetup();
284
348
  if (!isValidUpdateData(updateData)) {
285
349
  throw new Error("Update data must be a non-empty object");
286
350
  }
351
+ if (options.dryRun) {
352
+ const query = this.buildQuery();
353
+ const snapshot = await query.limit(10).get();
354
+ const countSnapshot = await this.buildQuery().count().get();
355
+ return {
356
+ wouldAffect: countSnapshot.data().count,
357
+ sampleIds: snapshot.docs.map((doc) => doc.id),
358
+ operation: "update"
359
+ };
360
+ }
287
361
  const logCollector = options.log?.enabled ? createLogCollector("update", this.collectionPath, this.conditions, updateData) : null;
288
362
  let successCount = 0;
289
363
  let failureCount = 0;
@@ -432,12 +506,16 @@ var BatchUpdater = class {
432
506
  }
433
507
  /**
434
508
  * Create multiple documents in batch
509
+ * Note: This method does not work with collectionGroup()
435
510
  * @param documents - Array of documents to create
436
511
  * @param options - Create options (e.g., progress callback, log options)
437
512
  * @returns Create result with success/failure counts, created IDs, and optional log file path
438
513
  */
439
514
  async create(documents, options = {}) {
440
515
  this.validateSetup();
516
+ if (this.isCollectionGroup) {
517
+ throw new Error("create() cannot be used with collectionGroup(). Use collection() with a specific path instead.");
518
+ }
441
519
  if (!Array.isArray(documents) || documents.length === 0) {
442
520
  throw new Error("Documents array must be non-empty");
443
521
  }
@@ -498,14 +576,24 @@ var BatchUpdater = class {
498
576
  * Upsert documents matching query conditions
499
577
  * Updates existing documents or creates them if they don't exist
500
578
  * @param updateData - Data to set/merge
501
- * @param options - Upsert options (e.g., progress callback, log options, batchSize for pagination)
502
- * @returns Upsert result with success/failure counts and optional log file path
579
+ * @param options - Upsert options (e.g., progress callback, log options, batchSize for pagination, dryRun)
580
+ * @returns Upsert result with success/failure counts and optional log file path, or DryRunResult if dryRun is true
503
581
  */
504
582
  async upsert(updateData, options = {}) {
505
583
  this.validateSetup();
506
584
  if (!isValidUpdateData(updateData)) {
507
585
  throw new Error("Update data must be a non-empty object");
508
586
  }
587
+ if (options.dryRun) {
588
+ const query = this.buildQuery();
589
+ const snapshot = await query.limit(10).get();
590
+ const countSnapshot = await this.buildQuery().count().get();
591
+ return {
592
+ wouldAffect: countSnapshot.data().count,
593
+ sampleIds: snapshot.docs.map((doc) => doc.id),
594
+ operation: "upsert"
595
+ };
596
+ }
509
597
  const logCollector = options.log?.enabled ? createLogCollector("upsert", this.collectionPath, this.conditions, updateData) : null;
510
598
  let successCount = 0;
511
599
  let failureCount = 0;
@@ -634,11 +722,21 @@ var BatchUpdater = class {
634
722
  }
635
723
  /**
636
724
  * Delete documents matching query conditions
637
- * @param options - Delete options (e.g., progress callback, log options, batchSize for pagination)
638
- * @returns Delete result with success/failure counts, deleted IDs, and optional log file path
725
+ * @param options - Delete options (e.g., progress callback, log options, batchSize for pagination, dryRun)
726
+ * @returns Delete result with success/failure counts, deleted IDs, and optional log file path, or DryRunResult if dryRun is true
639
727
  */
640
728
  async delete(options = {}) {
641
729
  this.validateSetup();
730
+ if (options.dryRun) {
731
+ const query = this.buildQuery();
732
+ const snapshot = await query.limit(10).get();
733
+ const countSnapshot = await this.buildQuery().count().get();
734
+ return {
735
+ wouldAffect: countSnapshot.data().count,
736
+ sampleIds: snapshot.docs.map((doc) => doc.id),
737
+ operation: "delete"
738
+ };
739
+ }
642
740
  const logCollector = options.log?.enabled ? createLogCollector("delete", this.collectionPath, this.conditions) : null;
643
741
  let successCount = 0;
644
742
  let failureCount = 0;
@@ -785,9 +883,7 @@ var BatchUpdater = class {
785
883
  * @private
786
884
  */
787
885
  buildQuery() {
788
- let query = this.firestore.collection(
789
- this.collectionPath
790
- );
886
+ let query = this.isCollectionGroup ? this.firestore.collectionGroup(this.collectionPath) : this.firestore.collection(this.collectionPath);
791
887
  for (const condition of this.conditions) {
792
888
  query = query.where(condition.field, condition.operator, condition.value);
793
889
  }
@@ -797,6 +893,9 @@ var BatchUpdater = class {
797
893
  if (this.limitCount !== void 0 && this.limitCount > 0) {
798
894
  query = query.limit(this.limitCount);
799
895
  }
896
+ if (this.selectedFields && this.selectedFields.length > 0) {
897
+ query = query.select(...this.selectedFields);
898
+ }
800
899
  return query;
801
900
  }
802
901
  /**
package/dist/index.mjs CHANGED
@@ -119,12 +119,20 @@ function createLogCollector(operation, collection, conditions, updateData) {
119
119
  }
120
120
 
121
121
  // src/utils/index.ts
122
- function calculateProgress(current, total) {
122
+ function calculateProgress(current, total, startTime) {
123
123
  const percentage = total === 0 ? 0 : Math.round(current / total * 100);
124
+ const now = Date.now();
125
+ const elapsedTime = startTime ? now - startTime : 0;
126
+ const docsPerSecond = elapsedTime > 0 ? Math.round(current / elapsedTime * 1e3 * 100) / 100 : 0;
127
+ const remaining = total - current;
128
+ const eta = docsPerSecond > 0 ? Math.round(remaining / docsPerSecond * 100) / 100 : 0;
124
129
  return {
125
130
  current,
126
131
  total,
127
- percentage
132
+ percentage,
133
+ elapsedTime,
134
+ docsPerSecond,
135
+ eta
128
136
  };
129
137
  }
130
138
  function getAffectedFields(updateData) {
@@ -151,20 +159,38 @@ var BatchUpdater = class {
151
159
  * @param firestore - Initialized Firestore instance from firebase-admin
152
160
  */
153
161
  constructor(firestore) {
162
+ this.isCollectionGroup = false;
154
163
  this.conditions = [];
155
164
  this.orderByConditions = [];
156
165
  this.firestore = firestore;
157
166
  }
158
167
  /**
159
168
  * Select a collection to operate on
169
+ * Supports subcollection paths like "users/userId/orders"
160
170
  * @param path - Collection path
161
171
  * @returns This instance for chaining
162
172
  */
163
173
  collection(path2) {
164
174
  this.collectionPath = path2;
175
+ this.isCollectionGroup = false;
165
176
  this.conditions = [];
166
177
  this.orderByConditions = [];
167
178
  this.limitCount = void 0;
179
+ this.selectedFields = void 0;
180
+ return this;
181
+ }
182
+ /**
183
+ * Select a collection group to operate on (queries across all subcollections with the same name)
184
+ * @param collectionId - Collection ID (not a path, just the collection name)
185
+ * @returns This instance for chaining
186
+ */
187
+ collectionGroup(collectionId) {
188
+ this.collectionPath = collectionId;
189
+ this.isCollectionGroup = true;
190
+ this.conditions = [];
191
+ this.orderByConditions = [];
192
+ this.limitCount = void 0;
193
+ this.selectedFields = void 0;
168
194
  return this;
169
195
  }
170
196
  /**
@@ -197,6 +223,44 @@ var BatchUpdater = class {
197
223
  this.limitCount = count;
198
224
  return this;
199
225
  }
226
+ /**
227
+ * Select specific fields to retrieve (reduces memory usage and read costs)
228
+ * @param fields - Field paths to retrieve
229
+ * @returns This instance for chaining
230
+ */
231
+ select(...fields) {
232
+ this.selectedFields = fields;
233
+ return this;
234
+ }
235
+ /**
236
+ * Count documents matching the query conditions
237
+ * @returns Count result with number of matching documents
238
+ */
239
+ async count() {
240
+ this.validateSetup();
241
+ const query = this.buildQuery();
242
+ const snapshot = await query.count().get();
243
+ return {
244
+ count: snapshot.data().count
245
+ };
246
+ }
247
+ /**
248
+ * Find the first document matching the query conditions
249
+ * @returns First matching document with id and data, or null if not found
250
+ */
251
+ async findOne() {
252
+ this.validateSetup();
253
+ const query = this.buildQuery().limit(1);
254
+ const snapshot = await query.get();
255
+ if (snapshot.empty) {
256
+ return null;
257
+ }
258
+ const doc = snapshot.docs[0];
259
+ return {
260
+ id: doc.id,
261
+ data: doc.data()
262
+ };
263
+ }
200
264
  /**
201
265
  * Preview changes before executing update
202
266
  * @param updateData - Data to update
@@ -231,14 +295,24 @@ var BatchUpdater = class {
231
295
  /**
232
296
  * Execute batch update operation
233
297
  * @param updateData - Data to update
234
- * @param options - Update options (e.g., progress callback, log options, batchSize for pagination)
235
- * @returns Update result with success/failure counts and optional log file path
298
+ * @param options - Update options (e.g., progress callback, log options, batchSize for pagination, dryRun)
299
+ * @returns Update result with success/failure counts and optional log file path, or DryRunResult if dryRun is true
236
300
  */
237
301
  async update(updateData, options = {}) {
238
302
  this.validateSetup();
239
303
  if (!isValidUpdateData(updateData)) {
240
304
  throw new Error("Update data must be a non-empty object");
241
305
  }
306
+ if (options.dryRun) {
307
+ const query = this.buildQuery();
308
+ const snapshot = await query.limit(10).get();
309
+ const countSnapshot = await this.buildQuery().count().get();
310
+ return {
311
+ wouldAffect: countSnapshot.data().count,
312
+ sampleIds: snapshot.docs.map((doc) => doc.id),
313
+ operation: "update"
314
+ };
315
+ }
242
316
  const logCollector = options.log?.enabled ? createLogCollector("update", this.collectionPath, this.conditions, updateData) : null;
243
317
  let successCount = 0;
244
318
  let failureCount = 0;
@@ -387,12 +461,16 @@ var BatchUpdater = class {
387
461
  }
388
462
  /**
389
463
  * Create multiple documents in batch
464
+ * Note: This method does not work with collectionGroup()
390
465
  * @param documents - Array of documents to create
391
466
  * @param options - Create options (e.g., progress callback, log options)
392
467
  * @returns Create result with success/failure counts, created IDs, and optional log file path
393
468
  */
394
469
  async create(documents, options = {}) {
395
470
  this.validateSetup();
471
+ if (this.isCollectionGroup) {
472
+ throw new Error("create() cannot be used with collectionGroup(). Use collection() with a specific path instead.");
473
+ }
396
474
  if (!Array.isArray(documents) || documents.length === 0) {
397
475
  throw new Error("Documents array must be non-empty");
398
476
  }
@@ -453,14 +531,24 @@ var BatchUpdater = class {
453
531
  * Upsert documents matching query conditions
454
532
  * Updates existing documents or creates them if they don't exist
455
533
  * @param updateData - Data to set/merge
456
- * @param options - Upsert options (e.g., progress callback, log options, batchSize for pagination)
457
- * @returns Upsert result with success/failure counts and optional log file path
534
+ * @param options - Upsert options (e.g., progress callback, log options, batchSize for pagination, dryRun)
535
+ * @returns Upsert result with success/failure counts and optional log file path, or DryRunResult if dryRun is true
458
536
  */
459
537
  async upsert(updateData, options = {}) {
460
538
  this.validateSetup();
461
539
  if (!isValidUpdateData(updateData)) {
462
540
  throw new Error("Update data must be a non-empty object");
463
541
  }
542
+ if (options.dryRun) {
543
+ const query = this.buildQuery();
544
+ const snapshot = await query.limit(10).get();
545
+ const countSnapshot = await this.buildQuery().count().get();
546
+ return {
547
+ wouldAffect: countSnapshot.data().count,
548
+ sampleIds: snapshot.docs.map((doc) => doc.id),
549
+ operation: "upsert"
550
+ };
551
+ }
464
552
  const logCollector = options.log?.enabled ? createLogCollector("upsert", this.collectionPath, this.conditions, updateData) : null;
465
553
  let successCount = 0;
466
554
  let failureCount = 0;
@@ -589,11 +677,21 @@ var BatchUpdater = class {
589
677
  }
590
678
  /**
591
679
  * Delete documents matching query conditions
592
- * @param options - Delete options (e.g., progress callback, log options, batchSize for pagination)
593
- * @returns Delete result with success/failure counts, deleted IDs, and optional log file path
680
+ * @param options - Delete options (e.g., progress callback, log options, batchSize for pagination, dryRun)
681
+ * @returns Delete result with success/failure counts, deleted IDs, and optional log file path, or DryRunResult if dryRun is true
594
682
  */
595
683
  async delete(options = {}) {
596
684
  this.validateSetup();
685
+ if (options.dryRun) {
686
+ const query = this.buildQuery();
687
+ const snapshot = await query.limit(10).get();
688
+ const countSnapshot = await this.buildQuery().count().get();
689
+ return {
690
+ wouldAffect: countSnapshot.data().count,
691
+ sampleIds: snapshot.docs.map((doc) => doc.id),
692
+ operation: "delete"
693
+ };
694
+ }
597
695
  const logCollector = options.log?.enabled ? createLogCollector("delete", this.collectionPath, this.conditions) : null;
598
696
  let successCount = 0;
599
697
  let failureCount = 0;
@@ -740,9 +838,7 @@ var BatchUpdater = class {
740
838
  * @private
741
839
  */
742
840
  buildQuery() {
743
- let query = this.firestore.collection(
744
- this.collectionPath
745
- );
841
+ let query = this.isCollectionGroup ? this.firestore.collectionGroup(this.collectionPath) : this.firestore.collection(this.collectionPath);
746
842
  for (const condition of this.conditions) {
747
843
  query = query.where(condition.field, condition.operator, condition.value);
748
844
  }
@@ -752,6 +848,9 @@ var BatchUpdater = class {
752
848
  if (this.limitCount !== void 0 && this.limitCount > 0) {
753
849
  query = query.limit(this.limitCount);
754
850
  }
851
+ if (this.selectedFields && this.selectedFields.length > 0) {
852
+ query = query.select(...this.selectedFields);
853
+ }
755
854
  return query;
756
855
  }
757
856
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firestore-batch-updater",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Batch update Firestore documents with query-based filtering and preview",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",