@twin.org/entity-storage-connector-cosmosdb 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.
package/README.md CHANGED
@@ -14,7 +14,7 @@ To perform testing of this component it may be necessary to launch a local insta
14
14
 
15
15
  ```shell
16
16
  docker pull mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest
17
- docker run --publish 8081:8081 --name twin-entity-storage-cosmos --detach --platform=linux/amd64 --memory=3g --cpus=2.0 mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest
17
+ docker run -p 18081:8081 --detach --name twin-entity-storage-cosmos mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:vnext-preview
18
18
  ```
19
19
 
20
20
  ## Examples
@@ -1,9 +1,10 @@
1
1
  // Copyright 2024 IOTA Stiftung.
2
2
  // SPDX-License-Identifier: Apache-2.0.
3
- import { CosmosClient, CosmosDbDiagnosticLevel, PartitionKeyKind } from "@azure/cosmos";
3
+ import { BulkOperationType, CosmosClient, PartitionKeyKind } from "@azure/cosmos";
4
4
  import { ContextIdHelper, ContextIdStore } from "@twin.org/context";
5
- import { BaseError, Coerce, ComponentFactory, GeneralError, Guards, Is, ObjectHelper } from "@twin.org/core";
5
+ import { BaseError, Coerce, ComponentFactory, GeneralError, Guards, HealthStatus, Is, ObjectHelper, Validation } from "@twin.org/core";
6
6
  import { ComparisonOperator, 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 Cosmos DB.
9
10
  */
@@ -27,6 +28,11 @@ export class CosmosDbEntityStorageConnector {
27
28
  * @internal
28
29
  */
29
30
  static _PARTITION_KEY_VALUE = "root";
31
+ /**
32
+ * The name for the schema.
33
+ * @internal
34
+ */
35
+ _entitySchemaName;
30
36
  /**
31
37
  * The schema for the entity.
32
38
  * @internal
@@ -70,13 +76,16 @@ export class CosmosDbEntityStorageConnector {
70
76
  Guards.stringValue(CosmosDbEntityStorageConnector.CLASS_NAME, "options.config.databaseId", options.config.databaseId);
71
77
  Guards.stringValue(CosmosDbEntityStorageConnector.CLASS_NAME, "options.config.containerId", options.config.containerId);
72
78
  this._entitySchema = EntitySchemaFactory.get(options.entitySchema);
79
+ this._entitySchemaName = options.entitySchema;
73
80
  this._partitionContextIds = options.partitionContextIds;
74
81
  this._primaryKey = EntitySchemaHelper.getPrimaryKey(this._entitySchema);
75
82
  this._config = options.config;
76
83
  this._client = new CosmosClient({
77
84
  endpoint: this._config.endpoint,
78
85
  key: this._config.key,
79
- diagnosticLevel: CosmosDbDiagnosticLevel.debug
86
+ connectionPolicy: {
87
+ enableEndpointDiscovery: !this._config.disableEndpointDiscovery
88
+ }
80
89
  });
81
90
  this._container = this._client
82
91
  .database(this._config.databaseId)
@@ -188,6 +197,37 @@ export class CosmosDbEntityStorageConnector {
188
197
  className() {
189
198
  return CosmosDbEntityStorageConnector.CLASS_NAME;
190
199
  }
200
+ /**
201
+ * Returns the health status of the component.
202
+ * @returns The health status of the component.
203
+ */
204
+ async health() {
205
+ try {
206
+ await this._client
207
+ .database(this._config.databaseId)
208
+ .container(this._config.containerId)
209
+ .read();
210
+ return [
211
+ {
212
+ source: CosmosDbEntityStorageConnector.CLASS_NAME,
213
+ status: HealthStatus.Ok,
214
+ description: "healthDescription",
215
+ data: { databaseId: this._config.databaseId, containerId: this._config.containerId }
216
+ }
217
+ ];
218
+ }
219
+ catch {
220
+ return [
221
+ {
222
+ source: CosmosDbEntityStorageConnector.CLASS_NAME,
223
+ status: HealthStatus.Error,
224
+ description: "healthDescription",
225
+ message: "connectionFailed",
226
+ data: { databaseId: this._config.databaseId, containerId: this._config.containerId }
227
+ }
228
+ ];
229
+ }
230
+ }
191
231
  /**
192
232
  * Get the schema for the entities.
193
233
  * @returns The schema for the entities.
@@ -214,9 +254,14 @@ export class CosmosDbEntityStorageConnector {
214
254
  .read();
215
255
  return this.itemToEntity(item);
216
256
  }
257
+ const conditionValues = [];
217
258
  const whereQuery = [
218
259
  `c.${CosmosDbEntityStorageConnector._PARTITION_KEY} = @partitionKey`
219
260
  ];
261
+ conditionValues.push({
262
+ name: "@partitionKey",
263
+ value: partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE
264
+ });
220
265
  // With a secondary index
221
266
  if (Is.stringValue(secondaryIndex)) {
222
267
  const secIndex = secondaryIndex.toString();
@@ -225,24 +270,20 @@ export class CosmosDbEntityStorageConnector {
225
270
  else {
226
271
  whereQuery.push(`c.${this._primaryKey.property} = @id`);
227
272
  }
273
+ conditionValues.push({ name: "@id", value: id });
228
274
  // With conditions
229
275
  if (Is.arrayValue(conditions)) {
230
276
  for (const c of conditions) {
231
- const schemaProp = this._entitySchema.properties?.find(p => p.property === c.property);
232
- whereQuery.push(
233
- // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
234
- `c.${String(c.property)} = ${this.propertyToDbValue(c.value, schemaProp?.type)}`);
277
+ whereQuery.push(`c.${c.property} = @${c.property}`);
278
+ conditionValues.push({
279
+ name: `@${c.property}`,
280
+ value: c.value
281
+ });
235
282
  }
236
283
  }
237
284
  const query = {
238
285
  query: `SELECT * FROM c WHERE ${whereQuery.join(" AND ")}`,
239
- parameters: [
240
- { name: "@id", value: id },
241
- {
242
- name: "@partitionKey",
243
- value: partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE
244
- }
245
- ]
286
+ parameters: conditionValues
246
287
  };
247
288
  const { resources: items } = await this._container.items.query(query).fetchAll();
248
289
  if (items.length === 1) {
@@ -271,8 +312,10 @@ export class CosmosDbEntityStorageConnector {
271
312
  Guards.object(CosmosDbEntityStorageConnector.CLASS_NAME, "entity", entity);
272
313
  const contextIds = await ContextIdStore.getContextIds();
273
314
  const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
274
- EntitySchemaHelper.validateEntity(entity, this.getSchema());
275
- const id = entity[this._primaryKey.property];
315
+ const prepared = EntityStorageHelper.prepareEntity(entity, this._entitySchema, undefined, {
316
+ nullBehavior: "omit"
317
+ });
318
+ const id = prepared[this._primaryKey.property];
276
319
  try {
277
320
  if (Is.arrayValue(conditions)) {
278
321
  const item = this._container.item(id, partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE);
@@ -284,7 +327,7 @@ export class CosmosDbEntityStorageConnector {
284
327
  await this._container.items.upsert({
285
328
  id,
286
329
  [CosmosDbEntityStorageConnector._PARTITION_KEY]: partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE,
287
- ...entity
330
+ ...prepared
288
331
  });
289
332
  }
290
333
  catch (err) {
@@ -301,6 +344,66 @@ export class CosmosDbEntityStorageConnector {
301
344
  }, err);
302
345
  }
303
346
  }
347
+ /**
348
+ * Set multiple entities in a batch.
349
+ * @param entities The entities to set.
350
+ * @returns Nothing.
351
+ */
352
+ async setBatch(entities) {
353
+ Guards.arrayValue(CosmosDbEntityStorageConnector.CLASS_NAME, "entities", entities);
354
+ const contextIds = await ContextIdStore.getContextIds();
355
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
356
+ const preparedEntities = entities.map(entity => EntityStorageHelper.prepareEntity(entity, this._entitySchema, undefined, {
357
+ nullBehavior: "omit"
358
+ }));
359
+ try {
360
+ await this._container.items.executeBulkOperations(preparedEntities.map(prepared => ({
361
+ operationType: BulkOperationType.Upsert,
362
+ partitionKey: partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE,
363
+ resourceBody: {
364
+ id: prepared[this._primaryKey.property],
365
+ [CosmosDbEntityStorageConnector._PARTITION_KEY]: partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE,
366
+ ...prepared
367
+ }
368
+ })));
369
+ }
370
+ catch (err) {
371
+ throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "setBatchFailed", undefined, err);
372
+ }
373
+ }
374
+ /**
375
+ * Empty all entities from the storage.
376
+ * @returns Nothing.
377
+ */
378
+ async empty() {
379
+ const contextIds = await ContextIdStore.getContextIds();
380
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
381
+ const pk = partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE;
382
+ try {
383
+ let continuationToken;
384
+ do {
385
+ const feedOptions = { maxItemCount: 100, continuationToken };
386
+ const { resources, continuationToken: nextToken } = await this._container.items
387
+ .query({
388
+ query: `SELECT c.id FROM c WHERE c.${CosmosDbEntityStorageConnector._PARTITION_KEY} = @pk`,
389
+ parameters: [{ name: "@pk", value: pk }]
390
+ }, feedOptions)
391
+ .fetchNext();
392
+ continuationToken = nextToken;
393
+ if (Is.arrayValue(resources)) {
394
+ const operations = resources.map(r => ({
395
+ operationType: BulkOperationType.Delete,
396
+ id: r.id,
397
+ partitionKey: pk
398
+ }));
399
+ await this._container.items.executeBulkOperations(operations);
400
+ }
401
+ } while (Is.stringValue(continuationToken));
402
+ }
403
+ catch (err) {
404
+ throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "emptyFailed", undefined, err);
405
+ }
406
+ }
304
407
  /**
305
408
  * Remove the entity.
306
409
  * @param id The id of the entity to remove.
@@ -332,6 +435,66 @@ export class CosmosDbEntityStorageConnector {
332
435
  }, err);
333
436
  }
334
437
  }
438
+ /**
439
+ * Remove multiple entities by id.
440
+ * @param ids The ids of the entities to remove.
441
+ * @returns Nothing.
442
+ */
443
+ async removeBatch(ids) {
444
+ Guards.arrayValue(CosmosDbEntityStorageConnector.CLASS_NAME, "ids", ids);
445
+ const contextIds = await ContextIdStore.getContextIds();
446
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
447
+ try {
448
+ const operations = ids.map(id => ({
449
+ operationType: BulkOperationType.Delete,
450
+ id,
451
+ partitionKey: partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE
452
+ }));
453
+ await this._container.items.executeBulkOperations(operations);
454
+ }
455
+ catch (err) {
456
+ throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "removeBatchFailed", undefined, err);
457
+ }
458
+ }
459
+ /**
460
+ * Teardown the storage by deleting the underlying container.
461
+ * @param nodeLoggingComponentType The node logging component type.
462
+ * @returns True if the teardown process was successful.
463
+ */
464
+ async teardown(nodeLoggingComponentType) {
465
+ const nodeLogging = ComponentFactory.getIfExists(nodeLoggingComponentType);
466
+ await nodeLogging?.log({
467
+ level: "info",
468
+ source: CosmosDbEntityStorageConnector.CLASS_NAME,
469
+ ts: Date.now(),
470
+ message: "containerDeleting",
471
+ data: { containerId: this._config.containerId }
472
+ });
473
+ try {
474
+ if (await this.containerExists()) {
475
+ await this._container.delete();
476
+ await this.waitForContainerNotExists();
477
+ }
478
+ await nodeLogging?.log({
479
+ level: "info",
480
+ source: CosmosDbEntityStorageConnector.CLASS_NAME,
481
+ ts: Date.now(),
482
+ message: "containerDeleted",
483
+ data: { containerId: this._config.containerId }
484
+ });
485
+ return true;
486
+ }
487
+ catch (err) {
488
+ await nodeLogging?.log({
489
+ level: "error",
490
+ source: CosmosDbEntityStorageConnector.CLASS_NAME,
491
+ ts: Date.now(),
492
+ message: "teardownFailed",
493
+ error: BaseError.fromError(err)
494
+ });
495
+ return false;
496
+ }
497
+ }
335
498
  /**
336
499
  * Find all the entities which match the conditions.
337
500
  * @param conditions The conditions to match for the entities.
@@ -346,6 +509,13 @@ export class CosmosDbEntityStorageConnector {
346
509
  let sql = "";
347
510
  const contextIds = await ContextIdStore.getContextIds();
348
511
  const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
512
+ EntityStorageHelper.validateSortProperties(this._entitySchema, sortProperties);
513
+ EntityStorageHelper.validateProperties(this._entitySchema, properties);
514
+ if (!Is.empty(limit)) {
515
+ const validationFailures = [];
516
+ Validation.integer("limit", limit, validationFailures, undefined, { minValue: 1 });
517
+ Validation.asValidationError(CosmosDbEntityStorageConnector.CLASS_NAME, "query", validationFailures);
518
+ }
349
519
  try {
350
520
  const returnSize = limit ?? CosmosDbEntityStorageConnector._DEFAULT_LIMIT;
351
521
  let orderByClause = "";
@@ -354,15 +524,6 @@ export class CosmosDbEntityStorageConnector {
354
524
  throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "sortSingle");
355
525
  }
356
526
  for (const sortProperty of sortProperties) {
357
- const propertySchema = this._entitySchema.properties?.find(e => e.property === sortProperty.property);
358
- if (!propertySchema ||
359
- (!propertySchema.isPrimary &&
360
- !propertySchema.isSecondary &&
361
- !propertySchema.sortDirection)) {
362
- throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "sortNotIndexed", {
363
- property: sortProperty.property
364
- });
365
- }
366
527
  const direction = sortProperty.sortDirection === SortDirection.Ascending ? "asc" : "desc";
367
528
  orderByClause = `ORDER BY c.${String(sortProperty.property)} ${direction}`;
368
529
  }
@@ -373,9 +534,58 @@ export class CosmosDbEntityStorageConnector {
373
534
  if (queryClause.length > 0) {
374
535
  queryClause = ` AND ${queryClause}`;
375
536
  }
376
- sql = `SELECT ${properties ? properties.map(p => `c.${p}`).join(", ") : "*"} FROM c WHERE c.partitionId = @partitionId ${queryClause} ${orderByClause}`;
377
- const querySpecs = {
378
- query: sql,
537
+ const selectClause = properties ? properties.map(p => `c.${p}`).join(", ") : "*";
538
+ const baseQuery = `SELECT ${selectClause} FROM c WHERE c.${CosmosDbEntityStorageConnector._PARTITION_KEY} = @partitionId${queryClause}`;
539
+ const queryParameters = [
540
+ {
541
+ name: "@partitionId",
542
+ value: partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE
543
+ },
544
+ ...Object.keys(attributeValues).map(key => ({ name: `@${key}`, value: attributeValues[key] }))
545
+ ];
546
+ const queryPartitionKey = partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE;
547
+ // For sorted queries use OFFSET LIMIT — CosmosDB continuation tokens are
548
+ // not reliably returned for ORDER BY queries across all service versions.
549
+ // The cursor is a numeric offset encoded as a string (same as SQL connectors).
550
+ const startIndex = Coerce.number(cursor) ?? 0;
551
+ sql = `${baseQuery} ${orderByClause} OFFSET ${startIndex} LIMIT ${returnSize + 1}`;
552
+ const sortedQuerySpecs = { query: sql, parameters: queryParameters };
553
+ const sortedResponse = await this._container.items
554
+ .query(sortedQuerySpecs, {
555
+ partitionKey: queryPartitionKey,
556
+ maxItemCount: returnSize + 1
557
+ })
558
+ .fetchNext();
559
+ const allItems = sortedResponse.resources;
560
+ const hasMore = allItems.length > returnSize;
561
+ return {
562
+ entities: (hasMore ? allItems.slice(0, returnSize) : allItems).map(i => this.itemToEntity(i)),
563
+ cursor: hasMore ? Coerce.string(startIndex + returnSize) : undefined
564
+ };
565
+ }
566
+ catch (err) {
567
+ throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "queryFailed", { sql }, err);
568
+ }
569
+ }
570
+ /**
571
+ * Count all the entities which match the conditions.
572
+ * @param conditions The optional conditions to match for the entities.
573
+ * @returns The total count of entities in the storage.
574
+ */
575
+ async count(conditions) {
576
+ try {
577
+ const contextIds = await ContextIdStore.getContextIds();
578
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
579
+ const attributeNames = {};
580
+ const attributeValues = {};
581
+ let queryClause = Is.empty(conditions)
582
+ ? ""
583
+ : this.buildQueryParameters("", conditions, attributeNames, attributeValues);
584
+ if (queryClause.length > 0) {
585
+ queryClause = ` AND ${queryClause}`;
586
+ }
587
+ const querySpec = {
588
+ query: `SELECT VALUE COUNT(1) FROM c WHERE c.${CosmosDbEntityStorageConnector._PARTITION_KEY} = @partitionId${queryClause}`,
379
589
  parameters: [
380
590
  {
381
591
  name: "@partitionId",
@@ -384,33 +594,127 @@ export class CosmosDbEntityStorageConnector {
384
594
  ...Object.keys(attributeValues).map(key => ({ name: `@${key}`, value: attributeValues[key] }))
385
595
  ]
386
596
  };
387
- const feedOptions = {
388
- maxItemCount: returnSize,
389
- continuationToken: cursor
390
- };
391
- const feedResponse = await this._container.items.query(querySpecs, feedOptions).fetchNext();
392
- return {
393
- entities: feedResponse.resources.map(i => this.itemToEntity(i)),
394
- cursor: feedResponse.continuationToken
395
- };
597
+ const { resources } = await this._container.items.query(querySpec).fetchAll();
598
+ return resources[0] ?? 0;
396
599
  }
397
600
  catch (err) {
398
- throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "queryFailed", { sql }, err);
601
+ throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "countFailed", undefined, err);
399
602
  }
400
603
  }
401
604
  /**
402
- * Delete the container.
403
- * @returns Nothing.
605
+ * Get a unique list of all the context ids from the storage.
606
+ * @returns The list of unique context ids.
404
607
  */
405
- async containerDelete() {
406
- const contextIds = await ContextIdStore.getContextIds();
407
- const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
608
+ async getPartitionContextIds() {
609
+ const partitionContextIds = this._partitionContextIds;
610
+ if (!Is.arrayValue(partitionContextIds)) {
611
+ return [];
612
+ }
408
613
  try {
409
- await this._container.deleteAllItemsForPartitionKey(partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE);
410
- await this._container.delete();
614
+ const { resources: partitionIds } = await this._container.items
615
+ .query({
616
+ query: `SELECT DISTINCT VALUE c.${CosmosDbEntityStorageConnector._PARTITION_KEY} FROM c`
617
+ })
618
+ .fetchAll();
619
+ return partitionIds
620
+ .filter(id => Is.stringValue(id))
621
+ .map(id => ContextIdHelper.shortSplit(partitionContextIds, id));
411
622
  }
412
- catch {
413
- // Ignore errors
623
+ catch (err) {
624
+ throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "getPartitionContextIdsFailed", undefined, err);
625
+ }
626
+ }
627
+ /**
628
+ * Create the target connector for performing the migration using a temporary container.
629
+ * @param newEntitySchema The name of the new entity schema to create the connector for.
630
+ * @returns Connector for performing the migration.
631
+ */
632
+ async createTargetConnector(newEntitySchema) {
633
+ const migrationContainerId = `${this._config.containerId}Migration${Date.now()}`;
634
+ return new CosmosDbEntityStorageConnector({
635
+ entitySchema: newEntitySchema,
636
+ config: { ...this._config, containerId: migrationContainerId },
637
+ partitionContextIds: this._partitionContextIds
638
+ });
639
+ }
640
+ /**
641
+ * Finalize the migration by tearing down the old container and replacing it with the target container.
642
+ * @param targetConnector The target connector to finalize the migration with.
643
+ * @param options The options to control how the migration is finalized.
644
+ * @param loggingComponentType The optional component type to use for logging.
645
+ * @returns The final connector pointing at the original container id.
646
+ */
647
+ async finalizeMigration(targetConnector, options, loggingComponentType) {
648
+ // There is no rename operation in DynamoDB so we have to create a new table with the original name and copy the data over
649
+ // Teardown the existing table with the original name to free up the name for the new table
650
+ await this.teardown(loggingComponentType);
651
+ // Create a new connector with the original table name but with the new schema
652
+ // and copy the data from the migration table to the new table using batch operations
653
+ const finalConnector = new CosmosDbEntityStorageConnector({
654
+ entitySchema: targetConnector._entitySchemaName,
655
+ config: this._config,
656
+ partitionContextIds: this._partitionContextIds
657
+ });
658
+ if (await finalConnector.bootstrap(loggingComponentType)) {
659
+ // Since there is no rename, we need to copy the data from the migration table to the new table
660
+ const partitions = await targetConnector.getPartitionContextIds();
661
+ const batchSize = options?.batchSize ?? CosmosDbEntityStorageConnector._DEFAULT_LIMIT;
662
+ await this.bulkCopy(targetConnector, finalConnector, partitions, batchSize);
663
+ await targetConnector.teardown(loggingComponentType);
664
+ return finalConnector;
665
+ }
666
+ throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "finalizeMigrationFailedBootstrap");
667
+ }
668
+ /**
669
+ * Cleanup the migration if a migration fails or needs to be aborted.
670
+ * @param targetConnector The target connector to cleanup.
671
+ * @param options The options to control how the migration is cleaned up.
672
+ * @param loggingComponentType The optional component type to use for logging.
673
+ */
674
+ async cleanupMigration(targetConnector, options, loggingComponentType) {
675
+ // If something failed the only thing to cleanup is the migration table
676
+ await targetConnector?.teardown?.(loggingComponentType);
677
+ }
678
+ /**
679
+ * Copy all entities from sourceConnector to destConnector, paging through each partition.
680
+ * @param sourceConnector The connector to read entities from.
681
+ * @param destConnector The connector to write entities to.
682
+ * @param partitions The partition list returned by getPartitionContextIds.
683
+ * @param batchSize The number of entities to read per page.
684
+ * @internal
685
+ */
686
+ async bulkCopy(sourceConnector, destConnector, partitions, batchSize) {
687
+ let partitionList;
688
+ if (Is.arrayValue(partitions)) {
689
+ partitionList = partitions;
690
+ }
691
+ else if (Is.arrayValue(sourceConnector._partitionContextIds)) {
692
+ partitionList = [];
693
+ }
694
+ else {
695
+ partitionList = [{}];
696
+ }
697
+ for (let i = 0; i < partitionList.length; i++) {
698
+ const partitionKey = ContextIdHelper.combinedContextKey(partitionList[i], sourceConnector._partitionContextIds) ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE;
699
+ let continuationToken;
700
+ do {
701
+ const feedOptions = { maxItemCount: batchSize, continuationToken };
702
+ const { resources, continuationToken: nextToken } = await sourceConnector._container.items
703
+ .query({
704
+ query: `SELECT * FROM c WHERE c.${CosmosDbEntityStorageConnector._PARTITION_KEY} = @partitionId`,
705
+ parameters: [{ name: "@partitionId", value: partitionKey }]
706
+ }, feedOptions)
707
+ .fetchNext();
708
+ continuationToken = nextToken;
709
+ if (Is.arrayValue(resources)) {
710
+ const operations = resources.map(({ _rid: rid, _self: self, _ts: ts, _etag: etag, _attachments: attachments, ...rest }) => ({
711
+ operationType: BulkOperationType.Upsert,
712
+ partitionKey,
713
+ resourceBody: rest
714
+ }));
715
+ await destConnector._container.items.executeBulkOperations(operations);
716
+ }
717
+ } while (Is.stringValue(continuationToken));
414
718
  }
415
719
  }
416
720
  /**
@@ -450,7 +754,7 @@ export class CosmosDbEntityStorageConnector {
450
754
  * @param objectPath The prefix to use for the condition.
451
755
  * @param comparator The operator to map.
452
756
  * @param type The type of the property.
453
- * @param attributeValues The attribute values to use in the query.
757
+ * @param attributeNames The attribute names to use in the query.
454
758
  * @returns The comparison expression.
455
759
  * @throws GeneralError if the comparison operator is not supported.
456
760
  * @internal
@@ -476,6 +780,10 @@ export class CosmosDbEntityStorageConnector {
476
780
  }
477
781
  else if (Is.array(comparator.value)) {
478
782
  const dbValues = comparator.value.map(v => this.propertyToDbValue(v, type));
783
+ if (dbValues.length === 0 && comparator.comparison === ComparisonOperator.In) {
784
+ // CosmosDB rejects `IN ()` — return always-false sentinel (#141).
785
+ return "1=0";
786
+ }
479
787
  const arrAttributeNames = [];
480
788
  for (let i = 0; i < dbValues.length; i++) {
481
789
  const arrAttributeName = `${propName}${i}`;
@@ -485,7 +793,27 @@ export class CosmosDbEntityStorageConnector {
485
793
  propName = attributeName;
486
794
  attributeName = `(${arrAttributeNames.map(name => `@${name}`).join(", ")})`;
487
795
  }
796
+ else if (Is.object(comparator.value) &&
797
+ (comparator.comparison === ComparisonOperator.Equals ||
798
+ comparator.comparison === ComparisonOperator.NotEquals)) {
799
+ // CosmosDB SQL does not support object equality with =; expand to per-property comparisons.
800
+ const op = comparator.comparison === ComparisonOperator.Equals ? "=" : "<>";
801
+ const join = comparator.comparison === ComparisonOperator.Equals ? " AND " : " OR ";
802
+ const clauses = [];
803
+ for (const [key, val] of Object.entries(comparator.value)) {
804
+ const paramName = `${propName}${key}`;
805
+ attributeValues[paramName] = val;
806
+ clauses.push(`c.${attributeName}.${key} ${op} @${paramName}`);
807
+ }
808
+ return clauses.length === 1 ? clauses[0] : `(${clauses.join(join)})`;
809
+ }
488
810
  else {
811
+ // Avoid parameter name conflicts by ensuring unique parameter names in the query
812
+ let counter = 1;
813
+ while (propName in attributeValues) {
814
+ propName = `${attributeName.replace(/\./g, "").replace(/@/g, "")}${counter}`;
815
+ counter++;
816
+ }
489
817
  attributeValues[propName] = comparator.value;
490
818
  }
491
819
  const matches = attributeName.split(".").length;
@@ -514,15 +842,23 @@ export class CosmosDbEntityStorageConnector {
514
842
  else if (comparator.comparison === ComparisonOperator.LessThanOrEqual) {
515
843
  return `c.${attributeName} <= @${propName}`;
516
844
  }
517
- else if (typeof attributeValues[propName] === "object" &&
845
+ else if (Is.object(attributeValues[propName]) &&
518
846
  comparator.comparison === ComparisonOperator.Includes) {
519
- return `array_contains(c.${attributeName}, @${propName})`;
847
+ return `ARRAY_CONTAINS(c.${attributeName}, @${propName}, true)`;
848
+ }
849
+ else if ((type === "array" || type === "object") &&
850
+ comparator.comparison === ComparisonOperator.Includes) {
851
+ return `ARRAY_CONTAINS(c.${attributeName}, @${propName})`;
852
+ }
853
+ else if ((type === "array" || type === "object") &&
854
+ comparator.comparison === ComparisonOperator.NotIncludes) {
855
+ return `NOT ARRAY_CONTAINS(c.${attributeName}, @${propName})`;
520
856
  }
521
857
  else if (comparator.comparison === ComparisonOperator.Includes) {
522
- return `contains(c.${attributeName}, @${propName})`;
858
+ return `CONTAINS(c.${attributeName}, @${propName})`;
523
859
  }
524
860
  else if (comparator.comparison === ComparisonOperator.NotIncludes) {
525
- return `notContains(c.${attributeName}, @${propName})`;
861
+ return `NOT CONTAINS(c.${attributeName}, @${propName})`;
526
862
  }
527
863
  else if (comparator.comparison === ComparisonOperator.In) {
528
864
  return `c.${propName} IN ${attributeName}`;
@@ -550,7 +886,7 @@ export class CosmosDbEntityStorageConnector {
550
886
  return `${Coerce.string(value)}`;
551
887
  }
552
888
  else if (type === "integer" || type === "number") {
553
- return Coerce.string(value) ?? "";
889
+ return Coerce.number(value) ?? 0;
554
890
  }
555
891
  else if (type === "boolean") {
556
892
  return Coerce.boolean(value) ?? false;
@@ -597,6 +933,8 @@ export class CosmosDbEntityStorageConnector {
597
933
  /**
598
934
  * Verify the conditions for the entity.
599
935
  * @param conditions The conditions to verify.
936
+ * @param obj The object to verify the conditions against.
937
+ * @returns True if all conditions are met, false otherwise.
600
938
  * @internal
601
939
  */
602
940
  verifyConditions(conditions, obj) {
@@ -609,13 +947,14 @@ export class CosmosDbEntityStorageConnector {
609
947
  * @internal
610
948
  */
611
949
  itemToEntity(item) {
612
- ObjectHelper.propertyDelete(item, "partitionId");
613
- ObjectHelper.propertyDelete(item, "_attachments");
614
- ObjectHelper.propertyDelete(item, "_etag");
615
- ObjectHelper.propertyDelete(item, "_rid");
616
- ObjectHelper.propertyDelete(item, "_self");
617
- ObjectHelper.propertyDelete(item, "_ts");
618
- return item;
950
+ return EntityStorageHelper.unPrepareEntity(item, [
951
+ CosmosDbEntityStorageConnector._PARTITION_KEY,
952
+ "_attachments",
953
+ "_etag",
954
+ "_rid",
955
+ "_self",
956
+ "_ts"
957
+ ]);
619
958
  }
620
959
  /**
621
960
  * Check if the database exists.
@@ -676,5 +1015,19 @@ export class CosmosDbEntityStorageConnector {
676
1015
  await new Promise(resolve => setTimeout(resolve, 250));
677
1016
  }
678
1017
  }
1018
+ /**
1019
+ * Wait for a container to not exist.
1020
+ * @returns Nothing.
1021
+ * @internal
1022
+ */
1023
+ async waitForContainerNotExists() {
1024
+ for (let attempt = 0; attempt < 20; attempt++) {
1025
+ const containerExists = await this.containerExists();
1026
+ if (!containerExists) {
1027
+ break;
1028
+ }
1029
+ await new Promise(resolve => setTimeout(resolve, 250));
1030
+ }
1031
+ }
679
1032
  }
680
1033
  //# sourceMappingURL=cosmosDbEntityStorageConnector.js.map