@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.
Files changed (355) hide show
  1. package/README.md +13 -0
  2. package/dist/sdk/module/sim-sdk-module-client-interceptor.d.ts +5 -0
  3. package/dist/sdk/module/sim-sdk-module-client-interceptor.js +14 -0
  4. package/dist/sdk/module/sim-sdk-static-client-factory.d.ts +26 -0
  5. package/dist/sdk/module/sim-sdk-static-client-factory.js +59 -0
  6. package/dist/sdk/send-patch.d.ts +8 -0
  7. package/dist/sdk/send-patch.js +11 -0
  8. package/dist/service/aws/caller/sim-aws-ambient-caller.d.ts +23 -0
  9. package/dist/service/aws/caller/sim-aws-ambient-caller.js +12 -0
  10. package/dist/service/aws/caller/sim-aws-caller-resolver.d.ts +39 -6
  11. package/dist/service/aws/caller/sim-aws-caller-resolver.js +32 -30
  12. package/dist/service/aws/caller/sim-aws-caller.d.ts +9 -0
  13. package/dist/service/aws/caller/sim-aws-resolved-caller.d.ts +19 -0
  14. package/dist/service/aws/caller/sim-aws-resolved-caller.js +26 -0
  15. package/dist/service/aws/factory/sim-aws-account-region-service-builder.d.ts +12 -0
  16. package/dist/service/aws/factory/sim-aws-account-region-service-builder.js +6 -0
  17. package/dist/service/aws/factory/sim-aws-account-service-cache.d.ts +12 -0
  18. package/dist/service/aws/factory/sim-aws-account-service-cache.js +9 -0
  19. package/dist/service/aws/factory/sim-aws-registered-service-builder.js +3 -1
  20. package/dist/service/aws/factory/sim-aws-service-factory.d.ts +5 -0
  21. package/dist/service/aws/factory/sim-aws-service-factory.js +14 -0
  22. package/dist/service/aws/sim-aws-properties.d.ts +15 -0
  23. package/dist/service/aws/sim-aws.js +2 -1
  24. package/dist/service/cloudformation/command/create-stack/create-stack.handler.d.ts +4 -0
  25. package/dist/service/cloudformation/command/create-stack/create-stack.handler.js +4 -1
  26. package/dist/service/cloudformation/deploy/sim-cfn-cdk-out-deployer.js +2 -1
  27. package/dist/service/cloudformation/deploy/sim-cfn-cdk-out-plan.d.ts +11 -2
  28. package/dist/service/cloudformation/deploy/sim-cfn-cdk-out-plan.js +2 -1
  29. package/dist/service/cloudformation/deploy/sim-cfn-cdk-out-stack-options.d.ts +6 -0
  30. package/dist/service/cloudformation/deploy/sim-cfn-template-deployer.d.ts +11 -0
  31. package/dist/service/cloudformation/deploy/sim-cfn-template-deployer.js +17 -15
  32. package/dist/service/cloudformation/deploy/sim-cfn-template-file-loader.d.ts +12 -0
  33. package/dist/service/cloudformation/deploy/sim-cfn-template-file-loader.js +2 -1
  34. package/dist/service/cloudformation/resource/cfn/cloudfront/sim-cloudfront-cache-policy-cfn.d.ts +23 -0
  35. package/dist/service/cloudformation/resource/cfn/cloudfront/sim-cloudfront-cache-policy-cfn.js +30 -0
  36. package/dist/service/cloudformation/resource/cfn/cloudfront/sim-cloudfront-cfn-value-adapter.js +8 -0
  37. package/dist/service/cloudformation/resource/name/sim-cfn-generated-resource-name.d.ts +29 -12
  38. package/dist/service/cloudformation/resource/name/sim-cfn-generated-resource-name.js +51 -20
  39. package/dist/service/cloudformation/stack/deploy/sim-cfn-resource-order.d.ts +19 -0
  40. package/dist/service/cloudformation/stack/deploy/sim-cfn-resource-order.js +6 -0
  41. package/dist/service/cloudformation/stack/deploy/sim-cfn-stack-resource-batch-creator.d.ts +7 -0
  42. package/dist/service/cloudformation/stack/deploy/sim-cfn-stack-resource-batch-creator.js +9 -2
  43. package/dist/service/cloudformation/stack/deploy/sim-cfn-stack-resource-creator.d.ts +2 -0
  44. package/dist/service/cloudformation/stack/deploy/sim-cfn-stack-resource-creator.js +2 -1
  45. package/dist/service/cloudformation/stack/sim-cfn-stack-resource-operations.d.ts +6 -0
  46. package/dist/service/cloudformation/stack/sim-cfn-stack-resource-operations.js +4 -1
  47. package/dist/service/cloudformation/stack/sim-cfn-stack.js +2 -1
  48. package/dist/service/cloudformation/stack/sim-cfn-stack.type.d.ts +6 -0
  49. package/dist/service/cloudfront/behaviour/sim-cloud-front-behavior.d.ts +5 -0
  50. package/dist/service/cloudfront/cache-policy/sim-cf-cache-policy-registry.d.ts +37 -0
  51. package/dist/service/cloudfront/cache-policy/sim-cf-cache-policy-registry.js +50 -0
  52. package/dist/service/cloudfront/cache-policy/sim-cf-cache-policy.d.ts +25 -0
  53. package/dist/service/cloudfront/cache-policy/sim-cf-cache-policy.js +21 -0
  54. package/dist/service/cloudfront/cache-policy/sim-cf-managed-cache-policies.d.ts +28 -0
  55. package/dist/service/cloudfront/cache-policy/sim-cf-managed-cache-policies.js +48 -0
  56. package/dist/service/cloudfront/cff/function-code-input/cff-function-code-input.d.ts +8 -0
  57. package/dist/service/cloudfront/cff/function-code-input/cff-function-code-input.js +13 -0
  58. package/dist/service/cloudfront/cff/sim-cf-function-commands.d.ts +52 -0
  59. package/dist/service/cloudfront/cff/sim-cf-function-commands.js +51 -0
  60. package/dist/service/cloudfront/cff/sim-cff-configuration.d.ts +41 -0
  61. package/dist/service/cloudfront/cff/sim-cff-configuration.js +34 -0
  62. package/dist/service/cloudfront/cff/sim-cff-stage.d.ts +23 -0
  63. package/dist/service/cloudfront/cff/sim-cff-stage.js +30 -0
  64. package/dist/service/cloudfront/cff/sim-cloudfront-function.d.ts +6 -1
  65. package/dist/service/cloudfront/cff/sim-cloudfront-function.js +6 -0
  66. package/dist/service/cloudfront/cfn/cache-policy/sim-cfn-cf-cache-policy-config.d.ts +27 -0
  67. package/dist/service/cloudfront/cfn/cache-policy/sim-cfn-cf-cache-policy-config.js +42 -0
  68. package/dist/service/cloudfront/cfn/cache-policy/sim-cfn-cf-cache-policy-creator.d.ts +24 -0
  69. package/dist/service/cloudfront/cfn/cache-policy/sim-cfn-cf-cache-policy-creator.js +32 -0
  70. 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
  71. package/dist/service/cloudfront/cfn/distro/{sim-cfn-cf-distro-response-headers-policy.js → sim-cfn-cf-distro-behavior-policies.js} +11 -37
  72. package/dist/service/cloudfront/cfn/distro/sim-cfn-cf-distro-creator.d.ts +6 -5
  73. package/dist/service/cloudfront/cfn/distro/sim-cfn-cf-distro-creator.js +8 -7
  74. package/dist/service/cloudfront/cfn/distro/sim-cfn-cf-distro-policy-drops.d.ts +23 -0
  75. package/dist/service/cloudfront/cfn/distro/sim-cfn-cf-distro-policy-drops.js +50 -0
  76. package/dist/service/cloudfront/cfn/sim-cfn-cloudfront-resource-factory.d.ts +1 -0
  77. package/dist/service/cloudfront/cfn/sim-cfn-cloudfront-resource-factory.js +10 -0
  78. package/dist/service/cloudfront/command/create-distribution/create-distribution.command.d.ts +1 -0
  79. package/dist/service/cloudfront/command/create-distribution/create-distribution.handler.d.ts +2 -0
  80. package/dist/service/cloudfront/command/create-function/create-function.handler.js +7 -1
  81. package/dist/service/cloudfront/command/function/sim-cf-describe-function.d.ts +22 -0
  82. package/dist/service/cloudfront/command/function/sim-cf-describe-function.js +32 -0
  83. package/dist/service/cloudfront/command/function/sim-cf-function-access.d.ts +48 -0
  84. package/dist/service/cloudfront/command/function/sim-cf-function-access.js +48 -0
  85. package/dist/service/cloudfront/command/function/sim-cf-function-command.types.d.ts +97 -0
  86. package/dist/service/cloudfront/command/function/sim-cf-function-command.types.js +10 -0
  87. package/dist/service/cloudfront/command/function/sim-cf-function-lookup.d.ts +12 -0
  88. package/dist/service/cloudfront/command/function/sim-cf-function-lookup.js +21 -0
  89. package/dist/service/cloudfront/command/function/sim-cf-function-summary.d.ts +11 -0
  90. package/dist/service/cloudfront/command/function/sim-cf-function-summary.js +30 -0
  91. package/dist/service/cloudfront/command/function/sim-cf-get-function.d.ts +23 -0
  92. package/dist/service/cloudfront/command/function/sim-cf-get-function.js +33 -0
  93. package/dist/service/cloudfront/command/function/sim-cf-list-functions.d.ts +22 -0
  94. package/dist/service/cloudfront/command/function/sim-cf-list-functions.js +38 -0
  95. package/dist/service/cloudfront/command/update-distribution/update-distribution.handler.d.ts +2 -0
  96. package/dist/service/cloudfront/distribution/configurator/sim-cf-behavior-cache-policy.d.ts +18 -0
  97. package/dist/service/cloudfront/distribution/configurator/sim-cf-behavior-cache-policy.js +30 -0
  98. package/dist/service/cloudfront/distribution/configurator/sim-cf-behavior-policies.d.ts +28 -0
  99. package/dist/service/cloudfront/distribution/configurator/sim-cf-behavior-policies.js +41 -0
  100. package/dist/service/cloudfront/distribution/configurator/sim-cf-behavior-properties.d.ts +2 -2
  101. package/dist/service/cloudfront/distribution/configurator/sim-cf-behavior-properties.js +6 -3
  102. package/dist/service/cloudfront/distribution/configurator/sim-cf-behavior-response-headers-policy.d.ts +0 -10
  103. package/dist/service/cloudfront/distribution/configurator/sim-cf-behavior-response-headers-policy.js +0 -19
  104. package/dist/service/cloudfront/distribution/configurator/sim-cf-distribution-configurator.factory.d.ts +2 -0
  105. package/dist/service/cloudfront/distribution/configurator/sim-cf-distribution-configurator.factory.js +4 -2
  106. package/dist/service/cloudfront/distribution/configurator/sim-cloud-front-behavior-configurator.d.ts +3 -3
  107. package/dist/service/cloudfront/distribution/configurator/sim-cloud-front-behavior-configurator.js +5 -5
  108. package/dist/service/cloudfront/distribution/configurator/sim-cloud-front-distribution-configurator.d.ts +3 -3
  109. package/dist/service/cloudfront/distribution/configurator/sim-cloud-front-distribution-configurator.js +4 -4
  110. package/dist/service/cloudfront/distribution/sim-cf-distribution-configuration-state.d.ts +3 -1
  111. package/dist/service/cloudfront/distribution/sim-cf-distribution-reconfigurer.d.ts +2 -0
  112. package/dist/service/cloudfront/error/sim-cloudfront.error.d.ts +21 -0
  113. package/dist/service/cloudfront/error/sim-cloudfront.error.js +25 -0
  114. package/dist/service/cloudfront/key-value-store/sim-cf-key-value-store-access.js +2 -10
  115. package/dist/service/cloudfront/key-value-store/sim-cf-key-value-store.js +2 -1
  116. package/dist/service/cloudfront/sdk/sim-cloudfront-sdk-command-router.js +12 -0
  117. package/dist/service/cloudfront/sim-cf-authorize.d.ts +16 -0
  118. package/dist/service/cloudfront/sim-cf-authorize.js +19 -0
  119. package/dist/service/cloudfront/sim-cf-etag.d.ts +7 -0
  120. package/dist/service/cloudfront/sim-cf-etag.js +10 -0
  121. package/dist/service/cloudfront/sim-cloudfront-commands.d.ts +8 -12
  122. package/dist/service/cloudfront/sim-cloudfront-commands.js +8 -17
  123. package/dist/service/cloudfront/sim-cloudfront-policies.d.ts +43 -0
  124. package/dist/service/cloudfront/sim-cloudfront-policies.js +53 -0
  125. package/dist/service/cloudfront/sim-cloudfront.d.ts +13 -17
  126. package/dist/service/cloudfront/sim-cloudfront.js +22 -25
  127. package/dist/service/ecr/authorize/sim-ecr-authorizer.d.ts +31 -0
  128. package/dist/service/ecr/authorize/sim-ecr-authorizer.js +38 -0
  129. package/dist/service/ecr/cfn/repository/sim-cfn-ecr-repository-creator.d.ts +11 -2
  130. package/dist/service/ecr/cfn/repository/sim-cfn-ecr-repository-creator.js +11 -2
  131. package/dist/service/ecr/cfn/sim-ecr-cfn-resource-factory.d.ts +4 -2
  132. package/dist/service/ecr/cfn/sim-ecr-cfn-resource-factory.js +8 -9
  133. package/dist/service/ecr/cfn/sim-ecr-cfn-unsupported-resource.d.ts +7 -0
  134. package/dist/service/ecr/cfn/sim-ecr-cfn-unsupported-resource.js +9 -0
  135. package/dist/service/ecr/sim-ecr.d.ts +7 -0
  136. package/dist/service/ecr/sim-ecr.js +16 -4
  137. package/dist/service/ecs/command/authorize/sim-ecs-authorizer.d.ts +12 -0
  138. package/dist/service/ecs/command/authorize/sim-ecs-authorizer.js +21 -0
  139. package/dist/service/ecs/command/register-task-definition/register-task-definition.handler.js +1 -0
  140. package/dist/service/ecs/sim-ecs-service-principal.d.ts +8 -0
  141. package/dist/service/ecs/sim-ecs-service-principal.js +8 -0
  142. package/dist/service/eventbridge/command/authorize/sim-event-bridge-authorizer.d.ts +12 -0
  143. package/dist/service/eventbridge/command/authorize/sim-event-bridge-authorizer.js +21 -0
  144. package/dist/service/eventbridge/command/rule/sim-event-bridge-rule-access.d.ts +13 -0
  145. package/dist/service/eventbridge/command/rule/sim-event-bridge-rule-access.js +9 -0
  146. package/dist/service/eventbridge/command/target/sim-event-bridge-put-targets.js +1 -0
  147. package/dist/service/firehose/command/authorize/sim-firehose-authorizer.d.ts +12 -0
  148. package/dist/service/firehose/command/authorize/sim-firehose-authorizer.js +20 -0
  149. package/dist/service/firehose/command/sim-firehose-delivery-stream-access.d.ts +4 -0
  150. package/dist/service/firehose/command/sim-firehose-delivery-stream-access.js +6 -0
  151. package/dist/service/firehose/command/stream/sim-firehose-create-delivery-stream.js +3 -1
  152. package/dist/service/firehose/sim-firehose-service-principal.d.ts +8 -0
  153. package/dist/service/firehose/sim-firehose-service-principal.js +8 -0
  154. package/dist/service/firehose/source/sim-firehose-source.d.ts +5 -0
  155. package/dist/service/firehose/source/sim-firehose-source.js +5 -0
  156. package/dist/service/glue/cfn/database/sim-cfn-glue-database-properties.js +2 -2
  157. package/dist/service/glue/cfn/sim-cfn-glue-generated-name.d.ts +10 -0
  158. package/dist/service/glue/cfn/sim-cfn-glue-generated-name.js +25 -0
  159. package/dist/service/glue/cfn/table/sim-cfn-glue-table-properties.js +2 -3
  160. package/dist/service/iam/authorize/allow/sim-iam-allow-requirement.d.ts +33 -2
  161. package/dist/service/iam/authorize/allow/sim-iam-allow-requirement.js +33 -2
  162. package/dist/service/iam/authorize/allow/sim-iam-allow-statements.d.ts +4 -3
  163. package/dist/service/iam/authorize/allow/sim-iam-allow-statements.js +9 -3
  164. package/dist/service/iam/authorize/context/sim-iam-auth-z-allow-requirement.d.ts +14 -5
  165. package/dist/service/iam/authorize/context/sim-iam-auth-z-allow-requirement.js +16 -12
  166. package/dist/service/iam/authorize/context/sim-iam-auth-z-caller-context-builder.d.ts +36 -7
  167. package/dist/service/iam/authorize/context/sim-iam-auth-z-caller-context-builder.js +22 -11
  168. package/dist/service/iam/authorize/context/sim-iam-auth-z-context-builder.d.ts +10 -71
  169. package/dist/service/iam/authorize/context/sim-iam-auth-z-context-builder.js +7 -2
  170. package/dist/service/iam/authorize/context/sim-iam-auth-z-input.d.ts +87 -0
  171. package/dist/service/iam/authorize/context/sim-iam-auth-z-input.js +1 -0
  172. package/dist/service/iam/authorize/match/sim-iam-policy-principal-matcher.d.ts +21 -3
  173. package/dist/service/iam/authorize/match/sim-iam-policy-principal-matcher.js +34 -10
  174. package/dist/service/iam/authorize/match/sim-iam-principal-match.d.ts +29 -8
  175. package/dist/service/iam/authorize/match/sim-iam-principal-match.js +38 -13
  176. package/dist/service/iam/authorize/pass-role/sim-iam-pass-role-authorizer.d.ts +74 -0
  177. package/dist/service/iam/authorize/pass-role/sim-iam-pass-role-authorizer.js +92 -0
  178. package/dist/service/iam/authorize/sim-iam-account-auth-z.d.ts +21 -5
  179. package/dist/service/iam/authorize/sim-iam-account-auth-z.js +7 -3
  180. package/dist/service/iam/authorize/sim-iam-authorizer.d.ts +5 -2
  181. package/dist/service/iam/authorize/sim-iam-authorizer.js +3 -1
  182. package/dist/service/iam/authorize/sim-iam-inter-service-auth-z.d.ts +1 -1
  183. package/dist/service/iam/authorize/sim-iam-wildcard.js +39 -4
  184. package/dist/service/iam/cfn/managed-policy/sim-cfn-iam-managed-policy-creator.js +2 -1
  185. package/dist/service/iam/cfn/name/sim-cfn-iam-generated-name.d.ts +16 -0
  186. package/dist/service/iam/cfn/name/sim-cfn-iam-generated-name.js +36 -0
  187. package/dist/service/iam/cfn/role/sim-cfn-iam-role-properties-parser.js +9 -8
  188. package/dist/service/iam/cfn/sim-cfn-iam-optional-string.d.ts +17 -0
  189. package/dist/service/iam/cfn/sim-cfn-iam-optional-string.js +17 -0
  190. package/dist/service/iam/cfn/user/sim-cfn-iam-user-properties-parser.js +4 -8
  191. package/dist/service/iam/command/policy/create-policy/create-policy-input-resolver.js +2 -0
  192. package/dist/service/iam/command/policy/put-role-policy/put-role-policy.handler.js +5 -0
  193. package/dist/service/iam/command/policy/put-user-policy/put-user-policy.handler.js +5 -0
  194. package/dist/service/iam/command/role/create-role/create-role.handler.js +4 -1
  195. package/dist/service/iam/error/sim-iam.error.d.ts +11 -0
  196. package/dist/service/iam/error/sim-iam.error.js +13 -0
  197. package/dist/service/iam/index.d.ts +1 -0
  198. package/dist/service/iam/policy/sim-iam-policy-document-of-size.d.ts +18 -0
  199. package/dist/service/iam/policy/sim-iam-policy-document-of-size.js +41 -0
  200. package/dist/service/iam/role/sim-iam-deploy-role.d.ts +56 -0
  201. package/dist/service/iam/role/sim-iam-deploy-role.js +50 -0
  202. package/dist/service/iam/role/sim-iam-role-with-policy.factory.d.ts +11 -0
  203. package/dist/service/iam/role/sim-iam-role-with-policy.factory.js +4 -2
  204. package/dist/service/iam/sim-iam-account-parts.d.ts +16 -0
  205. package/dist/service/iam/sim-iam-account-parts.js +2 -0
  206. package/dist/service/iam/sim-iam.d.ts +12 -2
  207. package/dist/service/iam/sim-iam.js +12 -0
  208. package/dist/service/iam/validate/size/sim-iam-policy-document-size.d.ts +57 -0
  209. package/dist/service/iam/validate/size/sim-iam-policy-document-size.js +71 -0
  210. package/dist/service/kms/command/authorize/sim-kms-authorizer.d.ts +3 -1
  211. package/dist/service/kms/command/authorize/sim-kms-authorizer.js +4 -1
  212. package/dist/service/kms/command/sim-kms-request-options.d.ts +11 -0
  213. package/dist/service/kms/key/sim-kms-key-policy.d.ts +5 -4
  214. package/dist/service/kms/key/sim-kms-key-policy.js +4 -3
  215. package/dist/service/lambda/cfn/function/sim-cfn-lambda-function-properties-parser.d.ts +2 -2
  216. package/dist/service/lambda/cfn/function/sim-cfn-lambda-function-properties-parser.js +4 -3
  217. package/dist/service/lambda/cfn/function/sim-cfn-lambda-generated-function-name.d.ts +10 -0
  218. package/dist/service/lambda/cfn/function/sim-cfn-lambda-generated-function-name.js +25 -0
  219. package/dist/service/lambda/command/authorize/sim-lambda-resource-policies.d.ts +1 -1
  220. package/dist/service/lambda/command/create-function/create-function-authorizer.d.ts +9 -2
  221. package/dist/service/lambda/command/create-function/create-function-authorizer.js +16 -2
  222. package/dist/service/lambda/command/create-function/create-function.handler.js +1 -1
  223. package/dist/service/lambda/command/update-function-configuration/update-function-configuration-authorizer.d.ts +7 -2
  224. package/dist/service/lambda/command/update-function-configuration/update-function-configuration-authorizer.js +14 -2
  225. package/dist/service/lambda/command/update-function-configuration/update-function-configuration.handler.js +1 -1
  226. package/dist/service/lambda/function/logging/sim-lambda-function-logging.d.ts +10 -1
  227. package/dist/service/lambda/function/logging/sim-lambda-function-logging.js +25 -1
  228. package/dist/service/lambda/function/logging/sim-lambda-invoke-error-log.d.ts +13 -0
  229. package/dist/service/lambda/function/logging/sim-lambda-invoke-error-log.js +18 -0
  230. package/dist/service/lambda/sim-lambda-service-principal.d.ts +7 -0
  231. package/dist/service/lambda/sim-lambda-service-principal.js +7 -0
  232. package/dist/service/logs/cfn/delivery/sim-cfn-delivery-authorization.d.ts +46 -0
  233. package/dist/service/logs/cfn/delivery/sim-cfn-delivery-authorization.js +49 -0
  234. package/dist/service/logs/cfn/delivery/sim-cfn-delivery-creator.d.ts +5 -0
  235. package/dist/service/logs/cfn/delivery/sim-cfn-delivery-creator.js +6 -0
  236. package/dist/service/logs/cfn/delivery/sim-cfn-delivery-destination-creator.d.ts +5 -0
  237. package/dist/service/logs/cfn/delivery/sim-cfn-delivery-destination-creator.js +6 -0
  238. package/dist/service/logs/cfn/delivery/sim-cfn-delivery-source-creator.d.ts +5 -0
  239. package/dist/service/logs/cfn/delivery/sim-cfn-delivery-source-creator.js +6 -0
  240. package/dist/service/logs/cfn/group/sim-cfn-log-group-creator.d.ts +9 -2
  241. package/dist/service/logs/cfn/group/sim-cfn-log-group-creator.js +34 -5
  242. package/dist/service/logs/cfn/sim-logs-cfn-resource-deleter.js +1 -1
  243. package/dist/service/logs/cfn/sim-logs-cfn-resource-factory.d.ts +4 -0
  244. package/dist/service/logs/cfn/sim-logs-cfn-resource-factory.js +9 -4
  245. package/dist/service/logs/sim-logs-commands.d.ts +3 -0
  246. package/dist/service/logs/sim-logs-commands.js +4 -0
  247. package/dist/service/logs/sim-logs.js +6 -1
  248. package/dist/service/s3/cfn/bucket/sim-cfn-s3-bucket-creator.d.ts +2 -2
  249. package/dist/service/s3/cfn/bucket/sim-cfn-s3-bucket-creator.js +4 -3
  250. package/dist/service/s3/cfn/bucket/sim-cfn-s3-bucket-generated-name.d.ts +12 -0
  251. package/dist/service/s3/cfn/bucket/sim-cfn-s3-bucket-generated-name.js +22 -0
  252. package/dist/service/s3/command/authorize/sim-s3-bucket-resource-policies.d.ts +1 -1
  253. package/dist/service/scheduler/command/authorize/sim-scheduler-authorizer.d.ts +10 -1
  254. package/dist/service/scheduler/command/authorize/sim-scheduler-authorizer.js +19 -1
  255. package/dist/service/scheduler/command/schedule/sim-scheduler-create-schedule.js +1 -0
  256. package/dist/service/scheduler/command/schedule/sim-scheduler-schedule-access.d.ts +8 -0
  257. package/dist/service/scheduler/command/schedule/sim-scheduler-schedule-access.js +9 -0
  258. package/dist/service/scheduler/command/schedule/sim-scheduler-update-schedule.js +1 -0
  259. package/dist/service/secretsmanager/cfn/secret/sim-cfn-secrets-manager-generated-name.d.ts +15 -0
  260. package/dist/service/secretsmanager/cfn/secret/sim-cfn-secrets-manager-generated-name.js +22 -0
  261. package/dist/service/secretsmanager/cfn/secret/sim-cfn-secrets-manager-secret-properties.d.ts +4 -10
  262. package/dist/service/secretsmanager/cfn/secret/sim-cfn-secrets-manager-secret-properties.js +13 -31
  263. package/dist/service/secretsmanager/cfn/secret/sim-cfn-secrets-manager-secret-value.d.ts +17 -0
  264. package/dist/service/secretsmanager/cfn/secret/sim-cfn-secrets-manager-secret-value.js +30 -0
  265. package/dist/service/secretsmanager/secret/sim-secrets-manager-secret-name.d.ts +6 -0
  266. package/dist/service/secretsmanager/secret/sim-secrets-manager-secret-name.js +8 -3
  267. package/dist/service/sns/command/authorize/sim-sns-topic-resource-policies.d.ts +1 -1
  268. package/dist/service/sqs/command/authorize/sim-sqs-queue-resource-policies.d.ts +1 -1
  269. package/dist/service/ssm/cfn/parameter/sim-cfn-ssm-generated-parameter-name.d.ts +16 -0
  270. package/dist/service/ssm/cfn/parameter/sim-cfn-ssm-generated-parameter-name.js +23 -0
  271. package/dist/service/ssm/cfn/parameter/sim-cfn-ssm-parameter-properties.d.ts +2 -4
  272. package/dist/service/ssm/cfn/parameter/sim-cfn-ssm-parameter-properties.js +5 -5
  273. package/dist/service/ssm/parameter/sim-ssm-kms-crypto.d.ts +6 -4
  274. package/dist/service/ssm/parameter/sim-ssm-kms-crypto.js +4 -4
  275. package/dist/service/ssm/parameter/sim-ssm-parameter-arn.d.ts +17 -0
  276. package/dist/service/ssm/parameter/sim-ssm-parameter-arn.js +20 -0
  277. package/dist/service/ssm/parameter/sim-ssm-parameter-kms.d.ts +15 -9
  278. package/dist/service/ssm/parameter/sim-ssm-parameter-kms.js +20 -10
  279. package/dist/service/ssm/parameter/sim-ssm-parameter-name.js +3 -12
  280. package/dist/service/stepfunctions/command/authorize/sim-step-functions-authorizer.d.ts +41 -0
  281. package/dist/service/stepfunctions/command/authorize/sim-step-functions-authorizer.js +64 -0
  282. package/dist/service/stepfunctions/command/machine/sim-state-machine-create.d.ts +5 -2
  283. package/dist/service/stepfunctions/command/machine/sim-state-machine-create.js +6 -2
  284. package/dist/service/stepfunctions/command/machine/sim-state-machine-delete.d.ts +26 -0
  285. package/dist/service/stepfunctions/command/machine/sim-state-machine-delete.js +29 -0
  286. package/dist/service/stepfunctions/command/machine/sim-state-machine-lookup.d.ts +7 -0
  287. package/dist/service/stepfunctions/command/machine/sim-state-machine-lookup.js +12 -3
  288. package/dist/service/stepfunctions/command/machine/sim-state-machine-update.d.ts +19 -0
  289. package/dist/service/stepfunctions/command/machine/{sim-state-machine-writes.js → sim-state-machine-update.js} +3 -14
  290. package/dist/service/stepfunctions/sim-step-functions.d.ts +8 -2
  291. package/dist/service/stepfunctions/sim-step-functions.js +26 -9
  292. package/dist/service/sts/auth-z/assume-role-auth-z-coordinator.d.ts +9 -2
  293. package/dist/service/sts/auth-z/assume-role-auth-z-coordinator.js +2 -2
  294. package/dist/service/sts/auth-z/assume-role-source-account-auth-z.d.ts +10 -4
  295. package/dist/service/sts/auth-z/assume-role-source-account-auth-z.js +8 -7
  296. package/dist/service/sts/auth-z/assume-role-target-auth-z.d.ts +2 -2
  297. package/dist/service/sts/auth-z/assume-role-trust-policy-authorizer.d.ts +6 -2
  298. package/dist/service/sts/auth-z/assume-role-trust-policy-authorizer.js +14 -11
  299. package/dist/service/sts/command/assume-role/assume-role.handler.d.ts +10 -1
  300. package/dist/service/sts/command/assume-role/assume-role.handler.js +6 -2
  301. package/dist/service/sts/command/get-caller-identity/get-caller-identity.handler.d.ts +10 -1
  302. package/dist/service/sts/command/get-caller-identity/get-caller-identity.handler.js +5 -1
  303. package/dist/service/sts/service-role/sim-service-role.js +5 -1
  304. package/dist/service/sts/sim-sts.d.ts +18 -1
  305. package/dist/service/sts/sim-sts.js +8 -0
  306. package/dist/util/memo/bounded-memo.d.ts +36 -0
  307. package/dist/util/memo/bounded-memo.js +58 -0
  308. package/docs/README.md +54 -0
  309. package/docs/ai-skill/README.md +69 -0
  310. package/docs/cli/README.md +284 -0
  311. package/docs/factories/README.md +140 -0
  312. package/docs/lint/README.md +176 -0
  313. package/docs/non-aws-dependencies/README.md +261 -0
  314. package/docs/sdk/README.md +274 -0
  315. package/docs/serve/README.md +1126 -0
  316. package/docs/services/acm/README.md +823 -0
  317. package/docs/services/apigateway/README.md +1919 -0
  318. package/docs/services/apigatewayv2/README.md +2678 -0
  319. package/docs/services/athena/README.md +1044 -0
  320. package/docs/services/bedrock/README.md +350 -0
  321. package/docs/services/cloudformation/README.md +3625 -0
  322. package/docs/services/cloudfront/README.md +3098 -0
  323. package/docs/services/cloudwatch/README.md +422 -0
  324. package/docs/services/cognito/README.md +4924 -0
  325. package/docs/services/dynamodb/README.md +3785 -0
  326. package/docs/services/ecr/README.md +302 -0
  327. package/docs/services/ecs/README.md +2021 -0
  328. package/docs/services/elbv2/README.md +1901 -0
  329. package/docs/services/eventbridge/README.md +1118 -0
  330. package/docs/services/firehose/README.md +811 -0
  331. package/docs/services/glue/README.md +507 -0
  332. package/docs/services/iam/README.md +1578 -0
  333. package/docs/services/kinesis/README.md +455 -0
  334. package/docs/services/kms/README.md +664 -0
  335. package/docs/services/lambda/README.md +3719 -0
  336. package/docs/services/logs/README.md +882 -0
  337. package/docs/services/organizations/README.md +574 -0
  338. package/docs/services/personalize/README.md +835 -0
  339. package/docs/services/rekognition/README.md +1070 -0
  340. package/docs/services/route53/README.md +1783 -0
  341. package/docs/services/s3/README.md +3029 -0
  342. package/docs/services/scheduler/README.md +814 -0
  343. package/docs/services/secretsmanager/README.md +670 -0
  344. package/docs/services/ses/README.md +1013 -0
  345. package/docs/services/sns/README.md +1904 -0
  346. package/docs/services/sqs/README.md +1185 -0
  347. package/docs/services/ssm/README.md +1096 -0
  348. package/docs/services/stepfunctions/README.md +1341 -0
  349. package/docs/services/sts/README.md +245 -0
  350. package/docs/services/wafv2/README.md +1380 -0
  351. package/docs/terraform/README.md +248 -0
  352. package/docs/time/README.md +284 -0
  353. package/llms.txt +59 -0
  354. package/package.json +7 -4
  355. 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.