@twin.org/entity-storage-connector-cosmosdb 0.0.3-next.2 → 0.0.3-next.20

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
@@ -1,6 +1,6 @@
1
- # TWIN Entity Storage Connector Cosmos DB
1
+ # Entity Storage Connector Cosmos DB
2
2
 
3
- Entity Storage connector implementation using Cosmos DB storage.
3
+ This package provides an Azure Cosmos DB backend for globally distributed persistence across regions. It is designed to work with the wider storage ecosystem so applications can keep behaviour consistent across connectors and environments.
4
4
 
5
5
  ## Installation
6
6
 
@@ -8,24 +8,13 @@ Entity Storage connector implementation using Cosmos DB storage.
8
8
  npm install @twin.org/entity-storage-connector-cosmosdb
9
9
  ```
10
10
 
11
- ## Testing
11
+ ## Docker
12
12
 
13
- The tests developed are functional tests and need an instance of CosmosDB up and running. To run CosmosDB locally there are two possibilities:
13
+ To perform testing of this component it may be necessary to launch a local instance to communicate with.
14
14
 
15
- To run a Docker image of Azure CosmosDB:
16
-
17
- ```sh
18
- docker run -d --platform=linux/amd64 -p 8081:8081 -m 3g --name twin-entity-storage-cosmos -e AZURE_COSMOS_EMULATOR_PARTITION_COUNT=10 -e AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE=true -e AZURE_COSMOS_EMULATOR_IP_ADDRESS_OVERRIDE='127.0.0.1' mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator
19
- ```
20
-
21
- Or
22
-
23
- To install and run the Azure CosmosDB Emulator [Azure CosmosDB Emulator](https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-develop-emulator)
24
-
25
- Afterwards you can run the tests as follows:
26
-
27
- ```sh
28
- npm run test
15
+ ```shell
16
+ docker pull 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
29
18
  ```
30
19
 
31
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 } 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.
@@ -373,7 +536,7 @@ export class CosmosDbEntityStorageConnector {
373
536
  if (queryClause.length > 0) {
374
537
  queryClause = ` AND ${queryClause}`;
375
538
  }
376
- sql = `SELECT ${properties ? properties.map(p => `c.${p}`).join(", ") : "*"} FROM c WHERE c.partitionId = @partitionId ${queryClause} ${orderByClause}`;
539
+ sql = `SELECT ${properties ? properties.map(p => `c.${p}`).join(", ") : "*"} FROM c WHERE c.${CosmosDbEntityStorageConnector._PARTITION_KEY} = @partitionId ${queryClause} ${orderByClause}`;
377
540
  const querySpecs = {
378
541
  query: sql,
379
542
  parameters: [
@@ -389,9 +552,23 @@ export class CosmosDbEntityStorageConnector {
389
552
  continuationToken: cursor
390
553
  };
391
554
  const feedResponse = await this._container.items.query(querySpecs, feedOptions).fetchNext();
555
+ // CosmosDB returns a continuation token even on the last page, so peek ahead
556
+ // to confirm there are actually more results before exposing the cursor.
557
+ let resultCursor;
558
+ if (feedResponse.resources.length >= returnSize && feedResponse.continuationToken) {
559
+ const peekResponse = await this._container.items
560
+ .query(querySpecs, {
561
+ maxItemCount: 1,
562
+ continuationToken: feedResponse.continuationToken
563
+ })
564
+ .fetchNext();
565
+ if (peekResponse.resources.length > 0) {
566
+ resultCursor = feedResponse.continuationToken;
567
+ }
568
+ }
392
569
  return {
393
570
  entities: feedResponse.resources.map(i => this.itemToEntity(i)),
394
- cursor: feedResponse.continuationToken
571
+ cursor: resultCursor
395
572
  };
396
573
  }
397
574
  catch (err) {
@@ -399,18 +576,153 @@ export class CosmosDbEntityStorageConnector {
399
576
  }
400
577
  }
401
578
  /**
402
- * Delete the container.
403
- * @returns Nothing.
579
+ * Count all the entities which match the conditions.
580
+ * @param conditions The optional conditions to match for the entities.
581
+ * @returns The total count of entities in the storage.
404
582
  */
405
- async containerDelete() {
406
- const contextIds = await ContextIdStore.getContextIds();
407
- const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
583
+ async count(conditions) {
408
584
  try {
409
- await this._container.deleteAllItemsForPartitionKey(partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE);
410
- await this._container.delete();
585
+ const contextIds = await ContextIdStore.getContextIds();
586
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
587
+ const attributeNames = {};
588
+ const attributeValues = {};
589
+ let queryClause = Is.empty(conditions)
590
+ ? ""
591
+ : this.buildQueryParameters("", conditions, attributeNames, attributeValues);
592
+ if (queryClause.length > 0) {
593
+ queryClause = ` AND ${queryClause}`;
594
+ }
595
+ const querySpec = {
596
+ query: `SELECT VALUE COUNT(1) FROM c WHERE c.${CosmosDbEntityStorageConnector._PARTITION_KEY} = @partitionId${queryClause}`,
597
+ parameters: [
598
+ {
599
+ name: "@partitionId",
600
+ value: partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE
601
+ },
602
+ ...Object.keys(attributeValues).map(key => ({ name: `@${key}`, value: attributeValues[key] }))
603
+ ]
604
+ };
605
+ const { resources } = await this._container.items.query(querySpec).fetchAll();
606
+ return resources[0] ?? 0;
411
607
  }
412
- catch {
413
- // Ignore errors
608
+ catch (err) {
609
+ throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "countFailed", undefined, err);
610
+ }
611
+ }
612
+ /**
613
+ * Get a unique list of all the context ids from the storage.
614
+ * @returns The list of unique context ids.
615
+ */
616
+ async getPartitionContextIds() {
617
+ const partitionContextIds = this._partitionContextIds;
618
+ if (!Is.arrayValue(partitionContextIds)) {
619
+ return [];
620
+ }
621
+ try {
622
+ const { resources: partitionIds } = await this._container.items
623
+ .query({
624
+ query: `SELECT DISTINCT VALUE c.${CosmosDbEntityStorageConnector._PARTITION_KEY} FROM c`
625
+ })
626
+ .fetchAll();
627
+ return partitionIds
628
+ .filter(id => Is.stringValue(id))
629
+ .map(id => ContextIdHelper.shortSplit(partitionContextIds, id));
630
+ }
631
+ catch (err) {
632
+ throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "getPartitionContextIdsFailed", undefined, err);
633
+ }
634
+ }
635
+ /**
636
+ * Create the target connector for performing the migration using a temporary container.
637
+ * @param newEntitySchema The name of the new entity schema to create the connector for.
638
+ * @returns Connector for performing the migration.
639
+ */
640
+ async createTargetConnector(newEntitySchema) {
641
+ const migrationContainerId = `${this._config.containerId}Migration${Date.now()}`;
642
+ return new CosmosDbEntityStorageConnector({
643
+ entitySchema: newEntitySchema,
644
+ config: { ...this._config, containerId: migrationContainerId },
645
+ partitionContextIds: this._partitionContextIds
646
+ });
647
+ }
648
+ /**
649
+ * Finalize the migration by tearing down the old container and replacing it with the target container.
650
+ * @param targetConnector The target connector to finalize the migration with.
651
+ * @param options The options to control how the migration is finalized.
652
+ * @param loggingComponentType The optional component type to use for logging.
653
+ * @returns The final connector pointing at the original container id.
654
+ */
655
+ async finalizeMigration(targetConnector, options, loggingComponentType) {
656
+ // There is no rename operation in DynamoDB so we have to create a new table with the original name and copy the data over
657
+ // Teardown the existing table with the original name to free up the name for the new table
658
+ await this.teardown(loggingComponentType);
659
+ // Create a new connector with the original table name but with the new schema
660
+ // and copy the data from the migration table to the new table using batch operations
661
+ const finalConnector = new CosmosDbEntityStorageConnector({
662
+ entitySchema: targetConnector._entitySchemaName,
663
+ config: this._config,
664
+ partitionContextIds: this._partitionContextIds
665
+ });
666
+ if (await finalConnector.bootstrap(loggingComponentType)) {
667
+ // Since there is no rename, we need to copy the data from the migration table to the new table
668
+ const partitions = await targetConnector.getPartitionContextIds();
669
+ const batchSize = options?.batchSize ?? CosmosDbEntityStorageConnector._DEFAULT_LIMIT;
670
+ await this.bulkCopy(targetConnector, finalConnector, partitions, batchSize);
671
+ await targetConnector.teardown(loggingComponentType);
672
+ return finalConnector;
673
+ }
674
+ throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "finalizeMigrationFailedBootstrap");
675
+ }
676
+ /**
677
+ * Cleanup the migration if a migration fails or needs to be aborted.
678
+ * @param targetConnector The target connector to cleanup.
679
+ * @param options The options to control how the migration is cleaned up.
680
+ * @param loggingComponentType The optional component type to use for logging.
681
+ */
682
+ async cleanupMigration(targetConnector, options, loggingComponentType) {
683
+ // If something failed the only thing to cleanup is the migration table
684
+ await targetConnector?.teardown?.(loggingComponentType);
685
+ }
686
+ /**
687
+ * Copy all entities from sourceConnector to destConnector, paging through each partition.
688
+ * @param sourceConnector The connector to read entities from.
689
+ * @param destConnector The connector to write entities to.
690
+ * @param partitions The partition list returned by getPartitionContextIds.
691
+ * @param batchSize The number of entities to read per page.
692
+ * @internal
693
+ */
694
+ async bulkCopy(sourceConnector, destConnector, partitions, batchSize) {
695
+ let partitionList;
696
+ if (Is.arrayValue(partitions)) {
697
+ partitionList = partitions;
698
+ }
699
+ else if (Is.arrayValue(sourceConnector._partitionContextIds)) {
700
+ partitionList = [];
701
+ }
702
+ else {
703
+ partitionList = [{}];
704
+ }
705
+ for (let i = 0; i < partitionList.length; i++) {
706
+ const partitionKey = ContextIdHelper.combinedContextKey(partitionList[i], sourceConnector._partitionContextIds) ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE;
707
+ let continuationToken;
708
+ do {
709
+ const feedOptions = { maxItemCount: batchSize, continuationToken };
710
+ const { resources, continuationToken: nextToken } = await sourceConnector._container.items
711
+ .query({
712
+ query: `SELECT * FROM c WHERE c.${CosmosDbEntityStorageConnector._PARTITION_KEY} = @partitionId`,
713
+ parameters: [{ name: "@partitionId", value: partitionKey }]
714
+ }, feedOptions)
715
+ .fetchNext();
716
+ continuationToken = nextToken;
717
+ if (Is.arrayValue(resources)) {
718
+ const operations = resources.map(({ _rid: rid, _self: self, _ts: ts, _etag: etag, _attachments: attachments, ...rest }) => ({
719
+ operationType: BulkOperationType.Upsert,
720
+ partitionKey,
721
+ resourceBody: rest
722
+ }));
723
+ await destConnector._container.items.executeBulkOperations(operations);
724
+ }
725
+ } while (Is.stringValue(continuationToken));
414
726
  }
415
727
  }
416
728
  /**
@@ -463,7 +775,18 @@ export class CosmosDbEntityStorageConnector {
463
775
  prop += comparator.property;
464
776
  let attributeName = this.populateAttributeNames(prop, attributeNames);
465
777
  let propName = `${attributeName.replace(/\./g, "").replace(/@/g, "")}`;
466
- if (Is.array(comparator.value)) {
778
+ if ((comparator.comparison === ComparisonOperator.Equals ||
779
+ comparator.comparison === ComparisonOperator.NotEquals) &&
780
+ (comparator.value === null || comparator.value === undefined)) {
781
+ // Cosmos DB SQL null semantics mirror standard SQL: any comparison using = or <>
782
+ // against null evaluates to UNKNOWN, not TRUE, so no rows are returned.
783
+ // IS_NULL() and IS_DEFINED() must be used instead (no bound parameter needed).
784
+ if (comparator.comparison === ComparisonOperator.Equals) {
785
+ return `(IS_NULL(c.${attributeName}) OR NOT IS_DEFINED(c.${attributeName}))`;
786
+ }
787
+ return `(IS_DEFINED(c.${attributeName}) AND NOT IS_NULL(c.${attributeName}))`;
788
+ }
789
+ else if (Is.array(comparator.value)) {
467
790
  const dbValues = comparator.value.map(v => this.propertyToDbValue(v, type));
468
791
  const arrAttributeNames = [];
469
792
  for (let i = 0; i < dbValues.length; i++) {
@@ -474,7 +797,27 @@ export class CosmosDbEntityStorageConnector {
474
797
  propName = attributeName;
475
798
  attributeName = `(${arrAttributeNames.map(name => `@${name}`).join(", ")})`;
476
799
  }
800
+ else if (Is.object(comparator.value) &&
801
+ (comparator.comparison === ComparisonOperator.Equals ||
802
+ comparator.comparison === ComparisonOperator.NotEquals)) {
803
+ // CosmosDB SQL does not support object equality with =; expand to per-property comparisons.
804
+ const op = comparator.comparison === ComparisonOperator.Equals ? "=" : "<>";
805
+ const join = comparator.comparison === ComparisonOperator.Equals ? " AND " : " OR ";
806
+ const clauses = [];
807
+ for (const [key, val] of Object.entries(comparator.value)) {
808
+ const paramName = `${propName}${key}`;
809
+ attributeValues[paramName] = val;
810
+ clauses.push(`c.${attributeName}.${key} ${op} @${paramName}`);
811
+ }
812
+ return clauses.length === 1 ? clauses[0] : `(${clauses.join(join)})`;
813
+ }
477
814
  else {
815
+ // Avoid parameter name conflicts by ensuring unique parameter names in the query
816
+ let counter = 1;
817
+ while (propName in attributeValues) {
818
+ propName = `${attributeName.replace(/\./g, "").replace(/@/g, "")}${counter}`;
819
+ counter++;
820
+ }
478
821
  attributeValues[propName] = comparator.value;
479
822
  }
480
823
  const matches = attributeName.split(".").length;
@@ -505,13 +848,13 @@ export class CosmosDbEntityStorageConnector {
505
848
  }
506
849
  else if (typeof attributeValues[propName] === "object" &&
507
850
  comparator.comparison === ComparisonOperator.Includes) {
508
- return `array_contains(c.${attributeName}, @${propName})`;
851
+ return `ARRAY_CONTAINS(c.${attributeName}, @${propName}, true)`;
509
852
  }
510
853
  else if (comparator.comparison === ComparisonOperator.Includes) {
511
- return `contains(c.${attributeName}, @${propName})`;
854
+ return `CONTAINS(c.${attributeName}, @${propName})`;
512
855
  }
513
856
  else if (comparator.comparison === ComparisonOperator.NotIncludes) {
514
- return `notContains(c.${attributeName}, @${propName})`;
857
+ return `NOT CONTAINS(c.${attributeName}, @${propName})`;
515
858
  }
516
859
  else if (comparator.comparison === ComparisonOperator.In) {
517
860
  return `c.${propName} IN ${attributeName}`;
@@ -598,13 +941,14 @@ export class CosmosDbEntityStorageConnector {
598
941
  * @internal
599
942
  */
600
943
  itemToEntity(item) {
601
- ObjectHelper.propertyDelete(item, "partitionId");
602
- ObjectHelper.propertyDelete(item, "_attachments");
603
- ObjectHelper.propertyDelete(item, "_etag");
604
- ObjectHelper.propertyDelete(item, "_rid");
605
- ObjectHelper.propertyDelete(item, "_self");
606
- ObjectHelper.propertyDelete(item, "_ts");
607
- return item;
944
+ return EntityStorageHelper.unPrepareEntity(item, [
945
+ CosmosDbEntityStorageConnector._PARTITION_KEY,
946
+ "_attachments",
947
+ "_etag",
948
+ "_rid",
949
+ "_self",
950
+ "_ts"
951
+ ]);
608
952
  }
609
953
  /**
610
954
  * Check if the database exists.
@@ -665,5 +1009,19 @@ export class CosmosDbEntityStorageConnector {
665
1009
  await new Promise(resolve => setTimeout(resolve, 250));
666
1010
  }
667
1011
  }
1012
+ /**
1013
+ * Wait for a container to not exist.
1014
+ * @returns Nothing.
1015
+ * @internal
1016
+ */
1017
+ async waitForContainerNotExists() {
1018
+ for (let attempt = 0; attempt < 20; attempt++) {
1019
+ const containerExists = await this.containerExists();
1020
+ if (!containerExists) {
1021
+ break;
1022
+ }
1023
+ await new Promise(resolve => setTimeout(resolve, 250));
1024
+ }
1025
+ }
668
1026
  }
669
1027
  //# sourceMappingURL=cosmosDbEntityStorageConnector.js.map