firestore-batch-updater 1.4.0 → 1.6.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
@@ -15,9 +15,13 @@
15
15
  - 일괄 생성/Upsert/삭제 - 여러 문서를 한 번에 생성, upsert 또는 삭제
16
16
  - 정렬 및 제한 - `orderBy()`와 `limit()`으로 정밀한 제어
17
17
  - 필드 선택 - `select()`로 필요한 필드만 로드 (메모리 및 비용 절약)
18
- - 단일 문서 작업 - `findOne()`, `updateOne()`, `deleteOne()`으로 효율적인 단일 문서 처리
18
+ - 단일 문서 작업 - `findOne()`, `createOne()`, `updateOne()`, `deleteOne()`으로 효율적인 단일 문서 처리
19
19
  - 존재 여부 확인 - `exists()`로 매칭 문서 존재 여부 빠르게 확인
20
20
  - 전체 문서 조회 - `getAll()`로 매칭되는 모든 문서 데이터 조회
21
+ - 집계 쿼리 - `aggregate()`로 서버 사이드 `sum`, `average`, `count` 연산
22
+ - 커서 페이지네이션 - `paginate()`로 메모리 효율적인 페이지 단위 조회
23
+ - ID 직접 조회 - `getOne()`으로 문서 ID로 빠른 조회
24
+ - 벌크 업데이트 - `bulkUpdate()`로 여러 문서에 각기 다른 데이터 업데이트
21
25
  - FieldValue 지원 - `increment()`, `arrayUnion()`, `delete()`, `serverTimestamp()` 등 사용 가능
22
26
  - 서브컬렉션 & 컬렉션 그룹 - 서브컬렉션 쿼리 또는 동일 이름의 모든 컬렉션 쿼리
23
27
  - Dry Run 모드 - 실제 변경 없이 작업 시뮬레이션
@@ -91,14 +95,19 @@ console.log(`${result.successCount}개 문서 업데이트 완료`);
91
95
  | `count()` | 매칭되는 문서 개수 조회 | `CountResult` |
92
96
  | `exists()` | 매칭되는 문서 존재 여부 확인 | `boolean` |
93
97
  | `findOne()` | 첫 번째 매칭 문서 조회 | `{ id, data } \| null` |
98
+ | `getOne(id)` | ID로 문서 직접 조회 | `{ id, data } \| null` |
94
99
  | `getAll()` | 모든 매칭 문서 조회 | `{ id, data }[]` |
95
100
  | `preview(data)` | 업데이트 전 미리보기 | `PreviewResult` |
96
101
  | `update(data, options?)` | 매칭되는 문서 업데이트 | `UpdateResult` |
97
102
  | `updateOne(data)` | 첫 번째 매칭 문서 업데이트 | `{ success, id }` |
98
103
  | `create(docs, options?)` | 새 문서 생성 | `CreateResult` |
104
+ | `createOne(data, id?)` | 단일 문서 생성 | `{ success, id }` |
99
105
  | `upsert(data, options?)` | 업데이트 또는 생성 (set with merge) | `UpsertResult` |
100
106
  | `delete(options?)` | 매칭되는 문서 삭제 | `DeleteResult` |
101
107
  | `deleteOne()` | 첫 번째 매칭 문서 삭제 | `{ success, id }` |
108
+ | `aggregate(spec)` | sum/average/count 집계 쿼리 | `AggregateResult` |
109
+ | `paginate(options)` | 커서 기반 페이지네이션 | `PaginateResult` |
110
+ | `bulkUpdate(updates, options?)` | 여러 문서에 각기 다른 데이터 업데이트 | `BulkUpdateResult` |
102
111
  | `getFields(field)` | 특정 필드 값 조회 | `FieldValueResult[]` |
103
112
 
104
113
  ### 옵션
@@ -146,6 +155,9 @@ console.log(`${result.successCount}개 문서 업데이트 완료`);
146
155
  | `CreateResult` | `successCount`, `failureCount`, `totalCount`, `createdIds[]`, `failedDocIds?`, `logFilePath?` |
147
156
  | `UpsertResult` | `successCount`, `failureCount`, `totalCount`, `failedDocIds?`, `logFilePath?` |
148
157
  | `DeleteResult` | `successCount`, `failureCount`, `totalCount`, `deletedIds[]`, `failedDocIds?`, `logFilePath?` |
158
+ | `AggregateResult` | `{ [alias]: number \| null }` |
159
+ | `PaginateResult` | `docs[]`, `nextCursor`, `hasMore` |
160
+ | `BulkUpdateResult` | `successCount`, `failureCount`, `totalCount`, `failedDocIds?`, `logFilePath?` |
149
161
  | `FieldValueResult` | `id`, `value` |
150
162
 
151
163
  ## 사용 예시
@@ -443,6 +455,123 @@ if (result.success) {
443
455
  }
444
456
  ```
445
457
 
458
+ ### 단일 문서 생성
459
+
460
+ ```typescript
461
+ // 자동 생성 ID로 문서 생성
462
+ const result = await updater
463
+ .collection("users")
464
+ .createOne({ name: "Alice", status: "active", score: 100 });
465
+
466
+ console.log(`문서 생성 완료: ${result.id}`);
467
+
468
+ // 커스텀 ID로 문서 생성
469
+ const result2 = await updater
470
+ .collection("users")
471
+ .createOne({ name: "Bob", status: "active" }, "custom-bob-id");
472
+ ```
473
+
474
+ ### 집계 쿼리
475
+
476
+ ```typescript
477
+ // 매칭 문서에 대해 sum, average, count 집계
478
+ const stats = await updater
479
+ .collection("orders")
480
+ .where("status", "==", "completed")
481
+ .aggregate({
482
+ totalAmount: { op: "sum", field: "amount" },
483
+ avgAmount: { op: "average", field: "amount" },
484
+ orderCount: { op: "count" },
485
+ });
486
+
487
+ console.log(`총액: ${stats.totalAmount}원`);
488
+ console.log(`평균: ${stats.avgAmount}원`);
489
+ console.log(`주문 수: ${stats.orderCount}건`);
490
+ ```
491
+
492
+ ### 커서 기반 페이지네이션
493
+
494
+ ```typescript
495
+ // 페이지 단위로 효율적으로 문서 조회
496
+ let nextCursor = undefined;
497
+
498
+ do {
499
+ const page = await updater
500
+ .collection("users")
501
+ .orderBy("createdAt", "desc")
502
+ .paginate({ pageSize: 20, startAfter: nextCursor });
503
+
504
+ page.docs.forEach((doc) => {
505
+ console.log(`${doc.id}: ${doc.data.name}`);
506
+ });
507
+
508
+ nextCursor = page.nextCursor;
509
+ } while (nextCursor);
510
+
511
+ // select와 함께 사용하여 메모리 효율 극대화
512
+ const page = await updater
513
+ .collection("users")
514
+ .select("name", "email")
515
+ .orderBy("name")
516
+ .paginate({ pageSize: 50 });
517
+ ```
518
+
519
+ ### ID로 문서 조회
520
+
521
+ ```typescript
522
+ // 문서 ID로 직접 조회 (쿼리 필터 없이 가장 빠름)
523
+ const user = await updater.collection("users").getOne("user-123");
524
+
525
+ if (user) {
526
+ console.log(`찾음: ${user.data.name} (${user.data.email})`);
527
+ } else {
528
+ console.log("사용자를 찾을 수 없음");
529
+ }
530
+
531
+ // select와 함께 사용하여 특정 필드만 가져오기
532
+ const userBasic = await updater
533
+ .collection("users")
534
+ .select("name", "email")
535
+ .getOne("user-123");
536
+
537
+ // 서브컬렉션에서 문서 조회
538
+ const order = await updater
539
+ .collection("users/user-123/orders")
540
+ .getOne("order-456");
541
+ ```
542
+
543
+ ### 벌크 업데이트
544
+
545
+ ```typescript
546
+ // 여러 문서를 각각 다른 데이터로 업데이트
547
+ const result = await updater.collection("users").bulkUpdate([
548
+ { id: "user-1", data: { name: "Alice", age: 30 } },
549
+ { id: "user-2", data: { name: "Bob", status: "active" } },
550
+ { id: "user-3", data: { email: "charlie@example.com" } },
551
+ ]);
552
+
553
+ console.log(`성공: ${result.successCount}, 실패: ${result.failureCount}`);
554
+
555
+ // 진행 상황 콜백과 함께 사용
556
+ const result = await updater.collection("products").bulkUpdate(
557
+ [
558
+ { id: "prod-1", data: { price: 29.99, stock: 100 } },
559
+ { id: "prod-2", data: { price: 49.99, stock: 50 } },
560
+ // ... 더 많은 업데이트
561
+ ],
562
+ {
563
+ onProgress: (progress) => {
564
+ console.log(`${progress.processedCount}/${progress.totalCount} 처리됨`);
565
+ },
566
+ }
567
+ );
568
+
569
+ // 실패 처리
570
+ if (result.failureCount > 0) {
571
+ console.log("실패한 문서 ID:", result.failedDocIds);
572
+ }
573
+ ```
574
+
446
575
  ### Dry Run 모드
447
576
 
448
577
  ```typescript
package/README.md CHANGED
@@ -15,9 +15,13 @@ English | [한국어](./README.ko.md)
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
17
  - Field selection - Use `select()` to load only needed fields (saves memory and costs)
18
- - Single document operations - Use `findOne()`, `updateOne()`, `deleteOne()` for efficient single-doc ops
18
+ - Single document operations - Use `findOne()`, `createOne()`, `updateOne()`, `deleteOne()` for efficient single-doc ops
19
19
  - Existence check - Use `exists()` to quickly check if matching documents exist
20
20
  - Get all documents - Use `getAll()` to retrieve all matching documents with data
21
+ - Aggregation - Use `aggregate()` for server-side `sum`, `average`, and `count` operations
22
+ - Cursor pagination - Use `paginate()` for memory-efficient page-by-page iteration
23
+ - Direct ID lookup - Use `getOne()` for fast document retrieval by ID
24
+ - Bulk updates - Use `bulkUpdate()` to update multiple documents with different data each
21
25
  - FieldValue support - Use `increment()`, `arrayUnion()`, `delete()`, `serverTimestamp()`, etc.
22
26
  - Subcollection & Collection Group - Query subcollections or all collections with the same name
23
27
  - Dry run mode - Simulate operations without making changes
@@ -91,14 +95,19 @@ console.log(`Updated ${result.successCount} documents`);
91
95
  | `count()` | Count matching documents | `CountResult` |
92
96
  | `exists()` | Check if matching documents exist | `boolean` |
93
97
  | `findOne()` | Find first matching document | `{ id, data } \| null` |
98
+ | `getOne(id)` | Get document by ID directly | `{ id, data } \| null` |
94
99
  | `getAll()` | Get all matching documents | `{ id, data }[]` |
95
100
  | `preview(data)` | Preview changes before update | `PreviewResult` |
96
101
  | `update(data, options?)` | Update matching documents | `UpdateResult` |
97
102
  | `updateOne(data)` | Update first matching document | `{ success, id }` |
98
103
  | `create(docs, options?)` | Create new documents | `CreateResult` |
104
+ | `createOne(data, id?)` | Create a single document | `{ success, id }` |
99
105
  | `upsert(data, options?)` | Update or create (set with merge) | `UpsertResult` |
100
106
  | `delete(options?)` | Delete matching documents | `DeleteResult` |
101
107
  | `deleteOne()` | Delete first matching document | `{ success, id }` |
108
+ | `aggregate(spec)` | Run sum/average/count queries | `AggregateResult` |
109
+ | `paginate(options)` | Cursor-based pagination | `PaginateResult` |
110
+ | `bulkUpdate(updates, options?)` | Update multiple docs with different data | `BulkUpdateResult` |
102
111
  | `getFields(field)` | Get specific field values | `FieldValueResult[]` |
103
112
 
104
113
  ### Options
@@ -146,6 +155,9 @@ All write operations support an optional `options` parameter:
146
155
  | `CreateResult` | `successCount`, `failureCount`, `totalCount`, `createdIds[]`, `failedDocIds?`, `logFilePath?` |
147
156
  | `UpsertResult` | `successCount`, `failureCount`, `totalCount`, `failedDocIds?`, `logFilePath?` |
148
157
  | `DeleteResult` | `successCount`, `failureCount`, `totalCount`, `deletedIds[]`, `failedDocIds?`, `logFilePath?` |
158
+ | `AggregateResult` | `{ [alias]: number \| null }` |
159
+ | `PaginateResult` | `docs[]`, `nextCursor`, `hasMore` |
160
+ | `BulkUpdateResult` | `successCount`, `failureCount`, `totalCount`, `failedDocIds?`, `logFilePath?` |
149
161
  | `FieldValueResult` | `id`, `value` |
150
162
 
151
163
  ## Usage Examples
@@ -442,6 +454,112 @@ if (result.success) {
442
454
  }
443
455
  ```
444
456
 
457
+ ### Create Single Document
458
+
459
+ ```typescript
460
+ // Create with auto-generated ID
461
+ const result = await updater
462
+ .collection("users")
463
+ .createOne({ name: "Alice", status: "active", score: 100 });
464
+
465
+ console.log(`Created document: ${result.id}`);
466
+
467
+ // Create with custom ID
468
+ const result2 = await updater
469
+ .collection("users")
470
+ .createOne({ name: "Bob", status: "active" }, "custom-bob-id");
471
+ ```
472
+
473
+ ### Aggregate Queries
474
+
475
+ ```typescript
476
+ // Sum, average, count on matching documents
477
+ const stats = await updater
478
+ .collection("orders")
479
+ .where("status", "==", "completed")
480
+ .aggregate({
481
+ totalAmount: { op: "sum", field: "amount" },
482
+ avgAmount: { op: "average", field: "amount" },
483
+ orderCount: { op: "count" },
484
+ });
485
+
486
+ console.log(`Total: $${stats.totalAmount}`);
487
+ console.log(`Average: $${stats.avgAmount}`);
488
+ console.log(`Orders: ${stats.orderCount}`);
489
+ ```
490
+
491
+ ### Cursor-Based Pagination
492
+
493
+ ```typescript
494
+ // Page through documents efficiently
495
+ let nextCursor = undefined;
496
+
497
+ do {
498
+ const page = await updater
499
+ .collection("users")
500
+ .orderBy("createdAt", "desc")
501
+ .paginate({ pageSize: 20, startAfter: nextCursor });
502
+
503
+ page.docs.forEach((doc) => {
504
+ console.log(`${doc.id}: ${doc.data.name}`);
505
+ });
506
+
507
+ nextCursor = page.nextCursor;
508
+ } while (nextCursor);
509
+
510
+ // Works with select for memory efficiency
511
+ const page = await updater
512
+ .collection("users")
513
+ .select("name", "email")
514
+ .orderBy("name")
515
+ .paginate({ pageSize: 50 });
516
+ ```
517
+
518
+ ### Get Document by ID
519
+
520
+ ```typescript
521
+ // Fast lookup when you know the document ID
522
+ const user = await updater.collection("users").getOne("user-123");
523
+
524
+ if (user) {
525
+ console.log(`Found: ${user.data.name}`);
526
+ } else {
527
+ console.log("User not found");
528
+ }
529
+
530
+ // Works with select for field filtering
531
+ const profile = await updater
532
+ .collection("users")
533
+ .select("name", "avatar")
534
+ .getOne("user-123");
535
+ ```
536
+
537
+ ### Bulk Update with Different Data
538
+
539
+ ```typescript
540
+ // Update multiple documents with different data for each
541
+ const result = await updater.collection("users").bulkUpdate([
542
+ { id: "user-1", data: { score: 100, rank: 1 } },
543
+ { id: "user-2", data: { score: 85, rank: 2 } },
544
+ { id: "user-3", data: { score: 70, rank: 3 } },
545
+ ]);
546
+
547
+ console.log(`Updated ${result.successCount} documents`);
548
+
549
+ // With progress tracking
550
+ const result2 = await updater.collection("products").bulkUpdate(
551
+ [
552
+ { id: "prod-1", data: { price: 29.99, stock: 100 } },
553
+ { id: "prod-2", data: { price: 49.99, stock: 50 } },
554
+ ],
555
+ {
556
+ onProgress: (progress) => {
557
+ console.log(`${progress.percentage}% complete`);
558
+ },
559
+ }
560
+ );
561
+ ```
562
+
445
563
  ### Dry Run Mode
446
564
 
447
565
  ```typescript
package/dist/index.d.mts CHANGED
@@ -260,6 +260,78 @@ interface OperationLog {
260
260
  };
261
261
  entries: LogEntry[];
262
262
  }
263
+ /**
264
+ * Result of createOne operation
265
+ */
266
+ interface CreateOneResult {
267
+ success: boolean;
268
+ id: string;
269
+ }
270
+ /**
271
+ * Aggregate operation specification
272
+ * Each key is the alias for the result, value defines the operation
273
+ */
274
+ interface AggregateSpec {
275
+ [alias: string]: {
276
+ op: "sum" | "average" | "count";
277
+ field?: string;
278
+ };
279
+ }
280
+ /**
281
+ * Result of aggregate operation
282
+ * Keys match the aliases from AggregateSpec
283
+ */
284
+ interface AggregateResult {
285
+ [alias: string]: number | null;
286
+ }
287
+ /**
288
+ * Options for paginate operation
289
+ */
290
+ interface PaginateOptions {
291
+ pageSize: number;
292
+ startAfter?: unknown;
293
+ }
294
+ /**
295
+ * Result of paginate operation
296
+ */
297
+ interface PaginateResult {
298
+ docs: {
299
+ id: string;
300
+ data: Record<string, any>;
301
+ }[];
302
+ nextCursor: unknown | null;
303
+ hasMore: boolean;
304
+ }
305
+ /**
306
+ * Input for bulk update operation
307
+ */
308
+ interface BulkUpdateInput {
309
+ id: string;
310
+ data: Record<string, any>;
311
+ }
312
+ /**
313
+ * Options for bulk update operation
314
+ */
315
+ interface BulkUpdateOptions {
316
+ /**
317
+ * Callback function for progress updates
318
+ * @param progress - Current progress information
319
+ */
320
+ onProgress?: (progress: ProgressInfo) => void;
321
+ /**
322
+ * Log file generation options
323
+ */
324
+ log?: LogOptions;
325
+ }
326
+ /**
327
+ * Result of bulk update operation
328
+ */
329
+ interface BulkUpdateResult {
330
+ successCount: number;
331
+ failureCount: number;
332
+ totalCount: number;
333
+ failedDocIds?: string[];
334
+ }
263
335
 
264
336
  /**
265
337
  * BatchUpdater - Core class for batch operations on Firestore
@@ -347,6 +419,15 @@ declare class BatchUpdater {
347
419
  id: string;
348
420
  data: Record<string, any>;
349
421
  }[]>;
422
+ /**
423
+ * Get a document by its ID directly (faster than findOne with where)
424
+ * @param id - Document ID
425
+ * @returns Document with id and data, or null if not found
426
+ */
427
+ getOne(id: string): Promise<{
428
+ id: string;
429
+ data: Record<string, any>;
430
+ } | null>;
350
431
  /**
351
432
  * Update the first document matching the query conditions
352
433
  * @param updateData - Data to update
@@ -364,6 +445,28 @@ declare class BatchUpdater {
364
445
  success: boolean;
365
446
  id: string | null;
366
447
  }>;
448
+ /**
449
+ * Create a single document in the collection
450
+ * @param data - Document data
451
+ * @param id - Optional document ID (auto-generated if not provided)
452
+ * @returns Result with success status and document id
453
+ */
454
+ createOne(data: Record<string, any>, id?: string): Promise<{
455
+ success: boolean;
456
+ id: string;
457
+ }>;
458
+ /**
459
+ * Run aggregate queries (sum, average, count) on matching documents
460
+ * @param spec - Aggregate specification defining operations and fields
461
+ * @returns Object with alias keys and numeric results
462
+ */
463
+ aggregate(spec: AggregateSpec): Promise<AggregateResult>;
464
+ /**
465
+ * Get documents with cursor-based pagination
466
+ * @param options - Pagination options (pageSize, startAfter cursor)
467
+ * @returns Page of documents with cursor for next page
468
+ */
469
+ paginate(options: PaginateOptions): Promise<PaginateResult>;
367
470
  /**
368
471
  * Preview changes before executing update
369
472
  * @param updateData - Data to update
@@ -395,6 +498,15 @@ declare class BatchUpdater {
395
498
  create(documents: CreateDocumentInput[], options?: CreateOptions): Promise<CreateResult & {
396
499
  logFilePath?: string;
397
500
  }>;
501
+ /**
502
+ * Update multiple documents with different data for each
503
+ * @param updates - Array of { id, data } objects specifying updates for each document
504
+ * @param options - Bulk update options (e.g., progress callback, log options)
505
+ * @returns Bulk update result with success/failure counts and optional log file path
506
+ */
507
+ bulkUpdate(updates: BulkUpdateInput[], options?: BulkUpdateOptions): Promise<BulkUpdateResult & {
508
+ logFilePath?: string;
509
+ }>;
398
510
  /**
399
511
  * Upsert documents matching query conditions
400
512
  * Updates existing documents or creates them if they don't exist
@@ -490,4 +602,4 @@ declare function isValidUpdateData(value: any): value is Record<string, any>;
490
602
  */
491
603
  declare function formatError(error: unknown, context?: string): string;
492
604
 
493
- 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 };
605
+ export { type AggregateResult, type AggregateSpec, BatchUpdater, type BulkUpdateInput, type BulkUpdateOptions, type BulkUpdateResult, type CountResult, type CreateDocumentInput, type CreateOneResult, type CreateOptions, type CreateResult, type DeleteOptions, type DeleteResult, type DocumentSnapshot, type DryRunResult, type FieldValueResult, type LogEntry, type LogOptions, type OperationLog, type OrderByCondition, type PaginateOptions, type PaginateResult, 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
@@ -260,6 +260,78 @@ interface OperationLog {
260
260
  };
261
261
  entries: LogEntry[];
262
262
  }
263
+ /**
264
+ * Result of createOne operation
265
+ */
266
+ interface CreateOneResult {
267
+ success: boolean;
268
+ id: string;
269
+ }
270
+ /**
271
+ * Aggregate operation specification
272
+ * Each key is the alias for the result, value defines the operation
273
+ */
274
+ interface AggregateSpec {
275
+ [alias: string]: {
276
+ op: "sum" | "average" | "count";
277
+ field?: string;
278
+ };
279
+ }
280
+ /**
281
+ * Result of aggregate operation
282
+ * Keys match the aliases from AggregateSpec
283
+ */
284
+ interface AggregateResult {
285
+ [alias: string]: number | null;
286
+ }
287
+ /**
288
+ * Options for paginate operation
289
+ */
290
+ interface PaginateOptions {
291
+ pageSize: number;
292
+ startAfter?: unknown;
293
+ }
294
+ /**
295
+ * Result of paginate operation
296
+ */
297
+ interface PaginateResult {
298
+ docs: {
299
+ id: string;
300
+ data: Record<string, any>;
301
+ }[];
302
+ nextCursor: unknown | null;
303
+ hasMore: boolean;
304
+ }
305
+ /**
306
+ * Input for bulk update operation
307
+ */
308
+ interface BulkUpdateInput {
309
+ id: string;
310
+ data: Record<string, any>;
311
+ }
312
+ /**
313
+ * Options for bulk update operation
314
+ */
315
+ interface BulkUpdateOptions {
316
+ /**
317
+ * Callback function for progress updates
318
+ * @param progress - Current progress information
319
+ */
320
+ onProgress?: (progress: ProgressInfo) => void;
321
+ /**
322
+ * Log file generation options
323
+ */
324
+ log?: LogOptions;
325
+ }
326
+ /**
327
+ * Result of bulk update operation
328
+ */
329
+ interface BulkUpdateResult {
330
+ successCount: number;
331
+ failureCount: number;
332
+ totalCount: number;
333
+ failedDocIds?: string[];
334
+ }
263
335
 
264
336
  /**
265
337
  * BatchUpdater - Core class for batch operations on Firestore
@@ -347,6 +419,15 @@ declare class BatchUpdater {
347
419
  id: string;
348
420
  data: Record<string, any>;
349
421
  }[]>;
422
+ /**
423
+ * Get a document by its ID directly (faster than findOne with where)
424
+ * @param id - Document ID
425
+ * @returns Document with id and data, or null if not found
426
+ */
427
+ getOne(id: string): Promise<{
428
+ id: string;
429
+ data: Record<string, any>;
430
+ } | null>;
350
431
  /**
351
432
  * Update the first document matching the query conditions
352
433
  * @param updateData - Data to update
@@ -364,6 +445,28 @@ declare class BatchUpdater {
364
445
  success: boolean;
365
446
  id: string | null;
366
447
  }>;
448
+ /**
449
+ * Create a single document in the collection
450
+ * @param data - Document data
451
+ * @param id - Optional document ID (auto-generated if not provided)
452
+ * @returns Result with success status and document id
453
+ */
454
+ createOne(data: Record<string, any>, id?: string): Promise<{
455
+ success: boolean;
456
+ id: string;
457
+ }>;
458
+ /**
459
+ * Run aggregate queries (sum, average, count) on matching documents
460
+ * @param spec - Aggregate specification defining operations and fields
461
+ * @returns Object with alias keys and numeric results
462
+ */
463
+ aggregate(spec: AggregateSpec): Promise<AggregateResult>;
464
+ /**
465
+ * Get documents with cursor-based pagination
466
+ * @param options - Pagination options (pageSize, startAfter cursor)
467
+ * @returns Page of documents with cursor for next page
468
+ */
469
+ paginate(options: PaginateOptions): Promise<PaginateResult>;
367
470
  /**
368
471
  * Preview changes before executing update
369
472
  * @param updateData - Data to update
@@ -395,6 +498,15 @@ declare class BatchUpdater {
395
498
  create(documents: CreateDocumentInput[], options?: CreateOptions): Promise<CreateResult & {
396
499
  logFilePath?: string;
397
500
  }>;
501
+ /**
502
+ * Update multiple documents with different data for each
503
+ * @param updates - Array of { id, data } objects specifying updates for each document
504
+ * @param options - Bulk update options (e.g., progress callback, log options)
505
+ * @returns Bulk update result with success/failure counts and optional log file path
506
+ */
507
+ bulkUpdate(updates: BulkUpdateInput[], options?: BulkUpdateOptions): Promise<BulkUpdateResult & {
508
+ logFilePath?: string;
509
+ }>;
398
510
  /**
399
511
  * Upsert documents matching query conditions
400
512
  * Updates existing documents or creates them if they don't exist
@@ -490,4 +602,4 @@ declare function isValidUpdateData(value: any): value is Record<string, any>;
490
602
  */
491
603
  declare function formatError(error: unknown, context?: string): string;
492
604
 
493
- 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 };
605
+ export { type AggregateResult, type AggregateSpec, BatchUpdater, type BulkUpdateInput, type BulkUpdateOptions, type BulkUpdateResult, type CountResult, type CreateDocumentInput, type CreateOneResult, type CreateOptions, type CreateResult, type DeleteOptions, type DeleteResult, type DocumentSnapshot, type DryRunResult, type FieldValueResult, type LogEntry, type LogOptions, type OperationLog, type OrderByCondition, type PaginateOptions, type PaginateResult, 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
@@ -31,7 +31,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  BatchUpdater: () => BatchUpdater,
34
- FieldValue: () => import_firestore.FieldValue,
34
+ FieldValue: () => import_firestore2.FieldValue,
35
35
  calculateProgress: () => calculateProgress,
36
36
  createLogCollector: () => createLogCollector,
37
37
  formatError: () => formatError,
@@ -43,6 +43,9 @@ __export(index_exports, {
43
43
  });
44
44
  module.exports = __toCommonJS(index_exports);
45
45
 
46
+ // src/core/batch-updater.ts
47
+ var import_firestore = require("firebase-admin/firestore");
48
+
46
49
  // src/utils/logger.ts
47
50
  var fs = __toESM(require("fs"));
48
51
  var path = __toESM(require("path"));
@@ -329,6 +332,38 @@ var BatchUpdater = class {
329
332
  data: doc.data()
330
333
  }));
331
334
  }
335
+ /**
336
+ * Get a document by its ID directly (faster than findOne with where)
337
+ * @param id - Document ID
338
+ * @returns Document with id and data, or null if not found
339
+ */
340
+ async getOne(id) {
341
+ this.validateSetup();
342
+ if (this.isCollectionGroup) {
343
+ throw new Error(
344
+ "getOne() cannot be used with collectionGroup(). Use findOne() with where conditions instead."
345
+ );
346
+ }
347
+ const docRef = this.firestore.collection(this.collectionPath).doc(id);
348
+ let docSnapshot;
349
+ if (this.selectedFields && this.selectedFields.length > 0) {
350
+ const query = this.firestore.collection(this.collectionPath).where("__name__", "==", docRef).select(...this.selectedFields);
351
+ const snapshot = await query.get();
352
+ if (snapshot.empty) {
353
+ return null;
354
+ }
355
+ docSnapshot = snapshot.docs[0];
356
+ } else {
357
+ docSnapshot = await docRef.get();
358
+ if (!docSnapshot.exists) {
359
+ return null;
360
+ }
361
+ }
362
+ return {
363
+ id: docSnapshot.id,
364
+ data: docSnapshot.data()
365
+ };
366
+ }
332
367
  /**
333
368
  * Update the first document matching the query conditions
334
369
  * @param updateData - Data to update
@@ -363,6 +398,95 @@ var BatchUpdater = class {
363
398
  await doc.ref.delete();
364
399
  return { success: true, id: doc.id };
365
400
  }
401
+ /**
402
+ * Create a single document in the collection
403
+ * @param data - Document data
404
+ * @param id - Optional document ID (auto-generated if not provided)
405
+ * @returns Result with success status and document id
406
+ */
407
+ async createOne(data, id) {
408
+ this.validateSetup();
409
+ if (this.isCollectionGroup) {
410
+ throw new Error(
411
+ "createOne() cannot be used with collectionGroup(). Use collection() with a specific path instead."
412
+ );
413
+ }
414
+ if (!isValidUpdateData(data)) {
415
+ throw new Error("Document data must be a non-empty object");
416
+ }
417
+ const collection = this.firestore.collection(this.collectionPath);
418
+ const docRef = id ? collection.doc(id) : collection.doc();
419
+ await docRef.set(data);
420
+ return { success: true, id: docRef.id };
421
+ }
422
+ /**
423
+ * Run aggregate queries (sum, average, count) on matching documents
424
+ * @param spec - Aggregate specification defining operations and fields
425
+ * @returns Object with alias keys and numeric results
426
+ */
427
+ async aggregate(spec) {
428
+ this.validateSetup();
429
+ if (!spec || Object.keys(spec).length === 0) {
430
+ throw new Error("Aggregate spec must be a non-empty object");
431
+ }
432
+ const query = this.buildQuery();
433
+ const aggregateFields = {};
434
+ for (const [alias, definition] of Object.entries(spec)) {
435
+ switch (definition.op) {
436
+ case "sum":
437
+ if (!definition.field) {
438
+ throw new Error(`Field is required for sum operation (alias: ${alias})`);
439
+ }
440
+ aggregateFields[alias] = import_firestore.AggregateField.sum(definition.field);
441
+ break;
442
+ case "average":
443
+ if (!definition.field) {
444
+ throw new Error(`Field is required for average operation (alias: ${alias})`);
445
+ }
446
+ aggregateFields[alias] = import_firestore.AggregateField.average(definition.field);
447
+ break;
448
+ case "count":
449
+ aggregateFields[alias] = import_firestore.AggregateField.count();
450
+ break;
451
+ default:
452
+ throw new Error(`Unknown aggregate operation: ${definition.op}`);
453
+ }
454
+ }
455
+ const snapshot = await query.aggregate(aggregateFields).get();
456
+ const data = snapshot.data();
457
+ const result = {};
458
+ for (const alias of Object.keys(spec)) {
459
+ result[alias] = data[alias] ?? null;
460
+ }
461
+ return result;
462
+ }
463
+ /**
464
+ * Get documents with cursor-based pagination
465
+ * @param options - Pagination options (pageSize, startAfter cursor)
466
+ * @returns Page of documents with cursor for next page
467
+ */
468
+ async paginate(options) {
469
+ this.validateSetup();
470
+ if (!options.pageSize || options.pageSize <= 0) {
471
+ throw new Error("pageSize must be a positive number");
472
+ }
473
+ let query = this.buildQuery().limit(options.pageSize + 1);
474
+ if (options.startAfter) {
475
+ query = query.startAfter(options.startAfter);
476
+ }
477
+ const snapshot = await query.get();
478
+ const hasMore = snapshot.docs.length > options.pageSize;
479
+ const docs = snapshot.docs.slice(0, options.pageSize).map((doc) => ({
480
+ id: doc.id,
481
+ data: doc.data()
482
+ }));
483
+ const lastDoc = snapshot.docs.length > 0 ? snapshot.docs[Math.min(snapshot.docs.length - 1, options.pageSize - 1)] : null;
484
+ return {
485
+ docs,
486
+ nextCursor: hasMore ? lastDoc : null,
487
+ hasMore
488
+ };
489
+ }
366
490
  /**
367
491
  * Preview changes before executing update
368
492
  * @param updateData - Data to update
@@ -629,6 +753,81 @@ var BatchUpdater = class {
629
753
  }
630
754
  return result;
631
755
  }
756
+ /**
757
+ * Update multiple documents with different data for each
758
+ * @param updates - Array of { id, data } objects specifying updates for each document
759
+ * @param options - Bulk update options (e.g., progress callback, log options)
760
+ * @returns Bulk update result with success/failure counts and optional log file path
761
+ */
762
+ async bulkUpdate(updates, options = {}) {
763
+ this.validateSetup();
764
+ if (this.isCollectionGroup) {
765
+ throw new Error(
766
+ "bulkUpdate() cannot be used with collectionGroup(). Use collection() with a specific path instead."
767
+ );
768
+ }
769
+ if (!Array.isArray(updates) || updates.length === 0) {
770
+ throw new Error("Updates array must be non-empty");
771
+ }
772
+ for (const update of updates) {
773
+ if (!update.id || typeof update.id !== "string") {
774
+ throw new Error("Each update must have a valid id");
775
+ }
776
+ if (!isValidUpdateData(update.data)) {
777
+ throw new Error("Each update must have valid data");
778
+ }
779
+ }
780
+ const totalCount = updates.length;
781
+ let successCount = 0;
782
+ let failureCount = 0;
783
+ const failedDocIds = [];
784
+ const logCollector = options.log?.enabled ? createLogCollector("update", this.collectionPath) : null;
785
+ const bulkWriter = this.firestore.bulkWriter();
786
+ const collection = this.firestore.collection(this.collectionPath);
787
+ let processedCount = 0;
788
+ const docIdMap = /* @__PURE__ */ new Map();
789
+ for (const update of updates) {
790
+ const docRef = collection.doc(update.id);
791
+ docIdMap.set(docRef.path, update.id);
792
+ }
793
+ bulkWriter.onWriteResult((ref) => {
794
+ successCount++;
795
+ processedCount++;
796
+ const docId = docIdMap.get(ref.path) || ref.id;
797
+ logCollector?.addEntry(docId, "success");
798
+ if (options.onProgress) {
799
+ const progress = calculateProgress(processedCount, totalCount);
800
+ options.onProgress(progress);
801
+ }
802
+ });
803
+ bulkWriter.onWriteError((error) => {
804
+ failureCount++;
805
+ processedCount++;
806
+ const docId = error.documentRef?.id || "unknown";
807
+ failedDocIds.push(docId);
808
+ logCollector?.addEntry(docId, "failure", error.message);
809
+ if (options.onProgress) {
810
+ const progress = calculateProgress(processedCount, totalCount);
811
+ options.onProgress(progress);
812
+ }
813
+ return false;
814
+ });
815
+ for (const update of updates) {
816
+ const docRef = collection.doc(update.id);
817
+ bulkWriter.update(docRef, update.data);
818
+ }
819
+ await bulkWriter.close();
820
+ const result = {
821
+ successCount,
822
+ failureCount,
823
+ totalCount,
824
+ failedDocIds: failedDocIds.length > 0 ? failedDocIds : void 0
825
+ };
826
+ if (logCollector && options.log) {
827
+ result.logFilePath = logCollector.finalize(options.log);
828
+ }
829
+ return result;
830
+ }
632
831
  /**
633
832
  * Upsert documents matching query conditions
634
833
  * Updates existing documents or creates them if they don't exist
@@ -973,7 +1172,7 @@ var BatchUpdater = class {
973
1172
  };
974
1173
 
975
1174
  // src/index.ts
976
- var import_firestore = require("firebase-admin/firestore");
1175
+ var import_firestore2 = require("firebase-admin/firestore");
977
1176
  // Annotate the CommonJS export names for ESM import in node:
978
1177
  0 && (module.exports = {
979
1178
  BatchUpdater,
package/dist/index.mjs CHANGED
@@ -1,3 +1,6 @@
1
+ // src/core/batch-updater.ts
2
+ import { AggregateField } from "firebase-admin/firestore";
3
+
1
4
  // src/utils/logger.ts
2
5
  import * as fs from "fs";
3
6
  import * as path from "path";
@@ -284,6 +287,38 @@ var BatchUpdater = class {
284
287
  data: doc.data()
285
288
  }));
286
289
  }
290
+ /**
291
+ * Get a document by its ID directly (faster than findOne with where)
292
+ * @param id - Document ID
293
+ * @returns Document with id and data, or null if not found
294
+ */
295
+ async getOne(id) {
296
+ this.validateSetup();
297
+ if (this.isCollectionGroup) {
298
+ throw new Error(
299
+ "getOne() cannot be used with collectionGroup(). Use findOne() with where conditions instead."
300
+ );
301
+ }
302
+ const docRef = this.firestore.collection(this.collectionPath).doc(id);
303
+ let docSnapshot;
304
+ if (this.selectedFields && this.selectedFields.length > 0) {
305
+ const query = this.firestore.collection(this.collectionPath).where("__name__", "==", docRef).select(...this.selectedFields);
306
+ const snapshot = await query.get();
307
+ if (snapshot.empty) {
308
+ return null;
309
+ }
310
+ docSnapshot = snapshot.docs[0];
311
+ } else {
312
+ docSnapshot = await docRef.get();
313
+ if (!docSnapshot.exists) {
314
+ return null;
315
+ }
316
+ }
317
+ return {
318
+ id: docSnapshot.id,
319
+ data: docSnapshot.data()
320
+ };
321
+ }
287
322
  /**
288
323
  * Update the first document matching the query conditions
289
324
  * @param updateData - Data to update
@@ -318,6 +353,95 @@ var BatchUpdater = class {
318
353
  await doc.ref.delete();
319
354
  return { success: true, id: doc.id };
320
355
  }
356
+ /**
357
+ * Create a single document in the collection
358
+ * @param data - Document data
359
+ * @param id - Optional document ID (auto-generated if not provided)
360
+ * @returns Result with success status and document id
361
+ */
362
+ async createOne(data, id) {
363
+ this.validateSetup();
364
+ if (this.isCollectionGroup) {
365
+ throw new Error(
366
+ "createOne() cannot be used with collectionGroup(). Use collection() with a specific path instead."
367
+ );
368
+ }
369
+ if (!isValidUpdateData(data)) {
370
+ throw new Error("Document data must be a non-empty object");
371
+ }
372
+ const collection = this.firestore.collection(this.collectionPath);
373
+ const docRef = id ? collection.doc(id) : collection.doc();
374
+ await docRef.set(data);
375
+ return { success: true, id: docRef.id };
376
+ }
377
+ /**
378
+ * Run aggregate queries (sum, average, count) on matching documents
379
+ * @param spec - Aggregate specification defining operations and fields
380
+ * @returns Object with alias keys and numeric results
381
+ */
382
+ async aggregate(spec) {
383
+ this.validateSetup();
384
+ if (!spec || Object.keys(spec).length === 0) {
385
+ throw new Error("Aggregate spec must be a non-empty object");
386
+ }
387
+ const query = this.buildQuery();
388
+ const aggregateFields = {};
389
+ for (const [alias, definition] of Object.entries(spec)) {
390
+ switch (definition.op) {
391
+ case "sum":
392
+ if (!definition.field) {
393
+ throw new Error(`Field is required for sum operation (alias: ${alias})`);
394
+ }
395
+ aggregateFields[alias] = AggregateField.sum(definition.field);
396
+ break;
397
+ case "average":
398
+ if (!definition.field) {
399
+ throw new Error(`Field is required for average operation (alias: ${alias})`);
400
+ }
401
+ aggregateFields[alias] = AggregateField.average(definition.field);
402
+ break;
403
+ case "count":
404
+ aggregateFields[alias] = AggregateField.count();
405
+ break;
406
+ default:
407
+ throw new Error(`Unknown aggregate operation: ${definition.op}`);
408
+ }
409
+ }
410
+ const snapshot = await query.aggregate(aggregateFields).get();
411
+ const data = snapshot.data();
412
+ const result = {};
413
+ for (const alias of Object.keys(spec)) {
414
+ result[alias] = data[alias] ?? null;
415
+ }
416
+ return result;
417
+ }
418
+ /**
419
+ * Get documents with cursor-based pagination
420
+ * @param options - Pagination options (pageSize, startAfter cursor)
421
+ * @returns Page of documents with cursor for next page
422
+ */
423
+ async paginate(options) {
424
+ this.validateSetup();
425
+ if (!options.pageSize || options.pageSize <= 0) {
426
+ throw new Error("pageSize must be a positive number");
427
+ }
428
+ let query = this.buildQuery().limit(options.pageSize + 1);
429
+ if (options.startAfter) {
430
+ query = query.startAfter(options.startAfter);
431
+ }
432
+ const snapshot = await query.get();
433
+ const hasMore = snapshot.docs.length > options.pageSize;
434
+ const docs = snapshot.docs.slice(0, options.pageSize).map((doc) => ({
435
+ id: doc.id,
436
+ data: doc.data()
437
+ }));
438
+ const lastDoc = snapshot.docs.length > 0 ? snapshot.docs[Math.min(snapshot.docs.length - 1, options.pageSize - 1)] : null;
439
+ return {
440
+ docs,
441
+ nextCursor: hasMore ? lastDoc : null,
442
+ hasMore
443
+ };
444
+ }
321
445
  /**
322
446
  * Preview changes before executing update
323
447
  * @param updateData - Data to update
@@ -584,6 +708,81 @@ var BatchUpdater = class {
584
708
  }
585
709
  return result;
586
710
  }
711
+ /**
712
+ * Update multiple documents with different data for each
713
+ * @param updates - Array of { id, data } objects specifying updates for each document
714
+ * @param options - Bulk update options (e.g., progress callback, log options)
715
+ * @returns Bulk update result with success/failure counts and optional log file path
716
+ */
717
+ async bulkUpdate(updates, options = {}) {
718
+ this.validateSetup();
719
+ if (this.isCollectionGroup) {
720
+ throw new Error(
721
+ "bulkUpdate() cannot be used with collectionGroup(). Use collection() with a specific path instead."
722
+ );
723
+ }
724
+ if (!Array.isArray(updates) || updates.length === 0) {
725
+ throw new Error("Updates array must be non-empty");
726
+ }
727
+ for (const update of updates) {
728
+ if (!update.id || typeof update.id !== "string") {
729
+ throw new Error("Each update must have a valid id");
730
+ }
731
+ if (!isValidUpdateData(update.data)) {
732
+ throw new Error("Each update must have valid data");
733
+ }
734
+ }
735
+ const totalCount = updates.length;
736
+ let successCount = 0;
737
+ let failureCount = 0;
738
+ const failedDocIds = [];
739
+ const logCollector = options.log?.enabled ? createLogCollector("update", this.collectionPath) : null;
740
+ const bulkWriter = this.firestore.bulkWriter();
741
+ const collection = this.firestore.collection(this.collectionPath);
742
+ let processedCount = 0;
743
+ const docIdMap = /* @__PURE__ */ new Map();
744
+ for (const update of updates) {
745
+ const docRef = collection.doc(update.id);
746
+ docIdMap.set(docRef.path, update.id);
747
+ }
748
+ bulkWriter.onWriteResult((ref) => {
749
+ successCount++;
750
+ processedCount++;
751
+ const docId = docIdMap.get(ref.path) || ref.id;
752
+ logCollector?.addEntry(docId, "success");
753
+ if (options.onProgress) {
754
+ const progress = calculateProgress(processedCount, totalCount);
755
+ options.onProgress(progress);
756
+ }
757
+ });
758
+ bulkWriter.onWriteError((error) => {
759
+ failureCount++;
760
+ processedCount++;
761
+ const docId = error.documentRef?.id || "unknown";
762
+ failedDocIds.push(docId);
763
+ logCollector?.addEntry(docId, "failure", error.message);
764
+ if (options.onProgress) {
765
+ const progress = calculateProgress(processedCount, totalCount);
766
+ options.onProgress(progress);
767
+ }
768
+ return false;
769
+ });
770
+ for (const update of updates) {
771
+ const docRef = collection.doc(update.id);
772
+ bulkWriter.update(docRef, update.data);
773
+ }
774
+ await bulkWriter.close();
775
+ const result = {
776
+ successCount,
777
+ failureCount,
778
+ totalCount,
779
+ failedDocIds: failedDocIds.length > 0 ? failedDocIds : void 0
780
+ };
781
+ if (logCollector && options.log) {
782
+ result.logFilePath = logCollector.finalize(options.log);
783
+ }
784
+ return result;
785
+ }
587
786
  /**
588
787
  * Upsert documents matching query conditions
589
788
  * Updates existing documents or creates them if they don't exist
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firestore-batch-updater",
3
- "version": "1.4.0",
3
+ "version": "1.6.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",