@kensio/yulin 1.20.15 → 1.20.17
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 +13 -0
- package/dist/sdk/module/sim-sdk-module-client-interceptor.d.ts +5 -0
- package/dist/sdk/module/sim-sdk-module-client-interceptor.js +14 -0
- package/dist/sdk/module/sim-sdk-static-client-factory.d.ts +26 -0
- package/dist/sdk/module/sim-sdk-static-client-factory.js +59 -0
- package/dist/sdk/send-patch.d.ts +8 -0
- package/dist/sdk/send-patch.js +11 -0
- package/dist/service/aws/caller/sim-aws-ambient-caller.d.ts +23 -0
- package/dist/service/aws/caller/sim-aws-ambient-caller.js +12 -0
- package/dist/service/aws/caller/sim-aws-caller-resolver.d.ts +39 -6
- package/dist/service/aws/caller/sim-aws-caller-resolver.js +32 -30
- package/dist/service/aws/caller/sim-aws-caller.d.ts +9 -0
- package/dist/service/aws/caller/sim-aws-resolved-caller.d.ts +19 -0
- package/dist/service/aws/caller/sim-aws-resolved-caller.js +26 -0
- package/dist/service/aws/factory/sim-aws-account-region-service-builder.d.ts +12 -0
- package/dist/service/aws/factory/sim-aws-account-region-service-builder.js +6 -0
- package/dist/service/aws/factory/sim-aws-account-service-cache.d.ts +12 -0
- package/dist/service/aws/factory/sim-aws-account-service-cache.js +9 -0
- package/dist/service/aws/factory/sim-aws-registered-service-builder.js +3 -1
- package/dist/service/aws/factory/sim-aws-service-factory.d.ts +5 -0
- package/dist/service/aws/factory/sim-aws-service-factory.js +14 -0
- package/dist/service/aws/sim-aws-properties.d.ts +15 -0
- package/dist/service/aws/sim-aws.js +2 -1
- package/dist/service/cloudformation/command/create-stack/create-stack.handler.d.ts +4 -0
- package/dist/service/cloudformation/command/create-stack/create-stack.handler.js +4 -1
- package/dist/service/cloudformation/deploy/sim-cfn-cdk-out-deployer.js +2 -1
- package/dist/service/cloudformation/deploy/sim-cfn-cdk-out-plan.d.ts +11 -2
- package/dist/service/cloudformation/deploy/sim-cfn-cdk-out-plan.js +2 -1
- package/dist/service/cloudformation/deploy/sim-cfn-cdk-out-stack-options.d.ts +6 -0
- package/dist/service/cloudformation/deploy/sim-cfn-template-deployer.d.ts +11 -0
- package/dist/service/cloudformation/deploy/sim-cfn-template-deployer.js +17 -15
- package/dist/service/cloudformation/deploy/sim-cfn-template-file-loader.d.ts +12 -0
- package/dist/service/cloudformation/deploy/sim-cfn-template-file-loader.js +2 -1
- package/dist/service/cloudformation/resource/cfn/cloudfront/sim-cloudfront-cache-policy-cfn.d.ts +23 -0
- package/dist/service/cloudformation/resource/cfn/cloudfront/sim-cloudfront-cache-policy-cfn.js +30 -0
- package/dist/service/cloudformation/resource/cfn/cloudfront/sim-cloudfront-cfn-value-adapter.js +8 -0
- package/dist/service/cloudformation/resource/name/sim-cfn-generated-resource-name.d.ts +29 -12
- package/dist/service/cloudformation/resource/name/sim-cfn-generated-resource-name.js +51 -20
- package/dist/service/cloudformation/stack/deploy/sim-cfn-resource-order.d.ts +19 -0
- package/dist/service/cloudformation/stack/deploy/sim-cfn-resource-order.js +6 -0
- package/dist/service/cloudformation/stack/deploy/sim-cfn-stack-resource-batch-creator.d.ts +7 -0
- package/dist/service/cloudformation/stack/deploy/sim-cfn-stack-resource-batch-creator.js +9 -2
- package/dist/service/cloudformation/stack/deploy/sim-cfn-stack-resource-creator.d.ts +2 -0
- package/dist/service/cloudformation/stack/deploy/sim-cfn-stack-resource-creator.js +2 -1
- package/dist/service/cloudformation/stack/sim-cfn-stack-resource-operations.d.ts +6 -0
- package/dist/service/cloudformation/stack/sim-cfn-stack-resource-operations.js +4 -1
- package/dist/service/cloudformation/stack/sim-cfn-stack.js +2 -1
- package/dist/service/cloudformation/stack/sim-cfn-stack.type.d.ts +6 -0
- package/dist/service/cloudfront/behaviour/sim-cloud-front-behavior.d.ts +5 -0
- package/dist/service/cloudfront/cache-policy/sim-cf-cache-policy-registry.d.ts +37 -0
- package/dist/service/cloudfront/cache-policy/sim-cf-cache-policy-registry.js +50 -0
- package/dist/service/cloudfront/cache-policy/sim-cf-cache-policy.d.ts +25 -0
- package/dist/service/cloudfront/cache-policy/sim-cf-cache-policy.js +21 -0
- package/dist/service/cloudfront/cache-policy/sim-cf-managed-cache-policies.d.ts +28 -0
- package/dist/service/cloudfront/cache-policy/sim-cf-managed-cache-policies.js +48 -0
- package/dist/service/cloudfront/cff/function-code-input/cff-function-code-input.d.ts +8 -0
- package/dist/service/cloudfront/cff/function-code-input/cff-function-code-input.js +13 -0
- package/dist/service/cloudfront/cff/sim-cf-function-commands.d.ts +52 -0
- package/dist/service/cloudfront/cff/sim-cf-function-commands.js +51 -0
- package/dist/service/cloudfront/cff/sim-cff-configuration.d.ts +41 -0
- package/dist/service/cloudfront/cff/sim-cff-configuration.js +34 -0
- package/dist/service/cloudfront/cff/sim-cff-stage.d.ts +23 -0
- package/dist/service/cloudfront/cff/sim-cff-stage.js +30 -0
- package/dist/service/cloudfront/cff/sim-cloudfront-function.d.ts +6 -1
- package/dist/service/cloudfront/cff/sim-cloudfront-function.js +6 -0
- package/dist/service/cloudfront/cfn/cache-policy/sim-cfn-cf-cache-policy-config.d.ts +27 -0
- package/dist/service/cloudfront/cfn/cache-policy/sim-cfn-cf-cache-policy-config.js +42 -0
- package/dist/service/cloudfront/cfn/cache-policy/sim-cfn-cf-cache-policy-creator.d.ts +24 -0
- package/dist/service/cloudfront/cfn/cache-policy/sim-cfn-cf-cache-policy-creator.js +32 -0
- package/dist/service/cloudfront/cfn/distro/{sim-cfn-cf-distro-response-headers-policy.d.ts → sim-cfn-cf-distro-behavior-policies.d.ts} +8 -7
- package/dist/service/cloudfront/cfn/distro/{sim-cfn-cf-distro-response-headers-policy.js → sim-cfn-cf-distro-behavior-policies.js} +11 -37
- package/dist/service/cloudfront/cfn/distro/sim-cfn-cf-distro-creator.d.ts +6 -5
- package/dist/service/cloudfront/cfn/distro/sim-cfn-cf-distro-creator.js +8 -7
- package/dist/service/cloudfront/cfn/distro/sim-cfn-cf-distro-policy-drops.d.ts +23 -0
- package/dist/service/cloudfront/cfn/distro/sim-cfn-cf-distro-policy-drops.js +50 -0
- package/dist/service/cloudfront/cfn/sim-cfn-cloudfront-resource-factory.d.ts +1 -0
- package/dist/service/cloudfront/cfn/sim-cfn-cloudfront-resource-factory.js +10 -0
- package/dist/service/cloudfront/command/create-distribution/create-distribution.command.d.ts +1 -0
- package/dist/service/cloudfront/command/create-distribution/create-distribution.handler.d.ts +2 -0
- package/dist/service/cloudfront/command/create-function/create-function.handler.js +7 -1
- package/dist/service/cloudfront/command/function/sim-cf-describe-function.d.ts +22 -0
- package/dist/service/cloudfront/command/function/sim-cf-describe-function.js +32 -0
- package/dist/service/cloudfront/command/function/sim-cf-function-access.d.ts +48 -0
- package/dist/service/cloudfront/command/function/sim-cf-function-access.js +48 -0
- package/dist/service/cloudfront/command/function/sim-cf-function-command.types.d.ts +97 -0
- package/dist/service/cloudfront/command/function/sim-cf-function-command.types.js +10 -0
- package/dist/service/cloudfront/command/function/sim-cf-function-lookup.d.ts +12 -0
- package/dist/service/cloudfront/command/function/sim-cf-function-lookup.js +21 -0
- package/dist/service/cloudfront/command/function/sim-cf-function-summary.d.ts +11 -0
- package/dist/service/cloudfront/command/function/sim-cf-function-summary.js +30 -0
- package/dist/service/cloudfront/command/function/sim-cf-get-function.d.ts +23 -0
- package/dist/service/cloudfront/command/function/sim-cf-get-function.js +33 -0
- package/dist/service/cloudfront/command/function/sim-cf-list-functions.d.ts +22 -0
- package/dist/service/cloudfront/command/function/sim-cf-list-functions.js +38 -0
- package/dist/service/cloudfront/command/update-distribution/update-distribution.handler.d.ts +2 -0
- package/dist/service/cloudfront/distribution/configurator/sim-cf-behavior-cache-policy.d.ts +18 -0
- package/dist/service/cloudfront/distribution/configurator/sim-cf-behavior-cache-policy.js +30 -0
- package/dist/service/cloudfront/distribution/configurator/sim-cf-behavior-policies.d.ts +28 -0
- package/dist/service/cloudfront/distribution/configurator/sim-cf-behavior-policies.js +41 -0
- package/dist/service/cloudfront/distribution/configurator/sim-cf-behavior-properties.d.ts +2 -2
- package/dist/service/cloudfront/distribution/configurator/sim-cf-behavior-properties.js +6 -3
- package/dist/service/cloudfront/distribution/configurator/sim-cf-behavior-response-headers-policy.d.ts +0 -10
- package/dist/service/cloudfront/distribution/configurator/sim-cf-behavior-response-headers-policy.js +0 -19
- package/dist/service/cloudfront/distribution/configurator/sim-cf-distribution-configurator.factory.d.ts +2 -0
- package/dist/service/cloudfront/distribution/configurator/sim-cf-distribution-configurator.factory.js +4 -2
- package/dist/service/cloudfront/distribution/configurator/sim-cloud-front-behavior-configurator.d.ts +3 -3
- package/dist/service/cloudfront/distribution/configurator/sim-cloud-front-behavior-configurator.js +5 -5
- package/dist/service/cloudfront/distribution/configurator/sim-cloud-front-distribution-configurator.d.ts +3 -3
- package/dist/service/cloudfront/distribution/configurator/sim-cloud-front-distribution-configurator.js +4 -4
- package/dist/service/cloudfront/distribution/sim-cf-distribution-configuration-state.d.ts +3 -1
- package/dist/service/cloudfront/distribution/sim-cf-distribution-reconfigurer.d.ts +2 -0
- package/dist/service/cloudfront/error/sim-cloudfront.error.d.ts +21 -0
- package/dist/service/cloudfront/error/sim-cloudfront.error.js +25 -0
- package/dist/service/cloudfront/key-value-store/sim-cf-key-value-store-access.js +2 -10
- package/dist/service/cloudfront/key-value-store/sim-cf-key-value-store.js +2 -1
- package/dist/service/cloudfront/sdk/sim-cloudfront-sdk-command-router.js +12 -0
- package/dist/service/cloudfront/sim-cf-authorize.d.ts +16 -0
- package/dist/service/cloudfront/sim-cf-authorize.js +19 -0
- package/dist/service/cloudfront/sim-cf-etag.d.ts +7 -0
- package/dist/service/cloudfront/sim-cf-etag.js +10 -0
- package/dist/service/cloudfront/sim-cloudfront-commands.d.ts +8 -12
- package/dist/service/cloudfront/sim-cloudfront-commands.js +8 -17
- package/dist/service/cloudfront/sim-cloudfront-policies.d.ts +43 -0
- package/dist/service/cloudfront/sim-cloudfront-policies.js +53 -0
- package/dist/service/cloudfront/sim-cloudfront.d.ts +13 -17
- package/dist/service/cloudfront/sim-cloudfront.js +22 -25
- package/dist/service/ecr/authorize/sim-ecr-authorizer.d.ts +31 -0
- package/dist/service/ecr/authorize/sim-ecr-authorizer.js +38 -0
- package/dist/service/ecr/cfn/repository/sim-cfn-ecr-repository-creator.d.ts +11 -2
- package/dist/service/ecr/cfn/repository/sim-cfn-ecr-repository-creator.js +11 -2
- package/dist/service/ecr/cfn/sim-ecr-cfn-resource-factory.d.ts +4 -2
- package/dist/service/ecr/cfn/sim-ecr-cfn-resource-factory.js +8 -9
- package/dist/service/ecr/cfn/sim-ecr-cfn-unsupported-resource.d.ts +7 -0
- package/dist/service/ecr/cfn/sim-ecr-cfn-unsupported-resource.js +9 -0
- package/dist/service/ecr/sim-ecr.d.ts +7 -0
- package/dist/service/ecr/sim-ecr.js +16 -4
- package/dist/service/ecs/command/authorize/sim-ecs-authorizer.d.ts +12 -0
- package/dist/service/ecs/command/authorize/sim-ecs-authorizer.js +21 -0
- package/dist/service/ecs/command/register-task-definition/register-task-definition.handler.js +1 -0
- package/dist/service/ecs/sim-ecs-service-principal.d.ts +8 -0
- package/dist/service/ecs/sim-ecs-service-principal.js +8 -0
- package/dist/service/eventbridge/command/authorize/sim-event-bridge-authorizer.d.ts +12 -0
- package/dist/service/eventbridge/command/authorize/sim-event-bridge-authorizer.js +21 -0
- package/dist/service/eventbridge/command/rule/sim-event-bridge-rule-access.d.ts +13 -0
- package/dist/service/eventbridge/command/rule/sim-event-bridge-rule-access.js +9 -0
- package/dist/service/eventbridge/command/target/sim-event-bridge-put-targets.js +1 -0
- package/dist/service/firehose/command/authorize/sim-firehose-authorizer.d.ts +12 -0
- package/dist/service/firehose/command/authorize/sim-firehose-authorizer.js +20 -0
- package/dist/service/firehose/command/sim-firehose-delivery-stream-access.d.ts +4 -0
- package/dist/service/firehose/command/sim-firehose-delivery-stream-access.js +6 -0
- package/dist/service/firehose/command/stream/sim-firehose-create-delivery-stream.js +3 -1
- package/dist/service/firehose/sim-firehose-service-principal.d.ts +8 -0
- package/dist/service/firehose/sim-firehose-service-principal.js +8 -0
- package/dist/service/firehose/source/sim-firehose-source.d.ts +5 -0
- package/dist/service/firehose/source/sim-firehose-source.js +5 -0
- package/dist/service/glue/cfn/database/sim-cfn-glue-database-properties.js +2 -2
- package/dist/service/glue/cfn/sim-cfn-glue-generated-name.d.ts +10 -0
- package/dist/service/glue/cfn/sim-cfn-glue-generated-name.js +25 -0
- package/dist/service/glue/cfn/table/sim-cfn-glue-table-properties.js +2 -3
- package/dist/service/iam/authorize/allow/sim-iam-allow-requirement.d.ts +33 -2
- package/dist/service/iam/authorize/allow/sim-iam-allow-requirement.js +33 -2
- package/dist/service/iam/authorize/allow/sim-iam-allow-statements.d.ts +4 -3
- package/dist/service/iam/authorize/allow/sim-iam-allow-statements.js +9 -3
- package/dist/service/iam/authorize/context/sim-iam-auth-z-allow-requirement.d.ts +14 -5
- package/dist/service/iam/authorize/context/sim-iam-auth-z-allow-requirement.js +16 -12
- package/dist/service/iam/authorize/context/sim-iam-auth-z-caller-context-builder.d.ts +36 -7
- package/dist/service/iam/authorize/context/sim-iam-auth-z-caller-context-builder.js +22 -11
- package/dist/service/iam/authorize/context/sim-iam-auth-z-context-builder.d.ts +10 -71
- package/dist/service/iam/authorize/context/sim-iam-auth-z-context-builder.js +7 -2
- package/dist/service/iam/authorize/context/sim-iam-auth-z-input.d.ts +87 -0
- package/dist/service/iam/authorize/context/sim-iam-auth-z-input.js +1 -0
- package/dist/service/iam/authorize/match/sim-iam-policy-principal-matcher.d.ts +21 -3
- package/dist/service/iam/authorize/match/sim-iam-policy-principal-matcher.js +34 -10
- package/dist/service/iam/authorize/match/sim-iam-principal-match.d.ts +29 -8
- package/dist/service/iam/authorize/match/sim-iam-principal-match.js +38 -13
- package/dist/service/iam/authorize/pass-role/sim-iam-pass-role-authorizer.d.ts +74 -0
- package/dist/service/iam/authorize/pass-role/sim-iam-pass-role-authorizer.js +92 -0
- package/dist/service/iam/authorize/sim-iam-account-auth-z.d.ts +21 -5
- package/dist/service/iam/authorize/sim-iam-account-auth-z.js +7 -3
- package/dist/service/iam/authorize/sim-iam-authorizer.d.ts +5 -2
- package/dist/service/iam/authorize/sim-iam-authorizer.js +3 -1
- package/dist/service/iam/authorize/sim-iam-inter-service-auth-z.d.ts +1 -1
- package/dist/service/iam/authorize/sim-iam-wildcard.js +39 -4
- package/dist/service/iam/cfn/managed-policy/sim-cfn-iam-managed-policy-creator.js +2 -1
- package/dist/service/iam/cfn/name/sim-cfn-iam-generated-name.d.ts +16 -0
- package/dist/service/iam/cfn/name/sim-cfn-iam-generated-name.js +36 -0
- package/dist/service/iam/cfn/role/sim-cfn-iam-role-properties-parser.js +9 -8
- package/dist/service/iam/cfn/sim-cfn-iam-optional-string.d.ts +17 -0
- package/dist/service/iam/cfn/sim-cfn-iam-optional-string.js +17 -0
- package/dist/service/iam/cfn/user/sim-cfn-iam-user-properties-parser.js +4 -8
- package/dist/service/iam/command/policy/create-policy/create-policy-input-resolver.js +2 -0
- package/dist/service/iam/command/policy/put-role-policy/put-role-policy.handler.js +5 -0
- package/dist/service/iam/command/policy/put-user-policy/put-user-policy.handler.js +5 -0
- package/dist/service/iam/command/role/create-role/create-role.handler.js +4 -1
- package/dist/service/iam/error/sim-iam.error.d.ts +11 -0
- package/dist/service/iam/error/sim-iam.error.js +13 -0
- package/dist/service/iam/index.d.ts +1 -0
- package/dist/service/iam/policy/sim-iam-policy-document-of-size.d.ts +18 -0
- package/dist/service/iam/policy/sim-iam-policy-document-of-size.js +41 -0
- package/dist/service/iam/role/sim-iam-deploy-role.d.ts +56 -0
- package/dist/service/iam/role/sim-iam-deploy-role.js +50 -0
- package/dist/service/iam/role/sim-iam-role-with-policy.factory.d.ts +11 -0
- package/dist/service/iam/role/sim-iam-role-with-policy.factory.js +4 -2
- package/dist/service/iam/sim-iam-account-parts.d.ts +16 -0
- package/dist/service/iam/sim-iam-account-parts.js +2 -0
- package/dist/service/iam/sim-iam.d.ts +12 -2
- package/dist/service/iam/sim-iam.js +12 -0
- package/dist/service/iam/validate/size/sim-iam-policy-document-size.d.ts +57 -0
- package/dist/service/iam/validate/size/sim-iam-policy-document-size.js +71 -0
- package/dist/service/kms/command/authorize/sim-kms-authorizer.d.ts +3 -1
- package/dist/service/kms/command/authorize/sim-kms-authorizer.js +4 -1
- package/dist/service/kms/command/sim-kms-request-options.d.ts +11 -0
- package/dist/service/kms/key/sim-kms-key-policy.d.ts +5 -4
- package/dist/service/kms/key/sim-kms-key-policy.js +4 -3
- package/dist/service/lambda/cfn/function/sim-cfn-lambda-function-properties-parser.d.ts +2 -2
- package/dist/service/lambda/cfn/function/sim-cfn-lambda-function-properties-parser.js +4 -3
- package/dist/service/lambda/cfn/function/sim-cfn-lambda-generated-function-name.d.ts +10 -0
- package/dist/service/lambda/cfn/function/sim-cfn-lambda-generated-function-name.js +25 -0
- package/dist/service/lambda/command/authorize/sim-lambda-resource-policies.d.ts +1 -1
- package/dist/service/lambda/command/create-function/create-function-authorizer.d.ts +9 -2
- package/dist/service/lambda/command/create-function/create-function-authorizer.js +16 -2
- package/dist/service/lambda/command/create-function/create-function.handler.js +1 -1
- package/dist/service/lambda/command/update-function-configuration/update-function-configuration-authorizer.d.ts +7 -2
- package/dist/service/lambda/command/update-function-configuration/update-function-configuration-authorizer.js +14 -2
- package/dist/service/lambda/command/update-function-configuration/update-function-configuration.handler.js +1 -1
- package/dist/service/lambda/function/logging/sim-lambda-function-logging.d.ts +10 -1
- package/dist/service/lambda/function/logging/sim-lambda-function-logging.js +25 -1
- package/dist/service/lambda/function/logging/sim-lambda-invoke-error-log.d.ts +13 -0
- package/dist/service/lambda/function/logging/sim-lambda-invoke-error-log.js +18 -0
- package/dist/service/lambda/sim-lambda-service-principal.d.ts +7 -0
- package/dist/service/lambda/sim-lambda-service-principal.js +7 -0
- package/dist/service/logs/cfn/delivery/sim-cfn-delivery-authorization.d.ts +46 -0
- package/dist/service/logs/cfn/delivery/sim-cfn-delivery-authorization.js +49 -0
- package/dist/service/logs/cfn/delivery/sim-cfn-delivery-creator.d.ts +5 -0
- package/dist/service/logs/cfn/delivery/sim-cfn-delivery-creator.js +6 -0
- package/dist/service/logs/cfn/delivery/sim-cfn-delivery-destination-creator.d.ts +5 -0
- package/dist/service/logs/cfn/delivery/sim-cfn-delivery-destination-creator.js +6 -0
- package/dist/service/logs/cfn/delivery/sim-cfn-delivery-source-creator.d.ts +5 -0
- package/dist/service/logs/cfn/delivery/sim-cfn-delivery-source-creator.js +6 -0
- package/dist/service/logs/cfn/group/sim-cfn-log-group-creator.d.ts +9 -2
- package/dist/service/logs/cfn/group/sim-cfn-log-group-creator.js +34 -5
- package/dist/service/logs/cfn/sim-logs-cfn-resource-deleter.js +1 -1
- package/dist/service/logs/cfn/sim-logs-cfn-resource-factory.d.ts +4 -0
- package/dist/service/logs/cfn/sim-logs-cfn-resource-factory.js +9 -4
- package/dist/service/logs/sim-logs-commands.d.ts +3 -0
- package/dist/service/logs/sim-logs-commands.js +4 -0
- package/dist/service/logs/sim-logs.js +6 -1
- package/dist/service/s3/cfn/bucket/sim-cfn-s3-bucket-creator.d.ts +2 -2
- package/dist/service/s3/cfn/bucket/sim-cfn-s3-bucket-creator.js +4 -3
- package/dist/service/s3/cfn/bucket/sim-cfn-s3-bucket-generated-name.d.ts +12 -0
- package/dist/service/s3/cfn/bucket/sim-cfn-s3-bucket-generated-name.js +22 -0
- package/dist/service/s3/command/authorize/sim-s3-bucket-resource-policies.d.ts +1 -1
- package/dist/service/scheduler/command/authorize/sim-scheduler-authorizer.d.ts +10 -1
- package/dist/service/scheduler/command/authorize/sim-scheduler-authorizer.js +19 -1
- package/dist/service/scheduler/command/schedule/sim-scheduler-create-schedule.js +1 -0
- package/dist/service/scheduler/command/schedule/sim-scheduler-schedule-access.d.ts +8 -0
- package/dist/service/scheduler/command/schedule/sim-scheduler-schedule-access.js +9 -0
- package/dist/service/scheduler/command/schedule/sim-scheduler-update-schedule.js +1 -0
- package/dist/service/secretsmanager/cfn/secret/sim-cfn-secrets-manager-generated-name.d.ts +15 -0
- package/dist/service/secretsmanager/cfn/secret/sim-cfn-secrets-manager-generated-name.js +22 -0
- package/dist/service/secretsmanager/cfn/secret/sim-cfn-secrets-manager-secret-properties.d.ts +4 -10
- package/dist/service/secretsmanager/cfn/secret/sim-cfn-secrets-manager-secret-properties.js +13 -31
- package/dist/service/secretsmanager/cfn/secret/sim-cfn-secrets-manager-secret-value.d.ts +17 -0
- package/dist/service/secretsmanager/cfn/secret/sim-cfn-secrets-manager-secret-value.js +30 -0
- package/dist/service/secretsmanager/secret/sim-secrets-manager-secret-name.d.ts +6 -0
- package/dist/service/secretsmanager/secret/sim-secrets-manager-secret-name.js +8 -3
- package/dist/service/sns/command/authorize/sim-sns-topic-resource-policies.d.ts +1 -1
- package/dist/service/sqs/command/authorize/sim-sqs-queue-resource-policies.d.ts +1 -1
- package/dist/service/ssm/cfn/parameter/sim-cfn-ssm-generated-parameter-name.d.ts +16 -0
- package/dist/service/ssm/cfn/parameter/sim-cfn-ssm-generated-parameter-name.js +23 -0
- package/dist/service/ssm/cfn/parameter/sim-cfn-ssm-parameter-properties.d.ts +2 -4
- package/dist/service/ssm/cfn/parameter/sim-cfn-ssm-parameter-properties.js +5 -5
- package/dist/service/ssm/parameter/sim-ssm-kms-crypto.d.ts +6 -4
- package/dist/service/ssm/parameter/sim-ssm-kms-crypto.js +4 -4
- package/dist/service/ssm/parameter/sim-ssm-parameter-arn.d.ts +17 -0
- package/dist/service/ssm/parameter/sim-ssm-parameter-arn.js +20 -0
- package/dist/service/ssm/parameter/sim-ssm-parameter-kms.d.ts +15 -9
- package/dist/service/ssm/parameter/sim-ssm-parameter-kms.js +20 -10
- package/dist/service/ssm/parameter/sim-ssm-parameter-name.js +3 -12
- package/dist/service/stepfunctions/command/authorize/sim-step-functions-authorizer.d.ts +41 -0
- package/dist/service/stepfunctions/command/authorize/sim-step-functions-authorizer.js +64 -0
- package/dist/service/stepfunctions/command/machine/sim-state-machine-create.d.ts +5 -2
- package/dist/service/stepfunctions/command/machine/sim-state-machine-create.js +6 -2
- package/dist/service/stepfunctions/command/machine/sim-state-machine-delete.d.ts +26 -0
- package/dist/service/stepfunctions/command/machine/sim-state-machine-delete.js +29 -0
- package/dist/service/stepfunctions/command/machine/sim-state-machine-lookup.d.ts +7 -0
- package/dist/service/stepfunctions/command/machine/sim-state-machine-lookup.js +12 -3
- package/dist/service/stepfunctions/command/machine/sim-state-machine-update.d.ts +19 -0
- package/dist/service/stepfunctions/command/machine/{sim-state-machine-writes.js → sim-state-machine-update.js} +3 -14
- package/dist/service/stepfunctions/sim-step-functions.d.ts +8 -2
- package/dist/service/stepfunctions/sim-step-functions.js +26 -9
- package/dist/service/sts/auth-z/assume-role-auth-z-coordinator.d.ts +9 -2
- package/dist/service/sts/auth-z/assume-role-auth-z-coordinator.js +2 -2
- package/dist/service/sts/auth-z/assume-role-source-account-auth-z.d.ts +10 -4
- package/dist/service/sts/auth-z/assume-role-source-account-auth-z.js +8 -7
- package/dist/service/sts/auth-z/assume-role-target-auth-z.d.ts +2 -2
- package/dist/service/sts/auth-z/assume-role-trust-policy-authorizer.d.ts +6 -2
- package/dist/service/sts/auth-z/assume-role-trust-policy-authorizer.js +14 -11
- package/dist/service/sts/command/assume-role/assume-role.handler.d.ts +10 -1
- package/dist/service/sts/command/assume-role/assume-role.handler.js +6 -2
- package/dist/service/sts/command/get-caller-identity/get-caller-identity.handler.d.ts +10 -1
- package/dist/service/sts/command/get-caller-identity/get-caller-identity.handler.js +5 -1
- package/dist/service/sts/service-role/sim-service-role.js +5 -1
- package/dist/service/sts/sim-sts.d.ts +18 -1
- package/dist/service/sts/sim-sts.js +8 -0
- package/dist/util/memo/bounded-memo.d.ts +36 -0
- package/dist/util/memo/bounded-memo.js +58 -0
- package/docs/README.md +54 -0
- package/docs/ai-skill/README.md +69 -0
- package/docs/cli/README.md +284 -0
- package/docs/factories/README.md +140 -0
- package/docs/lint/README.md +176 -0
- package/docs/non-aws-dependencies/README.md +261 -0
- package/docs/sdk/README.md +274 -0
- package/docs/serve/README.md +1126 -0
- package/docs/services/acm/README.md +823 -0
- package/docs/services/apigateway/README.md +1919 -0
- package/docs/services/apigatewayv2/README.md +2678 -0
- package/docs/services/athena/README.md +1044 -0
- package/docs/services/bedrock/README.md +350 -0
- package/docs/services/cloudformation/README.md +3625 -0
- package/docs/services/cloudfront/README.md +3098 -0
- package/docs/services/cloudwatch/README.md +422 -0
- package/docs/services/cognito/README.md +4924 -0
- package/docs/services/dynamodb/README.md +3785 -0
- package/docs/services/ecr/README.md +302 -0
- package/docs/services/ecs/README.md +2021 -0
- package/docs/services/elbv2/README.md +1901 -0
- package/docs/services/eventbridge/README.md +1118 -0
- package/docs/services/firehose/README.md +811 -0
- package/docs/services/glue/README.md +507 -0
- package/docs/services/iam/README.md +1578 -0
- package/docs/services/kinesis/README.md +455 -0
- package/docs/services/kms/README.md +664 -0
- package/docs/services/lambda/README.md +3719 -0
- package/docs/services/logs/README.md +882 -0
- package/docs/services/organizations/README.md +574 -0
- package/docs/services/personalize/README.md +835 -0
- package/docs/services/rekognition/README.md +1070 -0
- package/docs/services/route53/README.md +1783 -0
- package/docs/services/s3/README.md +3029 -0
- package/docs/services/scheduler/README.md +814 -0
- package/docs/services/secretsmanager/README.md +670 -0
- package/docs/services/ses/README.md +1013 -0
- package/docs/services/sns/README.md +1904 -0
- package/docs/services/sqs/README.md +1185 -0
- package/docs/services/ssm/README.md +1096 -0
- package/docs/services/stepfunctions/README.md +1341 -0
- package/docs/services/sts/README.md +245 -0
- package/docs/services/wafv2/README.md +1380 -0
- package/docs/terraform/README.md +248 -0
- package/docs/time/README.md +284 -0
- package/llms.txt +59 -0
- package/package.json +7 -4
- package/dist/service/stepfunctions/command/machine/sim-state-machine-writes.d.ts +0 -25
|
@@ -0,0 +1,3785 @@
|
|
|
1
|
+
# Simulated DynamoDB
|
|
2
|
+
|
|
3
|
+
Yulin includes a simulated DynamoDB for tests and local development. Tables are held in memory, and
|
|
4
|
+
every operation is authorized by simulated IAM.
|
|
5
|
+
|
|
6
|
+
This page covers creating, describing, listing and deleting tables. What a request says is checked
|
|
7
|
+
the way real DynamoDB checks it. A table that can be created here is one that could be created on
|
|
8
|
+
AWS.
|
|
9
|
+
|
|
10
|
+
DynamoDB-specific types are imported from the `@kensio/yulin/dynamodb` subpath.
|
|
11
|
+
|
|
12
|
+
## Creating a table
|
|
13
|
+
|
|
14
|
+
`CreateTable` needs a `TableName`, a `KeySchema`, and an `AttributeDefinitions` entry for every
|
|
15
|
+
attribute the key schema names.
|
|
16
|
+
|
|
17
|
+
```typescript sim-dynamodb-create-table
|
|
18
|
+
/**
|
|
19
|
+
* Creating a simulated on-demand table.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { CreateTableCommand } from "@aws-sdk/client-dynamodb";
|
|
23
|
+
|
|
24
|
+
import { SimAws } from "@kensio/yulin";
|
|
25
|
+
|
|
26
|
+
const simAws = new SimAws();
|
|
27
|
+
const dynamoDb = simAws.dynamoDb();
|
|
28
|
+
|
|
29
|
+
const creation = await dynamoDb.createTable(
|
|
30
|
+
new CreateTableCommand({
|
|
31
|
+
TableName: "FoobarTable",
|
|
32
|
+
KeySchema: [{ AttributeName: "id", KeyType: "HASH" }],
|
|
33
|
+
AttributeDefinitions: [{ AttributeName: "id", AttributeType: "S" }],
|
|
34
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
console.log(creation.TableDescription?.TableStatus); // "CREATING"
|
|
39
|
+
console.log(creation.TableDescription?.KeySchema?.[0]?.AttributeName); // "id"
|
|
40
|
+
|
|
41
|
+
// The table becomes ACTIVE once the scheduled background work has run.
|
|
42
|
+
await simAws.backgroundTasksComplete();
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
A new table is `CREATING`, and activation is scheduled as background work. Call
|
|
46
|
+
`simAws.backgroundTasksComplete()` when a test needs the table to be `ACTIVE`.
|
|
47
|
+
|
|
48
|
+
The description carries back what the request asked for. That is the key schema, the attribute
|
|
49
|
+
definitions, the table ARN, a table ID, and the billing and capacity the table was created with.
|
|
50
|
+
|
|
51
|
+
## Key schema and attribute definitions
|
|
52
|
+
|
|
53
|
+
The key schema holds one `HASH` element, optionally followed by one `RANGE` element, in that order.
|
|
54
|
+
`[{ RANGE }, { HASH }]` is refused here as it is on AWS.
|
|
55
|
+
|
|
56
|
+
`AttributeDefinitions` and the key attributes have to name exactly the same attributes. An attribute
|
|
57
|
+
defined that no key uses is a `ValidationException`, and so is a key attribute with no definition.
|
|
58
|
+
DynamoDB is only schemaless about the non-key attributes.
|
|
59
|
+
|
|
60
|
+
A key attribute is one of `S`, `N` or `B`, and an item written to the table has to carry every key
|
|
61
|
+
attribute as the type the table declared for it.
|
|
62
|
+
|
|
63
|
+
## Billing modes and throughput
|
|
64
|
+
|
|
65
|
+
`BillingMode` defaults to `PROVISIONED`, making `ProvisionedThroughput` required with at least one
|
|
66
|
+
read and one write capacity unit. A request that leaves both out asks for a provisioned table with
|
|
67
|
+
no capacity, and is refused.
|
|
68
|
+
|
|
69
|
+
`PAY_PER_REQUEST` refuses `ProvisionedThroughput`, since an on-demand table has no capacity to
|
|
70
|
+
provision.
|
|
71
|
+
|
|
72
|
+
```typescript sim-dynamodb-provisioned-table
|
|
73
|
+
/**
|
|
74
|
+
* Creating a simulated provisioned table with a sort key.
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
import { CreateTableCommand } from "@aws-sdk/client-dynamodb";
|
|
78
|
+
|
|
79
|
+
import { SimAws } from "@kensio/yulin";
|
|
80
|
+
|
|
81
|
+
const simAws = new SimAws();
|
|
82
|
+
|
|
83
|
+
const creation = await simAws.dynamoDb().createTable(
|
|
84
|
+
new CreateTableCommand({
|
|
85
|
+
TableName: "OrdersTable",
|
|
86
|
+
KeySchema: [
|
|
87
|
+
{ AttributeName: "customerId", KeyType: "HASH" },
|
|
88
|
+
{ AttributeName: "orderedAt", KeyType: "RANGE" },
|
|
89
|
+
],
|
|
90
|
+
AttributeDefinitions: [
|
|
91
|
+
{ AttributeName: "customerId", AttributeType: "S" },
|
|
92
|
+
{ AttributeName: "orderedAt", AttributeType: "N" },
|
|
93
|
+
],
|
|
94
|
+
BillingMode: "PROVISIONED",
|
|
95
|
+
ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 3 },
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const throughput = creation.TableDescription?.ProvisionedThroughput;
|
|
100
|
+
console.log(throughput?.ReadCapacityUnits); // 5
|
|
101
|
+
console.log(throughput?.WriteCapacityUnits); // 3
|
|
102
|
+
|
|
103
|
+
await simAws.backgroundTasksComplete();
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
An on-demand table reports `ReadCapacityUnits` and `WriteCapacityUnits` of 0, as real DynamoDB
|
|
107
|
+
reports for one.
|
|
108
|
+
|
|
109
|
+
`TableClass` is stored and reported, and changes nothing else, since no billing happens here.
|
|
110
|
+
`DeletionProtectionEnabled` does change what the table does, and is covered under deleting a table.
|
|
111
|
+
|
|
112
|
+
## Global secondary indexes
|
|
113
|
+
|
|
114
|
+
`GlobalSecondaryIndexes` on `CreateTable` declares indexes with a key of their own over the same
|
|
115
|
+
items. Each index needs an `IndexName`, a `KeySchema` and a `Projection`.
|
|
116
|
+
|
|
117
|
+
```typescript sim-dynamodb-global-secondary-index
|
|
118
|
+
/**
|
|
119
|
+
* Declaring a global secondary index on a simulated table.
|
|
120
|
+
*/
|
|
121
|
+
|
|
122
|
+
import { CreateTableCommand, PutItemCommand } from "@aws-sdk/client-dynamodb";
|
|
123
|
+
|
|
124
|
+
import { SimAws } from "@kensio/yulin";
|
|
125
|
+
|
|
126
|
+
const simAws = new SimAws();
|
|
127
|
+
const dynamoDb = simAws.dynamoDb();
|
|
128
|
+
|
|
129
|
+
const creation = await dynamoDb.createTable(
|
|
130
|
+
new CreateTableCommand({
|
|
131
|
+
TableName: "OrdersTable",
|
|
132
|
+
KeySchema: [{ AttributeName: "orderId", KeyType: "HASH" }],
|
|
133
|
+
// Every index key attribute needs a definition, alongside the table's own.
|
|
134
|
+
AttributeDefinitions: [
|
|
135
|
+
{ AttributeName: "orderId", AttributeType: "S" },
|
|
136
|
+
{ AttributeName: "status", AttributeType: "S" },
|
|
137
|
+
{ AttributeName: "orderedAt", AttributeType: "N" },
|
|
138
|
+
],
|
|
139
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
140
|
+
GlobalSecondaryIndexes: [
|
|
141
|
+
{
|
|
142
|
+
IndexName: "byStatus",
|
|
143
|
+
KeySchema: [
|
|
144
|
+
{ AttributeName: "status", KeyType: "HASH" },
|
|
145
|
+
{ AttributeName: "orderedAt", KeyType: "RANGE" },
|
|
146
|
+
],
|
|
147
|
+
Projection: { ProjectionType: "ALL" },
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const index = creation.TableDescription?.GlobalSecondaryIndexes?.[0];
|
|
154
|
+
console.log(index?.IndexName); // "byStatus"
|
|
155
|
+
console.log(index?.IndexStatus); // "CREATING"
|
|
156
|
+
console.log(index?.IndexArn); // ".../table/OrdersTable/index/byStatus"
|
|
157
|
+
|
|
158
|
+
await simAws.backgroundTasksComplete();
|
|
159
|
+
|
|
160
|
+
// This order carries neither index key attribute, so it is absent from
|
|
161
|
+
// byStatus rather than refused. Missing either one is enough.
|
|
162
|
+
await dynamoDb.putItem(
|
|
163
|
+
new PutItemCommand({
|
|
164
|
+
TableName: "OrdersTable",
|
|
165
|
+
Item: { orderId: { S: "order-1" }, total: { N: "42" } },
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`AttributeDefinitions` has to match the table key schema and every index key schema exactly, in both
|
|
171
|
+
directions. Declaring an index without adding its key attribute definitions is a
|
|
172
|
+
`ValidationException`, and so is defining an attribute no key uses. This is where `CreateTable`
|
|
173
|
+
input most often goes wrong.
|
|
174
|
+
|
|
175
|
+
An index key schema takes the same shape the table's does, with one `HASH` element, optionally
|
|
176
|
+
followed by one `RANGE` element, and key attributes of `S`, `N` or `B`. Index names are unique
|
|
177
|
+
within a table, and a table holds at most 20 indexes.
|
|
178
|
+
|
|
179
|
+
`Projection` says which attributes the index carries. `ALL` is the whole item, `KEYS_ONLY` is the
|
|
180
|
+
index keys plus the table keys, and `INCLUDE` adds 1 to 20 `NonKeyAttributes` to those. `INCLUDE`
|
|
181
|
+
with no attributes named is refused, since it would add no attribute to `KEYS_ONLY`, and attributes
|
|
182
|
+
named under either of the other two types are refused as well.
|
|
183
|
+
|
|
184
|
+
A table projects at most 100 `NonKeyAttributes` across all of its indexes, as well as 20 in any one
|
|
185
|
+
of them. An attribute projected into two indexes counts twice.
|
|
186
|
+
|
|
187
|
+
A provisioned table needs a `ProvisionedThroughput` per index as well as its own. `PAY_PER_REQUEST`
|
|
188
|
+
refuses one, since an on-demand index has no capacity to provision.
|
|
189
|
+
|
|
190
|
+
The description reports each index with its `IndexName`, `IndexArn`, `KeySchema`, `Projection`,
|
|
191
|
+
`ProvisionedThroughput` and `IndexStatus`. The index ARN is the table's own with the index named
|
|
192
|
+
under it. An index status follows its table's. It is `CREATING` on the `CreateTable` response and
|
|
193
|
+
`ACTIVE` once the table is. A table that declared no index leaves `GlobalSecondaryIndexes` out of
|
|
194
|
+
its description altogether.
|
|
195
|
+
|
|
196
|
+
An index is sparse. An item missing any one of an index's key attributes is absent from that index,
|
|
197
|
+
and the write itself still succeeds. An index keyed on two attributes needs both. The one thing a
|
|
198
|
+
write is held to on account of an index is the type. An item carrying an index key attribute as a
|
|
199
|
+
type the index did not declare is a `ValidationException`, since the index could never hold it.
|
|
200
|
+
|
|
201
|
+
## Local secondary indexes
|
|
202
|
+
|
|
203
|
+
`LocalSecondaryIndexes` on `CreateTable` gives an item collection a second sort key. The index
|
|
204
|
+
shares the table's partition key. An entry sits in the same partition as the item it indexes, and
|
|
205
|
+
its sort key is some other attribute. That is what serves an access pattern such as "this customer's
|
|
206
|
+
orders in date order" against a table keyed by customer and order id.
|
|
207
|
+
|
|
208
|
+
`CreateTable` is the only place one can be declared. AWS has no call that adds, changes or removes a
|
|
209
|
+
local secondary index afterwards. A table created without one stays without it for the whole of its
|
|
210
|
+
life.
|
|
211
|
+
|
|
212
|
+
```typescript sim-dynamodb-local-secondary-index
|
|
213
|
+
/**
|
|
214
|
+
* Declaring and querying a local secondary index.
|
|
215
|
+
*/
|
|
216
|
+
|
|
217
|
+
import {
|
|
218
|
+
CreateTableCommand,
|
|
219
|
+
PutItemCommand,
|
|
220
|
+
QueryCommand,
|
|
221
|
+
} from "@aws-sdk/client-dynamodb";
|
|
222
|
+
|
|
223
|
+
import { SimAws } from "@kensio/yulin";
|
|
224
|
+
|
|
225
|
+
const simAws = new SimAws();
|
|
226
|
+
const dynamoDb = simAws.dynamoDb();
|
|
227
|
+
|
|
228
|
+
await dynamoDb.createTable(
|
|
229
|
+
new CreateTableCommand({
|
|
230
|
+
TableName: "Orders",
|
|
231
|
+
KeySchema: [
|
|
232
|
+
{ AttributeName: "customerId", KeyType: "HASH" },
|
|
233
|
+
{ AttributeName: "orderId", KeyType: "RANGE" },
|
|
234
|
+
],
|
|
235
|
+
AttributeDefinitions: [
|
|
236
|
+
{ AttributeName: "customerId", AttributeType: "S" },
|
|
237
|
+
{ AttributeName: "orderId", AttributeType: "S" },
|
|
238
|
+
{ AttributeName: "placedAt", AttributeType: "S" },
|
|
239
|
+
],
|
|
240
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
241
|
+
LocalSecondaryIndexes: [
|
|
242
|
+
{
|
|
243
|
+
IndexName: "OrdersByDate",
|
|
244
|
+
// The partition key is the table's own. The sort key is the whole of
|
|
245
|
+
// what the index adds.
|
|
246
|
+
KeySchema: [
|
|
247
|
+
{ AttributeName: "customerId", KeyType: "HASH" },
|
|
248
|
+
{ AttributeName: "placedAt", KeyType: "RANGE" },
|
|
249
|
+
],
|
|
250
|
+
Projection: { ProjectionType: "KEYS_ONLY" },
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
}),
|
|
254
|
+
);
|
|
255
|
+
await simAws.backgroundTasksComplete();
|
|
256
|
+
|
|
257
|
+
await dynamoDb.putItem(
|
|
258
|
+
new PutItemCommand({
|
|
259
|
+
TableName: "Orders",
|
|
260
|
+
Item: {
|
|
261
|
+
customerId: { S: "customer-1" },
|
|
262
|
+
orderId: { S: "order-1" },
|
|
263
|
+
placedAt: { S: "2026-03-19" },
|
|
264
|
+
total: { N: "7" },
|
|
265
|
+
},
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
await dynamoDb.putItem(
|
|
270
|
+
new PutItemCommand({
|
|
271
|
+
TableName: "Orders",
|
|
272
|
+
Item: {
|
|
273
|
+
customerId: { S: "customer-1" },
|
|
274
|
+
orderId: { S: "order-2" },
|
|
275
|
+
placedAt: { S: "2026-01-08" },
|
|
276
|
+
total: { N: "42" },
|
|
277
|
+
},
|
|
278
|
+
}),
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const byDate = await dynamoDb.query(
|
|
282
|
+
new QueryCommand({
|
|
283
|
+
TableName: "Orders",
|
|
284
|
+
IndexName: "OrdersByDate",
|
|
285
|
+
KeyConditionExpression: "customerId = :customerId",
|
|
286
|
+
ExpressionAttributeValues: { ":customerId": { S: "customer-1" } },
|
|
287
|
+
// The index sits in the same partition as the item it indexes, so it can
|
|
288
|
+
// answer a strongly consistent read.
|
|
289
|
+
ConsistentRead: true,
|
|
290
|
+
}),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
// In date order, which is not the order the table's own sort key gives.
|
|
294
|
+
console.log(byDate.Items?.[0]?.["orderId"]?.S); // "order-2"
|
|
295
|
+
console.log(byDate.Items?.[1]?.["orderId"]?.S); // "order-1"
|
|
296
|
+
|
|
297
|
+
// The index projects its keys alone, so `total` is not on what it answers with.
|
|
298
|
+
console.log(byDate.Items?.[0]?.["total"]); // undefined
|
|
299
|
+
|
|
300
|
+
const whole = await dynamoDb.query(
|
|
301
|
+
new QueryCommand({
|
|
302
|
+
TableName: "Orders",
|
|
303
|
+
IndexName: "OrdersByDate",
|
|
304
|
+
KeyConditionExpression: "customerId = :customerId",
|
|
305
|
+
ExpressionAttributeValues: { ":customerId": { S: "customer-1" } },
|
|
306
|
+
// Asking for whole items fetches what the index does not project from the
|
|
307
|
+
// base table, which is the read AWS charges the extra capacity for.
|
|
308
|
+
Select: "ALL_ATTRIBUTES",
|
|
309
|
+
}),
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
console.log(whole.Items?.[0]?.["total"]?.N); // "42"
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
The key schema is what a declaration is held to. The `HASH` element is the table's own partition
|
|
316
|
+
key, and a `RANGE` element is required and names some other attribute. An index sorted by the
|
|
317
|
+
attribute the table is already sorted by is refused, since it would repeat the order the table is
|
|
318
|
+
in, and so is one keyed on a partition key of its own. A table with no sort key at all takes no
|
|
319
|
+
local secondary index, because it holds one item per partition key and there is no collection for a
|
|
320
|
+
second sort key to reorder.
|
|
321
|
+
|
|
322
|
+
A table holds at most 5 local secondary indexes. Index names are unique within a table across both
|
|
323
|
+
kinds, and a local secondary index cannot take the name of a global one. `Projection` follows the
|
|
324
|
+
same `ALL`, `KEYS_ONLY` and `INCLUDE` rules a global secondary index does, and the 100
|
|
325
|
+
`NonKeyAttributes` a table projects is counted across every index of both kinds.
|
|
326
|
+
|
|
327
|
+
A per-index `ProvisionedThroughput` is refused. A local secondary index is read and written out of
|
|
328
|
+
the table's own capacity. There is no capacity to provision for it, and real DynamoDB has no
|
|
329
|
+
throughput field on a `LocalSecondaryIndex` at all.
|
|
330
|
+
|
|
331
|
+
The description reports each index with its `IndexName`, `IndexArn`, `KeySchema` and `Projection`.
|
|
332
|
+
There is no `IndexStatus` and no `ProvisionedThroughput`, since the index is built with the table
|
|
333
|
+
and shares its capacity. A table that declared none leaves `LocalSecondaryIndexes` out of its
|
|
334
|
+
description altogether.
|
|
335
|
+
|
|
336
|
+
## Describing a table
|
|
337
|
+
|
|
338
|
+
`DescribeTable` answers with the same description `CreateTable` did, read off the table itself. A
|
|
339
|
+
test can check a table came out the way the request or the CloudFormation template meant it to.
|
|
340
|
+
|
|
341
|
+
The `TableName` parameter takes the table's name or its ARN.
|
|
342
|
+
|
|
343
|
+
## Listing tables
|
|
344
|
+
|
|
345
|
+
`ListTables` returns table names in DynamoDB's order, sorted by UTF-8 bytes. `Limit` takes a whole
|
|
346
|
+
number from 1 to 100 and defaults to 100.
|
|
347
|
+
|
|
348
|
+
`LastEvaluatedTableName` is the name to resume from, and it is absent on the last page. That is what
|
|
349
|
+
lets a caller loop until it is gone, rather than until a page comes back empty.
|
|
350
|
+
|
|
351
|
+
```typescript sim-dynamodb-list-tables
|
|
352
|
+
/**
|
|
353
|
+
* Paging through every simulated table, a page at a time.
|
|
354
|
+
*/
|
|
355
|
+
|
|
356
|
+
import {
|
|
357
|
+
CreateTableCommand,
|
|
358
|
+
ListTablesCommand,
|
|
359
|
+
} from "@aws-sdk/client-dynamodb";
|
|
360
|
+
|
|
361
|
+
import { SimAws } from "@kensio/yulin";
|
|
362
|
+
|
|
363
|
+
const simAws = new SimAws();
|
|
364
|
+
const dynamoDb = simAws.dynamoDb();
|
|
365
|
+
|
|
366
|
+
for (const tableName of ["TableC", "TableA", "TableB"]) {
|
|
367
|
+
await dynamoDb.createTable(
|
|
368
|
+
new CreateTableCommand({
|
|
369
|
+
TableName: tableName,
|
|
370
|
+
KeySchema: [{ AttributeName: "id", KeyType: "HASH" }],
|
|
371
|
+
AttributeDefinitions: [{ AttributeName: "id", AttributeType: "S" }],
|
|
372
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
373
|
+
}),
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const names: string[] = [];
|
|
378
|
+
let startAfter: string | undefined;
|
|
379
|
+
|
|
380
|
+
do {
|
|
381
|
+
const page = await dynamoDb.listTables(
|
|
382
|
+
new ListTablesCommand({ Limit: 2, ExclusiveStartTableName: startAfter }),
|
|
383
|
+
);
|
|
384
|
+
names.push(...(page.TableNames ?? []));
|
|
385
|
+
startAfter = page.LastEvaluatedTableName;
|
|
386
|
+
} while (startAfter !== undefined);
|
|
387
|
+
|
|
388
|
+
console.log(names); // ["TableA", "TableB", "TableC"]
|
|
389
|
+
|
|
390
|
+
await simAws.backgroundTasksComplete();
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
A token naming a table that has since been deleted still works. A page resumes at the first name
|
|
394
|
+
after the token.
|
|
395
|
+
|
|
396
|
+
## Updating a table
|
|
397
|
+
|
|
398
|
+
`UpdateTable` changes a table after it exists. It does one of these per call, as AWS does:
|
|
399
|
+
|
|
400
|
+
- change what the table is billed and provisioned as
|
|
401
|
+
- add one global secondary index
|
|
402
|
+
- remove one global secondary index
|
|
403
|
+
|
|
404
|
+
A request combining two of them is a `ValidationException`. `TableClass` and
|
|
405
|
+
`DeletionProtectionEnabled` sit outside the three and can ride along with any of them, or stand on
|
|
406
|
+
their own.
|
|
407
|
+
|
|
408
|
+
The table goes to `UPDATING` at once and settles back to `ACTIVE` once the scheduled background work
|
|
409
|
+
has run. It serves reads and writes throughout, since AWS keeps a table online while updating it. A
|
|
410
|
+
second `UpdateTable` while one is in flight is a `ResourceInUseException`.
|
|
411
|
+
|
|
412
|
+
Adding an index is the change most likely to go wrong in a deployment. It is worth writing a test
|
|
413
|
+
against. The new index is on the table straight away with an `IndexStatus` of `CREATING` and
|
|
414
|
+
`Backfilling` true, and cannot be read until it is `ACTIVE`. A `Query` or a `Scan` against it before
|
|
415
|
+
then is refused with `Cannot read from backfilling global secondary index`, which is what real
|
|
416
|
+
DynamoDB answers. The indexes the table already had stay `ACTIVE` and readable while the new one
|
|
417
|
+
builds.
|
|
418
|
+
|
|
419
|
+
The key attributes of the new index have to be in the `AttributeDefinitions` of the same call. That
|
|
420
|
+
is the only chance to declare them, and a request that leaves them out fails the same way a
|
|
421
|
+
`CreateTable` missing a definition does. Those definitions are added to the ones the table already
|
|
422
|
+
has, so redeclaring an existing attribute as another type is refused.
|
|
423
|
+
|
|
424
|
+
```typescript sim-dynamodb-update-table-index
|
|
425
|
+
/**
|
|
426
|
+
* Adding a global secondary index to a table that is already live.
|
|
427
|
+
*/
|
|
428
|
+
|
|
429
|
+
import {
|
|
430
|
+
DescribeTableCommand,
|
|
431
|
+
QueryCommand,
|
|
432
|
+
UpdateTableCommand,
|
|
433
|
+
} from "@aws-sdk/client-dynamodb";
|
|
434
|
+
|
|
435
|
+
import { SimAws } from "@kensio/yulin";
|
|
436
|
+
|
|
437
|
+
const simAws = new SimAws();
|
|
438
|
+
const dynamoDb = simAws.dynamoDb();
|
|
439
|
+
|
|
440
|
+
await dynamoDb.createTable({
|
|
441
|
+
input: {
|
|
442
|
+
TableName: "OrdersTable",
|
|
443
|
+
KeySchema: [{ AttributeName: "orderId", KeyType: "HASH" }],
|
|
444
|
+
AttributeDefinitions: [{ AttributeName: "orderId", AttributeType: "S" }],
|
|
445
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
await simAws.backgroundTasksComplete();
|
|
449
|
+
|
|
450
|
+
await dynamoDb.putItem({
|
|
451
|
+
input: {
|
|
452
|
+
TableName: "OrdersTable",
|
|
453
|
+
Item: { orderId: { S: "order-1" }, status: { S: "OPEN" } },
|
|
454
|
+
},
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// The attributes the new index is keyed on are declared on the same call, which
|
|
458
|
+
// is the only chance to declare them.
|
|
459
|
+
await dynamoDb.updateTable(
|
|
460
|
+
new UpdateTableCommand({
|
|
461
|
+
TableName: "OrdersTable",
|
|
462
|
+
AttributeDefinitions: [{ AttributeName: "status", AttributeType: "S" }],
|
|
463
|
+
GlobalSecondaryIndexUpdates: [
|
|
464
|
+
{
|
|
465
|
+
Create: {
|
|
466
|
+
IndexName: "byStatus",
|
|
467
|
+
KeySchema: [{ AttributeName: "status", KeyType: "HASH" }],
|
|
468
|
+
Projection: { ProjectionType: "ALL" },
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
],
|
|
472
|
+
}),
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
const building = await dynamoDb.describeTable(
|
|
476
|
+
new DescribeTableCommand({ TableName: "OrdersTable" }),
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
console.log(building.Table?.TableStatus); // "UPDATING"
|
|
480
|
+
console.log(building.Table?.GlobalSecondaryIndexes?.[0]?.IndexStatus); // "CREATING"
|
|
481
|
+
console.log(building.Table?.GlobalSecondaryIndexes?.[0]?.Backfilling); // true
|
|
482
|
+
|
|
483
|
+
// A query against the index now would be refused with
|
|
484
|
+
// "Cannot read from backfilling global secondary index: byStatus".
|
|
485
|
+
await simAws.backgroundTasksComplete();
|
|
486
|
+
|
|
487
|
+
// Once it is ACTIVE it answers for the order that was written before it existed.
|
|
488
|
+
const open = await dynamoDb.query(
|
|
489
|
+
new QueryCommand({
|
|
490
|
+
TableName: "OrdersTable",
|
|
491
|
+
IndexName: "byStatus",
|
|
492
|
+
KeyConditionExpression: "#status = :status",
|
|
493
|
+
ExpressionAttributeNames: { "#status": "status" },
|
|
494
|
+
ExpressionAttributeValues: { ":status": { S: "OPEN" } },
|
|
495
|
+
}),
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
console.log(open.Items?.[0]?.["orderId"]?.S); // "order-1"
|
|
499
|
+
|
|
500
|
+
// Removing it takes it back off the table.
|
|
501
|
+
await dynamoDb.updateTable(
|
|
502
|
+
new UpdateTableCommand({
|
|
503
|
+
TableName: "OrdersTable",
|
|
504
|
+
GlobalSecondaryIndexUpdates: [{ Delete: { IndexName: "byStatus" } }],
|
|
505
|
+
}),
|
|
506
|
+
);
|
|
507
|
+
await simAws.backgroundTasksComplete();
|
|
508
|
+
|
|
509
|
+
const described = await dynamoDb.describeTable(
|
|
510
|
+
new DescribeTableCommand({ TableName: "OrdersTable" }),
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
console.log(described.Table?.GlobalSecondaryIndexes); // undefined
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
Removing an index takes it out of `DescribeTable`, after which a read naming it gives
|
|
517
|
+
`ResourceNotFoundException`. Deleting one the table lacks gives the same, since there is no such
|
|
518
|
+
index either way.
|
|
519
|
+
|
|
520
|
+
A request carrying `ProvisionedThroughput` and no `BillingMode` reprovisions the table under the
|
|
521
|
+
mode it already has, so setting capacity on an on-demand table is refused, and never quietly
|
|
522
|
+
switched. Switching to `PROVISIONED` has to state the capacity here, which real DynamoDB estimates
|
|
523
|
+
instead. See Limitations.
|
|
524
|
+
|
|
525
|
+
## Deleting a table
|
|
526
|
+
|
|
527
|
+
`DeleteTable` puts the table into `DELETING` and answers with its description. The table is still
|
|
528
|
+
there to describe until the scheduled background work has run, at which point it and its items are
|
|
529
|
+
gone.
|
|
530
|
+
|
|
531
|
+
Real DynamoDB only deletes a table that is `ACTIVE`. One that is still `CREATING` or `UPDATING`
|
|
532
|
+
answers `ResourceInUseException`, and one that has gone answers `ResourceNotFoundException`.
|
|
533
|
+
Deleting a table that is already deleting succeeds.
|
|
534
|
+
|
|
535
|
+
A table created with `DeletionProtectionEnabled` refuses to be deleted at all, and stays as it was.
|
|
536
|
+
|
|
537
|
+
```typescript sim-dynamodb-deletion-protection
|
|
538
|
+
/**
|
|
539
|
+
* A simulated table that is protected from deletion.
|
|
540
|
+
*/
|
|
541
|
+
|
|
542
|
+
import {
|
|
543
|
+
CreateTableCommand,
|
|
544
|
+
DeleteTableCommand,
|
|
545
|
+
DescribeTableCommand,
|
|
546
|
+
} from "@aws-sdk/client-dynamodb";
|
|
547
|
+
|
|
548
|
+
import { SimAws } from "@kensio/yulin";
|
|
549
|
+
|
|
550
|
+
const simAws = new SimAws();
|
|
551
|
+
const dynamoDb = simAws.dynamoDb();
|
|
552
|
+
|
|
553
|
+
await dynamoDb.createTable(
|
|
554
|
+
new CreateTableCommand({
|
|
555
|
+
TableName: "ProtectedTable",
|
|
556
|
+
KeySchema: [{ AttributeName: "id", KeyType: "HASH" }],
|
|
557
|
+
AttributeDefinitions: [{ AttributeName: "id", AttributeType: "S" }],
|
|
558
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
559
|
+
DeletionProtectionEnabled: true,
|
|
560
|
+
}),
|
|
561
|
+
);
|
|
562
|
+
await simAws.backgroundTasksComplete();
|
|
563
|
+
|
|
564
|
+
try {
|
|
565
|
+
await dynamoDb.deleteTable(
|
|
566
|
+
new DeleteTableCommand({ TableName: "ProtectedTable" }),
|
|
567
|
+
);
|
|
568
|
+
} catch (error) {
|
|
569
|
+
if ((error as Error).name !== "ValidationException") {
|
|
570
|
+
throw error;
|
|
571
|
+
}
|
|
572
|
+
console.log("the table is protected from deletion");
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const description = await dynamoDb.describeTable(
|
|
576
|
+
new DescribeTableCommand({ TableName: "ProtectedTable" }),
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
console.log(description.Table?.TableStatus); // "ACTIVE"
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
`DeleteTable` takes the table's name or its ARN, as `DescribeTable` does.
|
|
583
|
+
|
|
584
|
+
## Tagging tables
|
|
585
|
+
|
|
586
|
+
A table is tagged by `CreateTable`, or afterwards by `TagResource`. `UntagResource` takes tags off,
|
|
587
|
+
and `ListTagsOfResource` reads them back. The three tag commands name their resource by ARN, in
|
|
588
|
+
`ResourceArn`, where the table commands take a name or an ARN.
|
|
589
|
+
|
|
590
|
+
```typescript sim-dynamodb-tag-table
|
|
591
|
+
/**
|
|
592
|
+
* Tagging a table on creation and afterwards, and reading the tags back.
|
|
593
|
+
*/
|
|
594
|
+
|
|
595
|
+
import {
|
|
596
|
+
CreateTableCommand,
|
|
597
|
+
ListTagsOfResourceCommand,
|
|
598
|
+
TagResourceCommand,
|
|
599
|
+
UntagResourceCommand,
|
|
600
|
+
} from "@aws-sdk/client-dynamodb";
|
|
601
|
+
|
|
602
|
+
import { SimAws } from "@kensio/yulin";
|
|
603
|
+
|
|
604
|
+
const simAws = new SimAws();
|
|
605
|
+
const dynamoDb = simAws.dynamoDb();
|
|
606
|
+
|
|
607
|
+
const creation = await dynamoDb.createTable(
|
|
608
|
+
new CreateTableCommand({
|
|
609
|
+
TableName: "OrdersTable",
|
|
610
|
+
KeySchema: [{ AttributeName: "orderId", KeyType: "HASH" }],
|
|
611
|
+
AttributeDefinitions: [{ AttributeName: "orderId", AttributeType: "S" }],
|
|
612
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
613
|
+
Tags: [{ Key: "Environment", Value: "test" }],
|
|
614
|
+
}),
|
|
615
|
+
);
|
|
616
|
+
await simAws.backgroundTasksComplete();
|
|
617
|
+
|
|
618
|
+
const tableArn = creation.TableDescription?.TableArn ?? "";
|
|
619
|
+
|
|
620
|
+
await dynamoDb.tagResource(
|
|
621
|
+
new TagResourceCommand({
|
|
622
|
+
ResourceArn: tableArn,
|
|
623
|
+
Tags: [
|
|
624
|
+
{ Key: "Owner", Value: "platform" },
|
|
625
|
+
// A key that is already there has its value replaced.
|
|
626
|
+
{ Key: "Environment", Value: "staging" },
|
|
627
|
+
],
|
|
628
|
+
}),
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
await dynamoDb.untagResource(
|
|
632
|
+
new UntagResourceCommand({ ResourceArn: tableArn, TagKeys: ["Owner"] }),
|
|
633
|
+
);
|
|
634
|
+
|
|
635
|
+
const { Tags } = await dynamoDb.listTagsOfResource(
|
|
636
|
+
new ListTagsOfResourceCommand({ ResourceArn: tableArn }),
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
console.log(Tags); // [{ Key: "Environment", Value: "staging" }]
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
`TagResource` and `UntagResource` answer with an empty body, so `ListTagsOfResource` is the only way
|
|
643
|
+
to see what either did. Untagging a key that was never set succeeds. The request asks for a table
|
|
644
|
+
without that key, and that is what it gets either way.
|
|
645
|
+
|
|
646
|
+
The rules a tag is held to are DynamoDB's:
|
|
647
|
+
|
|
648
|
+
- a key is 1 to 128 characters, and a value is 0 to 256, and a key with no value of its own is a tag
|
|
649
|
+
with an empty value
|
|
650
|
+
- both are written with letters, whitespace, digits and `+ - = . _ : /`, narrower than the set some
|
|
651
|
+
other AWS services take, with no `@` in it
|
|
652
|
+
- a key beginning `aws:` is refused, since that prefix is AWS's to assign
|
|
653
|
+
- a resource holds 50 tags
|
|
654
|
+
|
|
655
|
+
A request that breaks one of those is refused whole. A call carrying one good tag and one bad one
|
|
656
|
+
leaves the table's tags exactly as they were.
|
|
657
|
+
|
|
658
|
+
`ListTagsOfResource` pages with `NextToken`, and leaves the token off the last page:
|
|
659
|
+
|
|
660
|
+
```typescript
|
|
661
|
+
const tags = [];
|
|
662
|
+
let nextToken: string | undefined;
|
|
663
|
+
|
|
664
|
+
do {
|
|
665
|
+
const page = await dynamoDb.listTagsOfResource(
|
|
666
|
+
new ListTagsOfResourceCommand({
|
|
667
|
+
ResourceArn: tableArn,
|
|
668
|
+
NextToken: nextToken,
|
|
669
|
+
}),
|
|
670
|
+
);
|
|
671
|
+
|
|
672
|
+
tags.push(...page.Tags);
|
|
673
|
+
nextToken = page.NextToken;
|
|
674
|
+
} while (nextToken !== undefined);
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
A page carries 25 tags. The API has no page size parameter. That number is this simulator's own
|
|
678
|
+
choosing. It is half of the 50 a resource holds, which puts an ordinarily tagged table in one page
|
|
679
|
+
and lets a test that wants to see a `NextToken` reach one with 26 tags.
|
|
680
|
+
|
|
681
|
+
An `AWS::DynamoDB::Table` template property of `Tags` is deployed the same way, and a CDK app
|
|
682
|
+
calling `Tags.of(stack).add("Environment", "test")` gets a tagged table.
|
|
683
|
+
|
|
684
|
+
## Writing items
|
|
685
|
+
|
|
686
|
+
`PutItem` writes one item, replacing the whole item under its primary key rather than merging into
|
|
687
|
+
it. The item is there by the time the call returns. A write and the read that follows it need no
|
|
688
|
+
step in between.
|
|
689
|
+
|
|
690
|
+
```typescript sim-dynamodb-put-item
|
|
691
|
+
/**
|
|
692
|
+
* Writing an item, and reading back the item it replaced.
|
|
693
|
+
*/
|
|
694
|
+
|
|
695
|
+
import { CreateTableCommand, PutItemCommand } from "@aws-sdk/client-dynamodb";
|
|
696
|
+
|
|
697
|
+
import { SimAws } from "@kensio/yulin";
|
|
698
|
+
|
|
699
|
+
const simAws = new SimAws();
|
|
700
|
+
const dynamoDb = simAws.dynamoDb();
|
|
701
|
+
|
|
702
|
+
await dynamoDb.createTable(
|
|
703
|
+
new CreateTableCommand({
|
|
704
|
+
TableName: "OrdersTable",
|
|
705
|
+
KeySchema: [{ AttributeName: "orderId", KeyType: "HASH" }],
|
|
706
|
+
AttributeDefinitions: [{ AttributeName: "orderId", AttributeType: "S" }],
|
|
707
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
708
|
+
}),
|
|
709
|
+
);
|
|
710
|
+
await simAws.backgroundTasksComplete();
|
|
711
|
+
|
|
712
|
+
const written = await dynamoDb.putItem(
|
|
713
|
+
new PutItemCommand({
|
|
714
|
+
TableName: "OrdersTable",
|
|
715
|
+
Item: { orderId: { S: "order-1" }, total: { N: "19.99" } },
|
|
716
|
+
}),
|
|
717
|
+
);
|
|
718
|
+
|
|
719
|
+
console.log(written.Attributes); // undefined
|
|
720
|
+
|
|
721
|
+
const replaced = await dynamoDb.putItem(
|
|
722
|
+
new PutItemCommand({
|
|
723
|
+
TableName: "OrdersTable",
|
|
724
|
+
Item: { orderId: { S: "order-1" }, total: { N: "24.99" } },
|
|
725
|
+
ReturnValues: "ALL_OLD",
|
|
726
|
+
}),
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
console.log(replaced.Attributes?.["total"]?.N); // "19.99"
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
A write with no `ReturnValues` answers with no `Attributes`, as real DynamoDB does. `ALL_OLD` gives
|
|
733
|
+
back the item that was replaced, or nothing when the key was free. Those are the only two modes
|
|
734
|
+
PutItem has.
|
|
735
|
+
|
|
736
|
+
An item has to carry its whole primary key, each key attribute has to be the type the table
|
|
737
|
+
declared, and a key attribute cannot be empty. An empty string or empty binary value is fine
|
|
738
|
+
anywhere else in the item.
|
|
739
|
+
|
|
740
|
+
## Reading and deleting items
|
|
741
|
+
|
|
742
|
+
`GetItem` reads one item by its primary key, and `DeleteItem` removes one the same way. The `Key`
|
|
743
|
+
both take is the whole primary key and nothing else. A missing key element, an attribute outside the
|
|
744
|
+
key, or a value whose type fails to match the table's `AttributeDefinitions` is a
|
|
745
|
+
`ValidationException` naming the attribute at fault.
|
|
746
|
+
|
|
747
|
+
```typescript sim-dynamodb-get-delete-item
|
|
748
|
+
/**
|
|
749
|
+
* Writing an item, reading it back, and deleting it.
|
|
750
|
+
*/
|
|
751
|
+
|
|
752
|
+
import {
|
|
753
|
+
CreateTableCommand,
|
|
754
|
+
DeleteItemCommand,
|
|
755
|
+
GetItemCommand,
|
|
756
|
+
PutItemCommand,
|
|
757
|
+
} from "@aws-sdk/client-dynamodb";
|
|
758
|
+
|
|
759
|
+
import { SimAws } from "@kensio/yulin";
|
|
760
|
+
|
|
761
|
+
const simAws = new SimAws();
|
|
762
|
+
const dynamoDb = simAws.dynamoDb();
|
|
763
|
+
|
|
764
|
+
await dynamoDb.createTable(
|
|
765
|
+
new CreateTableCommand({
|
|
766
|
+
TableName: "OrdersTable",
|
|
767
|
+
KeySchema: [{ AttributeName: "orderId", KeyType: "HASH" }],
|
|
768
|
+
AttributeDefinitions: [{ AttributeName: "orderId", AttributeType: "S" }],
|
|
769
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
770
|
+
}),
|
|
771
|
+
);
|
|
772
|
+
await simAws.backgroundTasksComplete();
|
|
773
|
+
|
|
774
|
+
await dynamoDb.putItem(
|
|
775
|
+
new PutItemCommand({
|
|
776
|
+
TableName: "OrdersTable",
|
|
777
|
+
Item: { orderId: { S: "order-1" }, total: { N: "19.99" } },
|
|
778
|
+
}),
|
|
779
|
+
);
|
|
780
|
+
|
|
781
|
+
const found = await dynamoDb.getItem(
|
|
782
|
+
new GetItemCommand({
|
|
783
|
+
TableName: "OrdersTable",
|
|
784
|
+
Key: { orderId: { S: "order-1" } },
|
|
785
|
+
}),
|
|
786
|
+
);
|
|
787
|
+
|
|
788
|
+
console.log(found.Item?.["total"]?.N); // "19.99"
|
|
789
|
+
|
|
790
|
+
const removed = await dynamoDb.deleteItem(
|
|
791
|
+
new DeleteItemCommand({
|
|
792
|
+
TableName: "OrdersTable",
|
|
793
|
+
Key: { orderId: { S: "order-1" } },
|
|
794
|
+
ReturnValues: "ALL_OLD",
|
|
795
|
+
}),
|
|
796
|
+
);
|
|
797
|
+
|
|
798
|
+
console.log(removed.Attributes?.["total"]?.N); // "19.99"
|
|
799
|
+
|
|
800
|
+
const missing = await dynamoDb.getItem(
|
|
801
|
+
new GetItemCommand({
|
|
802
|
+
TableName: "OrdersTable",
|
|
803
|
+
Key: { orderId: { S: "order-1" } },
|
|
804
|
+
}),
|
|
805
|
+
);
|
|
806
|
+
|
|
807
|
+
console.log(missing.Item); // undefined
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
A key that holds nothing comes back with no `Item` at all, and not an empty one. That absence is how
|
|
811
|
+
a caller tells a miss from an item carrying nothing but its key.
|
|
812
|
+
|
|
813
|
+
`ConsistentRead` is accepted whichever way it is set, and has no effect. Every write has landed by
|
|
814
|
+
the time the call that made it returns. An eventually consistent read still answers with the latest
|
|
815
|
+
write.
|
|
816
|
+
|
|
817
|
+
`DeleteItem` names a key, never an item, so deleting a key that is already free succeeds and reports
|
|
818
|
+
no removal. Its `ReturnValues` takes `NONE` and `ALL_OLD`, as `PutItem` does, and `ALL_OLD` answers
|
|
819
|
+
with the item that was removed.
|
|
820
|
+
|
|
821
|
+
Both take the table's name or its ARN, as the table commands do.
|
|
822
|
+
|
|
823
|
+
## Updating items
|
|
824
|
+
|
|
825
|
+
`UpdateItem` changes part of an item, where `PutItem` replaces the whole thing. What to change is
|
|
826
|
+
written as an `UpdateExpression` made of `SET`, `REMOVE`, `ADD` and `DELETE` clauses, in any order.
|
|
827
|
+
Each keyword appears at most once, and the actions inside a clause are separated by commas.
|
|
828
|
+
|
|
829
|
+
A `SET` action is `path = operand`, where an operand is a value from `ExpressionAttributeValues`,
|
|
830
|
+
another document path, or a call to `if_not_exists(path, operand)` or `list_append(one, other)`. Two
|
|
831
|
+
operands can be joined by one `+` or `-`. An update expression carries no literals, so every
|
|
832
|
+
constant arrives through `ExpressionAttributeValues`. A `REMOVE` action is a document path on its
|
|
833
|
+
own, and removing an attribute that is absent succeeds without changing the item.
|
|
834
|
+
|
|
835
|
+
Every action reads the item as it stood before the request, and never the item being built. So this
|
|
836
|
+
expression, against an item of `{ a: 1, b: 2, c: 3 }`:
|
|
837
|
+
|
|
838
|
+
```text
|
|
839
|
+
REMOVE a SET b = a, c = b
|
|
840
|
+
```
|
|
841
|
+
|
|
842
|
+
leaves `{ b: 1, c: 2 }`. Both assignments read the values from before the update, and `a` is still
|
|
843
|
+
there to be read even though the `REMOVE` is written first.
|
|
844
|
+
|
|
845
|
+
```typescript sim-dynamodb-update-item
|
|
846
|
+
/**
|
|
847
|
+
* Changing part of an item, against the item as it stood before the update.
|
|
848
|
+
*/
|
|
849
|
+
|
|
850
|
+
import {
|
|
851
|
+
CreateTableCommand,
|
|
852
|
+
PutItemCommand,
|
|
853
|
+
UpdateItemCommand,
|
|
854
|
+
} from "@aws-sdk/client-dynamodb";
|
|
855
|
+
|
|
856
|
+
import { SimAws } from "@kensio/yulin";
|
|
857
|
+
|
|
858
|
+
const simAws = new SimAws();
|
|
859
|
+
const dynamoDb = simAws.dynamoDb();
|
|
860
|
+
|
|
861
|
+
await dynamoDb.createTable(
|
|
862
|
+
new CreateTableCommand({
|
|
863
|
+
TableName: "FoobarTable",
|
|
864
|
+
KeySchema: [{ AttributeName: "orderId", KeyType: "HASH" }],
|
|
865
|
+
AttributeDefinitions: [{ AttributeName: "orderId", AttributeType: "S" }],
|
|
866
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
867
|
+
}),
|
|
868
|
+
);
|
|
869
|
+
await simAws.backgroundTasksComplete();
|
|
870
|
+
|
|
871
|
+
await dynamoDb.putItem(
|
|
872
|
+
new PutItemCommand({
|
|
873
|
+
TableName: "FoobarTable",
|
|
874
|
+
Item: {
|
|
875
|
+
orderId: { S: "order-1" },
|
|
876
|
+
a: { N: "1" },
|
|
877
|
+
b: { N: "2" },
|
|
878
|
+
c: { N: "3" },
|
|
879
|
+
draft: { BOOL: true },
|
|
880
|
+
},
|
|
881
|
+
}),
|
|
882
|
+
);
|
|
883
|
+
|
|
884
|
+
// Both assignments read the values from before the update, so removing `a`
|
|
885
|
+
// first does not take it away from the assignment reading it.
|
|
886
|
+
const updated = await dynamoDb.updateItem(
|
|
887
|
+
new UpdateItemCommand({
|
|
888
|
+
TableName: "FoobarTable",
|
|
889
|
+
Key: { orderId: { S: "order-1" } },
|
|
890
|
+
UpdateExpression: "REMOVE a, draft SET b = a, c = b",
|
|
891
|
+
ReturnValues: "ALL_NEW",
|
|
892
|
+
}),
|
|
893
|
+
);
|
|
894
|
+
|
|
895
|
+
console.log(updated.Attributes?.["b"]?.N); // "1"
|
|
896
|
+
console.log(updated.Attributes?.["c"]?.N); // "2"
|
|
897
|
+
console.log(updated.Attributes?.["a"]); // undefined
|
|
898
|
+
|
|
899
|
+
// if_not_exists keeps a value that is already there, and assigns one when it
|
|
900
|
+
// is not.
|
|
901
|
+
const defaulted = await dynamoDb.updateItem(
|
|
902
|
+
new UpdateItemCommand({
|
|
903
|
+
TableName: "FoobarTable",
|
|
904
|
+
Key: { orderId: { S: "order-1" } },
|
|
905
|
+
UpdateExpression: "SET #s = if_not_exists(#s, :packing)",
|
|
906
|
+
ExpressionAttributeNames: { "#s": "status" },
|
|
907
|
+
ExpressionAttributeValues: { ":packing": { S: "packing" } },
|
|
908
|
+
ReturnValues: "ALL_NEW",
|
|
909
|
+
}),
|
|
910
|
+
);
|
|
911
|
+
|
|
912
|
+
console.log(defaulted.Attributes?.["status"]?.S); // "packing"
|
|
913
|
+
|
|
914
|
+
// UpdateItem upserts, so a key holding nothing gets an item built from the Key
|
|
915
|
+
// and the SET actions.
|
|
916
|
+
await dynamoDb.updateItem(
|
|
917
|
+
new UpdateItemCommand({
|
|
918
|
+
TableName: "FoobarTable",
|
|
919
|
+
Key: { orderId: { S: "order-2" } },
|
|
920
|
+
UpdateExpression: "SET #s = :new",
|
|
921
|
+
ExpressionAttributeNames: { "#s": "status" },
|
|
922
|
+
ExpressionAttributeValues: { ":new": { S: "new" } },
|
|
923
|
+
}),
|
|
924
|
+
);
|
|
925
|
+
```
|
|
926
|
+
|
|
927
|
+
An assignment reading a document path the item lacks is a `ValidationException`, as it is on AWS,
|
|
928
|
+
and never an assignment of nothing. `if_not_exists` is how an expression says what to assign when
|
|
929
|
+
the attribute may be absent.
|
|
930
|
+
|
|
931
|
+
### Counting and appending
|
|
932
|
+
|
|
933
|
+
`SET count = count + :n` and `SET count = count - :n` work out a number. DynamoDB takes one operator
|
|
934
|
+
between two operands, with no chaining and no brackets, so `:a + :b + :c` is refused. Arithmetic
|
|
935
|
+
against an attribute that is absent is a `ValidationException`, which is why a counter is usually
|
|
936
|
+
written `SET count = if_not_exists(count, :zero) + :one`.
|
|
937
|
+
|
|
938
|
+
The arithmetic runs on the decimal digits an item holds, never on JavaScript numbers. Adding 1 to
|
|
939
|
+
`9007199254740993` answers `9007199254740994` here, where an implementation going through a double
|
|
940
|
+
answers `9007199254740992`. A total wider than the 38 significant digits DynamoDB carries is
|
|
941
|
+
refused, never rounded.
|
|
942
|
+
|
|
943
|
+
`list_append(one, other)` puts two lists end to end in the order they were written, so
|
|
944
|
+
`list_append(history, :entry)` appends and `list_append(:entry, history)` prepends.
|
|
945
|
+
|
|
946
|
+
A `SET` at a list index past the end of the list appends, and never leaves a gap, and a `REMOVE` of
|
|
947
|
+
a list element closes the list up. Every index an expression names is read against the stored item,
|
|
948
|
+
so `REMOVE lines[0], lines[1]` takes away the first two elements, and never the first and the one
|
|
949
|
+
that moved down into its place.
|
|
950
|
+
|
|
951
|
+
### Adding to numbers and sets
|
|
952
|
+
|
|
953
|
+
`ADD path :value` and `DELETE path :value` are written as a path and a value with nothing between
|
|
954
|
+
them. Both work on a top-level attribute, as they do on AWS, and both take a value the request
|
|
955
|
+
carries, never a document path.
|
|
956
|
+
|
|
957
|
+
`ADD` on a number adds mathematically. An attribute that is absent counts as zero, and a negative
|
|
958
|
+
value counts down. `ADD` on a set unions the value into the stored set, and creates the attribute
|
|
959
|
+
when it is absent. The two sets have to be the same kind, and adding a number set to a string set is
|
|
960
|
+
refused.
|
|
961
|
+
|
|
962
|
+
AWS recommends `SET` over `ADD` for a number, and it is worth repeating here. A retried `ADD` counts
|
|
963
|
+
twice, where a retried `SET` writes the same value again.
|
|
964
|
+
|
|
965
|
+
```typescript sim-dynamodb-update-counter
|
|
966
|
+
/**
|
|
967
|
+
* Counting a view, appending to a list, and tagging an item.
|
|
968
|
+
*/
|
|
969
|
+
|
|
970
|
+
import {
|
|
971
|
+
CreateTableCommand,
|
|
972
|
+
PutItemCommand,
|
|
973
|
+
UpdateItemCommand,
|
|
974
|
+
} from "@aws-sdk/client-dynamodb";
|
|
975
|
+
|
|
976
|
+
import { SimAws } from "@kensio/yulin";
|
|
977
|
+
|
|
978
|
+
const simAws = new SimAws();
|
|
979
|
+
const dynamoDb = simAws.dynamoDb();
|
|
980
|
+
|
|
981
|
+
await dynamoDb.createTable(
|
|
982
|
+
new CreateTableCommand({
|
|
983
|
+
TableName: "FoobarTable",
|
|
984
|
+
KeySchema: [{ AttributeName: "pageId", KeyType: "HASH" }],
|
|
985
|
+
AttributeDefinitions: [{ AttributeName: "pageId", AttributeType: "S" }],
|
|
986
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
987
|
+
}),
|
|
988
|
+
);
|
|
989
|
+
await simAws.backgroundTasksComplete();
|
|
990
|
+
|
|
991
|
+
await dynamoDb.putItem(
|
|
992
|
+
new PutItemCommand({
|
|
993
|
+
TableName: "FoobarTable",
|
|
994
|
+
Item: {
|
|
995
|
+
pageId: { S: "page-1" },
|
|
996
|
+
history: { L: [{ S: "created" }] },
|
|
997
|
+
tags: { SS: ["draft"] },
|
|
998
|
+
},
|
|
999
|
+
}),
|
|
1000
|
+
);
|
|
1001
|
+
|
|
1002
|
+
const counted = await dynamoDb.updateItem(
|
|
1003
|
+
new UpdateItemCommand({
|
|
1004
|
+
TableName: "FoobarTable",
|
|
1005
|
+
Key: { pageId: { S: "page-1" } },
|
|
1006
|
+
UpdateExpression:
|
|
1007
|
+
"SET views = if_not_exists(views, :zero) + :one, " +
|
|
1008
|
+
"history = list_append(history, :entry) " +
|
|
1009
|
+
"ADD tags :added",
|
|
1010
|
+
ExpressionAttributeValues: {
|
|
1011
|
+
":zero": { N: "0" },
|
|
1012
|
+
":one": { N: "1" },
|
|
1013
|
+
":entry": { L: [{ S: "viewed" }] },
|
|
1014
|
+
":added": { SS: ["published"] },
|
|
1015
|
+
},
|
|
1016
|
+
ReturnValues: "UPDATED_NEW",
|
|
1017
|
+
}),
|
|
1018
|
+
);
|
|
1019
|
+
|
|
1020
|
+
// UPDATED_NEW answers with the attributes the expression touched.
|
|
1021
|
+
console.log(counted.Attributes?.["views"]?.N); // "1"
|
|
1022
|
+
console.log(counted.Attributes?.["history"]?.L?.length); // 2
|
|
1023
|
+
console.log(counted.Attributes?.["tags"]?.SS); // [ "draft", "published" ]
|
|
1024
|
+
console.log(counted.Attributes?.["pageId"]); // undefined
|
|
1025
|
+
|
|
1026
|
+
// Two actions cannot write to one attribute, so taking a tag away is its own
|
|
1027
|
+
// update rather than a DELETE alongside the ADD above.
|
|
1028
|
+
const untagged = await dynamoDb.updateItem(
|
|
1029
|
+
new UpdateItemCommand({
|
|
1030
|
+
TableName: "FoobarTable",
|
|
1031
|
+
Key: { pageId: { S: "page-1" } },
|
|
1032
|
+
UpdateExpression: "DELETE tags :gone",
|
|
1033
|
+
ExpressionAttributeValues: { ":gone": { SS: ["draft"] } },
|
|
1034
|
+
ReturnValues: "UPDATED_NEW",
|
|
1035
|
+
}),
|
|
1036
|
+
);
|
|
1037
|
+
|
|
1038
|
+
console.log(untagged.Attributes?.["tags"]?.SS); // [ "published" ]
|
|
1039
|
+
```
|
|
1040
|
+
|
|
1041
|
+
`DELETE` is set subtraction and nothing else. The value has to be a set of the kind the attribute
|
|
1042
|
+
holds, a member the set fails to hold is allowed, and a subtraction that empties the set takes the
|
|
1043
|
+
attribute away with it, since DynamoDB has no empty set.
|
|
1044
|
+
|
|
1045
|
+
`ADD` and `DELETE` against a String, Binary, List or Map attribute are refused, as they are on AWS.
|
|
1046
|
+
|
|
1047
|
+
Assigning into a map the item lacks is a `ValidationException` too. `SET address.city = :c` needs an
|
|
1048
|
+
`address` map to write into, and an update never makes one on the way past.
|
|
1049
|
+
|
|
1050
|
+
An update cannot move an item's primary key. Assigning to a key attribute, or removing one, is a
|
|
1051
|
+
`ValidationException` naming the attribute, since the request already names the item it works on
|
|
1052
|
+
through its `Key`.
|
|
1053
|
+
|
|
1054
|
+
A request with no `UpdateExpression` at all still writes. It leaves a stored item as it was, and
|
|
1055
|
+
creates one holding nothing but the `Key` when the key held nothing.
|
|
1056
|
+
|
|
1057
|
+
`ReturnValues` takes `NONE`, `ALL_OLD`, `ALL_NEW`, `UPDATED_OLD` and `UPDATED_NEW`. The `ALL_` modes
|
|
1058
|
+
answer with the whole item, as it stood before the update or as it now is. The `UPDATED_` modes
|
|
1059
|
+
answer with the parts of it the expression touched and nothing else, nested the way the item nests
|
|
1060
|
+
them. `ALL_OLD` and `UPDATED_OLD` carry nothing when the key held nothing, and `UPDATED_NEW` carries
|
|
1061
|
+
nothing when the expression only removed attributes.
|
|
1062
|
+
|
|
1063
|
+
`UpdateItem` takes a `ConditionExpression` as well, checked the same way as on the other writes.
|
|
1064
|
+
Both expressions draw on the same `ExpressionAttributeNames` and `ExpressionAttributeValues`, and a
|
|
1065
|
+
placeholder used by either counts as used.
|
|
1066
|
+
|
|
1067
|
+
## Conditional writes
|
|
1068
|
+
|
|
1069
|
+
`PutItem`, `DeleteItem` and `UpdateItem` take a `ConditionExpression`, checked against whatever is
|
|
1070
|
+
stored under the key before anything changes. A condition that fails to hold leaves the item exactly
|
|
1071
|
+
as it was and throws `ConditionalCheckFailedException`, with the name and message real DynamoDB
|
|
1072
|
+
uses.
|
|
1073
|
+
|
|
1074
|
+
That is how a write becomes an insert if absent, and how a version attribute becomes optimistic
|
|
1075
|
+
locking.
|
|
1076
|
+
|
|
1077
|
+
```typescript sim-dynamodb-conditional-write
|
|
1078
|
+
/**
|
|
1079
|
+
* Inserting only if absent, and writing only against the version last read.
|
|
1080
|
+
*/
|
|
1081
|
+
|
|
1082
|
+
import { CreateTableCommand, PutItemCommand } from "@aws-sdk/client-dynamodb";
|
|
1083
|
+
|
|
1084
|
+
import { SimAws } from "@kensio/yulin";
|
|
1085
|
+
|
|
1086
|
+
const simAws = new SimAws();
|
|
1087
|
+
const dynamoDb = simAws.dynamoDb();
|
|
1088
|
+
|
|
1089
|
+
await dynamoDb.createTable(
|
|
1090
|
+
new CreateTableCommand({
|
|
1091
|
+
TableName: "FoobarTable",
|
|
1092
|
+
KeySchema: [{ AttributeName: "orderId", KeyType: "HASH" }],
|
|
1093
|
+
AttributeDefinitions: [{ AttributeName: "orderId", AttributeType: "S" }],
|
|
1094
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
1095
|
+
}),
|
|
1096
|
+
);
|
|
1097
|
+
await simAws.backgroundTasksComplete();
|
|
1098
|
+
|
|
1099
|
+
const order = {
|
|
1100
|
+
TableName: "FoobarTable",
|
|
1101
|
+
Item: { orderId: { S: "order-1" }, version: { N: "1" } },
|
|
1102
|
+
ConditionExpression: "attribute_not_exists(orderId)",
|
|
1103
|
+
};
|
|
1104
|
+
|
|
1105
|
+
// The key is free, so the insert goes through.
|
|
1106
|
+
await dynamoDb.putItem(new PutItemCommand(order));
|
|
1107
|
+
|
|
1108
|
+
// The key is taken now, so the same insert is turned away.
|
|
1109
|
+
try {
|
|
1110
|
+
await dynamoDb.putItem(new PutItemCommand(order));
|
|
1111
|
+
} catch (error) {
|
|
1112
|
+
console.log((error as Error).name); // "ConditionalCheckFailedException"
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Optimistic locking: write only if the version is still the one last read.
|
|
1116
|
+
await dynamoDb.putItem(
|
|
1117
|
+
new PutItemCommand({
|
|
1118
|
+
TableName: "FoobarTable",
|
|
1119
|
+
Item: { orderId: { S: "order-1" }, version: { N: "2" } },
|
|
1120
|
+
ConditionExpression: "version = :was",
|
|
1121
|
+
ExpressionAttributeValues: { ":was": { N: "1" } },
|
|
1122
|
+
}),
|
|
1123
|
+
);
|
|
1124
|
+
|
|
1125
|
+
// A second writer holding the same stale version now loses the race.
|
|
1126
|
+
try {
|
|
1127
|
+
await dynamoDb.putItem(
|
|
1128
|
+
new PutItemCommand({
|
|
1129
|
+
TableName: "FoobarTable",
|
|
1130
|
+
Item: { orderId: { S: "order-1" }, version: { N: "2" } },
|
|
1131
|
+
ConditionExpression: "version = :was",
|
|
1132
|
+
ExpressionAttributeValues: { ":was": { N: "1" } },
|
|
1133
|
+
ReturnValuesOnConditionCheckFailure: "ALL_OLD",
|
|
1134
|
+
}),
|
|
1135
|
+
);
|
|
1136
|
+
} catch (error) {
|
|
1137
|
+
// ALL_OLD puts the item it lost to on the exception, so a retry needs no
|
|
1138
|
+
// second read.
|
|
1139
|
+
console.log((error as { Item?: Record<string, { N?: string }> }).Item);
|
|
1140
|
+
// { orderId: { S: "order-1" }, version: { N: "2" } }
|
|
1141
|
+
}
|
|
1142
|
+
```
|
|
1143
|
+
|
|
1144
|
+
`ReturnValuesOnConditionCheckFailure` takes `NONE` and `ALL_OLD`. `ALL_OLD` puts the stored item on
|
|
1145
|
+
the exception as `Item`, and there is no `Item` when the key held nothing.
|
|
1146
|
+
|
|
1147
|
+
The expression is read before the table is reached, and an expression DynamoDB would refuse is
|
|
1148
|
+
refused whether or not the key holds anything.
|
|
1149
|
+
|
|
1150
|
+
### What a condition can say
|
|
1151
|
+
|
|
1152
|
+
The comparators are `=`, `<>`, `<`, `<=`, `>` and `>=`. `BETWEEN` takes two bounds and counts both
|
|
1153
|
+
as inside. `IN` takes up to 100 operands. `AND`, `OR`, `NOT` and brackets combine them, with `NOT`
|
|
1154
|
+
binding tighter than `AND` and `AND` tighter than `OR`. Keywords are read in any case, so `and`
|
|
1155
|
+
works as well as `AND`.
|
|
1156
|
+
|
|
1157
|
+
The functions are `attribute_exists`, `attribute_not_exists`, `attribute_type`, `begins_with`,
|
|
1158
|
+
`contains` and `size`. Function names are read in lower case only, as they are on real AWS.
|
|
1159
|
+
`attribute_exists` is true for an attribute stored as `NULL`, since `NULL` is a value and not an
|
|
1160
|
+
absent one. The first operand of every one of them names a path in the item. A supplied value there
|
|
1161
|
+
is refused, never compared. `size` is a number and not a condition, so it goes beside a comparator.
|
|
1162
|
+
It measures a string or binary in bytes, and a set, a list or a map in how many things it holds.
|
|
1163
|
+
|
|
1164
|
+
Strings compare by UTF-8 byte order, numbers compare by the digits they hold, and binary compares as
|
|
1165
|
+
unsigned bytes.
|
|
1166
|
+
|
|
1167
|
+
A comparison between two different types is never an error. Equality works across types, and a
|
|
1168
|
+
string and a number are unequal, with `=` false and `<>` true. Ordering fails across types, so `<`,
|
|
1169
|
+
`<=`, `>` and `>=` are all false between them, as they are for a path the item lacks. That is what
|
|
1170
|
+
real DynamoDB does, and it is what lets one condition guard items that do not all carry the same
|
|
1171
|
+
attributes.
|
|
1172
|
+
|
|
1173
|
+
`ExpressionAttributeNames` and `ExpressionAttributeValues` have to agree exactly with the
|
|
1174
|
+
expression, in both directions. A placeholder the request leaves undefined is a
|
|
1175
|
+
`ValidationException`, and so is an entry no expression uses.
|
|
1176
|
+
|
|
1177
|
+
## Projecting attributes
|
|
1178
|
+
|
|
1179
|
+
`GetItem` takes a `ProjectionExpression`, a comma-separated list of document paths. Only those paths
|
|
1180
|
+
come back. A path is an attribute name, then any number of `.attribute` dereferences and `[n]` list
|
|
1181
|
+
indexes, such as `address.city` or `lines[0].sku`.
|
|
1182
|
+
|
|
1183
|
+
An attribute name that is a DynamoDB reserved word, or that has a character an expression cannot
|
|
1184
|
+
carry, is written as a `#name` placeholder and defined in `ExpressionAttributeNames`.
|
|
1185
|
+
|
|
1186
|
+
```typescript sim-dynamodb-projection-expression
|
|
1187
|
+
/**
|
|
1188
|
+
* Reading part of an item with a projection expression.
|
|
1189
|
+
*/
|
|
1190
|
+
|
|
1191
|
+
import {
|
|
1192
|
+
CreateTableCommand,
|
|
1193
|
+
GetItemCommand,
|
|
1194
|
+
PutItemCommand,
|
|
1195
|
+
} from "@aws-sdk/client-dynamodb";
|
|
1196
|
+
|
|
1197
|
+
import { SimAws } from "@kensio/yulin";
|
|
1198
|
+
|
|
1199
|
+
const simAws = new SimAws();
|
|
1200
|
+
const dynamoDb = simAws.dynamoDb();
|
|
1201
|
+
|
|
1202
|
+
await dynamoDb.createTable(
|
|
1203
|
+
new CreateTableCommand({
|
|
1204
|
+
TableName: "FoobarTable",
|
|
1205
|
+
KeySchema: [{ AttributeName: "orderId", KeyType: "HASH" }],
|
|
1206
|
+
AttributeDefinitions: [{ AttributeName: "orderId", AttributeType: "S" }],
|
|
1207
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
1208
|
+
}),
|
|
1209
|
+
);
|
|
1210
|
+
await simAws.backgroundTasksComplete();
|
|
1211
|
+
|
|
1212
|
+
await dynamoDb.putItem(
|
|
1213
|
+
new PutItemCommand({
|
|
1214
|
+
TableName: "FoobarTable",
|
|
1215
|
+
Item: {
|
|
1216
|
+
orderId: { S: "order-1" },
|
|
1217
|
+
status: { S: "shipped" },
|
|
1218
|
+
address: { M: { city: { S: "Leeds" }, postcode: { S: "LS1 1AA" } } },
|
|
1219
|
+
lines: { L: [{ S: "widget" }, { S: "gasket" }] },
|
|
1220
|
+
},
|
|
1221
|
+
}),
|
|
1222
|
+
);
|
|
1223
|
+
|
|
1224
|
+
const output = await dynamoDb.getItem(
|
|
1225
|
+
new GetItemCommand({
|
|
1226
|
+
TableName: "FoobarTable",
|
|
1227
|
+
Key: { orderId: { S: "order-1" } },
|
|
1228
|
+
ProjectionExpression: "#s, address.city, lines[0]",
|
|
1229
|
+
ExpressionAttributeNames: { "#s": "status" },
|
|
1230
|
+
}),
|
|
1231
|
+
);
|
|
1232
|
+
|
|
1233
|
+
console.log(Object.keys(output.Item ?? {}));
|
|
1234
|
+
// [ "status", "address", "lines" ]
|
|
1235
|
+
|
|
1236
|
+
// The nested shape is kept: the address holds only the projected attribute.
|
|
1237
|
+
console.log(output.Item?.["address"]?.M);
|
|
1238
|
+
// { city: { S: "Leeds" } }
|
|
1239
|
+
|
|
1240
|
+
// A projected list element comes back as a one element list.
|
|
1241
|
+
console.log(output.Item?.["lines"]?.L?.length);
|
|
1242
|
+
// 1
|
|
1243
|
+
```
|
|
1244
|
+
|
|
1245
|
+
A projected path the item lacks is left out. That is allowed, and it never comes back as a `NULL`.
|
|
1246
|
+
An item with none of the projected paths answers with an `Item` holding nothing.
|
|
1247
|
+
|
|
1248
|
+
The placeholders and the expression have to agree exactly, in both directions. A `#name` the request
|
|
1249
|
+
leaves undefined is a `ValidationException`, and so is an `ExpressionAttributeNames` entry no
|
|
1250
|
+
expression uses. The second is what a request hits after an expression is edited and the old
|
|
1251
|
+
placeholder is left behind.
|
|
1252
|
+
|
|
1253
|
+
Two paths where one contains the other, such as `address, address.city`, are a
|
|
1254
|
+
`ValidationException`, as they are on real AWS. The pair leaves it open whether the whole map or one
|
|
1255
|
+
attribute of it was wanted. Naming one path twice counts the same way.
|
|
1256
|
+
|
|
1257
|
+
A document path goes at most 32 levels deep, as far as an item nests. A negative index, a fractional
|
|
1258
|
+
index and a path past that depth are each a `ValidationException` naming the path.
|
|
1259
|
+
|
|
1260
|
+
## Querying an item collection
|
|
1261
|
+
|
|
1262
|
+
A table with a sort key holds an item collection under each partition key, holding the items with
|
|
1263
|
+
that partition key, ordered by their sort key. `Query` reads one of those collections.
|
|
1264
|
+
|
|
1265
|
+
`KeyConditionExpression` says which. It is one equality on the partition key, optionally joined by
|
|
1266
|
+
`AND` to one condition on the sort key. The sort key condition is `=`, `<`, `<=`, `>`, `>=`,
|
|
1267
|
+
`BETWEEN` or `begins_with`, and both bounds of a `BETWEEN` are inside the range.
|
|
1268
|
+
|
|
1269
|
+
```typescript sim-dynamodb-query
|
|
1270
|
+
/**
|
|
1271
|
+
* Reading a customer's orders back in sort key order.
|
|
1272
|
+
*/
|
|
1273
|
+
|
|
1274
|
+
import {
|
|
1275
|
+
CreateTableCommand,
|
|
1276
|
+
PutItemCommand,
|
|
1277
|
+
QueryCommand,
|
|
1278
|
+
} from "@aws-sdk/client-dynamodb";
|
|
1279
|
+
|
|
1280
|
+
import { SimAws } from "@kensio/yulin";
|
|
1281
|
+
|
|
1282
|
+
const simAws = new SimAws();
|
|
1283
|
+
const dynamoDb = simAws.dynamoDb();
|
|
1284
|
+
|
|
1285
|
+
await dynamoDb.createTable(
|
|
1286
|
+
new CreateTableCommand({
|
|
1287
|
+
TableName: "OrdersTable",
|
|
1288
|
+
KeySchema: [
|
|
1289
|
+
{ AttributeName: "customerId", KeyType: "HASH" },
|
|
1290
|
+
{ AttributeName: "orderId", KeyType: "RANGE" },
|
|
1291
|
+
],
|
|
1292
|
+
AttributeDefinitions: [
|
|
1293
|
+
{ AttributeName: "customerId", AttributeType: "S" },
|
|
1294
|
+
{ AttributeName: "orderId", AttributeType: "S" },
|
|
1295
|
+
],
|
|
1296
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
1297
|
+
}),
|
|
1298
|
+
);
|
|
1299
|
+
await simAws.backgroundTasksComplete();
|
|
1300
|
+
|
|
1301
|
+
for (const orderId of ["2026-03-01", "2026-01-14", "2027-01-02"]) {
|
|
1302
|
+
await dynamoDb.putItem(
|
|
1303
|
+
new PutItemCommand({
|
|
1304
|
+
TableName: "OrdersTable",
|
|
1305
|
+
Item: { customerId: { S: "c-1" }, orderId: { S: orderId } },
|
|
1306
|
+
}),
|
|
1307
|
+
);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
const page = await dynamoDb.query(
|
|
1311
|
+
new QueryCommand({
|
|
1312
|
+
TableName: "OrdersTable",
|
|
1313
|
+
KeyConditionExpression:
|
|
1314
|
+
"customerId = :customer AND begins_with(orderId, :prefix)",
|
|
1315
|
+
ExpressionAttributeValues: {
|
|
1316
|
+
":customer": { S: "c-1" },
|
|
1317
|
+
":prefix": { S: "2026-" },
|
|
1318
|
+
},
|
|
1319
|
+
}),
|
|
1320
|
+
);
|
|
1321
|
+
|
|
1322
|
+
console.log(page.Items?.map((item) => item["orderId"]?.S));
|
|
1323
|
+
// [ "2026-01-14", "2026-03-01" ]
|
|
1324
|
+
|
|
1325
|
+
console.log(page.Count); // 2
|
|
1326
|
+
console.log(page.ScannedCount); // 2
|
|
1327
|
+
|
|
1328
|
+
// ScanIndexForward reads the collection backwards.
|
|
1329
|
+
const newestFirst = await dynamoDb.query(
|
|
1330
|
+
new QueryCommand({
|
|
1331
|
+
TableName: "OrdersTable",
|
|
1332
|
+
KeyConditionExpression: "customerId = :customer",
|
|
1333
|
+
ExpressionAttributeValues: { ":customer": { S: "c-1" } },
|
|
1334
|
+
ScanIndexForward: false,
|
|
1335
|
+
}),
|
|
1336
|
+
);
|
|
1337
|
+
|
|
1338
|
+
console.log(newestFirst.Items?.map((item) => item["orderId"]?.S));
|
|
1339
|
+
// [ "2027-01-02", "2026-03-01", "2026-01-14" ]
|
|
1340
|
+
```
|
|
1341
|
+
|
|
1342
|
+
The order is DynamoDB's and not JavaScript's. A String sort key orders by its UTF-8 bytes, a Binary
|
|
1343
|
+
one as unsigned bytes, and a Number one by value however it was written, so `1E2` and `100` are one
|
|
1344
|
+
key and not two, and `9` sorts below `20`.
|
|
1345
|
+
|
|
1346
|
+
`begins_with` reads a prefix of a String or Binary sort key. Against a Number sort key it is a
|
|
1347
|
+
`ValidationException`, as it is on AWS. A number is stored as a value, never as the digits it was
|
|
1348
|
+
written with. It has no prefix.
|
|
1349
|
+
|
|
1350
|
+
A query on a table with no sort key is allowed, and reads the one item under the partition key.
|
|
1351
|
+
|
|
1352
|
+
### What a key condition can say
|
|
1353
|
+
|
|
1354
|
+
The grammar is closed, and deliberately narrower than a `ConditionExpression`. Each of these is a
|
|
1355
|
+
`ValidationException` naming what was wrong:
|
|
1356
|
+
|
|
1357
|
+
- a key condition that leaves the partition key untested for equality
|
|
1358
|
+
- a range operator or `begins_with` applied to the partition key
|
|
1359
|
+
- an operator or function a sort key condition refuses, such as `<>` or `contains`
|
|
1360
|
+
- an attribute outside the table's primary key
|
|
1361
|
+
- `OR` or `NOT` anywhere
|
|
1362
|
+
- the same key attribute tested twice
|
|
1363
|
+
- a `BETWEEN` whose upper bound is below its lower bound, or whose bounds are different types
|
|
1364
|
+
- a value written into the expression, where `ExpressionAttributeValues` should supply it
|
|
1365
|
+
- a value whose type differs from the one the table declared for that key attribute, such as
|
|
1366
|
+
comparing an `S` sort key against an `N`. A key attribute has one type, and the condition could
|
|
1367
|
+
never hold, and an empty page would read as a collection that happens to hold nothing.
|
|
1368
|
+
|
|
1369
|
+
An attribute name that is a DynamoDB reserved word is written as a `#name` placeholder and defined
|
|
1370
|
+
in `ExpressionAttributeNames`, as in any other expression.
|
|
1371
|
+
|
|
1372
|
+
### Paging a collection
|
|
1373
|
+
|
|
1374
|
+
`Limit` counts the items a query evaluated. `LastEvaluatedKey` is the primary key of the item the
|
|
1375
|
+
walk stopped on, and the next request passes it back as `ExclusiveStartKey` to resume after it.
|
|
1376
|
+
|
|
1377
|
+
```typescript sim-dynamodb-query-paging
|
|
1378
|
+
/**
|
|
1379
|
+
* Paging through an item collection until the token runs out.
|
|
1380
|
+
*/
|
|
1381
|
+
|
|
1382
|
+
import {
|
|
1383
|
+
CreateTableCommand,
|
|
1384
|
+
PutItemCommand,
|
|
1385
|
+
QueryCommand,
|
|
1386
|
+
} from "@aws-sdk/client-dynamodb";
|
|
1387
|
+
import type { AttributeValue } from "@aws-sdk/client-dynamodb";
|
|
1388
|
+
|
|
1389
|
+
import { SimAws } from "@kensio/yulin";
|
|
1390
|
+
|
|
1391
|
+
const simAws = new SimAws();
|
|
1392
|
+
const dynamoDb = simAws.dynamoDb();
|
|
1393
|
+
|
|
1394
|
+
await dynamoDb.createTable(
|
|
1395
|
+
new CreateTableCommand({
|
|
1396
|
+
TableName: "EventsTable",
|
|
1397
|
+
KeySchema: [
|
|
1398
|
+
{ AttributeName: "streamId", KeyType: "HASH" },
|
|
1399
|
+
{ AttributeName: "eventId", KeyType: "RANGE" },
|
|
1400
|
+
],
|
|
1401
|
+
AttributeDefinitions: [
|
|
1402
|
+
{ AttributeName: "streamId", AttributeType: "S" },
|
|
1403
|
+
{ AttributeName: "eventId", AttributeType: "N" },
|
|
1404
|
+
],
|
|
1405
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
1406
|
+
}),
|
|
1407
|
+
);
|
|
1408
|
+
await simAws.backgroundTasksComplete();
|
|
1409
|
+
|
|
1410
|
+
for (const eventId of ["1", "2", "3"]) {
|
|
1411
|
+
await dynamoDb.putItem(
|
|
1412
|
+
new PutItemCommand({
|
|
1413
|
+
TableName: "EventsTable",
|
|
1414
|
+
Item: { streamId: { S: "stream-1" }, eventId: { N: eventId } },
|
|
1415
|
+
}),
|
|
1416
|
+
);
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
const read: string[] = [];
|
|
1420
|
+
let exclusiveStartKey: Record<string, AttributeValue> | undefined;
|
|
1421
|
+
|
|
1422
|
+
do {
|
|
1423
|
+
const page = await dynamoDb.query(
|
|
1424
|
+
new QueryCommand({
|
|
1425
|
+
TableName: "EventsTable",
|
|
1426
|
+
KeyConditionExpression: "streamId = :stream",
|
|
1427
|
+
ExpressionAttributeValues: { ":stream": { S: "stream-1" } },
|
|
1428
|
+
Limit: 2,
|
|
1429
|
+
ExclusiveStartKey: exclusiveStartKey,
|
|
1430
|
+
}),
|
|
1431
|
+
);
|
|
1432
|
+
|
|
1433
|
+
read.push(...(page.Items ?? []).map((item) => item["eventId"]?.N ?? ""));
|
|
1434
|
+
exclusiveStartKey = page.LastEvaluatedKey;
|
|
1435
|
+
} while (exclusiveStartKey !== undefined);
|
|
1436
|
+
|
|
1437
|
+
console.log(read); // [ "1", "2", "3" ]
|
|
1438
|
+
```
|
|
1439
|
+
|
|
1440
|
+
`LastEvaluatedKey` is left out only when the key range ran out inside the `Limit`. Reaching the
|
|
1441
|
+
limit on the last matching item still hands out a token, and the next call answers with an empty
|
|
1442
|
+
page and no token. That is what real DynamoDB does, since it cannot know the range is exhausted
|
|
1443
|
+
without looking past it. A loop like the one above is the way to read a whole collection.
|
|
1444
|
+
|
|
1445
|
+
A token still works when the item it names has since been deleted. It says where to resume. A token
|
|
1446
|
+
from a different partition key is refused, since it names a collection this query goes unreading.
|
|
1447
|
+
|
|
1448
|
+
## Reading a global secondary index
|
|
1449
|
+
|
|
1450
|
+
`IndexName` on `Query` and `Scan` reads an index in place of the table. The key condition is held to
|
|
1451
|
+
the index key schema and not the table's, which is the point. The index is how an access pattern the
|
|
1452
|
+
table key cannot serve gets served.
|
|
1453
|
+
|
|
1454
|
+
```typescript sim-dynamodb-query-index
|
|
1455
|
+
/**
|
|
1456
|
+
* Querying a global secondary index.
|
|
1457
|
+
*/
|
|
1458
|
+
|
|
1459
|
+
import {
|
|
1460
|
+
CreateTableCommand,
|
|
1461
|
+
PutItemCommand,
|
|
1462
|
+
QueryCommand,
|
|
1463
|
+
} from "@aws-sdk/client-dynamodb";
|
|
1464
|
+
|
|
1465
|
+
import { SimAws } from "@kensio/yulin";
|
|
1466
|
+
|
|
1467
|
+
const simAws = new SimAws();
|
|
1468
|
+
const dynamoDb = simAws.dynamoDb();
|
|
1469
|
+
|
|
1470
|
+
await dynamoDb.createTable(
|
|
1471
|
+
new CreateTableCommand({
|
|
1472
|
+
TableName: "OrdersTable",
|
|
1473
|
+
KeySchema: [{ AttributeName: "orderId", KeyType: "HASH" }],
|
|
1474
|
+
AttributeDefinitions: [
|
|
1475
|
+
{ AttributeName: "orderId", AttributeType: "S" },
|
|
1476
|
+
{ AttributeName: "status", AttributeType: "S" },
|
|
1477
|
+
],
|
|
1478
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
1479
|
+
GlobalSecondaryIndexes: [
|
|
1480
|
+
{
|
|
1481
|
+
IndexName: "byStatus",
|
|
1482
|
+
KeySchema: [{ AttributeName: "status", KeyType: "HASH" }],
|
|
1483
|
+
Projection: { ProjectionType: "INCLUDE", NonKeyAttributes: ["total"] },
|
|
1484
|
+
},
|
|
1485
|
+
],
|
|
1486
|
+
}),
|
|
1487
|
+
);
|
|
1488
|
+
await simAws.backgroundTasksComplete();
|
|
1489
|
+
|
|
1490
|
+
await dynamoDb.putItem(
|
|
1491
|
+
new PutItemCommand({
|
|
1492
|
+
TableName: "OrdersTable",
|
|
1493
|
+
Item: {
|
|
1494
|
+
orderId: { S: "order-1" },
|
|
1495
|
+
status: { S: "OPEN" },
|
|
1496
|
+
total: { N: "42" },
|
|
1497
|
+
note: { S: "Gift wrap" },
|
|
1498
|
+
},
|
|
1499
|
+
}),
|
|
1500
|
+
);
|
|
1501
|
+
|
|
1502
|
+
// A draft carries no status, so the index does not hold it.
|
|
1503
|
+
await dynamoDb.putItem(
|
|
1504
|
+
new PutItemCommand({
|
|
1505
|
+
TableName: "OrdersTable",
|
|
1506
|
+
Item: { orderId: { S: "order-2" }, total: { N: "7" } },
|
|
1507
|
+
}),
|
|
1508
|
+
);
|
|
1509
|
+
|
|
1510
|
+
const open = await dynamoDb.query(
|
|
1511
|
+
new QueryCommand({
|
|
1512
|
+
TableName: "OrdersTable",
|
|
1513
|
+
IndexName: "byStatus",
|
|
1514
|
+
KeyConditionExpression: "#status = :status",
|
|
1515
|
+
ExpressionAttributeNames: { "#status": "status" },
|
|
1516
|
+
ExpressionAttributeValues: { ":status": { S: "OPEN" } },
|
|
1517
|
+
}),
|
|
1518
|
+
);
|
|
1519
|
+
|
|
1520
|
+
console.log(open.Count); // 1
|
|
1521
|
+
console.log(open.Items?.[0]?.["orderId"]?.S); // "order-1"
|
|
1522
|
+
console.log(open.Items?.[0]?.["total"]?.N); // "42"
|
|
1523
|
+
|
|
1524
|
+
// `note` is not projected, so it is not on the item the index answers with.
|
|
1525
|
+
console.log(open.Items?.[0]?.["note"]); // undefined
|
|
1526
|
+
```
|
|
1527
|
+
|
|
1528
|
+
The index is sparse. An item missing any of the index key attributes stays out of the index. A read
|
|
1529
|
+
of it simply misses that item. The write itself said so at no point.
|
|
1530
|
+
|
|
1531
|
+
An index key can repeat, so several items can share one. Items sharing an index key come back in no
|
|
1532
|
+
particular order, and `LastEvaluatedKey` carries the index key attributes together with the table
|
|
1533
|
+
key attributes, and the two together name one of them exactly enough to resume after. An
|
|
1534
|
+
`ExclusiveStartKey` carrying only part of that is refused.
|
|
1535
|
+
|
|
1536
|
+
A read answers with the attributes the index projects, so `Select` defaults to
|
|
1537
|
+
`ALL_PROJECTED_ATTRIBUTES` in place of `ALL_ATTRIBUTES`. Asking for more than the index carries is
|
|
1538
|
+
refused outright:
|
|
1539
|
+
|
|
1540
|
+
- `Select: ALL_ATTRIBUTES` against an index whose projection falls short of `ALL` is a
|
|
1541
|
+
`ValidationException`.
|
|
1542
|
+
- A `FilterExpression` naming an attribute the index omits is refused too. The attribute is absent
|
|
1543
|
+
from the items the index holds, and the filter would drop all of them and the empty page would
|
|
1544
|
+
read as a collection that happens to hold nothing.
|
|
1545
|
+
|
|
1546
|
+
An `IndexName` the table lacks gives `ResourceNotFoundException`. `ConsistentRead: true` against a
|
|
1547
|
+
global secondary index is a `ValidationException`, because a global secondary index is maintained
|
|
1548
|
+
asynchronously on AWS and cannot answer a strongly consistent read at all.
|
|
1549
|
+
|
|
1550
|
+
`Scan` takes `IndexName` the same way, including in parallel segments, which divide by the index
|
|
1551
|
+
partition key rather than the table's.
|
|
1552
|
+
|
|
1553
|
+
## Reading a local secondary index
|
|
1554
|
+
|
|
1555
|
+
`IndexName` reaches a local secondary index the same way, and the walk is the same walk. The index
|
|
1556
|
+
is sparse, the key condition is held to the index key schema, and `Scan` takes the index too. Two
|
|
1557
|
+
things differ, and both follow from the index sitting in the same partition as the item it indexes.
|
|
1558
|
+
|
|
1559
|
+
`ConsistentRead: true` is answered here. The index is written with the item, in the same partition.
|
|
1560
|
+
There is no window in which it lags behind the table.
|
|
1561
|
+
|
|
1562
|
+
An attribute the index omits is fetched from the base table, and never refused. So `Select:
|
|
1563
|
+
ALL_ATTRIBUTES` against a `KEYS_ONLY` index answers with whole items, and a `FilterExpression` may
|
|
1564
|
+
name any attribute of the item, and never only a projected one. Real DynamoDB charges the extra read
|
|
1565
|
+
capacity for that fetch. A read that asks for one of those anyway still answers with what the index
|
|
1566
|
+
projects, since `Select` defaults to `ALL_PROJECTED_ATTRIBUTES` on any index.
|
|
1567
|
+
|
|
1568
|
+
`LastEvaluatedKey` carries three attributes, which are the table partition key, the index sort key
|
|
1569
|
+
and the table sort key. Two entries can share a whole index key. The table sort key is what names
|
|
1570
|
+
one of them exactly enough to resume after. An `ExclusiveStartKey` missing any of the three is
|
|
1571
|
+
refused.
|
|
1572
|
+
|
|
1573
|
+
## Scanning a table
|
|
1574
|
+
|
|
1575
|
+
`Scan` reads every item in a table. It needs no key knowledge at all. That is what makes it the
|
|
1576
|
+
operation test setup and assertions reach for, and the wrong operation for most application access
|
|
1577
|
+
patterns, since it reads the whole table however few items the caller wanted.
|
|
1578
|
+
|
|
1579
|
+
```typescript sim-dynamodb-scan
|
|
1580
|
+
/**
|
|
1581
|
+
* Reading a whole table back, whatever partition keys it holds.
|
|
1582
|
+
*/
|
|
1583
|
+
|
|
1584
|
+
import {
|
|
1585
|
+
CreateTableCommand,
|
|
1586
|
+
PutItemCommand,
|
|
1587
|
+
ScanCommand,
|
|
1588
|
+
} from "@aws-sdk/client-dynamodb";
|
|
1589
|
+
|
|
1590
|
+
import { SimAws } from "@kensio/yulin";
|
|
1591
|
+
|
|
1592
|
+
const simAws = new SimAws();
|
|
1593
|
+
const dynamoDb = simAws.dynamoDb();
|
|
1594
|
+
|
|
1595
|
+
await dynamoDb.createTable(
|
|
1596
|
+
new CreateTableCommand({
|
|
1597
|
+
TableName: "OrdersTable",
|
|
1598
|
+
KeySchema: [
|
|
1599
|
+
{ AttributeName: "customerId", KeyType: "HASH" },
|
|
1600
|
+
{ AttributeName: "orderId", KeyType: "RANGE" },
|
|
1601
|
+
],
|
|
1602
|
+
AttributeDefinitions: [
|
|
1603
|
+
{ AttributeName: "customerId", AttributeType: "S" },
|
|
1604
|
+
{ AttributeName: "orderId", AttributeType: "S" },
|
|
1605
|
+
],
|
|
1606
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
1607
|
+
}),
|
|
1608
|
+
);
|
|
1609
|
+
await simAws.backgroundTasksComplete();
|
|
1610
|
+
|
|
1611
|
+
const written = [
|
|
1612
|
+
{ customerId: "c-1", orderId: "2026-03" },
|
|
1613
|
+
{ customerId: "c-1", orderId: "2026-01" },
|
|
1614
|
+
{ customerId: "c-1", orderId: "2026-02" },
|
|
1615
|
+
{ customerId: "c-2", orderId: "2026-04" },
|
|
1616
|
+
{ customerId: "c-3", orderId: "2026-05" },
|
|
1617
|
+
];
|
|
1618
|
+
|
|
1619
|
+
for (const order of written) {
|
|
1620
|
+
await dynamoDb.putItem(
|
|
1621
|
+
new PutItemCommand({
|
|
1622
|
+
TableName: "OrdersTable",
|
|
1623
|
+
Item: {
|
|
1624
|
+
customerId: { S: order.customerId },
|
|
1625
|
+
orderId: { S: order.orderId },
|
|
1626
|
+
},
|
|
1627
|
+
}),
|
|
1628
|
+
);
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
const page = await dynamoDb.scan(new ScanCommand({ TableName: "OrdersTable" }));
|
|
1632
|
+
|
|
1633
|
+
console.log(page.Count); // 5
|
|
1634
|
+
console.log(page.ScannedCount); // 5
|
|
1635
|
+
|
|
1636
|
+
// The items under one partition key come back together, ascending by sort key.
|
|
1637
|
+
console.log(
|
|
1638
|
+
page.Items?.filter((item) => item["customerId"]?.S === "c-1").map(
|
|
1639
|
+
(item) => item["orderId"]?.S,
|
|
1640
|
+
),
|
|
1641
|
+
);
|
|
1642
|
+
// [ "2026-01", "2026-02", "2026-03" ]
|
|
1643
|
+
```
|
|
1644
|
+
|
|
1645
|
+
The partition key values themselves come back in an arbitrary order. It is neither the sorted order
|
|
1646
|
+
nor the order the items were written in. Real DynamoDB walks a table by the hash of the partition
|
|
1647
|
+
key, and a scan that came back globally sorted would be something no real table gives you, and a
|
|
1648
|
+
test leaning on one would pass here and fail against the service.
|
|
1649
|
+
|
|
1650
|
+
The order is arbitrary but fixed. Two scans of an unchanged table read it the same way, and that is
|
|
1651
|
+
what lets a token resume one.
|
|
1652
|
+
|
|
1653
|
+
`Limit`, `LastEvaluatedKey` and `ExclusiveStartKey` page a scan the way they page a query, and the
|
|
1654
|
+
loop is the same one. `ConsistentRead` is accepted and changes nothing, since every simulated read
|
|
1655
|
+
is already the strongly consistent one.
|
|
1656
|
+
|
|
1657
|
+
### Scanning in parallel
|
|
1658
|
+
|
|
1659
|
+
`Segment` and `TotalSegments` divide a table between workers. `TotalSegments` is how many shares the
|
|
1660
|
+
table is divided into, and `Segment` is the zero based number of the share this request reads.
|
|
1661
|
+
|
|
1662
|
+
```typescript sim-dynamodb-parallel-scan
|
|
1663
|
+
/**
|
|
1664
|
+
* Reading a table in four segments.
|
|
1665
|
+
*/
|
|
1666
|
+
|
|
1667
|
+
import {
|
|
1668
|
+
CreateTableCommand,
|
|
1669
|
+
PutItemCommand,
|
|
1670
|
+
ScanCommand,
|
|
1671
|
+
} from "@aws-sdk/client-dynamodb";
|
|
1672
|
+
|
|
1673
|
+
import { SimAws } from "@kensio/yulin";
|
|
1674
|
+
|
|
1675
|
+
const simAws = new SimAws();
|
|
1676
|
+
const dynamoDb = simAws.dynamoDb();
|
|
1677
|
+
|
|
1678
|
+
await dynamoDb.createTable(
|
|
1679
|
+
new CreateTableCommand({
|
|
1680
|
+
TableName: "OrdersTable",
|
|
1681
|
+
KeySchema: [
|
|
1682
|
+
{ AttributeName: "customerId", KeyType: "HASH" },
|
|
1683
|
+
{ AttributeName: "orderId", KeyType: "RANGE" },
|
|
1684
|
+
],
|
|
1685
|
+
AttributeDefinitions: [
|
|
1686
|
+
{ AttributeName: "customerId", AttributeType: "S" },
|
|
1687
|
+
{ AttributeName: "orderId", AttributeType: "S" },
|
|
1688
|
+
],
|
|
1689
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
1690
|
+
}),
|
|
1691
|
+
);
|
|
1692
|
+
await simAws.backgroundTasksComplete();
|
|
1693
|
+
|
|
1694
|
+
for (const customerId of ["c-1", "c-2", "c-3", "c-4"]) {
|
|
1695
|
+
for (const orderId of ["2026-01", "2026-02"]) {
|
|
1696
|
+
await dynamoDb.putItem(
|
|
1697
|
+
new PutItemCommand({
|
|
1698
|
+
TableName: "OrdersTable",
|
|
1699
|
+
Item: { customerId: { S: customerId }, orderId: { S: orderId } },
|
|
1700
|
+
}),
|
|
1701
|
+
);
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
const totalSegments = 4;
|
|
1706
|
+
|
|
1707
|
+
// Which segment each of a customer's orders came back in.
|
|
1708
|
+
const segmentsByCustomer = new Map<string, number[]>();
|
|
1709
|
+
|
|
1710
|
+
for (let segment = 0; segment < totalSegments; segment++) {
|
|
1711
|
+
const segmentPage = await dynamoDb.scan(
|
|
1712
|
+
new ScanCommand({
|
|
1713
|
+
TableName: "OrdersTable",
|
|
1714
|
+
Segment: segment,
|
|
1715
|
+
TotalSegments: totalSegments,
|
|
1716
|
+
}),
|
|
1717
|
+
);
|
|
1718
|
+
|
|
1719
|
+
const items = segmentPage.Items ?? [];
|
|
1720
|
+
|
|
1721
|
+
for (const item of items) {
|
|
1722
|
+
const customerId = item["customerId"]?.S ?? "";
|
|
1723
|
+
const segments = segmentsByCustomer.get(customerId) ?? [];
|
|
1724
|
+
|
|
1725
|
+
segmentsByCustomer.set(customerId, [...segments, segment]);
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
// The segments together are the whole table, with nothing read twice.
|
|
1730
|
+
console.log(segmentsByCustomer.values().toArray().flat().length); // 8
|
|
1731
|
+
console.log(segmentsByCustomer.size); // 4
|
|
1732
|
+
|
|
1733
|
+
// And each customer's two orders came back in one segment rather than split
|
|
1734
|
+
// between two.
|
|
1735
|
+
console.log(
|
|
1736
|
+
segmentsByCustomer
|
|
1737
|
+
.values()
|
|
1738
|
+
.map((segments) => new Set(segments).size)
|
|
1739
|
+
.toArray(),
|
|
1740
|
+
);
|
|
1741
|
+
// [ 1, 1, 1, 1 ]
|
|
1742
|
+
```
|
|
1743
|
+
|
|
1744
|
+
An item belongs to a segment by its partition key value, so every item of one item collection lands
|
|
1745
|
+
in the same segment. That is what makes a segment a share of the table's partition keys rather than
|
|
1746
|
+
a share of its items, and it is why segments come out uneven. A segment holding nothing is ordinary,
|
|
1747
|
+
and dividing a table into more segments than it has partition key values leaves most of them empty.
|
|
1748
|
+
|
|
1749
|
+
There is no speed to gain here, since a simulated scan walks a map in memory. What a parallel scan
|
|
1750
|
+
gives a test is the caller's side of one. Code that divides a table between workers can be run
|
|
1751
|
+
without a real table.
|
|
1752
|
+
|
|
1753
|
+
Each segment pages on its own. `Limit` and `LastEvaluatedKey` work per segment, and the next request
|
|
1754
|
+
passes that segment's token back with the same `Segment` and `TotalSegments`. A token from another
|
|
1755
|
+
segment is refused, since it names a place that segment's walk never reaches.
|
|
1756
|
+
|
|
1757
|
+
These are the rules a request is held to, each a `ValidationException`:
|
|
1758
|
+
|
|
1759
|
+
- `Segment` without `TotalSegments`, or `TotalSegments` without `Segment`. They are supplied
|
|
1760
|
+
together or not at all, and a request naming both as absent reads the whole table.
|
|
1761
|
+
- a `Segment` at or above `TotalSegments`, or below zero. It is zero based. The last segment of four
|
|
1762
|
+
is `3`.
|
|
1763
|
+
- a `TotalSegments` outside 1 to 1000000. A `TotalSegments` of 1 is a sequential scan.
|
|
1764
|
+
- an `ExclusiveStartKey` belonging to another segment.
|
|
1765
|
+
|
|
1766
|
+
`Segment` and `TotalSegments` are refused on `Query`, since it is a single-collection operation and
|
|
1767
|
+
never had them. A query reads one item collection, which sits under one partition key and so inside
|
|
1768
|
+
one segment.
|
|
1769
|
+
|
|
1770
|
+
## Filtering a read
|
|
1771
|
+
|
|
1772
|
+
`FilterExpression` drops items a `Query` or a `Scan` read. It is the same grammar a
|
|
1773
|
+
[conditional write](#conditional-writes) is guarded by, evaluated against each item the read
|
|
1774
|
+
reached.
|
|
1775
|
+
|
|
1776
|
+
It runs after the read, and that order is what the counts report. The walk is cut at the `Limit`
|
|
1777
|
+
first, and the filter then drops items from the page that came back. `ScannedCount` is how many
|
|
1778
|
+
items the read evaluated, and `Count` how many of those survived.
|
|
1779
|
+
|
|
1780
|
+
```typescript sim-dynamodb-query-filter
|
|
1781
|
+
/**
|
|
1782
|
+
* Reading a customer's open orders, and counting what that cost.
|
|
1783
|
+
*/
|
|
1784
|
+
|
|
1785
|
+
import {
|
|
1786
|
+
CreateTableCommand,
|
|
1787
|
+
PutItemCommand,
|
|
1788
|
+
QueryCommand,
|
|
1789
|
+
} from "@aws-sdk/client-dynamodb";
|
|
1790
|
+
|
|
1791
|
+
import { SimAws } from "@kensio/yulin";
|
|
1792
|
+
|
|
1793
|
+
const simAws = new SimAws();
|
|
1794
|
+
const dynamoDb = simAws.dynamoDb();
|
|
1795
|
+
|
|
1796
|
+
await dynamoDb.createTable(
|
|
1797
|
+
new CreateTableCommand({
|
|
1798
|
+
TableName: "OrdersTable",
|
|
1799
|
+
KeySchema: [
|
|
1800
|
+
{ AttributeName: "customerId", KeyType: "HASH" },
|
|
1801
|
+
{ AttributeName: "orderId", KeyType: "RANGE" },
|
|
1802
|
+
],
|
|
1803
|
+
AttributeDefinitions: [
|
|
1804
|
+
{ AttributeName: "customerId", AttributeType: "S" },
|
|
1805
|
+
{ AttributeName: "orderId", AttributeType: "S" },
|
|
1806
|
+
],
|
|
1807
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
1808
|
+
}),
|
|
1809
|
+
);
|
|
1810
|
+
await simAws.backgroundTasksComplete();
|
|
1811
|
+
|
|
1812
|
+
const orders = [
|
|
1813
|
+
{ orderId: "2026-01", status: "OPEN" },
|
|
1814
|
+
{ orderId: "2026-02", status: "SHIPPED" },
|
|
1815
|
+
{ orderId: "2026-03", status: "OPEN" },
|
|
1816
|
+
{ orderId: "2026-04", status: "SHIPPED" },
|
|
1817
|
+
];
|
|
1818
|
+
|
|
1819
|
+
for (const order of orders) {
|
|
1820
|
+
await dynamoDb.putItem(
|
|
1821
|
+
new PutItemCommand({
|
|
1822
|
+
TableName: "OrdersTable",
|
|
1823
|
+
Item: {
|
|
1824
|
+
customerId: { S: "c-1" },
|
|
1825
|
+
orderId: { S: order.orderId },
|
|
1826
|
+
status: { S: order.status },
|
|
1827
|
+
},
|
|
1828
|
+
}),
|
|
1829
|
+
);
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
const page = await dynamoDb.query(
|
|
1833
|
+
new QueryCommand({
|
|
1834
|
+
TableName: "OrdersTable",
|
|
1835
|
+
KeyConditionExpression: "customerId = :customer",
|
|
1836
|
+
FilterExpression: "#status = :open",
|
|
1837
|
+
ExpressionAttributeNames: { "#status": "status" },
|
|
1838
|
+
ExpressionAttributeValues: {
|
|
1839
|
+
":customer": { S: "c-1" },
|
|
1840
|
+
":open": { S: "OPEN" },
|
|
1841
|
+
},
|
|
1842
|
+
Limit: 3,
|
|
1843
|
+
}),
|
|
1844
|
+
);
|
|
1845
|
+
|
|
1846
|
+
console.log(page.Items?.map((item) => item["orderId"]?.S));
|
|
1847
|
+
// [ "2026-01", "2026-03" ]
|
|
1848
|
+
|
|
1849
|
+
// Three items were read, and two of them survived the filter.
|
|
1850
|
+
console.log(page.ScannedCount); // 3
|
|
1851
|
+
console.log(page.Count); // 2
|
|
1852
|
+
|
|
1853
|
+
// There is more to read, even though the page came back shorter than the Limit.
|
|
1854
|
+
console.log(page.LastEvaluatedKey?.["orderId"]?.S); // "2026-03"
|
|
1855
|
+
|
|
1856
|
+
// Select COUNT counts the same read and answers with no items at all.
|
|
1857
|
+
const counted = await dynamoDb.query(
|
|
1858
|
+
new QueryCommand({
|
|
1859
|
+
TableName: "OrdersTable",
|
|
1860
|
+
KeyConditionExpression: "customerId = :customer",
|
|
1861
|
+
FilterExpression: "#status = :open",
|
|
1862
|
+
ExpressionAttributeNames: { "#status": "status" },
|
|
1863
|
+
ExpressionAttributeValues: {
|
|
1864
|
+
":customer": { S: "c-1" },
|
|
1865
|
+
":open": { S: "OPEN" },
|
|
1866
|
+
},
|
|
1867
|
+
Select: "COUNT",
|
|
1868
|
+
}),
|
|
1869
|
+
);
|
|
1870
|
+
|
|
1871
|
+
console.log(counted.Count); // 2
|
|
1872
|
+
console.log(counted.ScannedCount); // 4
|
|
1873
|
+
console.log(counted.Items); // undefined
|
|
1874
|
+
```
|
|
1875
|
+
|
|
1876
|
+
A filter saves no capacity. Every item it drops was read. A filtered query is charged for what it
|
|
1877
|
+
threw away on AWS.
|
|
1878
|
+
|
|
1879
|
+
A `Count` below the `Limit` therefore leaves it open whether the collection is exhausted. A page can
|
|
1880
|
+
even come back with no items at all and a `LastEvaluatedKey`, when the filter dropped every item on
|
|
1881
|
+
it. Loop until the token is gone. A short or empty page proves nothing on its own.
|
|
1882
|
+
|
|
1883
|
+
An item that lacks what the filter points at fails it. `status = :open` drops an item with no
|
|
1884
|
+
`status`, the same way a condition on a write fails to hold for an attribute that is absent.
|
|
1885
|
+
|
|
1886
|
+
### What a filter may name
|
|
1887
|
+
|
|
1888
|
+
A `Query` filter may not name a key attribute, and a `Scan` filter may name any attribute at all. A
|
|
1889
|
+
query has already narrowed the read by its `KeyConditionExpression`, and a filter on the partition
|
|
1890
|
+
key or the sort key is either that condition written twice or a condition the key condition should
|
|
1891
|
+
have carried. Real DynamoDB refuses it as a `ValidationException`, and so does this. A scan narrows
|
|
1892
|
+
no read. There a key attribute is an attribute like any other.
|
|
1893
|
+
|
|
1894
|
+
The rule is about where a path starts, so `details.customerId` is allowed on a query with a
|
|
1895
|
+
`customerId` partition key. It names an attribute of a map and not the key. Writing the key
|
|
1896
|
+
attribute as an `ExpressionAttributeNames` placeholder gets past it no more easily.
|
|
1897
|
+
|
|
1898
|
+
The key condition and the filter share one set of placeholders. A `#name` or `:value` either of them
|
|
1899
|
+
uses counts as used, and one that goes unused by both is refused the way an unused placeholder
|
|
1900
|
+
always is.
|
|
1901
|
+
|
|
1902
|
+
### Counting and projecting with Select
|
|
1903
|
+
|
|
1904
|
+
`Select` says which attributes a read answers with. A table read defaults to `ALL_ATTRIBUTES`,
|
|
1905
|
+
meaning whole items.
|
|
1906
|
+
|
|
1907
|
+
`COUNT` answers with `Count` and `ScannedCount` and no `Items` at all, as at the end of the example
|
|
1908
|
+
above. It reads and filters the same items, and leaves them out of the response. `Limit` and
|
|
1909
|
+
`LastEvaluatedKey` page a counted read the same way.
|
|
1910
|
+
|
|
1911
|
+
The other two values are held to the rules AWS holds them to, each a `ValidationException`:
|
|
1912
|
+
|
|
1913
|
+
- `SPECIFIC_ATTRIBUTES` needs a `ProjectionExpression` to name what to answer with.
|
|
1914
|
+
- a `ProjectionExpression` alongside any `Select` other than `SPECIFIC_ATTRIBUTES`. That one is the
|
|
1915
|
+
`Select` that projects. Writing a `ProjectionExpression` and no `Select` at all is fine.
|
|
1916
|
+
- `ALL_PROJECTED_ATTRIBUTES` without an `IndexName`. It asks for the attributes an index projects,
|
|
1917
|
+
and a table read has no index to project from.
|
|
1918
|
+
|
|
1919
|
+
Projecting a `Query` or a `Scan` is absent, so `SPECIFIC_ATTRIBUTES` gets as far as the
|
|
1920
|
+
`ProjectionExpression` refusal rather than being accepted with no attribute to project.
|
|
1921
|
+
|
|
1922
|
+
## Reading and writing items in batches
|
|
1923
|
+
|
|
1924
|
+
`BatchWriteItem` puts and deletes items across tables in one call, and `BatchGetItem` reads them by
|
|
1925
|
+
primary key. Both take `RequestItems`, a map of table name or ARN to what that table is asked for.
|
|
1926
|
+
|
|
1927
|
+
A batch write asks each table for a list of write requests, each carrying exactly one `PutRequest`
|
|
1928
|
+
or `DeleteRequest`.
|
|
1929
|
+
|
|
1930
|
+
```typescript sim-dynamodb-batch-write-item
|
|
1931
|
+
/**
|
|
1932
|
+
* Writing and deleting items across two tables in one call.
|
|
1933
|
+
*/
|
|
1934
|
+
|
|
1935
|
+
import {
|
|
1936
|
+
BatchWriteItemCommand,
|
|
1937
|
+
CreateTableCommand,
|
|
1938
|
+
GetItemCommand,
|
|
1939
|
+
} from "@aws-sdk/client-dynamodb";
|
|
1940
|
+
|
|
1941
|
+
import { SimAws } from "@kensio/yulin";
|
|
1942
|
+
|
|
1943
|
+
const simAws = new SimAws();
|
|
1944
|
+
const dynamoDb = simAws.dynamoDb();
|
|
1945
|
+
|
|
1946
|
+
await dynamoDb.createTable(
|
|
1947
|
+
new CreateTableCommand({
|
|
1948
|
+
TableName: "OrdersTable",
|
|
1949
|
+
KeySchema: [{ AttributeName: "orderId", KeyType: "HASH" }],
|
|
1950
|
+
AttributeDefinitions: [{ AttributeName: "orderId", AttributeType: "S" }],
|
|
1951
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
1952
|
+
}),
|
|
1953
|
+
);
|
|
1954
|
+
await dynamoDb.createTable(
|
|
1955
|
+
new CreateTableCommand({
|
|
1956
|
+
TableName: "CustomersTable",
|
|
1957
|
+
KeySchema: [{ AttributeName: "customerId", KeyType: "HASH" }],
|
|
1958
|
+
AttributeDefinitions: [{ AttributeName: "customerId", AttributeType: "S" }],
|
|
1959
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
1960
|
+
}),
|
|
1961
|
+
);
|
|
1962
|
+
await simAws.backgroundTasksComplete();
|
|
1963
|
+
|
|
1964
|
+
const written = await dynamoDb.batchWriteItem(
|
|
1965
|
+
new BatchWriteItemCommand({
|
|
1966
|
+
RequestItems: {
|
|
1967
|
+
OrdersTable: [
|
|
1968
|
+
{
|
|
1969
|
+
PutRequest: {
|
|
1970
|
+
Item: { orderId: { S: "order-1" }, total: { N: "19.99" } },
|
|
1971
|
+
},
|
|
1972
|
+
},
|
|
1973
|
+
{
|
|
1974
|
+
PutRequest: {
|
|
1975
|
+
Item: { orderId: { S: "order-2" }, total: { N: "24.99" } },
|
|
1976
|
+
},
|
|
1977
|
+
},
|
|
1978
|
+
{ DeleteRequest: { Key: { orderId: { S: "order-0" } } } },
|
|
1979
|
+
],
|
|
1980
|
+
CustomersTable: [
|
|
1981
|
+
{ PutRequest: { Item: { customerId: { S: "customer-1" } } } },
|
|
1982
|
+
],
|
|
1983
|
+
},
|
|
1984
|
+
}),
|
|
1985
|
+
);
|
|
1986
|
+
|
|
1987
|
+
// Nothing here is throttled, so nothing is ever left unprocessed.
|
|
1988
|
+
console.log(written.UnprocessedItems); // {}
|
|
1989
|
+
|
|
1990
|
+
const output = await dynamoDb.getItem(
|
|
1991
|
+
new GetItemCommand({
|
|
1992
|
+
TableName: "OrdersTable",
|
|
1993
|
+
Key: { orderId: { S: "order-2" } },
|
|
1994
|
+
}),
|
|
1995
|
+
);
|
|
1996
|
+
|
|
1997
|
+
console.log(output.Item?.["total"]?.N); // "24.99"
|
|
1998
|
+
```
|
|
1999
|
+
|
|
2000
|
+
A put replaces the whole item under its key, exactly as `PutItem` does, and a delete names a key, so
|
|
2001
|
+
deleting a key that is already free succeeds. Neither answers with the item it wrote over. A batch
|
|
2002
|
+
has no `ReturnValues`, and no `ConditionExpression` either. A conditional write is what `PutItem`,
|
|
2003
|
+
`DeleteItem` and `UpdateItem` are for.
|
|
2004
|
+
|
|
2005
|
+
Six things take the whole batch down rather than one entry of it, leaving no write behind:
|
|
2006
|
+
|
|
2007
|
+
- a table that is absent
|
|
2008
|
+
- key attributes that do not match the table's key schema
|
|
2009
|
+
- more than one operation on the same item of one table
|
|
2010
|
+
- one table named twice, once by its name and once by its ARN
|
|
2011
|
+
- more than 25 write requests, counted across every table the request names
|
|
2012
|
+
- an item over the 400 KB an item holds
|
|
2013
|
+
|
|
2014
|
+
Real DynamoDB also refuses a request over 16 MB. That one is absent, for the reason under
|
|
2015
|
+
Limitations.
|
|
2016
|
+
|
|
2017
|
+
The same key in two different tables is two items, never one. A batch may write both.
|
|
2018
|
+
|
|
2019
|
+
A batch read asks each table for `Keys`, and for how to read them. `ConsistentRead` and
|
|
2020
|
+
`ProjectionExpression` are settled per table rather than per call, so one call can read the whole of
|
|
2021
|
+
one table's items and part of another's.
|
|
2022
|
+
|
|
2023
|
+
```typescript sim-dynamodb-batch-get-item
|
|
2024
|
+
/**
|
|
2025
|
+
* Reading items from two tables in one call, projecting one of them.
|
|
2026
|
+
*/
|
|
2027
|
+
|
|
2028
|
+
import {
|
|
2029
|
+
BatchGetItemCommand,
|
|
2030
|
+
CreateTableCommand,
|
|
2031
|
+
PutItemCommand,
|
|
2032
|
+
} from "@aws-sdk/client-dynamodb";
|
|
2033
|
+
|
|
2034
|
+
import { SimAws } from "@kensio/yulin";
|
|
2035
|
+
|
|
2036
|
+
const simAws = new SimAws();
|
|
2037
|
+
const dynamoDb = simAws.dynamoDb();
|
|
2038
|
+
|
|
2039
|
+
await dynamoDb.createTable(
|
|
2040
|
+
new CreateTableCommand({
|
|
2041
|
+
TableName: "OrdersTable",
|
|
2042
|
+
KeySchema: [{ AttributeName: "orderId", KeyType: "HASH" }],
|
|
2043
|
+
AttributeDefinitions: [{ AttributeName: "orderId", AttributeType: "S" }],
|
|
2044
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
2045
|
+
}),
|
|
2046
|
+
);
|
|
2047
|
+
await dynamoDb.createTable(
|
|
2048
|
+
new CreateTableCommand({
|
|
2049
|
+
TableName: "CustomersTable",
|
|
2050
|
+
KeySchema: [{ AttributeName: "customerId", KeyType: "HASH" }],
|
|
2051
|
+
AttributeDefinitions: [{ AttributeName: "customerId", AttributeType: "S" }],
|
|
2052
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
2053
|
+
}),
|
|
2054
|
+
);
|
|
2055
|
+
await simAws.backgroundTasksComplete();
|
|
2056
|
+
|
|
2057
|
+
await dynamoDb.putItem(
|
|
2058
|
+
new PutItemCommand({
|
|
2059
|
+
TableName: "OrdersTable",
|
|
2060
|
+
Item: {
|
|
2061
|
+
orderId: { S: "order-1" },
|
|
2062
|
+
total: { N: "19.99" },
|
|
2063
|
+
note: { S: "gift wrapped" },
|
|
2064
|
+
},
|
|
2065
|
+
}),
|
|
2066
|
+
);
|
|
2067
|
+
await dynamoDb.putItem(
|
|
2068
|
+
new PutItemCommand({
|
|
2069
|
+
TableName: "CustomersTable",
|
|
2070
|
+
Item: { customerId: { S: "customer-1" }, name: { S: "Ada" } },
|
|
2071
|
+
}),
|
|
2072
|
+
);
|
|
2073
|
+
|
|
2074
|
+
const output = await dynamoDb.batchGetItem(
|
|
2075
|
+
new BatchGetItemCommand({
|
|
2076
|
+
RequestItems: {
|
|
2077
|
+
OrdersTable: {
|
|
2078
|
+
Keys: [{ orderId: { S: "order-1" } }, { orderId: { S: "order-404" } }],
|
|
2079
|
+
ConsistentRead: true,
|
|
2080
|
+
ProjectionExpression: "total",
|
|
2081
|
+
},
|
|
2082
|
+
CustomersTable: {
|
|
2083
|
+
Keys: [{ customerId: { S: "customer-1" } }],
|
|
2084
|
+
},
|
|
2085
|
+
},
|
|
2086
|
+
}),
|
|
2087
|
+
);
|
|
2088
|
+
|
|
2089
|
+
// The key that holds nothing is left out rather than standing in the answer.
|
|
2090
|
+
console.log(output.Responses["OrdersTable"]?.length); // 1
|
|
2091
|
+
console.log(output.Responses["OrdersTable"]?.[0]); // { total: { N: "19.99" } }
|
|
2092
|
+
console.log(output.Responses["CustomersTable"]?.[0]?.["name"]?.S); // "Ada"
|
|
2093
|
+
console.log(output.UnprocessedKeys); // {}
|
|
2094
|
+
```
|
|
2095
|
+
|
|
2096
|
+
An item that was never written is left out of `Responses`, with no placeholder standing in for it,
|
|
2097
|
+
so what came back is what was there. A table that held none of the keys it was asked for is still in
|
|
2098
|
+
`Responses`, with an empty list. DynamoDB reads a batch in parallel and answers in no particular
|
|
2099
|
+
order, and a caller that needs to tell its items apart reads the key attributes off them rather than
|
|
2100
|
+
counting on where they are in the list.
|
|
2101
|
+
|
|
2102
|
+
More than 100 keys in one call, counted across every table the request names, is a
|
|
2103
|
+
`ValidationException`. So is the same key twice for one table, and so is one table named twice, once
|
|
2104
|
+
by its name and once by its ARN.
|
|
2105
|
+
|
|
2106
|
+
Both commands answer with the map of what they could not get to, `UnprocessedItems` for a write and
|
|
2107
|
+
`UnprocessedKeys` for a read. Both are always empty here, since no request is throttled, but they
|
|
2108
|
+
are there all the same. The retry loop real code is written around still terminates:
|
|
2109
|
+
|
|
2110
|
+
```typescript
|
|
2111
|
+
let unprocessed = {
|
|
2112
|
+
OrdersTable: [{ PutRequest: { Item: { orderId: { S: "order-1" } } } }],
|
|
2113
|
+
};
|
|
2114
|
+
|
|
2115
|
+
while (Object.keys(unprocessed).length > 0) {
|
|
2116
|
+
const output = await dynamoDb.batchWriteItem(
|
|
2117
|
+
new BatchWriteItemCommand({ RequestItems: unprocessed }),
|
|
2118
|
+
);
|
|
2119
|
+
|
|
2120
|
+
// Always empty against the simulator, so the loop runs once.
|
|
2121
|
+
unprocessed = output.UnprocessedItems;
|
|
2122
|
+
}
|
|
2123
|
+
```
|
|
2124
|
+
|
|
2125
|
+
## Reading and writing items in transactions
|
|
2126
|
+
|
|
2127
|
+
`TransactWriteItems` applies up to 100 actions in one step. Either all of them happen or none of
|
|
2128
|
+
them do, so two items that have to agree with each other can be written together.
|
|
2129
|
+
|
|
2130
|
+
Each action carries exactly one of `Put`, `Update`, `Delete` and `ConditionCheck`, and names its own
|
|
2131
|
+
table. A `ConditionCheck` writes nothing. It is how a transaction says that an item it is leaving
|
|
2132
|
+
alone has to hold for the items it is changing to be written.
|
|
2133
|
+
|
|
2134
|
+
What a test usually wants to show is the failure. A transaction that succeeds looks the same as two
|
|
2135
|
+
separate writes, so what is worth asserting is that a failed condition on the second action left the
|
|
2136
|
+
first one unwritten.
|
|
2137
|
+
|
|
2138
|
+
```typescript sim-dynamodb-transact-write-items
|
|
2139
|
+
/**
|
|
2140
|
+
* Writing a ledger entry and the balance it moves, or writing neither.
|
|
2141
|
+
*/
|
|
2142
|
+
|
|
2143
|
+
import {
|
|
2144
|
+
CreateTableCommand,
|
|
2145
|
+
GetItemCommand,
|
|
2146
|
+
PutItemCommand,
|
|
2147
|
+
TransactWriteItemsCommand,
|
|
2148
|
+
} from "@aws-sdk/client-dynamodb";
|
|
2149
|
+
|
|
2150
|
+
import { SimAws } from "@kensio/yulin";
|
|
2151
|
+
|
|
2152
|
+
const simAws = new SimAws();
|
|
2153
|
+
const dynamoDb = simAws.dynamoDb();
|
|
2154
|
+
|
|
2155
|
+
await dynamoDb.createTable(
|
|
2156
|
+
new CreateTableCommand({
|
|
2157
|
+
TableName: "AccountsTable",
|
|
2158
|
+
KeySchema: [{ AttributeName: "accountId", KeyType: "HASH" }],
|
|
2159
|
+
AttributeDefinitions: [{ AttributeName: "accountId", AttributeType: "S" }],
|
|
2160
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
2161
|
+
}),
|
|
2162
|
+
);
|
|
2163
|
+
await dynamoDb.createTable(
|
|
2164
|
+
new CreateTableCommand({
|
|
2165
|
+
TableName: "LedgerTable",
|
|
2166
|
+
KeySchema: [{ AttributeName: "entryId", KeyType: "HASH" }],
|
|
2167
|
+
AttributeDefinitions: [{ AttributeName: "entryId", AttributeType: "S" }],
|
|
2168
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
2169
|
+
}),
|
|
2170
|
+
);
|
|
2171
|
+
await simAws.backgroundTasksComplete();
|
|
2172
|
+
|
|
2173
|
+
// The account is closed, so the balance may not move.
|
|
2174
|
+
await dynamoDb.putItem(
|
|
2175
|
+
new PutItemCommand({
|
|
2176
|
+
TableName: "AccountsTable",
|
|
2177
|
+
Item: {
|
|
2178
|
+
accountId: { S: "account-1" },
|
|
2179
|
+
balance: { N: "100" },
|
|
2180
|
+
status: { S: "closed" },
|
|
2181
|
+
},
|
|
2182
|
+
}),
|
|
2183
|
+
);
|
|
2184
|
+
|
|
2185
|
+
const ledgerEntry = {
|
|
2186
|
+
Put: {
|
|
2187
|
+
TableName: "LedgerTable",
|
|
2188
|
+
Item: { entryId: { S: "entry-1" }, amount: { N: "25" } },
|
|
2189
|
+
},
|
|
2190
|
+
};
|
|
2191
|
+
|
|
2192
|
+
const balanceUpdate = {
|
|
2193
|
+
Update: {
|
|
2194
|
+
TableName: "AccountsTable",
|
|
2195
|
+
Key: { accountId: { S: "account-1" } },
|
|
2196
|
+
UpdateExpression: "SET balance = balance - :amount",
|
|
2197
|
+
ConditionExpression: "#status = :open",
|
|
2198
|
+
ExpressionAttributeNames: { "#status": "status" },
|
|
2199
|
+
ExpressionAttributeValues: {
|
|
2200
|
+
":amount": { N: "25" },
|
|
2201
|
+
":open": { S: "open" },
|
|
2202
|
+
},
|
|
2203
|
+
},
|
|
2204
|
+
};
|
|
2205
|
+
|
|
2206
|
+
try {
|
|
2207
|
+
await dynamoDb.transactWriteItems(
|
|
2208
|
+
new TransactWriteItemsCommand({
|
|
2209
|
+
TransactItems: [ledgerEntry, balanceUpdate],
|
|
2210
|
+
}),
|
|
2211
|
+
);
|
|
2212
|
+
} catch (error) {
|
|
2213
|
+
const cancelled = error as {
|
|
2214
|
+
name: string;
|
|
2215
|
+
CancellationReasons?: { Code: string; Message?: string }[];
|
|
2216
|
+
};
|
|
2217
|
+
|
|
2218
|
+
console.log(cancelled.name); // "TransactionCanceledException"
|
|
2219
|
+
console.log(cancelled.CancellationReasons);
|
|
2220
|
+
// [
|
|
2221
|
+
// { Code: "None" },
|
|
2222
|
+
// {
|
|
2223
|
+
// Code: "ConditionalCheckFailed",
|
|
2224
|
+
// Message: "The conditional request failed.",
|
|
2225
|
+
// },
|
|
2226
|
+
// ]
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
// The first action is reported even though nothing was wrong with it, and the
|
|
2230
|
+
// ledger entry it would have written is not there.
|
|
2231
|
+
const entry = await dynamoDb.getItem(
|
|
2232
|
+
new GetItemCommand({
|
|
2233
|
+
TableName: "LedgerTable",
|
|
2234
|
+
Key: { entryId: { S: "entry-1" } },
|
|
2235
|
+
}),
|
|
2236
|
+
);
|
|
2237
|
+
|
|
2238
|
+
console.log(entry.Item); // undefined
|
|
2239
|
+
```
|
|
2240
|
+
|
|
2241
|
+
`CancellationReasons` lines up with `TransactItems`. There is one entry per action, in the same
|
|
2242
|
+
order, including the actions that would have gone through, which carry the code `None`. The codes
|
|
2243
|
+
have no `Exception` suffix. A failed condition reads as `ConditionalCheckFailed`, never as the
|
|
2244
|
+
`ConditionalCheckFailedException` a single `PutItem` throws.
|
|
2245
|
+
|
|
2246
|
+
An action that sets `ReturnValuesOnConditionCheckFailure` to `ALL_OLD` gets `Item` on its
|
|
2247
|
+
cancellation reason, holding the item as it was, and a retry needs no second read.
|
|
2248
|
+
|
|
2249
|
+
These refuse the request outright rather than cancelling it, with no write either way:
|
|
2250
|
+
|
|
2251
|
+
- more than 100 actions
|
|
2252
|
+
- an action carrying more than one of `Put`, `Update`, `Delete` and `ConditionCheck`, or none of
|
|
2253
|
+
them
|
|
2254
|
+
- two actions on the same item of one table
|
|
2255
|
+
- a table that is absent, or a key that fails to match its key schema
|
|
2256
|
+
- an update that would move the item's primary key
|
|
2257
|
+
- an item carrying a secondary index key attribute as a type the index did not declare
|
|
2258
|
+
- an update that would take the item past the 400 KB one item holds
|
|
2259
|
+
|
|
2260
|
+
One table may be named as often as the transaction likes, and that is the difference from a batch.
|
|
2261
|
+
What it may not do is touch one item twice.
|
|
2262
|
+
|
|
2263
|
+
### Retrying a transaction
|
|
2264
|
+
|
|
2265
|
+
`ClientRequestToken` makes a retry idempotent. Replaying a token with the same actions inside ten
|
|
2266
|
+
minutes succeeds without applying the writes again, and replaying it with different actions gives
|
|
2267
|
+
`IdempotentParameterMismatchException`. Only a transaction that was applied is remembered, so
|
|
2268
|
+
retrying one that was cancelled runs it again.
|
|
2269
|
+
|
|
2270
|
+
The ten minutes are measured on the simulated clock. A test moves past the window rather than
|
|
2271
|
+
waiting for it:
|
|
2272
|
+
|
|
2273
|
+
```typescript
|
|
2274
|
+
const withdrawal = {
|
|
2275
|
+
TransactItems: [
|
|
2276
|
+
{
|
|
2277
|
+
Update: {
|
|
2278
|
+
TableName: "AccountsTable",
|
|
2279
|
+
Key: { accountId: { S: "account-1" } },
|
|
2280
|
+
UpdateExpression: "SET balance = balance - :amount",
|
|
2281
|
+
ExpressionAttributeValues: { ":amount": { N: "25" } },
|
|
2282
|
+
},
|
|
2283
|
+
},
|
|
2284
|
+
],
|
|
2285
|
+
ClientRequestToken: "6b6b1a1e-0e2d-4d3f-9f5a-1c0f2b3d4e5f",
|
|
2286
|
+
};
|
|
2287
|
+
|
|
2288
|
+
// The balance moves once, however many times the call is retried.
|
|
2289
|
+
await dynamoDb.transactWriteItems(new TransactWriteItemsCommand(withdrawal));
|
|
2290
|
+
await dynamoDb.transactWriteItems(new TransactWriteItemsCommand(withdrawal));
|
|
2291
|
+
|
|
2292
|
+
// Past the window, the same token is a new transaction, and it moves again.
|
|
2293
|
+
await simAws.clock().advanceBy({ minutes: 10 });
|
|
2294
|
+
await dynamoDb.transactWriteItems(new TransactWriteItemsCommand(withdrawal));
|
|
2295
|
+
```
|
|
2296
|
+
|
|
2297
|
+
### Reading in a transaction
|
|
2298
|
+
|
|
2299
|
+
`TransactGetItems` reads up to 100 items in one step, and is always strongly consistent. There is no
|
|
2300
|
+
`ConsistentRead` to set. Each `Get` names its own table, and takes a `ProjectionExpression`.
|
|
2301
|
+
|
|
2302
|
+
```typescript sim-dynamodb-transact-get-items
|
|
2303
|
+
/**
|
|
2304
|
+
* Reading two items in one step, one of which is not there.
|
|
2305
|
+
*/
|
|
2306
|
+
|
|
2307
|
+
import {
|
|
2308
|
+
CreateTableCommand,
|
|
2309
|
+
PutItemCommand,
|
|
2310
|
+
TransactGetItemsCommand,
|
|
2311
|
+
} from "@aws-sdk/client-dynamodb";
|
|
2312
|
+
|
|
2313
|
+
import { SimAws } from "@kensio/yulin";
|
|
2314
|
+
|
|
2315
|
+
const simAws = new SimAws();
|
|
2316
|
+
const dynamoDb = simAws.dynamoDb();
|
|
2317
|
+
|
|
2318
|
+
await dynamoDb.createTable(
|
|
2319
|
+
new CreateTableCommand({
|
|
2320
|
+
TableName: "AccountsTable",
|
|
2321
|
+
KeySchema: [{ AttributeName: "accountId", KeyType: "HASH" }],
|
|
2322
|
+
AttributeDefinitions: [{ AttributeName: "accountId", AttributeType: "S" }],
|
|
2323
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
2324
|
+
}),
|
|
2325
|
+
);
|
|
2326
|
+
await simAws.backgroundTasksComplete();
|
|
2327
|
+
|
|
2328
|
+
await dynamoDb.putItem(
|
|
2329
|
+
new PutItemCommand({
|
|
2330
|
+
TableName: "AccountsTable",
|
|
2331
|
+
Item: {
|
|
2332
|
+
accountId: { S: "account-1" },
|
|
2333
|
+
balance: { N: "100" },
|
|
2334
|
+
status: { S: "open" },
|
|
2335
|
+
},
|
|
2336
|
+
}),
|
|
2337
|
+
);
|
|
2338
|
+
|
|
2339
|
+
const output = await dynamoDb.transactGetItems(
|
|
2340
|
+
new TransactGetItemsCommand({
|
|
2341
|
+
TransactItems: [
|
|
2342
|
+
{
|
|
2343
|
+
Get: {
|
|
2344
|
+
TableName: "AccountsTable",
|
|
2345
|
+
Key: { accountId: { S: "account-1" } },
|
|
2346
|
+
ProjectionExpression: "balance",
|
|
2347
|
+
},
|
|
2348
|
+
},
|
|
2349
|
+
{
|
|
2350
|
+
Get: {
|
|
2351
|
+
TableName: "AccountsTable",
|
|
2352
|
+
Key: { accountId: { S: "account-404" } },
|
|
2353
|
+
},
|
|
2354
|
+
},
|
|
2355
|
+
],
|
|
2356
|
+
}),
|
|
2357
|
+
);
|
|
2358
|
+
|
|
2359
|
+
// Responses is positional and is never compacted, so a missing item is an
|
|
2360
|
+
// entry with no Item rather than nothing at all.
|
|
2361
|
+
console.log(output.Responses[0]); // { Item: { balance: { N: "100" } } }
|
|
2362
|
+
console.log(output.Responses[1]); // {}
|
|
2363
|
+
```
|
|
2364
|
+
|
|
2365
|
+
That is the difference from `BatchGetItem`, which leaves a missing item out of its answer
|
|
2366
|
+
altogether. Here the answers stay lined up with the Gets that asked for them.
|
|
2367
|
+
|
|
2368
|
+
## Expiring items with time to live
|
|
2369
|
+
|
|
2370
|
+
`UpdateTimeToLive` names the attribute a table expires items by, and `DescribeTimeToLive` reports
|
|
2371
|
+
it. The attribute holds epoch seconds in a Number. An item without it, or holding a String or
|
|
2372
|
+
anything else, never expires, and that is allowed. Nor does an item whose timestamp is more than
|
|
2373
|
+
five years in the past, which DynamoDB treats as a malformed value rather than as long overdue.
|
|
2374
|
+
|
|
2375
|
+
Expiry runs on [the simulated clock](https://yulinsim.dev/time/). Moving the clock forward is what deletes items
|
|
2376
|
+
whose time to live has run out, so one `advanceBy` expires a table's sessions alongside whatever
|
|
2377
|
+
else that advance causes elsewhere in the simulation. That is the only call a test has.
|
|
2378
|
+
|
|
2379
|
+
Deletion is not immediate. Real DynamoDB marks an item expired at its timestamp and deletes it
|
|
2380
|
+
typically within 48 hours, and reads keep returning it until then. That gap is simulated, and a test
|
|
2381
|
+
can advance an hour past a session's expiry, see the session come back from `GetItem`, and find out
|
|
2382
|
+
that the code under test needs to cope with it.
|
|
2383
|
+
|
|
2384
|
+
```typescript sim-dynamodb-time-to-live
|
|
2385
|
+
/**
|
|
2386
|
+
* Items expiring as the simulated clock moves past their time to live.
|
|
2387
|
+
*/
|
|
2388
|
+
|
|
2389
|
+
import {
|
|
2390
|
+
CreateTableCommand,
|
|
2391
|
+
DescribeTimeToLiveCommand,
|
|
2392
|
+
GetItemCommand,
|
|
2393
|
+
PutItemCommand,
|
|
2394
|
+
UpdateTimeToLiveCommand,
|
|
2395
|
+
} from "@aws-sdk/client-dynamodb";
|
|
2396
|
+
|
|
2397
|
+
import { SimAws, SimFixedClock } from "@kensio/yulin";
|
|
2398
|
+
|
|
2399
|
+
const simAws = new SimAws({
|
|
2400
|
+
clock: new SimFixedClock(new Date("2026-08-01T09:00:00.000Z")),
|
|
2401
|
+
});
|
|
2402
|
+
const dynamoDb = simAws.dynamoDb();
|
|
2403
|
+
|
|
2404
|
+
await dynamoDb.createTable(
|
|
2405
|
+
new CreateTableCommand({
|
|
2406
|
+
TableName: "SessionsTable",
|
|
2407
|
+
KeySchema: [{ AttributeName: "sessionId", KeyType: "HASH" }],
|
|
2408
|
+
AttributeDefinitions: [{ AttributeName: "sessionId", AttributeType: "S" }],
|
|
2409
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
2410
|
+
}),
|
|
2411
|
+
);
|
|
2412
|
+
|
|
2413
|
+
await dynamoDb.updateTimeToLive(
|
|
2414
|
+
new UpdateTimeToLiveCommand({
|
|
2415
|
+
TableName: "SessionsTable",
|
|
2416
|
+
TimeToLiveSpecification: { Enabled: true, AttributeName: "expiresAt" },
|
|
2417
|
+
}),
|
|
2418
|
+
);
|
|
2419
|
+
await simAws.backgroundTasksComplete();
|
|
2420
|
+
|
|
2421
|
+
const described = await dynamoDb.describeTimeToLive(
|
|
2422
|
+
new DescribeTimeToLiveCommand({ TableName: "SessionsTable" }),
|
|
2423
|
+
);
|
|
2424
|
+
|
|
2425
|
+
console.log(described.TimeToLiveDescription?.TimeToLiveStatus); // "ENABLED"
|
|
2426
|
+
|
|
2427
|
+
// A session that expires in an hour.
|
|
2428
|
+
const nowSeconds = Math.floor(simAws.now().getTime() / 1000);
|
|
2429
|
+
|
|
2430
|
+
await dynamoDb.putItem(
|
|
2431
|
+
new PutItemCommand({
|
|
2432
|
+
TableName: "SessionsTable",
|
|
2433
|
+
Item: {
|
|
2434
|
+
sessionId: { S: "abc" },
|
|
2435
|
+
expiresAt: { N: String(nowSeconds + 3600) },
|
|
2436
|
+
},
|
|
2437
|
+
}),
|
|
2438
|
+
);
|
|
2439
|
+
|
|
2440
|
+
await simAws.clock().advanceBy({ hours: 2 });
|
|
2441
|
+
|
|
2442
|
+
const stale = await dynamoDb.getItem(
|
|
2443
|
+
new GetItemCommand({
|
|
2444
|
+
TableName: "SessionsTable",
|
|
2445
|
+
Key: { sessionId: { S: "abc" } },
|
|
2446
|
+
}),
|
|
2447
|
+
);
|
|
2448
|
+
|
|
2449
|
+
// Expired an hour ago, and still there, as it would be on AWS.
|
|
2450
|
+
console.log(stale.Item === undefined); // false
|
|
2451
|
+
|
|
2452
|
+
await simAws.clock().advanceBy({ days: 3 });
|
|
2453
|
+
|
|
2454
|
+
const collected = await dynamoDb.getItem(
|
|
2455
|
+
new GetItemCommand({
|
|
2456
|
+
TableName: "SessionsTable",
|
|
2457
|
+
Key: { sessionId: { S: "abc" } },
|
|
2458
|
+
}),
|
|
2459
|
+
);
|
|
2460
|
+
|
|
2461
|
+
// Past the deletion window, with nothing else asked of the simulation.
|
|
2462
|
+
console.log(collected.Item === undefined); // true
|
|
2463
|
+
```
|
|
2464
|
+
|
|
2465
|
+
`UpdateTimeToLive` moves the status to `ENABLING` and it settles on `ENABLED` once the background
|
|
2466
|
+
work has run, following the sequence a table's own status goes through. Switching it off goes
|
|
2467
|
+
through `DISABLING` to `DISABLED`, and a `DISABLED` table reports no attribute name.
|
|
2468
|
+
|
|
2469
|
+
An `UpdateTimeToLive` asking for the state the table is already in is a `ValidationException`, as it
|
|
2470
|
+
is on AWS, so code that has to be idempotent reads `DescribeTimeToLive` first. Changing the
|
|
2471
|
+
attribute an enabled table expires by means switching time to live off and then on again.
|
|
2472
|
+
|
|
2473
|
+
DynamoDB also takes one `UpdateTimeToLive` per table per hour. That hour is measured on the
|
|
2474
|
+
simulated clock. A second call inside it is a `ValidationException` and `simAws.clock().advanceBy({
|
|
2475
|
+
hours: 1 })` is what lets the next one through.
|
|
2476
|
+
|
|
2477
|
+
Switching time to live on reaches the items already on the table, since their attributes were only
|
|
2478
|
+
inert while it was off. A removal already scheduled is checked again when it comes due. An item
|
|
2479
|
+
overwritten with a later timestamp, or one on a table whose time to live has since been switched
|
|
2480
|
+
off, stays where it is.
|
|
2481
|
+
|
|
2482
|
+
## Capturing changes with a stream
|
|
2483
|
+
|
|
2484
|
+
A `StreamSpecification` on `CreateTable` gives a table a stream, and every change to an item is
|
|
2485
|
+
captured on it as a record. That is an `INSERT` for the first write of an item, a `MODIFY` for a
|
|
2486
|
+
write over one that was there, and a `REMOVE` for a deletion. `DescribeTable` reports the
|
|
2487
|
+
specification back along with `LatestStreamArn` and `LatestStreamLabel`.
|
|
2488
|
+
|
|
2489
|
+
Which images a record carries is what `StreamViewType` chooses, and every record carries the keys of
|
|
2490
|
+
the item that changed whichever one it is:
|
|
2491
|
+
|
|
2492
|
+
| `StreamViewType` | `INSERT` | `MODIFY` | `REMOVE` |
|
|
2493
|
+
| -------------------- | --------------- | ----------------- | --------------- |
|
|
2494
|
+
| `KEYS_ONLY` | keys | keys | keys |
|
|
2495
|
+
| `NEW_IMAGE` | keys, new image | keys, new image | keys |
|
|
2496
|
+
| `OLD_IMAGE` | keys | keys, old image | keys, old image |
|
|
2497
|
+
| `NEW_AND_OLD_IMAGES` | keys, new image | keys, both images | keys, old image |
|
|
2498
|
+
|
|
2499
|
+
A `REMOVE` under `NEW_IMAGE` and an `INSERT` under `OLD_IMAGE` are keys and nothing else, because
|
|
2500
|
+
the view type asks for an image the record lacks. The record is still written, since it is how a
|
|
2501
|
+
reader learns that the change happened at all.
|
|
2502
|
+
|
|
2503
|
+
```typescript sim-dynamodb-stream-specification
|
|
2504
|
+
/**
|
|
2505
|
+
* A table capturing its item changes on a stream.
|
|
2506
|
+
*/
|
|
2507
|
+
|
|
2508
|
+
import {
|
|
2509
|
+
CreateTableCommand,
|
|
2510
|
+
DescribeTableCommand,
|
|
2511
|
+
PutItemCommand,
|
|
2512
|
+
UpdateTableCommand,
|
|
2513
|
+
} from "@aws-sdk/client-dynamodb";
|
|
2514
|
+
|
|
2515
|
+
import { SimAws } from "@kensio/yulin";
|
|
2516
|
+
|
|
2517
|
+
const simAws = new SimAws();
|
|
2518
|
+
const dynamoDb = simAws.dynamoDb();
|
|
2519
|
+
|
|
2520
|
+
await dynamoDb.createTable(
|
|
2521
|
+
new CreateTableCommand({
|
|
2522
|
+
TableName: "OrdersTable",
|
|
2523
|
+
KeySchema: [{ AttributeName: "orderId", KeyType: "HASH" }],
|
|
2524
|
+
AttributeDefinitions: [{ AttributeName: "orderId", AttributeType: "S" }],
|
|
2525
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
2526
|
+
StreamSpecification: {
|
|
2527
|
+
StreamEnabled: true,
|
|
2528
|
+
StreamViewType: "NEW_AND_OLD_IMAGES",
|
|
2529
|
+
},
|
|
2530
|
+
}),
|
|
2531
|
+
);
|
|
2532
|
+
await simAws.backgroundTasksComplete();
|
|
2533
|
+
|
|
2534
|
+
const described = await dynamoDb.describeTable(
|
|
2535
|
+
new DescribeTableCommand({ TableName: "OrdersTable" }),
|
|
2536
|
+
);
|
|
2537
|
+
|
|
2538
|
+
console.log(described.Table?.StreamSpecification?.StreamViewType); // "NEW_AND_OLD_IMAGES"
|
|
2539
|
+
console.log(described.Table?.LatestStreamArn?.includes("/stream/")); // true
|
|
2540
|
+
|
|
2541
|
+
// Every write from here is captured on the stream.
|
|
2542
|
+
await dynamoDb.putItem(
|
|
2543
|
+
new PutItemCommand({
|
|
2544
|
+
TableName: "OrdersTable",
|
|
2545
|
+
Item: { orderId: { S: "order-1" }, total: { N: "101" } },
|
|
2546
|
+
}),
|
|
2547
|
+
);
|
|
2548
|
+
|
|
2549
|
+
// Switching the stream off keeps what it captured, and keeps naming it.
|
|
2550
|
+
await dynamoDb.updateTable(
|
|
2551
|
+
new UpdateTableCommand({
|
|
2552
|
+
TableName: "OrdersTable",
|
|
2553
|
+
StreamSpecification: { StreamEnabled: false },
|
|
2554
|
+
}),
|
|
2555
|
+
);
|
|
2556
|
+
await simAws.backgroundTasksComplete();
|
|
2557
|
+
|
|
2558
|
+
const withoutStream = await dynamoDb.describeTable(
|
|
2559
|
+
new DescribeTableCommand({ TableName: "OrdersTable" }),
|
|
2560
|
+
);
|
|
2561
|
+
|
|
2562
|
+
console.log(withoutStream.Table?.StreamSpecification?.StreamEnabled); // false
|
|
2563
|
+
console.log(withoutStream.Table?.LatestStreamArn !== undefined); // true
|
|
2564
|
+
```
|
|
2565
|
+
|
|
2566
|
+
`UpdateTable` switches a stream on for a table that has none and off for one that has one. A
|
|
2567
|
+
`StreamViewType` belongs to the stream rather than to the table, and there is no changing it in
|
|
2568
|
+
place. Switching the stream off and on again is what AWS makes an application do, and gives the
|
|
2569
|
+
table a stream with a fresh label and ARN. Asking to switch on a stream that is already on, or off
|
|
2570
|
+
one that is absent, is a `ValidationException` either way.
|
|
2571
|
+
|
|
2572
|
+
A time to live expiry is captured as a `REMOVE` carrying `userIdentity: { type: "Service",
|
|
2573
|
+
principalId: "dynamodb.amazonaws.com" }`, where a deletion the application asked for carries none.
|
|
2574
|
+
That is how a stream consumer tells an item it deleted from one DynamoDB collected.
|
|
2575
|
+
|
|
2576
|
+
Nothing is captured for a write that never reached the item, such as a refused conditional write, a
|
|
2577
|
+
cancelled transaction, a delete of a key holding nothing, or a request the table refused. Deleting
|
|
2578
|
+
the table takes its items with it in one go. No record is captured for that either.
|
|
2579
|
+
|
|
2580
|
+
## Reading a stream's records
|
|
2581
|
+
|
|
2582
|
+
Reading the records back is the DynamoDB Streams API, which AWS puts behind a client of its own.
|
|
2583
|
+
`simAws.dynamoDbStreams()` is that API here, with `ListStreams`, `DescribeStream`,
|
|
2584
|
+
`GetShardIterator` and `GetRecords`.
|
|
2585
|
+
|
|
2586
|
+
Reading a stream takes four calls the first time. `ListStreams` finds the stream ARN for a table,
|
|
2587
|
+
`DescribeStream` reports the shard, `GetShardIterator` says where on that shard to start, and
|
|
2588
|
+
`GetRecords` reads from there and hands back the iterator to carry on with.
|
|
2589
|
+
|
|
2590
|
+
```typescript sim-dynamodb-stream-records
|
|
2591
|
+
/**
|
|
2592
|
+
* Reading a table's captured changes back off its stream.
|
|
2593
|
+
*/
|
|
2594
|
+
|
|
2595
|
+
import { CreateTableCommand, PutItemCommand } from "@aws-sdk/client-dynamodb";
|
|
2596
|
+
import {
|
|
2597
|
+
DescribeStreamCommand,
|
|
2598
|
+
GetRecordsCommand,
|
|
2599
|
+
GetShardIteratorCommand,
|
|
2600
|
+
ListStreamsCommand,
|
|
2601
|
+
} from "@aws-sdk/client-dynamodb-streams";
|
|
2602
|
+
|
|
2603
|
+
import { SimAws } from "@kensio/yulin";
|
|
2604
|
+
|
|
2605
|
+
const simAws = new SimAws();
|
|
2606
|
+
const dynamoDb = simAws.dynamoDb();
|
|
2607
|
+
const dynamoDbStreams = simAws.dynamoDbStreams();
|
|
2608
|
+
|
|
2609
|
+
await dynamoDb.createTable(
|
|
2610
|
+
new CreateTableCommand({
|
|
2611
|
+
TableName: "OrdersTable",
|
|
2612
|
+
KeySchema: [{ AttributeName: "orderId", KeyType: "HASH" }],
|
|
2613
|
+
AttributeDefinitions: [{ AttributeName: "orderId", AttributeType: "S" }],
|
|
2614
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
2615
|
+
StreamSpecification: {
|
|
2616
|
+
StreamEnabled: true,
|
|
2617
|
+
StreamViewType: "NEW_AND_OLD_IMAGES",
|
|
2618
|
+
},
|
|
2619
|
+
}),
|
|
2620
|
+
);
|
|
2621
|
+
await simAws.backgroundTasksComplete();
|
|
2622
|
+
|
|
2623
|
+
await dynamoDb.putItem(
|
|
2624
|
+
new PutItemCommand({
|
|
2625
|
+
TableName: "OrdersTable",
|
|
2626
|
+
Item: { orderId: { S: "order-1" }, total: { N: "101" } },
|
|
2627
|
+
}),
|
|
2628
|
+
);
|
|
2629
|
+
|
|
2630
|
+
const listed = await dynamoDbStreams.listStreams(
|
|
2631
|
+
new ListStreamsCommand({ TableName: "OrdersTable" }),
|
|
2632
|
+
);
|
|
2633
|
+
const streamArn = listed.Streams?.[0]?.StreamArn;
|
|
2634
|
+
|
|
2635
|
+
const described = await dynamoDbStreams.describeStream(
|
|
2636
|
+
new DescribeStreamCommand({ StreamArn: streamArn }),
|
|
2637
|
+
);
|
|
2638
|
+
const shardId = described.StreamDescription?.Shards?.[0]?.ShardId;
|
|
2639
|
+
|
|
2640
|
+
console.log(described.StreamDescription?.StreamStatus); // "ENABLED"
|
|
2641
|
+
|
|
2642
|
+
const iterator = await dynamoDbStreams.getShardIterator(
|
|
2643
|
+
new GetShardIteratorCommand({
|
|
2644
|
+
StreamArn: streamArn,
|
|
2645
|
+
ShardId: shardId,
|
|
2646
|
+
ShardIteratorType: "TRIM_HORIZON",
|
|
2647
|
+
}),
|
|
2648
|
+
);
|
|
2649
|
+
|
|
2650
|
+
const read = await dynamoDbStreams.getRecords(
|
|
2651
|
+
new GetRecordsCommand({ ShardIterator: iterator.ShardIterator }),
|
|
2652
|
+
);
|
|
2653
|
+
|
|
2654
|
+
console.log(read.Records?.[0]?.eventName); // "INSERT"
|
|
2655
|
+
console.log(read.Records?.[0]?.dynamodb?.NewImage?.["total"]?.N); // "101"
|
|
2656
|
+
|
|
2657
|
+
// The iterator to poll with next, which is there while the stream is open.
|
|
2658
|
+
console.log(read.NextShardIterator !== undefined); // true
|
|
2659
|
+
```
|
|
2660
|
+
|
|
2661
|
+
A record carries `eventID`, `eventName`, `eventSource`, `awsRegion` and a `dynamodb` body holding
|
|
2662
|
+
`Keys`, the images the view type selects, `SequenceNumber`, `SizeBytes` and
|
|
2663
|
+
`ApproximateCreationDateTime`. A time to live removal carries `userIdentity: { PrincipalId:
|
|
2664
|
+
"dynamodb.amazonaws.com", Type: "Service" }`. The Streams API capitalizes those two fields where the
|
|
2665
|
+
Lambda event carries the same values as `principalId` and `type`. A consumer written against one
|
|
2666
|
+
shape fails to read the other.
|
|
2667
|
+
|
|
2668
|
+
### Where to start reading
|
|
2669
|
+
|
|
2670
|
+
`ShardIteratorType` picks the place on the shard an iterator starts at.
|
|
2671
|
+
|
|
2672
|
+
| `ShardIteratorType` | Starts at |
|
|
2673
|
+
| ----------------------- | ---------------------------------------- |
|
|
2674
|
+
| `TRIM_HORIZON` | the oldest record the stream still holds |
|
|
2675
|
+
| `LATEST` | just after the newest record on it |
|
|
2676
|
+
| `AT_SEQUENCE_NUMBER` | the record the `SequenceNumber` names |
|
|
2677
|
+
| `AFTER_SEQUENCE_NUMBER` | the record following the one it names |
|
|
2678
|
+
|
|
2679
|
+
`AT_SEQUENCE_NUMBER` and `AFTER_SEQUENCE_NUMBER` need a `SequenceNumber`, and the other two are
|
|
2680
|
+
refused if given one, since an iterator asking for both is saying two different things about where
|
|
2681
|
+
to start.
|
|
2682
|
+
|
|
2683
|
+
### Polling with NextShardIterator
|
|
2684
|
+
|
|
2685
|
+
`GetRecords` answers with the iterator to use for the next call. Reading a stream to the end and
|
|
2686
|
+
polling it is the same loop either way. Pass each `NextShardIterator` to the following `GetRecords`.
|
|
2687
|
+
|
|
2688
|
+
One `GetRecords` hands back at most 1000 records, and a reader with more than that behind it stays
|
|
2689
|
+
behind until it polls again. `Limit` asks for fewer, and a `Limit` above 1000 is a
|
|
2690
|
+
`ValidationException`.
|
|
2691
|
+
|
|
2692
|
+
An empty `Records` array alongside a `NextShardIterator` is the ordinary answer for a reader that
|
|
2693
|
+
has caught up, and means to look again, and not that anything is wrong. `NextShardIterator` is
|
|
2694
|
+
absent only when the shard is closed and the reader has reached the end of it, which happens once
|
|
2695
|
+
the table has switched the stream off and everything on it has been read.
|
|
2696
|
+
|
|
2697
|
+
### The 24 hour retention window
|
|
2698
|
+
|
|
2699
|
+
A stream keeps its records for 24 hours on the simulated clock, and the trim is applied when the
|
|
2700
|
+
stream is read. Reading from a position the stream no longer holds raises a
|
|
2701
|
+
`TrimmedDataAccessException`, whether the sequence number was named in a `GetShardIterator` call or
|
|
2702
|
+
carried in an iterator that was still good when it was handed out. `TRIM_HORIZON` never raises it,
|
|
2703
|
+
since it means the oldest record still there, whatever has gone.
|
|
2704
|
+
|
|
2705
|
+
A stream stays listable and readable after its table switches it off, and after everything on it has
|
|
2706
|
+
been trimmed. A trimmed stream reads as empty, never as missing.
|
|
2707
|
+
|
|
2708
|
+
### Delivering a stream to a Lambda function
|
|
2709
|
+
|
|
2710
|
+
Most applications consume a stream by having a Lambda function run on it rather than by polling it
|
|
2711
|
+
themselves, and that is a
|
|
2712
|
+
[Lambda event source mapping](https://yulinsim.dev/services/lambda/#triggering-a-function-from-a-dynamodb-stream "Simulated Lambda event source mapping docs").
|
|
2713
|
+
Create the mapping, write to the table, and the function is invoked with the changes. The
|
|
2714
|
+
`GetRecords` loop above is still there for a consumer that wants to read a stream directly.
|
|
2715
|
+
|
|
2716
|
+
## Numbers
|
|
2717
|
+
|
|
2718
|
+
A DynamoDB number carries up to 38 significant digits, where a JavaScript number carries about 15.
|
|
2719
|
+
Numbers are held here as the digits they were written with. An identifier, a monetary amount or a
|
|
2720
|
+
large counter comes back exactly as it went in.
|
|
2721
|
+
|
|
2722
|
+
```typescript sim-dynamodb-number-precision
|
|
2723
|
+
/**
|
|
2724
|
+
* A number too large for a JavaScript number, kept whole.
|
|
2725
|
+
*/
|
|
2726
|
+
|
|
2727
|
+
import { CreateTableCommand, PutItemCommand } from "@aws-sdk/client-dynamodb";
|
|
2728
|
+
|
|
2729
|
+
import { SimAws } from "@kensio/yulin";
|
|
2730
|
+
|
|
2731
|
+
const simAws = new SimAws();
|
|
2732
|
+
const dynamoDb = simAws.dynamoDb();
|
|
2733
|
+
|
|
2734
|
+
await dynamoDb.createTable(
|
|
2735
|
+
new CreateTableCommand({
|
|
2736
|
+
TableName: "CountersTable",
|
|
2737
|
+
KeySchema: [{ AttributeName: "id", KeyType: "HASH" }],
|
|
2738
|
+
AttributeDefinitions: [{ AttributeName: "id", AttributeType: "S" }],
|
|
2739
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
2740
|
+
}),
|
|
2741
|
+
);
|
|
2742
|
+
await simAws.backgroundTasksComplete();
|
|
2743
|
+
|
|
2744
|
+
await dynamoDb.putItem(
|
|
2745
|
+
new PutItemCommand({
|
|
2746
|
+
TableName: "CountersTable",
|
|
2747
|
+
Item: { id: { S: "counter" }, count: { N: "9007199254740993" } },
|
|
2748
|
+
}),
|
|
2749
|
+
);
|
|
2750
|
+
|
|
2751
|
+
const replaced = await dynamoDb.putItem(
|
|
2752
|
+
new PutItemCommand({
|
|
2753
|
+
TableName: "CountersTable",
|
|
2754
|
+
Item: { id: { S: "counter" }, count: { N: "9007199254740994" } },
|
|
2755
|
+
ReturnValues: "ALL_OLD",
|
|
2756
|
+
}),
|
|
2757
|
+
);
|
|
2758
|
+
|
|
2759
|
+
// A JavaScript number would have rounded this to 9007199254740992.
|
|
2760
|
+
console.log(replaced.Attributes?.["count"]?.N); // "9007199254740993"
|
|
2761
|
+
```
|
|
2762
|
+
|
|
2763
|
+
The digits are normalised the way DynamoDB normalises them. Leading and trailing zeros are trimmed,
|
|
2764
|
+
and an exponent is worked back into plain notation, so `1E5` and `100000.00` are the same number.
|
|
2765
|
+
That is what makes `{ N: "1" }` and `{ N: "1.0" }` the same key.
|
|
2766
|
+
|
|
2767
|
+
A number with more than 38 significant digits, or outside the range `1E-130` to
|
|
2768
|
+
`9.9999999999999999999999999999999999999E+125` and its negative mirror, is a `ValidationException`.
|
|
2769
|
+
|
|
2770
|
+
## Sets, lists and maps
|
|
2771
|
+
|
|
2772
|
+
A set holds one kind of value, holds at least one, and holds each value once. Binary members compare
|
|
2773
|
+
by their bytes, so two `Uint8Array` values holding the same bytes are one member and are refused as
|
|
2774
|
+
a duplicate.
|
|
2775
|
+
|
|
2776
|
+
Lists and maps nest up to 32 levels, and one item is at most 400 KB counting its attribute names as
|
|
2777
|
+
well as its values. Both are `ValidationException` when exceeded.
|
|
2778
|
+
|
|
2779
|
+
## The document client
|
|
2780
|
+
|
|
2781
|
+
`@aws-sdk/lib-dynamodb` takes plain JavaScript values rather than AttributeValues. Intercept a
|
|
2782
|
+
`DynamoDBDocumentClient` and its Commands reach simulated DynamoDB with the values converted, so
|
|
2783
|
+
code written against the document client runs against the simulator unchanged.
|
|
2784
|
+
|
|
2785
|
+
```typescript sim-dynamodb-document-client
|
|
2786
|
+
/**
|
|
2787
|
+
* Reading and writing items as plain JavaScript with the document client.
|
|
2788
|
+
*/
|
|
2789
|
+
|
|
2790
|
+
import { CreateTableCommand, DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
2791
|
+
import {
|
|
2792
|
+
DynamoDBDocumentClient,
|
|
2793
|
+
GetCommand,
|
|
2794
|
+
PutCommand,
|
|
2795
|
+
UpdateCommand,
|
|
2796
|
+
} from "@aws-sdk/lib-dynamodb";
|
|
2797
|
+
|
|
2798
|
+
import { SimSdk } from "@kensio/yulin/sdk";
|
|
2799
|
+
|
|
2800
|
+
using simSdk = new SimSdk();
|
|
2801
|
+
|
|
2802
|
+
const documents = DynamoDBDocumentClient.from(
|
|
2803
|
+
new DynamoDBClient({ region: "eu-west-2" }),
|
|
2804
|
+
);
|
|
2805
|
+
simSdk.intercept(documents);
|
|
2806
|
+
|
|
2807
|
+
await documents.send(
|
|
2808
|
+
new CreateTableCommand({
|
|
2809
|
+
TableName: "OrdersTable",
|
|
2810
|
+
KeySchema: [{ AttributeName: "orderId", KeyType: "HASH" }],
|
|
2811
|
+
AttributeDefinitions: [{ AttributeName: "orderId", AttributeType: "S" }],
|
|
2812
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
2813
|
+
}),
|
|
2814
|
+
);
|
|
2815
|
+
await simSdk.simAws.backgroundTasksComplete();
|
|
2816
|
+
|
|
2817
|
+
// Nested objects, lists and Sets all go in as themselves.
|
|
2818
|
+
await documents.send(
|
|
2819
|
+
new PutCommand({
|
|
2820
|
+
TableName: "OrdersTable",
|
|
2821
|
+
Item: {
|
|
2822
|
+
orderId: "order-1",
|
|
2823
|
+
total: 42,
|
|
2824
|
+
paid: false,
|
|
2825
|
+
lines: [{ sku: "widget", quantity: 2 }],
|
|
2826
|
+
tags: new Set(["priority", "gift"]),
|
|
2827
|
+
},
|
|
2828
|
+
}),
|
|
2829
|
+
);
|
|
2830
|
+
|
|
2831
|
+
const updated = await documents.send(
|
|
2832
|
+
new UpdateCommand({
|
|
2833
|
+
TableName: "OrdersTable",
|
|
2834
|
+
Key: { orderId: "order-1" },
|
|
2835
|
+
UpdateExpression: "SET paid = :paid",
|
|
2836
|
+
ExpressionAttributeValues: { ":paid": true },
|
|
2837
|
+
ReturnValues: "ALL_NEW",
|
|
2838
|
+
}),
|
|
2839
|
+
);
|
|
2840
|
+
|
|
2841
|
+
console.log(updated.Attributes?.["paid"]); // true
|
|
2842
|
+
|
|
2843
|
+
const read = await documents.send(
|
|
2844
|
+
new GetCommand({ TableName: "OrdersTable", Key: { orderId: "order-1" } }),
|
|
2845
|
+
);
|
|
2846
|
+
|
|
2847
|
+
const lines = read.Item?.["lines"] as { sku: string; quantity: number }[];
|
|
2848
|
+
console.log(lines[0]?.quantity); // 2
|
|
2849
|
+
|
|
2850
|
+
const tags = read.Item?.["tags"] as Set<string>;
|
|
2851
|
+
console.log(tags.has("priority")); // true
|
|
2852
|
+
```
|
|
2853
|
+
|
|
2854
|
+
`PutCommand`, `GetCommand`, `DeleteCommand`, `UpdateCommand`, `QueryCommand`, `ScanCommand`,
|
|
2855
|
+
`BatchWriteCommand` and `BatchGetCommand` are converted. A document Command with no route here, such
|
|
2856
|
+
as `TransactWriteCommand`, is refused by name before anything tries to convert its values.
|
|
2857
|
+
|
|
2858
|
+
Intercept the document client itself. `DynamoDBDocumentClient.from(client)` builds a separate object
|
|
2859
|
+
outside the `DynamoDBClient` class, so intercepting the base client leaves Commands sent through the
|
|
2860
|
+
document one untouched. See [the SDK docs](https://yulinsim.dev/sdk/#the-dynamodb-document-client).
|
|
2861
|
+
|
|
2862
|
+
### Querying and scanning through the document client
|
|
2863
|
+
|
|
2864
|
+
`@aws-sdk/lib-dynamodb` names its `QueryCommand` and `ScanCommand` exactly as
|
|
2865
|
+
`@aws-sdk/client-dynamodb` does. Both are routed, and which one a request gets is decided by the
|
|
2866
|
+
Command it was sent with rather than by the name. The two can be used on the same intercepted
|
|
2867
|
+
client.
|
|
2868
|
+
|
|
2869
|
+
Expression values and `ExclusiveStartKey` are converted on the way in, and `Items` and
|
|
2870
|
+
`LastEvaluatedKey` on the way out. A key from one page goes straight back in as the start of the
|
|
2871
|
+
next, so `paginateQuery` and `paginateScan` work as they are.
|
|
2872
|
+
|
|
2873
|
+
```typescript sim-dynamodb-document-read
|
|
2874
|
+
/**
|
|
2875
|
+
* Querying a simulated table through the document client, a page at a time.
|
|
2876
|
+
*/
|
|
2877
|
+
|
|
2878
|
+
import { CreateTableCommand, DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
2879
|
+
import {
|
|
2880
|
+
DynamoDBDocumentClient,
|
|
2881
|
+
paginateQuery,
|
|
2882
|
+
PutCommand,
|
|
2883
|
+
QueryCommand,
|
|
2884
|
+
} from "@aws-sdk/lib-dynamodb";
|
|
2885
|
+
|
|
2886
|
+
import { SimSdk } from "@kensio/yulin/sdk";
|
|
2887
|
+
|
|
2888
|
+
using simSdk = new SimSdk();
|
|
2889
|
+
|
|
2890
|
+
const documents = DynamoDBDocumentClient.from(
|
|
2891
|
+
new DynamoDBClient({ region: "eu-west-2" }),
|
|
2892
|
+
);
|
|
2893
|
+
simSdk.intercept(documents);
|
|
2894
|
+
|
|
2895
|
+
await documents.send(
|
|
2896
|
+
new CreateTableCommand({
|
|
2897
|
+
TableName: "OrdersTable",
|
|
2898
|
+
KeySchema: [
|
|
2899
|
+
{ AttributeName: "customerId", KeyType: "HASH" },
|
|
2900
|
+
{ AttributeName: "orderId", KeyType: "RANGE" },
|
|
2901
|
+
],
|
|
2902
|
+
AttributeDefinitions: [
|
|
2903
|
+
{ AttributeName: "customerId", AttributeType: "S" },
|
|
2904
|
+
{ AttributeName: "orderId", AttributeType: "S" },
|
|
2905
|
+
],
|
|
2906
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
2907
|
+
}),
|
|
2908
|
+
);
|
|
2909
|
+
await simSdk.simAws.backgroundTasksComplete();
|
|
2910
|
+
|
|
2911
|
+
for (const orderId of ["order-1", "order-2"]) {
|
|
2912
|
+
await documents.send(
|
|
2913
|
+
new PutCommand({
|
|
2914
|
+
TableName: "OrdersTable",
|
|
2915
|
+
Item: { customerId: "cust-1", orderId, total: 42 },
|
|
2916
|
+
}),
|
|
2917
|
+
);
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
const query = {
|
|
2921
|
+
TableName: "OrdersTable",
|
|
2922
|
+
KeyConditionExpression: "customerId = :customer",
|
|
2923
|
+
ExpressionAttributeValues: { ":customer": "cust-1" },
|
|
2924
|
+
Limit: 1,
|
|
2925
|
+
};
|
|
2926
|
+
|
|
2927
|
+
const first = await documents.send(new QueryCommand(query));
|
|
2928
|
+
|
|
2929
|
+
console.log(first.Items?.[0]?.["total"]); // 42
|
|
2930
|
+
|
|
2931
|
+
// The key comes back as plain JavaScript, and goes back in as it is.
|
|
2932
|
+
const second = await documents.send(
|
|
2933
|
+
new QueryCommand({ ...query, ExclusiveStartKey: first.LastEvaluatedKey }),
|
|
2934
|
+
);
|
|
2935
|
+
|
|
2936
|
+
console.log(second.Items?.[0]?.["orderId"]); // order-2
|
|
2937
|
+
|
|
2938
|
+
// The paginators send the same Commands, so they need nothing extra. Each one
|
|
2939
|
+
// writes the next start key into the input it was given, so it gets a copy.
|
|
2940
|
+
const pages = paginateQuery({ client: documents, pageSize: 1 }, { ...query });
|
|
2941
|
+
|
|
2942
|
+
for await (const page of pages) {
|
|
2943
|
+
console.log(page.Items?.length); // 1
|
|
2944
|
+
}
|
|
2945
|
+
```
|
|
2946
|
+
|
|
2947
|
+
### Which native types map to which descriptors
|
|
2948
|
+
|
|
2949
|
+
| Written as | Stored as | Read back as |
|
|
2950
|
+
| ------------------------------------------------- | --------- | --------------------------- |
|
|
2951
|
+
| `string` | `S` | `string` |
|
|
2952
|
+
| `number` | `N` | `number` |
|
|
2953
|
+
| `bigint` | `N` | `number` or `bigint` |
|
|
2954
|
+
| `NumberValue` | `N` | `number` or `bigint` |
|
|
2955
|
+
| `boolean` | `BOOL` | `boolean` |
|
|
2956
|
+
| `null` | `NULL` | `null` |
|
|
2957
|
+
| `Uint8Array`, `Buffer` and the other typed arrays | `B` | `Uint8Array` |
|
|
2958
|
+
| `Set` of strings | `SS` | `Set` of strings |
|
|
2959
|
+
| `Set` of numbers, bigints or `NumberValue` | `NS` | `Set` of numbers or bigints |
|
|
2960
|
+
| `Set` of binary | `BS` | `Set` of binary |
|
|
2961
|
+
| `Array` | `L` | `Array` |
|
|
2962
|
+
| plain object, `Map` | `M` | plain object |
|
|
2963
|
+
|
|
2964
|
+
A class instance goes unconverted. The real document client refuses one unless it was built with
|
|
2965
|
+
`convertClassInstanceToMap`, and an object with behaviour is never quietly flattened into
|
|
2966
|
+
attributes.
|
|
2967
|
+
|
|
2968
|
+
### Numbers through the document client
|
|
2969
|
+
|
|
2970
|
+
A simulated table holds a number's digits exactly, but the document client converts to and from
|
|
2971
|
+
JavaScript numbers, and that is where digits are lost. It is the same loss AWS has. A test that
|
|
2972
|
+
passes here is telling you something true about the real thing.
|
|
2973
|
+
|
|
2974
|
+
- Writing a `number` outside the safe integer range is refused, never stored already rounded. Write
|
|
2975
|
+
a `bigint`, or a `NumberValue` from `@aws-sdk/lib-dynamodb`, to keep the digits.
|
|
2976
|
+
- Reading a stored number outside the safe integer range gives a `bigint`.
|
|
2977
|
+
- Reading a stored decimal with more digits than a JavaScript number carries gives a rounded
|
|
2978
|
+
`number`. The table still holds every digit, and the rounding is the document client's. Read
|
|
2979
|
+
through an ordinary `GetItemCommand` to see the stored digits.
|
|
2980
|
+
- Reading a stored number that is outside the safe integer range and carrying a fraction is refused,
|
|
2981
|
+
since there is no value it could answer with.
|
|
2982
|
+
|
|
2983
|
+
## Table names and ARNs
|
|
2984
|
+
|
|
2985
|
+
A table name is 3 to 255 characters of letters, numbers, underscores, hyphens and periods. The name
|
|
2986
|
+
is unique within an Account and Region, and the table ARN is built from that scope.
|
|
2987
|
+
|
|
2988
|
+
```typescript sim-dynamodb-scoping
|
|
2989
|
+
/**
|
|
2990
|
+
* The same table name in two Accounts, or two Regions, is two tables.
|
|
2991
|
+
*/
|
|
2992
|
+
|
|
2993
|
+
import { CreateTableCommand } from "@aws-sdk/client-dynamodb";
|
|
2994
|
+
|
|
2995
|
+
import { SimAws } from "@kensio/yulin";
|
|
2996
|
+
|
|
2997
|
+
const simAws = new SimAws();
|
|
2998
|
+
|
|
2999
|
+
const tableInput = {
|
|
3000
|
+
TableName: "FoobarTable",
|
|
3001
|
+
KeySchema: [{ AttributeName: "id", KeyType: "HASH" as const }],
|
|
3002
|
+
AttributeDefinitions: [{ AttributeName: "id", AttributeType: "S" as const }],
|
|
3003
|
+
BillingMode: "PAY_PER_REQUEST" as const,
|
|
3004
|
+
};
|
|
3005
|
+
|
|
3006
|
+
// Two Accounts, one Region.
|
|
3007
|
+
const firstAccount = await simAws
|
|
3008
|
+
.account("111111111111")
|
|
3009
|
+
.region("eu-west-2")
|
|
3010
|
+
.dynamoDb()
|
|
3011
|
+
.createTable(new CreateTableCommand(tableInput));
|
|
3012
|
+
|
|
3013
|
+
const secondAccount = await simAws
|
|
3014
|
+
.account("222222222222")
|
|
3015
|
+
.region("eu-west-2")
|
|
3016
|
+
.dynamoDb()
|
|
3017
|
+
.createTable(new CreateTableCommand(tableInput));
|
|
3018
|
+
|
|
3019
|
+
console.log(firstAccount.TableDescription?.TableArn);
|
|
3020
|
+
// "arn:aws:dynamodb:eu-west-2:111111111111:table/FoobarTable"
|
|
3021
|
+
console.log(secondAccount.TableDescription?.TableArn);
|
|
3022
|
+
// "arn:aws:dynamodb:eu-west-2:222222222222:table/FoobarTable"
|
|
3023
|
+
|
|
3024
|
+
// One Account, two Regions.
|
|
3025
|
+
const otherRegion = await simAws
|
|
3026
|
+
.account("111111111111")
|
|
3027
|
+
.region("us-east-1")
|
|
3028
|
+
.dynamoDb()
|
|
3029
|
+
.createTable(new CreateTableCommand(tableInput));
|
|
3030
|
+
|
|
3031
|
+
console.log(otherRegion.TableDescription?.TableArn);
|
|
3032
|
+
// "arn:aws:dynamodb:us-east-1:111111111111:table/FoobarTable"
|
|
3033
|
+
|
|
3034
|
+
await simAws.backgroundTasksComplete();
|
|
3035
|
+
```
|
|
3036
|
+
|
|
3037
|
+
Creating a name that is already taken in the same scope fails with `ResourceInUseException`.
|
|
3038
|
+
|
|
3039
|
+
## Deploying a table from CloudFormation
|
|
3040
|
+
|
|
3041
|
+
Simulated CloudFormation creates a table from an `AWS::DynamoDB::Table` resource, in the stack's
|
|
3042
|
+
account and region. The table is created through `CreateTable`. A template-created table is the same
|
|
3043
|
+
thing an SDK caller would get, with the same name validation, the same key schema and attribute
|
|
3044
|
+
definition rules, and the same ARN.
|
|
3045
|
+
|
|
3046
|
+
`Ref` on the resource gives the table name, as it does on real AWS, and it can be handed straight to
|
|
3047
|
+
`PutItem`. `Fn::GetAtt … Arn` gives the table ARN, and an IAM policy names it by that.
|
|
3048
|
+
|
|
3049
|
+
```typescript sim-dynamodb-cloudformation-table
|
|
3050
|
+
/**
|
|
3051
|
+
* Deploying a table from a CloudFormation template and writing to it.
|
|
3052
|
+
*/
|
|
3053
|
+
|
|
3054
|
+
import { PutItemCommand } from "@aws-sdk/client-dynamodb";
|
|
3055
|
+
|
|
3056
|
+
import { SimAws } from "@kensio/yulin";
|
|
3057
|
+
|
|
3058
|
+
const simAws = new SimAws();
|
|
3059
|
+
|
|
3060
|
+
const stack = await simAws.cloudFormation().deployTemplate({
|
|
3061
|
+
stackName: "orders-stack",
|
|
3062
|
+
template: {
|
|
3063
|
+
Resources: {
|
|
3064
|
+
OrdersTable: {
|
|
3065
|
+
Type: "AWS::DynamoDB::Table",
|
|
3066
|
+
Properties: {
|
|
3067
|
+
KeySchema: [{ AttributeName: "id", KeyType: "HASH" }],
|
|
3068
|
+
AttributeDefinitions: [{ AttributeName: "id", AttributeType: "S" }],
|
|
3069
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
3070
|
+
},
|
|
3071
|
+
},
|
|
3072
|
+
},
|
|
3073
|
+
Outputs: {
|
|
3074
|
+
OrdersTableName: { Value: { Ref: "OrdersTable" } },
|
|
3075
|
+
OrdersTableArn: { Value: { "Fn::GetAtt": ["OrdersTable", "Arn"] } },
|
|
3076
|
+
},
|
|
3077
|
+
},
|
|
3078
|
+
});
|
|
3079
|
+
|
|
3080
|
+
await stack.waitForDeployComplete();
|
|
3081
|
+
await simAws.backgroundTasksComplete();
|
|
3082
|
+
|
|
3083
|
+
// Ref resolves to the table name, so it works as a PutItem TableName.
|
|
3084
|
+
const tableName = stack.output("OrdersTableName");
|
|
3085
|
+
|
|
3086
|
+
console.log(tableName);
|
|
3087
|
+
// "orders-stack-OrdersTable"
|
|
3088
|
+
|
|
3089
|
+
await simAws
|
|
3090
|
+
.dynamoDb()
|
|
3091
|
+
.putItem(
|
|
3092
|
+
new PutItemCommand({ TableName: tableName, Item: { id: { S: "1" } } }),
|
|
3093
|
+
);
|
|
3094
|
+
|
|
3095
|
+
console.log(stack.output("OrdersTableArn"));
|
|
3096
|
+
// "arn:aws:dynamodb:us-east-1:888888888888:table/orders-stack-OrdersTable"
|
|
3097
|
+
```
|
|
3098
|
+
|
|
3099
|
+
The properties that are read are `TableName`, `KeySchema`, `AttributeDefinitions`, `BillingMode`,
|
|
3100
|
+
`ProvisionedThroughput`, `TableClass`, `DeletionProtectionEnabled`, `Tags`,
|
|
3101
|
+
`GlobalSecondaryIndexes`, `LocalSecondaryIndexes`, `StreamSpecification` and
|
|
3102
|
+
`TimeToLiveSpecification`. All but the last are passed to `CreateTable`, never applied here. A value
|
|
3103
|
+
the template gets wrong fails the same way it would for an SDK caller.
|
|
3104
|
+
|
|
3105
|
+
`TimeToLiveSpecification` is applied after the table is created, through `UpdateTimeToLive`. Real
|
|
3106
|
+
`CreateTable` has no parameter for it either, so real CloudFormation makes the table and then
|
|
3107
|
+
updates it. A specification the template got wrong is refused in the words `UpdateTimeToLive`
|
|
3108
|
+
refuses it in.
|
|
3109
|
+
|
|
3110
|
+
A table with no `TableName` is named after the stack, its logical ID and a tail derived from both.
|
|
3111
|
+
The table above with its name left out would be `orders-stack-OrdersTable-` and twelve more
|
|
3112
|
+
characters, where real CloudFormation ends the name in twelve random ones. Two stacks deploying the
|
|
3113
|
+
same template get two differently named tables. The name is trimmed to the 255 characters a table
|
|
3114
|
+
name allows, and [the CloudFormation docs](https://yulinsim.dev/services/cloudformation/#names-cloudformation-generates "Names CloudFormation generates")
|
|
3115
|
+
cover how the stack name and the logical ID share what is left.
|
|
3116
|
+
|
|
3117
|
+
`Fn::GetAtt … StreamArn` gives the ARN of the stream the table's `StreamSpecification` gave it. On a
|
|
3118
|
+
table with no `StreamSpecification` it is refused by name, naming the table, since an invented
|
|
3119
|
+
stream ARN would read as a working stream to whatever the template handed it to. Real CloudFormation
|
|
3120
|
+
refuses the same template while validating it, where this refuses when the attribute is asked for.
|
|
3121
|
+
|
|
3122
|
+
A property with behaviour that is absent is left out and recorded in
|
|
3123
|
+
[`stack.ignoredProperties`](https://yulinsim.dev/services/cloudformation/#properties-a-resource-was-created-without),
|
|
3124
|
+
and the table is created and the rest of the stack still deploys. Those properties are
|
|
3125
|
+
`KinesisStreamSpecification`, `SSESpecification`, `PointInTimeRecoverySpecification`,
|
|
3126
|
+
`ContributorInsightsSpecification`, `ImportSourceSpecification`, `ResourcePolicy`,
|
|
3127
|
+
`OnDemandThroughput` and `WarmThroughput`. A property `AWS::DynamoDB::Table` lacks is recorded the
|
|
3128
|
+
same way, so a typo or a property AWS added since this list was written.
|
|
3129
|
+
|
|
3130
|
+
`AWS::DynamoDB::GlobalTable` deploys a table as well, under
|
|
3131
|
+
[deploying a global table](#deploying-a-global-table-from-cloudformation).
|
|
3132
|
+
|
|
3133
|
+
CDK works without hand-editing. A `dynamodb.Table` synthesises a template that deploys here, with
|
|
3134
|
+
the table name reaching a function through its environment and a grant policy naming the table by
|
|
3135
|
+
the ARN `Fn::GetAtt` gives.
|
|
3136
|
+
|
|
3137
|
+
## Deploying a table with secondary indexes
|
|
3138
|
+
|
|
3139
|
+
`GlobalSecondaryIndexes` and `LocalSecondaryIndexes` are read off the resource and handed to
|
|
3140
|
+
`CreateTable` with the rest of the table. An index a template declared is the index an SDK caller
|
|
3141
|
+
would have got. It is queried and scanned the same way.
|
|
3142
|
+
|
|
3143
|
+
```typescript sim-dynamodb-cloudformation-indexes
|
|
3144
|
+
/**
|
|
3145
|
+
* Deploying a table with secondary indexes from a CloudFormation template.
|
|
3146
|
+
*/
|
|
3147
|
+
|
|
3148
|
+
import { PutItemCommand, QueryCommand } from "@aws-sdk/client-dynamodb";
|
|
3149
|
+
|
|
3150
|
+
import { SimAws } from "@kensio/yulin";
|
|
3151
|
+
|
|
3152
|
+
const simAws = new SimAws();
|
|
3153
|
+
|
|
3154
|
+
const stack = await simAws.cloudFormation().deployTemplate({
|
|
3155
|
+
stackName: "orders-stack",
|
|
3156
|
+
template: {
|
|
3157
|
+
Resources: {
|
|
3158
|
+
OrdersTable: {
|
|
3159
|
+
Type: "AWS::DynamoDB::Table",
|
|
3160
|
+
Properties: {
|
|
3161
|
+
TableName: "orders",
|
|
3162
|
+
KeySchema: [
|
|
3163
|
+
{ AttributeName: "customerId", KeyType: "HASH" },
|
|
3164
|
+
{ AttributeName: "orderId", KeyType: "RANGE" },
|
|
3165
|
+
],
|
|
3166
|
+
AttributeDefinitions: [
|
|
3167
|
+
{ AttributeName: "customerId", AttributeType: "S" },
|
|
3168
|
+
{ AttributeName: "orderId", AttributeType: "S" },
|
|
3169
|
+
{ AttributeName: "status", AttributeType: "S" },
|
|
3170
|
+
{ AttributeName: "total", AttributeType: "N" },
|
|
3171
|
+
],
|
|
3172
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
3173
|
+
GlobalSecondaryIndexes: [
|
|
3174
|
+
{
|
|
3175
|
+
IndexName: "byStatus",
|
|
3176
|
+
KeySchema: [{ AttributeName: "status", KeyType: "HASH" }],
|
|
3177
|
+
Projection: { ProjectionType: "ALL" },
|
|
3178
|
+
},
|
|
3179
|
+
],
|
|
3180
|
+
LocalSecondaryIndexes: [
|
|
3181
|
+
{
|
|
3182
|
+
IndexName: "byTotal",
|
|
3183
|
+
KeySchema: [
|
|
3184
|
+
{ AttributeName: "customerId", KeyType: "HASH" },
|
|
3185
|
+
{ AttributeName: "total", KeyType: "RANGE" },
|
|
3186
|
+
],
|
|
3187
|
+
Projection: { ProjectionType: "ALL" },
|
|
3188
|
+
},
|
|
3189
|
+
],
|
|
3190
|
+
},
|
|
3191
|
+
},
|
|
3192
|
+
},
|
|
3193
|
+
},
|
|
3194
|
+
});
|
|
3195
|
+
|
|
3196
|
+
await stack.waitForDeployComplete();
|
|
3197
|
+
await simAws.backgroundTasksComplete();
|
|
3198
|
+
|
|
3199
|
+
await simAws.dynamoDb().putItem(
|
|
3200
|
+
new PutItemCommand({
|
|
3201
|
+
TableName: "orders",
|
|
3202
|
+
Item: {
|
|
3203
|
+
customerId: { S: "customer-1" },
|
|
3204
|
+
orderId: { S: "order-1" },
|
|
3205
|
+
status: { S: "OPEN" },
|
|
3206
|
+
total: { N: "42" },
|
|
3207
|
+
},
|
|
3208
|
+
}),
|
|
3209
|
+
);
|
|
3210
|
+
|
|
3211
|
+
// The global index is keyed by a partition key the table does not have.
|
|
3212
|
+
const open = await simAws.dynamoDb().query(
|
|
3213
|
+
new QueryCommand({
|
|
3214
|
+
TableName: "orders",
|
|
3215
|
+
IndexName: "byStatus",
|
|
3216
|
+
KeyConditionExpression: "#status = :status",
|
|
3217
|
+
ExpressionAttributeNames: { "#status": "status" },
|
|
3218
|
+
ExpressionAttributeValues: { ":status": { S: "OPEN" } },
|
|
3219
|
+
}),
|
|
3220
|
+
);
|
|
3221
|
+
|
|
3222
|
+
console.log(open.Items?.[0]?.["orderId"]?.S); // "order-1"
|
|
3223
|
+
|
|
3224
|
+
// The local index sorts one customer's orders by total.
|
|
3225
|
+
const byTotal = await simAws.dynamoDb().query(
|
|
3226
|
+
new QueryCommand({
|
|
3227
|
+
TableName: "orders",
|
|
3228
|
+
IndexName: "byTotal",
|
|
3229
|
+
KeyConditionExpression: "customerId = :customerId",
|
|
3230
|
+
ExpressionAttributeValues: { ":customerId": { S: "customer-1" } },
|
|
3231
|
+
}),
|
|
3232
|
+
);
|
|
3233
|
+
|
|
3234
|
+
console.log(byTotal.Count); // 1
|
|
3235
|
+
```
|
|
3236
|
+
|
|
3237
|
+
Which properties an index entry may carry is decided here, and no other part of an index is. A
|
|
3238
|
+
template declaring an index whose key attributes are missing from `AttributeDefinitions` fails that
|
|
3239
|
+
resource with the error the API gives for the same input. The same goes for the projection rules,
|
|
3240
|
+
the per-index throughput a provisioned table needs, and the rule that a local secondary index shares
|
|
3241
|
+
the table's partition key.
|
|
3242
|
+
|
|
3243
|
+
`ContributorInsightsSpecification`, `OnDemandThroughput` and `WarmThroughput` on a global secondary
|
|
3244
|
+
index are absent. The index is created without them and the record names the index it was on, such
|
|
3245
|
+
as `GlobalSecondaryIndexes.0.WarmThroughput`. `LocalSecondaryIndexes` entries have `IndexName`,
|
|
3246
|
+
`KeySchema` and `Projection` alone, so anything further on one is recorded the same way. A
|
|
3247
|
+
`ProvisionedThroughput` there still fails the resource, because an index entry goes to `CreateTable`
|
|
3248
|
+
as the template wrote it and real DynamoDB refuses capacity on a local index.
|
|
3249
|
+
|
|
3250
|
+
A CDK `Table` with `addGlobalSecondaryIndex` and `addLocalSecondaryIndex` synthesises a template
|
|
3251
|
+
that deploys here without hand-editing.
|
|
3252
|
+
|
|
3253
|
+
## Deploying a table with a stream
|
|
3254
|
+
|
|
3255
|
+
A `StreamSpecification` on the resource deploys a table with a stream, and `Fn::GetAtt … StreamArn`
|
|
3256
|
+
gives the stream's ARN. CloudFormation's `StreamSpecification` has no `StreamEnabled` field, unlike
|
|
3257
|
+
the SDK's. Declaring the property is what asks for the stream, and `StreamViewType` is required.
|
|
3258
|
+
|
|
3259
|
+
```typescript sim-dynamodb-cloudformation-stream
|
|
3260
|
+
/**
|
|
3261
|
+
* Deploying a table with a stream from a CloudFormation template.
|
|
3262
|
+
*/
|
|
3263
|
+
|
|
3264
|
+
import { PutItemCommand } from "@aws-sdk/client-dynamodb";
|
|
3265
|
+
import {
|
|
3266
|
+
DescribeStreamCommand,
|
|
3267
|
+
GetRecordsCommand,
|
|
3268
|
+
GetShardIteratorCommand,
|
|
3269
|
+
} from "@aws-sdk/client-dynamodb-streams";
|
|
3270
|
+
|
|
3271
|
+
import { SimAws } from "@kensio/yulin";
|
|
3272
|
+
|
|
3273
|
+
const simAws = new SimAws();
|
|
3274
|
+
|
|
3275
|
+
const stack = await simAws.cloudFormation().deployTemplate({
|
|
3276
|
+
stackName: "orders-stack",
|
|
3277
|
+
template: {
|
|
3278
|
+
Resources: {
|
|
3279
|
+
OrdersTable: {
|
|
3280
|
+
Type: "AWS::DynamoDB::Table",
|
|
3281
|
+
Properties: {
|
|
3282
|
+
TableName: "orders",
|
|
3283
|
+
KeySchema: [{ AttributeName: "orderId", KeyType: "HASH" }],
|
|
3284
|
+
AttributeDefinitions: [
|
|
3285
|
+
{ AttributeName: "orderId", AttributeType: "S" },
|
|
3286
|
+
],
|
|
3287
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
3288
|
+
StreamSpecification: { StreamViewType: "NEW_AND_OLD_IMAGES" },
|
|
3289
|
+
},
|
|
3290
|
+
},
|
|
3291
|
+
},
|
|
3292
|
+
Outputs: {
|
|
3293
|
+
OrdersStreamArn: {
|
|
3294
|
+
Value: { "Fn::GetAtt": ["OrdersTable", "StreamArn"] },
|
|
3295
|
+
},
|
|
3296
|
+
},
|
|
3297
|
+
},
|
|
3298
|
+
});
|
|
3299
|
+
|
|
3300
|
+
await stack.waitForDeployComplete();
|
|
3301
|
+
await simAws.backgroundTasksComplete();
|
|
3302
|
+
|
|
3303
|
+
// The Output holds the ARN of the stream the deployed table captures on.
|
|
3304
|
+
const streamArn = stack.output("OrdersStreamArn");
|
|
3305
|
+
|
|
3306
|
+
console.log(streamArn.includes("/stream/")); // true
|
|
3307
|
+
|
|
3308
|
+
await simAws.dynamoDb().putItem(
|
|
3309
|
+
new PutItemCommand({
|
|
3310
|
+
TableName: "orders",
|
|
3311
|
+
Item: { orderId: { S: "order-1" }, total: { N: "101" } },
|
|
3312
|
+
}),
|
|
3313
|
+
);
|
|
3314
|
+
|
|
3315
|
+
// The write is on the stream, read the way any consumer reads it.
|
|
3316
|
+
const dynamoDbStreams = simAws.dynamoDbStreams();
|
|
3317
|
+
|
|
3318
|
+
const described = await dynamoDbStreams.describeStream(
|
|
3319
|
+
new DescribeStreamCommand({ StreamArn: streamArn }),
|
|
3320
|
+
);
|
|
3321
|
+
|
|
3322
|
+
const iterator = await dynamoDbStreams.getShardIterator(
|
|
3323
|
+
new GetShardIteratorCommand({
|
|
3324
|
+
StreamArn: streamArn,
|
|
3325
|
+
ShardId: described.StreamDescription?.Shards?.[0]?.ShardId,
|
|
3326
|
+
ShardIteratorType: "TRIM_HORIZON",
|
|
3327
|
+
}),
|
|
3328
|
+
);
|
|
3329
|
+
|
|
3330
|
+
const read = await dynamoDbStreams.getRecords(
|
|
3331
|
+
new GetRecordsCommand({ ShardIterator: iterator.ShardIterator }),
|
|
3332
|
+
);
|
|
3333
|
+
|
|
3334
|
+
console.log(read.Records?.[0]?.eventName); // "INSERT"
|
|
3335
|
+
```
|
|
3336
|
+
|
|
3337
|
+
The specification goes to `CreateTable` with the rest of the table, and a template naming a view
|
|
3338
|
+
type that is absent, or naming none at all, is refused in the words `CreateTable` refuses an SDK
|
|
3339
|
+
caller in.
|
|
3340
|
+
|
|
3341
|
+
`StreamSpecification.ResourcePolicy` is a policy on the stream rather than on the table. It is
|
|
3342
|
+
absent. The table is created without it and the whole property path is recorded in
|
|
3343
|
+
[`stack.ignoredProperties`](https://yulinsim.dev/services/cloudformation/#properties-a-resource-was-created-without).
|
|
3344
|
+
|
|
3345
|
+
Changing `StreamViewType` in a deployed template is a different thing here to what it is on real
|
|
3346
|
+
CloudFormation, which replaces the table. `UpdateTable` refuses the change in place, so switching
|
|
3347
|
+
the stream off and on again is what gives a table a stream with a different view type.
|
|
3348
|
+
|
|
3349
|
+
## Deploying a global table from CloudFormation
|
|
3350
|
+
|
|
3351
|
+
An `AWS::DynamoDB::GlobalTable` naming one replica deploys an ordinary simulated table in that
|
|
3352
|
+
region, because with one replica that is what it is. It is turned into the `AWS::DynamoDB::Table` it
|
|
3353
|
+
is and created down the path above. It is the same table with the same rules behind it.
|
|
3354
|
+
|
|
3355
|
+
That is the resource CDK's `TableV2` synthesises for every table it makes, whether or not any
|
|
3356
|
+
replica regions were asked for, since it always appends the stack's own region. So a `TableV2` stack
|
|
3357
|
+
deploys here without hand-editing, the same way a `dynamodb.Table` one does.
|
|
3358
|
+
|
|
3359
|
+
```typescript sim-dynamodb-cloudformation-global-table
|
|
3360
|
+
/**
|
|
3361
|
+
* Deploying a global table with one replica from a CloudFormation template.
|
|
3362
|
+
*/
|
|
3363
|
+
|
|
3364
|
+
import { PutItemCommand } from "@aws-sdk/client-dynamodb";
|
|
3365
|
+
|
|
3366
|
+
import { SimAws } from "@kensio/yulin";
|
|
3367
|
+
|
|
3368
|
+
const simAws = new SimAws();
|
|
3369
|
+
|
|
3370
|
+
const stack = await simAws.cloudFormation().deployTemplate({
|
|
3371
|
+
stackName: "orders-stack",
|
|
3372
|
+
template: {
|
|
3373
|
+
Resources: {
|
|
3374
|
+
OrdersTable: {
|
|
3375
|
+
Type: "AWS::DynamoDB::GlobalTable",
|
|
3376
|
+
Properties: {
|
|
3377
|
+
TableName: "orders",
|
|
3378
|
+
KeySchema: [{ AttributeName: "id", KeyType: "HASH" }],
|
|
3379
|
+
AttributeDefinitions: [{ AttributeName: "id", AttributeType: "S" }],
|
|
3380
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
3381
|
+
// The replica carries what an ordinary table says about itself.
|
|
3382
|
+
Replicas: [
|
|
3383
|
+
{
|
|
3384
|
+
Region: "us-east-1",
|
|
3385
|
+
Tags: [{ Key: "Environment", Value: "test" }],
|
|
3386
|
+
},
|
|
3387
|
+
],
|
|
3388
|
+
},
|
|
3389
|
+
},
|
|
3390
|
+
},
|
|
3391
|
+
Outputs: {
|
|
3392
|
+
OrdersTableName: { Value: { Ref: "OrdersTable" } },
|
|
3393
|
+
},
|
|
3394
|
+
},
|
|
3395
|
+
});
|
|
3396
|
+
|
|
3397
|
+
await stack.waitForDeployComplete();
|
|
3398
|
+
await simAws.backgroundTasksComplete();
|
|
3399
|
+
|
|
3400
|
+
const tableName = stack.output("OrdersTableName");
|
|
3401
|
+
|
|
3402
|
+
console.log(tableName);
|
|
3403
|
+
// "orders"
|
|
3404
|
+
|
|
3405
|
+
await simAws
|
|
3406
|
+
.dynamoDb()
|
|
3407
|
+
.putItem(
|
|
3408
|
+
new PutItemCommand({ TableName: tableName, Item: { id: { S: "1" } } }),
|
|
3409
|
+
);
|
|
3410
|
+
```
|
|
3411
|
+
|
|
3412
|
+
`Ref` gives the table name, as it does for `AWS::DynamoDB::Table`. `Fn::GetAtt` answers for `Arn`,
|
|
3413
|
+
`StreamArn` and `TableId`, which are the attributes the resource type documents. `TableId` is the
|
|
3414
|
+
one an ordinary table has no attribute for at all.
|
|
3415
|
+
|
|
3416
|
+
The replica carries the settings an ordinary table carries itself. `TableClass`,
|
|
3417
|
+
`DeletionProtectionEnabled` and `Tags` are read off it rather than off the table. Everything else a
|
|
3418
|
+
global table states the same way an ordinary one does is handed on as it was written. That covers
|
|
3419
|
+
`TableName`, `KeySchema`, `AttributeDefinitions`, `BillingMode`, `LocalSecondaryIndexes`,
|
|
3420
|
+
`StreamSpecification` and `TimeToLiveSpecification`.
|
|
3421
|
+
|
|
3422
|
+
Capacity is the one thing a global table splits in two. Writes are the table's, in
|
|
3423
|
+
`WriteProvisionedThroughputSettings`, since every replica takes the same writes, and reads belong to
|
|
3424
|
+
the replica, in `ReadProvisionedThroughputSettings`. With one replica there is one of each, and they
|
|
3425
|
+
go back together into the `ProvisionedThroughput` `CreateTable` takes. A global secondary index is
|
|
3426
|
+
split the same way. The table declares the index and provisions its writes, and the replica's
|
|
3427
|
+
`GlobalSecondaryIndexes` entry names that index and provisions its reads.
|
|
3428
|
+
|
|
3429
|
+
A global table naming two or more replica regions is created as an ordinary table in the region the
|
|
3430
|
+
stack is deploying into, with `Replicas` recorded in
|
|
3431
|
+
[`stack.ignoredProperties`](https://yulinsim.dev/services/cloudformation/#properties-a-resource-was-created-without)
|
|
3432
|
+
naming the regions. Replication genuinely is absent, so everything the table does within one region
|
|
3433
|
+
behaves as the template describes and nothing is copied to the others.
|
|
3434
|
+
|
|
3435
|
+
A global table with no `Replicas` at all fails the resource, since `Replicas` is required and real
|
|
3436
|
+
CloudFormation refuses that template too. So does one whose single replica names a region outside
|
|
3437
|
+
the stack's own, since the replica list has to include the region the table would be created in.
|
|
3438
|
+
|
|
3439
|
+
A property with behaviour that is absent skips the resource, in the same terms an
|
|
3440
|
+
`AWS::DynamoDB::Table` one does. Those are `MultiRegionConsistency`, `SSESpecification`,
|
|
3441
|
+
`WarmThroughput` and `WriteOnDemandThroughputSettings` on the table, and
|
|
3442
|
+
`PointInTimeRecoverySpecification`, `KinesisStreamSpecification`,
|
|
3443
|
+
`ContributorInsightsSpecification`, `ResourcePolicy`, `SSESpecification` and
|
|
3444
|
+
`ReadOnDemandThroughputSettings` on the replica. Capacity that scales with load skips the resource
|
|
3445
|
+
too, since no process here scales it, namely `WriteCapacityAutoScalingSettings` and
|
|
3446
|
+
`ReadCapacityAutoScalingSettings`. A property `AWS::DynamoDB::GlobalTable` lacks fails the resource
|
|
3447
|
+
instead.
|
|
3448
|
+
|
|
3449
|
+
## IAM authorization
|
|
3450
|
+
|
|
3451
|
+
`CreateTable` authorizes `dynamodb:CreateTable` against the ARN the table is about to have, before
|
|
3452
|
+
it looks the name up. A caller with no permission is denied whether or not the name is free, and an
|
|
3453
|
+
unauthorized caller cannot find out which names are taken.
|
|
3454
|
+
|
|
3455
|
+
`DescribeTable`, `PutItem`, `GetItem`, `DeleteItem` and `UpdateItem` authorize against the table ARN
|
|
3456
|
+
in the same way, each against the `dynamodb:` action of its own name. `ListTables` names no table.
|
|
3457
|
+
It authorizes against `*`.
|
|
3458
|
+
|
|
3459
|
+
A transaction is authorized as the operations it is made of rather than as itself. Each action of a
|
|
3460
|
+
`TransactWriteItems` needs `dynamodb:PutItem`, `dynamodb:UpdateItem`, `dynamodb:DeleteItem` or
|
|
3461
|
+
`dynamodb:ConditionCheckItem` against the table it names, and each `Get` of a `TransactGetItems`
|
|
3462
|
+
needs `dynamodb:GetItem`. A caller refused any one of them is refused the whole transaction. No item
|
|
3463
|
+
is written.
|
|
3464
|
+
|
|
3465
|
+
## Available functionality
|
|
3466
|
+
|
|
3467
|
+
- `CreateTable`, with table name, key schema, attribute definition, billing mode and throughput
|
|
3468
|
+
validation.
|
|
3469
|
+
- `GlobalSecondaryIndexes` on `CreateTable`, with index name, key schema, projection and per-index
|
|
3470
|
+
throughput validation, `AttributeDefinitions` matched against every key schema in the request, and
|
|
3471
|
+
each index reported in the table description.
|
|
3472
|
+
- `LocalSecondaryIndexes` on `CreateTable`, with the key schema rules that make an index local, the
|
|
3473
|
+
5 index cap, index names unique across both kinds, and each index reported in the table
|
|
3474
|
+
description.
|
|
3475
|
+
- `IndexName` on `Query` and `Scan`, reading a sparse index of either kind by its own key schema,
|
|
3476
|
+
answering with the attributes it projects, and paging with a `LastEvaluatedKey` carrying the index
|
|
3477
|
+
key and the table key together. A local secondary index also answers a strongly consistent read,
|
|
3478
|
+
and fetches an unprojected attribute from the base table.
|
|
3479
|
+
- `DescribeTable`, answering with the full table description, by table name or ARN.
|
|
3480
|
+
- `ListTables`, ordered by UTF-8 bytes and paged with `Limit` and `ExclusiveStartTableName`.
|
|
3481
|
+
- `DeleteTable`, following the table status DynamoDB moves a deleted table through, and refusing a
|
|
3482
|
+
table that is protected from deletion.
|
|
3483
|
+
- `PutItem`, with the attribute value model behind it. Numbers keep their digits, sets compare by
|
|
3484
|
+
value, and key attributes are checked against what the table declared. It takes a table name or
|
|
3485
|
+
ARN and authorizes before the lookup.
|
|
3486
|
+
- `GetItem`, answering with the item under a primary key, and with no `Item` at all when the key
|
|
3487
|
+
holds nothing.
|
|
3488
|
+
- `ProjectionExpression` on `GetItem`, with document paths, list indexing and
|
|
3489
|
+
`ExpressionAttributeNames` placeholders.
|
|
3490
|
+
- `DeleteItem`, removing the item under a primary key and answering with it for `ALL_OLD`.
|
|
3491
|
+
- `UpdateItem`, with `SET`, `REMOVE`, `ADD` and `DELETE` update expressions, `if_not_exists`,
|
|
3492
|
+
`list_append`, decimal arithmetic, list element paths, upserting when the key holds nothing, and
|
|
3493
|
+
all five `ReturnValues` modes. Every action reads the item as it stood before the update.
|
|
3494
|
+
- `Query`, reading one item collection in sort key order, with the seven sort key conditions,
|
|
3495
|
+
`ScanIndexForward`, and `Limit`, `LastEvaluatedKey` and `ExclusiveStartKey` paging.
|
|
3496
|
+
- `Scan`, reading every item in a table with the same paging, and `Segment` and `TotalSegments`
|
|
3497
|
+
dividing a table between parallel workers.
|
|
3498
|
+
- `FilterExpression` on `Query` and `Scan`, applied after the `Limit` so that `ScannedCount` counts
|
|
3499
|
+
what was read and `Count` what survived, and refused on a `Query` when it names a key attribute.
|
|
3500
|
+
- `Select` on `Query` and `Scan`, with `COUNT` answering with counts alone and the rules tying
|
|
3501
|
+
`SPECIFIC_ATTRIBUTES`, `ALL_PROJECTED_ATTRIBUTES` and a projection together.
|
|
3502
|
+
- `ConditionExpression` on `PutItem`, `DeleteItem` and `UpdateItem`, with the six comparators,
|
|
3503
|
+
`BETWEEN`, `IN`, `AND`, `OR`, `NOT`, brackets, and the `attribute_exists`, `attribute_not_exists`,
|
|
3504
|
+
`attribute_type`, `begins_with`, `contains` and `size` functions.
|
|
3505
|
+
- `BatchWriteItem`, putting and deleting items across tables in one call, with the 25 request cap,
|
|
3506
|
+
the whole batch refusals, and an empty `UnprocessedItems`.
|
|
3507
|
+
- `BatchGetItem`, reading items across tables in one call, with `ConsistentRead` and
|
|
3508
|
+
`ProjectionExpression` per table, the 100 key cap, and an empty `UnprocessedKeys`.
|
|
3509
|
+
- `TransactWriteItems`, applying up to 100 `Put`, `Update`, `Delete` and `ConditionCheck` actions in
|
|
3510
|
+
one step, with `TransactionCanceledException` carrying a cancellation reason per action, and
|
|
3511
|
+
`ClientRequestToken` making a retry idempotent for ten simulated minutes.
|
|
3512
|
+
- `TransactGetItems`, reading up to 100 items in one step, with a positional `Responses` array in
|
|
3513
|
+
which a missing item is an entry with no `Item`.
|
|
3514
|
+
- `UpdateTable`, doing one of a billing and throughput change, one global secondary index creation
|
|
3515
|
+
or one global secondary index deletion per call, with `TableClass` and `DeletionProtectionEnabled`
|
|
3516
|
+
riding along. The table moves through `UPDATING` while serving reads and writes, a new index
|
|
3517
|
+
reports `Backfilling` and refuses reads until it is `ACTIVE`, and a second update in flight gives
|
|
3518
|
+
`ResourceInUseException`.
|
|
3519
|
+
- `Tags` on `CreateTable`, with `TagResource`, `UntagResource` and `ListTagsOfResource` addressing
|
|
3520
|
+
the table by ARN, the key, value and count rules DynamoDB applies, and `NextToken` paging.
|
|
3521
|
+
- `UpdateTimeToLive` and `DescribeTimeToLive`, moving through `ENABLING` and `DISABLING` to settle,
|
|
3522
|
+
with the one update per hour rule measured on the simulated clock. Items expire as the clock moves
|
|
3523
|
+
past their deletion window, with no sweep for a test to call.
|
|
3524
|
+
- `StreamSpecification` on `CreateTable` and `UpdateTable`, capturing every item change as a stream
|
|
3525
|
+
record with the images its `StreamViewType` selects, a time to live expiry carrying a `Service`
|
|
3526
|
+
`userIdentity`, and `StreamSpecification`, `LatestStreamArn` and `LatestStreamLabel` reported by
|
|
3527
|
+
`DescribeTable`.
|
|
3528
|
+
- The DynamoDB Streams API through `simAws.dynamoDbStreams()`, with `ListStreams`, `DescribeStream`,
|
|
3529
|
+
`GetShardIterator` and `GetRecords`, all four shard iterator types, a `NextShardIterator` that is
|
|
3530
|
+
absent only for a closed and drained shard, and the 24 hour retention window with
|
|
3531
|
+
`TrimmedDataAccessException` past the trim point.
|
|
3532
|
+
- `AWS::DynamoDB::Table` in CloudFormation, created through `CreateTable`, with `Ref` giving the
|
|
3533
|
+
table name, `Fn::GetAtt … Arn` the table ARN, `TimeToLiveSpecification` deploying a table that
|
|
3534
|
+
expires items, `Tags` deploying a tagged table, `GlobalSecondaryIndexes` and
|
|
3535
|
+
`LocalSecondaryIndexes` deploying a table whose indexes are then queried and scanned, and
|
|
3536
|
+
`StreamSpecification` deploying a table with a stream that `Fn::GetAtt … StreamArn` names.
|
|
3537
|
+
- `AWS::DynamoDB::GlobalTable` in CloudFormation, where one replica deploys the same table the
|
|
3538
|
+
`AWS::DynamoDB::Table` path does, with the replica's `TableClass`, `DeletionProtectionEnabled` and
|
|
3539
|
+
`Tags` read off it, the table's writes and the replica's reads put back together into one
|
|
3540
|
+
provisioned capacity for the table and for each global secondary index, and `Ref`, `Fn::GetAtt …
|
|
3541
|
+
Arn`, `Fn::GetAtt … StreamArn` and `Fn::GetAtt … TableId` answering. A CDK `TableV2` stack deploys
|
|
3542
|
+
through it without hand-editing.
|
|
3543
|
+
- SDK interception, and an intercepted `DynamoDBClient` or `DynamoDBStreamsClient` reaches the
|
|
3544
|
+
simulation.
|
|
3545
|
+
- The `@aws-sdk/lib-dynamodb` document client, with `PutCommand`, `GetCommand`, `DeleteCommand`,
|
|
3546
|
+
`UpdateCommand`, `QueryCommand`, `ScanCommand`, `BatchWriteCommand` and `BatchGetCommand`
|
|
3547
|
+
converting native JavaScript values on the way in and out, and `paginateQuery` and `paginateScan`
|
|
3548
|
+
paging through a simulated table.
|
|
3549
|
+
|
|
3550
|
+
## Limitations
|
|
3551
|
+
|
|
3552
|
+
- The document client's transaction and PartiQL Commands go unconverted. PartiQL is an operation
|
|
3553
|
+
this simulation lacks yet. The transactions are simulated as operations, so only the document form
|
|
3554
|
+
of them is missing. Both are refused by name, never half converted.
|
|
3555
|
+
- A document client's translate config goes unread. The marshalling options it was built with do not
|
|
3556
|
+
apply. See [the SDK docs](https://yulinsim.dev/sdk/#limitations).
|
|
3557
|
+
- `Expected`, `ConditionalOperator`, `AttributeUpdates`, `KeyConditions`, `QueryFilter` and
|
|
3558
|
+
`ScanFilter` go unconverted for the document client, because simulated DynamoDB refuses all six
|
|
3559
|
+
anyway. A request carrying one is refused by the operation rather than by the conversion.
|
|
3560
|
+
- A read of a global secondary index answers with the attributes the index projects, and no fetch
|
|
3561
|
+
fills in the rest. Real DynamoDB behaves the same way. It never reads the base table for an
|
|
3562
|
+
attribute a global secondary index omits. That is why `Select: ALL_ATTRIBUTES` against a partial
|
|
3563
|
+
projection is refused outright. A local secondary index does fetch from the base table, and that
|
|
3564
|
+
is simulated. `ProjectionExpression` is absent on `Query` or `Scan` either way, so naming a
|
|
3565
|
+
non-projected attribute that way never arises.
|
|
3566
|
+
- The 10 GB limit on one item collection is left out, along with
|
|
3567
|
+
`ItemCollectionSizeLimitExceededException`. A table with a local secondary index can hold as much
|
|
3568
|
+
under one partition key here as memory allows. A write real DynamoDB would refuse for the size of
|
|
3569
|
+
the collection it lands in goes through. `ReturnItemCollectionMetrics` is refused by name, and a
|
|
3570
|
+
write cannot ask how large the collection it touched has grown either.
|
|
3571
|
+
- An index key is one attribute, or two. Real DynamoDB now takes more than that, and a key schema of
|
|
3572
|
+
more than two elements is refused here.
|
|
3573
|
+
- `ItemCount` and `IndexSizeBytes` are 0 for every index, the same way the table's own figures are.
|
|
3574
|
+
- Per-index `ProvisionedThroughput` is read, validated and reported, and enforces no limit. No read
|
|
3575
|
+
or write against an index is throttled, since none against the table is either.
|
|
3576
|
+
- A local secondary index cannot be added to or removed from a table after it has been created, and
|
|
3577
|
+
that is AWS behaviour rather than a limitation here. `CreateTable` is the only call that declares
|
|
3578
|
+
one. `UpdateTable` refuses a `LocalSecondaryIndexes` change by having no such parameter at all, as
|
|
3579
|
+
AWS does.
|
|
3580
|
+
- There is no backfill to run when `UpdateTable` adds an index, since which items an index holds is
|
|
3581
|
+
worked out when the index is read. The `CREATING` window is a status the background scheduler
|
|
3582
|
+
advances rather than work being done. The index answers for the items already on the table the
|
|
3583
|
+
moment it goes `ACTIVE`. What a test observes matches AWS while the mechanism differs. No
|
|
3584
|
+
operation here takes longer to add an index to a large table than to an empty one.
|
|
3585
|
+
- `Backfilling` is reported as true while a new index is `CREATING` and left out once it is
|
|
3586
|
+
`ACTIVE`. Real DynamoDB has a second phase in which the index is still `CREATING` with
|
|
3587
|
+
`Backfilling` false. After that point it can no longer be deleted mid-build. That phase is left
|
|
3588
|
+
out. An index here can be deleted at any point before it is `ACTIVE`.
|
|
3589
|
+
- Changing the provisioned capacity of an existing global secondary index is refused outright. A
|
|
3590
|
+
per-index capacity is read and reported but enforces no limit, so changing one would move a number
|
|
3591
|
+
with no effect.
|
|
3592
|
+
- Switching a table to `PROVISIONED` with `UpdateTable` has to state the capacity. Real DynamoDB
|
|
3593
|
+
estimates it from the table's consumption over the previous half hour, and no measurement of
|
|
3594
|
+
consumption happens here, and an estimate would be an invented number that a deployment then reads
|
|
3595
|
+
back.
|
|
3596
|
+
- An `AttributeDefinition` for an index that has since been deleted stays on the table. No call
|
|
3597
|
+
removes a definition. A table can report one that no key now uses, which `CreateTable` would have
|
|
3598
|
+
refused on the way in.
|
|
3599
|
+
- Tagging is immediate. AWS documents `TagResource` and `UntagResource` as eventually consistent. A
|
|
3600
|
+
real `ListTagsOfResource` issued straight after one of them may answer with the previous tags or
|
|
3601
|
+
with none. Here the change is there by the time the call returns, and a test cannot observe the
|
|
3602
|
+
window a retry would be written for.
|
|
3603
|
+
- A `ListTagsOfResource` page carries 25 tags. The API has no page size parameter. The number is
|
|
3604
|
+
this simulator's own choice rather than DynamoDB's, and a real page may hold a different number.
|
|
3605
|
+
- The 10 KB limit on the total size of a resource's tags goes unenforced. The 50 tag count and the
|
|
3606
|
+
key and value lengths are, and 50 tags of the greatest key and value length are over 10 KB. A set
|
|
3607
|
+
of tags real DynamoDB would refuse for its size is accepted here.
|
|
3608
|
+
- Exceeding the 50 tag limit is a `ValidationException`. Real DynamoDB documents
|
|
3609
|
+
`LimitExceededException` for `TagResource`, but describes it entirely in terms of how many table
|
|
3610
|
+
operations are running at once, which is a different thing from how many tags a table carries.
|
|
3611
|
+
- Tag based IAM condition keys are absent. `aws:RequestTag`, `aws:ResourceTag` and `aws:TagKeys` go
|
|
3612
|
+
unevaluated, and a policy that allows tagging only under a particular key allows all of it here.
|
|
3613
|
+
- Tables are the only taggable DynamoDB resource here. Backups and global table replicas are absent.
|
|
3614
|
+
An ARN naming one of those resolves to no resource. Real DynamoDB also copies a table's tags onto
|
|
3615
|
+
its secondary indexes, which have no target to copy to yet.
|
|
3616
|
+
- The time to live deletion window is a fixed 48 hours, where AWS promises only that an expired item
|
|
3617
|
+
is typically deleted within 48 hours. A simulation has to pick a point in that range, and this
|
|
3618
|
+
picks the far end, because that is the longest an expired item can still be readable and so is the
|
|
3619
|
+
behaviour an application has to cope with. A test can rely on an item surviving its TTL timestamp,
|
|
3620
|
+
and on it being gone once the window has passed. It should not assert that an expired item is
|
|
3621
|
+
still there partway through the window, since real DynamoDB may well have collected it by then.
|
|
3622
|
+
- Time to live expiry is dispatched by moving the clock through `simAws.clock()`, not by real time
|
|
3623
|
+
elapsing. An item whose window goes by while a running-mode clock tracks the host stays where it
|
|
3624
|
+
is until something moves the clock. A simulated DynamoDB constructed standalone as `new
|
|
3625
|
+
SimDynamoDb()` has no clock control at all. No item there ever expires.
|
|
3626
|
+
- A Lambda event source mapping is the only simulated service integration that consumes a stream.
|
|
3627
|
+
Anything else reads one through the Streams API itself. A Kinesis Data Streams destination is
|
|
3628
|
+
absent.
|
|
3629
|
+
- `Fn::GetAtt … StreamArn` on a table with no `StreamSpecification` is refused when the attribute is
|
|
3630
|
+
asked for, where real CloudFormation refuses the template while validating it. The timing differs,
|
|
3631
|
+
and the outcome matches.
|
|
3632
|
+
- Changing a deployed table's `StreamViewType` falls short of the table replacement real
|
|
3633
|
+
CloudFormation performs. The change goes through `UpdateTable`, which refuses a view type change
|
|
3634
|
+
in place.
|
|
3635
|
+
- An `AWS::DynamoDB::GlobalTable` naming two or more replica regions is created as an ordinary table
|
|
3636
|
+
in the region the stack is deploying into, with `Replicas` recorded in `stack.ignoredProperties`.
|
|
3637
|
+
Replication between regions is absent at all, so everything the table does within one region
|
|
3638
|
+
behaves as the template describes and no data is copied to the others. A replica list that leaves
|
|
3639
|
+
out the stack's own region is refused, as real CloudFormation refuses it.
|
|
3640
|
+
- A global table's per-replica settings cannot differ from the primary's, because there is only ever
|
|
3641
|
+
one replica. Anything a second replica would have said differently cannot be reached.
|
|
3642
|
+
- `WriteCapacityAutoScalingSettings` and `ReadCapacityAutoScalingSettings` are recorded, never
|
|
3643
|
+
applied, and the table is created at the `MinCapacity` each of them names, and that is where
|
|
3644
|
+
autoscaling starts it on AWS. No process here scales capacity afterwards.
|
|
3645
|
+
- A shard iterator never expires. Real DynamoDB gives one 15 minutes and then answers
|
|
3646
|
+
`ExpiredIteratorException`, which a consumer handles by asking for another from the sequence
|
|
3647
|
+
number it last checkpointed. No check here refuses an iterator for being old.
|
|
3648
|
+
- `DescribeStream` never reports a `LastEvaluatedShardId`, since a simulated stream has one shard
|
|
3649
|
+
and a page of shards is always all of them. `ShardFilter` is refused by name, never ignored, since
|
|
3650
|
+
there is no shard lineage for it to walk.
|
|
3651
|
+
- A stream is never dropped once everything on it has been trimmed. Real DynamoDB eventually stops
|
|
3652
|
+
listing a disabled stream whose records have all aged out, where the ARN a test is holding goes on
|
|
3653
|
+
resolving here and reads as empty.
|
|
3654
|
+
- The two readers per shard throughput limit and the `DescribeStream` rate limit go unapplied. Both
|
|
3655
|
+
are throughput protections a single-process simulation cannot produce honestly.
|
|
3656
|
+
- The five year time to live eligibility rule counts 1825 days rather than five calendar years, and
|
|
3657
|
+
an item whose timestamp sits within a couple of days of the boundary may be treated differently
|
|
3658
|
+
here to how AWS treats it.
|
|
3659
|
+
- A stream has one shard, which never splits. AWS documents an open shard as corresponding to one
|
|
3660
|
+
table partition, and a simulated table is always one partition. This is accurate. It does mean the
|
|
3661
|
+
records come out in one total order across every key, and that is stronger than the per-key order
|
|
3662
|
+
AWS guarantees. A consumer relying on it here would be relying on something real DynamoDB leaves
|
|
3663
|
+
unpromised.
|
|
3664
|
+
- Stream sequence numbers are a counter rendered at a fixed 21 digits, where real AWS varies the
|
|
3665
|
+
width between 21 and 40. That makes comparing them as text always agree with comparing them as
|
|
3666
|
+
numbers, and that divergence is in a reader's favour. They are independent of the clock, because
|
|
3667
|
+
several items commonly change inside one millisecond and a clock cannot tell those apart.
|
|
3668
|
+
- A stream record's `SizeBytes` counts the text of each value, summed over the keys and every image
|
|
3669
|
+
the record carries. That is the rule AWS's own published sample records follow, and it differs
|
|
3670
|
+
from the rule the 400 KB item limit uses, where a number costs about half its digits.
|
|
3671
|
+
- `KinesisStreamSpecification` is absent. A table's changes go to its own stream or nowhere.
|
|
3672
|
+
- Encryption at rest is absent. An `SSESpecification` with `Enabled` set on a CloudFormation
|
|
3673
|
+
Resource is recorded, and items are still held in the clear. `Enabled: false` asks for the AWS
|
|
3674
|
+
owned key real DynamoDB uses by default. It is accepted.
|
|
3675
|
+
- Table resource policies are absent. `ResourcePolicy` on a CloudFormation Resource is recorded, and
|
|
3676
|
+
a table a policy was meant to keep callers out of is open here and closed on AWS.
|
|
3677
|
+
- `OnDemandThroughput` and `WarmThroughput` are recorded, never applied. No process here applies a
|
|
3678
|
+
request-unit maximum or pre-warms capacity.
|
|
3679
|
+
- `BillingModeSummary` and `TableClassSummary` are reported only when the request named a
|
|
3680
|
+
`BillingMode` or a `TableClass`. Real DynamoDB reports the effective values whichever way the
|
|
3681
|
+
table was created.
|
|
3682
|
+
- `ItemCount` and `TableSizeBytes` are always 0. Real DynamoDB updates both about every six hours.
|
|
3683
|
+
They lag behind the items there too.
|
|
3684
|
+
- Deletion happens as soon as the background work runs, where real DynamoDB may take a while over a
|
|
3685
|
+
large table. No call waits for a `DELETING` table to go. A test that needs it gone calls
|
|
3686
|
+
`simAws.backgroundTasksComplete()`.
|
|
3687
|
+
- The segment a parallel scan puts an item in differs from the segment real DynamoDB would put it
|
|
3688
|
+
in. DynamoDB's partition key hash is unpublished, and a different one is used here. What matches
|
|
3689
|
+
is the shape. Whole item collections move together, and the segments come out uneven. A test
|
|
3690
|
+
asserting which segment a given key lands in is asserting something about this simulator in place
|
|
3691
|
+
of about DynamoDB.
|
|
3692
|
+
- A table ARN naming another Account or Region is refused, never resolved to the local table of that
|
|
3693
|
+
name. Cross-account table access needs a resource policy, and that is absent here.
|
|
3694
|
+
- A table in `UPDATING` refuses `DeleteTable` as well as a second `UpdateTable`, as AWS behaves,
|
|
3695
|
+
since a table has to be `ACTIVE` before either.
|
|
3696
|
+
- No check enforces capacity. A provisioned table's throughput is stored and reported, and no
|
|
3697
|
+
request is ever throttled with `ProvisionedThroughputExceededException`.
|
|
3698
|
+
- A query or scan page is never cut short by size. Real DynamoDB stops a page at 1 MB and hands out
|
|
3699
|
+
a `LastEvaluatedKey`, which is left out here. A page breaks only on a `Limit`. A test reading a
|
|
3700
|
+
large collection or a large table with no `Limit` gets all of it in one page where a real one
|
|
3701
|
+
would page.
|
|
3702
|
+
- No part of this throttles or measures a query or a scan, so `ReturnConsumedCapacity` is refused
|
|
3703
|
+
unless it names `NONE`, and a `Limit` is the only thing that ends a page early.
|
|
3704
|
+
- A key condition takes no brackets. `(customerId = :c) AND orderId > :o` is refused here, where
|
|
3705
|
+
real DynamoDB accepts it. The shape of a key condition is fixed. There is no sub-expression for
|
|
3706
|
+
brackets to group, and being stricter is the direction that fails safely. It is a puzzling refusal
|
|
3707
|
+
here rather than a query that means something different on AWS.
|
|
3708
|
+
- `ProjectionExpression` is refused on `Query` and on `Scan`, since it changes which parts of an
|
|
3709
|
+
item the operation answers with. `Select` covers the counted and the projected read in the
|
|
3710
|
+
meantime.
|
|
3711
|
+
- `UnprocessedItems` and `UnprocessedKeys` are always empty. No request here is throttled and no
|
|
3712
|
+
response stops at a size, and the branch of a batch retry loop that resends what did not go
|
|
3713
|
+
through is never taken against the simulator.
|
|
3714
|
+
- The 16 MB limit on a batch request goes unenforced. Real DynamoDB counts the request as the JSON
|
|
3715
|
+
it arrived as, and the JSON is larger than the items it carries. That inflation is left out. A
|
|
3716
|
+
batch of 25 items under 400 KB each is under 10 MB by the sizes counted here. No measurement this
|
|
3717
|
+
simulation takes ever reaches 16 MB.
|
|
3718
|
+
- `TransactionConflictException` and `TransactionInProgressException` are left out. Every call here
|
|
3719
|
+
is serialised in one process. No transaction ever meets another one working on the same item, and
|
|
3720
|
+
both errors are out of reach.
|
|
3721
|
+
- Only `None` and `ConditionalCheckFailed` appear as cancellation codes. No request here is
|
|
3722
|
+
throttled and no item collection is tracked, so `ProvisionedThroughputExceeded` and
|
|
3723
|
+
`ItemCollectionSizeLimitExceeded` never happen, and input DynamoDB would report as a
|
|
3724
|
+
`ValidationError` per action is refused up front as a `ValidationException` for the whole request.
|
|
3725
|
+
- The 4 MB limit on a transactional write is counted from the items and keys the actions carry,
|
|
3726
|
+
rather than from the JSON the request arrived as, which is larger. A transaction near the limit
|
|
3727
|
+
here is near the limit there, but the byte counts differ.
|
|
3728
|
+
- A `ClientRequestToken` is compared against the `TransactItems` as the JSON they arrived as, and a
|
|
3729
|
+
retry that names the same actions in a different order reads as a different request and is refused
|
|
3730
|
+
in place of replayed. A retry of the same call sends the same JSON. This shows up only in a test
|
|
3731
|
+
that rebuilds the request by hand.
|
|
3732
|
+
- AWS creates one table with secondary indexes at a time in an account and region, and refuses a
|
|
3733
|
+
`CreateTable` that overlaps another one. Simulated CloudFormation creates each batch of resources
|
|
3734
|
+
whose dependencies are met at once. A template holding two indexed tables with no `DependsOn`
|
|
3735
|
+
between them deploys here and may not on AWS. That is the exact template shape that diverges. One
|
|
3736
|
+
indexed table, or several with `DependsOn` ordering them, behaves the same either way.
|
|
3737
|
+
- A CloudFormation stack reaches `CREATE_COMPLETE` while the table it created is still `CREATING`.
|
|
3738
|
+
Real CloudFormation waits for the table to be `ACTIVE`, and a test reading the status after the
|
|
3739
|
+
stack deployed calls `simAws.backgroundTasksComplete()` first.
|
|
3740
|
+
- A CloudFormation stack update replaces a changed table rather than updating it in place. The items
|
|
3741
|
+
in it are lost where real CloudFormation would keep them for a property it can change without
|
|
3742
|
+
replacement.
|
|
3743
|
+
- `ProjectionExpression` is simulated on `GetItem`, `BatchGetItem` and `TransactGetItems`. On
|
|
3744
|
+
`Query` and `Scan` it is refused, never ignored.
|
|
3745
|
+
- The legacy `AttributesToGet` is refused outright, since an item that came back whole where part of
|
|
3746
|
+
it was asked for would hide an application reading an attribute it never requested.
|
|
3747
|
+
`ProjectionExpression` replaced it, and real DynamoDB has built no feature on it since.
|
|
3748
|
+
- The 4 KB limit on an expression and the 255 byte limit on a placeholder go unenforced. No
|
|
3749
|
+
operation here is slower for a long expression. An expression real DynamoDB would refuse for its
|
|
3750
|
+
size is evaluated.
|
|
3751
|
+
- Reads are always strongly consistent. `ConsistentRead` is accepted either way and changes nothing,
|
|
3752
|
+
whether a request sets it once for a read or per table for a batch read, and a test cannot observe
|
|
3753
|
+
a stale read here the way it might against a real table.
|
|
3754
|
+
- Condition expressions are simulated on `PutItem`, `DeleteItem`, `UpdateItem` and the actions of
|
|
3755
|
+
`TransactWriteItems`, key conditions on `Query`, and filters on `Query` and `Scan`.
|
|
3756
|
+
- Two update actions cannot write to overlapping paths, so `ADD tags :added DELETE tags :gone` in
|
|
3757
|
+
one expression is refused as it is on AWS. Taking members out of a set an expression also adds to
|
|
3758
|
+
is a second update.
|
|
3759
|
+
- An update is applied in one go. No part of this shows the concurrency an atomic counter is for. A
|
|
3760
|
+
simulated `ADD` counts exactly once per call, where a real one is what makes two callers counting
|
|
3761
|
+
at the same time both count.
|
|
3762
|
+
- The legacy `AttributeUpdates` is refused outright, for the same reason `Expected` is.
|
|
3763
|
+
`UpdateExpression` replaced it, and real DynamoDB has built no feature on it since.
|
|
3764
|
+
- A `REMOVE` whose path reaches through an attribute that is missing, or that is something other
|
|
3765
|
+
than a map, changes nothing rather than being refused. `REMOVE` names a place in the item. There
|
|
3766
|
+
was nothing there to remove either way.
|
|
3767
|
+
- The legacy `Expected` and `ConditionalOperator` are refused outright, since an expectation that is
|
|
3768
|
+
never evaluated would let a write or a delete through that DynamoDB would have turned away.
|
|
3769
|
+
`ConditionExpression` replaced them, and real DynamoDB has built no feature on them since.
|
|
3770
|
+
- The 4 KB limit on an expression and the 300 operator limit go unenforced. No operation here is
|
|
3771
|
+
slower for a long expression, and an expression real DynamoDB would refuse for its size is
|
|
3772
|
+
evaluated.
|
|
3773
|
+
- Capacity and item collection reporting are absent. `ReturnConsumedCapacity` and
|
|
3774
|
+
`ReturnItemCollectionMetrics` are refused unless they name `NONE`.
|
|
3775
|
+
- A `Key` that fails to match the table's key schema is refused with the attribute named. Real
|
|
3776
|
+
DynamoDB answers `The provided key element fails to match the schema` without saying which
|
|
3777
|
+
attribute was at fault.
|
|
3778
|
+
- A number comes back in plain decimal notation, whatever notation it was written in. A request
|
|
3779
|
+
carrying `1E5` reads back `100000`. The value is the one that was written either way, but the text
|
|
3780
|
+
is only sometimes character for character what real DynamoDB would answer with for a number at the
|
|
3781
|
+
extremes of its range.
|
|
3782
|
+
- Item sizes follow the figures AWS documents for its 400 KB limit, which AWS itself describes as
|
|
3783
|
+
approximate. An item near the limit here is near the limit there, but the byte counts differ.
|
|
3784
|
+
- PartiQL is absent, and stays off the roadmap for this service.
|
|
3785
|
+
- `serveSimAws` serves no DynamoDB HTTP API.
|