@twin.org/entity-storage-connector-dynamodb 0.0.3-next.12 → 0.0.3-next.14

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
@@ -13,8 +13,7 @@ npm install @twin.org/entity-storage-connector-dynamodb
13
13
  To perform testing of this component it may be necessary to launch a local instance to communicate with.
14
14
 
15
15
  ```shell
16
- docker pull amazon/dynamodb-local:latest
17
- docker run -d --name twin-entity-storage-dynamodb -p 10000:8000 amazon/dynamodb-local:latest
16
+ docker run -d --name twin-entity-storage-dynamodb -p 8000:8000 amazon/dynamodb-local
18
17
  ```
19
18
 
20
19
  ## Examples
@@ -1,11 +1,12 @@
1
1
  // Copyright 2024 IOTA Stiftung.
2
2
  // SPDX-License-Identifier: Apache-2.0.
3
- import { DynamoDB, QueryCommand, waitUntilTableExists } from "@aws-sdk/client-dynamodb";
3
+ import { BatchWriteItemCommand, DynamoDB, QueryCommand, ScanCommand as RawScanCommand, waitUntilTableExists, waitUntilTableNotExists } from "@aws-sdk/client-dynamodb";
4
4
  import { BatchWriteCommand, DeleteCommand, DynamoDBDocumentClient, GetCommand, PutCommand, ScanCommand } from "@aws-sdk/lib-dynamodb";
5
5
  import { unmarshall } from "@aws-sdk/util-dynamodb";
6
6
  import { ContextIdHelper, ContextIdStore } from "@twin.org/context";
7
7
  import { BaseError, Coerce, ComponentFactory, Converter, GeneralError, Guards, HealthStatus, Is, ObjectHelper } from "@twin.org/core";
8
8
  import { ComparisonOperator, EntitySchemaFactory, EntitySchemaHelper, LogicalOperator, SortDirection } from "@twin.org/entity";
9
+ import { EntityHelper } from "@twin.org/entity-storage-models";
9
10
  /**
10
11
  * Class for performing entity storage operations using Dynamo DB.
11
12
  */
@@ -29,6 +30,11 @@ export class DynamoDbEntityStorageConnector {
29
30
  * @internal
30
31
  */
31
32
  static _PARTITION_KEY_VALUE = "root";
33
+ /**
34
+ * The name for the schema.
35
+ * @internal
36
+ */
37
+ _entitySchemaName;
32
38
  /**
33
39
  * The schema for the entity.
34
40
  * @internal
@@ -64,8 +70,9 @@ export class DynamoDbEntityStorageConnector {
64
70
  }
65
71
  Guards.stringValue(DynamoDbEntityStorageConnector.CLASS_NAME, "options.config.region", options.config.region);
66
72
  Guards.stringValue(DynamoDbEntityStorageConnector.CLASS_NAME, "options.config.tableName", options.config.tableName);
67
- this._entitySchema = EntitySchemaFactory.get(options.entitySchema);
68
73
  this._partitionContextIds = options.partitionContextIds;
74
+ this._entitySchemaName = options.entitySchema;
75
+ this._entitySchema = EntitySchemaFactory.get(options.entitySchema);
69
76
  this._primaryKey = EntitySchemaHelper.getPrimaryKey(this._entitySchema);
70
77
  this._config = options.config;
71
78
  this._config.endpoint = Is.stringValue(this._config.endpoint)
@@ -203,7 +210,7 @@ export class DynamoDbEntityStorageConnector {
203
210
  // Wait for table to exist
204
211
  await waitUntilTableExists({
205
212
  client: dbConnection,
206
- maxWaitTime: 60000
213
+ maxWaitTime: 60
207
214
  }, {
208
215
  TableName: this._config.tableName
209
216
  });
@@ -279,8 +286,12 @@ export class DynamoDbEntityStorageConnector {
279
286
  }
280
287
  });
281
288
  const response = await docClient.send(getCommand);
282
- delete response.Item?.[DynamoDbEntityStorageConnector._PARTITION_KEY];
283
- return response.Item;
289
+ if (response.Item) {
290
+ return EntityHelper.unPrepareEntity(response.Item, [
291
+ DynamoDbEntityStorageConnector._PARTITION_KEY
292
+ ]);
293
+ }
294
+ return undefined;
284
295
  }
285
296
  const finalConditions = {
286
297
  conditions: []
@@ -325,17 +336,21 @@ export class DynamoDbEntityStorageConnector {
325
336
  Guards.object(DynamoDbEntityStorageConnector.CLASS_NAME, "entity", entity);
326
337
  const contextIds = await ContextIdStore.getContextIds();
327
338
  const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
328
- EntitySchemaHelper.validateEntity(entity, this.getSchema());
329
- const id = entity[this._primaryKey.property];
339
+ const prepared = EntityHelper.prepareEntity(entity, this._entitySchema, partitionKey
340
+ ? [{ property: DynamoDbEntityStorageConnector._PARTITION_KEY, value: partitionKey }]
341
+ : [
342
+ {
343
+ property: DynamoDbEntityStorageConnector._PARTITION_KEY,
344
+ value: DynamoDbEntityStorageConnector._PARTITION_KEY_VALUE
345
+ }
346
+ ]);
347
+ const id = prepared[this._primaryKey.property];
330
348
  try {
331
349
  const docClient = this.createDocClient();
332
350
  const { conditionExpression, attributeNames, attributeValues } = this.buildConditionExpression(conditions);
333
351
  const putCommand = new PutCommand({
334
352
  TableName: this._config.tableName,
335
- Item: {
336
- [DynamoDbEntityStorageConnector._PARTITION_KEY]: partitionKey ?? DynamoDbEntityStorageConnector._PARTITION_KEY_VALUE,
337
- ...entity
338
- },
353
+ Item: prepared,
339
354
  // Only set the condition expression if we have conditions to match
340
355
  // and the primary key exists, otherwise we are creating a new object
341
356
  ConditionExpression: Is.stringValue(conditionExpression)
@@ -369,22 +384,24 @@ export class DynamoDbEntityStorageConnector {
369
384
  Guards.arrayValue(DynamoDbEntityStorageConnector.CLASS_NAME, "entities", entities);
370
385
  const contextIds = await ContextIdStore.getContextIds();
371
386
  const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
372
- for (const entity of entities) {
373
- EntitySchemaHelper.validateEntity(entity, this.getSchema());
374
- }
387
+ const preparedEntities = entities.map(entity => EntityHelper.prepareEntity(entity, this._entitySchema, partitionKey
388
+ ? [{ property: DynamoDbEntityStorageConnector._PARTITION_KEY, value: partitionKey }]
389
+ : [
390
+ {
391
+ property: DynamoDbEntityStorageConnector._PARTITION_KEY,
392
+ value: DynamoDbEntityStorageConnector._PARTITION_KEY_VALUE
393
+ }
394
+ ]));
375
395
  try {
376
396
  const docClient = this.createDocClient();
377
397
  const chunkSize = 25;
378
- for (let i = 0; i < entities.length; i += chunkSize) {
379
- const chunk = entities.slice(i, i + chunkSize);
398
+ for (let i = 0; i < preparedEntities.length; i += chunkSize) {
399
+ const chunk = preparedEntities.slice(i, i + chunkSize);
380
400
  await docClient.send(new BatchWriteCommand({
381
401
  RequestItems: {
382
402
  [this._config.tableName]: chunk.map(entity => ({
383
403
  PutRequest: {
384
- Item: {
385
- [DynamoDbEntityStorageConnector._PARTITION_KEY]: partitionKey ?? DynamoDbEntityStorageConnector._PARTITION_KEY_VALUE,
386
- ...entity
387
- }
404
+ Item: entity
388
405
  }
389
406
  }))
390
407
  }
@@ -534,6 +551,7 @@ export class DynamoDbEntityStorageConnector {
534
551
  try {
535
552
  const dbConnection = this.createConnection();
536
553
  await dbConnection.deleteTable({ TableName: this._config.tableName });
554
+ await waitUntilTableNotExists({ client: dbConnection, maxWaitTime: 60 }, { TableName: this._config.tableName });
537
555
  await nodeLogging?.log({
538
556
  level: "info",
539
557
  source: DynamoDbEntityStorageConnector.CLASS_NAME,
@@ -571,12 +589,22 @@ export class DynamoDbEntityStorageConnector {
571
589
  }
572
590
  /**
573
591
  * Count all the entities which match the conditions.
592
+ * @param conditions The optional conditions to match for the entities.
574
593
  * @returns The total count of entities in the storage.
575
594
  */
576
- async count() {
595
+ async count(conditions) {
577
596
  try {
578
597
  const contextIds = await ContextIdStore.getContextIds();
579
598
  const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
599
+ const attributeNames = {
600
+ "#partitionId": DynamoDbEntityStorageConnector._PARTITION_KEY
601
+ };
602
+ const attributeValues = {
603
+ ":partitionId": {
604
+ S: partitionKey ?? DynamoDbEntityStorageConnector._PARTITION_KEY_VALUE
605
+ }
606
+ };
607
+ const expressions = this.buildQueryParameters("", conditions, attributeNames, attributeValues);
580
608
  const dbConnection = this.createConnection();
581
609
  let total = 0;
582
610
  let exclusiveStartKey;
@@ -585,14 +613,11 @@ export class DynamoDbEntityStorageConnector {
585
613
  TableName: this._config.tableName,
586
614
  Select: "COUNT",
587
615
  KeyConditionExpression: "#partitionId = :partitionId",
588
- ExpressionAttributeNames: {
589
- "#partitionId": DynamoDbEntityStorageConnector._PARTITION_KEY
590
- },
591
- ExpressionAttributeValues: {
592
- ":partitionId": {
593
- S: partitionKey ?? DynamoDbEntityStorageConnector._PARTITION_KEY_VALUE
594
- }
595
- },
616
+ FilterExpression: Is.stringValue(expressions.filterCondition)
617
+ ? expressions.filterCondition
618
+ : undefined,
619
+ ExpressionAttributeNames: attributeNames,
620
+ ExpressionAttributeValues: attributeValues,
596
621
  ExclusiveStartKey: exclusiveStartKey
597
622
  }));
598
623
  total += result.Count ?? 0;
@@ -604,6 +629,151 @@ export class DynamoDbEntityStorageConnector {
604
629
  throw new GeneralError(DynamoDbEntityStorageConnector.CLASS_NAME, "countFailed", undefined, err);
605
630
  }
606
631
  }
632
+ /**
633
+ * Get a unique list of all the context ids from the storage.
634
+ * @returns The list of unique context ids.
635
+ */
636
+ async getPartitionContextIds() {
637
+ if (!Is.arrayValue(this._partitionContextIds)) {
638
+ return [];
639
+ }
640
+ const contextIdsMap = {};
641
+ try {
642
+ const docClient = this.createDocClient();
643
+ let exclusiveStartKey;
644
+ do {
645
+ const scanResult = await docClient.send(new ScanCommand({
646
+ TableName: this._config.tableName,
647
+ ProjectionExpression: "#partitionId",
648
+ ExpressionAttributeNames: {
649
+ "#partitionId": DynamoDbEntityStorageConnector._PARTITION_KEY
650
+ },
651
+ ExclusiveStartKey: exclusiveStartKey
652
+ }));
653
+ for (const item of scanResult.Items ?? []) {
654
+ const partitionId = item[DynamoDbEntityStorageConnector._PARTITION_KEY];
655
+ if (Is.stringValue(partitionId) && !(partitionId in contextIdsMap)) {
656
+ contextIdsMap[partitionId] = ContextIdHelper.shortSplit(this._partitionContextIds ?? [], partitionId);
657
+ }
658
+ }
659
+ exclusiveStartKey = scanResult.LastEvaluatedKey;
660
+ } while (exclusiveStartKey);
661
+ }
662
+ catch (err) {
663
+ throw new GeneralError(DynamoDbEntityStorageConnector.CLASS_NAME, "getPartitionContextIdsFailed", undefined, err);
664
+ }
665
+ return Object.values(contextIdsMap);
666
+ }
667
+ /**
668
+ * Create the target connector for performing the migration it will use a temporary storage location.
669
+ * @param newEntitySchema The name of the new entity schema to create the connector for.
670
+ * @returns Connector for performing the migration.
671
+ */
672
+ async createTargetConnector(newEntitySchema) {
673
+ // We create a new table for the migration with a unique name to avoid conflicts with the existing table
674
+ // This table will be swapped with the existing table once the migration is finalized.
675
+ const migrationTableName = `${this._config.tableName}Migration${Date.now()}`;
676
+ return new DynamoDbEntityStorageConnector({
677
+ entitySchema: newEntitySchema,
678
+ config: {
679
+ ...this._config,
680
+ tableName: migrationTableName
681
+ },
682
+ partitionContextIds: this._partitionContextIds
683
+ });
684
+ }
685
+ /**
686
+ * Finalize the migration by tearing down the old connector and replacing it with the new one.
687
+ * @param targetConnector The target connector to finalize the migration with.
688
+ * @param options The options to control how the migration is finalized.
689
+ * @param loggingComponentType The logging component type to use for logging during the migration finalization.
690
+ * @returns A promise that resolves when the migration is finalized.
691
+ */
692
+ async finalizeMigration(targetConnector, options, loggingComponentType) {
693
+ // There is no rename operation in DynamoDB so we have to create a new table with the original name and copy the data over
694
+ // Teardown the existing table with the original name to free up the name for the new table
695
+ await this.teardown(loggingComponentType);
696
+ // Create a new connector with the original table name but with the new schema
697
+ // and copy the data from the migration table to the new table using batch operations
698
+ const finalConnector = new DynamoDbEntityStorageConnector({
699
+ entitySchema: targetConnector._entitySchemaName,
700
+ config: this._config,
701
+ partitionContextIds: this._partitionContextIds
702
+ });
703
+ if (await finalConnector.bootstrap(loggingComponentType)) {
704
+ // Since there is no rename, we need to copy the data from the migration table to the new table
705
+ const partitions = await targetConnector.getPartitionContextIds();
706
+ const batchSize = options?.batchSize ?? DynamoDbEntityStorageConnector._DEFAULT_LIMIT;
707
+ await this.bulkCopy(targetConnector, finalConnector, partitions, batchSize);
708
+ await targetConnector.teardown(loggingComponentType);
709
+ return finalConnector;
710
+ }
711
+ throw new GeneralError(DynamoDbEntityStorageConnector.CLASS_NAME, "finalizeMigrationFailedBootstrap", undefined);
712
+ }
713
+ /**
714
+ * Cleanup the migration if a migration fails or needs to be aborted.
715
+ * @param targetConnector The target connector to cleanup the migration with.
716
+ * @param options The options to control how the migration is cleaned up.
717
+ * @param loggingComponentType The optional component type to use for logging the migration progress.
718
+ * @returns A promise that resolves when the migration is cleaned up.
719
+ */
720
+ async cleanupMigration(targetConnector, options, loggingComponentType) {
721
+ // If something failed the only thing to cleanup is the migration table
722
+ await targetConnector?.teardown?.(loggingComponentType);
723
+ }
724
+ /**
725
+ * Copy all entities from sourceConnector to destConnector, paging through each partition.
726
+ * @param sourceConnector The connector to read entities from.
727
+ * @param destConnector The connector to write entities to.
728
+ * @param partitions The partition list returned by getPartitionContextIds.
729
+ * @param batchSize The number of entities to read per page.
730
+ * @internal
731
+ */
732
+ async bulkCopy(sourceConnector, destConnector, partitions, batchSize) {
733
+ let partitionList;
734
+ if (Is.arrayValue(partitions)) {
735
+ partitionList = partitions;
736
+ }
737
+ else if (Is.arrayValue(sourceConnector._partitionContextIds)) {
738
+ partitionList = [];
739
+ }
740
+ else {
741
+ partitionList = [{}];
742
+ }
743
+ const dbConnection = sourceConnector.createConnection();
744
+ const chunkSize = 25;
745
+ for (let i = 0; i < partitionList.length; i++) {
746
+ const partitionKey = ContextIdHelper.combinedContextKey(partitionList[i], sourceConnector._partitionContextIds) ?? DynamoDbEntityStorageConnector._PARTITION_KEY_VALUE;
747
+ let exclusiveStartKey;
748
+ do {
749
+ const { Items: items, LastEvaluatedKey: lastKey } = await dbConnection.send(new QueryCommand({
750
+ TableName: sourceConnector._config.tableName,
751
+ KeyConditionExpression: `#${DynamoDbEntityStorageConnector._PARTITION_KEY} = :${DynamoDbEntityStorageConnector._PARTITION_KEY}`,
752
+ ExpressionAttributeNames: {
753
+ [`#${DynamoDbEntityStorageConnector._PARTITION_KEY}`]: DynamoDbEntityStorageConnector._PARTITION_KEY
754
+ },
755
+ ExpressionAttributeValues: {
756
+ [`:${DynamoDbEntityStorageConnector._PARTITION_KEY}`]: { S: partitionKey }
757
+ },
758
+ Limit: batchSize,
759
+ ExclusiveStartKey: exclusiveStartKey
760
+ }));
761
+ exclusiveStartKey = lastKey;
762
+ if (Is.arrayValue(items)) {
763
+ for (let j = 0; j < items.length; j += chunkSize) {
764
+ const chunk = items.slice(j, j + chunkSize);
765
+ await dbConnection.send(new BatchWriteItemCommand({
766
+ RequestItems: {
767
+ [destConnector._config.tableName]: chunk.map(item => ({
768
+ PutRequest: { Item: item }
769
+ }))
770
+ }
771
+ }));
772
+ }
773
+ }
774
+ } while (exclusiveStartKey);
775
+ }
776
+ }
607
777
  /**
608
778
  * Create the parameters for a query.
609
779
  * @param objectPath The path for the nested object.
@@ -618,19 +788,44 @@ export class DynamoDbEntityStorageConnector {
618
788
  if (Is.undefined(condition)) {
619
789
  return {
620
790
  keyCondition: "",
621
- filterCondition: ""
791
+ filterCondition: "",
792
+ requiresScan: false
622
793
  };
623
794
  }
624
795
  if ("conditions" in condition) {
625
796
  if (condition.conditions.length === 0) {
626
797
  return {
627
798
  keyCondition: "",
628
- filterCondition: ""
799
+ filterCondition: "",
800
+ requiresScan: false
629
801
  };
630
802
  }
631
803
  // It's a group of comparisons, so check the individual items and combine with the logical operator
632
804
  const joinConditions = condition.conditions.map(c => this.buildQueryParameters(objectPath, c, attributeNames, attributeValues, secondaryIndex));
633
805
  const logicalOperator = this.mapConditionalOperator(condition.logicalOperator);
806
+ // DynamoDB does not support OR in KeyConditionExpression, so when the operator
807
+ // is OR we must move all conditions (including key conditions) into FilterExpression.
808
+ if (condition.logicalOperator === LogicalOperator.Or) {
809
+ const parts = joinConditions
810
+ .map(j => {
811
+ const subParts = [j.keyCondition.trim(), j.filterCondition.trim()].filter(s => s.length > 0);
812
+ if (subParts.length === 0) {
813
+ return "";
814
+ }
815
+ if (subParts.length === 1) {
816
+ return subParts[0];
817
+ }
818
+ return `(${subParts.join(" AND ")})`;
819
+ })
820
+ .filter(s => s.length > 0);
821
+ const hasKeyConditions = joinConditions.some(j => j.keyCondition.length > 0);
822
+ const filterCondition = parts.join(" OR ");
823
+ return {
824
+ keyCondition: "",
825
+ filterCondition: Is.stringValue(filterCondition) ? ` (${filterCondition}) ` : "",
826
+ requiresScan: hasKeyConditions
827
+ };
828
+ }
634
829
  const keyCondition = joinConditions
635
830
  .filter(j => j.keyCondition.length > 0)
636
831
  .map(j => j.keyCondition)
@@ -641,7 +836,8 @@ export class DynamoDbEntityStorageConnector {
641
836
  .join(` ${logicalOperator} `);
642
837
  return {
643
838
  keyCondition: Is.stringValue(keyCondition) ? ` (${keyCondition}) ` : "",
644
- filterCondition: Is.stringValue(filterCondition) ? ` (${filterCondition}) ` : ""
839
+ filterCondition: Is.stringValue(filterCondition) ? ` (${filterCondition}) ` : "",
840
+ requiresScan: joinConditions.some(j => j.requiresScan)
645
841
  };
646
842
  }
647
843
  const schemaProp = this._entitySchema.properties?.find(p => p.property === condition.property);
@@ -650,7 +846,8 @@ export class DynamoDbEntityStorageConnector {
650
846
  const isKey = schemaProp?.isPrimary ?? (schemaProp?.isSecondary && schemaProp?.property === secondaryIndex);
651
847
  return {
652
848
  keyCondition: isKey ? comparison : "",
653
- filterCondition: !isKey ? comparison : ""
849
+ filterCondition: !isKey ? comparison : "",
850
+ requiresScan: false
654
851
  };
655
852
  }
656
853
  /**
@@ -672,14 +869,24 @@ export class DynamoDbEntityStorageConnector {
672
869
  prop += comparator.property;
673
870
  let attributeName = this.populateAttributeNames(prop, attributeNames);
674
871
  if (Is.empty(comparator.value)) {
872
+ // prepareEntity converts undefined → null before storing, so DynamoDB holds the
873
+ // attribute with type NULL rather than omitting it. Use attribute_type to match.
874
+ const nullTypePropName = `:${attributeName.replace(/\./g, "").replace(/#/g, "")}Null`;
875
+ attributeValues[nullTypePropName] = { S: "NULL" };
675
876
  if (comparator.comparison === ComparisonOperator.Equals) {
676
- return `attribute_not_exists(${attributeName})`;
877
+ return `attribute_type(${attributeName}, ${nullTypePropName})`;
677
878
  }
678
879
  else if (comparator.comparison === ComparisonOperator.NotEquals) {
679
- return `attribute_exists(${attributeName})`;
880
+ return `NOT attribute_type(${attributeName}, ${nullTypePropName})`;
680
881
  }
681
882
  }
682
- let propName = `:${attributeName.replace(/\./g, "").replace(/#/g, "")}`;
883
+ const basePropName = `:${attributeName.replace(/\./g, "").replace(/#/g, "")}`;
884
+ let propName = basePropName;
885
+ let propSuffix = 0;
886
+ while (!Is.undefined(attributeValues[propName])) {
887
+ propSuffix++;
888
+ propName = `${basePropName}${propSuffix}`;
889
+ }
683
890
  if (Is.array(comparator.value)) {
684
891
  const dbValues = comparator.value.map(v => this.propertyToDbValue(v, type));
685
892
  const arrAttributeNames = [];
@@ -716,7 +923,7 @@ export class DynamoDbEntityStorageConnector {
716
923
  return `contains(${attributeName}, ${propName})`;
717
924
  }
718
925
  else if (comparator.comparison === ComparisonOperator.NotIncludes) {
719
- return `notContains(${attributeName}, ${propName})`;
926
+ return `NOT contains(${attributeName}, ${propName})`;
720
927
  }
721
928
  else if (comparator.comparison === ComparisonOperator.In) {
722
929
  return `${propName} IN ${attributeName}`;
@@ -816,9 +1023,9 @@ export class DynamoDbEntityStorageConnector {
816
1023
  * @internal
817
1024
  */
818
1025
  createConnectionConfig() {
819
- const requestHandler = {
820
- requestTimeout: this._config.connectionTimeoutMs
821
- };
1026
+ const requestHandler = Is.number(this._config.connectionTimeoutMs)
1027
+ ? { requestTimeout: this._config.connectionTimeoutMs }
1028
+ : undefined;
822
1029
  if (Is.stringValue(this._config.secretAccessKey) &&
823
1030
  Is.stringValue(this._config.accessKeyId) &&
824
1031
  this._config.authMode === "credentials") {
@@ -829,13 +1036,15 @@ export class DynamoDbEntityStorageConnector {
829
1036
  },
830
1037
  endpoint: this._config.endpoint,
831
1038
  region: this._config.region,
832
- requestHandler
1039
+ requestHandler,
1040
+ maxAttempts: this._config.maxAttempts
833
1041
  };
834
1042
  }
835
1043
  return {
836
1044
  endpoint: this._config.endpoint,
837
1045
  region: this._config.region,
838
- requestHandler
1046
+ requestHandler,
1047
+ maxAttempts: this._config.maxAttempts
839
1048
  };
840
1049
  }
841
1050
  /**
@@ -847,8 +1056,9 @@ export class DynamoDbEntityStorageConnector {
847
1056
  async tableExists(tableName) {
848
1057
  try {
849
1058
  const dbConnection = this.createConnection();
850
- await dbConnection.describeTable({ TableName: tableName });
851
- return true;
1059
+ const result = await dbConnection.describeTable({ TableName: tableName });
1060
+ // A table in DELETING state should not be treated as existing
1061
+ return result.Table?.TableStatus !== "DELETING";
852
1062
  }
853
1063
  catch {
854
1064
  return false;
@@ -903,6 +1113,49 @@ export class DynamoDbEntityStorageConnector {
903
1113
  }
904
1114
  };
905
1115
  const expressions = this.buildQueryParameters("", conditions, attributeNames, attributeValues, secondaryIndex);
1116
+ // OR conditions on primary key attributes can't use KeyConditionExpression or
1117
+ // FilterExpression in a QueryCommand — fall back to a full table ScanCommand.
1118
+ if (expressions.requiresScan) {
1119
+ let scanFilter = "#partitionId = :partitionId";
1120
+ if (Is.stringValue(expressions.filterCondition)) {
1121
+ scanFilter += ` AND ${expressions.filterCondition.trim()}`;
1122
+ }
1123
+ const dbConnection = this.createConnection();
1124
+ const matchingItems = [];
1125
+ let scanStartKey = Is.empty(cursor)
1126
+ ? undefined
1127
+ : ObjectHelper.fromBytes(Converter.base64ToBytes(cursor));
1128
+ do {
1129
+ const scanResult = await dbConnection.send(new RawScanCommand({
1130
+ TableName: this._config.tableName,
1131
+ FilterExpression: scanFilter,
1132
+ ExpressionAttributeNames: attributeNames,
1133
+ ExpressionAttributeValues: attributeValues,
1134
+ ProjectionExpression: properties?.map(p => p).join(", "),
1135
+ ExclusiveStartKey: scanStartKey
1136
+ }));
1137
+ matchingItems.push(...(scanResult.Items ?? []));
1138
+ scanStartKey = scanResult.LastEvaluatedKey;
1139
+ } while (!Is.empty(scanStartKey));
1140
+ const hasMore = matchingItems.length > returnSize;
1141
+ const returnedRawItems = hasMore ? matchingItems.slice(0, returnSize) : matchingItems;
1142
+ let resultCursor;
1143
+ if (hasMore) {
1144
+ const lastRawItem = returnedRawItems[returnedRawItems.length - 1];
1145
+ const syntheticKey = {
1146
+ [DynamoDbEntityStorageConnector._PARTITION_KEY]: lastRawItem[DynamoDbEntityStorageConnector._PARTITION_KEY],
1147
+ [this._primaryKey.property]: lastRawItem[this._primaryKey.property]
1148
+ };
1149
+ resultCursor = Converter.bytesToBase64(ObjectHelper.toBytes(syntheticKey));
1150
+ }
1151
+ const scanEntities = returnedRawItems.map(item => {
1152
+ const unmarshalled = unmarshall(item);
1153
+ return EntityHelper.unPrepareEntity(unmarshalled, [
1154
+ DynamoDbEntityStorageConnector._PARTITION_KEY
1155
+ ]);
1156
+ });
1157
+ return { entities: scanEntities, cursor: resultCursor };
1158
+ }
906
1159
  let keyExpression = "#partitionId = :partitionId";
907
1160
  if (expressions.keyCondition.length > 0) {
908
1161
  keyExpression += ` AND ${expressions.keyCondition}`;
@@ -917,7 +1170,7 @@ export class DynamoDbEntityStorageConnector {
917
1170
  ExpressionAttributeNames: attributeNames,
918
1171
  ExpressionAttributeValues: attributeValues,
919
1172
  ProjectionExpression: properties?.map(p => p).join(", "),
920
- Limit: returnSize,
1173
+ Limit: returnSize + 1,
921
1174
  ScanIndexForward: scanAscending,
922
1175
  ExclusiveStartKey: Is.empty(cursor)
923
1176
  ? undefined
@@ -925,20 +1178,28 @@ export class DynamoDbEntityStorageConnector {
925
1178
  });
926
1179
  const connection = this.createDocClient();
927
1180
  const results = await connection.send(query);
928
- let entities = [];
929
- if (Is.arrayValue(results.Items)) {
930
- entities = results.Items.map(item => {
931
- const unmarshalled = unmarshall(item);
932
- delete unmarshalled[DynamoDbEntityStorageConnector._PARTITION_KEY];
933
- return unmarshalled;
934
- });
1181
+ const rawItems = results.Items ?? [];
1182
+ const hasMore = rawItems.length > returnSize;
1183
+ const returnedRawItems = hasMore ? rawItems.slice(0, returnSize) : rawItems;
1184
+ let resultCursor;
1185
+ if (hasMore) {
1186
+ const lastRawItem = returnedRawItems[returnedRawItems.length - 1];
1187
+ const syntheticKey = {
1188
+ [DynamoDbEntityStorageConnector._PARTITION_KEY]: lastRawItem[DynamoDbEntityStorageConnector._PARTITION_KEY],
1189
+ [this._primaryKey.property]: lastRawItem[this._primaryKey.property]
1190
+ };
1191
+ if (Is.stringValue(secondaryIndex)) {
1192
+ syntheticKey[secondaryIndex] = lastRawItem[secondaryIndex];
1193
+ }
1194
+ resultCursor = Converter.bytesToBase64(ObjectHelper.toBytes(syntheticKey));
935
1195
  }
936
- return {
937
- entities,
938
- cursor: Is.empty(results.LastEvaluatedKey)
939
- ? undefined
940
- : Converter.bytesToBase64(ObjectHelper.toBytes(results.LastEvaluatedKey))
941
- };
1196
+ const entities = returnedRawItems.map(item => {
1197
+ const unmarshalled = unmarshall(item);
1198
+ return EntityHelper.unPrepareEntity(unmarshalled, [
1199
+ DynamoDbEntityStorageConnector._PARTITION_KEY
1200
+ ]);
1201
+ });
1202
+ return { entities, cursor: resultCursor };
942
1203
  }
943
1204
  catch (err) {
944
1205
  if (BaseError.isErrorCode(err, "ResourceNotFoundException")) {