@twin.org/entity-storage-connector-gcp-firestore 0.0.3-next.9 → 0.9.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.
@@ -1,9 +1,10 @@
1
1
  // Copyright 2024 IOTA Stiftung.
2
2
  // SPDX-License-Identifier: Apache-2.0.
3
- import { Firestore } from "@google-cloud/firestore";
3
+ import { Filter, Firestore } from "@google-cloud/firestore";
4
4
  import { ContextIdHelper, ContextIdStore } from "@twin.org/context";
5
- import { BaseError, ComponentFactory, Converter, GeneralError, Guards, Is, ObjectHelper } from "@twin.org/core";
6
- import { ComparisonOperator, EntityConditions, EntitySchemaFactory, EntitySchemaHelper, SortDirection } from "@twin.org/entity";
5
+ import { BaseError, ComponentFactory, Converter, GeneralError, Guards, HealthStatus, Is, ObjectHelper, Validation } from "@twin.org/core";
6
+ import { ComparisonOperator, EntityConditions, EntitySchemaFactory, EntitySchemaHelper, LogicalOperator, SortDirection } from "@twin.org/entity";
7
+ import { EntityStorageHelper } from "@twin.org/entity-storage-models";
7
8
  /**
8
9
  * Class for performing entity storage operations using Firestore.
9
10
  */
@@ -17,6 +18,17 @@ export class FirestoreEntityStorageConnector {
17
18
  * @internal
18
19
  */
19
20
  static _DEFAULT_LIMIT = 40;
21
+ /**
22
+ * Separator used between context ID parts in Firestore collection names.
23
+ * Must not be "/" which Firestore interprets as a path separator.
24
+ * @internal
25
+ */
26
+ static _PARTITION_SEPARATOR = ":";
27
+ /**
28
+ * The name for the schema.
29
+ * @internal
30
+ */
31
+ _entitySchemaName;
20
32
  /**
21
33
  * The schema for the entity.
22
34
  * @internal
@@ -58,6 +70,7 @@ export class FirestoreEntityStorageConnector {
58
70
  credentials = ObjectHelper.fromBytes(Converter.base64ToBytes(options.config.credentials));
59
71
  }
60
72
  this._config = options.config;
73
+ this._entitySchemaName = options.entitySchema;
61
74
  this._entitySchema = EntitySchemaFactory.get(options.entitySchema);
62
75
  this._partitionContextIds = options.partitionContextIds;
63
76
  this._primaryKey = EntitySchemaHelper.getPrimaryKey(this._entitySchema);
@@ -82,6 +95,34 @@ export class FirestoreEntityStorageConnector {
82
95
  className() {
83
96
  return FirestoreEntityStorageConnector.CLASS_NAME;
84
97
  }
98
+ /**
99
+ * Returns the health status of the component.
100
+ * @returns The health status of the component.
101
+ */
102
+ async health() {
103
+ try {
104
+ await this._firestoreClient.listCollections();
105
+ return [
106
+ {
107
+ source: FirestoreEntityStorageConnector.CLASS_NAME,
108
+ status: HealthStatus.Ok,
109
+ description: "healthDescription",
110
+ data: { projectId: this._config.projectId, collectionName: this._config.collectionName }
111
+ }
112
+ ];
113
+ }
114
+ catch {
115
+ return [
116
+ {
117
+ source: FirestoreEntityStorageConnector.CLASS_NAME,
118
+ status: HealthStatus.Error,
119
+ description: "healthDescription",
120
+ message: "connectionFailed",
121
+ data: { projectId: this._config.projectId, collectionName: this._config.collectionName }
122
+ }
123
+ ];
124
+ }
125
+ }
85
126
  /**
86
127
  * Get the schema for the entities.
87
128
  * @returns The schema for the entities.
@@ -149,14 +190,14 @@ export class FirestoreEntityStorageConnector {
149
190
  async get(id, secondaryIndex, conditions) {
150
191
  Guards.stringValue(FirestoreEntityStorageConnector.CLASS_NAME, "id", id);
151
192
  const contextIds = await ContextIdStore.getContextIds();
152
- const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
193
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds, FirestoreEntityStorageConnector._PARTITION_SEPARATOR);
153
194
  try {
154
195
  const collection = this._firestoreClient.collection(this.collectionName(partitionKey));
155
196
  if (!Is.arrayValue(conditions)) {
156
197
  const docRef = collection.doc(id);
157
198
  const doc = await docRef.get();
158
199
  if (doc.exists) {
159
- return doc.data();
200
+ return EntityStorageHelper.unPrepareEntity(doc.data(), []);
160
201
  }
161
202
  }
162
203
  // Use conditions to construct a query
@@ -175,8 +216,7 @@ export class FirestoreEntityStorageConnector {
175
216
  }
176
217
  const querySnapshot = await query.limit(1).get();
177
218
  if (!querySnapshot.empty) {
178
- const entity = querySnapshot.docs[0].data();
179
- return entity;
219
+ return EntityStorageHelper.unPrepareEntity(querySnapshot.docs[0].data(), []);
180
220
  }
181
221
  }
182
222
  catch (err) {
@@ -192,20 +232,22 @@ export class FirestoreEntityStorageConnector {
192
232
  async set(entity, conditions) {
193
233
  Guards.object(FirestoreEntityStorageConnector.CLASS_NAME, "entity", entity);
194
234
  const contextIds = await ContextIdStore.getContextIds();
195
- const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
196
- EntitySchemaHelper.validateEntity(entity, this.getSchema());
235
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds, FirestoreEntityStorageConnector._PARTITION_SEPARATOR);
236
+ const prepared = EntityStorageHelper.prepareEntity(entity, this._entitySchema, undefined, {
237
+ nullBehavior: "nullify"
238
+ });
197
239
  try {
198
- const id = entity[this._primaryKey.property];
240
+ const id = prepared[this._primaryKey.property];
199
241
  const collection = this._firestoreClient.collection(this.collectionName(partitionKey));
200
242
  const docRef = collection.doc(id);
201
243
  if (!Is.arrayValue(conditions)) {
202
- await docRef.set(entity);
244
+ await docRef.set(prepared);
203
245
  }
204
246
  else {
205
247
  await this._firestoreClient.runTransaction(async (transaction) => {
206
248
  const docSnapshot = await transaction.get(docRef);
207
249
  if (!docSnapshot.exists) {
208
- transaction.set(docRef, entity);
250
+ transaction.set(docRef, prepared);
209
251
  }
210
252
  else {
211
253
  const data = docSnapshot.data();
@@ -216,7 +258,7 @@ export class FirestoreEntityStorageConnector {
216
258
  value: c.value
217
259
  }))
218
260
  })) {
219
- transaction.set(docRef, entity);
261
+ transaction.set(docRef, prepared);
220
262
  }
221
263
  }
222
264
  });
@@ -226,6 +268,60 @@ export class FirestoreEntityStorageConnector {
226
268
  throw new GeneralError(FirestoreEntityStorageConnector.CLASS_NAME, "setEntityFailed", { id: entity.id }, err);
227
269
  }
228
270
  }
271
+ /**
272
+ * Set multiple entities in a batch.
273
+ * @param entities The entities to set.
274
+ * @returns Nothing.
275
+ */
276
+ async setBatch(entities) {
277
+ Guards.arrayValue(FirestoreEntityStorageConnector.CLASS_NAME, "entities", entities);
278
+ const contextIds = await ContextIdStore.getContextIds();
279
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds, FirestoreEntityStorageConnector._PARTITION_SEPARATOR);
280
+ const preparedEntities = entities.map(entity => EntityStorageHelper.prepareEntity(entity, this._entitySchema, undefined, {
281
+ nullBehavior: "nullify"
282
+ }));
283
+ try {
284
+ const collection = this._firestoreClient.collection(this.collectionName(partitionKey));
285
+ const chunkSize = FirestoreEntityStorageConnector._DEFAULT_LIMIT;
286
+ for (let i = 0; i < preparedEntities.length; i += chunkSize) {
287
+ const chunk = preparedEntities.slice(i, i + chunkSize);
288
+ const batch = this._firestoreClient.batch();
289
+ for (const entity of chunk) {
290
+ const id = entity[this._primaryKey.property];
291
+ const docRef = collection.doc(id);
292
+ batch.set(docRef, entity);
293
+ }
294
+ await batch.commit();
295
+ }
296
+ }
297
+ catch (err) {
298
+ throw new GeneralError(FirestoreEntityStorageConnector.CLASS_NAME, "setBatchFailed", undefined, err);
299
+ }
300
+ }
301
+ /**
302
+ * Empty the storage by deleting all entities in the collection.
303
+ * @returns Nothing.
304
+ */
305
+ async empty() {
306
+ const contextIds = await ContextIdStore.getContextIds();
307
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds, FirestoreEntityStorageConnector._PARTITION_SEPARATOR);
308
+ try {
309
+ const collection = this._firestoreClient.collection(this.collectionName(partitionKey));
310
+ const snapshot = await collection.get();
311
+ const chunkSize = 500;
312
+ for (let i = 0; i < snapshot.docs.length; i += chunkSize) {
313
+ const chunk = snapshot.docs.slice(i, i + chunkSize);
314
+ const batch = this._firestoreClient.batch();
315
+ for (const doc of chunk) {
316
+ batch.delete(doc.ref);
317
+ }
318
+ await batch.commit();
319
+ }
320
+ }
321
+ catch (err) {
322
+ throw new GeneralError(FirestoreEntityStorageConnector.CLASS_NAME, "emptyFailed", undefined, err);
323
+ }
324
+ }
229
325
  /**
230
326
  * Remove the entity.
231
327
  * @param id The id of the entity to remove.
@@ -235,7 +331,7 @@ export class FirestoreEntityStorageConnector {
235
331
  async remove(id, conditions) {
236
332
  Guards.stringValue(FirestoreEntityStorageConnector.CLASS_NAME, "id", id);
237
333
  const contextIds = await ContextIdStore.getContextIds();
238
- const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
334
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds, FirestoreEntityStorageConnector._PARTITION_SEPARATOR);
239
335
  try {
240
336
  const collection = this._firestoreClient.collection(this.collectionName(partitionKey));
241
337
  const docRef = collection.doc(id);
@@ -264,6 +360,146 @@ export class FirestoreEntityStorageConnector {
264
360
  throw new GeneralError(FirestoreEntityStorageConnector.CLASS_NAME, "removeEntityFailed", { id }, err);
265
361
  }
266
362
  }
363
+ /**
364
+ * Remove multiple entities by their primary key IDs using a Firestore WriteBatch.
365
+ * @param ids The ids of the entities to remove.
366
+ * @returns Nothing.
367
+ */
368
+ async removeBatch(ids) {
369
+ Guards.arrayValue(FirestoreEntityStorageConnector.CLASS_NAME, "ids", ids);
370
+ const contextIds = await ContextIdStore.getContextIds();
371
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds, FirestoreEntityStorageConnector._PARTITION_SEPARATOR);
372
+ try {
373
+ const collection = this._firestoreClient.collection(this.collectionName(partitionKey));
374
+ const chunkSize = 500;
375
+ for (let i = 0; i < ids.length; i += chunkSize) {
376
+ const chunk = ids.slice(i, i + chunkSize);
377
+ const batch = this._firestoreClient.batch();
378
+ for (const id of chunk) {
379
+ const docRef = collection.doc(id);
380
+ batch.delete(docRef);
381
+ }
382
+ await batch.commit();
383
+ }
384
+ }
385
+ catch (err) {
386
+ throw new GeneralError(FirestoreEntityStorageConnector.CLASS_NAME, "removeBatchFailed", undefined, err);
387
+ }
388
+ }
389
+ /**
390
+ * Teardown the storage by deleting all documents across all partition collections.
391
+ * @param nodeLoggingComponentType The node logging component type.
392
+ * @returns True if the teardown process was successful.
393
+ */
394
+ async teardown(nodeLoggingComponentType) {
395
+ const nodeLogging = ComponentFactory.getIfExists(nodeLoggingComponentType);
396
+ await nodeLogging?.log({
397
+ level: "info",
398
+ source: FirestoreEntityStorageConnector.CLASS_NAME,
399
+ ts: Date.now(),
400
+ message: "storeTearingDown"
401
+ });
402
+ try {
403
+ await this.deleteAllPartitionCollections(this._config.collectionName);
404
+ await nodeLogging?.log({
405
+ level: "info",
406
+ source: FirestoreEntityStorageConnector.CLASS_NAME,
407
+ ts: Date.now(),
408
+ message: "storeTornDown"
409
+ });
410
+ return true;
411
+ }
412
+ catch (err) {
413
+ await nodeLogging?.log({
414
+ level: "error",
415
+ source: FirestoreEntityStorageConnector.CLASS_NAME,
416
+ ts: Date.now(),
417
+ message: "teardownFailed",
418
+ error: BaseError.fromError(err)
419
+ });
420
+ return false;
421
+ }
422
+ }
423
+ /**
424
+ * Get a unique list of all the context ids from the storage.
425
+ * @returns The list of unique context ids.
426
+ */
427
+ async getPartitionContextIds() {
428
+ const partitionContextIds = this._partitionContextIds;
429
+ if (!Is.arrayValue(partitionContextIds)) {
430
+ return [];
431
+ }
432
+ try {
433
+ const prefix = `${this._config.collectionName}_`;
434
+ const collections = await this._firestoreClient.listCollections();
435
+ const result = [];
436
+ for (const col of collections) {
437
+ if (col.id.startsWith(prefix)) {
438
+ const partitionKey = col.id.slice(prefix.length);
439
+ if (Is.stringValue(partitionKey)) {
440
+ result.push(ContextIdHelper.shortSplit(partitionContextIds, partitionKey, FirestoreEntityStorageConnector._PARTITION_SEPARATOR));
441
+ }
442
+ }
443
+ }
444
+ return result;
445
+ }
446
+ catch (err) {
447
+ throw new GeneralError(FirestoreEntityStorageConnector.CLASS_NAME, "getPartitionContextIdsFailed", undefined, err);
448
+ }
449
+ }
450
+ /**
451
+ * Create the target connector for performing the migration using a temporary collection name.
452
+ * @param newEntitySchema The name of the new entity schema to create the connector for.
453
+ * @returns Connector for performing the migration.
454
+ */
455
+ async createTargetConnector(newEntitySchema) {
456
+ const migrationCollectionName = `${this._config.collectionName}Migration${Date.now()}`;
457
+ return new FirestoreEntityStorageConnector({
458
+ entitySchema: newEntitySchema,
459
+ config: { ...this._config, collectionName: migrationCollectionName },
460
+ partitionContextIds: this._partitionContextIds
461
+ });
462
+ }
463
+ /**
464
+ * Finalize the migration by tearing down the old collections and replacing them with the target collections.
465
+ * @param targetConnector The target connector to finalize the migration with.
466
+ * @param options The options to control how the migration is finalized.
467
+ * @param loggingComponentType The optional component type to use for logging.
468
+ * @returns The final connector pointing at the original collection name.
469
+ */
470
+ async finalizeMigration(targetConnector, options, loggingComponentType) {
471
+ // Firestore has no collection-rename operation, so we create fresh collections under
472
+ // the original name, copy all documents from the migration collections, then delete the
473
+ // migration collections.
474
+ // Teardown all existing source collections to free up the original collection name prefix.
475
+ await this.teardown(loggingComponentType);
476
+ // Create a new connector at the original collection name but with the new schema.
477
+ const originalCollectionName = this._config.collectionName;
478
+ const finalConnector = new FirestoreEntityStorageConnector({
479
+ entitySchema: targetConnector._entitySchemaName,
480
+ config: { ...targetConnector._config, collectionName: originalCollectionName },
481
+ partitionContextIds: this._partitionContextIds
482
+ });
483
+ if (await finalConnector.bootstrap(loggingComponentType)) {
484
+ // Since there is no rename, we need to copy the data from the migration table to the new table
485
+ const partitions = await targetConnector.getPartitionContextIds();
486
+ const batchSize = options?.batchSize ?? FirestoreEntityStorageConnector._DEFAULT_LIMIT;
487
+ await this.bulkCopy(targetConnector, finalConnector, partitions, batchSize);
488
+ await targetConnector.teardown(loggingComponentType);
489
+ return finalConnector;
490
+ }
491
+ throw new GeneralError(FirestoreEntityStorageConnector.CLASS_NAME, "finalizeMigrationFailedBootstrap", undefined);
492
+ }
493
+ /**
494
+ * Cleanup the migration if a migration fails or needs to be aborted.
495
+ * @param targetConnector The target connector to cleanup.
496
+ * @param options The options to control how the migration is cleaned up.
497
+ * @param loggingComponentType The optional component type to use for logging.
498
+ */
499
+ async cleanupMigration(targetConnector, options, loggingComponentType) {
500
+ // If something failed the only thing to cleanup is the migration table
501
+ await targetConnector?.teardown?.(loggingComponentType);
502
+ }
267
503
  /**
268
504
  * Find all the entities which match the conditions.
269
505
  * @param conditions The conditions to match for the entities.
@@ -276,12 +512,64 @@ export class FirestoreEntityStorageConnector {
276
512
  async query(conditions, sortProperties, properties, cursor, limit) {
277
513
  const queryDescription = [];
278
514
  const contextIds = await ContextIdStore.getContextIds();
279
- const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
515
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds, FirestoreEntityStorageConnector._PARTITION_SEPARATOR);
516
+ const finalLimit = limit ?? FirestoreEntityStorageConnector._DEFAULT_LIMIT;
517
+ EntityStorageHelper.validateSortProperties(this._entitySchema, sortProperties);
518
+ EntityStorageHelper.validateProperties(this._entitySchema, properties);
519
+ if (!Is.empty(limit)) {
520
+ const validationFailures = [];
521
+ Validation.integer("limit", limit, validationFailures, undefined, { minValue: 1 });
522
+ Validation.asValidationError(FirestoreEntityStorageConnector.CLASS_NAME, "query", validationFailures);
523
+ }
280
524
  try {
281
525
  const collection = this._firestoreClient.collection(this.collectionName(partitionKey));
526
+ // Firestore has no native substring search. When any condition needs in-memory
527
+ // filtering (string Includes / NotIncludes), fetch all matching docs and filter
528
+ // client-side, using an index-based cursor for pagination.
529
+ if (!Is.empty(conditions) && this.needsPostFilter(conditions)) {
530
+ queryDescription.push("InMemoryFilter");
531
+ let baseQuery = collection;
532
+ if (Is.arrayValue(sortProperties)) {
533
+ for (const { property, sortDirection } of sortProperties) {
534
+ baseQuery = baseQuery.orderBy(property, sortDirection === SortDirection.Ascending ? "asc" : "desc");
535
+ }
536
+ }
537
+ const allSnapshot = await baseQuery.get();
538
+ let allEntities = allSnapshot.docs.map((doc) => EntityStorageHelper.unPrepareEntity(doc.data(), []));
539
+ allEntities = allEntities.filter(e => EntityConditions.check(e, conditions));
540
+ let projected;
541
+ if (Is.arrayValue(properties)) {
542
+ projected = allEntities.map(e => {
543
+ const out = {};
544
+ for (const prop of properties) {
545
+ if (prop in e) {
546
+ out[prop] = e[prop];
547
+ }
548
+ }
549
+ return out;
550
+ });
551
+ }
552
+ else {
553
+ projected = allEntities;
554
+ }
555
+ const start = Is.stringValue(cursor) ? Number.parseInt(cursor, 10) : 0;
556
+ const page = projected.slice(start, start + finalLimit);
557
+ const nextCursor = start + finalLimit < projected.length ? String(start + finalLimit) : undefined;
558
+ return { entities: page, cursor: nextCursor };
559
+ }
560
+ if (this.hasEmptyInCondition(conditions)) {
561
+ return { entities: [], cursor: undefined };
562
+ }
563
+ // Prune empty-In leaves from OR branches: the Firestore SDK throws on
564
+ // Filter.where(prop, "in", []) even inside an OR where other branches still match.
565
+ // hasEmptyInCondition above already handles the all-false case, so pruning here
566
+ // is safe — any removed leaf was a no-op branch.
567
+ const effectiveConditions = !Is.empty(conditions)
568
+ ? (this.pruneEmptyInConditions(conditions) ?? undefined)
569
+ : conditions;
282
570
  let query = collection;
283
- if (!Is.empty(conditions)) {
284
- query = this.applyConditions(query, conditions);
571
+ if (!Is.empty(effectiveConditions)) {
572
+ query = this.applyConditions(query, effectiveConditions);
285
573
  queryDescription.push(`Conditions: ${JSON.stringify(conditions)}`);
286
574
  }
287
575
  if (Is.arrayValue(sortProperties)) {
@@ -291,24 +579,30 @@ export class FirestoreEntityStorageConnector {
291
579
  queryDescription.push(`Sort: ${JSON.stringify(sortProperties)}`);
292
580
  }
293
581
  if (Is.stringValue(cursor)) {
294
- const cursorDoc = await this._firestoreClient.doc(cursor).get();
295
- if (cursorDoc?.exists) {
296
- query = query.startAfter(cursorDoc);
582
+ // Discard cursors from a different partition — startAfter() throws if the
583
+ // snapshot belongs to a different collection than the current query.
584
+ const cursorCollection = cursor.slice(0, cursor.lastIndexOf("/"));
585
+ if (cursorCollection === this.collectionName(partitionKey)) {
586
+ const cursorDoc = await this._firestoreClient.doc(cursor).get();
587
+ if (cursorDoc?.exists) {
588
+ query = query.startAfter(cursorDoc);
589
+ }
590
+ queryDescription.push(`Cursor: ${cursor}`);
297
591
  }
298
- queryDescription.push(`Cursor: ${cursor}`);
299
592
  }
300
- const finalLimit = limit ?? FirestoreEntityStorageConnector._DEFAULT_LIMIT;
301
- query = query.limit(finalLimit);
593
+ query = query.limit(finalLimit + 1);
302
594
  queryDescription.push(`Limit: ${finalLimit}`);
303
- if (properties) {
595
+ if (Is.arrayValue(properties)) {
304
596
  query = query.select(...properties);
305
597
  queryDescription.push(`Properties: ${properties.join(", ")}`);
306
598
  }
307
599
  const querySnapshot = await query.get();
308
- const entities = querySnapshot.docs.map((doc) => doc.data());
600
+ const hasMore = querySnapshot.docs.length > finalLimit;
601
+ const resultDocs = hasMore ? querySnapshot.docs.slice(0, finalLimit) : querySnapshot.docs;
602
+ const entities = resultDocs.map((doc) => EntityStorageHelper.unPrepareEntity(doc.data(), []));
309
603
  let nextCursor;
310
- if (entities.length === finalLimit) {
311
- nextCursor = querySnapshot.docs[querySnapshot.docs.length - 1].ref.path;
604
+ if (hasMore) {
605
+ nextCursor = resultDocs[resultDocs.length - 1].ref.path;
312
606
  }
313
607
  return {
314
608
  entities,
@@ -320,86 +614,249 @@ export class FirestoreEntityStorageConnector {
320
614
  }
321
615
  }
322
616
  /**
323
- * Delete all entities in the collection.
324
- * @returns Nothing.
325
- * @internal
617
+ * Count all the entities which match the conditions.
618
+ * @param conditions The optional conditions to match for the entities.
619
+ * @returns The total count of entities in the storage.
326
620
  */
327
- async collectionDelete() {
328
- const collection = this._firestoreClient.collection(this.collectionName());
329
- const batchSize = 500;
330
- const query = collection.limit(batchSize);
621
+ async count(conditions) {
331
622
  try {
332
- await this.deleteQueryBatch(query, batchSize);
623
+ const contextIds = await ContextIdStore.getContextIds();
624
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds, FirestoreEntityStorageConnector._PARTITION_SEPARATOR);
625
+ const collection = this._firestoreClient.collection(this.collectionName(partitionKey));
626
+ if (this.hasEmptyInCondition(conditions)) {
627
+ return 0;
628
+ }
629
+ if (!Is.empty(conditions) && this.needsPostFilter(conditions)) {
630
+ const allSnapshot = await collection.get();
631
+ const allEntities = allSnapshot.docs.map((doc) => EntityStorageHelper.unPrepareEntity(doc.data(), []));
632
+ return allEntities.filter(e => EntityConditions.check(e, conditions)).length;
633
+ }
634
+ const effectiveConditions = !Is.empty(conditions)
635
+ ? (this.pruneEmptyInConditions(conditions) ?? undefined)
636
+ : conditions;
637
+ let query = collection;
638
+ if (!Is.empty(effectiveConditions)) {
639
+ query = this.applyConditions(query, effectiveConditions);
640
+ }
641
+ const snapshot = await query.count().get();
642
+ return snapshot.data().count;
333
643
  }
334
- catch (error) {
335
- throw new GeneralError(FirestoreEntityStorageConnector.CLASS_NAME, "collectionDeleteFailed", { collectionName: this._config.collectionName }, error);
644
+ catch (err) {
645
+ throw new GeneralError(FirestoreEntityStorageConnector.CLASS_NAME, "countFailed", undefined, err);
646
+ }
647
+ }
648
+ /**
649
+ * Copy all entities from sourceConnector to destConnector, paging through each partition.
650
+ * @param sourceConnector The connector to read entities from.
651
+ * @param destConnector The connector to write entities to.
652
+ * @param partitions The partition list returned by getPartitionContextIds.
653
+ * @param batchSize The number of entities to read per page.
654
+ * @internal
655
+ */
656
+ async bulkCopy(sourceConnector, destConnector, partitions, batchSize) {
657
+ let partitionList;
658
+ if (Is.arrayValue(partitions)) {
659
+ partitionList = partitions;
660
+ }
661
+ else if (Is.arrayValue(sourceConnector._partitionContextIds)) {
662
+ partitionList = [];
663
+ }
664
+ else {
665
+ partitionList = [{}];
666
+ }
667
+ for (let i = 0; i < partitionList.length; i++) {
668
+ const partitionKey = ContextIdHelper.combinedContextKey(partitionList[i], sourceConnector._partitionContextIds, FirestoreEntityStorageConnector._PARTITION_SEPARATOR);
669
+ const sourceCollection = sourceConnector._firestoreClient.collection(sourceConnector.collectionName(partitionKey));
670
+ const destCollection = destConnector._firestoreClient.collection(destConnector.collectionName(partitionKey));
671
+ let lastDoc;
672
+ let hasMore = true;
673
+ while (hasMore) {
674
+ let pageQuery = sourceCollection.limit(batchSize);
675
+ if (lastDoc) {
676
+ pageQuery = pageQuery.startAfter(lastDoc);
677
+ }
678
+ const snapshot = await pageQuery.get();
679
+ const docs = snapshot.docs;
680
+ for (let j = 0; j < docs.length; j += batchSize) {
681
+ const chunk = docs.slice(j, j + batchSize);
682
+ const batch = destConnector._firestoreClient.batch();
683
+ for (const doc of chunk) {
684
+ batch.set(destCollection.doc(doc.id), doc.data());
685
+ }
686
+ await batch.commit();
687
+ }
688
+ lastDoc = docs[docs.length - 1];
689
+ hasMore = docs.length === batchSize;
690
+ }
691
+ }
692
+ }
693
+ /**
694
+ * Delete all documents in every collection whose name starts with collectionName_.
695
+ * @param collectionName The base collection name prefix.
696
+ * @internal
697
+ */
698
+ async deleteAllPartitionCollections(collectionName) {
699
+ const prefix = `${collectionName}_`;
700
+ const collections = await this._firestoreClient.listCollections();
701
+ const chunkSize = 500;
702
+ for (const col of collections) {
703
+ if (col.id.startsWith(prefix)) {
704
+ const snapshot = await col.get();
705
+ for (let i = 0; i < snapshot.docs.length; i += chunkSize) {
706
+ const chunk = snapshot.docs.slice(i, i + chunkSize);
707
+ const batch = this._firestoreClient.batch();
708
+ for (const doc of chunk) {
709
+ batch.delete(doc.ref);
710
+ }
711
+ await batch.commit();
712
+ }
713
+ }
336
714
  }
337
715
  }
338
716
  /**
339
- * Apply conditions to a Firestore query.
717
+ * Returns true when the condition tree is guaranteed to match nothing due to empty
718
+ * In lists, respecting AND/OR boolean semantics (#141):
719
+ * - AND group: true if ANY child is always-false (false AND x = false)
720
+ * - OR group: true if ALL children are always-false (false OR false = false)
721
+ * - Leaf: true only for `In []`
722
+ * @param condition The condition tree to inspect.
723
+ * @returns True if a short-circuit to empty results is required.
724
+ * @internal
725
+ */
726
+ hasEmptyInCondition(condition) {
727
+ if (Is.empty(condition)) {
728
+ return false;
729
+ }
730
+ if ("conditions" in condition) {
731
+ return condition.logicalOperator === LogicalOperator.Or
732
+ ? condition.conditions.every(c => this.hasEmptyInCondition(c))
733
+ : condition.conditions.some(c => this.hasEmptyInCondition(c));
734
+ }
735
+ return (condition.comparison === ComparisonOperator.In &&
736
+ Is.array(condition.value) &&
737
+ condition.value.length === 0);
738
+ }
739
+ /**
740
+ * Returns a copy of the condition tree with all empty-In leaves removed.
741
+ * Used to keep `In []` out of native Firestore Filter calls (the SDK throws on
742
+ * `Filter.where(prop, "in", [])`) while preserving correct OR semantics (#141).
743
+ * Returns null when the entire subtree reduces to nothing (caller should treat
744
+ * as no conditions).
745
+ * @param condition The condition to prune.
746
+ * @returns The pruned condition, or null if the subtree was fully removed.
747
+ * @internal
748
+ */
749
+ pruneEmptyInConditions(condition) {
750
+ if (!("conditions" in condition)) {
751
+ if (condition.comparison === ComparisonOperator.In &&
752
+ Is.array(condition.value) &&
753
+ condition.value.length === 0) {
754
+ return null;
755
+ }
756
+ return condition;
757
+ }
758
+ // For AND groups: if any child has an empty In, the whole AND is dead.
759
+ // Do not recurse — promoting the surviving siblings would turn a dead
760
+ // branch into a live one when this AND sits inside an OR (#141).
761
+ if (condition.logicalOperator !== LogicalOperator.Or && this.hasEmptyInCondition(condition)) {
762
+ return null;
763
+ }
764
+ // For OR groups: prune dead branches individually so the Firestore SDK
765
+ // never receives `In []`, while keeping live siblings.
766
+ const pruned = condition.conditions
767
+ .map(c => this.pruneEmptyInConditions(c))
768
+ .filter((c) => c !== null);
769
+ if (pruned.length === 0) {
770
+ return null;
771
+ }
772
+ if (pruned.length === 1) {
773
+ return pruned[0];
774
+ }
775
+ return { ...condition, conditions: pruned };
776
+ }
777
+ /**
778
+ * Returns true when any leaf condition requires client-side filtering
779
+ * (Firestore has no native string-contains / not-contains operator).
780
+ * @param condition The condition tree to inspect.
781
+ * @returns True if post-filtering is required.
782
+ * @internal
783
+ */
784
+ needsPostFilter(condition) {
785
+ if (Is.empty(condition)) {
786
+ return false;
787
+ }
788
+ if ("conditions" in condition) {
789
+ return condition.conditions.some(c => this.needsPostFilter(c));
790
+ }
791
+ const { comparison, value } = condition;
792
+ if (comparison === ComparisonOperator.NotIncludes) {
793
+ return true;
794
+ }
795
+ // Includes on a primitive (string/number) means substring search — not natively supported.
796
+ // Includes on an object means array-contains, which Firestore does support.
797
+ if (comparison === ComparisonOperator.Includes &&
798
+ (value === null || typeof value !== "object")) {
799
+ return true;
800
+ }
801
+ return false;
802
+ }
803
+ /**
804
+ * Apply conditions to a Firestore query using composite Filter objects so that
805
+ * OR groups are handled correctly.
340
806
  * @param query The initial query.
341
807
  * @param condition The condition to apply.
342
808
  * @returns The updated query.
343
809
  * @internal
344
810
  */
345
811
  applyConditions(query, condition) {
812
+ return query.where(this.buildFilter(condition));
813
+ }
814
+ /**
815
+ * Recursively convert an EntityCondition tree into a Firestore Filter.
816
+ * Only called for native conditions (needsPostFilter must be false).
817
+ * @param condition The condition to convert.
818
+ * @returns A Firestore Filter.
819
+ * @throws GeneralError if the comparison operator is not supported.
820
+ * @internal
821
+ */
822
+ buildFilter(condition) {
346
823
  if ("conditions" in condition) {
347
- // It's a group of conditions
348
- for (const c of condition.conditions) {
349
- query = this.applyConditions(query, c);
350
- }
351
- return query;
824
+ const filters = condition.conditions.map(c => this.buildFilter(c));
825
+ return condition.logicalOperator === LogicalOperator.Or
826
+ ? Filter.or(...filters)
827
+ : Filter.and(...filters);
352
828
  }
353
- // It's a single condition
354
829
  const { property, comparison } = condition;
355
- // Firestore has no undefined type — the SDK throws on undefined values.
356
- // For Equals/NotEquals, null already has the correct semantics:
830
+ // Firestore has no undefined type — null has the correct semantics:
357
831
  // == null matches documents where the field is null OR missing
358
832
  // != null matches documents where the field exists and is not null
359
833
  const value = condition.value === undefined ? null : condition.value;
360
834
  switch (comparison) {
361
835
  case ComparisonOperator.Equals:
362
- return query.where(property, "==", value);
836
+ return Filter.where(property, "==", value);
363
837
  case ComparisonOperator.NotEquals:
364
- return query.where(property, "!=", value);
838
+ return Filter.where(property, "!=", value);
365
839
  case ComparisonOperator.GreaterThan:
366
- return query.where(property, ">", value);
840
+ return Filter.where(property, ">", value);
367
841
  case ComparisonOperator.LessThan:
368
- return query.where(property, "<", value);
842
+ return Filter.where(property, "<", value);
369
843
  case ComparisonOperator.GreaterThanOrEqual:
370
- return query.where(property, ">=", value);
844
+ return Filter.where(property, ">=", value);
371
845
  case ComparisonOperator.LessThanOrEqual:
372
- return query.where(property, "<=", value);
846
+ return Filter.where(property, "<=", value);
373
847
  case ComparisonOperator.In:
374
- return query.where(property, "in", value);
848
+ return Filter.where(property, "in", value);
375
849
  case ComparisonOperator.Includes:
376
- return query.where(property, "array-contains", value);
850
+ // Object value → array-contains (caller ensured needsPostFilter is false here)
851
+ return Filter.where(property, "array-contains", value);
377
852
  case ComparisonOperator.NotIncludes:
378
853
  default:
379
854
  throw new GeneralError(FirestoreEntityStorageConnector.CLASS_NAME, "unsupportedComparisonOperator", { comparison });
380
855
  }
381
856
  }
382
- /**
383
- * Delete all entities in the collection.
384
- * @returns Nothing.
385
- * @internal
386
- */
387
- async deleteQueryBatch(query, batchSize) {
388
- const snapshot = await query.get();
389
- if (snapshot.size === 0) {
390
- return;
391
- }
392
- const batch = this._firestoreClient.batch();
393
- for (const doc of snapshot.docs) {
394
- batch.delete(doc.ref);
395
- }
396
- await batch.commit();
397
- if (snapshot.size === batchSize) {
398
- await this.deleteQueryBatch(query, batchSize);
399
- }
400
- }
401
857
  /**
402
858
  * Get the collection name based on partition key.
859
+ * @param partitionKey The optional partition key to include in the collection name.
403
860
  * @returns The collection name.
404
861
  * @internal
405
862
  */