@kensio/yulin 1.21.16 → 1.21.18

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 (31) hide show
  1. package/dist/sdk/router/sim-sdk-command-router.type.d.ts +9 -0
  2. package/dist/sdk/sim-sdk-command-dispatcher.js +1 -1
  3. package/dist/service/cloudformation/cdk/provider/sim-cdk-provider-invoke-arn.d.ts +13 -0
  4. package/dist/service/cloudformation/cdk/provider/sim-cdk-provider-invoke-arn.js +63 -0
  5. package/dist/service/cloudformation/cdk/provider/sim-cdk-provider-invoke-auth-z.d.ts +36 -0
  6. package/dist/service/cloudformation/cdk/provider/sim-cdk-provider-invoke-auth-z.js +47 -0
  7. package/dist/service/cloudformation/cdk/s3/bucket-notifications/property/sim-cdk-bucket-notification-properties.js +5 -3
  8. package/dist/service/cloudformation/cdk/s3/bucket-notifications/sim-cdk-bucket-notifications-remover.js +2 -1
  9. package/dist/service/cloudformation/cdk/s3/bucket-notifications/sim-cdk-bucket-notifications.d.ts +7 -4
  10. package/dist/service/cloudformation/cdk/s3/bucket-notifications/sim-cdk-bucket-notifications.js +9 -5
  11. package/dist/service/cloudformation/cdk/ssm/cross-region-parameter/sim-cdk-cross-region-parameter-properties.js +4 -3
  12. package/dist/service/cloudformation/cdk/ssm/cross-region-parameter/sim-cdk-cross-region-parameter-reader.js +2 -1
  13. package/dist/service/cloudformation/resource/create/sim-cfn-resource-creator.js +11 -1
  14. package/dist/service/cloudformation/resource/delete/sim-cfn-resource-deleter.js +11 -1
  15. package/dist/service/dynamodb/document/sim-dynamodb-document-marshall-options.d.ts +42 -0
  16. package/dist/service/dynamodb/document/sim-dynamodb-document-marshall-options.js +36 -0
  17. package/dist/service/dynamodb/document/sim-dynamodb-document-marshall.d.ts +6 -5
  18. package/dist/service/dynamodb/document/sim-dynamodb-document-marshall.js +37 -43
  19. package/dist/service/dynamodb/document/sim-dynamodb-document-number.d.ts +4 -2
  20. package/dist/service/dynamodb/document/sim-dynamodb-document-number.js +5 -3
  21. package/dist/service/dynamodb/document/sim-dynamodb-document-path.d.ts +0 -4
  22. package/dist/service/dynamodb/document/sim-dynamodb-document-path.js +17 -10
  23. package/dist/service/dynamodb/document/sim-dynamodb-document-route.js +5 -1
  24. package/dist/service/dynamodb/document/sim-dynamodb-document-scalar.d.ts +13 -0
  25. package/dist/service/dynamodb/document/sim-dynamodb-document-scalar.js +46 -0
  26. package/dist/service/dynamodb/document/sim-dynamodb-document-set.d.ts +8 -3
  27. package/dist/service/dynamodb/document/sim-dynamodb-document-set.js +21 -14
  28. package/docs/sdk/README.md +5 -4
  29. package/docs/services/cloudformation/README.md +88 -0
  30. package/docs/services/dynamodb/README.md +81 -2
  31. package/package.json +1 -1
@@ -22,6 +22,15 @@ export interface SimSdkCommandContext {
22
22
  * session needs that split, because its session ARN owns no policies.
23
23
  */
24
24
  readonly caller?: SimAwsCaller | undefined;
25
+ /**
26
+ * The SDK client the Command was sent through, when the send came from one.
27
+ *
28
+ * A route needs it only for configuration the Command itself does not
29
+ * carry, such as the marshalling options a `DynamoDBDocumentClient` was
30
+ * built with. A request bridged from the wire has no client object behind
31
+ * it, so this is left out there.
32
+ */
33
+ readonly client?: unknown;
25
34
  }
26
35
  /**
27
36
  * Route one intercepted SDK Command to a simulated service operation.
@@ -41,7 +41,7 @@ export class SimSdkCommandDispatcher {
41
41
  .supportedCommandNames()
42
42
  .join(", ")}`);
43
43
  }
44
- return await route(command, { caller });
44
+ return await route(command, { caller, client });
45
45
  }
46
46
  /**
47
47
  * Get the ambient SimAws.runAs caller for this dispatcher's SimAws
@@ -0,0 +1,13 @@
1
+ import type { SimCfnResource } from "../../resource/sim-cfn-resource.js";
2
+ import type { SimCfnTemplateValueRecord } from "../../template/value/sim-cfn-template-value.js";
3
+ import { type SimLambdaFunctionArn } from "../../../lambda/function/sim-lambda-function-configuration.js";
4
+ /**
5
+ * The provider function a custom Resource's `ServiceToken` names, as an ARN.
6
+ *
7
+ * Nothing comes back where the Resource names no provider this Stack can put
8
+ * an ARN to. A `ServiceToken` built from a Parameter, or one pointing at a
9
+ * Resource type that is not a function, both leave the deployment with nothing
10
+ * to authorize against, and inventing an ARN for it would refuse a template
11
+ * over a permission real CloudFormation never asked for.
12
+ */
13
+ export declare function simCdkProviderInvokeArn(resolvedProperties: SimCfnTemplateValueRecord, resources: ReadonlyMap<string, SimCfnResource>): SimLambdaFunctionArn | undefined;
@@ -0,0 +1,63 @@
1
+ import { namesSimCfnUnansweredAttribute } from "../../resource/cfn/sim-cfn-unanswered-attribute.js";
2
+ import { parseSimLambdaFunctionArn } from "../../../lambda/function/sim-lambda-function-arn-parts.js";
3
+ import { simLambdaFunctionArn, } from "../../../lambda/function/sim-lambda-function-configuration.js";
4
+ const lambdaFunctionResourceType = "AWS::Lambda::Function";
5
+ /**
6
+ * The provider function a custom Resource's `ServiceToken` names, as an ARN.
7
+ *
8
+ * Nothing comes back where the Resource names no provider this Stack can put
9
+ * an ARN to. A `ServiceToken` built from a Parameter, or one pointing at a
10
+ * Resource type that is not a function, both leave the deployment with nothing
11
+ * to authorize against, and inventing an ARN for it would refuse a template
12
+ * over a permission real CloudFormation never asked for.
13
+ */
14
+ export function simCdkProviderInvokeArn(resolvedProperties, resources) {
15
+ const serviceToken = resolvedProperties["ServiceToken"];
16
+ if (typeof serviceToken !== "string" || serviceToken === "") {
17
+ return undefined;
18
+ }
19
+ return (writtenFunctionArn(serviceToken) ??
20
+ standInFunctionArn(serviceToken, resources));
21
+ }
22
+ /**
23
+ * The ARN a `ServiceToken` already carries.
24
+ *
25
+ * A provider deployed outside this Stack is named by its ARN outright, and a
26
+ * template written by hand can name one in the same Stack that way too.
27
+ */
28
+ function writtenFunctionArn(serviceToken) {
29
+ return parseSimLambdaFunctionArn(serviceToken) === undefined
30
+ ? undefined
31
+ : serviceToken;
32
+ }
33
+ /**
34
+ * The ARN behind a `ServiceToken` that resolved to an attribute stand-in.
35
+ *
36
+ * CDK writes `ServiceToken` as an `Fn::GetAtt` for the provider function's
37
+ * `Arn`, and this simulator leaves that provider uncreated: the function is
38
+ * declined on its Python runtime and then reported as scaffolding for the
39
+ * custom Resource the simulator carries out itself. An uncreated Resource
40
+ * answers `Fn::GetAtt` with {@link namesSimCfnUnansweredAttribute}'s stand-in
41
+ * rather than an ARN.
42
+ *
43
+ * The name survives that refusal. Sim Lambda works out the name real
44
+ * CloudFormation would have given the function before declining it, so the
45
+ * Resource's `Ref` holds a function name to build the ARN from. A provider
46
+ * that reached neither creation nor a name has only its logical ID behind
47
+ * `Ref`, and an ARN built from that would be denied by a policy naming the
48
+ * function real CloudFormation invokes.
49
+ */
50
+ function standInFunctionArn(serviceToken, resources) {
51
+ if (!namesSimCfnUnansweredAttribute(serviceToken, resources.keys())) {
52
+ return undefined;
53
+ }
54
+ const provider = resources.get(serviceToken.split(".", 1)[0] ?? "");
55
+ if (provider?.type !== lambdaFunctionResourceType ||
56
+ (!provider.deployed && provider.uncreatedPhysicalName === undefined)) {
57
+ return undefined;
58
+ }
59
+ const { refValue } = provider;
60
+ return typeof refValue === "string" && refValue !== ""
61
+ ? simLambdaFunctionArn(provider.accountRegionScope, refValue)
62
+ : undefined;
63
+ }
@@ -0,0 +1,36 @@
1
+ import type { SimAws } from "../../../aws/sim-aws.js";
2
+ import type { SimAwsCaller } from "../../../aws/caller/sim-aws-caller.js";
3
+ import type { SimCloudFormationParsedResourceType } from "../../resource/factory/sim-cfn-resource-factory.type.js";
4
+ import type { SimCfnResource } from "../../resource/sim-cfn-resource.js";
5
+ import type { SimCfnTemplateValueRecord } from "../../template/value/sim-cfn-template-value.js";
6
+ /**
7
+ * What authorizing one custom Resource's provider invoke needs.
8
+ */
9
+ export interface SimCdkProviderInvokeAuthZProperties {
10
+ readonly resourceType: SimCloudFormationParsedResourceType;
11
+ readonly resource: SimCfnResource;
12
+ readonly resolvedProperties: SimCfnTemplateValueRecord;
13
+ readonly resources: ReadonlyMap<string, SimCfnResource>;
14
+ readonly simAws: SimAws;
15
+ readonly caller?: SimAwsCaller | undefined;
16
+ }
17
+ /**
18
+ * Authorize the invoke a custom Resource costs the deployment.
19
+ *
20
+ * Real CloudFormation answers a `Custom::` Resource by invoking the function
21
+ * its `ServiceToken` names, as the Stack's execution role. That invoke is the
22
+ * first thing a deployment pays for, and a role without `lambda:InvokeFunction`
23
+ * on the provider fails the Stack there rather than at the work the provider
24
+ * would have done.
25
+ *
26
+ * This simulator carries several custom Resources out itself instead of
27
+ * running the provider, which leaves the invoke unpaid for unless it is
28
+ * authorized here. A deploy Role standing in for a real CloudFormation
29
+ * execution policy is meant to fail the same template the real one refuses.
30
+ *
31
+ * Only the caller's identity policies decide it. The provider function is
32
+ * uncreated in the usual case, so there is no simulated function holding a
33
+ * resource policy to consult, and CDK grants the invoke on the execution role
34
+ * anyway.
35
+ */
36
+ export declare function authorizeSimCdkProviderInvoke(properties: SimCdkProviderInvokeAuthZProperties): void;
@@ -0,0 +1,47 @@
1
+ import { simScopeIamAuthZ } from "../../../iam/authorize/sim-iam-region-auth-z.js";
2
+ import { SimIamAccessDenied } from "../../../iam/error/sim-iam.error.js";
3
+ import { simCdkProviderInvokeArn } from "./sim-cdk-provider-invoke-arn.js";
4
+ const customResourceProviderName = "Custom";
5
+ const invokeAction = "lambda:InvokeFunction";
6
+ /**
7
+ * Authorize the invoke a custom Resource costs the deployment.
8
+ *
9
+ * Real CloudFormation answers a `Custom::` Resource by invoking the function
10
+ * its `ServiceToken` names, as the Stack's execution role. That invoke is the
11
+ * first thing a deployment pays for, and a role without `lambda:InvokeFunction`
12
+ * on the provider fails the Stack there rather than at the work the provider
13
+ * would have done.
14
+ *
15
+ * This simulator carries several custom Resources out itself instead of
16
+ * running the provider, which leaves the invoke unpaid for unless it is
17
+ * authorized here. A deploy Role standing in for a real CloudFormation
18
+ * execution policy is meant to fail the same template the real one refuses.
19
+ *
20
+ * Only the caller's identity policies decide it. The provider function is
21
+ * uncreated in the usual case, so there is no simulated function holding a
22
+ * resource policy to consult, and CDK grants the invoke on the execution role
23
+ * anyway.
24
+ */
25
+ export function authorizeSimCdkProviderInvoke(properties) {
26
+ if (properties.resourceType.providerName !== customResourceProviderName) {
27
+ return;
28
+ }
29
+ const functionArn = simCdkProviderInvokeArn(properties.resolvedProperties, properties.resources);
30
+ if (functionArn === undefined) {
31
+ return;
32
+ }
33
+ const { accountId, regionName } = properties.resource.accountRegionScope;
34
+ const decision = simScopeIamAuthZ(properties.simAws.accountRegionScope(accountId, regionName)).authorize({
35
+ action: invokeAction,
36
+ resource: functionArn,
37
+ caller: properties.caller,
38
+ });
39
+ if (decision.isDenied) {
40
+ throw new SimIamAccessDenied({
41
+ principal: decision.caller.principal,
42
+ reason: decision.denialReason,
43
+ action: invokeAction,
44
+ resource: functionArn,
45
+ });
46
+ }
47
+ }
@@ -2,9 +2,11 @@ import { bucketNotificationsError } from "../error/sim-cdk-bucket-notification-e
2
2
  /**
3
3
  * The properties CDK puts on a Custom::S3BucketNotifications Resource.
4
4
  *
5
- * `ServiceToken` is read and ignored. It points at CDK's own Python provider
6
- * function, whose whole job is the PutBucketNotificationConfiguration call this
7
- * factory makes instead.
5
+ * `ServiceToken` is accepted and read no further here. It points at CDK's own
6
+ * Python provider function, whose whole job is the
7
+ * PutBucketNotificationConfiguration call this factory makes instead. The
8
+ * deployment reads it once more, to authorize the invoke real CloudFormation
9
+ * makes of that function.
8
10
  */
9
11
  const knownPropertyNames = new Set([
10
12
  "BucketName",
@@ -1,5 +1,6 @@
1
1
  import { bucketNotificationsError } from "./error/sim-cdk-bucket-notification-error.js";
2
2
  import { SimCdkBucketNotificationProperties } from "./property/sim-cdk-bucket-notification-properties.js";
3
+ import { simCfnResourceCallerOptions } from "../../../resource/caller/sim-cfn-resource-caller-options.js";
3
4
  /**
4
5
  * Removes the notification configuration a Custom::S3BucketNotifications
5
6
  * Resource put on a Bucket.
@@ -29,6 +30,6 @@ export class SimCdkBucketNotificationsRemover {
29
30
  Bucket: properties.bucketName,
30
31
  NotificationConfiguration: {},
31
32
  },
32
- });
33
+ }, simCfnResourceCallerOptions(context.caller));
33
34
  }
34
35
  }
@@ -16,10 +16,13 @@ import type { SimCfnResource, SimCloudFormationResourceCreateContext, SimCloudFo
16
16
  * bucket alongside the function's `AWS::Lambda::Permission` is the circular
17
17
  * dependency this Resource type exists to break.
18
18
  *
19
- * `ServiceToken` is read for the provider function it names, and otherwise
20
- * ignored. That function is declined on its runtime, as CDK's BucketDeployment
21
- * provider is, and recorded as inert rather than as a gap, since this factory
22
- * has already made the call it would have made.
19
+ * `ServiceToken` is read for the provider function it names. That function is
20
+ * declined on its runtime, as CDK's BucketDeployment provider is, and recorded
21
+ * as inert rather than as a gap, since this factory has already made the call
22
+ * it would have made. The deployment still pays for the invoke real
23
+ * CloudFormation makes, which
24
+ * {@link import("../../provider/sim-cdk-provider-invoke-auth-z.js").authorizeSimCdkProviderInvoke}
25
+ * authorizes for every custom Resource ahead of its factory.
23
26
  */
24
27
  export declare class SimCdkBucketNotificationsResourceFactory implements SimCfnServiceResourceFactory {
25
28
  /**
@@ -2,6 +2,7 @@ import { SimCdkBucketNotificationConfiguration } from "./configuration/sim-cdk-b
2
2
  import { bucketNotificationsError } from "./error/sim-cdk-bucket-notification-error.js";
3
3
  import { SimCdkBucketNotificationProperties } from "./property/sim-cdk-bucket-notification-properties.js";
4
4
  import { SimCdkBucketNotificationsRemover } from "./sim-cdk-bucket-notifications-remover.js";
5
+ import { simCfnResourceCallerOptions } from "../../../resource/caller/sim-cfn-resource-caller-options.js";
5
6
  /**
6
7
  * CloudFormation Resource factory for CDK Bucket event notifications.
7
8
  *
@@ -18,10 +19,13 @@ import { SimCdkBucketNotificationsRemover } from "./sim-cdk-bucket-notifications
18
19
  * bucket alongside the function's `AWS::Lambda::Permission` is the circular
19
20
  * dependency this Resource type exists to break.
20
21
  *
21
- * `ServiceToken` is read for the provider function it names, and otherwise
22
- * ignored. That function is declined on its runtime, as CDK's BucketDeployment
23
- * provider is, and recorded as inert rather than as a gap, since this factory
24
- * has already made the call it would have made.
22
+ * `ServiceToken` is read for the provider function it names. That function is
23
+ * declined on its runtime, as CDK's BucketDeployment provider is, and recorded
24
+ * as inert rather than as a gap, since this factory has already made the call
25
+ * it would have made. The deployment still pays for the invoke real
26
+ * CloudFormation makes, which
27
+ * {@link import("../../provider/sim-cdk-provider-invoke-auth-z.js").authorizeSimCdkProviderInvoke}
28
+ * authorizes for every custom Resource ahead of its factory.
25
29
  */
26
30
  export class SimCdkBucketNotificationsResourceFactory {
27
31
  /**
@@ -48,7 +52,7 @@ export class SimCdkBucketNotificationsResourceFactory {
48
52
  NotificationConfiguration: configuration,
49
53
  SkipDestinationValidation: properties.skipDestinationValidation,
50
54
  },
51
- });
55
+ }, simCfnResourceCallerOptions(context.caller));
52
56
  return undefined;
53
57
  }
54
58
  /**
@@ -5,9 +5,10 @@ import { crossRegionParameterError } from "./sim-cdk-cross-region-parameter-erro
5
5
  * The properties CDK puts on a Custom::CrossRegionStringParameterReader
6
6
  * Resource.
7
7
  *
8
- * `ServiceToken` is read and ignored. It points at CDK's own provider
9
- * function, whose whole job is the GetParameter call this factory makes
10
- * instead. `RefreshToken` is the logical ID of the function version the
8
+ * `ServiceToken` is accepted and read no further here. It points at CDK's own
9
+ * provider function, whose whole job is the GetParameter call this factory
10
+ * makes instead, and the deployment reads it once more to authorize the invoke
11
+ * real CloudFormation makes of that function. `RefreshToken` is the logical ID of the function version the
11
12
  * parameter was written from, and CDK puts it there so that publishing a new
12
13
  * version makes CloudFormation run the reader again. The reader here runs on
13
14
  * every deployment either way, so the token has nothing left to say.
@@ -2,6 +2,7 @@ import { SimSsmParameterNotFound } from "../../../../ssm/error/sim-ssm.error.js"
2
2
  import { crossRegionParameterError, crossRegionParameterResourceTypeName, } from "./sim-cdk-cross-region-parameter-error.js";
3
3
  import { SimCdkCrossRegionParameterProperties } from "./sim-cdk-cross-region-parameter-properties.js";
4
4
  import { SimCdkCrossRegionParameterReading } from "./sim-cdk-cross-region-parameter-reading.js";
5
+ import { simCfnResourceCallerOptions } from "../../../resource/caller/sim-cfn-resource-caller-options.js";
5
6
  /**
6
7
  * CloudFormation Resource factory for CDK's cross-Region parameter reader.
7
8
  *
@@ -64,7 +65,7 @@ export class SimCdkCrossRegionParameterReaderResourceFactory {
64
65
  const output = await context.simAws
65
66
  .accountRegionScope(resource.accountRegionScope.accountId, regionName)
66
67
  .ssm()
67
- .getParameter({ input: { Name: parameterName } });
68
+ .getParameter({ input: { Name: parameterName } }, simCfnResourceCallerOptions(context.caller));
68
69
  return output.Parameter?.Value;
69
70
  }
70
71
  catch (error) {
@@ -1,6 +1,7 @@
1
1
  import { parseSimCloudFormationResourceType } from "../parser/sim-cfn-resource-parser.js";
2
2
  import { resolveSimCloudFormationServiceResourceFactory } from "../resolve/service/sim-cfn-service-resolver.js";
3
3
  import { assertDefined } from "../../../../util/type-guard/defined.js";
4
+ import { authorizeSimCdkProviderInvoke } from "../../cdk/provider/sim-cdk-provider-invoke-auth-z.js";
4
5
  /**
5
6
  * Creates the underlying simulated AWS service Resource for a CloudFormation
6
7
  * Resource.
@@ -35,9 +36,18 @@ export class SimCfnResourceCreator {
35
36
  const resourceType = parseSimCloudFormationResourceType(type);
36
37
  const factory = this.cfnResourceFactory ??
37
38
  resolveSimCloudFormationServiceResourceFactory(context.simAws, this.resource.accountRegionScope, resourceType);
39
+ const resolvedProperties = await this.resource.resolvedProperties(context);
40
+ authorizeSimCdkProviderInvoke({
41
+ resourceType,
42
+ resource: this.resource,
43
+ resolvedProperties,
44
+ resources: context.resources,
45
+ simAws: context.simAws,
46
+ caller: context.caller,
47
+ });
38
48
  const resolvedContext = {
39
49
  ...context,
40
- resolvedProperties: await this.resource.resolvedProperties(context),
50
+ resolvedProperties,
41
51
  };
42
52
  return await factory.create(resourceType.resourceTypeName, this.resource, resolvedContext);
43
53
  }
@@ -1,6 +1,7 @@
1
1
  import { parseSimCloudFormationResourceType } from "../parser/sim-cfn-resource-parser.js";
2
2
  import { resolveSimCloudFormationServiceResourceFactory } from "../resolve/service/sim-cfn-service-resolver.js";
3
3
  import { assertDefined } from "../../../../util/type-guard/defined.js";
4
+ import { authorizeSimCdkProviderInvoke } from "../../cdk/provider/sim-cdk-provider-invoke-auth-z.js";
4
5
  /**
5
6
  * Removes the underlying simulated AWS service Resource for a CloudFormation
6
7
  * Resource.
@@ -39,9 +40,18 @@ export class SimCfnResourceDeleter {
39
40
  const resourceType = parseSimCloudFormationResourceType(type);
40
41
  const factory = this.cfnResourceFactory ??
41
42
  resolveSimCloudFormationServiceResourceFactory(context.simAws, this.resource.accountRegionScope, resourceType);
43
+ const resolvedProperties = await this.resource.resolvedProperties(context);
44
+ authorizeSimCdkProviderInvoke({
45
+ resourceType,
46
+ resource: this.resource,
47
+ resolvedProperties,
48
+ resources: context.resources,
49
+ simAws: context.simAws,
50
+ caller: context.caller,
51
+ });
42
52
  await factory.delete(resourceType.resourceTypeName, this.resource, {
43
53
  ...context,
44
- resolvedProperties: await this.resource.resolvedProperties(context),
54
+ resolvedProperties,
45
55
  });
46
56
  }
47
57
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The marshalling options a document client was built with.
3
+ *
4
+ * `DynamoDBDocumentClient.from(client, { marshallOptions })` changes what the
5
+ * real conversion does with values it would otherwise refuse, so the simulated
6
+ * conversion reads the same options rather than always applying the defaults.
7
+ * Every one of them is off unless the client asked for it, which is how the
8
+ * real document client leaves them.
9
+ */
10
+ export interface SimDynamoDbDocumentMarshallOptions {
11
+ /**
12
+ * Drop an undefined value out of a map, a list or a set instead of refusing
13
+ * it.
14
+ */
15
+ readonly removeUndefinedValues: boolean;
16
+ /**
17
+ * Write an empty string, an empty binary value and an empty set as NULL.
18
+ */
19
+ readonly convertEmptyValues: boolean;
20
+ /**
21
+ * Read a class instance as a map of its own properties.
22
+ */
23
+ readonly convertClassInstanceToMap: boolean;
24
+ /**
25
+ * Write a number outside the safe integer range, digits already lost, rather
26
+ * than refusing it.
27
+ */
28
+ readonly allowImpreciseNumbers: boolean;
29
+ }
30
+ /**
31
+ * What a document client built with no options of its own converts by.
32
+ */
33
+ export declare const simDynamoDbDocumentMarshallDefaults: SimDynamoDbDocumentMarshallOptions;
34
+ /**
35
+ * Read the marshalling options off the client a Command was sent through.
36
+ *
37
+ * `DynamoDBDocumentClient` keeps the translation config it was built with on
38
+ * its resolved config, which is where the real marshalling middleware reads it
39
+ * from. A client that named none, and anything that is not a document client
40
+ * at all, converts by the defaults.
41
+ */
42
+ export declare function simDynamoDbDocumentMarshallOptions(client: unknown): SimDynamoDbDocumentMarshallOptions;
@@ -0,0 +1,36 @@
1
+ import { isRecord } from "../../../util/type-guard/record.js";
2
+ /**
3
+ * What a document client built with no options of its own converts by.
4
+ */
5
+ export const simDynamoDbDocumentMarshallDefaults = {
6
+ removeUndefinedValues: false,
7
+ convertEmptyValues: false,
8
+ convertClassInstanceToMap: false,
9
+ allowImpreciseNumbers: false,
10
+ };
11
+ /**
12
+ * Read the marshalling options off the client a Command was sent through.
13
+ *
14
+ * `DynamoDBDocumentClient` keeps the translation config it was built with on
15
+ * its resolved config, which is where the real marshalling middleware reads it
16
+ * from. A client that named none, and anything that is not a document client
17
+ * at all, converts by the defaults.
18
+ */
19
+ export function simDynamoDbDocumentMarshallOptions(client) {
20
+ const config = isRecord(client) ? client["config"] : undefined;
21
+ const translateConfig = isRecord(config)
22
+ ? config["translateConfig"]
23
+ : undefined;
24
+ const options = isRecord(translateConfig)
25
+ ? translateConfig["marshallOptions"]
26
+ : undefined;
27
+ if (!isRecord(options)) {
28
+ return simDynamoDbDocumentMarshallDefaults;
29
+ }
30
+ return {
31
+ removeUndefinedValues: options["removeUndefinedValues"] === true,
32
+ convertEmptyValues: options["convertEmptyValues"] === true,
33
+ convertClassInstanceToMap: options["convertClassInstanceToMap"] === true,
34
+ allowImpreciseNumbers: options["allowImpreciseNumbers"] === true,
35
+ };
36
+ }
@@ -1,4 +1,5 @@
1
1
  import type { SimDynamoDbAttributeValue } from "../command/item/item.types.js";
2
+ import type { SimDynamoDbDocumentMarshallOptions } from "./sim-dynamodb-document-marshall-options.js";
2
3
  /**
3
4
  * Read a native JavaScript value as the AttributeValue it stands for.
4
5
  *
@@ -7,9 +8,9 @@ import type { SimDynamoDbAttributeValue } from "../command/item/item.types.js";
7
8
  * order, so a value that reaches a simulated table through the document client
8
9
  * is the value that would have reached the real one.
9
10
  *
10
- * `undefined` is refused rather than dropped. The real document client drops it
11
- * when the client was built with `removeUndefinedValues`, which is a translate
12
- * config this simulation does not read yet, so refusing is what keeps a test
13
- * from passing against an item AWS would have written differently.
11
+ * The options are the ones the document client was built with. They decide
12
+ * what happens to a value the defaults refuse: an undefined member, an empty
13
+ * string or binary value, a class instance, and a number past the range a
14
+ * JavaScript number holds exactly.
14
15
  */
15
- export declare function simDynamoDbDocumentAttributeValue(value: unknown, path: string): SimDynamoDbAttributeValue;
16
+ export declare function simDynamoDbDocumentAttributeValue(value: unknown, path: string, options: SimDynamoDbDocumentMarshallOptions): SimDynamoDbAttributeValue;
@@ -1,6 +1,5 @@
1
1
  import { SimDynamoDbDocumentValueError } from "../error/dynamodb.error.js";
2
- import { isSimDynamoDbDocumentBinary } from "./sim-dynamodb-document-binary.js";
3
- import { isSimDynamoDbDocumentNumberValue, simDynamoDbDocumentNumberAttribute, } from "./sim-dynamodb-document-number.js";
2
+ import { simDynamoDbDocumentScalarAttribute } from "./sim-dynamodb-document-scalar.js";
4
3
  import { simDynamoDbDocumentSetAttribute } from "./sim-dynamodb-document-set.js";
5
4
  /**
6
5
  * Read a native JavaScript value as the AttributeValue it stands for.
@@ -10,61 +9,48 @@ import { simDynamoDbDocumentSetAttribute } from "./sim-dynamodb-document-set.js"
10
9
  * order, so a value that reaches a simulated table through the document client
11
10
  * is the value that would have reached the real one.
12
11
  *
13
- * `undefined` is refused rather than dropped. The real document client drops it
14
- * when the client was built with `removeUndefinedValues`, which is a translate
15
- * config this simulation does not read yet, so refusing is what keeps a test
16
- * from passing against an item AWS would have written differently.
12
+ * The options are the ones the document client was built with. They decide
13
+ * what happens to a value the defaults refuse: an undefined member, an empty
14
+ * string or binary value, a class instance, and a number past the range a
15
+ * JavaScript number holds exactly.
17
16
  */
18
- export function simDynamoDbDocumentAttributeValue(value, path) {
17
+ export function simDynamoDbDocumentAttributeValue(value, path, options) {
19
18
  if (value === undefined) {
20
- throw new SimDynamoDbDocumentValueError(`${path} is undefined. The real document client drops it only when it ` +
21
- `was built with removeUndefinedValues, which simulated DynamoDB does ` +
22
- `not read yet, so leave the attribute out instead`);
19
+ throw new SimDynamoDbDocumentValueError(`${path} is undefined. Build the document client with ` +
20
+ `removeUndefinedValues to drop it, which is what the real one asks ` +
21
+ `for, or leave the attribute out instead`);
23
22
  }
24
23
  if (value === null) {
25
24
  return { NULL: true };
26
25
  }
27
26
  if (Array.isArray(value)) {
28
- return { L: listMembers(value, path) };
27
+ return { L: listMembers(value, path, options) };
29
28
  }
30
- return containerOrScalar(value, path);
29
+ return containerOrScalar(value, path, options);
31
30
  }
32
31
  /**
33
32
  * Read a value that is not null, undefined or a list.
34
33
  */
35
- function containerOrScalar(value, path) {
34
+ function containerOrScalar(value, path, options) {
36
35
  if (value instanceof Set) {
37
- return simDynamoDbDocumentSetAttribute(value, path);
36
+ return simDynamoDbDocumentSetAttribute(value, path, options);
38
37
  }
39
38
  if (value instanceof Map) {
40
- return { M: mapEntries([...value], path) };
39
+ return { M: mapEntries([...value], path, options) };
41
40
  }
42
41
  if (isPlainObject(value)) {
43
- return { M: mapEntries(Object.entries(value), path) };
42
+ return { M: mapEntries(Object.entries(value), path, options) };
44
43
  }
45
- return scalar(value, path);
46
- }
47
- /**
48
- * Read a value that stands for one attribute on its own.
49
- */
50
- function scalar(value, path) {
51
- if (isSimDynamoDbDocumentBinary(value)) {
52
- return { B: value };
53
- }
54
- if (typeof value === "boolean") {
55
- return { BOOL: value };
56
- }
57
- if (typeof value === "number") {
58
- return simDynamoDbDocumentNumberAttribute(value, path);
44
+ const scalar = simDynamoDbDocumentScalarAttribute(value, path, options);
45
+ if (scalar !== undefined) {
46
+ return scalar;
59
47
  }
60
- if (isSimDynamoDbDocumentNumberValue(value)) {
61
- return { N: value.toAttributeValue().N };
62
- }
63
- if (typeof value === "bigint") {
64
- return { N: value.toString() };
65
- }
66
- if (typeof value === "string") {
67
- return { S: value };
48
+ // A class instance is read as a map only when the client asked for it, which
49
+ // is the last thing the real conversion tries before giving up. Null reached
50
+ // an answer of its own before any of this.
51
+ if (typeof value === "object" && options.convertClassInstanceToMap) {
52
+ const instance = value;
53
+ return { M: mapEntries(Object.entries(instance), path, options) };
68
54
  }
69
55
  throw new SimDynamoDbDocumentValueError(`${path} is a ${typeof value} the document client has no attribute type ` +
70
56
  `for`);
@@ -72,27 +58,35 @@ function scalar(value, path) {
72
58
  /**
73
59
  * The members of a list, with the functions left out as the real one leaves
74
60
  * them out.
61
+ *
62
+ * A dropped undefined member takes its position with it, so the members after
63
+ * it move up. That is what the real conversion does: it filters before it
64
+ * converts, rather than writing a NULL where the member was.
75
65
  */
76
- function listMembers(values, path) {
66
+ function listMembers(values, path, options) {
77
67
  return values
78
- .filter((member) => typeof member !== "function")
79
- .map((member, index) => simDynamoDbDocumentAttributeValue(member, `${path}[${index.toString()}]`));
68
+ .filter((member) => typeof member !== "function" &&
69
+ !(member === undefined && options.removeUndefinedValues))
70
+ .map((member, index) => simDynamoDbDocumentAttributeValue(member, `${path}[${index.toString()}]`, options));
80
71
  }
81
72
  /**
82
73
  * The entries of a map, with the functions left out.
83
74
  */
84
- function mapEntries(entries, path) {
75
+ function mapEntries(entries, path, options) {
85
76
  const attributes = {};
86
77
  for (const [name, member] of entries) {
87
78
  if (typeof member === "function") {
88
79
  continue;
89
80
  }
81
+ if (member === undefined && options.removeUndefinedValues) {
82
+ continue;
83
+ }
90
84
  const key = String(name);
91
85
  // Defined rather than assigned, so an attribute named `__proto__` becomes
92
86
  // an ordinary attribute instead of reaching the prototype setter. The real
93
87
  // document client assigns, and so loses that attribute.
94
88
  Object.defineProperty(attributes, key, {
95
- value: simDynamoDbDocumentAttributeValue(member, `${path}.${key}`),
89
+ value: simDynamoDbDocumentAttributeValue(member, `${path}.${key}`, options),
96
90
  enumerable: true,
97
91
  writable: true,
98
92
  configurable: true,
@@ -1,4 +1,5 @@
1
1
  import type { SimDynamoDbAttributeValue } from "../command/item/item.types.js";
2
+ import type { SimDynamoDbDocumentMarshallOptions } from "./sim-dynamodb-document-marshall-options.js";
2
3
  /**
3
4
  * A value carrying its own Number attribute, which is what lib-dynamodb's
4
5
  * `NumberValue` is.
@@ -23,9 +24,10 @@ export declare function isSimDynamoDbDocumentNumberValue(value: unknown): value
23
24
  * than storing one that has already lost digits. A simulated table holds the
24
25
  * digits it is given exactly, so this refusal is the only thing standing
25
26
  * between an application and a silently rounded identifier, which is why it is
26
- * kept rather than relaxed. A decimal inside the range is written as it stands.
27
+ * kept until a client asks for `allowImpreciseNumbers` and takes the rounding
28
+ * on. A decimal inside the range is written as it stands.
27
29
  */
28
- export declare function simDynamoDbDocumentNumberAttribute(value: number, path: string): SimDynamoDbAttributeValue;
30
+ export declare function simDynamoDbDocumentNumberAttribute(value: number, path: string, options: SimDynamoDbDocumentMarshallOptions): SimDynamoDbAttributeValue;
29
31
  /**
30
32
  * Read a Number attribute back as the document client answers with it.
31
33
  *
@@ -15,13 +15,15 @@ export function isSimDynamoDbDocumentNumberValue(value) {
15
15
  * than storing one that has already lost digits. A simulated table holds the
16
16
  * digits it is given exactly, so this refusal is the only thing standing
17
17
  * between an application and a silently rounded identifier, which is why it is
18
- * kept rather than relaxed. A decimal inside the range is written as it stands.
18
+ * kept until a client asks for `allowImpreciseNumbers` and takes the rounding
19
+ * on. A decimal inside the range is written as it stands.
19
20
  */
20
- export function simDynamoDbDocumentNumberAttribute(value, path) {
21
+ export function simDynamoDbDocumentNumberAttribute(value, path, options) {
21
22
  if (!Number.isFinite(value)) {
22
23
  throw new SimDynamoDbDocumentValueError(`${path} is ${value.toString()}, and DynamoDB has no such number`);
23
24
  }
24
- if (value > Number.MAX_SAFE_INTEGER || value < Number.MIN_SAFE_INTEGER) {
25
+ if (!options.allowImpreciseNumbers &&
26
+ (value > Number.MAX_SAFE_INTEGER || value < Number.MIN_SAFE_INTEGER)) {
25
27
  throw new SimDynamoDbDocumentValueError(`${path} is ${value.toString()}, which is outside the range a ` +
26
28
  `JavaScript number holds exactly. Write it as a bigint, or as a ` +
27
29
  `NumberValue from @aws-sdk/lib-dynamodb, so its digits survive`);
@@ -16,10 +16,6 @@ export interface SimDynamoDbDocumentPath {
16
16
  */
17
17
  convert(value: unknown, conversion: SimDynamoDbDocumentConversion, path: string): unknown;
18
18
  }
19
- /**
20
- * A path to one attribute value.
21
- */
22
- export declare function simDynamoDbDocumentValue(): SimDynamoDbDocumentPath;
23
19
  /**
24
20
  * A path to a record or list whose every member is one attribute value, which
25
21
  * is what an Item, a Key and a set of expression values are.
@@ -1,10 +1,23 @@
1
1
  import { isRecord } from "../../../util/type-guard/record.js";
2
2
  /**
3
- * One attribute value, converted where it stands.
3
+ * Every attribute value of an item, a key or a set of expression values.
4
+ *
5
+ * An attribute whose value is undefined is left out rather than converted, and
6
+ * that happens whatever the document client was built with. The real client
7
+ * drops it in the same place: `removeUndefinedValues` governs the values
8
+ * inside an attribute, and an item is not itself one of them.
4
9
  */
5
- class SimDynamoDbDocumentValuePath {
10
+ class SimDynamoDbDocumentValuesPath {
6
11
  convert(value, conversion, path) {
7
- return conversion(value, path);
12
+ if (Array.isArray(value)) {
13
+ return value.map((member, index) => conversion(member, `${path}[${index.toString()}]`));
14
+ }
15
+ if (!isRecord(value)) {
16
+ return value;
17
+ }
18
+ return Object.fromEntries(Object.entries(value)
19
+ .filter(([, member]) => member !== undefined)
20
+ .map(([name, member]) => [name, conversion(member, `${path}.${name}`)]));
8
21
  }
9
22
  }
10
23
  /**
@@ -55,18 +68,12 @@ class SimDynamoDbDocumentFieldsPath {
55
68
  return converted;
56
69
  }
57
70
  }
58
- /**
59
- * A path to one attribute value.
60
- */
61
- export function simDynamoDbDocumentValue() {
62
- return new SimDynamoDbDocumentValuePath();
63
- }
64
71
  /**
65
72
  * A path to a record or list whose every member is one attribute value, which
66
73
  * is what an Item, a Key and a set of expression values are.
67
74
  */
68
75
  export function simDynamoDbDocumentValues() {
69
- return simDynamoDbDocumentEach(simDynamoDbDocumentValue());
76
+ return new SimDynamoDbDocumentValuesPath();
70
77
  }
71
78
  /**
72
79
  * A path through every member of a record or list.
@@ -1,4 +1,5 @@
1
1
  import { simSdkCallerOptions, } from "../../../sdk/index.js";
2
+ import { simDynamoDbDocumentMarshallOptions } from "./sim-dynamodb-document-marshall-options.js";
2
3
  import { simDynamoDbDocumentAttributeValue } from "./sim-dynamodb-document-marshall.js";
3
4
  import { simDynamoDbDocumentNativeValue } from "./sim-dynamodb-document-unmarshall.js";
4
5
  /**
@@ -23,7 +24,10 @@ export class SimDynamoDbDocumentRoute {
23
24
  */
24
25
  route() {
25
26
  return async (command, context) => {
26
- const input = this.input.convert(command.input, (value, path) => simDynamoDbDocumentAttributeValue(value, path), "input");
27
+ // Read per send, as the real middleware reads it, so the same route
28
+ // serves document clients built with different options.
29
+ const options = simDynamoDbDocumentMarshallOptions(context.client);
30
+ const input = this.input.convert(command.input, (value, path) => simDynamoDbDocumentAttributeValue(value, path, options), "input");
27
31
  const output = await this.send(input, simSdkCallerOptions(context));
28
32
  return this.output.convert(output, (value, path) => simDynamoDbDocumentNativeValue(value, path), "output");
29
33
  };
@@ -0,0 +1,13 @@
1
+ import type { SimDynamoDbAttributeValue } from "../command/item/item.types.js";
2
+ import type { SimDynamoDbDocumentMarshallOptions } from "./sim-dynamodb-document-marshall-options.js";
3
+ /**
4
+ * Read a value that stands for one attribute on its own.
5
+ *
6
+ * The kinds are tried in the order the real conversion tries them. A value
7
+ * that is none of them answers with nothing, and what happens to it next is
8
+ * for the caller to decide.
9
+ *
10
+ * An empty string and a binary value holding no bytes are written as NULL when
11
+ * the client was built with `convertEmptyValues`, and as themselves otherwise.
12
+ */
13
+ export declare function simDynamoDbDocumentScalarAttribute(value: unknown, path: string, options: SimDynamoDbDocumentMarshallOptions): SimDynamoDbAttributeValue | undefined;
@@ -0,0 +1,46 @@
1
+ import { isSimDynamoDbDocumentBinary } from "./sim-dynamodb-document-binary.js";
2
+ import { isSimDynamoDbDocumentNumberValue, simDynamoDbDocumentNumberAttribute, } from "./sim-dynamodb-document-number.js";
3
+ /**
4
+ * Read a value that stands for one attribute on its own.
5
+ *
6
+ * The kinds are tried in the order the real conversion tries them. A value
7
+ * that is none of them answers with nothing, and what happens to it next is
8
+ * for the caller to decide.
9
+ *
10
+ * An empty string and a binary value holding no bytes are written as NULL when
11
+ * the client was built with `convertEmptyValues`, and as themselves otherwise.
12
+ */
13
+ export function simDynamoDbDocumentScalarAttribute(value, path, options) {
14
+ if (isSimDynamoDbDocumentBinary(value)) {
15
+ return isEmptyLength(value) && options.convertEmptyValues
16
+ ? { NULL: true }
17
+ : { B: value };
18
+ }
19
+ if (typeof value === "boolean") {
20
+ return { BOOL: value };
21
+ }
22
+ if (typeof value === "number") {
23
+ return simDynamoDbDocumentNumberAttribute(value, path, options);
24
+ }
25
+ if (isSimDynamoDbDocumentNumberValue(value)) {
26
+ return { N: value.toAttributeValue().N };
27
+ }
28
+ if (typeof value === "bigint") {
29
+ return { N: value.toString() };
30
+ }
31
+ if (typeof value === "string") {
32
+ return value.length === 0 && options.convertEmptyValues
33
+ ? { NULL: true }
34
+ : { S: value };
35
+ }
36
+ return undefined;
37
+ }
38
+ /**
39
+ * Whether a value holds nothing, by the length the real conversion reads.
40
+ *
41
+ * An ArrayBuffer reports `byteLength` rather than `length`, so an empty one is
42
+ * not empty by this measure, and the real conversion leaves it alone too.
43
+ */
44
+ function isEmptyLength(value) {
45
+ return value.length === 0;
46
+ }
@@ -1,4 +1,5 @@
1
1
  import type { SimDynamoDbAttributeValue } from "../command/item/item.types.js";
2
+ import type { SimDynamoDbDocumentMarshallOptions } from "./sim-dynamodb-document-marshall-options.js";
2
3
  /**
3
4
  * Read a JavaScript Set as one of DynamoDB's three set attributes.
4
5
  *
@@ -8,7 +9,11 @@ import type { SimDynamoDbAttributeValue } from "../command/item/item.types.js";
8
9
  * further down, where the table reads the value. That is what the real one
9
10
  * does, so a set written this way behaves the same either side.
10
11
  *
11
- * DynamoDB has no empty set, so an empty one is refused rather than written as
12
- * something else.
12
+ * An undefined member is dropped when the client was built with
13
+ * `removeUndefinedValues`, and refused otherwise. DynamoDB has no empty set, so
14
+ * a set with nothing left in it is written as NULL when the client was built
15
+ * with `convertEmptyValues`, and refused otherwise. The two are read in that
16
+ * order, so a set holding nothing but undefined is an empty set by the time its
17
+ * size is looked at, which is where the real conversion looks at it too.
13
18
  */
14
- export declare function simDynamoDbDocumentSetAttribute(set: ReadonlySet<unknown>, path: string): SimDynamoDbAttributeValue;
19
+ export declare function simDynamoDbDocumentSetAttribute(set: ReadonlySet<unknown>, path: string, options: SimDynamoDbDocumentMarshallOptions): SimDynamoDbAttributeValue;
@@ -10,31 +10,38 @@ import { isSimDynamoDbDocumentNumberValue, simDynamoDbDocumentNumberAttribute, }
10
10
  * further down, where the table reads the value. That is what the real one
11
11
  * does, so a set written this way behaves the same either side.
12
12
  *
13
- * DynamoDB has no empty set, so an empty one is refused rather than written as
14
- * something else.
13
+ * An undefined member is dropped when the client was built with
14
+ * `removeUndefinedValues`, and refused otherwise. DynamoDB has no empty set, so
15
+ * a set with nothing left in it is written as NULL when the client was built
16
+ * with `convertEmptyValues`, and refused otherwise. The two are read in that
17
+ * order, so a set holding nothing but undefined is an empty set by the time its
18
+ * size is looked at, which is where the real conversion looks at it too.
15
19
  */
16
- export function simDynamoDbDocumentSetAttribute(set, path) {
17
- if (set.size === 0) {
18
- throw new SimDynamoDbDocumentValueError(`${path} is an empty Set, and DynamoDB has no empty set`);
20
+ export function simDynamoDbDocumentSetAttribute(set, path, options) {
21
+ const members = [...set].filter((member) => !(member === undefined && options.removeUndefinedValues));
22
+ if (!options.removeUndefinedValues && set.has(undefined)) {
23
+ throw new SimDynamoDbDocumentValueError(`${path} is a Set holding undefined. Build the document client with ` +
24
+ `removeUndefinedValues to drop it, which is what the real one asks for`);
19
25
  }
20
- if (set.has(undefined)) {
21
- throw new SimDynamoDbDocumentValueError(`${path} is a Set holding undefined. The real document client drops it ` +
22
- `only when it was built with removeUndefinedValues, which simulated ` +
23
- `DynamoDB does not read yet`);
26
+ if (members.length === 0) {
27
+ if (options.convertEmptyValues) {
28
+ return { NULL: true };
29
+ }
30
+ throw new SimDynamoDbDocumentValueError(`${path} is an empty Set, and DynamoDB has no empty set`);
24
31
  }
25
- return membersAttribute([...set], path);
32
+ return membersAttribute(members, path, options);
26
33
  }
27
34
  /**
28
35
  * Read the members of a set that is known to hold something.
29
36
  */
30
- function membersAttribute(members, path) {
37
+ function membersAttribute(members, path, options) {
31
38
  const first = members[0];
32
39
  if (typeof first === "string") {
33
40
  return { SS: members.map(String) };
34
41
  }
35
42
  if (isNumberMember(first)) {
36
43
  return {
37
- NS: members.map((member, index) => numberText(member, path, index)),
44
+ NS: members.map((member, index) => numberText(member, path, index, options)),
38
45
  };
39
46
  }
40
47
  if (isSimDynamoDbDocumentBinary(first)) {
@@ -57,14 +64,14 @@ function isNumberMember(member) {
57
64
  /**
58
65
  * The digits one member of a number set is written with.
59
66
  */
60
- function numberText(member, path, index) {
67
+ function numberText(member, path, index, options) {
61
68
  if (typeof member === "bigint") {
62
69
  return member.toString();
63
70
  }
64
71
  if (isSimDynamoDbDocumentNumberValue(member)) {
65
72
  return member.toAttributeValue().N;
66
73
  }
67
- const attribute = simDynamoDbDocumentNumberAttribute(Number(member), `${path}[${index.toString()}]`);
74
+ const attribute = simDynamoDbDocumentNumberAttribute(Number(member), `${path}[${index.toString()}]`, options);
68
75
  // A number attribute is the only thing that function answers with.
69
76
  return attribute.N ?? "";
70
77
  }
@@ -282,7 +282,8 @@ service throws `SimSdkUnknownServiceError`.
282
282
  - Simulated errors have SDK-shaped `name` and `$metadata` fields. They are separate classes from the
283
283
  SDK exceptions, so match them by `error.name` instead of `instanceof`.
284
284
  - The callback form of `send(command, callback)` is not supported. Use the promise form.
285
- - Yulin ignores the translation options in
286
- `DynamoDBDocumentClient.from(client, { marshallOptions, unmarshallOptions })`. The conversion uses
287
- the defaults. `removeUndefinedValues: true` has no effect. Yulin refuses an `undefined` attribute
288
- that the configured document client would otherwise remove.
285
+ - Yulin reads the `marshallOptions` in
286
+ `DynamoDBDocumentClient.from(client, { marshallOptions, unmarshallOptions })` and ignores the
287
+ `unmarshallOptions`. A stored value comes back the way a document client built with no options of
288
+ its own reads it. See
289
+ [the DynamoDB docs](https://yulinsim.dev/services/dynamodb/#marshalling-options).
@@ -2549,6 +2549,94 @@ working simulation with a hand-written one.
2549
2549
  The provider is found through the `ServiceToken` its custom resource names it by, not by the logical
2550
2550
  ID CDK generated for it. That ID is a hash of the construct path, and no kind of thing to match on.
2551
2551
 
2552
+ ### What a custom resource costs the deploy Role
2553
+
2554
+ Real CloudFormation answers a `Custom::` resource by invoking the provider function its `ServiceToken`
2555
+ names, as the stack's execution role. Yulin carries several of these out itself instead of running the
2556
+ provider, and it authorizes that invoke anyway. A deploy Role holding no `lambda:InvokeFunction` on the
2557
+ provider fails the stack where the real deployment fails.
2558
+
2559
+ The `ServiceToken` decides the ARN. A token written as an ARN is taken as it stands. CDK's `Fn::GetAtt`
2560
+ for the provider's `Arn` is resolved back to the function the stack declares, since the provider is
2561
+ left uncreated and has no ARN of its own to give.
2562
+
2563
+ The work the resource does is authorized too, as the deployment's principal.
2564
+ `Custom::S3BucketNotifications` costs `s3:PutBucketNotification` and
2565
+ `Custom::CrossRegionStringParameterReader` costs `ssm:GetParameter`. `Custom::CDKBucketDeployment`
2566
+ writes its Objects into the simulated Bucket without sending `PutObject`, so nothing there reaches IAM.
2567
+
2568
+ ```typescript sim-cloudformation-custom-resource-invoke
2569
+ /**
2570
+ * A deploy Role refused the provider invoke its custom resource needs.
2571
+ */
2572
+
2573
+ import { SimAws } from "@kensio/yulin";
2574
+
2575
+ const simAws = new SimAws();
2576
+
2577
+ const { Role } = await simAws.iam().createRole({
2578
+ input: {
2579
+ RoleName: "DeployRole",
2580
+ AssumeRolePolicyDocument: JSON.stringify({
2581
+ Version: "2012-10-17",
2582
+ Statement: [
2583
+ {
2584
+ Effect: "Allow",
2585
+ Principal: { Service: "cloudformation.amazonaws.com" },
2586
+ Action: "sts:AssumeRole",
2587
+ },
2588
+ ],
2589
+ }),
2590
+ },
2591
+ });
2592
+
2593
+ // Everything the template needs apart from the invoke of the provider.
2594
+ await simAws.iam().putRolePolicy({
2595
+ input: {
2596
+ RoleName: "DeployRole",
2597
+ PolicyName: "DeployPolicy",
2598
+ PolicyDocument: JSON.stringify({
2599
+ Version: "2012-10-17",
2600
+ Statement: [
2601
+ {
2602
+ Effect: "Allow",
2603
+ Action: ["cloudformation:*", "s3:*"],
2604
+ Resource: "*",
2605
+ },
2606
+ ],
2607
+ }),
2608
+ },
2609
+ });
2610
+
2611
+ try {
2612
+ await simAws.cloudFormation().deployTemplate({
2613
+ stackName: "uploads-stack",
2614
+ caller: { kind: "arn", arn: Role.Arn },
2615
+ template: {
2616
+ Resources: {
2617
+ Bucket: {
2618
+ Type: "AWS::S3::Bucket",
2619
+ Properties: { BucketName: "uploads" },
2620
+ },
2621
+ BucketNotifications: {
2622
+ Type: "Custom::S3BucketNotifications",
2623
+ Properties: {
2624
+ ServiceToken: "arn:aws:lambda:us-east-1:888888888888:function:cdk",
2625
+ BucketName: { Ref: "Bucket" },
2626
+ NotificationConfiguration: {},
2627
+ Managed: true,
2628
+ },
2629
+ },
2630
+ },
2631
+ },
2632
+ });
2633
+ } catch (error) {
2634
+ // is not authorized to perform: lambda:InvokeFunction on resource:
2635
+ // arn:aws:lambda:us-east-1:888888888888:function:cdk
2636
+ console.log((error as Error).message);
2637
+ }
2638
+ ```
2639
+
2552
2640
  ## S3 Bucket notifications
2553
2641
 
2554
2642
  The `NotificationConfiguration` property of `AWS::S3::Bucket` deploys through the ordinary
@@ -3040,6 +3040,84 @@ Intercept the document client itself. `DynamoDBDocumentClient.from(client)` buil
3040
3040
  outside the `DynamoDBClient` class, so intercepting the base client leaves Commands sent through the
3041
3041
  document one untouched. See [the SDK docs](https://yulinsim.dev/sdk/#the-dynamodb-document-client).
3042
3042
 
3043
+ ### Marshalling options
3044
+
3045
+ `DynamoDBDocumentClient.from(client, { marshallOptions })` changes what the conversion does with a
3046
+ value the defaults refuse. Yulin reads those options off the client each Command was sent through.
3047
+ Two document clients over one simulation each convert by their own.
3048
+
3049
+ - `removeUndefinedValues` drops an `undefined` out of a map, a list or a `Set`. Without it, an
3050
+ `undefined` in any of the three is refused. The real client refuses it in the same place.
3051
+ - `convertEmptyValues` writes an empty string, an empty binary value and an empty `Set` as `NULL`.
3052
+ - `convertClassInstanceToMap` reads an object with behaviour as a map of its own properties.
3053
+ - `allowImpreciseNumbers` writes a number past `Number.MAX_SAFE_INTEGER` with the digits it has
3054
+ already been rounded to.
3055
+
3056
+ An `undefined` attribute of an item, a key or a set of expression values is left out whatever the
3057
+ client was built with. `removeUndefinedValues` governs the values held inside an attribute, and the
3058
+ attributes of an item sit a level above that (the real client drops an undefined one there without
3059
+ being asked to).
3060
+
3061
+ ```typescript sim-dynamodb-document-marshall-options
3062
+ /**
3063
+ * Writing a partly filled object through a document client that drops
3064
+ * undefined values.
3065
+ */
3066
+
3067
+ import { CreateTableCommand, DynamoDBClient } from "@aws-sdk/client-dynamodb";
3068
+ import {
3069
+ DynamoDBDocumentClient,
3070
+ GetCommand,
3071
+ PutCommand,
3072
+ } from "@aws-sdk/lib-dynamodb";
3073
+
3074
+ import { SimSdk } from "@kensio/yulin/sdk";
3075
+
3076
+ using simSdk = new SimSdk();
3077
+
3078
+ const documents = DynamoDBDocumentClient.from(
3079
+ new DynamoDBClient({ region: "eu-west-2" }),
3080
+ { marshallOptions: { removeUndefinedValues: true } },
3081
+ );
3082
+ simSdk.intercept(documents);
3083
+
3084
+ await documents.send(
3085
+ new CreateTableCommand({
3086
+ TableName: "PagesTable",
3087
+ KeySchema: [{ AttributeName: "pageId", KeyType: "HASH" }],
3088
+ AttributeDefinitions: [{ AttributeName: "pageId", AttributeType: "S" }],
3089
+ BillingMode: "PAY_PER_REQUEST",
3090
+ }),
3091
+ );
3092
+ await simSdk.simAws.backgroundTasksComplete();
3093
+
3094
+ // The summary was never filled in, and one section is still to be written.
3095
+ await documents.send(
3096
+ new PutCommand({
3097
+ TableName: "PagesTable",
3098
+ Item: {
3099
+ pageId: "page-1",
3100
+ meta: { title: "Home", summary: undefined },
3101
+ sections: ["intro", undefined, "outro"],
3102
+ },
3103
+ }),
3104
+ );
3105
+
3106
+ const read = await documents.send(
3107
+ new GetCommand({ TableName: "PagesTable", Key: { pageId: "page-1" } }),
3108
+ );
3109
+
3110
+ const meta = read.Item?.["meta"] as Record<string, string>;
3111
+ console.log(Object.keys(meta)); // [ 'title' ]
3112
+
3113
+ // A dropped member takes its position with it.
3114
+ const sections = read.Item?.["sections"] as string[];
3115
+ console.log(sections); // [ 'intro', 'outro' ]
3116
+ ```
3117
+
3118
+ `unmarshallOptions` are ignored. A stored value comes back the way a document client built with no
3119
+ options of its own reads it.
3120
+
3043
3121
  ### Querying and scanning through the document client
3044
3122
 
3045
3123
  `@aws-sdk/lib-dynamodb` names its `QueryCommand` and `ScanCommand` exactly as
@@ -3152,8 +3230,9 @@ A simulated table holds a number's digits exactly, but the document client conve
3152
3230
  JavaScript numbers, and that is where digits are lost. It is the same loss AWS has. A test that
3153
3231
  passes here is telling you something true about the real thing.
3154
3232
 
3155
- - Writing a `number` outside the safe integer range is refused, never stored already rounded. Write
3156
- a `bigint`, or a `NumberValue` from `@aws-sdk/lib-dynamodb`, to keep the digits.
3233
+ - Writing a `number` outside the safe integer range is refused unless the client was built with
3234
+ `allowImpreciseNumbers`. Write a `bigint`, or a `NumberValue` from `@aws-sdk/lib-dynamodb`, to keep
3235
+ the digits.
3157
3236
  - Reading a stored number outside the safe integer range gives a `bigint`.
3158
3237
  - Reading a stored decimal with more digits than a JavaScript number carries gives a rounded
3159
3238
  `number`. The table still holds every digit, and the rounding is the document client's. Read
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kensio/yulin",
3
- "version": "1.21.16",
3
+ "version": "1.21.18",
4
4
  "description": "AWS system behaviour simulation for isolated unit testing",
5
5
  "repository": "https://github.com/KensioSoftware/yulin",
6
6
  "homepage": "https://yulinsim.dev/",