@go-to-k/cdkd 0.283.26 → 0.283.28

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
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { $ as coerceCfnBoolean, $t as resolveAutoAssetStorage, A as cyan, An as LocalMigrateError, At as loadPublishableAssetManifest, B as findSilentDropProperties, Bn as StackTerminationProtectionError, Bt as buildDenyExternalAccessPolicy, C as makeCanonicalizePropertiesFn, Cn as setAwsClients, Ct as rebuildClientForBucketRegion, D as renderStatefulReason, Dt as WorkGraph, E as isStatefulRecreateTargetSync, Et as stringifyValue, F as IAMRoleProvider, Fn as PartialFailureError, Ft as ensureAssetStorage, G as IntrinsicFunctionResolver, Gn as normalizeAwsError, Gt as runDockerStreaming, H as slowCcOperationTimeoutMs, Ht as formatDockerLoginError, I as collectInlinePolicyNamesManagedBySiblings, In as ProvisioningError, It as getBootstrapMarkerKey, J as refStateLookupFromResource, Jn as isRetryableTransientError, Jt as Synthesizer, K as cfnRefValueFromPhysicalId, Kn as withErrorHandling, Kt as AssetManifestLoader, L as clearOnUpdateRemoval, Ln as ResourceTimeoutError, Lt as parseBootstrapMarker, M as green, Mt as escapeRegExp, N as red, Nn as MissingCdkCliError, Nt as AssetModeResolver, O as formatResourceLine, On as DeployCancelledError, Ot as buildAssetRedirectMap, P as yellow, Pn as NestedStackChildDirectDestroyError, Pt as BOOTSTRAP_MARKER_PREFIX, Q as assertRegionMatch, Qt as resolveApp, R as ProviderRegistry, Rn as ResourceUpdateNotSupportedError, Rt as validateAssetBucketName, S as unsupportedFinalSnapshotError, Sn as resetAwsClients, St as S3StateBackend, T as MULTI_REGION_RECREATE_BLOCKED_TYPES, Tn as CdkdError, Tt as AssetPublisher, U as disableInstanceApiTermination, Ut as getDockerCmd, V as CloudControlProvider, Vt as buildDockerImage, W as isTerminationProtectionPropagationError, Wt as runDockerForeground, X as normalizeAwsTagsToCfn, Xn as markNonRetryable, Xt as getDefaultStateBucketName, Y as WAFv2WebACLProvider, Yn as isThrottlingError, Yt as synthesisStatusMessage, Z as resolveExplicitPhysicalId, Zt as getLegacyStateBucketName, _ as buildFinalSnapshotIdentifier, _n as processStackMessages, _t as describeTypeWithThrottleRetry, a as DeploymentEventsStore, an as stateBucketExistenceConfirmed, at as requireConfigObject, b as isFinalSnapshotError, bn as AwsClients, bt as TemplateParser, c as replayFailedOperations, cn as CFN_TEMPLATE_URL_LIMIT, ct as redactSecretsForState, d as deleteSkipReason, dn as uploadCfnTemplate, dt as s3BucketDomainName, en as resolveCaptureObservedState, et as configBooleanRefusal, f as withResourceDeadline, fn as expectedOwnerParam, ft as s3BucketDualStackDomainName, g as PRE_DELETE_SNAPSHOT_TYPES, gn as AssemblyReader, gt as DiffCalculator, h as ATOMIC_FINAL_SNAPSHOT_TYPES, hn as derivePartitionAndUrlSuffix, ht as applyRoleArnIfSet, i as DeploymentEventsReader, in as resolveUseCdkBootstrapAssets, it as requireConfigArray, j as gray, jn as LocalStartServiceError, jt as rewriteTemplateAssetReferences, k as bold, kn as LocalInvokeBuildError$1, kt as createAssetRedirectResolver, l as replayRollback, ln as MIGRATE_TMP_PREFIX, lt as scrubResourceRecord, m as computeImplicitDeleteEdges, mn as canonicalizeRegion, mt as s3BucketWebsiteUrl, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as resolveStateBucketWithDefault, nt as readConfigString, o as planFailedOps, on as warnDeprecatedNoPrefixCliFlag, ot as requireConfigString, p as IMPLICIT_DELETE_DEPENDENCIES, pn as PARTITION_TABLE, pt as s3BucketRegionalDomainName, q as getAccountInfo, qn as isMarkedNonRetryable, qt as getDockerImageBySourceHash, r as DeployEngine, rn as resolveStateBucketWithDefaultAndSource, rt as replayWarn, s as planRollback, sn as CFN_TEMPLATE_BODY_LIMIT, st as TEMPLATE_SOURCED_READBACK_RULES, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as resolveSkipPrefix, tt as configStringRefusal, u as UNSPECIFIED_SKIP_REASON, un as findLargeInlineResources, ut as s3BucketArn, v as ccRoutedFinalSnapshotError, vt as withRetry, w as extractDeploymentEventError, wt as shouldRetainResource, x as refusesFinalSnapshot, xn as getAwsClients, xt as LockManager, y as createPreDeleteFinalSnapshot, yn as resolveBucketRegion, yt as DagBuilder, z as findActionableSilentDrops, zn as StackHasActiveImportsError, zt as validateContainerRepoName } from "./deploy-engine-B6z9CCAL.js";
2
+ import { $ as coerceCfnBoolean, $t as resolveApp, A as cyan, An as LocalInvokeBuildError$1, At as createAssetRedirectResolver, B as findSilentDropProperties, Bn as StackHasActiveImportsError, Bt as validateContainerRepoName, C as makeCanonicalizePropertiesFn, Cn as resetAwsClients, Ct as S3StateBackend, D as renderStatefulReason, Dt as stringifyValue, E as isStatefulRecreateTargetSync, En as CdkdError, Et as AssetPublisher, F as IAMRoleProvider, Fn as NestedStackChildDirectDestroyError, Ft as BOOTSTRAP_MARKER_PREFIX, G as IntrinsicFunctionResolver, Gt as runDockerForeground, H as slowCcOperationTimeoutMs, Ht as buildDockerImage, I as collectInlinePolicyNamesManagedBySiblings, In as PartialFailureError, It as ensureAssetStorage, J as refStateLookupFromResource, Jn as isMarkedNonRetryable, Jt as getDockerImageBySourceHash, K as cfnRefValueFromPhysicalId, Kn as normalizeAwsError, Kt as runDockerStreaming, L as clearOnUpdateRemoval, Ln as ProvisioningError, Lt as getBootstrapMarkerKey, M as green, Mn as LocalStartServiceError, Mt as rewriteTemplateAssetReferences, N as red, Nt as escapeRegExp, O as formatResourceLine, Ot as WorkGraph, P as yellow, Pn as MissingCdkCliError, Pt as AssetModeResolver, Q as assertRegionMatch, Qt as getLegacyStateBucketName, R as ProviderRegistry, Rn as ResourceTimeoutError, Rt as parseBootstrapMarker, S as unsupportedFinalSnapshotError, Sn as getAwsClients, St as LockManager, T as MULTI_REGION_RECREATE_BLOCKED_TYPES, Tt as shouldRetainResource, U as disableInstanceApiTermination, Ut as formatDockerLoginError, V as CloudControlProvider, Vn as StackTerminationProtectionError, Vt as buildDenyExternalAccessPolicy, W as isTerminationProtectionPropagationError, Wt as getDockerCmd, X as normalizeAwsTagsToCfn, Xn as isThrottlingError, Xt as synthesisStatusMessage, Y as WAFv2WebACLProvider, Yn as isRetryableTransientError, Yt as Synthesizer, Z as resolveExplicitPhysicalId, Zn as markNonRetryable, Zt as getDefaultStateBucketName, _ as buildFinalSnapshotIdentifier, _n as AssemblyReader, _t as INTRINSIC_KEYS, a as DeploymentEventsStore, an as resolveUseCdkBootstrapAssets, at as requireConfigObject, b as isFinalSnapshotError, bn as resolveBucketRegion, bt as DagBuilder, c as replayFailedOperations, cn as CFN_TEMPLATE_BODY_LIMIT, ct as redactSecretsForState, d as deleteSkipReason, dn as findLargeInlineResources, dt as s3BucketDomainName, en as resolveAutoAssetStorage, et as configBooleanRefusal, f as withResourceDeadline, fn as uploadCfnTemplate, ft as s3BucketDualStackDomainName, g as PRE_DELETE_SNAPSHOT_TYPES, gn as derivePartitionAndUrlSuffix, gt as DiffCalculator, h as ATOMIC_FINAL_SNAPSHOT_TYPES, hn as canonicalizeRegion, ht as applyRoleArnIfSet, i as DeploymentEventsReader, in as resolveStateBucketWithDefaultAndSource, it as requireConfigArray, j as gray, jn as LocalMigrateError, jt as loadPublishableAssetManifest, k as bold, kn as DeployCancelledError, kt as buildAssetRedirectMap, l as replayRollback, ln as CFN_TEMPLATE_URL_LIMIT, lt as scrubResourceRecord, m as computeImplicitDeleteEdges, mn as PARTITION_TABLE, mt as s3BucketWebsiteUrl, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as resolveSkipPrefix, nt as readConfigString, o as planFailedOps, on as stateBucketExistenceConfirmed, ot as requireConfigString, p as IMPLICIT_DELETE_DEPENDENCIES, pn as expectedOwnerParam, pt as s3BucketRegionalDomainName, q as getAccountInfo, qn as withErrorHandling, qt as AssetManifestLoader, r as DeployEngine, rn as resolveStateBucketWithDefault, rt as replayWarn, s as planRollback, sn as warnDeprecatedNoPrefixCliFlag, st as TEMPLATE_SOURCED_READBACK_RULES, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as resolveCaptureObservedState, tt as configStringRefusal, u as UNSPECIFIED_SKIP_REASON, un as MIGRATE_TMP_PREFIX, ut as s3BucketArn, v as ccRoutedFinalSnapshotError, vn as processStackMessages, vt as describeTypeWithThrottleRetry, w as extractDeploymentEventError, wn as setAwsClients, wt as rebuildClientForBucketRegion, x as refusesFinalSnapshot, xn as AwsClients, xt as TemplateParser, y as createPreDeleteFinalSnapshot, yt as withRetry, z as findActionableSilentDrops, zn as ResourceUpdateNotSupportedError, zt as validateAssetBucketName } from "./deploy-engine-5rA6lMn8.js";
3
3
  import { a as getLiveRenderer, c as PATTERN_B_RESOURCE_TYPES, d as generateResourceNameWithFallback, h as withStackName, i as runStackBuffered, m as withSkipPrefix, n as getLogger, o as PATTERN_B_NAME_OPTIONS, s as PATTERN_B_NAME_PROPERTIES, u as generateResourceName } from "./logger-zRrlbaQt.js";
4
- import { t as ASGProvider } from "./asg-provider-CshsD-Pi.js";
4
+ import { t as ASGProvider } from "./asg-provider-DzaowTd_.js";
5
5
  import { AsyncLocalStorage } from "node:async_hooks";
6
6
  import { createHash, randomBytes, randomUUID } from "node:crypto";
7
7
  import { CopyObjectCommand, CreateBucketCommand, DeleteBucketAnalyticsConfigurationCommand, DeleteBucketCommand, DeleteBucketCorsCommand, DeleteBucketEncryptionCommand, DeleteBucketIntelligentTieringConfigurationCommand, DeleteBucketInventoryConfigurationCommand, DeleteBucketLifecycleCommand, DeleteBucketMetricsConfigurationCommand, DeleteBucketOwnershipControlsCommand, DeleteBucketPolicyCommand, DeleteBucketReplicationCommand, DeleteBucketTaggingCommand, DeleteBucketWebsiteCommand, DeleteObjectsCommand, GetBucketAccelerateConfigurationCommand, GetBucketCorsCommand, GetBucketEncryptionCommand, GetBucketLifecycleConfigurationCommand, GetBucketLocationCommand, GetBucketLoggingCommand, GetBucketNotificationConfigurationCommand, GetBucketOwnershipControlsCommand, GetBucketPolicyCommand, GetBucketReplicationCommand, GetBucketTaggingCommand, GetBucketVersioningCommand, GetBucketWebsiteCommand, GetObjectCommand, GetObjectLockConfigurationCommand, GetPublicAccessBlockCommand, HeadBucketCommand, ListBucketAnalyticsConfigurationsCommand, ListBucketIntelligentTieringConfigurationsCommand, ListBucketInventoryConfigurationsCommand, ListBucketMetricsConfigurationsCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchBucket, PutBucketAccelerateConfigurationCommand, PutBucketAnalyticsConfigurationCommand, PutBucketCorsCommand, PutBucketEncryptionCommand, PutBucketIntelligentTieringConfigurationCommand, PutBucketInventoryConfigurationCommand, PutBucketLifecycleConfigurationCommand, PutBucketLoggingCommand, PutBucketMetricsConfigurationCommand, PutBucketNotificationConfigurationCommand, PutBucketOwnershipControlsCommand, PutBucketPolicyCommand, PutBucketReplicationCommand, PutBucketTaggingCommand, PutBucketVersioningCommand, PutBucketWebsiteCommand, PutObjectCommand, PutObjectLockConfigurationCommand, PutPublicAccessBlockCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
@@ -13989,6 +13989,209 @@ function isRecord$1(value) {
13989
13989
  return typeof value === "object" && value !== null && !Array.isArray(value);
13990
13990
  }
13991
13991
 
13992
+ //#endregion
13993
+ //#region src/provisioning/dynamodb-warm-throughput.ts
13994
+ /**
13995
+ * The two DynamoDB `WarmThroughput` rules that `AWS::DynamoDB::Table` and
13996
+ * `AWS::DynamoDB::GlobalTable` both need, in ONE spelling.
13997
+ *
13998
+ * `WarmThroughput` is the same CloudFormation block on both types, and both
13999
+ * providers have to answer the same two questions about it before an
14000
+ * `UpdateTable` / `CreateTable` goes out:
14001
+ *
14002
+ * 1. **Is this block SENDABLE, and as what numbers?** CloudFormation is
14003
+ * stringly typed, so `{ReadUnitsPerSecond: '12000'}` — or anything that
14004
+ * came back from an `Fn::Sub` — arrives as a STRING and must not be
14005
+ * forwarded verbatim into a numeric `Long` field.
14006
+ * 2. **Would sending it LOWER what AWS already reports?** Warm throughput
14007
+ * only ever rises with a table's traffic and AWS REJECTS a call that
14008
+ * lowers it (`decreasing WarmThroughput is not supported`, measured live
14009
+ * us-east-1 2026-08-13 for issue #1768).
14010
+ *
14011
+ * **Provenance, stated precisely because it is easy to over-claim.** Exactly
14012
+ * ONE of these rules ever shipped: `AWS::DynamoDB::Table` got them in PR #1808
14013
+ * (issues #1760 / #1768), and the `AWS::DynamoDB::GlobalTable` side never
14014
+ * existed outside the change that created this file (issue #1857). So this is
14015
+ * the Table rule LIFTED — not two shipped rules reconciled, and no deployed
14016
+ * behaviour changed for either type when it moved here.
14017
+ *
14018
+ * What WAS compared is the lifted rule against the GlobalTable spelling
14019
+ * drafted alongside it, over the shapes both had to answer (a quoted numeric
14020
+ * string, a partially usable block, a mixed decrease/increase, an absent live
14021
+ * value, a zero, a negative, a non-numeric string, a whitespace string, an
14022
+ * explicit `undefined` / `null`, a boolean, an empty block, a scalar, an
14023
+ * array). {@link isWarmThroughputDecrease} agreed on every one; the coercion
14024
+ * agreed on all but a whitespace-only string, where a bare `Number(' ')` is
14025
+ * `0` rather than `NaN`. This module keeps the REFUSING answer for that shape,
14026
+ * since `' '` is not a capacity anyone declared and a warm throughput of 0
14027
+ * is not a request AWS can honour. That is a DRAFT reconciled against a
14028
+ * shipped rule, which is worth less than two shipped rules agreeing — the
14029
+ * reason to read the probe list as a design note rather than as field
14030
+ * evidence.
14031
+ *
14032
+ * Living here rather than in either provider is the point. A decrease guard is
14033
+ * three clauses that all FAIL OPEN for different reasons, and two files
14034
+ * spelling it independently is two chances for a later "fix" to change one of
14035
+ * them — at which point the sibling type silently keeps the old answer, and
14036
+ * nothing in the tree says which is intended. Same class as
14037
+ * `emr-configuration.ts`, and the same reason.
14038
+ *
14039
+ * Deliberately NOT here: everything only ONE provider has. The `Table` side's
14040
+ * `isSendableWarmThroughput` / `isRefusedWarmThroughput` /
14041
+ * `declaresWarmThroughput` / `warmThroughputAlreadyMatches` are its drift-side
14042
+ * and already-matches gates, which `GlobalTable` has no counterpart to (issue
14043
+ * #1742 strips the per-index `WarmThroughput` from BOTH of its drift
14044
+ * comparison sides unconditionally, so drift never asks the question there);
14045
+ * the `GlobalTable` side's `warmThroughputDiagnostic` builds a
14046
+ * `ThroughputDiagnostic` that only that provider's collector understands.
14047
+ * Moving a helper with one caller here would buy nothing and cost a hop.
14048
+ *
14049
+ * Issues: #1760 / #1768 (Table, PR #1808), #1857 (GlobalTable).
14050
+ */
14051
+ /**
14052
+ * The two `WarmThroughput` members, in the ONE order every message, every
14053
+ * comparison and every emitted block uses. A shared order is what makes two
14054
+ * blocks carrying the same numbers compare equal by `deepEqual` and serialize
14055
+ * to the same wire bytes regardless of the order the template wrote them in.
14056
+ */
14057
+ const WARM_THROUGHPUT_MEMBERS = ["ReadUnitsPerSecond", "WriteUnitsPerSecond"];
14058
+ /**
14059
+ * A CloudFormation-borne numeric property, or `undefined` when the value is
14060
+ * not a usable number.
14061
+ *
14062
+ * Plain `Number()` coercion is NOT good enough and the tree learned it three
14063
+ * separate times: `Number(null)`, `Number('')`, `Number([])`, `Number(false)`
14064
+ * and `Number(' ')` are all **0**, not `NaN` — so a live `0` would compare
14065
+ * EQUAL to a desired `null` / `''` / `[]` / `false`, and a whitespace-only
14066
+ * string would be forwarded as a request for zero units.
14067
+ *
14068
+ * A YAML-borne numeric STRING is still accepted, because that is a real
14069
+ * template shape and `'12000'` genuinely means 12000.
14070
+ *
14071
+ * EXPORTED, and not because warm throughput needs it exported. This exact rule
14072
+ * was hand-written three times — here, as `dynamodb-table-provider.ts`'s
14073
+ * `capacityNumber` (byte-identical), and as `dynamodb-globaltable-provider.ts`'s
14074
+ * `toFiniteNumber` (a different spelling of the same total function) — which is
14075
+ * precisely the divergence this module exists to stop: the next "fix" to one of
14076
+ * them would have left the other two answering differently, with nothing in the
14077
+ * tree saying which was intended. It reads `ReadCapacityUnits` /
14078
+ * `MaxReadRequestUnits` / `MinCapacity` as readily as it reads
14079
+ * `ReadUnitsPerSecond`; there is only ever one right answer for "is this
14080
+ * stringly-typed CFn value a number I can send?".
14081
+ */
14082
+ function toFiniteNumber(value) {
14083
+ if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
14084
+ if (typeof value === "string" && value.trim() !== "") {
14085
+ const n = Number(value);
14086
+ return Number.isFinite(n) ? n : void 0;
14087
+ }
14088
+ }
14089
+ /**
14090
+ * Coerce a CFn `WarmThroughput` block to the numeric shape the SDK's `Long`
14091
+ * fields accept, PER MEMBER.
14092
+ *
14093
+ * PER MEMBER, not whole-block, and that distinction is the point: a block
14094
+ * whose write half is an unresolved intrinsic still has a perfectly good read
14095
+ * half, and dropping both would silently discard a value the template really
14096
+ * did ask for. The dropped member is NAMED (`droppedMembers`) so the caller's
14097
+ * warning can say which half went missing instead of reporting the whole
14098
+ * property.
14099
+ *
14100
+ * A block with NO usable member yields `spec: undefined` — refused rather than
14101
+ * forwarded, because forwarding a malformed block surfaces as an opaque AWS
14102
+ * validation error naming neither cdkd nor the property. `droppedMembers` is
14103
+ * still populated in that case, so a REFUSAL message can name what it refused;
14104
+ * a block that is not an object at all (an unresolved `Fn::If`, a scalar, an
14105
+ * array) has no member to name and reports none, which is what lets a caller
14106
+ * word "one half went missing" differently from "the block is unusable".
14107
+ *
14108
+ * Pure: takes the raw bag, returns numbers, logs nothing. Each caller owns its
14109
+ * own message, so the wording stays consistent across that provider's send
14110
+ * sites without forcing one wording across both providers.
14111
+ */
14112
+ function coerceWarmThroughput$1(raw) {
14113
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return { droppedMembers: [] };
14114
+ const bag = raw;
14115
+ const spec = {};
14116
+ const droppedMembers = [];
14117
+ for (const member of WARM_THROUGHPUT_MEMBERS) {
14118
+ if (bag[member] === void 0) continue;
14119
+ const coerced = toFiniteNumber(bag[member]);
14120
+ if (coerced === void 0) {
14121
+ droppedMembers.push(member);
14122
+ continue;
14123
+ }
14124
+ spec[member] = coerced;
14125
+ }
14126
+ if (Object.keys(spec).length === 0) return { droppedMembers };
14127
+ return {
14128
+ spec,
14129
+ droppedMembers
14130
+ };
14131
+ }
14132
+ /**
14133
+ * Whether the COERCED desired `WarmThroughput` would LOWER what AWS already
14134
+ * reports.
14135
+ *
14136
+ * Measured live (us-east-1, 2026-08-13, issue #1768) against a table AWS
14137
+ * reports `{ReadUnitsPerSecond: 12000, WriteUnitsPerSecond: 4000}` for:
14138
+ *
14139
+ * ```
14140
+ * UpdateTable WarmThroughput={ReadUnitsPerSecond: 6000, WriteUnitsPerSecond: 2000}
14141
+ * ValidationException: One or more parameter values were invalid: Requested
14142
+ * ReadUnitsPerSecond for WarmThroughput for table is lower than current
14143
+ * WarmThroughput, decreasing WarmThroughput is not supported
14144
+ * UpdateTable WarmThroughput={ReadUnitsPerSecond: 6000} -> same rejection
14145
+ * UpdateTable WarmThroughput={WriteUnitsPerSecond: 2000} -> same rejection, naming WriteUnitsPerSecond
14146
+ * UpdateTable WarmThroughput={12000, 4000} (re-assert) -> ACCEPTED
14147
+ * ```
14148
+ *
14149
+ * So the value is one AWS raises with the table's traffic and never lowers,
14150
+ * and a decrease is REJECTED rather than accepted-and-ignored — which is what
14151
+ * had to be measured before choosing, because the two produce different
14152
+ * correct answers. The caller SKIPS the call on a true here.
14153
+ *
14154
+ * Evaluated on the COERCED spec, never on the raw bag: analysing the raw bag
14155
+ * makes the verdict describe a request that is not the one being sent (a
14156
+ * dropped member is not part of the call and must not be part of the
14157
+ * comparison).
14158
+ *
14159
+ * Semantics, all three chosen to FAIL OPEN — i.e. to let the call through and
14160
+ * leave AWS as the authority — because a false positive here silently drops a
14161
+ * legitimate INCREASE, which is a real capacity change the user asked for,
14162
+ * while a false negative merely reproduces the pre-fix behaviour of an
14163
+ * AWS-side rejection that names the property:
14164
+ * - DECLARED members only. An absent member is not a request to lower
14165
+ * anything, so it takes no part in the verdict.
14166
+ * - MIXED is not a decrease. One member below live and the other above means
14167
+ * the call carries a genuine increase; AWS decides.
14168
+ * - An absent or unusable LIVE counterpart is not a decrease. Without a
14169
+ * number to compare against there is no evidence of one. Only the LIVE side
14170
+ * can reach that arm: the desired side is a COERCED spec, so a malformed
14171
+ * template value has already been dropped by {@link coerceWarmThroughput}.
14172
+ *
14173
+ * A decrease therefore requires every declared member to be at-or-below live
14174
+ * AND at least one to be strictly below.
14175
+ *
14176
+ * The skip this drives is right for EVERY `update()` caller — the deploy
14177
+ * engine, `cdkd drift --revert`, and the rollback executor's two revert arms —
14178
+ * because none of them can make AWS lower the value, so none loses anything a
14179
+ * doomed call would have achieved.
14180
+ */
14181
+ function isWarmThroughputDecrease(desired, live) {
14182
+ if (desired === void 0 || live === void 0) return false;
14183
+ let sawDecrease = false;
14184
+ for (const member of WARM_THROUGHPUT_MEMBERS) {
14185
+ if (desired[member] === void 0) continue;
14186
+ const wanted = toFiniteNumber(desired[member]);
14187
+ const current = toFiniteNumber(live[member]);
14188
+ if (wanted === void 0 || current === void 0) return false;
14189
+ if (wanted > current) return false;
14190
+ if (wanted < current) sawDecrease = true;
14191
+ }
14192
+ return sawDecrease;
14193
+ }
14194
+
13992
14195
  //#endregion
13993
14196
  //#region src/provisioning/providers/dynamodb-table-provider.ts
13994
14197
  /**
@@ -14091,28 +14294,6 @@ function hasUsableTableCapacity(value) {
14091
14294
  return true;
14092
14295
  }
14093
14296
  /**
14094
- * A capacity member, or `undefined` when the value is not a usable number.
14095
- *
14096
- * `Number()` coercion is NOT good enough here and the first draft of this
14097
- * helper shipped that bug: `Number(null)`, `Number('')`, `Number([])` and
14098
- * `Number(false)` are all **0**, not `NaN` — so a live `0` would have compared
14099
- * EQUAL to a desired `null` / `''` / `[]` / `false` and suppressed the call.
14100
- * The blast radius on DynamoDB is small (a PROVISIONED index's capacity is
14101
- * always >= 1), but the rule this encodes is copied to other providers, so it
14102
- * is spelled correctly rather than relying on the type that happens to be
14103
- * unreachable today.
14104
- *
14105
- * A YAML-borne numeric STRING is still accepted, because that is a real
14106
- * template shape and `'5'` genuinely means 5.
14107
- */
14108
- function capacityNumber(value) {
14109
- if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
14110
- if (typeof value === "string" && value.trim() !== "") {
14111
- const n = Number(value);
14112
- return Number.isFinite(n) ? n : void 0;
14113
- }
14114
- }
14115
- /**
14116
14297
  * Does the capacity AWS currently holds for a GSI already equal the pair the
14117
14298
  * template requests? (issue #1630)
14118
14299
  *
@@ -14131,10 +14312,10 @@ function capacityNumber(value) {
14131
14312
  */
14132
14313
  function liveCapacityAlreadyMatches(live, requested) {
14133
14314
  if (live === void 0) return false;
14134
- const liveRead = capacityNumber(live.ReadCapacityUnits);
14135
- const liveWrite = capacityNumber(live.WriteCapacityUnits);
14136
- const wantRead = capacityNumber(requested.ReadCapacityUnits);
14137
- const wantWrite = capacityNumber(requested.WriteCapacityUnits);
14315
+ const liveRead = toFiniteNumber(live.ReadCapacityUnits);
14316
+ const liveWrite = toFiniteNumber(live.WriteCapacityUnits);
14317
+ const wantRead = toFiniteNumber(requested.ReadCapacityUnits);
14318
+ const wantWrite = toFiniteNumber(requested.WriteCapacityUnits);
14138
14319
  if (liveRead === void 0 || liveWrite === void 0) return false;
14139
14320
  if (wantRead === void 0 || wantWrite === void 0) return false;
14140
14321
  return liveRead === wantRead && liveWrite === wantWrite;
@@ -14182,8 +14363,10 @@ const BILLING_FLIP_ACTIVE_WAIT_ATTEMPTS = 600;
14182
14363
  * Still not `!== undefined`, and still ONE spelling for the write sites and the
14183
14364
  * drift side both: a value this refuses is a value cdkd does not send, so the
14184
14365
  * readback must not emit AWS's computed value as though the template had asked
14185
- * for it. A YAML-borne numeric STRING is accepted, via {@link capacityNumber},
14186
- * because `'12000'` genuinely means 12000.
14366
+ * for it. A YAML-borne numeric STRING is accepted, by the shared coercion's own
14367
+ * number reader ({@link toFiniteNumber}, in `../dynamodb-warm-throughput.ts` —
14368
+ * the one this file's capacity paths read too), because `'12000'` genuinely
14369
+ * means 12000.
14187
14370
  */
14188
14371
  function isSendableWarmThroughput(value) {
14189
14372
  return coerceWarmThroughput(value) !== void 0;
@@ -14204,8 +14387,9 @@ function isSendableWarmThroughput(value) {
14204
14387
  * over), and it makes the quoted form WORK instead of merely warning.
14205
14388
  *
14206
14389
  * Per MEMBER, so a bad one cannot take a good one with it, and never `NaN`:
14207
- * {@link capacityNumber} accepts a number or a numeric string and rejects
14208
- * everything else, so a member that does not resolve is OMITTED from the spec
14390
+ * the shared coercion's number reader is {@link toFiniteNumber} — a number or
14391
+ * a numeric string, everything else rejected — so a member that
14392
+ * does not resolve is OMITTED from the spec
14209
14393
  * and reported in `dropped` for the caller to announce. AWS accepts a
14210
14394
  * one-member `WarmThroughput` (measured us-east-1, 2026-08-13: an
14211
14395
  * `UpdateTable` carrying only `ReadUnitsPerSecond` is a valid request shape —
@@ -14244,27 +14428,22 @@ function isSendableWarmThroughput(value) {
14244
14428
  * the comparator's `deepEqual` is strict about that. It is the generic
14245
14429
  * stringly-typed-CFn class (every numeric property has it), not something this
14246
14430
  * coercion can reach from the write side.
14431
+ *
14432
+ * The RULE moved to `../dynamodb-warm-throughput.ts` when
14433
+ * `AWS::DynamoDB::GlobalTable` needed the same one (issue #1857); this is the
14434
+ * local adapter onto the shape this file's four call sites already read, so
14435
+ * the behaviour, the signature and the tests are unchanged. The shared
14436
+ * coercion always reports `droppedMembers`, including on a total refusal —
14437
+ * that arm is unreachable from here, since a `usable === 0` outcome maps to
14438
+ * `undefined` and the names go with it, which is exactly what this file did
14439
+ * before.
14247
14440
  */
14248
14441
  function coerceWarmThroughput(value) {
14249
- if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
14250
- const bag = value;
14251
- const spec = {};
14252
- const dropped = [];
14253
- let usable = 0;
14254
- for (const member of ["ReadUnitsPerSecond", "WriteUnitsPerSecond"]) {
14255
- if (bag[member] === void 0) continue;
14256
- const n = capacityNumber(bag[member]);
14257
- if (n === void 0) {
14258
- dropped.push(member);
14259
- continue;
14260
- }
14261
- spec[member] = n;
14262
- usable++;
14263
- }
14264
- if (usable === 0) return void 0;
14442
+ const { spec, droppedMembers } = coerceWarmThroughput$1(value);
14443
+ if (spec === void 0) return void 0;
14265
14444
  return {
14266
14445
  spec,
14267
- dropped
14446
+ dropped: [...droppedMembers]
14268
14447
  };
14269
14448
  }
14270
14449
  /**
@@ -14337,60 +14516,6 @@ function declaresWarmThroughput(properties) {
14337
14516
  return properties !== void 0 && isSendableWarmThroughput(properties["WarmThroughput"]);
14338
14517
  }
14339
14518
  /**
14340
- * Is the WarmThroughput this update would SEND a DECREASE of what AWS already
14341
- * holds (issue #1768)?
14342
- *
14343
- * Measured live (us-east-1, 2026-08-13) against a table AWS reports
14344
- * `{ReadUnitsPerSecond: 12000, WriteUnitsPerSecond: 4000}` for:
14345
- *
14346
- * ```
14347
- * UpdateTable WarmThroughput={ReadUnitsPerSecond: 6000, WriteUnitsPerSecond: 2000}
14348
- * ValidationException: One or more parameter values were invalid: Requested
14349
- * ReadUnitsPerSecond for WarmThroughput for table is lower than current
14350
- * WarmThroughput, decreasing WarmThroughput is not supported
14351
- * UpdateTable WarmThroughput={ReadUnitsPerSecond: 6000} -> same rejection
14352
- * UpdateTable WarmThroughput={WriteUnitsPerSecond: 2000} -> same rejection, naming WriteUnitsPerSecond
14353
- * UpdateTable WarmThroughput={12000, 4000} (re-assert) -> ACCEPTED
14354
- * ```
14355
- *
14356
- * So the value is one AWS raises with the table's traffic and never lowers,
14357
- * and a decrease is REJECTED rather than accepted-and-ignored — which is what
14358
- * the issue said to measure before choosing, because the two produce different
14359
- * correct answers. The caller SKIPS the call on a true here.
14360
- *
14361
- * Rules, each one chosen so the skip is right for EVERY `update()` caller
14362
- * (the deploy engine, `cdkd drift --revert`, and the rollback executor's two
14363
- * revert arms — none of which can make AWS lower the value, so none of them
14364
- * loses anything a doomed call would have achieved):
14365
- *
14366
- * - Only the members the desired side DECLARES are examined; an omitted member
14367
- * is not sent and cannot be a decrease.
14368
- * - EVERY declared member must be at-or-below its live counterpart AND at
14369
- * least one strictly below. A MIXED request (one member up, one down) still
14370
- * goes out: AWS rejects it by name, which is the pre-fix behavior and better
14371
- * than silently dropping the increase the user asked for.
14372
- * - Anything not resolvable to a finite number fails OPEN (the call is still
14373
- * issued): the worst case is the pre-fix behavior, whereas a false positive
14374
- * would silently drop a real increase. Only the LIVE side can actually reach
14375
- * that arm now (PR review round 8): the desired side is a COERCED spec, so a
14376
- * malformed template value or an unresolved intrinsic has already been
14377
- * dropped by {@link coerceWarmThroughput} and cannot arrive here. The check
14378
- * is kept for the live side, which is whatever `DescribeTable` returned.
14379
- */
14380
- function isWarmThroughputDecrease(desired, live) {
14381
- if (live === void 0) return false;
14382
- let sawDecrease = false;
14383
- for (const member of ["ReadUnitsPerSecond", "WriteUnitsPerSecond"]) {
14384
- if (desired[member] === void 0) continue;
14385
- const wanted = capacityNumber(desired[member]);
14386
- const current = capacityNumber(live[member]);
14387
- if (wanted === void 0 || current === void 0) return false;
14388
- if (wanted > current) return false;
14389
- if (wanted < current) sawDecrease = true;
14390
- }
14391
- return sawDecrease;
14392
- }
14393
- /**
14394
14519
  * Does the warm throughput AWS currently holds already equal what this update
14395
14520
  * would request (issue #1768 PR review)?
14396
14521
  *
@@ -14419,10 +14544,10 @@ function isWarmThroughputDecrease(desired, live) {
14419
14544
  function warmThroughputAlreadyMatches(desired, live) {
14420
14545
  if (live === void 0) return false;
14421
14546
  let compared = 0;
14422
- for (const member of ["ReadUnitsPerSecond", "WriteUnitsPerSecond"]) {
14547
+ for (const member of WARM_THROUGHPUT_MEMBERS) {
14423
14548
  if (desired[member] === void 0) continue;
14424
- const wanted = capacityNumber(desired[member]);
14425
- const current = capacityNumber(live[member]);
14549
+ const wanted = toFiniteNumber(desired[member]);
14550
+ const current = toFiniteNumber(live[member]);
14426
14551
  if (wanted === void 0 || current === void 0) return false;
14427
14552
  if (wanted !== current) return false;
14428
14553
  compared++;
@@ -15529,7 +15654,7 @@ var DynamoDBTableProvider = class {
15529
15654
  */
15530
15655
  skipZeroCapacityIndexUpdate(indexName, physicalId, requested) {
15531
15656
  if (requested === void 0) return false;
15532
- if (capacityNumber(requested.ReadCapacityUnits) !== 0 || capacityNumber(requested.WriteCapacityUnits) !== 0) return false;
15657
+ if (toFiniteNumber(requested.ReadCapacityUnits) !== 0 || toFiniteNumber(requested.WriteCapacityUnits) !== 0) return false;
15533
15658
  this.logger.warn(`GSI ${indexName} on DynamoDB table ${physicalId}: the requested ProvisionedThroughput is {ReadCapacityUnits: 0, WriteCapacityUnits: 0}, which is AWS's on-demand placeholder rather than a capacity DynamoDB accepts (the minimum is 1), so no per-index throughput update was sent. This usually means the value came from a pre-#1767 cdkd state record — a \`cdkd drift --revert\` of such a record, or a rollback replaying it. Re-run \`cdkd deploy\` (or \`cdkd drift --accept\`) to refresh the record.`);
15534
15659
  return true;
15535
15660
  }
@@ -16425,6 +16550,35 @@ var DynamoDBGlobalTableProvider = class {
16425
16550
  }
16426
16551
  }
16427
16552
  /**
16553
+ * The `WarmThroughput` block to put on ONE `GlobalSecondaryIndexUpdate`, or
16554
+ * `undefined` when it must not be sent (issue #1857).
16555
+ *
16556
+ * ONE helper for all three `UpdateTable` send sites — the billing-flip
16557
+ * ride-along, the `added` loop's `Create`, and the `modified` loop's
16558
+ * `Update` — because three copies of a decrease guard is three chances to
16559
+ * word one differently, and the whole point of {@link isWarmThroughputDecrease}
16560
+ * is that the answer does not depend on which site asked.
16561
+ *
16562
+ * The `desired` argument is already COERCED (`toSdkGlobalSecondaryIndexes`
16563
+ * did it), so an unusable block never reaches here at all: it was refused
16564
+ * and reported during translation, and arrives as `undefined`. What is left
16565
+ * for this helper is the one question translation cannot answer, because it
16566
+ * needs live AWS state: would this call LOWER what AWS reports?
16567
+ *
16568
+ * The refusal is a WARN and a dropped member, not a throw. A decrease is a
16569
+ * template that has fallen behind a value AWS grew on its own; failing the
16570
+ * deploy over it would block every unrelated change to the same table, and
16571
+ * there is no template edit that "fixes" it other than raising a number the
16572
+ * user never chose.
16573
+ */
16574
+ warmThroughputForSend(indexName, desired, liveWarmByIndexName, logicalId, physicalId) {
16575
+ if (!desired) return void 0;
16576
+ const live = liveWarmByIndexName.get(indexName);
16577
+ if (!isWarmThroughputDecrease(desired, live)) return desired;
16578
+ const format = (spec) => WARM_THROUGHPUT_MEMBERS.filter((m) => spec?.[m] !== void 0).map((m) => `${m}=${spec?.[m]}`).join(", ");
16579
+ this.logger.warn(`DynamoDB GlobalTable ${logicalId}: GSI '${indexName}' on ${physicalId}: WarmThroughput was NOT sent — the template asks for ${format(desired)} while AWS reports ${format(live)}, and DynamoDB rejects a decrease ("decreasing WarmThroughput is not supported"). Warm throughput only rises with a table's traffic, so a template below the live value can never be applied; raise it to at least the live value, or remove it. Every other change to this index is applied normally.`);
16580
+ }
16581
+ /**
16428
16582
  * Add a single replica region. Issues one `UpdateTableCommand` with
16429
16583
  * `ReplicaUpdates: [{ Create: { RegionName, ... } }]` and polls
16430
16584
  * `DescribeTable` until the replica's `ReplicaStatus` flips to ACTIVE.
@@ -16510,6 +16664,16 @@ var DynamoDBGlobalTableProvider = class {
16510
16664
  return (props["Replicas"] ?? []).find((r) => r["Region"] === currentRegion)?.["Tags"];
16511
16665
  };
16512
16666
  if (tableArn) await this.applyTagDiff(tableArn, extractLocalTags(previousProperties), extractLocalTags(properties));
16667
+ const liveWarmByIndexName = /* @__PURE__ */ new Map();
16668
+ for (const liveGsi of describeResp.Table?.GlobalSecondaryIndexes ?? []) {
16669
+ const name = liveGsi.IndexName;
16670
+ const warm = liveGsi.WarmThroughput;
16671
+ if (typeof name !== "string" || !warm) continue;
16672
+ const spec = {};
16673
+ if (warm.ReadUnitsPerSecond !== void 0) spec.ReadUnitsPerSecond = warm.ReadUnitsPerSecond;
16674
+ if (warm.WriteUnitsPerSecond !== void 0) spec.WriteUnitsPerSecond = warm.WriteUnitsPerSecond;
16675
+ if (Object.keys(spec).length > 0) liveWarmByIndexName.set(name, spec);
16676
+ }
16513
16677
  const recordedOldBilling = previousProperties["BillingMode"];
16514
16678
  const recordedOldBillingAbsent = recordedOldBilling === void 0;
16515
16679
  const recordedOldBillingUsable = typeof recordedOldBilling === "string" && recordedOldBilling.trim() !== "";
@@ -16659,11 +16823,11 @@ var DynamoDBGlobalTableProvider = class {
16659
16823
  const gsi = byIndexName.get(indexName);
16660
16824
  if (!gsi?.ProvisionedThroughput) continue;
16661
16825
  const previousWarm = previousByIndexName.get(indexName)?.WarmThroughput;
16662
- const warmChanged = gsi.WarmThroughput !== void 0 && !deepEqual$1(gsi.WarmThroughput, previousWarm);
16826
+ const warmToSend = gsi.WarmThroughput !== void 0 && !deepEqual$1(gsi.WarmThroughput, previousWarm) ? this.warmThroughputForSend(indexName, gsi.WarmThroughput, liveWarmByIndexName, logicalId, physicalId) : void 0;
16663
16827
  indexUpdates.push({ Update: {
16664
16828
  IndexName: indexName,
16665
16829
  ProvisionedThroughput: gsi.ProvisionedThroughput,
16666
- ...warmChanged && { WarmThroughput: gsi.WarmThroughput }
16830
+ ...warmToSend && { WarmThroughput: warmToSend }
16667
16831
  } });
16668
16832
  if (survivingIndexNames.has(indexName)) gsiHandledByBillingFlip.add(indexName);
16669
16833
  }
@@ -16798,13 +16962,14 @@ var DynamoDBGlobalTableProvider = class {
16798
16962
  }
16799
16963
  for (const gsi of gsiDiff.added) {
16800
16964
  if (!gsi.IndexName || !gsi.KeySchema || !gsi.Projection) continue;
16965
+ const addedWarmToSend = this.warmThroughputForSend(gsi.IndexName, gsi.WarmThroughput, liveWarmByIndexName, logicalId, physicalId);
16801
16966
  const gsiUpdate = { Create: {
16802
16967
  IndexName: gsi.IndexName,
16803
16968
  KeySchema: gsi.KeySchema,
16804
16969
  Projection: gsi.Projection,
16805
16970
  ...gsi.ProvisionedThroughput && { ProvisionedThroughput: gsi.ProvisionedThroughput },
16806
16971
  ...gsi.OnDemandThroughput && { OnDemandThroughput: gsi.OnDemandThroughput },
16807
- ...gsi.WarmThroughput && { WarmThroughput: gsi.WarmThroughput }
16972
+ ...addedWarmToSend && { WarmThroughput: addedWarmToSend }
16808
16973
  } };
16809
16974
  await this.dynamoDBClient.send(new UpdateTableCommand({
16810
16975
  TableName: physicalId,
@@ -16831,9 +16996,14 @@ var DynamoDBGlobalTableProvider = class {
16831
16996
  if (Object.keys(onDemand).length > 0) update.OnDemandThroughput = onDemand;
16832
16997
  if (!billingFlipped && gsi.ProvisionedThroughput) update.ProvisionedThroughput = gsi.ProvisionedThroughput;
16833
16998
  const previousSdk = previousSdkByName.get(gsi.IndexName);
16834
- if (gsi.WarmThroughput !== void 0 && !deepEqual$1(gsi.WarmThroughput, previousSdk?.WarmThroughput)) update.WarmThroughput = gsi.WarmThroughput;
16999
+ let warmDecreaseRefused = false;
17000
+ if (gsi.WarmThroughput !== void 0 && !deepEqual$1(gsi.WarmThroughput, previousSdk?.WarmThroughput)) {
17001
+ const warmToSend = this.warmThroughputForSend(gsi.IndexName, gsi.WarmThroughput, liveWarmByIndexName, logicalId, physicalId);
17002
+ if (warmToSend) update.WarmThroughput = warmToSend;
17003
+ else warmDecreaseRefused = true;
17004
+ }
16835
17005
  if (!update.ProvisionedThroughput && !update.OnDemandThroughput && !update.WarmThroughput) {
16836
- if (!billingFlipped && unresolvedMembers.length === 0) this.logger.warn(`GSI '${gsi.IndexName}' on ${physicalId} changed in a way DynamoDB's UpdateTable cannot express (KeySchema / Projection are immutable on an existing index). Recreate the index under a new name to apply the change.`);
17006
+ if (!billingFlipped && unresolvedMembers.length === 0 && !warmDecreaseRefused) this.logger.warn(`GSI '${gsi.IndexName}' on ${physicalId} changed in a way DynamoDB's UpdateTable cannot express (KeySchema / Projection are immutable on an existing index). Recreate the index under a new name to apply the change.`);
16837
17007
  continue;
16838
17008
  }
16839
17009
  const gsiUpdate = { Update: update };
@@ -17310,9 +17480,27 @@ var DynamoDBGlobalTableProvider = class {
17310
17480
  try {
17311
17481
  if (hasTransitionalIndex(preDeleteIndexes)) {
17312
17482
  this.logger.debug(`Waiting for indexes on ${physicalId} to settle before DeleteTable (issue #1521)`);
17313
- await this.waitForIndexesActive(physicalId, logicalId);
17483
+ await this.waitForIndexesActive(physicalId, logicalId, { proceedNote: DELETE_INDEX_WAIT_PROCEED_NOTE });
17314
17484
  }
17315
- await this.dynamoDBClient.send(new DeleteTableCommand({ TableName: physicalId }));
17485
+ let deleteAttempts = 0;
17486
+ await withRetry(async () => {
17487
+ if (deleteAttempts++ > 0) {
17488
+ if (deleteAttempts === 2) {
17489
+ const remainingAttempts = 1 + DELETE_INDEX_BUSY_MAX_RETRIES - deleteAttempts;
17490
+ this.logger.warn(`DynamoDB GlobalTable ${logicalId}: AWS refused DeleteTable on ${physicalId} because a global secondary index is still being created, updated or deleted — application auto-scaling can start an index capacity change at any moment, including during this destroy. Waiting for the index to settle and retrying (up to ${remainingAttempts} more attempts, each preceded by up to ${DELETE_INDEX_BUSY_REARM_MAX_ATTEMPTS} DescribeTable polls ~1s apart — a little over a minute of settling per attempt). Nothing is wrong with the table; a large index backfill can outlast this budget, in which case the destroy fails with AWS's own message and re-running it succeeds once the index is ACTIVE.`);
17491
+ }
17492
+ await this.waitForIndexesActive(physicalId, logicalId, {
17493
+ maxAttempts: DELETE_INDEX_BUSY_REARM_MAX_ATTEMPTS,
17494
+ proceedNote: DELETE_INDEX_WAIT_PROCEED_NOTE
17495
+ });
17496
+ }
17497
+ await this.dynamoDBClient.send(new DeleteTableCommand({ TableName: physicalId }));
17498
+ }, logicalId, {
17499
+ maxRetries: DELETE_INDEX_BUSY_MAX_RETRIES,
17500
+ isRetryable: (message) => isIndexBusyDeleteError(message),
17501
+ logger: this.logger,
17502
+ sleep: (ms) => deleteTableRetryDelays.sleep ? deleteTableRetryDelays.sleep(ms) : new Promise((resolve) => setTimeout(resolve, ms))
17503
+ });
17316
17504
  await this.waitForTableGone(physicalId, logicalId);
17317
17505
  this.logger.debug(`Successfully deleted DynamoDB GlobalTable ${logicalId}`);
17318
17506
  } catch (error) {
@@ -17928,22 +18116,49 @@ var DynamoDBGlobalTableProvider = class {
17928
18116
  * throwing. A missed registration self-heals on the next deploy (the
17929
18117
  * reconcile re-asserts anything not already present), whereas throwing
17930
18118
  * here would fail a deploy whose actual resources are all correct. Index
17931
- * backfill on a large table can take a long time, hence the 15-minute cap.
17932
- * The caller that follows it with a mutating call keeps AWS's own error as
17933
- * the backstop for the case where even that cap is not enough.
17934
- */
17935
- async waitForIndexesActive(tableName, logicalId) {
17936
- const maxAttempts = 900;
18119
+ * backfill on a large table can take a long time, hence the 15-minute
18120
+ * DEFAULT cap. The caller that follows it with a mutating call keeps AWS's
18121
+ * own error as the backstop for the case where even that cap is not enough.
18122
+ *
18123
+ * `opts.maxAttempts` exists because the cap is only right for a wait that
18124
+ * runs ONCE: the #1830 delete retry re-runs this per attempt, so the caller's
18125
+ * wall clock is the product and the default would blow past the per-resource
18126
+ * deadline `destroy-runner.ts` enforces.
18127
+ *
18128
+ * `opts.proceedNote` covers the OTHER thing that is caller-specific: what it
18129
+ * costs when the wait ends WITHOUT confirming. Both arms that end that way —
18130
+ * the timeout, and a `DescribeTable` that failed for a reason other than
18131
+ * throttling or a gone table — stop waiting and let the caller proceed, so
18132
+ * one note serves both. The consequences genuinely differ: on the delete
18133
+ * path AWS refuses the `DeleteTable` and that refusal is the backstop, while
18134
+ * the auto-scaling caller has NO backstop — `reconcileAutoScalingTargets`
18135
+ * simply skips an index that is not ready and nothing downstream refuses.
18136
+ * The default sentence is the auto-scaling one; every delete-path caller
18137
+ * passes {@link DELETE_INDEX_WAIT_PROCEED_NOTE}.
18138
+ */
18139
+ async waitForIndexesActive(tableName, logicalId, opts) {
18140
+ const maxAttempts = opts?.maxAttempts ?? 900;
18141
+ const proceedNote = opts?.proceedNote ?? "auto-scaling registration for a still-building index may have been skipped. The next deploy re-asserts it.";
17937
18142
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
17938
18143
  try {
17939
18144
  if (!hasTransitionalIndex((await this.dynamoDBClient.send(new DescribeTableCommand({ TableName: tableName }))).Table?.GlobalSecondaryIndexes ?? [])) return;
17940
18145
  } catch (err) {
17941
- this.logger.debug(`DescribeTable while waiting for indexes on ${tableName}: ${err instanceof Error ? err.message : String(err)}`);
18146
+ const message = err instanceof Error ? err.message : String(err);
18147
+ if (isThrottlingError(err)) {
18148
+ this.logger.debug(`DescribeTable throttled while waiting for indexes on ${tableName} (attempt ${attempt}/${maxAttempts}); still waiting: ${message}`);
18149
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
18150
+ continue;
18151
+ }
18152
+ if (err instanceof ResourceNotFoundException$1) {
18153
+ this.logger.debug(`Table ${tableName} (${logicalId}) no longer exists while waiting for indexes`);
18154
+ return;
18155
+ }
18156
+ this.logger.warn(`DescribeTable failed while waiting for indexes on ${tableName} (${logicalId}), so cdkd stopped waiting; ${proceedNote} DescribeTable error: ${message}`);
17942
18157
  return;
17943
18158
  }
17944
18159
  await new Promise((resolve) => setTimeout(resolve, 1e3));
17945
18160
  }
17946
- this.logger.warn(`Indexes on ${tableName} (${logicalId}) did not all reach ACTIVE within ${maxAttempts}s; auto-scaling registration for a still-building index may have been skipped. The next deploy re-asserts it.`);
18161
+ this.logger.warn(`Indexes on ${tableName} (${logicalId}) did not all reach ACTIVE within ${maxAttempts} DescribeTable polls (~1s apart, so a little over ${maxAttempts}s of wall clock); ${proceedNote}`);
17947
18162
  }
17948
18163
  /**
17949
18164
  * Wait until a specific replica's `ReplicaStatus` flips to ACTIVE.
@@ -18082,6 +18297,14 @@ function autoScalingResourceId(tableName, indexName) {
18082
18297
  */
18083
18298
  const autoScalingRetryDelays = {};
18084
18299
  /**
18300
+ * Test seam for the index-busy `DeleteTable` retry's backoff (issue #1830),
18301
+ * mirroring {@link autoScalingRetryDelays}. Production leaves `sleep`
18302
+ * undefined so `withRetry`'s real schedule applies; a test injects a no-op so
18303
+ * the ~47s budget does not have to be waited out. Kept SEPARATE from the
18304
+ * auto-scaling seam so a test can slow one path without silencing the other.
18305
+ */
18306
+ const deleteTableRetryDelays = {};
18307
+ /**
18085
18308
  * Resource ids per `DescribeScalableTargets` request. Kept at the API's
18086
18309
  * documented per-page ceiling rather than a larger guess — the request is
18087
18310
  * paginated anyway, and over-asking risks a `ValidationException` that would
@@ -18235,12 +18458,113 @@ const INDEX_TRANSITIONAL_STATUSES = /* @__PURE__ */ new Set([
18235
18458
  function hasTransitionalIndex(indexes) {
18236
18459
  return (indexes ?? []).some((gsi) => INDEX_TRANSITIONAL_STATUSES.has(gsi.IndexStatus ?? ""));
18237
18460
  }
18238
- /** Coerce a CFn numeric (CFn is stringly-typed) to a finite number. */
18239
- function toFiniteNumber(value) {
18240
- if (value === void 0 || value === null || value === "") return void 0;
18241
- if (typeof value !== "number" && typeof value !== "string") return void 0;
18242
- const n = Number(value);
18243
- return Number.isFinite(n) ? n : void 0;
18461
+ /**
18462
+ * AWS's refusal when `DeleteTable` lands while one of the table's GSIs is
18463
+ * mid-transition (issue #1830). Observed verbatim on a real destroy:
18464
+ *
18465
+ * Attempt to change a resource which is still in use: Cannot delete table
18466
+ * while indexes are being created, updated, or deleted.
18467
+ *
18468
+ * Matched on the leading clause rather than on the whole sentence: the
18469
+ * trailing status list is AWS wording that can gain a state without changing
18470
+ * what the message MEANS, and the leading clause is already specific enough
18471
+ * that nothing else in the DynamoDB surface produces it. Case-insensitive
18472
+ * because the text arrives inside a wrapper prefix whose casing is not ours.
18473
+ *
18474
+ * Deliberately NOT keyed on the exception NAME: AWS reports this as a plain
18475
+ * `ResourceInUseException`, which is the same name it uses for genuinely
18476
+ * terminal conflicts (deleting a table that is `CREATING`, creating one that
18477
+ * already exists). Retrying on the name would turn those into a 47s stall
18478
+ * before the same failure.
18479
+ */
18480
+ const INDEX_BUSY_DELETE_MESSAGE = /cannot delete table while indexes are being/i;
18481
+ function isIndexBusyDeleteError(message) {
18482
+ return INDEX_BUSY_DELETE_MESSAGE.test(message);
18483
+ }
18484
+ /**
18485
+ * Retry budget for the index-busy `DeleteTable` refusal (issue #1830).
18486
+ *
18487
+ * Left at `withRetry`'s own default of 8 rather than raised. Named rather than
18488
+ * inlined because passing it at all is what opts this call out of
18489
+ * `withRetry`'s dense IAM-propagation schedule, which would be wrong for a
18490
+ * condition measured in seconds-to-minutes rather than sub-second.
18491
+ *
18492
+ * The count MULTIPLIES the per-attempt re-arm poll below, so the two have to
18493
+ * be read together — see {@link DELETE_INDEX_BUSY_REARM_MAX_ATTEMPTS} for the
18494
+ * arithmetic and for why the poll is not the 15-minute one.
18495
+ */
18496
+ const DELETE_INDEX_BUSY_MAX_RETRIES = 8;
18497
+ /**
18498
+ * Seconds the index-busy `DeleteTable` retry spends re-arming BEFORE each
18499
+ * retry (issue #1830) — one `DescribeTable` per second, and it returns on the
18500
+ * first one that reports every index settled.
18501
+ *
18502
+ * Bounded well under
18503
+ * {@link DynamoDBGlobalTableProvider['waitForIndexesActive']}'s 15-minute
18504
+ * default because THAT default is sized for a wait that runs once, while this
18505
+ * one runs per retry: the caller's wall clock is
18506
+ * `DELETE_INDEX_BUSY_MAX_RETRIES x this + withRetry's ~47s of backoff`, and
18507
+ * `destroy-runner.ts` caps a single `delete()` at the per-resource deadline
18508
+ * (30 min by default; this provider declares no `getMinResourceTimeoutMs` to
18509
+ * lift it). At 900s the product was ~2h, so a genuinely stuck index produced a
18510
+ * 30-minute wait ending in a generic `ResourceTimeoutError` that never
18511
+ * mentions indexes. At 60s the worst case is ~8.8 min and the user gets AWS's
18512
+ * own actionable sentence instead.
18513
+ *
18514
+ * The FIRST attempt is unaffected — it does not re-arm at all, and the #1521
18515
+ * pre-delete gate ahead of the loop keeps its full 15-minute poll.
18516
+ */
18517
+ const DELETE_INDEX_BUSY_REARM_MAX_ATTEMPTS = 60;
18518
+ /**
18519
+ * What proceeding costs when a DELETE-path index-settle wait ends WITHOUT
18520
+ * confirming — passed as
18521
+ * {@link DynamoDBGlobalTableProvider['waitForIndexesActive']}'s `proceedNote`
18522
+ * by BOTH delete-path callers (the #1521 pre-delete gate and the #1830 retry
18523
+ * re-arm).
18524
+ *
18525
+ * The default note that helper falls back to is the auto-scaling caller's, and
18526
+ * it is not merely unhelpful here but wrong: nothing on the delete path
18527
+ * re-asserts anything on a later deploy. Conversely the delete path really
18528
+ * does have a backstop — `DeleteTable` follows immediately and AWS refuses it
18529
+ * again if the index is still transitioning — which the auto-scaling caller
18530
+ * does not: `reconcileAutoScalingTargets` skips an index that is not ready and
18531
+ * nothing downstream notices. One sentence per caller, so neither claims the
18532
+ * other's safety net.
18533
+ */
18534
+ const DELETE_INDEX_WAIT_PROCEED_NOTE = `the DeleteTable goes ahead anyway and AWS's own refusal stays the backstop.`;
18535
+ /**
18536
+ * Build the {@link ThroughputDiagnostic} for a `WarmThroughput` block that did
18537
+ * not survive coercion intact, or `undefined` when it did.
18538
+ *
18539
+ * Two distinct outcomes, worded differently because the remedy differs:
18540
+ * - PARTIAL — one member dropped, the other still sent. The call goes ahead;
18541
+ * the user needs to know which half silently did not.
18542
+ * - REFUSED — nothing sendable. No call is made for the property at all,
18543
+ * which is the point (issue #1857 defect 3): forwarding the malformed block
18544
+ * would surface as an AWS validation error that names neither cdkd nor the
18545
+ * property.
18546
+ */
18547
+ function warmThroughputDiagnostic(raw, coercion, indexName) {
18548
+ if (raw === void 0 || raw === null) return void 0;
18549
+ const scope = {
18550
+ kind: "unresolved-member",
18551
+ member: "WarmThroughput"
18552
+ };
18553
+ const dropped = coercion.droppedMembers.join(" / ");
18554
+ if (coercion.spec) {
18555
+ if (coercion.droppedMembers.length === 0) return void 0;
18556
+ const kept = WARM_THROUGHPUT_MEMBERS.filter((m) => coercion.spec?.[m] !== void 0).join(" / ");
18557
+ return {
18558
+ ...scope,
18559
+ ...indexName !== void 0 && { indexName },
18560
+ message: `WarmThroughput.${dropped} did not resolve to a number and was DROPPED; ${kept} is still sent. AWS types these as numbers, so a quoted "12000" is fine but an unresolved intrinsic or an object is not. The dropped member keeps its current AWS value until the template supplies a usable one.`
18561
+ };
18562
+ }
18563
+ return {
18564
+ ...scope,
18565
+ ...indexName !== void 0 && { indexName },
18566
+ message: `WarmThroughput is declared but no member resolved to a number${dropped ? ` (${dropped})` : ""}, so the whole block is REFUSED and not sent. Forwarding it would fail as an opaque AWS validation error naming neither cdkd nor the property. Set WarmThroughput.ReadUnitsPerSecond / WriteUnitsPerSecond to numbers, or remove the block. Nothing else about the index is affected.`
18567
+ };
18244
18568
  }
18245
18569
  /**
18246
18570
  * Merge an explicitly-supplied, already-SDK-shaped throughput block over a
@@ -18535,7 +18859,10 @@ function toSdkGlobalSecondaryIndexes(properties, region, billingMode, source = "
18535
18859
  KeySchema: gsi["KeySchema"],
18536
18860
  Projection: gsi["Projection"]
18537
18861
  };
18538
- if (gsi["WarmThroughput"] !== void 0) sdk.WarmThroughput = gsi["WarmThroughput"];
18862
+ const warmCoercion = coerceWarmThroughput$1(gsi["WarmThroughput"]);
18863
+ if (warmCoercion.spec) sdk.WarmThroughput = warmCoercion.spec;
18864
+ const warmDiagnostic = warmThroughputDiagnostic(gsi["WarmThroughput"], warmCoercion, typeof indexName === "string" ? indexName : void 0);
18865
+ if (warmDiagnostic) diagnostics?.push(warmDiagnostic);
18539
18866
  const explicitProvisioned = asRecord$1(gsi["ProvisionedThroughput"]);
18540
18867
  const explicitOnDemand = asRecord$1(gsi["OnDemandThroughput"]);
18541
18868
  if (explicitProvisioned && billingMode !== "PROVISIONED") diagnostics?.push({
@@ -56744,6 +57071,307 @@ function createDeployCommand() {
56744
57071
  return cmd;
56745
57072
  }
56746
57073
 
57074
+ //#endregion
57075
+ //#region src/analyzer/outputs-diff.ts
57076
+ /**
57077
+ * A `${...}` placeholder `Fn::Sub` did NOT substitute.
57078
+ *
57079
+ * `resolveSub` catches a genuine `Ref` / `Fn::GetAtt` miss, WARNS, and keeps the
57080
+ * literal `${Foo}` in the output string — it neither throws nor leaves an
57081
+ * intrinsic object behind. So a half-substituted `Fn::Sub` reaches this module
57082
+ * looking like an ordinary resolved string, and comparing it against state
57083
+ * reports a phantom change whose printed value is `"arn:...${NewBucket}"`.
57084
+ *
57085
+ * Only consulted for a value whose TEMPLATE source actually contained an
57086
+ * `Fn::Sub` (see {@link templateUsesSub}). Applying it to every string
57087
+ * over-matches badly: an IAM policy body with `${aws:username}`, a UserData
57088
+ * snippet with a shell `${VAR}`, or any literal a user wrote would set
57089
+ * `resolutionFailed` and suppress the WHOLE Outputs section for that stack, on
57090
+ * every run, forever.
57091
+ *
57092
+ * Within `Fn::Sub`-sourced values one false positive remains and is accepted:
57093
+ * `${!Literal}` is `Fn::Sub`'s ESCAPE and resolves to the literal text
57094
+ * `${Literal}`, which post-resolution is indistinguishable from a placeholder
57095
+ * that failed to substitute. That case merely SUPPRESSES the section (the
57096
+ * pre-#1921 behavior) whereas the alternative is a phantom reported every run.
57097
+ */
57098
+ const UNSUBSTITUTED_SUB_PLACEHOLDER = /\$\{[^}]*\}/;
57099
+ /**
57100
+ * True when `value` is, or anywhere CONTAINS, something the diff could not
57101
+ * fully resolve against current state.
57102
+ *
57103
+ * The deploy engine's twin check is `Object.values(bag).some(v => v ===
57104
+ * undefined)` — its resolver THROWS on failure and the catch stores `undefined`.
57105
+ * The diff's resolver is best-effort and fails in three further ways that all
57106
+ * have to count as "unresolved" here, because anything that slips through is
57107
+ * compared against state and reported as a change the apply will never make:
57108
+ *
57109
+ * 1. `undefined` — the SAME signal the deploy side keys on. `resolve` returns it
57110
+ * WITHOUT throwing for a constructible-but-unknown attribute
57111
+ * (`AWS::DynamoDB::Table.StreamArn`, `AWS::IAM::Policy.PolicyId`,
57112
+ * `AWS::EC2::SecurityGroup.VpcId`, ...). `JSON.stringify` drops an
57113
+ * `undefined`-valued key, so state can never hold one and such an output
57114
+ * would otherwise report a PERMANENT phantom `ADD` printing `new: undefined`.
57115
+ * 2. A symbol — `Ref: AWS::NoValue` resolves to a sentinel symbol, which
57116
+ * `resolveValue` strips only INSIDE arrays / objects; a top-level
57117
+ * `Fn::If` selecting `AWS::NoValue` returns it bare. Not persistable, so
57118
+ * again a permanent phantom.
57119
+ * 3. A surviving intrinsic OBJECT, or — only when the template source used
57120
+ * `Fn::Sub` — an unsubstituted placeholder STRING (see
57121
+ * {@link UNSUBSTITUTED_SUB_PLACEHOLDER}).
57122
+ *
57123
+ * DEEP rather than shallow: an unresolved leaf can sit anywhere inside an array
57124
+ * or object an outer intrinsic already built.
57125
+ */
57126
+ function isUnresolvedValue(value, sourceUsedSub) {
57127
+ if (value === void 0) return true;
57128
+ if (typeof value === "symbol") return true;
57129
+ if (typeof value === "string") return sourceUsedSub && UNSUBSTITUTED_SUB_PLACEHOLDER.test(value);
57130
+ if (value === null || typeof value !== "object") return false;
57131
+ if (Array.isArray(value)) return value.some((v) => isUnresolvedValue(v, sourceUsedSub));
57132
+ const keys = Object.keys(value);
57133
+ if (keys.length === 1 && INTRINSIC_KEYS.has(keys[0])) return true;
57134
+ return Object.values(value).some((v) => isUnresolvedValue(v, sourceUsedSub));
57135
+ }
57136
+ /**
57137
+ * True when this output's RAW template value contains an `Fn::Sub` anywhere —
57138
+ * including nested inside an `Fn::Join` / `Fn::If`, since those resolve their
57139
+ * arguments and a laundered placeholder from an inner `Fn::Sub` surfaces in the
57140
+ * outer result.
57141
+ */
57142
+ function templateUsesSub(templateValue) {
57143
+ if (templateValue === null || typeof templateValue !== "object") return false;
57144
+ if (Array.isArray(templateValue)) return templateValue.some(templateUsesSub);
57145
+ const record = templateValue;
57146
+ if (Object.prototype.hasOwnProperty.call(record, "Fn::Sub")) return true;
57147
+ return Object.values(record).some(templateUsesSub);
57148
+ }
57149
+ /**
57150
+ * True when `value` is a string carrying a SECRET-BEARING CloudFormation
57151
+ * dynamic reference — `{{resolve:secretsmanager:` or `{{resolve:ssm-secure:`,
57152
+ * the two spellings that are secret regardless of what they point at.
57153
+ *
57154
+ * The diff resolves with `skipDynamicReferences`, so a secret-bearing output
57155
+ * arrives here as its unresolved `{{resolve:...}}` expression — which is also
57156
+ * what post-GHSA state stores. A state record written by an OLDER binary can
57157
+ * still hold the resolved PLAINTEXT instead (that mismatch is exactly what
57158
+ * `cdkd scrub` exists to repair), and printing it is this module's problem
57159
+ * because it is the first code path that DISPLAYS a stored output value.
57160
+ */
57161
+ function isSecretDynamicReference(value) {
57162
+ if (typeof value !== "string") return false;
57163
+ return value.includes("{{resolve:secretsmanager:") || value.includes("{{resolve:ssm-secure:");
57164
+ }
57165
+ /** Structural equality, mirroring the deploy engine's `outputMapsEqual` leaf rule. */
57166
+ function valuesEqual(a, b) {
57167
+ if (a === b) return true;
57168
+ if (a == null || b == null) return a === b;
57169
+ if (typeof a !== typeof b) return false;
57170
+ if (typeof a !== "object") return false;
57171
+ if (Array.isArray(a) || Array.isArray(b)) {
57172
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
57173
+ return a.every((v, i) => valuesEqual(v, b[i]));
57174
+ }
57175
+ const ao = a;
57176
+ const bo = b;
57177
+ const ak = Object.keys(ao);
57178
+ if (ak.length !== Object.keys(bo).length) return false;
57179
+ for (const k of ak) {
57180
+ if (!Object.prototype.hasOwnProperty.call(bo, k)) return false;
57181
+ if (!valuesEqual(ao[k], bo[k])) return false;
57182
+ }
57183
+ return true;
57184
+ }
57185
+ /**
57186
+ * Resolve a template's `Outputs` section into the exact bag shape the deploy
57187
+ * engine persists to `StackState.outputs` (issue #1921).
57188
+ *
57189
+ * This is the preview twin of `DeployEngine.resolveOutputs`, and the three
57190
+ * semantics below are load-bearing for parity — a preview that builds a
57191
+ * DIFFERENT bag than the apply persists reports a phantom change on every run,
57192
+ * which is this issue's own bug class inverted. `tests/unit/analyzer/
57193
+ * outputs-diff.test.ts` fences each one against drift on the deploy side.
57194
+ *
57195
+ * 1. An output whose `Condition` evaluates false is SKIPPED, not resolved —
57196
+ * CloudFormation simply does not create it (mirrors the resource side's
57197
+ * `filterResourcesByCondition`; an unknown condition name is kept).
57198
+ * 2. An `Export.Name` is stored as a SECOND key holding the same value, so
57199
+ * `Fn::ImportValue` can find it by export name. Both keys land in state, so
57200
+ * both must land here.
57201
+ * 3. Resolution is best-effort and never throws — a diff must never harden into
57202
+ * an error where the deploy would have succeeded.
57203
+ *
57204
+ * The parity claim is scoped to the deploy engine's NO-CHANGE branch, which is
57205
+ * the one this preview stands in for. Its changed-resources branch resolves
57206
+ * outputs with no `resolutionFailed` gate at all — correctly, because by then
57207
+ * every resource exists and resolution is expected to succeed, whereas a diff
57208
+ * runs BEFORE any of them are created.
57209
+ *
57210
+ * Deliberately does NOT redact secrets the way the deploy side does: this
57211
+ * resolver runs with `skipDynamicReferences`, so a `{{resolve:...}}` reference
57212
+ * is never resolved to plaintext in the first place and the bag already holds
57213
+ * the expression that state stores post-redaction.
57214
+ */
57215
+ async function resolveTemplateOutputs(template, resolveFn, conditions) {
57216
+ const logger = getLogger().child("OutputsDiff");
57217
+ const outputs = {};
57218
+ const exportNames = /* @__PURE__ */ new Set();
57219
+ const failedKeys = /* @__PURE__ */ new Set();
57220
+ const secretSourceKeys = /* @__PURE__ */ new Set();
57221
+ let resolutionFailed = false;
57222
+ if (!template.Outputs) return {
57223
+ outputs,
57224
+ exportNames,
57225
+ failedKeys,
57226
+ secretSourceKeys,
57227
+ resolutionFailed
57228
+ };
57229
+ /**
57230
+ * Record a key the resolver DROPPED — plus the literal `Export.Name` alias the
57231
+ * deploy would have written alongside it. Both are absent from the bag, so
57232
+ * both would otherwise read as phantom REMOVEs and make the suppression
57233
+ * warning fire on the ordinary pending-resource case.
57234
+ */
57235
+ const recordFailure = (outputKey, output) => {
57236
+ failedKeys.add(outputKey);
57237
+ if (typeof output.Export?.Name === "string") failedKeys.add(output.Export.Name);
57238
+ };
57239
+ for (const [outputKey, output] of Object.entries(template.Outputs)) {
57240
+ if (!isSecretDynamicReference(output.Value)) continue;
57241
+ secretSourceKeys.add(outputKey);
57242
+ if (typeof output.Export?.Name === "string") secretSourceKeys.add(output.Export.Name);
57243
+ }
57244
+ for (const [outputKey, output] of Object.entries(template.Outputs)) {
57245
+ if (output.Condition !== void 0 && conditions?.[output.Condition] === false) {
57246
+ logger.debug(`Skipping output ${outputKey} — condition ${output.Condition} is false`);
57247
+ continue;
57248
+ }
57249
+ const sourceUsedSub = templateUsesSub(output.Value);
57250
+ let value;
57251
+ try {
57252
+ value = await resolveFn(structuredClone(output.Value));
57253
+ } catch (error) {
57254
+ logger.debug(`Diff could not resolve output ${outputKey}: ${String(error)}`);
57255
+ resolutionFailed = true;
57256
+ recordFailure(outputKey, output);
57257
+ continue;
57258
+ }
57259
+ if (isUnresolvedValue(value, sourceUsedSub)) {
57260
+ logger.debug(`Diff left output ${outputKey} unresolved (references a pending resource)`);
57261
+ resolutionFailed = true;
57262
+ recordFailure(outputKey, output);
57263
+ continue;
57264
+ }
57265
+ outputs[outputKey] = value;
57266
+ if (output.Export?.Name) {
57267
+ let exportName = output.Export.Name;
57268
+ const exportSourceUsedSub = templateUsesSub(output.Export.Name);
57269
+ if (typeof exportName !== "string") try {
57270
+ exportName = await resolveFn(structuredClone(exportName));
57271
+ } catch (error) {
57272
+ logger.debug(`Diff could not resolve Export.Name of ${outputKey}: ${String(error)}`);
57273
+ resolutionFailed = true;
57274
+ continue;
57275
+ }
57276
+ if (typeof exportName === "string" && !isUnresolvedValue(exportName, exportSourceUsedSub)) {
57277
+ outputs[exportName] = value;
57278
+ exportNames.add(exportName);
57279
+ } else resolutionFailed = true;
57280
+ }
57281
+ }
57282
+ return {
57283
+ outputs,
57284
+ exportNames,
57285
+ failedKeys,
57286
+ secretSourceKeys,
57287
+ resolutionFailed
57288
+ };
57289
+ }
57290
+ /**
57291
+ * Compare the persisted Outputs bag against the one the next deploy would
57292
+ * write, key by key (issue #1921).
57293
+ *
57294
+ * Compares BAG KEYS rather than template output names on purpose. The bag is
57295
+ * what actually lands in `StackState.outputs` AND what
57296
+ * `ExportIndexStore.updateForStack` publishes, so a key-level comparison is
57297
+ * exactly the `outputMapsEqual` predicate the deploy engine gates its persist
57298
+ * on — no interpretive layer in between that could drift from apply semantics.
57299
+ * The practical payoff is that the motivating #875 case shows the user the
57300
+ * literal export name their downstream `Fn::ImportValue` needs.
57301
+ *
57302
+ * ## Withholding legacy secret plaintext
57303
+ *
57304
+ * This is the first code path that DISPLAYS a stored output value, and
57305
+ * `cdkd diff` is routinely run in CI — so a value that turns out to be secret
57306
+ * plaintext would land in build logs. `StackState.outputs` is only guaranteed
57307
+ * redacted for records written after the GHSA fix; an older binary persisted
57308
+ * the RESOLVED plaintext, which is the condition `cdkd scrub` repairs.
57309
+ *
57310
+ * Two independent signals identify such a record, and BOTH are needed:
57311
+ *
57312
+ * - the desired side is still a secret-bearing expression per
57313
+ * {@link isSecretDynamicReference} (this resolver runs with
57314
+ * `skipDynamicReferences`) while the stored side is not; and
57315
+ * - `secretSourceKeys` — the template itself declares the key's value as such a
57316
+ * reference. This one reaches cases the first cannot: a
57317
+ * condition-skipped secret output, or one deleted from the template, has NO
57318
+ * desired side at all and would otherwise print in full as a REMOVE row.
57319
+ *
57320
+ * A hit on either makes the WHOLE record suspect, not just that key: the
57321
+ * record was written by a pre-GHSA binary, so every value in it is unredacted.
57322
+ * Withholding is therefore record-level. The change is still REPORTED — only
57323
+ * the value is withheld — so `--fail` and the exports story are unaffected.
57324
+ */
57325
+ function computeOutputsDiff(current, desired, exportNames, secretSourceKeys = /* @__PURE__ */ new Set()) {
57326
+ const changes = [];
57327
+ const currentBag = current ?? {};
57328
+ const legacyRecord = Object.entries(currentBag).some(([name, oldValue]) => {
57329
+ if (isSecretDynamicReference(oldValue)) return false;
57330
+ return isSecretDynamicReference(desired[name]) || secretSourceKeys.has(name);
57331
+ });
57332
+ const push = (change) => {
57333
+ if (legacyRecord && change.changeType !== "ADD") {
57334
+ const { oldValue: _dropped, ...rest } = change;
57335
+ changes.push({
57336
+ ...rest,
57337
+ oldValueRedacted: true
57338
+ });
57339
+ return;
57340
+ }
57341
+ changes.push(change);
57342
+ };
57343
+ for (const [name, newValue] of Object.entries(desired)) {
57344
+ const isExport = exportNames.has(name);
57345
+ if (!Object.prototype.hasOwnProperty.call(currentBag, name)) {
57346
+ changes.push({
57347
+ name,
57348
+ changeType: "ADD",
57349
+ newValue,
57350
+ isExport
57351
+ });
57352
+ continue;
57353
+ }
57354
+ const oldValue = currentBag[name];
57355
+ if (!valuesEqual(oldValue, newValue)) push({
57356
+ name,
57357
+ changeType: "MODIFY",
57358
+ oldValue,
57359
+ newValue,
57360
+ isExport
57361
+ });
57362
+ }
57363
+ for (const [name, oldValue] of Object.entries(currentBag)) {
57364
+ if (Object.prototype.hasOwnProperty.call(desired, name)) continue;
57365
+ push({
57366
+ name,
57367
+ changeType: "REMOVE",
57368
+ oldValue,
57369
+ isExport: false
57370
+ });
57371
+ }
57372
+ return changes;
57373
+ }
57374
+
56747
57375
  //#endregion
56748
57376
  //#region src/cli/cfn-stack-states.ts
56749
57377
  /**
@@ -57705,6 +58333,11 @@ async function loadStateOrEmpty(stackName, region, stateBackend) {
57705
58333
  * resolver so changes buried inside intrinsics (e.g. `Fn::Join` literal
57706
58334
  * args) are detected against resolved values in state.
57707
58335
  *
58336
+ * Also computes the `Outputs` delta (issue #1921) — an Outputs-only change has
58337
+ * a byte-identical `Resources` section, so without it such a stack previews as
58338
+ * "No changes detected" while the apply persists new outputs and republishes
58339
+ * the exports index.
58340
+ *
57708
58341
  * Pure with respect to AWS state mutation — only reads state (the resolver
57709
58342
  * may read producer state for `Fn::ImportValue` / `Fn::GetStackOutput`).
57710
58343
  */
@@ -57749,7 +58382,16 @@ async function computeStackDiff(currentState, template, region, stackName, state
57749
58382
  ...conditions && { conditions },
57750
58383
  skipDynamicReferences: true
57751
58384
  });
57752
- return diffCalculator.calculateDiff(currentState, effectiveTemplate, resolveFn, canonicalizeProperties);
58385
+ const changes = await diffCalculator.calculateDiff(currentState, effectiveTemplate, resolveFn, canonicalizeProperties);
58386
+ const resolved = await resolveTemplateOutputs(effectiveTemplate, resolveFn, conditions);
58387
+ let outputChanges = [];
58388
+ if (resolved.resolutionFailed) {
58389
+ if (computeOutputsDiff(currentState.outputs, resolved.outputs, resolved.exportNames, resolved.secretSourceKeys).filter((change) => !resolved.failedKeys.has(change.name)).length > 0) logger.warn(`Outputs of stack ${stackName} may have changed, but one or more could not be resolved against current state — omitting the Outputs section from this diff. It is usually an output referencing a resource this deploy has yet to create.`);
58390
+ } else outputChanges = computeOutputsDiff(currentState.outputs, resolved.outputs, resolved.exportNames, resolved.secretSourceKeys);
58391
+ return {
58392
+ changes,
58393
+ outputChanges
58394
+ };
57753
58395
  }
57754
58396
  /**
57755
58397
  * Resolve a nested-stack child's input `Parameters` (declared on the parent's
@@ -57812,12 +58454,14 @@ async function resolveChildStackParameters(parentStackRow, parentTemplate, paren
57812
58454
  async function buildDiffTree(args) {
57813
58455
  const { stackName, displayName, region, template, nestedTemplates, recursive, stateBackend, diffCalculator, parameters, canonicalizeProperties, assetRedirect, cfnFallback } = args;
57814
58456
  const state = await loadStateOrEmpty(stackName, region, stateBackend);
58457
+ const { changes, outputChanges } = await computeStackDiff(state, template, region, stackName, stateBackend, diffCalculator, parameters, canonicalizeProperties, cfnFallback);
57815
58458
  const node = {
57816
58459
  stackName,
57817
58460
  displayName,
57818
58461
  region,
57819
- changes: await computeStackDiff(state, template, region, stackName, stateBackend, diffCalculator, parameters, canonicalizeProperties, cfnFallback),
58462
+ changes,
57820
58463
  ccApiRoutes: collectCcApiRoutes(template, state),
58464
+ outputChanges,
57821
58465
  children: []
57822
58466
  };
57823
58467
  if (!recursive) return node;
@@ -57862,12 +58506,14 @@ async function buildDiffTree(args) {
57862
58506
  */
57863
58507
  async function buildDeletedSubtree(stackName, region, stateBackend, diffCalculator) {
57864
58508
  const state = await loadStateOrEmpty(stackName, region, stateBackend);
58509
+ const { changes, outputChanges } = await computeStackDiff(state, EMPTY_TEMPLATE, region, stackName, stateBackend, diffCalculator);
57865
58510
  const node = {
57866
58511
  stackName,
57867
58512
  displayName: stackName,
57868
58513
  region,
57869
- changes: await computeStackDiff(state, EMPTY_TEMPLATE, region, stackName, stateBackend, diffCalculator),
58514
+ changes,
57870
58515
  ccApiRoutes: /* @__PURE__ */ new Map(),
58516
+ outputChanges,
57871
58517
  children: []
57872
58518
  };
57873
58519
  for (const [logicalId, resource] of Object.entries(state.resources)) {
@@ -57925,7 +58571,7 @@ function collectCcApiRoutes(template, state) {
57925
58571
  /** True when this node has at least one real (non-`NO_CHANGE`) change. */
57926
58572
  function nodeHasChanges(node) {
57927
58573
  for (const change of node.changes.values()) if (change.changeType !== "NO_CHANGE") return true;
57928
- return false;
58574
+ return node.outputChanges.length > 0;
57929
58575
  }
57930
58576
  /** True when this node OR any descendant has a real change (tree-wide drift detector for `--fail`). */
57931
58577
  function treeHasChanges(node) {
@@ -57956,26 +58602,17 @@ function diffTreeToJson(node) {
57956
58602
  stack: node.stackName,
57957
58603
  region: node.region,
57958
58604
  changes,
58605
+ outputChanges: node.outputChanges.map((change) => ({
58606
+ name: change.name,
58607
+ changeType: change.changeType,
58608
+ ...change.changeType !== "ADD" && !change.oldValueRedacted ? { oldValue: change.oldValue } : {},
58609
+ ...change.oldValueRedacted ? { oldValueRedacted: true } : {},
58610
+ ...change.changeType !== "REMOVE" ? { newValue: change.newValue } : {},
58611
+ export: change.isExport
58612
+ })),
57959
58613
  children: node.children.map(diffTreeToJson)
57960
58614
  };
57961
58615
  }
57962
- const INTRINSIC_KEYS = /* @__PURE__ */ new Set([
57963
- "Ref",
57964
- "Fn::Sub",
57965
- "Fn::GetAtt",
57966
- "Fn::Join",
57967
- "Fn::Select",
57968
- "Fn::Split",
57969
- "Fn::If",
57970
- "Fn::ImportValue",
57971
- "Fn::FindInMap",
57972
- "Fn::Base64",
57973
- "Fn::GetAZs",
57974
- "Fn::Equals",
57975
- "Fn::And",
57976
- "Fn::Or",
57977
- "Fn::Not"
57978
- ]);
57979
58616
  function isIntrinsic(value) {
57980
58617
  if (value === null || value === void 0 || typeof value !== "object" || Array.isArray(value)) return false;
57981
58618
  const keys = Object.keys(value);
@@ -58111,6 +58748,84 @@ function renderChangeLines(changes, logFn, ccApiRoutes) {
58111
58748
  delete: deleteCount
58112
58749
  };
58113
58750
  }
58751
+ /** Stand-in printed instead of a withheld legacy-plaintext output value. */
58752
+ const REDACTED_LEGACY_PLAINTEXT = "<redacted: legacy plaintext in state — run `cdkd scrub`>";
58753
+ /**
58754
+ * Strip characters that let a template-controlled string manipulate the
58755
+ * TERMINAL rather than merely appear in it: C0 (incl. ESC, so no ANSI sequence
58756
+ * survives), DEL, C1 (the 8-bit CSI forms some terminals still honor), and the
58757
+ * bidi / format overrides that visually reorder a rendered line.
58758
+ *
58759
+ * Applied only on the human-render path. The `--json` payload is deliberately
58760
+ * left byte-faithful: it is a machine interface, an export NAME is data a
58761
+ * consumer may match on, and silently mutating it there would trade a real
58762
+ * correctness regression for a display concern belonging to whatever renders
58763
+ * the JSON. `JSON.stringify` already escapes everything below 0x20.
58764
+ */
58765
+ function stripControlChars(value) {
58766
+ return value.replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "");
58767
+ }
58768
+ /**
58769
+ * The same guard for an already-JSON-SERIALIZED value: C1 and the bidi marks
58770
+ * only, deliberately NOT C0.
58771
+ *
58772
+ * `JSON.stringify` escapes every character below 0x20 that occurs INSIDE a
58773
+ * string, so the only C0 left on this path is the pretty-printer's own
58774
+ * structural newlines — stripping those collapses a multi-line value onto a
58775
+ * single line. What `JSON.stringify` passes through unchanged is the C1 range
58776
+ * and the bidi overrides, and those are what this removes.
58777
+ */
58778
+ function stripDisplayOnlyChars(value) {
58779
+ return value.replace(/[\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "");
58780
+ }
58781
+ /**
58782
+ * Render one node's Outputs delta (issue #1921) via `logFn`, returning the
58783
+ * per-kind counts. Emits nothing when there is no delta, so an unchanged
58784
+ * Outputs section adds no noise to a resource-only diff.
58785
+ *
58786
+ * Rows are keyed by the PERSISTED bag key, which is what `StackState.outputs`
58787
+ * holds and what the exports index publishes. An output carrying an
58788
+ * `Export.Name` therefore shows TWO rows — its logical name and its export
58789
+ * name — because the deploy writes both keys. That is the useful half for the
58790
+ * motivating case: the `[export]`-tagged row IS the string a downstream
58791
+ * `Fn::ImportValue` resolves, so the user can match it against the consumer
58792
+ * that was failing with "export not found".
58793
+ */
58794
+ function renderOutputChangeLines(outputChanges, logFn) {
58795
+ const counts = {
58796
+ add: 0,
58797
+ change: 0,
58798
+ remove: 0
58799
+ };
58800
+ if (outputChanges.length === 0) return counts;
58801
+ logFn("\n Outputs:");
58802
+ const indent = " ";
58803
+ const render = (value) => stripDisplayOnlyChars((JSON.stringify(value, null, 2) ?? "undefined").replace(/\n/g, `\n${indent}`));
58804
+ const renderOld = (change) => change.oldValueRedacted ? REDACTED_LEGACY_PLAINTEXT : render(change.oldValue);
58805
+ for (const change of outputChanges) {
58806
+ const exported = change.isExport ? " [export]" : "";
58807
+ const name = stripControlChars(change.name);
58808
+ switch (change.changeType) {
58809
+ case "ADD":
58810
+ counts.add++;
58811
+ logFn(` [+] ${name}${exported}`);
58812
+ logFn(` new: ${render(change.newValue)}`);
58813
+ break;
58814
+ case "MODIFY":
58815
+ counts.change++;
58816
+ logFn(` [~] ${name}${exported}`);
58817
+ logFn(` old: ${renderOld(change)}`);
58818
+ logFn(` new: ${render(change.newValue)}`);
58819
+ break;
58820
+ case "REMOVE":
58821
+ counts.remove++;
58822
+ logFn(` [-] ${name}${exported}`);
58823
+ logFn(` old: ${renderOld(change)}`);
58824
+ break;
58825
+ }
58826
+ }
58827
+ return counts;
58828
+ }
58114
58829
  /**
58115
58830
  * Render a diff tree (root + nested children, DFS) via `logFn`. Only nodes
58116
58831
  * that actually have changes get a block — unchanged nested children are
@@ -58123,7 +58838,9 @@ function renderDiffTree(node, isRoot, logFn) {
58123
58838
  if (nodeHasChanges(node)) {
58124
58839
  logFn(isRoot ? `\nStack ${node.stackName}:` : `\nNested stack: ${node.displayName}`);
58125
58840
  const { create, update, delete: del } = renderChangeLines(node.changes, logFn, node.ccApiRoutes);
58841
+ const outputs = renderOutputChangeLines(node.outputChanges, logFn);
58126
58842
  logFn(`\n${create} to create, ${update} to update, ${del} to delete`);
58843
+ if (outputs.add + outputs.change + outputs.remove > 0) logFn(`${outputs.add} output(s) to add, ${outputs.change} to change, ${outputs.remove} to remove`);
58127
58844
  }
58128
58845
  for (const child of node.children) renderDiffTree(child, false, logFn);
58129
58846
  }
@@ -79390,7 +80107,7 @@ function createMigrateCommand() {
79390
80107
  */
79391
80108
  function buildProgram() {
79392
80109
  const program = new Command();
79393
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.283.26");
80110
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.283.28");
79394
80111
  program.hook("preAction", (_thisCommand, actionCommand) => {
79395
80112
  const { profile } = actionCommand.optsWithGlobals();
79396
80113
  if (profile !== void 0) process.env["AWS_PROFILE"] = profile;