@twin.org/entity-storage-connector-dynamodb 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.
@@ -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";
4
- import { DeleteCommand, DynamoDBDocumentClient, GetCommand, PutCommand } from "@aws-sdk/lib-dynamodb";
3
+ import { BatchWriteItemCommand, DynamoDB, QueryCommand, ScanCommand as RawScanCommand, waitUntilTableExists, waitUntilTableNotExists } from "@aws-sdk/client-dynamodb";
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
- import { BaseError, Coerce, ComponentFactory, Converter, GeneralError, Guards, Is, ObjectHelper } from "@twin.org/core";
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 { EntityStorageHelper } 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)
@@ -79,6 +86,35 @@ export class DynamoDbEntityStorageConnector {
79
86
  className() {
80
87
  return DynamoDbEntityStorageConnector.CLASS_NAME;
81
88
  }
89
+ /**
90
+ * Returns the health status of the component.
91
+ * @returns The health status of the component.
92
+ */
93
+ async health() {
94
+ try {
95
+ const dbConnection = this.createConnection();
96
+ await dbConnection.describeTable({ TableName: this._config.tableName });
97
+ return [
98
+ {
99
+ source: DynamoDbEntityStorageConnector.CLASS_NAME,
100
+ status: HealthStatus.Ok,
101
+ description: "healthDescription",
102
+ data: { tableName: this._config.tableName }
103
+ }
104
+ ];
105
+ }
106
+ catch {
107
+ return [
108
+ {
109
+ source: DynamoDbEntityStorageConnector.CLASS_NAME,
110
+ status: HealthStatus.Error,
111
+ description: "healthDescription",
112
+ message: "connectionFailed",
113
+ data: { tableName: this._config.tableName }
114
+ }
115
+ ];
116
+ }
117
+ }
82
118
  /**
83
119
  * Get the schema for the entities.
84
120
  * @returns The schema for the entities.
@@ -174,7 +210,7 @@ export class DynamoDbEntityStorageConnector {
174
210
  // Wait for table to exist
175
211
  await waitUntilTableExists({
176
212
  client: dbConnection,
177
- maxWaitTime: 60000
213
+ maxWaitTime: 60
178
214
  }, {
179
215
  TableName: this._config.tableName
180
216
  });
@@ -250,8 +286,12 @@ export class DynamoDbEntityStorageConnector {
250
286
  }
251
287
  });
252
288
  const response = await docClient.send(getCommand);
253
- delete response.Item?.[DynamoDbEntityStorageConnector._PARTITION_KEY];
254
- return response.Item;
289
+ if (response.Item) {
290
+ return EntityStorageHelper.unPrepareEntity(response.Item, [
291
+ DynamoDbEntityStorageConnector._PARTITION_KEY
292
+ ]);
293
+ }
294
+ return undefined;
255
295
  }
256
296
  const finalConditions = {
257
297
  conditions: []
@@ -296,17 +336,21 @@ export class DynamoDbEntityStorageConnector {
296
336
  Guards.object(DynamoDbEntityStorageConnector.CLASS_NAME, "entity", entity);
297
337
  const contextIds = await ContextIdStore.getContextIds();
298
338
  const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
299
- EntitySchemaHelper.validateEntity(entity, this.getSchema());
300
- const id = entity[this._primaryKey.property];
339
+ const prepared = EntityStorageHelper.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
+ ], { nullBehavior: "omit" });
347
+ const id = prepared[this._primaryKey.property];
301
348
  try {
302
349
  const docClient = this.createDocClient();
303
350
  const { conditionExpression, attributeNames, attributeValues } = this.buildConditionExpression(conditions);
304
351
  const putCommand = new PutCommand({
305
352
  TableName: this._config.tableName,
306
- Item: {
307
- [DynamoDbEntityStorageConnector._PARTITION_KEY]: partitionKey ?? DynamoDbEntityStorageConnector._PARTITION_KEY_VALUE,
308
- ...entity
309
- },
353
+ Item: prepared,
310
354
  // Only set the condition expression if we have conditions to match
311
355
  // and the primary key exists, otherwise we are creating a new object
312
356
  ConditionExpression: Is.stringValue(conditionExpression)
@@ -331,6 +375,93 @@ export class DynamoDbEntityStorageConnector {
331
375
  }, err);
332
376
  }
333
377
  }
378
+ /**
379
+ * Set multiple entities in a batch.
380
+ * @param entities The entities to set.
381
+ * @returns Nothing.
382
+ */
383
+ async setBatch(entities) {
384
+ Guards.arrayValue(DynamoDbEntityStorageConnector.CLASS_NAME, "entities", entities);
385
+ const contextIds = await ContextIdStore.getContextIds();
386
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
387
+ const preparedEntities = entities.map(entity => EntityStorageHelper.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
+ ], { nullBehavior: "omit" }));
395
+ try {
396
+ const docClient = this.createDocClient();
397
+ const chunkSize = 25;
398
+ for (let i = 0; i < preparedEntities.length; i += chunkSize) {
399
+ const chunk = preparedEntities.slice(i, i + chunkSize);
400
+ await docClient.send(new BatchWriteCommand({
401
+ RequestItems: {
402
+ [this._config.tableName]: chunk.map(entity => ({
403
+ PutRequest: {
404
+ Item: entity
405
+ }
406
+ }))
407
+ }
408
+ }));
409
+ }
410
+ }
411
+ catch (err) {
412
+ if (BaseError.isErrorCode(err, "ResourceNotFoundException")) {
413
+ throw new GeneralError(DynamoDbEntityStorageConnector.CLASS_NAME, "tableDoesNotExist", { tableName: this._config.tableName }, err);
414
+ }
415
+ throw new GeneralError(DynamoDbEntityStorageConnector.CLASS_NAME, "setBatchFailed", undefined, err);
416
+ }
417
+ }
418
+ /**
419
+ * Empty the entity storage.
420
+ * @returns Nothing.
421
+ */
422
+ async empty() {
423
+ try {
424
+ const contextIds = await ContextIdStore.getContextIds();
425
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
426
+ const pKey = partitionKey ?? DynamoDbEntityStorageConnector._PARTITION_KEY_VALUE;
427
+ const docClient = this.createDocClient();
428
+ const chunkSize = 25;
429
+ let exclusiveStartKey;
430
+ do {
431
+ const scanResult = await docClient.send(new ScanCommand({
432
+ TableName: this._config.tableName,
433
+ FilterExpression: "#partitionId = :partitionId",
434
+ ExpressionAttributeNames: {
435
+ "#partitionId": DynamoDbEntityStorageConnector._PARTITION_KEY
436
+ },
437
+ ExpressionAttributeValues: {
438
+ ":partitionId": pKey
439
+ },
440
+ ExclusiveStartKey: exclusiveStartKey
441
+ }));
442
+ const items = scanResult.Items ?? [];
443
+ for (let i = 0; i < items.length; i += chunkSize) {
444
+ const chunk = items.slice(i, i + chunkSize);
445
+ await docClient.send(new BatchWriteCommand({
446
+ RequestItems: {
447
+ [this._config.tableName]: chunk.map((item) => ({
448
+ DeleteRequest: {
449
+ Key: {
450
+ [DynamoDbEntityStorageConnector._PARTITION_KEY]: item[DynamoDbEntityStorageConnector._PARTITION_KEY],
451
+ [this._primaryKey.property]: item[this._primaryKey.property]
452
+ }
453
+ }
454
+ }))
455
+ }
456
+ }));
457
+ }
458
+ exclusiveStartKey = scanResult.LastEvaluatedKey;
459
+ } while (exclusiveStartKey);
460
+ }
461
+ catch (err) {
462
+ throw new GeneralError(DynamoDbEntityStorageConnector.CLASS_NAME, "emptyFailed", undefined, err);
463
+ }
464
+ }
334
465
  /**
335
466
  * Remove the entity.
336
467
  * @param id The id of the entity to remove.
@@ -370,6 +501,77 @@ export class DynamoDbEntityStorageConnector {
370
501
  }, err);
371
502
  }
372
503
  }
504
+ /**
505
+ * Remove multiple entities by their IDs in a batch.
506
+ * @param ids The ids of the entities to remove.
507
+ * @returns Nothing.
508
+ */
509
+ async removeBatch(ids) {
510
+ Guards.arrayValue(DynamoDbEntityStorageConnector.CLASS_NAME, "ids", ids);
511
+ const contextIds = await ContextIdStore.getContextIds();
512
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
513
+ try {
514
+ const docClient = this.createDocClient();
515
+ const chunkSize = 25;
516
+ const primaryKeyProperty = this._primaryKey.property;
517
+ for (let i = 0; i < ids.length; i += chunkSize) {
518
+ const chunk = ids.slice(i, i + chunkSize);
519
+ await docClient.send(new BatchWriteCommand({
520
+ RequestItems: {
521
+ [this._config.tableName]: chunk.map(id => ({
522
+ DeleteRequest: {
523
+ Key: {
524
+ [primaryKeyProperty]: id,
525
+ [DynamoDbEntityStorageConnector._PARTITION_KEY]: partitionKey ?? DynamoDbEntityStorageConnector._PARTITION_KEY_VALUE
526
+ }
527
+ }
528
+ }))
529
+ }
530
+ }));
531
+ }
532
+ }
533
+ catch (err) {
534
+ throw new GeneralError(DynamoDbEntityStorageConnector.CLASS_NAME, "removeBatchFailed", undefined, err);
535
+ }
536
+ }
537
+ /**
538
+ * Teardown the entity storage by deleting the underlying table.
539
+ * @param nodeLoggingComponentType The node logging component type.
540
+ * @returns True if the teardown process was successful.
541
+ */
542
+ async teardown(nodeLoggingComponentType) {
543
+ const nodeLogging = ComponentFactory.getIfExists(nodeLoggingComponentType);
544
+ await nodeLogging?.log({
545
+ level: "info",
546
+ source: DynamoDbEntityStorageConnector.CLASS_NAME,
547
+ ts: Date.now(),
548
+ message: "tableDeleting",
549
+ data: { tableName: this._config.tableName }
550
+ });
551
+ try {
552
+ const dbConnection = this.createConnection();
553
+ await dbConnection.deleteTable({ TableName: this._config.tableName });
554
+ await waitUntilTableNotExists({ client: dbConnection, maxWaitTime: 60 }, { TableName: this._config.tableName });
555
+ await nodeLogging?.log({
556
+ level: "info",
557
+ source: DynamoDbEntityStorageConnector.CLASS_NAME,
558
+ ts: Date.now(),
559
+ message: "tableDeleted",
560
+ data: { tableName: this._config.tableName }
561
+ });
562
+ return true;
563
+ }
564
+ catch (err) {
565
+ await nodeLogging?.log({
566
+ level: "error",
567
+ source: DynamoDbEntityStorageConnector.CLASS_NAME,
568
+ ts: Date.now(),
569
+ message: "teardownFailed",
570
+ error: BaseError.fromError(err)
571
+ });
572
+ return false;
573
+ }
574
+ }
373
575
  /**
374
576
  * Find all the entities which match the conditions.
375
577
  * @param conditions The conditions to match for the entities.
@@ -386,15 +588,191 @@ export class DynamoDbEntityStorageConnector {
386
588
  return this.internalQuery(conditions, sortProperties, properties, cursor, limit, undefined, partitionKey);
387
589
  }
388
590
  /**
389
- * Delete the table.
390
- * @returns Nothing.
591
+ * Count all the entities which match the conditions.
592
+ * @param conditions The optional conditions to match for the entities.
593
+ * @returns The total count of entities in the storage.
391
594
  */
392
- async tableDelete() {
595
+ async count(conditions) {
393
596
  try {
597
+ const contextIds = await ContextIdStore.getContextIds();
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);
394
608
  const dbConnection = this.createConnection();
395
- await dbConnection.deleteTable({ TableName: this._config.tableName });
609
+ let total = 0;
610
+ let exclusiveStartKey;
611
+ do {
612
+ const result = await dbConnection.send(new QueryCommand({
613
+ TableName: this._config.tableName,
614
+ Select: "COUNT",
615
+ KeyConditionExpression: "#partitionId = :partitionId",
616
+ FilterExpression: Is.stringValue(expressions.filterCondition)
617
+ ? expressions.filterCondition
618
+ : undefined,
619
+ ExpressionAttributeNames: attributeNames,
620
+ ExpressionAttributeValues: attributeValues,
621
+ ExclusiveStartKey: exclusiveStartKey
622
+ }));
623
+ total += result.Count ?? 0;
624
+ exclusiveStartKey = result.LastEvaluatedKey;
625
+ } while (exclusiveStartKey);
626
+ return total;
627
+ }
628
+ catch (err) {
629
+ throw new GeneralError(DynamoDbEntityStorageConnector.CLASS_NAME, "countFailed", undefined, err);
630
+ }
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);
396
775
  }
397
- catch { }
398
776
  }
399
777
  /**
400
778
  * Create the parameters for a query.
@@ -410,19 +788,44 @@ export class DynamoDbEntityStorageConnector {
410
788
  if (Is.undefined(condition)) {
411
789
  return {
412
790
  keyCondition: "",
413
- filterCondition: ""
791
+ filterCondition: "",
792
+ requiresScan: false
414
793
  };
415
794
  }
416
795
  if ("conditions" in condition) {
417
796
  if (condition.conditions.length === 0) {
418
797
  return {
419
798
  keyCondition: "",
420
- filterCondition: ""
799
+ filterCondition: "",
800
+ requiresScan: false
421
801
  };
422
802
  }
423
803
  // It's a group of comparisons, so check the individual items and combine with the logical operator
424
804
  const joinConditions = condition.conditions.map(c => this.buildQueryParameters(objectPath, c, attributeNames, attributeValues, secondaryIndex));
425
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
+ }
426
829
  const keyCondition = joinConditions
427
830
  .filter(j => j.keyCondition.length > 0)
428
831
  .map(j => j.keyCondition)
@@ -433,7 +836,8 @@ export class DynamoDbEntityStorageConnector {
433
836
  .join(` ${logicalOperator} `);
434
837
  return {
435
838
  keyCondition: Is.stringValue(keyCondition) ? ` (${keyCondition}) ` : "",
436
- filterCondition: Is.stringValue(filterCondition) ? ` (${filterCondition}) ` : ""
839
+ filterCondition: Is.stringValue(filterCondition) ? ` (${filterCondition}) ` : "",
840
+ requiresScan: joinConditions.some(j => j.requiresScan)
437
841
  };
438
842
  }
439
843
  const schemaProp = this._entitySchema.properties?.find(p => p.property === condition.property);
@@ -442,7 +846,8 @@ export class DynamoDbEntityStorageConnector {
442
846
  const isKey = schemaProp?.isPrimary ?? (schemaProp?.isSecondary && schemaProp?.property === secondaryIndex);
443
847
  return {
444
848
  keyCondition: isKey ? comparison : "",
445
- filterCondition: !isKey ? comparison : ""
849
+ filterCondition: !isKey ? comparison : "",
850
+ requiresScan: false
446
851
  };
447
852
  }
448
853
  /**
@@ -464,6 +869,8 @@ export class DynamoDbEntityStorageConnector {
464
869
  prop += comparator.property;
465
870
  let attributeName = this.populateAttributeNames(prop, attributeNames);
466
871
  if (Is.empty(comparator.value)) {
872
+ // With "omit" storage, optional null/undefined fields are absent from the item entirely.
873
+ // attribute_not_exists matches absent attributes; attribute_exists matches present ones.
467
874
  if (comparator.comparison === ComparisonOperator.Equals) {
468
875
  return `attribute_not_exists(${attributeName})`;
469
876
  }
@@ -471,7 +878,13 @@ export class DynamoDbEntityStorageConnector {
471
878
  return `attribute_exists(${attributeName})`;
472
879
  }
473
880
  }
474
- let propName = `:${attributeName.replace(/\./g, "").replace(/#/g, "")}`;
881
+ const basePropName = `:${attributeName.replace(/\./g, "").replace(/#/g, "")}`;
882
+ let propName = basePropName;
883
+ let propSuffix = 0;
884
+ while (!Is.undefined(attributeValues[propName])) {
885
+ propSuffix++;
886
+ propName = `${basePropName}${propSuffix}`;
887
+ }
475
888
  if (Is.array(comparator.value)) {
476
889
  const dbValues = comparator.value.map(v => this.propertyToDbValue(v, type));
477
890
  const arrAttributeNames = [];
@@ -508,7 +921,7 @@ export class DynamoDbEntityStorageConnector {
508
921
  return `contains(${attributeName}, ${propName})`;
509
922
  }
510
923
  else if (comparator.comparison === ComparisonOperator.NotIncludes) {
511
- return `notContains(${attributeName}, ${propName})`;
924
+ return `NOT contains(${attributeName}, ${propName})`;
512
925
  }
513
926
  else if (comparator.comparison === ComparisonOperator.In) {
514
927
  return `${propName} IN ${attributeName}`;
@@ -608,6 +1021,9 @@ export class DynamoDbEntityStorageConnector {
608
1021
  * @internal
609
1022
  */
610
1023
  createConnectionConfig() {
1024
+ const requestHandler = Is.number(this._config.connectionTimeoutMs)
1025
+ ? { requestTimeout: this._config.connectionTimeoutMs }
1026
+ : undefined;
611
1027
  if (Is.stringValue(this._config.secretAccessKey) &&
612
1028
  Is.stringValue(this._config.accessKeyId) &&
613
1029
  this._config.authMode === "credentials") {
@@ -617,12 +1033,16 @@ export class DynamoDbEntityStorageConnector {
617
1033
  secretAccessKey: this._config.secretAccessKey
618
1034
  },
619
1035
  endpoint: this._config.endpoint,
620
- region: this._config.region
1036
+ region: this._config.region,
1037
+ requestHandler,
1038
+ maxAttempts: this._config.maxAttempts
621
1039
  };
622
1040
  }
623
1041
  return {
624
1042
  endpoint: this._config.endpoint,
625
- region: this._config.region
1043
+ region: this._config.region,
1044
+ requestHandler,
1045
+ maxAttempts: this._config.maxAttempts
626
1046
  };
627
1047
  }
628
1048
  /**
@@ -634,8 +1054,9 @@ export class DynamoDbEntityStorageConnector {
634
1054
  async tableExists(tableName) {
635
1055
  try {
636
1056
  const dbConnection = this.createConnection();
637
- await dbConnection.describeTable({ TableName: tableName });
638
- return true;
1057
+ const result = await dbConnection.describeTable({ TableName: tableName });
1058
+ // A table in DELETING state should not be treated as existing
1059
+ return result.Table?.TableStatus !== "DELETING";
639
1060
  }
640
1061
  catch {
641
1062
  return false;
@@ -690,6 +1111,49 @@ export class DynamoDbEntityStorageConnector {
690
1111
  }
691
1112
  };
692
1113
  const expressions = this.buildQueryParameters("", conditions, attributeNames, attributeValues, secondaryIndex);
1114
+ // OR conditions on primary key attributes can't use KeyConditionExpression or
1115
+ // FilterExpression in a QueryCommand — fall back to a full table ScanCommand.
1116
+ if (expressions.requiresScan) {
1117
+ let scanFilter = "#partitionId = :partitionId";
1118
+ if (Is.stringValue(expressions.filterCondition)) {
1119
+ scanFilter += ` AND ${expressions.filterCondition.trim()}`;
1120
+ }
1121
+ const dbConnection = this.createConnection();
1122
+ const matchingItems = [];
1123
+ let scanStartKey = Is.empty(cursor)
1124
+ ? undefined
1125
+ : ObjectHelper.fromBytes(Converter.base64ToBytes(cursor));
1126
+ do {
1127
+ const scanResult = await dbConnection.send(new RawScanCommand({
1128
+ TableName: this._config.tableName,
1129
+ FilterExpression: scanFilter,
1130
+ ExpressionAttributeNames: attributeNames,
1131
+ ExpressionAttributeValues: attributeValues,
1132
+ ProjectionExpression: properties?.map(p => p).join(", "),
1133
+ ExclusiveStartKey: scanStartKey
1134
+ }));
1135
+ matchingItems.push(...(scanResult.Items ?? []));
1136
+ scanStartKey = scanResult.LastEvaluatedKey;
1137
+ } while (!Is.empty(scanStartKey));
1138
+ const hasMore = matchingItems.length > returnSize;
1139
+ const returnedRawItems = hasMore ? matchingItems.slice(0, returnSize) : matchingItems;
1140
+ let resultCursor;
1141
+ if (hasMore) {
1142
+ const lastRawItem = returnedRawItems[returnedRawItems.length - 1];
1143
+ const syntheticKey = {
1144
+ [DynamoDbEntityStorageConnector._PARTITION_KEY]: lastRawItem[DynamoDbEntityStorageConnector._PARTITION_KEY],
1145
+ [this._primaryKey.property]: lastRawItem[this._primaryKey.property]
1146
+ };
1147
+ resultCursor = Converter.bytesToBase64(ObjectHelper.toBytes(syntheticKey));
1148
+ }
1149
+ const scanEntities = returnedRawItems.map(item => {
1150
+ const unmarshalled = unmarshall(item);
1151
+ return EntityStorageHelper.unPrepareEntity(unmarshalled, [
1152
+ DynamoDbEntityStorageConnector._PARTITION_KEY
1153
+ ]);
1154
+ });
1155
+ return { entities: scanEntities, cursor: resultCursor };
1156
+ }
693
1157
  let keyExpression = "#partitionId = :partitionId";
694
1158
  if (expressions.keyCondition.length > 0) {
695
1159
  keyExpression += ` AND ${expressions.keyCondition}`;
@@ -704,7 +1168,7 @@ export class DynamoDbEntityStorageConnector {
704
1168
  ExpressionAttributeNames: attributeNames,
705
1169
  ExpressionAttributeValues: attributeValues,
706
1170
  ProjectionExpression: properties?.map(p => p).join(", "),
707
- Limit: returnSize,
1171
+ Limit: returnSize + 1,
708
1172
  ScanIndexForward: scanAscending,
709
1173
  ExclusiveStartKey: Is.empty(cursor)
710
1174
  ? undefined
@@ -712,20 +1176,28 @@ export class DynamoDbEntityStorageConnector {
712
1176
  });
713
1177
  const connection = this.createDocClient();
714
1178
  const results = await connection.send(query);
715
- let entities = [];
716
- if (Is.arrayValue(results.Items)) {
717
- entities = results.Items.map(item => {
718
- const unmarshalled = unmarshall(item);
719
- delete unmarshalled[DynamoDbEntityStorageConnector._PARTITION_KEY];
720
- return unmarshalled;
721
- });
1179
+ const rawItems = results.Items ?? [];
1180
+ const hasMore = rawItems.length > returnSize;
1181
+ const returnedRawItems = hasMore ? rawItems.slice(0, returnSize) : rawItems;
1182
+ let resultCursor;
1183
+ if (hasMore) {
1184
+ const lastRawItem = returnedRawItems[returnedRawItems.length - 1];
1185
+ const syntheticKey = {
1186
+ [DynamoDbEntityStorageConnector._PARTITION_KEY]: lastRawItem[DynamoDbEntityStorageConnector._PARTITION_KEY],
1187
+ [this._primaryKey.property]: lastRawItem[this._primaryKey.property]
1188
+ };
1189
+ if (Is.stringValue(secondaryIndex)) {
1190
+ syntheticKey[secondaryIndex] = lastRawItem[secondaryIndex];
1191
+ }
1192
+ resultCursor = Converter.bytesToBase64(ObjectHelper.toBytes(syntheticKey));
722
1193
  }
723
- return {
724
- entities,
725
- cursor: Is.empty(results.LastEvaluatedKey)
726
- ? undefined
727
- : Converter.bytesToBase64(ObjectHelper.toBytes(results.LastEvaluatedKey))
728
- };
1194
+ const entities = returnedRawItems.map(item => {
1195
+ const unmarshalled = unmarshall(item);
1196
+ return EntityStorageHelper.unPrepareEntity(unmarshalled, [
1197
+ DynamoDbEntityStorageConnector._PARTITION_KEY
1198
+ ]);
1199
+ });
1200
+ return { entities, cursor: resultCursor };
729
1201
  }
730
1202
  catch (err) {
731
1203
  if (BaseError.isErrorCode(err, "ResourceNotFoundException")) {