@go-to-k/cdkd 0.103.2 → 0.105.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -9,7 +9,7 @@ import { CreateTopicCommand, DeleteTopicCommand, GetSubscriptionAttributesComman
9
9
  import { AddPermissionCommand, CreateEventSourceMappingCommand, CreateFunctionCommand, CreateFunctionUrlConfigCommand, DeleteEventSourceMappingCommand, DeleteFunctionCommand, DeleteFunctionUrlConfigCommand, DeleteLayerVersionCommand, GetEventSourceMappingCommand, GetFunctionCommand, GetFunctionUrlConfigCommand, GetLayerVersionByArnCommand, GetPolicyCommand, LambdaClient, ListFunctionsCommand, ListLayersCommand, ListTagsCommand, PublishLayerVersionCommand, RemovePermissionCommand, ResourceNotFoundException, TagResourceCommand as TagResourceCommand$1, UntagResourceCommand as UntagResourceCommand$1, UpdateEventSourceMappingCommand, UpdateFunctionCodeCommand, UpdateFunctionConfigurationCommand, UpdateFunctionUrlConfigCommand, waitUntilFunctionUpdatedV2 } from "@aws-sdk/client-lambda";
10
10
  import { AssumeRoleCommand, GetCallerIdentityCommand, STSClient } from "@aws-sdk/client-sts";
11
11
  import { AssociateRouteTableCommand, AttachInternetGatewayCommand, AuthorizeSecurityGroupEgressCommand, AuthorizeSecurityGroupIngressCommand, CreateInternetGatewayCommand, CreateNatGatewayCommand, CreateNetworkAclCommand, CreateNetworkAclEntryCommand, CreateRouteCommand, CreateRouteTableCommand, CreateSecurityGroupCommand, CreateSubnetCommand, CreateTagsCommand, CreateVpcCommand, DeleteInternetGatewayCommand, DeleteNatGatewayCommand, DeleteNetworkAclCommand, DeleteNetworkAclEntryCommand, DeleteNetworkInterfaceCommand, DeleteRouteCommand, DeleteRouteTableCommand, DeleteSecurityGroupCommand, DeleteSubnetCommand, DeleteTagsCommand, DeleteVpcCommand, DescribeAvailabilityZonesCommand, DescribeInstanceAttributeCommand, DescribeInstancesCommand, DescribeInternetGatewaysCommand, DescribeNatGatewaysCommand, DescribeNetworkAclsCommand, DescribeNetworkInterfacesCommand, DescribeRouteTablesCommand, DescribeSecurityGroupsCommand, DescribeSubnetsCommand, DescribeVolumesCommand, DescribeVpcAttributeCommand, DescribeVpcsCommand, DetachInternetGatewayCommand, DisassociateRouteTableCommand, EC2Client, ModifyInstanceAttributeCommand, ModifySubnetAttributeCommand, ModifyVpcAttributeCommand, ReplaceNetworkAclAssociationCommand, RevokeSecurityGroupEgressCommand, RevokeSecurityGroupIngressCommand, RunInstancesCommand, TerminateInstancesCommand, waitUntilInstanceRunning, waitUntilInstanceTerminated, waitUntilNatGatewayAvailable, waitUntilNatGatewayDeleted } from "@aws-sdk/client-ec2";
12
- import { CreateTableCommand, DeleteTableCommand, DescribeTableCommand, DynamoDBClient, ListTablesCommand, ListTagsOfResourceCommand, ResourceNotFoundException as ResourceNotFoundException$1, TagResourceCommand as TagResourceCommand$2, UntagResourceCommand as UntagResourceCommand$2, UpdateTableCommand, UpdateTimeToLiveCommand } from "@aws-sdk/client-dynamodb";
12
+ import { CreateTableCommand, DeleteTableCommand, DescribeContinuousBackupsCommand, DescribeContributorInsightsCommand, DescribeKinesisStreamingDestinationCommand, DescribeTableCommand, DescribeTimeToLiveCommand, DynamoDBClient, ListTablesCommand, ListTagsOfResourceCommand, ResourceNotFoundException as ResourceNotFoundException$1, TagResourceCommand as TagResourceCommand$2, UntagResourceCommand as UntagResourceCommand$2, UpdateTableCommand, UpdateTimeToLiveCommand } from "@aws-sdk/client-dynamodb";
13
13
  import { CloudFormationClient, CreateChangeSetCommand, DeleteChangeSetCommand, DeleteStackCommand, DescribeChangeSetCommand, DescribeStackEventsCommand, DescribeStackResourcesCommand, DescribeStacksCommand, DescribeTypeCommand, ExecuteChangeSetCommand, GetTemplateCommand, UpdateStackCommand, waitUntilChangeSetCreateComplete, waitUntilStackDeleteComplete, waitUntilStackImportComplete, waitUntilStackUpdateComplete } from "@aws-sdk/client-cloudformation";
14
14
  import { APIGatewayClient, CreateAuthorizerCommand, CreateDeploymentCommand, CreateResourceCommand, CreateStageCommand, DeleteAuthorizerCommand, DeleteDeploymentCommand, DeleteMethodCommand, DeleteResourceCommand, DeleteStageCommand, GetAccountCommand, GetAuthorizerCommand, GetDeploymentCommand, GetMethodCommand, GetResourceCommand, GetStageCommand, NotFoundException as NotFoundException$1, PutIntegrationCommand, PutIntegrationResponseCommand, PutMethodCommand, PutMethodResponseCommand, TagResourceCommand as TagResourceCommand$3, UntagResourceCommand as UntagResourceCommand$3, UpdateAccountCommand, UpdateAuthorizerCommand, UpdateMethodCommand, UpdateStageCommand } from "@aws-sdk/client-api-gateway";
15
15
  import { CreateEventBusCommand, DeleteEventBusCommand, DeleteRuleCommand, DescribeEventBusCommand, DescribeRuleCommand, EventBridgeClient, ListEventBusesCommand, ListRulesCommand, ListTagsForResourceCommand as ListTagsForResourceCommand$1, ListTargetsByRuleCommand, PutRuleCommand, PutTargetsCommand, RemoveTargetsCommand, ResourceNotFoundException as ResourceNotFoundException$2, TagResourceCommand as TagResourceCommand$4, UntagResourceCommand as UntagResourceCommand$4, UpdateEventBusCommand } from "@aws-sdk/client-eventbridge";
@@ -35,6 +35,7 @@ import { Command, Option } from "commander";
35
35
  import { writeFileSync as writeFileSync$1 } from "fs";
36
36
  import { join as join$1 } from "path";
37
37
  import * as zlib from "node:zlib";
38
+ import { ApplicationAutoScalingClient, DescribeScalingPoliciesCommand } from "@aws-sdk/client-application-auto-scaling";
38
39
  import { ApiGatewayV2Client, CreateApiCommand, CreateAuthorizerCommand as CreateAuthorizerCommand$1, CreateIntegrationCommand, CreateRouteCommand as CreateRouteCommand$1, CreateStageCommand as CreateStageCommand$1, DeleteApiCommand, DeleteAuthorizerCommand as DeleteAuthorizerCommand$1, DeleteIntegrationCommand, DeleteRouteCommand as DeleteRouteCommand$1, DeleteStageCommand as DeleteStageCommand$1, GetApiCommand, GetApisCommand, GetAuthorizerCommand as GetAuthorizerCommand$1, GetIntegrationCommand, GetRouteCommand, GetStageCommand as GetStageCommand$1, NotFoundException as NotFoundException$3, UpdateApiCommand, UpdateAuthorizerCommand as UpdateAuthorizerCommand$1, UpdateIntegrationCommand, UpdateRouteCommand, UpdateStageCommand as UpdateStageCommand$1 } from "@aws-sdk/client-apigatewayv2";
39
40
  import { CreateStateMachineCommand, DeleteStateMachineCommand, DescribeStateMachineCommand, ListStateMachinesCommand, ListTagsForResourceCommand as ListTagsForResourceCommand$9, SFNClient, StateMachineDoesNotExist, TagResourceCommand as TagResourceCommand$10, UntagResourceCommand as UntagResourceCommand$9, UpdateStateMachineCommand } from "@aws-sdk/client-sfn";
40
41
  import { CreateClusterCommand, CreateServiceCommand, DeleteClusterCommand, DeleteServiceCommand, DeregisterTaskDefinitionCommand, DescribeClustersCommand, DescribeServicesCommand, DescribeTaskDefinitionCommand, ECSClient, ListClustersCommand, ListServicesCommand, ListTagsForResourceCommand as ListTagsForResourceCommand$10, PutClusterCapacityProvidersCommand, RegisterTaskDefinitionCommand, TagResourceCommand as TagResourceCommand$11, UntagResourceCommand as UntagResourceCommand$10, UpdateClusterCommand, UpdateServiceCommand } from "@aws-sdk/client-ecs";
@@ -7864,20 +7865,49 @@ var DynamoDBTableProvider = class {
7864
7865
  * is the 2019.11.21 generation, which uses the regular DynamoDB CRUD API
7865
7866
  * (`CreateTableCommand` + `UpdateTableCommand` with `ReplicaUpdates`).
7866
7867
  *
7867
- * MVP scope:
7868
- * - `update()` throws `ResourceUpdateNotSupportedError`. In-place GlobalTable
7869
- * updates (replica add/remove, GSI add/remove, BillingMode flip, throughput
7870
- * rewrites) are out of scope and a follow-up PR.
7871
- * - `getDriftUnknownPaths` declares TTL + throughput-settings paths because
7872
- * cdkd's create/update flows surface them but the read-side reverse
7873
- * mapping is non-trivial and would fire false drift.
7868
+ * In-place update support (post-PR #384 follow-up):
7869
+ * - `update()` covers every mutable surface — Tags, DeletionProtection,
7870
+ * TableClass, SSE, StreamSpec, OnDemand throughput, BillingMode flip,
7871
+ * Replica add / remove / modify, GSI add / remove / modify, TTL toggle.
7872
+ * - The serialization is load-bearing: AWS's `UpdateTable` accepts only
7873
+ * ONE of `{BillingMode, ReplicaUpdates, GlobalSecondaryIndexUpdates}`
7874
+ * per call, so each category is its own SDK round-trip with a wait-for
7875
+ * -ACTIVE in between. Immutable property changes (TableName, KeySchema,
7876
+ * AttributeDefinitions removal, LocalSecondaryIndexes) throw
7877
+ * `ProvisioningError` naming the offending field — the deploy engine's
7878
+ * diff classification should catch these as REPLACEMENT before ever
7879
+ * calling `update()`, but the guard is defense-in-depth.
7874
7880
  * - Per-replica drift (`ContributorInsightsSpecification` /
7875
- * `PointInTimeRecoverySpecification` / `KinesisStreamSpecification`) is
7876
- * out of scope for v1.
7881
+ * `PointInTimeRecoverySpecification` / `KinesisStreamSpecification`)
7882
+ * is surfaced for BOTH the LOCAL replica AND cross-region replicas
7883
+ * via per-region SDK clients (cached in `regionalClientCache` for
7884
+ * the deploy run). Issue #389 lifted the v1 LOCAL-only limitation.
7885
+ * - Cross-region replica Tags propagation (Issue #389): when the
7886
+ * update path detects a Tags-only diff on a non-local replica,
7887
+ * cdkd resolves the replica's table ARN by swapping the region
7888
+ * segment of the local ARN and issues `TagResource` /
7889
+ * `UntagResource` against a per-region client.
7877
7890
  */
7878
7891
  var DynamoDBGlobalTableProvider = class {
7879
7892
  dynamoDBClient;
7880
7893
  logger = getLogger().child("DynamoDBGlobalTableProvider");
7894
+ /**
7895
+ * Caches per-region `DynamoDBClient` instances for cross-region drift
7896
+ * reads (`readCurrentState`) and cross-region Tag propagation
7897
+ * (`update()`). Keyed by region string; reuses the default credential
7898
+ * chain. Lifetime is the provider instance — one deploy run.
7899
+ */
7900
+ regionalClientCache = /* @__PURE__ */ new Map();
7901
+ /**
7902
+ * Caches `getAttribute(physicalId, attribute)` results for the lifetime
7903
+ * of this provider instance (one deploy run). Safe under the current
7904
+ * `update()` contract because `update()` cannot mid-deploy mutate
7905
+ * StreamArn / Arn / TableId — those are AWS-managed identifiers that
7906
+ * only change on REPLACEMENT (which destroys the provider instance).
7907
+ * If a future PR adds a stream toggle path that flips StreamArn on the
7908
+ * same physicalId, the cache must be invalidated on the matching
7909
+ * UpdateTable success.
7910
+ */
7881
7911
  attributeCache = /* @__PURE__ */ new Map();
7882
7912
  handledProperties = new Map([["AWS::DynamoDB::GlobalTable", new Set([
7883
7913
  "TableName",
@@ -7893,14 +7923,57 @@ var DynamoDBGlobalTableProvider = class {
7893
7923
  "TimeToLiveSpecification",
7894
7924
  "WriteProvisionedThroughputSettings",
7895
7925
  "WriteOnDemandThroughputSettings",
7896
- "DeletionProtectionEnabled",
7897
- "Tags"
7926
+ "DeletionProtectionEnabled"
7898
7927
  ])]]);
7899
7928
  constructor() {
7900
7929
  const awsClients = getAwsClients();
7901
7930
  this.dynamoDBClient = awsClients.dynamoDB;
7902
7931
  }
7903
7932
  /**
7933
+ * Return a `DynamoDBClient` pinned to the given region, caching per
7934
+ * region for the lifetime of this provider instance. Uses the default
7935
+ * credential chain (env / shared config / IAM role) — no explicit
7936
+ * credential plumbing needed.
7937
+ *
7938
+ * Used by `readCurrentState` (cross-region per-replica sub-spec reads)
7939
+ * and `update()` (cross-region replica Tag propagation). Both code
7940
+ * paths fire region-scoped DynamoDB APIs (`DescribeContributorInsights`
7941
+ * / `DescribeContinuousBackups` / `DescribeKinesisStreamingDestination`
7942
+ * / `TagResource` / `UntagResource`) against the replica's region.
7943
+ *
7944
+ * The cache may return `this.dynamoDBClient` when `region` happens to
7945
+ * match the local client's region — in tests the local mock is
7946
+ * intercepted via `vi.mock('../../utils/aws-clients.js')` so reuse
7947
+ * is safe (and explicitly desired so the mock catches the call).
7948
+ */
7949
+ getRegionalClient(region) {
7950
+ const cached = this.regionalClientCache.get(region);
7951
+ if (cached) return cached;
7952
+ const client = new DynamoDBClient({ region });
7953
+ this.regionalClientCache.set(region, client);
7954
+ return client;
7955
+ }
7956
+ /**
7957
+ * Construct the regional table ARN for a cross-region replica of a
7958
+ * GlobalTable. AWS replicates the same `TableName` across every
7959
+ * replica region, with each replica's ARN differing only in the
7960
+ * `:<region>:` segment. Cheaper than a second `DescribeTable` round-
7961
+ * trip on the regional client.
7962
+ *
7963
+ * Example:
7964
+ * local ARN: arn:aws:dynamodb:us-east-1:123:table/Foo
7965
+ * for eu-west-1 → arn:aws:dynamodb:eu-west-1:123:table/Foo
7966
+ *
7967
+ * Returns `undefined` when the local ARN is malformed (defensive —
7968
+ * downstream callers omit the offending operation rather than throw).
7969
+ */
7970
+ replicaArnForRegion(localTableArn, targetRegion) {
7971
+ const segments = localTableArn.split(":");
7972
+ if (segments.length < 6) return void 0;
7973
+ segments[3] = targetRegion;
7974
+ return segments.join(":");
7975
+ }
7976
+ /**
7904
7977
  * Create a DynamoDB Global Table (CDK TableV2).
7905
7978
  *
7906
7979
  * GlobalTable is built on the regular DynamoDB Table primitive: cdkd issues
@@ -7935,15 +8008,7 @@ var DynamoDBGlobalTableProvider = class {
7935
8008
  AttributeDefinitions: attributeDefinitions,
7936
8009
  BillingMode: billingMode
7937
8010
  };
7938
- if (billingMode === "PROVISIONED") {
7939
- const writeAutoScaling = properties["WriteProvisionedThroughputSettings"]?.["WriteCapacityAutoScalingSettings"];
7940
- const writeCapacity = Number(writeAutoScaling?.["MinCapacity"] ?? 5);
7941
- const readAutoScaling = (replicas.find((r) => r["Region"] === currentRegion)?.["ReadProvisionedThroughputSettings"])?.["ReadCapacityAutoScalingSettings"];
7942
- createParams.ProvisionedThroughput = {
7943
- ReadCapacityUnits: Number(readAutoScaling?.["MinCapacity"] ?? 5),
7944
- WriteCapacityUnits: writeCapacity
7945
- };
7946
- }
8011
+ if (billingMode === "PROVISIONED") createParams.ProvisionedThroughput = derivePerCallProvisionedThroughput(properties, currentRegion);
7947
8012
  const streamSpecInput = properties["StreamSpecification"];
7948
8013
  const needsStream = replicas.some((r) => r["Region"] !== currentRegion) || replicas.length > 1;
7949
8014
  if (streamSpecInput) createParams.StreamSpecification = {
@@ -7969,7 +8034,8 @@ var DynamoDBGlobalTableProvider = class {
7969
8034
  if (properties["TableClass"]) createParams.TableClass = properties["TableClass"];
7970
8035
  const wodts = properties["WriteOnDemandThroughputSettings"];
7971
8036
  if (wodts?.["MaxWriteRequestUnits"] !== void 0) createParams.OnDemandThroughput = { MaxWriteRequestUnits: Number(wodts["MaxWriteRequestUnits"]) };
7972
- if (properties["Tags"]) createParams.Tags = properties["Tags"];
8037
+ const localReplicaTags = replicas.find((r) => r["Region"] === currentRegion)?.["Tags"];
8038
+ if (localReplicaTags && localReplicaTags.length > 0) createParams.Tags = localReplicaTags;
7973
8039
  try {
7974
8040
  await this.dynamoDBClient.send(new CreateTableCommand(createParams));
7975
8041
  this.logger.debug(`CreateTable initiated for ${tableName}, waiting for ACTIVE`);
@@ -7978,11 +8044,11 @@ var DynamoDBGlobalTableProvider = class {
7978
8044
  throw new ProvisioningError(`Failed to create DynamoDB GlobalTable ${logicalId}: ${error instanceof Error ? error.message : String(error)}`, resourceType, logicalId, tableName, cause);
7979
8045
  }
7980
8046
  try {
7981
- const tableInfo = await this.waitForTableActive(tableName);
8047
+ const tableInfo = await this.waitForTableActive(tableName, logicalId);
7982
8048
  for (const replica of replicas) {
7983
8049
  const region = replica["Region"];
7984
8050
  if (!region || region === currentRegion) continue;
7985
- await this.addReplica(tableName, replica, region);
8051
+ await this.addReplica(tableName, replica, region, logicalId);
7986
8052
  }
7987
8053
  if (properties["TimeToLiveSpecification"]) {
7988
8054
  const ttl = properties["TimeToLiveSpecification"];
@@ -8009,6 +8075,21 @@ var DynamoDBGlobalTableProvider = class {
8009
8075
  } catch (wiringError) {
8010
8076
  this.logger.warn(`Wiring failed after CreateTable for ${tableName}; attempting best-effort cleanup`);
8011
8077
  try {
8078
+ const replicasForCleanup = (await this.dynamoDBClient.send(new DescribeTableCommand({ TableName: tableName }))).Table?.Replicas ?? [];
8079
+ for (const replica of replicasForCleanup) {
8080
+ const region = replica.RegionName;
8081
+ if (!region || region === currentRegion) continue;
8082
+ try {
8083
+ await this.dynamoDBClient.send(new UpdateTableCommand({
8084
+ TableName: tableName,
8085
+ ReplicaUpdates: [{ Delete: { RegionName: region } }]
8086
+ }));
8087
+ await this.waitForReplicaGone(tableName, region, logicalId);
8088
+ } catch (replicaCleanupErr) {
8089
+ const msg = replicaCleanupErr instanceof Error ? replicaCleanupErr.message : String(replicaCleanupErr);
8090
+ this.logger.warn(`Partial-create cleanup: failed to drop replica ${region} on ${tableName}: ${msg}. Run: aws dynamodb update-table --table-name ${tableName} --replica-updates 'Delete={RegionName=${region}}' --region ${currentRegion}`);
8091
+ }
8092
+ }
8012
8093
  await this.dynamoDBClient.send(new DeleteTableCommand({ TableName: tableName }));
8013
8094
  } catch (cleanupErr) {
8014
8095
  const cleanupMsg = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr);
@@ -8025,7 +8106,7 @@ var DynamoDBGlobalTableProvider = class {
8025
8106
  * Capped at 10 minutes per replica (AWS Replica provisioning typically
8026
8107
  * takes 1–5 min).
8027
8108
  */
8028
- async addReplica(tableName, replica, region) {
8109
+ async addReplica(tableName, replica, region, logicalId) {
8029
8110
  const create = { RegionName: region };
8030
8111
  if (replica["KMSMasterKeyId"]) create.KMSMasterKeyId = replica["KMSMasterKeyId"];
8031
8112
  if (replica["GlobalSecondaryIndexes"]) create.GlobalSecondaryIndexes = replica["GlobalSecondaryIndexes"];
@@ -8035,19 +8116,263 @@ var DynamoDBGlobalTableProvider = class {
8035
8116
  TableName: tableName,
8036
8117
  ReplicaUpdates: replicaUpdates
8037
8118
  }));
8038
- await this.waitForReplicaActive(tableName, region);
8119
+ await this.waitForReplicaActive(tableName, region, logicalId);
8120
+ }
8121
+ /**
8122
+ * Update a DynamoDB Global Table in place.
8123
+ *
8124
+ * AWS-side state-machine constraint: `UpdateTable` accepts only ONE of
8125
+ * `{BillingMode, ReplicaUpdates, GlobalSecondaryIndexUpdates}` per call,
8126
+ * so each category must serialize into its own SDK round-trip with a
8127
+ * `waitForTableActiveAfterUpdate` between every step. Order:
8128
+ * 1. Wait for current ACTIVE (defensive).
8129
+ * 2. Tags diff (TagResource / UntagResource — no wait needed).
8130
+ * 3. Non-conflicting flat fields (DeletionProtectionEnabled / TableClass
8131
+ * / SSESpecification / StreamSpecification / OnDemandThroughput)
8132
+ * in one combined `UpdateTableCommand`. Wait ACTIVE.
8133
+ * 4. BillingMode flip (separate UpdateTable). Wait ACTIVE.
8134
+ * 5. Replica diff (serial per Create / Update / Delete). Wait ACTIVE
8135
+ * after each.
8136
+ * 6. GSI diff (serial per Create / Update / Delete; new GSIs may need
8137
+ * additional AttributeDefinitions). Wait ACTIVE after each.
8138
+ * 7. TimeToLiveSpecification toggle.
8139
+ *
8140
+ * Immutable properties (TableName / KeySchema / AttributeDefinitions
8141
+ * removals / LocalSecondaryIndexes changes) throw `ProvisioningError`
8142
+ * naming the offending field — the deploy engine's diff classifier
8143
+ * should catch these as REPLACEMENT before ever calling `update()`,
8144
+ * but the guard is defense-in-depth.
8145
+ */
8146
+ async update(logicalId, physicalId, resourceType, properties, previousProperties) {
8147
+ this.logger.debug(`Updating DynamoDB GlobalTable ${logicalId}: ${physicalId}`);
8148
+ if (properties["TableName"] !== void 0 && previousProperties["TableName"] !== void 0 && properties["TableName"] !== previousProperties["TableName"]) throw new ProvisioningError(`TableName is immutable on AWS::DynamoDB::GlobalTable; replacement required (deploy with --replace, or destroy + redeploy)`, resourceType, logicalId, physicalId);
8149
+ if (properties["KeySchema"] !== void 0 && previousProperties["KeySchema"] !== void 0 && !deepEqual$1(properties["KeySchema"], previousProperties["KeySchema"])) throw new ProvisioningError(`KeySchema is immutable on AWS::DynamoDB::GlobalTable; replacement required (deploy with --replace, or destroy + redeploy)`, resourceType, logicalId, physicalId);
8150
+ if (properties["LocalSecondaryIndexes"] !== void 0 && previousProperties["LocalSecondaryIndexes"] !== void 0 && !deepEqual$1(properties["LocalSecondaryIndexes"], previousProperties["LocalSecondaryIndexes"])) throw new ProvisioningError(`LocalSecondaryIndexes is immutable on AWS::DynamoDB::GlobalTable; replacement required (deploy with --replace, or destroy + redeploy)`, resourceType, logicalId, physicalId);
8151
+ const oldAttrs = previousProperties["AttributeDefinitions"] ?? [];
8152
+ const newAttrs = properties["AttributeDefinitions"] ?? [];
8153
+ const removedAttrs = oldAttrs.filter((o) => !newAttrs.some((n) => n.AttributeName === o.AttributeName));
8154
+ if (removedAttrs.length > 0) throw new ProvisioningError(`AttributeDefinitions removals are immutable on AWS::DynamoDB::GlobalTable (offenders: ${removedAttrs.map((a) => a.AttributeName).join(", ")}); replacement required`, resourceType, logicalId, physicalId);
8155
+ const currentRegion = await this.dynamoDBClient.config.region() ?? "";
8156
+ try {
8157
+ await this.waitForTableActiveAfterUpdate(physicalId, logicalId);
8158
+ const tableArn = (await this.dynamoDBClient.send(new DescribeTableCommand({ TableName: physicalId }))).Table?.TableArn;
8159
+ const extractLocalTags = (props) => {
8160
+ return (props["Replicas"] ?? []).find((r) => r["Region"] === currentRegion)?.["Tags"];
8161
+ };
8162
+ if (tableArn) await this.applyTagDiff(tableArn, extractLocalTags(previousProperties), extractLocalTags(properties));
8163
+ const flatUpdate = { TableName: physicalId };
8164
+ let flatChanged = false;
8165
+ if (properties["DeletionProtectionEnabled"] !== previousProperties["DeletionProtectionEnabled"]) {
8166
+ flatUpdate.DeletionProtectionEnabled = Boolean(properties["DeletionProtectionEnabled"] ?? false);
8167
+ flatChanged = true;
8168
+ }
8169
+ if (properties["TableClass"] !== void 0 && properties["TableClass"] !== previousProperties["TableClass"]) {
8170
+ flatUpdate.TableClass = properties["TableClass"];
8171
+ flatChanged = true;
8172
+ }
8173
+ if (properties["SSESpecification"] !== void 0 && !deepEqual$1(properties["SSESpecification"], previousProperties["SSESpecification"])) {
8174
+ const sse = properties["SSESpecification"];
8175
+ flatUpdate.SSESpecification = {
8176
+ Enabled: sse["SSEEnabled"] !== void 0 ? Boolean(sse["SSEEnabled"]) : true,
8177
+ ...sse["SSEType"] !== void 0 && { SSEType: sse["SSEType"] }
8178
+ };
8179
+ flatChanged = true;
8180
+ }
8181
+ if (properties["StreamSpecification"] !== void 0 && !deepEqual$1(properties["StreamSpecification"], previousProperties["StreamSpecification"])) {
8182
+ flatUpdate.StreamSpecification = {
8183
+ StreamEnabled: true,
8184
+ StreamViewType: properties["StreamSpecification"]["StreamViewType"]
8185
+ };
8186
+ flatChanged = true;
8187
+ }
8188
+ if (!deepEqual$1(properties["WriteOnDemandThroughputSettings"], previousProperties["WriteOnDemandThroughputSettings"])) {
8189
+ const wodts = properties["WriteOnDemandThroughputSettings"];
8190
+ if (wodts?.["MaxWriteRequestUnits"] !== void 0) {
8191
+ flatUpdate.OnDemandThroughput = { MaxWriteRequestUnits: Number(wodts["MaxWriteRequestUnits"]) };
8192
+ flatChanged = true;
8193
+ }
8194
+ }
8195
+ if (flatChanged) {
8196
+ await this.dynamoDBClient.send(new UpdateTableCommand(flatUpdate));
8197
+ await this.waitForTableActiveAfterUpdate(physicalId, logicalId);
8198
+ }
8199
+ const oldBilling = previousProperties["BillingMode"] ?? "PAY_PER_REQUEST";
8200
+ const newBilling = properties["BillingMode"] ?? "PAY_PER_REQUEST";
8201
+ if (oldBilling !== newBilling) {
8202
+ const billingUpdate = {
8203
+ TableName: physicalId,
8204
+ BillingMode: newBilling
8205
+ };
8206
+ if (newBilling === "PROVISIONED") billingUpdate.ProvisionedThroughput = derivePerCallProvisionedThroughput(properties, currentRegion);
8207
+ await this.dynamoDBClient.send(new UpdateTableCommand(billingUpdate));
8208
+ await this.waitForTableActiveAfterUpdate(physicalId, logicalId);
8209
+ }
8210
+ const replicaDiff = diffReplicas(previousProperties["Replicas"] ?? [], properties["Replicas"] ?? []);
8211
+ for (const replica of replicaDiff.removed) {
8212
+ const region = replica["Region"];
8213
+ if (!region || region === currentRegion) continue;
8214
+ await this.dynamoDBClient.send(new UpdateTableCommand({
8215
+ TableName: physicalId,
8216
+ ReplicaUpdates: [{ Delete: { RegionName: region } }]
8217
+ }));
8218
+ await this.waitForReplicaGone(physicalId, region, logicalId);
8219
+ }
8220
+ for (const replica of replicaDiff.added) {
8221
+ const region = replica["Region"];
8222
+ if (!region || region === currentRegion) continue;
8223
+ await this.addReplica(physicalId, replica, region, logicalId);
8224
+ }
8225
+ for (const replica of replicaDiff.modified) {
8226
+ const region = replica["Region"];
8227
+ if (!region || region === currentRegion) continue;
8228
+ const oldReplicaTags = (previousProperties["Replicas"] ?? []).find((r) => r["Region"] === region)?.["Tags"];
8229
+ const newReplicaTags = replica["Tags"];
8230
+ if (!deepEqual$1(oldReplicaTags, newReplicaTags)) if (tableArn) {
8231
+ const replicaArn = this.replicaArnForRegion(tableArn, region);
8232
+ if (replicaArn) try {
8233
+ const regionalClient = this.getRegionalClient(region);
8234
+ await this.applyTagDiffOnClient(regionalClient, replicaArn, oldReplicaTags, newReplicaTags);
8235
+ } catch (tagErr) {
8236
+ this.logger.warn(`Could not apply Tags diff to cross-region replica ${region} of ${physicalId}: ${tagErr instanceof Error ? tagErr.message : String(tagErr)}. The replica's Tags state will surface as drift until the next successful deploy.`);
8237
+ }
8238
+ else this.logger.warn(`Could not derive replica ARN for region ${region} from ${tableArn} — skipping Tags propagation for ${physicalId}`);
8239
+ } else this.logger.warn(`Local DescribeTable returned no TableArn — cannot propagate Tags to cross-region replica ${region} of ${physicalId}`);
8240
+ const updateAction = { RegionName: region };
8241
+ if (replica["KMSMasterKeyId"] !== void 0) updateAction.KMSMasterKeyId = replica["KMSMasterKeyId"];
8242
+ if (replica["GlobalSecondaryIndexes"]) updateAction.GlobalSecondaryIndexes = replica["GlobalSecondaryIndexes"];
8243
+ if (replica["TableClassOverride"]) updateAction.TableClassOverride = replica["TableClassOverride"];
8244
+ if (!(updateAction.KMSMasterKeyId !== void 0 || updateAction.GlobalSecondaryIndexes !== void 0 || updateAction.TableClassOverride !== void 0)) {
8245
+ this.logger.debug(`Cross-region replica ${region} of ${physicalId}: only Tags-style changes detected; UpdateReplica skipped (AWS rejects empty Update actions). Tags propagation handled above via per-region client.`);
8246
+ continue;
8247
+ }
8248
+ await this.dynamoDBClient.send(new UpdateTableCommand({
8249
+ TableName: physicalId,
8250
+ ReplicaUpdates: [{ Update: updateAction }]
8251
+ }));
8252
+ await this.waitForReplicaActive(physicalId, region, logicalId);
8253
+ }
8254
+ const gsiDiff = diffGlobalSecondaryIndexes(previousProperties["GlobalSecondaryIndexes"] ?? [], properties["GlobalSecondaryIndexes"] ?? []);
8255
+ for (const gsi of gsiDiff.removed) {
8256
+ if (!gsi.IndexName) continue;
8257
+ const gsiUpdate = { Delete: { IndexName: gsi.IndexName } };
8258
+ await this.dynamoDBClient.send(new UpdateTableCommand({
8259
+ TableName: physicalId,
8260
+ GlobalSecondaryIndexUpdates: [gsiUpdate]
8261
+ }));
8262
+ await this.waitForTableActiveAfterUpdate(physicalId, logicalId);
8263
+ }
8264
+ for (const gsi of gsiDiff.added) {
8265
+ if (!gsi.IndexName || !gsi.KeySchema || !gsi.Projection) continue;
8266
+ const gsiUpdate = { Create: {
8267
+ IndexName: gsi.IndexName,
8268
+ KeySchema: gsi.KeySchema,
8269
+ Projection: gsi.Projection,
8270
+ ...gsi.ProvisionedThroughput && { ProvisionedThroughput: gsi.ProvisionedThroughput },
8271
+ ...gsi.OnDemandThroughput && { OnDemandThroughput: gsi.OnDemandThroughput }
8272
+ } };
8273
+ await this.dynamoDBClient.send(new UpdateTableCommand({
8274
+ TableName: physicalId,
8275
+ AttributeDefinitions: newAttrs,
8276
+ GlobalSecondaryIndexUpdates: [gsiUpdate]
8277
+ }));
8278
+ await this.waitForTableActiveAfterUpdate(physicalId, logicalId);
8279
+ }
8280
+ for (const gsi of gsiDiff.modified) {
8281
+ if (!gsi.IndexName) continue;
8282
+ const gsiUpdate = { Update: {
8283
+ IndexName: gsi.IndexName,
8284
+ ...gsi.ProvisionedThroughput && { ProvisionedThroughput: gsi.ProvisionedThroughput },
8285
+ ...gsi.OnDemandThroughput && { OnDemandThroughput: gsi.OnDemandThroughput }
8286
+ } };
8287
+ await this.dynamoDBClient.send(new UpdateTableCommand({
8288
+ TableName: physicalId,
8289
+ GlobalSecondaryIndexUpdates: [gsiUpdate]
8290
+ }));
8291
+ await this.waitForTableActiveAfterUpdate(physicalId, logicalId);
8292
+ }
8293
+ if (!deepEqual$1(properties["TimeToLiveSpecification"], previousProperties["TimeToLiveSpecification"])) {
8294
+ const ttl = properties["TimeToLiveSpecification"];
8295
+ if (ttl?.["AttributeName"]) await this.dynamoDBClient.send(new UpdateTimeToLiveCommand({
8296
+ TableName: physicalId,
8297
+ TimeToLiveSpecification: {
8298
+ Enabled: ttl["Enabled"] !== void 0 ? Boolean(ttl["Enabled"]) : true,
8299
+ AttributeName: ttl["AttributeName"]
8300
+ }
8301
+ }));
8302
+ else if (previousProperties["TimeToLiveSpecification"]) {
8303
+ const prevTtl = previousProperties["TimeToLiveSpecification"];
8304
+ if (prevTtl["AttributeName"]) await this.dynamoDBClient.send(new UpdateTimeToLiveCommand({
8305
+ TableName: physicalId,
8306
+ TimeToLiveSpecification: {
8307
+ Enabled: false,
8308
+ AttributeName: prevTtl["AttributeName"]
8309
+ }
8310
+ }));
8311
+ }
8312
+ }
8313
+ const finalDescribe = await this.dynamoDBClient.send(new DescribeTableCommand({ TableName: physicalId }));
8314
+ return {
8315
+ physicalId,
8316
+ wasReplaced: false,
8317
+ attributes: {
8318
+ Arn: finalDescribe.Table?.TableArn,
8319
+ TableId: finalDescribe.Table?.TableId,
8320
+ StreamArn: finalDescribe.Table?.LatestStreamArn,
8321
+ TableName: physicalId
8322
+ }
8323
+ };
8324
+ } catch (error) {
8325
+ if (error instanceof ProvisioningError) throw error;
8326
+ const cause = error instanceof Error ? error : void 0;
8327
+ throw new ProvisioningError(`Failed to update DynamoDB GlobalTable ${logicalId}: ${error instanceof Error ? error.message : String(error)}`, resourceType, logicalId, physicalId, cause);
8328
+ }
8039
8329
  }
8040
8330
  /**
8041
- * Update a DynamoDB Global Table.
8331
+ * Apply a diff between old and new CFn-shape Tags arrays via DynamoDB's
8332
+ * `TagResource` / `UntagResource` APIs against the local client. Both
8333
+ * take the table ARN as `ResourceArn`.
8042
8334
  *
8043
- * MVP: in-place updates are out of scope — replica add/remove, GSI
8044
- * add/remove, BillingMode flip, and throughput rewrites each have
8045
- * distinct UpdateTable shapes and ordering rules. `cdkd drift --revert`
8046
- * surfaces this as `ResourceUpdateNotSupportedError` (exit code 2);
8047
- * the user falls back to `cdkd deploy --replace` or destroy + redeploy.
8335
+ * Local-replica convenience wrapper around `applyTagDiffOnClient` so
8336
+ * existing call sites stay unchanged.
8048
8337
  */
8049
- async update(logicalId, _physicalId, resourceType, _properties, _previousProperties) {
8050
- throw new ResourceUpdateNotSupportedError(resourceType, logicalId, "GlobalTable in-place updates are not yet supported; use 'cdkd deploy --replace' or destroy + redeploy");
8338
+ async applyTagDiff(tableArn, oldTagsRaw, newTagsRaw) {
8339
+ await this.applyTagDiffOnClient(this.dynamoDBClient, tableArn, oldTagsRaw, newTagsRaw);
8340
+ }
8341
+ /**
8342
+ * Apply a Tags diff against the given `DynamoDBClient` (which may be
8343
+ * the local client or a per-region client returned by
8344
+ * `getRegionalClient`). Used by the local-replica path AND the
8345
+ * cross-region replica Tags propagation path (Issue #389).
8346
+ */
8347
+ async applyTagDiffOnClient(client, tableArn, oldTagsRaw, newTagsRaw) {
8348
+ const toMap = (tags) => {
8349
+ const m = /* @__PURE__ */ new Map();
8350
+ for (const t of tags ?? []) if (t.Key !== void 0 && t.Value !== void 0) m.set(t.Key, t.Value);
8351
+ return m;
8352
+ };
8353
+ const oldMap = toMap(oldTagsRaw);
8354
+ const newMap = toMap(newTagsRaw);
8355
+ const tagsToAdd = [];
8356
+ for (const [k, v] of newMap) if (oldMap.get(k) !== v) tagsToAdd.push({
8357
+ Key: k,
8358
+ Value: v
8359
+ });
8360
+ const tagsToRemove = [];
8361
+ for (const k of oldMap.keys()) if (!newMap.has(k)) tagsToRemove.push(k);
8362
+ if (tagsToRemove.length > 0) {
8363
+ await client.send(new UntagResourceCommand$2({
8364
+ ResourceArn: tableArn,
8365
+ TagKeys: tagsToRemove
8366
+ }));
8367
+ this.logger.debug(`Removed ${tagsToRemove.length} tag(s) from DynamoDB GlobalTable ${tableArn}`);
8368
+ }
8369
+ if (tagsToAdd.length > 0) {
8370
+ await client.send(new TagResourceCommand$2({
8371
+ ResourceArn: tableArn,
8372
+ Tags: tagsToAdd
8373
+ }));
8374
+ this.logger.debug(`Added/updated ${tagsToAdd.length} tag(s) on DynamoDB GlobalTable ${tableArn}`);
8375
+ }
8051
8376
  }
8052
8377
  /**
8053
8378
  * Delete a DynamoDB Global Table.
@@ -8075,7 +8400,7 @@ var DynamoDBGlobalTableProvider = class {
8075
8400
  }));
8076
8401
  this.logger.debug(`Disabled DeletionProtectionEnabled on ${logicalId}, waiting for ACTIVE`);
8077
8402
  try {
8078
- await this.waitForTableActiveAfterUpdate(physicalId);
8403
+ await this.waitForTableActiveAfterUpdate(physicalId, logicalId);
8079
8404
  } catch (waitErr) {
8080
8405
  this.logger.debug(`Could not wait for table ${physicalId} ACTIVE after protection flip: ${waitErr instanceof Error ? waitErr.message : String(waitErr)}`);
8081
8406
  }
@@ -8099,7 +8424,7 @@ var DynamoDBGlobalTableProvider = class {
8099
8424
  TableName: physicalId,
8100
8425
  ReplicaUpdates: [{ Delete: { RegionName: region } }]
8101
8426
  }));
8102
- await this.waitForReplicaGone(physicalId, region);
8427
+ await this.waitForReplicaGone(physicalId, region, logicalId);
8103
8428
  } catch (replicaErr) {
8104
8429
  if (!(replicaErr instanceof ResourceNotFoundException$1)) throw replicaErr;
8105
8430
  }
@@ -8112,7 +8437,7 @@ var DynamoDBGlobalTableProvider = class {
8112
8437
  }
8113
8438
  try {
8114
8439
  await this.dynamoDBClient.send(new DeleteTableCommand({ TableName: physicalId }));
8115
- await this.waitForTableGone(physicalId);
8440
+ await this.waitForTableGone(physicalId, logicalId);
8116
8441
  this.logger.debug(`Successfully deleted DynamoDB GlobalTable ${logicalId}`);
8117
8442
  } catch (error) {
8118
8443
  if (error instanceof ResourceNotFoundException$1) {
@@ -8158,22 +8483,29 @@ var DynamoDBGlobalTableProvider = class {
8158
8483
  /**
8159
8484
  * Read the AWS-current DynamoDB GlobalTable configuration in CFn-property shape.
8160
8485
  *
8161
- * Reverse-maps `DescribeTable` + `ListTagsOfResource` into the
8162
- * `AWS::DynamoDB::GlobalTable` property set.
8486
+ * Reverse-maps `DescribeTable` + `ListTagsOfResource` + `DescribeTimeToLive`
8487
+ * + per-replica `DescribeContributorInsights` /
8488
+ * `DescribeContinuousBackups` / `DescribeKinesisStreamingDestination`
8489
+ * into the `AWS::DynamoDB::GlobalTable` property set.
8163
8490
  *
8164
8491
  * Type-discriminator gating (memory rule
8165
8492
  * `feedback_always_emit_check_type_discriminator.md`):
8166
- * - `ProvisionedThroughput`-bearing fields are only surfaced when
8167
- * `BillingMode === 'PROVISIONED'`. Emitting placeholders on
8168
- * PAY_PER_REQUEST tables (or vice versa) would fire false drift on
8169
- * every clean run.
8170
8493
  * - StreamSpecification / SSESpecification follow the existing
8171
8494
  * DynamoDB::Table provider's Class 1 guard: only surfaced when AWS
8172
8495
  * reports the feature actually enabled.
8173
- *
8174
- * `getDriftUnknownPaths` declares TTL + write-throughput-settings
8175
- * those round-trip in the create path but the reverse-mapping is not
8176
- * yet implemented and would fire guaranteed false drift.
8496
+ * - `ProvisionedThroughput`-bearing fields are declared in
8497
+ * `getDriftUnknownPaths` and intentionally not emitted in v1 the
8498
+ * reverse-mapping from AWS's `ProvisionedThroughput` shape into
8499
+ * CFn's `WriteProvisionedThroughputSettings` /
8500
+ * `ReadProvisionedThroughputSettings` wrappers (which carry
8501
+ * `WriteCapacityAutoScalingSettings` etc.) needs more work to round
8502
+ * -trip cleanly.
8503
+ *
8504
+ * Per-replica sub-specifications (`ContributorInsightsSpecification` /
8505
+ * `PointInTimeRecoverySpecification` / `KinesisStreamSpecification`)
8506
+ * are surfaced only for the LOCAL replica. Cross-region replicas
8507
+ * require per-region SDK clients (`new DynamoDBClient({region})`),
8508
+ * deferred to a follow-up PR.
8177
8509
  */
8178
8510
  async readCurrentState(physicalId, _logicalId, _resourceType) {
8179
8511
  try {
@@ -8196,19 +8528,54 @@ var DynamoDBGlobalTableProvider = class {
8196
8528
  if (table.SSEDescription.SSEType !== void 0) sse["SSEType"] = table.SSEDescription.SSEType;
8197
8529
  result["SSESpecification"] = sse;
8198
8530
  }
8199
- result["Replicas"] = (table.Replicas ?? []).map((r) => ({
8200
- Region: r.RegionName,
8201
- ...r.KMSMasterKeyId !== void 0 && { KMSMasterKeyId: r.KMSMasterKeyId }
8202
- }));
8203
8531
  if (table.TableClassSummary?.TableClass) result["TableClass"] = table.TableClassSummary.TableClass;
8204
8532
  if (table.DeletionProtectionEnabled !== void 0) result["DeletionProtectionEnabled"] = table.DeletionProtectionEnabled;
8205
- if (table.TableArn) try {
8206
- result["Tags"] = normalizeAwsTagsToCfn((await this.dynamoDBClient.send(new ListTagsOfResourceCommand({ ResourceArn: table.TableArn }))).Tags);
8207
- } catch (err) {
8208
- if (err instanceof ResourceNotFoundException$1) return void 0;
8209
- result["Tags"] = [];
8533
+ const currentRegion = await this.dynamoDBClient.config.region() ?? "";
8534
+ const tableNameForSubs = table.TableName ?? physicalId;
8535
+ result["Replicas"] = await Promise.all((table.Replicas ?? []).map(async (r) => {
8536
+ const entry = { Region: r.RegionName };
8537
+ if (r.KMSMasterKeyId !== void 0) entry["KMSMasterKeyId"] = r.KMSMasterKeyId;
8538
+ if (!r.RegionName) return entry;
8539
+ const isLocal = r.RegionName === currentRegion;
8540
+ const client = isLocal ? this.dynamoDBClient : this.getRegionalClient(r.RegionName);
8541
+ const regionLabel = r.RegionName;
8542
+ const replicaArn = isLocal ? table.TableArn : table.TableArn ? this.replicaArnForRegion(table.TableArn, r.RegionName) : void 0;
8543
+ const subs = await this.readReplicaSubSpecs(client, tableNameForSubs, regionLabel);
8544
+ Object.assign(entry, subs);
8545
+ if (replicaArn) try {
8546
+ entry["Tags"] = normalizeAwsTagsToCfn((await client.send(new ListTagsOfResourceCommand({ ResourceArn: replicaArn }))).Tags);
8547
+ } catch (tagErr) {
8548
+ if (tagErr instanceof ResourceNotFoundException$1) {
8549
+ if (isLocal) throw tagErr;
8550
+ this.logger.debug(`Cross-region replica ${regionLabel} returned RNF on ListTagsOfResource; omitting Tags`);
8551
+ entry["Tags"] = [];
8552
+ } else {
8553
+ this.logger.warn(`Could not fetch tags for DynamoDB GlobalTable ${tableNameForSubs} in ${regionLabel}: ${tagErr instanceof Error ? tagErr.message : String(tagErr)}`);
8554
+ entry["Tags"] = [];
8555
+ }
8556
+ }
8557
+ else entry["Tags"] = [];
8558
+ return entry;
8559
+ }));
8560
+ if (table.OnDemandThroughput?.MaxWriteRequestUnits !== void 0) result["WriteOnDemandThroughputSettings"] = { MaxWriteRequestUnits: table.OnDemandThroughput.MaxWriteRequestUnits };
8561
+ else result["WriteOnDemandThroughputSettings"] = {};
8562
+ if (billingMode === "PROVISIONED") {
8563
+ const writeCapacity = table.ProvisionedThroughput?.WriteCapacityUnits;
8564
+ if (writeCapacity !== void 0) if (!await this.hasWriteAutoScalingPolicy(tableNameForSubs)) result["WriteProvisionedThroughputSettings"] = { WriteCapacityUnits: writeCapacity };
8565
+ else result["WriteProvisionedThroughputSettings"] = {};
8566
+ else result["WriteProvisionedThroughputSettings"] = {};
8567
+ } else result["WriteProvisionedThroughputSettings"] = {};
8568
+ try {
8569
+ const ttlDesc = (await this.dynamoDBClient.send(new DescribeTimeToLiveCommand({ TableName: tableNameForSubs }))).TimeToLiveDescription;
8570
+ const ttlStatus = ttlDesc?.TimeToLiveStatus;
8571
+ if (ttlStatus === "ENABLED" && ttlDesc?.AttributeName) result["TimeToLiveSpecification"] = {
8572
+ AttributeName: ttlDesc.AttributeName,
8573
+ Enabled: true
8574
+ };
8575
+ else if (ttlStatus === "DISABLED") {}
8576
+ } catch (ttlErr) {
8577
+ this.logger.debug(`Could not read TimeToLive for ${tableNameForSubs}: ${ttlErr instanceof Error ? ttlErr.message : String(ttlErr)}`);
8210
8578
  }
8211
- else result["Tags"] = [];
8212
8579
  return result;
8213
8580
  } catch (err) {
8214
8581
  if (err instanceof ResourceNotFoundException$1) return void 0;
@@ -8216,25 +8583,101 @@ var DynamoDBGlobalTableProvider = class {
8216
8583
  }
8217
8584
  }
8218
8585
  /**
8586
+ * Read per-replica sub-specifications against the given `DynamoDBClient`
8587
+ * (which may be the local client or a per-region client returned by
8588
+ * `getRegionalClient`):
8589
+ * - `ContributorInsightsSpecification` via `DescribeContributorInsights`
8590
+ * (table-level; GSI overrides are NOT surfaced in v1 — they would
8591
+ * require one call per GSI and a different CFn nesting under the
8592
+ * `Replicas[].GlobalSecondaryIndexes[]` shape).
8593
+ * - `PointInTimeRecoverySpecification` via `DescribeContinuousBackups`.
8594
+ * - `KinesisStreamSpecification` via
8595
+ * `DescribeKinesisStreamingDestination` (filtered to the local
8596
+ * region's destination when AWS reports more than one).
8597
+ *
8598
+ * Each call is best-effort: errors omit the offending key rather than
8599
+ * fail the whole drift read.
8600
+ *
8601
+ * Issue #389 lifted the LOCAL-only limitation by parameterizing the
8602
+ * client — the same reverse-mapping logic runs against any region's
8603
+ * client.
8604
+ */
8605
+ async readReplicaSubSpecs(client, tableName, regionLabel) {
8606
+ const out = {};
8607
+ try {
8608
+ const ci = await client.send(new DescribeContributorInsightsCommand({ TableName: tableName }));
8609
+ if (ci.ContributorInsightsStatus) out["ContributorInsightsSpecification"] = { Enabled: ci.ContributorInsightsStatus === "ENABLED" };
8610
+ } catch (err) {
8611
+ this.logger.debug(`Could not read ContributorInsights for ${tableName} in ${regionLabel}: ${err instanceof Error ? err.message : String(err)}`);
8612
+ }
8613
+ try {
8614
+ const pitrStatus = (await client.send(new DescribeContinuousBackupsCommand({ TableName: tableName }))).ContinuousBackupsDescription?.PointInTimeRecoveryDescription?.PointInTimeRecoveryStatus;
8615
+ if (pitrStatus) out["PointInTimeRecoverySpecification"] = { PointInTimeRecoveryEnabled: pitrStatus === "ENABLED" };
8616
+ } catch (err) {
8617
+ this.logger.debug(`Could not read PointInTimeRecovery for ${tableName} in ${regionLabel}: ${err instanceof Error ? err.message : String(err)}`);
8618
+ }
8619
+ try {
8620
+ const active = ((await client.send(new DescribeKinesisStreamingDestinationCommand({ TableName: tableName }))).KinesisDataStreamDestinations ?? []).find((d) => d.DestinationStatus === "ACTIVE" || d.DestinationStatus === "ENABLING");
8621
+ if (active?.StreamArn) {
8622
+ const ksOut = { StreamArn: active.StreamArn };
8623
+ if (active.ApproximateCreationDateTimePrecision !== void 0) ksOut["ApproximateCreationDateTimePrecision"] = active.ApproximateCreationDateTimePrecision;
8624
+ out["KinesisStreamSpecification"] = ksOut;
8625
+ }
8626
+ } catch (err) {
8627
+ this.logger.debug(`Could not read KinesisStreamingDestination for ${tableName} in ${regionLabel}: ${err instanceof Error ? err.message : String(err)}`);
8628
+ }
8629
+ return out;
8630
+ }
8631
+ /**
8632
+ * Detect whether application-autoscaling has a scaling policy on this
8633
+ * table's WriteCapacityUnits dimension. Used to decide whether the
8634
+ * `WriteProvisionedThroughputSettings.WriteCapacityUnits` flat-value
8635
+ * surface in `readCurrentState` is safe to emit (auto-scaling
8636
+ * dynamically mutates capacity, so a flat snapshot would fire false
8637
+ * drift on every scale event — when an autoscaling policy is in play
8638
+ * we omit the flat surface so a follow-up PR can reverse-map the
8639
+ * full `WriteCapacityAutoScalingSettings` shape).
8640
+ *
8641
+ * Best-effort: errors return `false` (no autoscaling detected) so a
8642
+ * permissions gap on `application-autoscaling:DescribeScalingPolicies`
8643
+ * does not abort drift reads. Logged at debug.
8644
+ */
8645
+ async hasWriteAutoScalingPolicy(tableName) {
8646
+ try {
8647
+ return ((await new ApplicationAutoScalingClient({ region: await this.dynamoDBClient.config.region() ?? "" }).send(new DescribeScalingPoliciesCommand({
8648
+ ServiceNamespace: "dynamodb",
8649
+ ResourceId: `table/${tableName}`,
8650
+ ScalableDimension: "dynamodb:table:WriteCapacityUnits"
8651
+ }))).ScalingPolicies ?? []).length > 0;
8652
+ } catch (err) {
8653
+ this.logger.debug(`Could not query application-autoscaling for ${tableName}: ${err instanceof Error ? err.message : String(err)}`);
8654
+ return false;
8655
+ }
8656
+ }
8657
+ /**
8219
8658
  * State property paths cdkd's GlobalTable readCurrentState cannot (yet)
8220
8659
  * reverse-map. The drift comparator skips these so a templated value
8221
8660
  * doesn't fire guaranteed false drift on every clean run.
8222
8661
  *
8223
- * - `TimeToLiveSpecification`: cdkd's create() applies it via
8224
- * UpdateTimeToLive, but the reverse-mapping needs a separate
8225
- * DescribeTimeToLive call (not yet implemented).
8226
- * - `WriteProvisionedThroughputSettings` /
8227
- * `WriteOnDemandThroughputSettings`: CFn's shapes wrap
8228
- * auto-scaling / on-demand max-RU settings whose reverse-mapping
8229
- * from `DescribeTable.ProvisionedThroughput` / `OnDemandThroughput`
8230
- * is non-trivial and would fire false drift in v1.
8662
+ * Issue #389 emptied this list:
8663
+ * - `WriteProvisionedThroughputSettings` (flat-WriteCapacityUnits
8664
+ * subset) is now reverse-mapped from `Table.ProvisionedThroughput`
8665
+ * when BillingMode is PROVISIONED AND application-autoscaling has
8666
+ * no policy on the WriteCapacityUnits dimension. Auto-scaling
8667
+ * cases emit an empty `{}` placeholder so the comparator does NOT
8668
+ * fire false drift on the literal `WriteCapacityUnits` subtree.
8669
+ * - `WriteOnDemandThroughputSettings` is reverse-mapped from
8670
+ * `Table.OnDemandThroughput.MaxWriteRequestUnits`. Always-emit
8671
+ * `{}` placeholder when AWS reports no override.
8672
+ * - `TimeToLiveSpecification` is reverse-mapped via
8673
+ * `DescribeTimeToLive` in `readCurrentState`.
8674
+ *
8675
+ * Returning an empty list is the canonical "no known-unknown paths"
8676
+ * shape — keeping the method present (rather than removing it) for
8677
+ * future additions and for backward compat on test reflection.
8231
8678
  */
8232
8679
  getDriftUnknownPaths(_resourceType) {
8233
- return [
8234
- "TimeToLiveSpecification",
8235
- "WriteProvisionedThroughputSettings",
8236
- "WriteOnDemandThroughputSettings"
8237
- ];
8680
+ return [];
8238
8681
  }
8239
8682
  /**
8240
8683
  * Adopt an existing DynamoDB GlobalTable into cdkd state.
@@ -8278,7 +8721,7 @@ var DynamoDBGlobalTableProvider = class {
8278
8721
  } while (exclusiveStartTableName);
8279
8722
  return null;
8280
8723
  }
8281
- async waitForTableActive(tableName, maxAttempts = 120) {
8724
+ async waitForTableActive(tableName, logicalId, maxAttempts = 120) {
8282
8725
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
8283
8726
  const response = await this.dynamoDBClient.send(new DescribeTableCommand({ TableName: tableName }));
8284
8727
  const status = response.Table?.TableStatus;
@@ -8288,10 +8731,10 @@ var DynamoDBGlobalTableProvider = class {
8288
8731
  tableId: response.Table?.TableId,
8289
8732
  streamArn: response.Table?.LatestStreamArn
8290
8733
  };
8291
- if (status !== "CREATING" && status !== "UPDATING") throw new Error(`Unexpected table status: ${status}`);
8734
+ if (status !== "CREATING" && status !== "UPDATING") throw new ProvisioningError(`Unexpected table status while waiting for ACTIVE on ${tableName}: ${status}`, "AWS::DynamoDB::GlobalTable", logicalId, tableName);
8292
8735
  await new Promise((resolve) => setTimeout(resolve, 1e3));
8293
8736
  }
8294
- throw new Error(`Table ${tableName} did not reach ACTIVE within ${maxAttempts}s`);
8737
+ throw new ProvisioningError(`Table ${tableName} did not reach ACTIVE within ${maxAttempts}s`, "AWS::DynamoDB::GlobalTable", logicalId, tableName);
8295
8738
  }
8296
8739
  /**
8297
8740
  * Wait for the table to reach ACTIVE after an UpdateTable call. Unlike
@@ -8299,32 +8742,32 @@ var DynamoDBGlobalTableProvider = class {
8299
8742
  * table may already be ACTIVE on the no-op path (already-disabled
8300
8743
  * protection) or transition through UPDATING.
8301
8744
  */
8302
- async waitForTableActiveAfterUpdate(tableName, maxAttempts = 120) {
8745
+ async waitForTableActiveAfterUpdate(tableName, logicalId, maxAttempts = 600) {
8303
8746
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
8304
8747
  if ((await this.dynamoDBClient.send(new DescribeTableCommand({ TableName: tableName }))).Table?.TableStatus === "ACTIVE") return;
8305
8748
  await new Promise((resolve) => setTimeout(resolve, 1e3));
8306
8749
  }
8307
- throw new Error(`Table ${tableName} did not reach ACTIVE within ${maxAttempts}s after UpdateTable`);
8750
+ throw new ProvisioningError(`Table ${tableName} did not reach ACTIVE within ${maxAttempts}s after UpdateTable`, "AWS::DynamoDB::GlobalTable", logicalId, tableName);
8308
8751
  }
8309
8752
  /**
8310
8753
  * Wait until a specific replica's `ReplicaStatus` flips to ACTIVE.
8311
8754
  * Replica provisioning typically takes 1–5 min; cap at 10 min.
8312
8755
  */
8313
- async waitForReplicaActive(tableName, region, maxAttempts = 600) {
8756
+ async waitForReplicaActive(tableName, region, logicalId, maxAttempts = 600) {
8314
8757
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
8315
8758
  const replica = (await this.dynamoDBClient.send(new DescribeTableCommand({ TableName: tableName }))).Table?.Replicas?.find((r) => r.RegionName === region);
8316
8759
  if (replica?.ReplicaStatus === "ACTIVE") return;
8317
8760
  this.logger.debug(`Replica ${region} status: ${replica?.ReplicaStatus} (attempt ${attempt}/${maxAttempts})`);
8318
8761
  await new Promise((resolve) => setTimeout(resolve, 1e3));
8319
8762
  }
8320
- throw new Error(`Replica ${region} for table ${tableName} did not reach ACTIVE within ${maxAttempts}s`);
8763
+ throw new ProvisioningError(`Replica ${region} for table ${tableName} did not reach ACTIVE within ${maxAttempts}s`, "AWS::DynamoDB::GlobalTable", logicalId, tableName);
8321
8764
  }
8322
8765
  /**
8323
8766
  * Wait until a specific replica disappears from `Replicas[]` after a
8324
8767
  * Delete replica update. Replica deletion typically takes 1–5 min;
8325
8768
  * cap at 10 min.
8326
8769
  */
8327
- async waitForReplicaGone(tableName, region, maxAttempts = 600) {
8770
+ async waitForReplicaGone(tableName, region, logicalId, maxAttempts = 600) {
8328
8771
  for (let attempt = 1; attempt <= maxAttempts; attempt++) try {
8329
8772
  if (!(await this.dynamoDBClient.send(new DescribeTableCommand({ TableName: tableName }))).Table?.Replicas?.find((r) => r.RegionName === region)) return;
8330
8773
  await new Promise((resolve) => setTimeout(resolve, 1e3));
@@ -8332,7 +8775,7 @@ var DynamoDBGlobalTableProvider = class {
8332
8775
  if (err instanceof ResourceNotFoundException$1) return;
8333
8776
  throw err;
8334
8777
  }
8335
- throw new Error(`Replica ${region} for table ${tableName} did not disappear within ${maxAttempts}s`);
8778
+ throw new ProvisioningError(`Replica ${region} for table ${tableName} did not disappear within ${maxAttempts}s`, "AWS::DynamoDB::GlobalTable", logicalId, tableName);
8336
8779
  }
8337
8780
  /**
8338
8781
  * Wait for `DescribeTable` to return `ResourceNotFoundException`,
@@ -8345,7 +8788,7 @@ var DynamoDBGlobalTableProvider = class {
8345
8788
  * Typical small-table delete completes in 5–30s; cap at 10 min for
8346
8789
  * worst-case large-table / replica-cascade scenarios.
8347
8790
  */
8348
- async waitForTableGone(tableName, maxAttempts = 600) {
8791
+ async waitForTableGone(tableName, logicalId, maxAttempts = 600) {
8349
8792
  for (let attempt = 1; attempt <= maxAttempts; attempt++) try {
8350
8793
  await this.dynamoDBClient.send(new DescribeTableCommand({ TableName: tableName }));
8351
8794
  await new Promise((resolve) => setTimeout(resolve, 1e3));
@@ -8353,9 +8796,104 @@ var DynamoDBGlobalTableProvider = class {
8353
8796
  if (err instanceof ResourceNotFoundException$1) return;
8354
8797
  throw err;
8355
8798
  }
8356
- throw new Error(`Table ${tableName} did not disappear within ${maxAttempts}s`);
8799
+ throw new ProvisioningError(`Table ${tableName} did not disappear within ${maxAttempts}s`, "AWS::DynamoDB::GlobalTable", logicalId, tableName);
8357
8800
  }
8358
8801
  };
8802
+ /**
8803
+ * Diff CFn `Replicas[]` arrays. Keyed by `Region`. Returns adds, removes,
8804
+ * and modifies (entries whose other keys — KMSMasterKeyId,
8805
+ * GlobalSecondaryIndexes, TableClassOverride — differ from the old shape).
8806
+ */
8807
+ /**
8808
+ * Derive the per-call `ProvisionedThroughput` shape required by
8809
+ * `CreateTableCommand` / `UpdateTableCommand` when BillingMode flips to
8810
+ * PROVISIONED. Shared between create() and the BillingMode-flip path in
8811
+ * update() so a user template's non-default read/write capacity is
8812
+ * preserved consistently across both code paths.
8813
+ *
8814
+ * Source of truth (CFn `AWS::DynamoDB::GlobalTable` shape):
8815
+ * - WriteCapacityUnits → `properties.WriteProvisionedThroughputSettings`
8816
+ * (top-level on the table). Literal `WriteCapacityUnits` wins over
8817
+ * auto-scaling `MinCapacity`; both default to 5 if absent.
8818
+ * - ReadCapacityUnits → `Replicas[?Region==<region>].ReadProvisionedThroughputSettings`
8819
+ * (per-replica, the deploy region's setting). Same literal-vs-auto-
8820
+ * scaling-vs-default-5 precedence.
8821
+ */
8822
+ function derivePerCallProvisionedThroughput(properties, region) {
8823
+ const wps = properties["WriteProvisionedThroughputSettings"];
8824
+ const writeAutoScaling = wps?.["WriteCapacityAutoScalingSettings"];
8825
+ const writeCapacity = Number(wps?.["WriteCapacityUnits"] ?? writeAutoScaling?.["MinCapacity"] ?? 5);
8826
+ const localReadSettings = (properties["Replicas"] ?? []).find((r) => r["Region"] === region)?.["ReadProvisionedThroughputSettings"];
8827
+ const readAutoScaling = localReadSettings?.["ReadCapacityAutoScalingSettings"];
8828
+ return {
8829
+ ReadCapacityUnits: Number(localReadSettings?.["ReadCapacityUnits"] ?? readAutoScaling?.["MinCapacity"] ?? 5),
8830
+ WriteCapacityUnits: writeCapacity
8831
+ };
8832
+ }
8833
+ function diffReplicas(oldReplicas, newReplicas) {
8834
+ const oldByRegion = /* @__PURE__ */ new Map();
8835
+ const newByRegion = /* @__PURE__ */ new Map();
8836
+ for (const r of oldReplicas) {
8837
+ const region = r["Region"];
8838
+ if (region) oldByRegion.set(region, r);
8839
+ }
8840
+ for (const r of newReplicas) {
8841
+ const region = r["Region"];
8842
+ if (region) newByRegion.set(region, r);
8843
+ }
8844
+ const added = [];
8845
+ const removed = [];
8846
+ const modified = [];
8847
+ for (const [region, replica] of newByRegion) if (!oldByRegion.has(region)) added.push(replica);
8848
+ else if (!deepEqual$1(oldByRegion.get(region), replica)) modified.push(replica);
8849
+ for (const [region, replica] of oldByRegion) if (!newByRegion.has(region)) removed.push(replica);
8850
+ return {
8851
+ added,
8852
+ removed,
8853
+ modified
8854
+ };
8855
+ }
8856
+ /**
8857
+ * Diff CFn `GlobalSecondaryIndexes[]` arrays. Keyed by `IndexName`.
8858
+ * Modified = same IndexName but other fields (ProvisionedThroughput /
8859
+ * OnDemandThroughput) differ. KeySchema / Projection changes count as
8860
+ * "modified" too — AWS rejects those via UpdateGSI, but the diff caller
8861
+ * surfaces the AWS-side error rather than this helper second-guessing.
8862
+ */
8863
+ function diffGlobalSecondaryIndexes(oldGsi, newGsi) {
8864
+ const oldByName = /* @__PURE__ */ new Map();
8865
+ const newByName = /* @__PURE__ */ new Map();
8866
+ for (const g of oldGsi) if (g.IndexName) oldByName.set(g.IndexName, g);
8867
+ for (const g of newGsi) if (g.IndexName) newByName.set(g.IndexName, g);
8868
+ const added = [];
8869
+ const removed = [];
8870
+ const modified = [];
8871
+ for (const [name, gsi] of newByName) if (!oldByName.has(name)) added.push(gsi);
8872
+ else if (!deepEqual$1(oldByName.get(name), gsi)) modified.push(gsi);
8873
+ for (const [name, gsi] of oldByName) if (!newByName.has(name)) removed.push(gsi);
8874
+ return {
8875
+ added,
8876
+ removed,
8877
+ modified
8878
+ };
8879
+ }
8880
+ /**
8881
+ * Structural equality via JSON.stringify. Both inputs are CFn-shape
8882
+ * POJOs (no functions, no symbols, no cycles), so JSON round-trip is
8883
+ * sufficient and free of the property-order pitfalls of deeper
8884
+ * comparators. Object property order from `Object.keys` is insertion
8885
+ * order in modern engines; AWS-SDK shapes are constructed by the SDK
8886
+ * in stable order, so this is safe in practice.
8887
+ */
8888
+ function deepEqual$1(a, b) {
8889
+ if (a === b) return true;
8890
+ if (a === void 0 || b === void 0) return false;
8891
+ try {
8892
+ return JSON.stringify(a) === JSON.stringify(b);
8893
+ } catch {
8894
+ return false;
8895
+ }
8896
+ }
8359
8897
 
8360
8898
  //#endregion
8361
8899
  //#region src/provisioning/providers/logs-loggroup-provider.ts
@@ -43625,7 +44163,7 @@ function reorderArgs(argv) {
43625
44163
  */
43626
44164
  async function main() {
43627
44165
  const program = new Command();
43628
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.103.2");
44166
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.105.0");
43629
44167
  program.addCommand(createBootstrapCommand());
43630
44168
  program.addCommand(createSynthCommand());
43631
44169
  program.addCommand(createListCommand());