@go-to-k/cdkd 0.283.8 → 0.283.10

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 createAssetRedirectResolver, B as findSilentDropProperties, Bn as StackTerminationProtectionError, Bt as buildDenyExternalAccessPolicy, C as makeCanonicalizePropertiesFn, Cn as setAwsClients, Ct as S3StateBackend, D as renderStatefulReason, Dt as stringifyValue, E as isStatefulRecreateTargetSync, Et as AssetPublisher, 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, 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 rewriteTemplateAssetReferences, N as red, Nn as MissingCdkCliError, Nt as AssetModeResolver, O as formatResourceLine, On as DeployCancelledError, Ot as WorkGraph, 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 LockManager, T as MULTI_REGION_RECREATE_BLOCKED_TYPES, Tn as CdkdError, Tt as shouldRetainResource, U as disableInstanceApiTermination, Ut as getDockerCmd, V as CloudControlProvider, Vt as buildDockerImage, W as isTerminationProtectionPropagationError, Wt as runDockerForeground, X as normalizeAwsTagsToCfn, Xt as getDefaultStateBucketName, Y as WAFv2WebACLProvider, Yt as synthesisStatusMessage, Z as resolveExplicitPhysicalId, Zt as getLegacyStateBucketName, _ as buildFinalSnapshotIdentifier, _n as processStackMessages, _t as isRetryableTransientError, a as DeploymentEventsStore, an as stateBucketExistenceConfirmed, at as requireConfigObject, b as isFinalSnapshotError, bn as AwsClients, bt as DagBuilder, c as replayFailedOperations, cn as CFN_TEMPLATE_URL_LIMIT, ct as s3BucketDomainName, d as deleteSkipReason, dn as uploadCfnTemplate, dt as s3BucketWebsiteUrl, en as resolveCaptureObservedState, et as configBooleanRefusal, f as withResourceDeadline, fn as expectedOwnerParam, ft as applyRoleArnIfSet, g as PRE_DELETE_SNAPSHOT_TYPES, gn as AssemblyReader, gt as isMarkedNonRetryable, h as ATOMIC_FINAL_SNAPSHOT_TYPES, hn as derivePartitionAndUrlSuffix, ht as withRetry, i as DeploymentEventsReader, in as resolveUseCdkBootstrapAssets, it as requireConfigArray, j as gray, jn as LocalStartServiceError, jt as loadPublishableAssetManifest, k as bold, kn as LocalInvokeBuildError$1, kt as buildAssetRedirectMap, l as replayRollback, ln as MIGRATE_TMP_PREFIX, lt as s3BucketDualStackDomainName, m as computeImplicitDeleteEdges, mn as canonicalizeRegion, mt as describeTypeWithThrottleRetry, 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 DiffCalculator, q as getAccountInfo, qt as getDockerImageBySourceHash, r as DeployEngine, rn as resolveStateBucketWithDefaultAndSource, rt as replayWarn, s as planRollback, sn as CFN_TEMPLATE_BODY_LIMIT, st as s3BucketArn, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as resolveSkipPrefix, tt as configStringRefusal, u as UNSPECIFIED_SKIP_REASON, un as findLargeInlineResources, ut as s3BucketRegionalDomainName, v as ccRoutedFinalSnapshotError, vt as isThrottlingError, w as extractDeploymentEventError, wt as rebuildClientForBucketRegion, x as refusesFinalSnapshot, xn as getAwsClients, xt as TemplateParser, y as createPreDeleteFinalSnapshot, yn as resolveBucketRegion, yt as markNonRetryable, z as findActionableSilentDrops, zn as StackHasActiveImportsError, zt as validateContainerRepoName } from "./deploy-engine-D95wmKN8.js";
2
+ import { $ as coerceCfnBoolean, $t as resolveStateBucketWithDefaultAndSource, A as cyan, An as NestedStackChildDirectDestroyError, At as BOOTSTRAP_MARKER_PREFIX, B as findSilentDropProperties, Bt as runDockerForeground, C as makeCanonicalizePropertiesFn, Ct as stringifyValue, D as renderStatefulReason, Dn as LocalStartServiceError, Dt as loadPublishableAssetManifest, E as isStatefulRecreateTargetSync, En as LocalMigrateError, Et as createAssetRedirectResolver, F as IAMRoleProvider, Fn as StackHasActiveImportsError, Ft as validateContainerRepoName, G as IntrinsicFunctionResolver, Gn as isThrottlingError, Gt as synthesisStatusMessage, H as slowCcOperationTimeoutMs, Hn as withErrorHandling, Ht as AssetManifestLoader, I as collectInlinePolicyNamesManagedBySiblings, In as StackTerminationProtectionError, It as buildDenyExternalAccessPolicy, J as refStateLookupFromResource, Jt as resolveApp, K as cfnRefValueFromPhysicalId, Kn as markNonRetryable, Kt as getDefaultStateBucketName, L as clearOnUpdateRemoval, Lt as buildDockerImage, M as green, Mn as ProvisioningError, Mt as getBootstrapMarkerKey, N as red, Nn as ResourceTimeoutError, Nt as parseBootstrapMarker, O as formatResourceLine, Ot as rewriteTemplateAssetReferences, P as yellow, Pn as ResourceUpdateNotSupportedError, Pt as validateAssetBucketName, Q as assertRegionMatch, Qt as resolveStateBucketWithDefault, R as ProviderRegistry, Rt as formatDockerLoginError, S as unsupportedFinalSnapshotError, St as AssetPublisher, T as MULTI_REGION_RECREATE_BLOCKED_TYPES, Tn as LocalInvokeBuildError$1, Tt as buildAssetRedirectMap, U as disableInstanceApiTermination, Un as isMarkedNonRetryable, Ut as getDockerImageBySourceHash, V as CloudControlProvider, Vn as normalizeAwsError, Vt as runDockerStreaming, W as isTerminationProtectionPropagationError, Wn as isRetryableTransientError, Wt as Synthesizer, X as normalizeAwsTagsToCfn, Xt as resolveCaptureObservedState, Y as WAFv2WebACLProvider, Yt as resolveAutoAssetStorage, Z as resolveExplicitPhysicalId, Zt as resolveSkipPrefix, _ as buildFinalSnapshotIdentifier, _n as getAwsClients, _t as TemplateParser, a as DeploymentEventsStore, an as MIGRATE_TMP_PREFIX, at as requireConfigObject, b as isFinalSnapshotError, bt as rebuildClientForBucketRegion, c as replayFailedOperations, cn as expectedOwnerParam, ct as s3BucketDomainName, d as deleteSkipReason, dn as derivePartitionAndUrlSuffix, dt as s3BucketWebsiteUrl, en as resolveUseCdkBootstrapAssets, et as configBooleanRefusal, f as withResourceDeadline, fn as AssemblyReader, ft as applyRoleArnIfSet, g as PRE_DELETE_SNAPSHOT_TYPES, gn as AwsClients, gt as DagBuilder, h as ATOMIC_FINAL_SNAPSHOT_TYPES, hn as resolveBucketRegion, ht as withRetry, i as DeploymentEventsReader, in as CFN_TEMPLATE_URL_LIMIT, it as requireConfigArray, j as gray, jn as PartialFailureError, jt as ensureAssetStorage, k as bold, kn as MissingCdkCliError, kt as AssetModeResolver, l as replayRollback, ln as PARTITION_TABLE, lt as s3BucketDualStackDomainName, m as computeImplicitDeleteEdges, mt as describeTypeWithThrottleRetry, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as warnDeprecatedNoPrefixCliFlag, nt as readConfigString, o as planFailedOps, on as findLargeInlineResources, ot as requireConfigString, p as IMPLICIT_DELETE_DEPENDENCIES, pn as processStackMessages, pt as DiffCalculator, q as getAccountInfo, qt as getLegacyStateBucketName, r as DeployEngine, rn as CFN_TEMPLATE_BODY_LIMIT, rt as replayWarn, s as planRollback, sn as uploadCfnTemplate, st as s3BucketArn, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as stateBucketExistenceConfirmed, tt as configStringRefusal, u as UNSPECIFIED_SKIP_REASON, un as canonicalizeRegion, ut as s3BucketRegionalDomainName, v as ccRoutedFinalSnapshotError, vn as resetAwsClients, vt as LockManager, w as extractDeploymentEventError, wn as DeployCancelledError, wt as WorkGraph, x as refusesFinalSnapshot, xn as CdkdError, xt as shouldRetainResource, y as createPreDeleteFinalSnapshot, yn as setAwsClients, yt as S3StateBackend, z as findActionableSilentDrops, zt as getDockerCmd } from "./deploy-engine-Bvr31OQ0.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-Tu4sDbhu.js";
4
+ import { t as ASGProvider } from "./asg-provider-CrBdHIgB.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";
@@ -14107,10 +14107,149 @@ const BILLING_FLIP_ACTIVE_WAIT_ATTEMPTS = 600;
14107
14107
  * though the template had asked, and `--revert` can never clear it because the
14108
14108
  * write gate skips the falsy value. Keep this the ONLY spelling of the rule.
14109
14109
  *
14110
- * Deliberately TRUTHINESS, because that is what the wire has always done.
14110
+ * It USED to be bare truthiness, "because that is what the wire has always
14111
+ * done". PR review of issue #1768 showed that is not a rule, it is the absence
14112
+ * of one: `WarmThroughput: {}` and `WarmThroughput: 'nonsense'` are both truthy,
14113
+ * so they went out as `UpdateTable{WarmThroughput: {}}` /
14114
+ * `{WarmThroughput: 'nonsense'}` — a call that can only be rejected, and one
14115
+ * cdkd would repeat on every deploy. Harmless-looking at the table level (one
14116
+ * doomed call, loudly); newly REACHABLE per index once issue #1768's per-index
14117
+ * send path landed, where the same value is emitted once per index. So the rule
14118
+ * is now: at least ONE of the two user-settable members must resolve to a
14119
+ * finite number.
14120
+ *
14121
+ * Still not `!== undefined`, and still ONE spelling for the write sites and the
14122
+ * drift side both: a value this refuses is a value cdkd does not send, so the
14123
+ * readback must not emit AWS's computed value as though the template had asked
14124
+ * for it. A YAML-borne numeric STRING is accepted, via {@link capacityNumber},
14125
+ * because `'12000'` genuinely means 12000.
14111
14126
  */
14112
14127
  function isSendableWarmThroughput(value) {
14113
- return Boolean(value);
14128
+ return coerceWarmThroughput(value) !== void 0;
14129
+ }
14130
+ /**
14131
+ * Turn a template-declared `WarmThroughput` into the NUMERIC spec cdkd puts on
14132
+ * the wire, or `undefined` when nothing in it is usable (PR review round 5).
14133
+ *
14134
+ * The send sites used to forward the declared value VERBATIM while
14135
+ * {@link isSendableWarmThroughput} accepted a numeric STRING, so the predicate
14136
+ * blessed the one shape it exists to stop: `{ReadUnitsPerSecond: '12000'}` went
14137
+ * out as `"WarmThroughput":{"ReadUnitsPerSecond":"12000"}` — a string in a Long
14138
+ * field, which DynamoDB rejects — and the drift side then answered "declared"
14139
+ * for it, so the readback emitted AWS's numeric value and the table drifted on
14140
+ * every run. Coercing rather than dropping string support is the better answer
14141
+ * for a stringly-typed CFn template (`'12000'` genuinely means 12000, and
14142
+ * `create()`'s table-level `ProvisionedThroughput` already coerces one line
14143
+ * over), and it makes the quoted form WORK instead of merely warning.
14144
+ *
14145
+ * Per MEMBER, so a bad one cannot take a good one with it, and never `NaN`:
14146
+ * {@link capacityNumber} accepts a number or a numeric string and rejects
14147
+ * everything else, so a member that does not resolve is OMITTED from the spec
14148
+ * and reported in `dropped` for the caller to announce. AWS accepts a
14149
+ * one-member `WarmThroughput` (measured us-east-1, 2026-08-13: an
14150
+ * `UpdateTable` carrying only `ReadUnitsPerSecond` is a valid request shape —
14151
+ * it was refused for being a DECREASE, not for its shape), so a partial send
14152
+ * is a real request rather than a malformed one.
14153
+ *
14154
+ * {@link isSendableWarmThroughput} is defined AS this function's success, so
14155
+ * the drift-side gate and the write-side coercion cannot answer differently —
14156
+ * structurally, not by hand. That identity is exact at the BLOCK level and
14157
+ * ONLY there (PR review round 6, where the earlier wording was measured and
14158
+ * found broader than the truth): both sides ask "is any member usable", so a
14159
+ * block cdkd sends is a block drift compares, and a block cdkd refuses is one
14160
+ * drift ignores.
14161
+ *
14162
+ * PER MEMBER it does NOT hold, and the residual is real:
14163
+ * `{ReadUnitsPerSecond: 12000, WriteUnitsPerSecond: {Ref: 'Unset'}}` sends only
14164
+ * `ReadUnitsPerSecond`, while `readCurrentState` emits AWS's computed value for
14165
+ * BOTH members and `getDriftUnknownPaths` leaves the path compared — so AWS's
14166
+ * `WriteUnitsPerSecond`, which cdkd never sent, is compared against the
14167
+ * template forever. It fails OPEN — the difference is REPORTED by `cdkd drift`,
14168
+ * never hidden — and {@link DynamoDBTableProvider.coerceWarmThroughputForSend}
14169
+ * names the dropped member when the value is APPLIED.
14170
+ *
14171
+ * How loud, precisely (measured, PR review round 7 — an earlier version of this
14172
+ * sentence said "on every deploy" and was falsified by the code two hundred
14173
+ * lines down): every warn in this provider sits behind a CHANGE gate, so the
14174
+ * drop is announced on the deploy that INTRODUCES it and is silent on every
14175
+ * repeat deploy of the same value, while the drift report persists. Loud once,
14176
+ * then a standing drift entry — not a per-deploy nag, and not silent either.
14177
+ * Closing the residual means emitting per-member, which is a `readCurrentState`
14178
+ * shape change with its own drift-baseline migration — deliberately not folded
14179
+ * into this issue.
14180
+ *
14181
+ * NOT a drift fix either way: state records what the TEMPLATE said, so a
14182
+ * `properties` baseline still holds `'12000'` against a numeric readback and
14183
+ * the comparator's `deepEqual` is strict about that. It is the generic
14184
+ * stringly-typed-CFn class (every numeric property has it), not something this
14185
+ * coercion can reach from the write side.
14186
+ */
14187
+ function coerceWarmThroughput(value) {
14188
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
14189
+ const bag = value;
14190
+ const spec = {};
14191
+ const dropped = [];
14192
+ let usable = 0;
14193
+ for (const member of ["ReadUnitsPerSecond", "WriteUnitsPerSecond"]) {
14194
+ if (bag[member] === void 0) continue;
14195
+ const n = capacityNumber(bag[member]);
14196
+ if (n === void 0) {
14197
+ dropped.push(member);
14198
+ continue;
14199
+ }
14200
+ spec[member] = n;
14201
+ usable++;
14202
+ }
14203
+ if (usable === 0) return void 0;
14204
+ return {
14205
+ spec,
14206
+ dropped
14207
+ };
14208
+ }
14209
+ /**
14210
+ * Is this a `WarmThroughput` the user WROTE that the tightened send rule then
14211
+ * refuses (PR review of issue #1768)?
14212
+ *
14213
+ * The set of values whose OUTCOME this issue changed, and the exact set the
14214
+ * write sites must warn about. Tightening {@link isSendableWarmThroughput}
14215
+ * closed a doomed-call class, and silently: a template declaring
14216
+ * `{ ReadUnitsPerSecnd: 20000 }` (a member typo), `'nonsense'`, `{}`, or an
14217
+ * unresolved intrinsic used to reach AWS and be REJECTED BY NAME, and now
14218
+ * vanishes with no warning, no debug line, and a green deploy — the "loud
14219
+ * failure for a quiet lie" trade the adopted-index arm in `applyGsiUpdates`
14220
+ * refuses, and out of step with every other skip in this file
14221
+ * ({@link DynamoDBTableProvider.skipZeroCapacityIndexUpdate},
14222
+ * {@link DynamoDBTableProvider.skipWarmThroughputDecrease}), which all warn.
14223
+ *
14224
+ * ABSENT is silent, because that is the ordinary case — the overwhelming
14225
+ * majority of templates declare no warm throughput at all and must not be
14226
+ * nagged. FALSY-but-present (`null`, `''`, `false`, `0`) is silent too: those
14227
+ * were skipped silently BEFORE this issue as well (`Boolean(value)` was the
14228
+ * whole rule), so they are not a behavior this PR changed and warning about
14229
+ * them would be a new noise source, not a restored signal.
14230
+ */
14231
+ function isRefusedWarmThroughput(value) {
14232
+ if (value === void 0) return false;
14233
+ if (!value) return false;
14234
+ return !isSendableWarmThroughput(value);
14235
+ }
14236
+ /**
14237
+ * Does the DESIRED bag carry enough for the drift side to answer "what did the
14238
+ * template declare"?
14239
+ *
14240
+ * An ABSENT or EMPTY bag answers NO: `drift.ts` passes `properties ?? {}`, and
14241
+ * other callers pass nothing at all, so `{}` means "nothing recorded" rather
14242
+ * than "this key is absent" (the contract on {@link ResourceProvider.getDriftUnknownPaths}).
14243
+ * Every gate below falls back to the pre-gate behavior on a NO, since dropping
14244
+ * a key on the strength of a bag that was never populated is unrecoverable
14245
+ * phantom drift in the other direction.
14246
+ *
14247
+ * Named once and shared by the table-level `WarmThroughput` gate (issue #1760)
14248
+ * and the per-index gates (issue #1767) so the two cannot answer the
14249
+ * "uninformative bag" question differently.
14250
+ */
14251
+ function desiredBagIsInformative(properties) {
14252
+ return properties !== void 0 && Object.keys(properties).length > 0;
14114
14253
  }
14115
14254
  /**
14116
14255
  * Does this desired-property bag DECLARE a table-level `WarmThroughput`?
@@ -14133,8 +14272,267 @@ function isSendableWarmThroughput(value) {
14133
14272
  * phantom drift, while the residual is a loud, per-resource revert failure.
14134
14273
  */
14135
14274
  function declaresWarmThroughput(properties) {
14136
- if (properties === void 0 || Object.keys(properties).length === 0) return true;
14137
- return isSendableWarmThroughput(properties["WarmThroughput"]);
14275
+ if (!desiredBagIsInformative(properties)) return true;
14276
+ return properties !== void 0 && isSendableWarmThroughput(properties["WarmThroughput"]);
14277
+ }
14278
+ /**
14279
+ * Is the WarmThroughput this update would SEND a DECREASE of what AWS already
14280
+ * holds (issue #1768)?
14281
+ *
14282
+ * Measured live (us-east-1, 2026-08-13) against a table AWS reports
14283
+ * `{ReadUnitsPerSecond: 12000, WriteUnitsPerSecond: 4000}` for:
14284
+ *
14285
+ * ```
14286
+ * UpdateTable WarmThroughput={ReadUnitsPerSecond: 6000, WriteUnitsPerSecond: 2000}
14287
+ * ValidationException: One or more parameter values were invalid: Requested
14288
+ * ReadUnitsPerSecond for WarmThroughput for table is lower than current
14289
+ * WarmThroughput, decreasing WarmThroughput is not supported
14290
+ * UpdateTable WarmThroughput={ReadUnitsPerSecond: 6000} -> same rejection
14291
+ * UpdateTable WarmThroughput={WriteUnitsPerSecond: 2000} -> same rejection, naming WriteUnitsPerSecond
14292
+ * UpdateTable WarmThroughput={12000, 4000} (re-assert) -> ACCEPTED
14293
+ * ```
14294
+ *
14295
+ * So the value is one AWS raises with the table's traffic and never lowers,
14296
+ * and a decrease is REJECTED rather than accepted-and-ignored — which is what
14297
+ * the issue said to measure before choosing, because the two produce different
14298
+ * correct answers. The caller SKIPS the call on a true here.
14299
+ *
14300
+ * Rules, each one chosen so the skip is right for EVERY `update()` caller
14301
+ * (the deploy engine, `cdkd drift --revert`, and the rollback executor's two
14302
+ * revert arms — none of which can make AWS lower the value, so none of them
14303
+ * loses anything a doomed call would have achieved):
14304
+ *
14305
+ * - Only the members the desired side DECLARES are examined; an omitted member
14306
+ * is not sent and cannot be a decrease.
14307
+ * - EVERY declared member must be at-or-below its live counterpart AND at
14308
+ * least one strictly below. A MIXED request (one member up, one down) still
14309
+ * goes out: AWS rejects it by name, which is the pre-fix behavior and better
14310
+ * than silently dropping the increase the user asked for.
14311
+ * - Anything not resolvable to a finite number fails OPEN (the call is still
14312
+ * issued): the worst case is the pre-fix behavior, whereas a false positive
14313
+ * would silently drop a real increase. Only the LIVE side can actually reach
14314
+ * that arm now (PR review round 8): the desired side is a COERCED spec, so a
14315
+ * malformed template value or an unresolved intrinsic has already been
14316
+ * dropped by {@link coerceWarmThroughput} and cannot arrive here. The check
14317
+ * is kept for the live side, which is whatever `DescribeTable` returned.
14318
+ */
14319
+ function isWarmThroughputDecrease(desired, live) {
14320
+ if (live === void 0) return false;
14321
+ let sawDecrease = false;
14322
+ for (const member of ["ReadUnitsPerSecond", "WriteUnitsPerSecond"]) {
14323
+ if (desired[member] === void 0) continue;
14324
+ const wanted = capacityNumber(desired[member]);
14325
+ const current = capacityNumber(live[member]);
14326
+ if (wanted === void 0 || current === void 0) return false;
14327
+ if (wanted > current) return false;
14328
+ if (wanted < current) sawDecrease = true;
14329
+ }
14330
+ return sawDecrease;
14331
+ }
14332
+ /**
14333
+ * Does the warm throughput AWS currently holds already equal what this update
14334
+ * would request (issue #1768 PR review)?
14335
+ *
14336
+ * The warm-throughput twin of {@link liveCapacityAlreadyMatches}, and compared
14337
+ * the same way and for the same reason: the live side is a
14338
+ * `...WarmThroughputDescription` carrying an AWS-managed `Status` alongside the
14339
+ * two units, so a structural compare against the two-member desired object
14340
+ * could never match and the suppression would be dead code that only LOOKED
14341
+ * safe.
14342
+ *
14343
+ * Only the members the desired side DECLARES are compared — an omitted member
14344
+ * is not part of the request, so it cannot make the request differ. Anything
14345
+ * unresolvable fails OPEN (the call is still issued): the worst case is a
14346
+ * redundant `UpdateTable`, whereas a false MATCH silently drops a
14347
+ * warm-throughput raise the user asked for. As in
14348
+ * {@link isWarmThroughputDecrease}, only the LIVE side can reach that arm now
14349
+ * that the desired side is a coerced spec.
14350
+ *
14351
+ * Deliberately NOT applied to the TABLE-level branch, which still sends an
14352
+ * equal re-assert: that is measured-accepted by AWS (us-east-1, 2026-08-13 —
14353
+ * re-asserting `{12000, 4000}` returns a normal `TableDescription`), it is ONE
14354
+ * call rather than one per index, and its own change gate already requires the
14355
+ * value to differ from the recorded previous. The per-index path is where the
14356
+ * redundancy multiplies and where the carried #1767 residual reaches it.
14357
+ */
14358
+ function warmThroughputAlreadyMatches(desired, live) {
14359
+ if (live === void 0) return false;
14360
+ let compared = 0;
14361
+ for (const member of ["ReadUnitsPerSecond", "WriteUnitsPerSecond"]) {
14362
+ if (desired[member] === void 0) continue;
14363
+ const wanted = capacityNumber(desired[member]);
14364
+ const current = capacityNumber(live[member]);
14365
+ if (wanted === void 0 || current === void 0) return false;
14366
+ if (wanted !== current) return false;
14367
+ compared++;
14368
+ }
14369
+ return compared > 0;
14370
+ }
14371
+ /**
14372
+ * Reverse-map one live secondary-index description to its CloudFormation
14373
+ * shape (issue #1767).
14374
+ *
14375
+ * `DescribeTable` describes an index with a superset of the CFn members —
14376
+ * measured live, us-east-1 2026-08-13, on a `PAY_PER_REQUEST` table with one
14377
+ * GSI and no capacity settings:
14378
+ *
14379
+ * ```json
14380
+ * { "IndexName": "gsi1",
14381
+ * "KeySchema": [{ "AttributeName": "gsipk", "KeyType": "HASH" }],
14382
+ * "Projection": { "ProjectionType": "ALL" },
14383
+ * "IndexStatus": "CREATING", "Backfilling": true,
14384
+ * "ProvisionedThroughput": { "NumberOfDecreasesToday": 0, "ReadCapacityUnits": 0, "WriteCapacityUnits": 0 },
14385
+ * "IndexSizeBytes": 0, "ItemCount": 0,
14386
+ * "IndexArn": "arn:aws:dynamodb:...:table/.../index/gsi1",
14387
+ * "WarmThroughput": { "ReadUnitsPerSecond": 12000, "WriteUnitsPerSecond": 4000, "Status": "UPDATING" } }
14388
+ * ```
14389
+ *
14390
+ * NONE of `IndexStatus` / `Backfilling` / `IndexSizeBytes` / `ItemCount` /
14391
+ * `IndexArn` is a CFn property, the `ProvisionedThroughput` block is AWS's
14392
+ * `{0, 0}` on-demand placeholder (the #1571 trap), and the per-index
14393
+ * `WarmThroughput` is the AWS-computed value the table-level gate already
14394
+ * refuses to surface (issue #1760). Forwarding the description verbatim put
14395
+ * all of them into the drift comparison, and — unlike a top-level key the
14396
+ * baseline lacks — they are members of an ARRAY the baseline DOES carry, which
14397
+ * `deepEqual` compares positionally, so every one of them participated.
14398
+ *
14399
+ * The mapper is therefore an ALLOW-LIST rather than a deny-list: a member AWS
14400
+ * adds later is dropped by construction instead of silently joining the
14401
+ * comparison.
14402
+ *
14403
+ * `desired` is the DESIRED bag's entry for the SAME `IndexName` (matched by
14404
+ * name, never by position — the list is an unordered set, see
14405
+ * {@link DynamoDBTableProvider.getDriftUnorderedPaths}). The three throughput
14406
+ * blocks are emitted only when that entry declares them, the #1760 shape one
14407
+ * nesting level down; `bagInformative` false means the caller supplied no bag
14408
+ * to decide with and every block is emitted, cleaned of its AWS-managed
14409
+ * members.
14410
+ *
14411
+ * Known residual, unchanged by this fix: CFn's per-index
14412
+ * `ContributorInsightsSpecification` has no `DescribeTable` counterpart (it
14413
+ * needs a per-index `DescribeContributorInsights` call), so a template
14414
+ * declaring one still cannot converge against a `properties` baseline. It is
14415
+ * ALSO dropped on the write side, since `create()` forwards the CFn blob to
14416
+ * `CreateTable` and the SDK serializer discards the unknown member — still
14417
+ * true after round 6 taught that path to coerce `WarmThroughput`, because the
14418
+ * entry is rebuilt with a SPREAD and every other member, known or not, rides
14419
+ * along unchanged. Filed as issue
14420
+ * [#1782](https://github.com/go-to-k/cdkd/issues/1782).
14421
+ */
14422
+ function reverseMapSecondaryIndex(live, desired, bagInformative) {
14423
+ const out = {};
14424
+ if (live.IndexName !== void 0) out["IndexName"] = live.IndexName;
14425
+ if (live.KeySchema !== void 0) out["KeySchema"] = live.KeySchema.map((element) => ({ ...element }));
14426
+ if (live.Projection !== void 0) {
14427
+ const projection = {};
14428
+ if (live.Projection.ProjectionType !== void 0) projection["ProjectionType"] = live.Projection.ProjectionType;
14429
+ if (live.Projection.NonKeyAttributes !== void 0) projection["NonKeyAttributes"] = [...live.Projection.NonKeyAttributes];
14430
+ if (Object.keys(projection).length > 0) out["Projection"] = projection;
14431
+ }
14432
+ if ("ProvisionedThroughput" in live && indexDeclares(desired, "ProvisionedThroughput", bagInformative) && live.ProvisionedThroughput) {
14433
+ const provisioned = live.ProvisionedThroughput;
14434
+ const pt = {};
14435
+ if (provisioned.ReadCapacityUnits !== void 0) pt["ReadCapacityUnits"] = provisioned.ReadCapacityUnits;
14436
+ if (provisioned.WriteCapacityUnits !== void 0) pt["WriteCapacityUnits"] = provisioned.WriteCapacityUnits;
14437
+ if (Object.keys(pt).length > 0) out["ProvisionedThroughput"] = pt;
14438
+ }
14439
+ if ("OnDemandThroughput" in live && indexDeclares(desired, "OnDemandThroughput", bagInformative) && live.OnDemandThroughput) {
14440
+ const onDemand = live.OnDemandThroughput;
14441
+ const odt = {};
14442
+ if (onDemand.MaxReadRequestUnits !== void 0) odt["MaxReadRequestUnits"] = onDemand.MaxReadRequestUnits;
14443
+ if (onDemand.MaxWriteRequestUnits !== void 0) odt["MaxWriteRequestUnits"] = onDemand.MaxWriteRequestUnits;
14444
+ if (Object.keys(odt).length > 0) out["OnDemandThroughput"] = odt;
14445
+ }
14446
+ if ("WarmThroughput" in live && indexDeclares(desired, "WarmThroughput", bagInformative) && live.WarmThroughput) {
14447
+ const warm = live.WarmThroughput;
14448
+ const wt = {};
14449
+ if (warm.ReadUnitsPerSecond !== void 0) wt["ReadUnitsPerSecond"] = warm.ReadUnitsPerSecond;
14450
+ if (warm.WriteUnitsPerSecond !== void 0) wt["WriteUnitsPerSecond"] = warm.WriteUnitsPerSecond;
14451
+ if (Object.keys(wt).length > 0) out["WarmThroughput"] = wt;
14452
+ }
14453
+ return out;
14454
+ }
14455
+ /**
14456
+ * Does the DESIRED entry for one index declare this block (issue #1767)?
14457
+ *
14458
+ * The question is always "would cdkd SEND this block", because that is what
14459
+ * makes AWS's readback value explainable by the template — so each block is
14460
+ * answered by the write rule that actually governs it, never by a second
14461
+ * spelling of one:
14462
+ *
14463
+ * - `WarmThroughput` routes through {@link isSendableWarmThroughput}, the
14464
+ * predicate EVERY per-index write path calls — the `Create` / `Update` GSI
14465
+ * actions in `applyGsiUpdates` AND `create()`'s `CreateTable` forward, which
14466
+ * maps each entry through
14467
+ * {@link DynamoDBTableProvider.coerceIndexWarmThroughputForCreate}. It is
14468
+ * TIGHTER than truthiness (at least one member resolving to a finite
14469
+ * number), so `WarmThroughput: {}` is neither sent nor emitted on any path.
14470
+ * An earlier version of this bullet carved out the TABLE-create path as an
14471
+ * exception, and that exception was a DEFECT rather than a design: it also
14472
+ * put a quoted `'12000'` on the wire as a string in a Long field, so one
14473
+ * template failed on a fresh create and succeeded on a later GSI add. Fixed
14474
+ * in PR review round 6; the carve-out is gone because the divergence is.
14475
+ * - `ProvisionedThroughput` / `OnDemandThroughput` keep TRUTHINESS, which is
14476
+ * what their write path does: those two members ARE still forwarded verbatim
14477
+ * (the create-path mapper rewrites only `WarmThroughput`), and
14478
+ * `applyGsiUpdates` gates on `gsi.ProvisionedThroughput` alone. Widening
14479
+ * `isSendableWarmThroughput` over them would be wrong twice over — it reads
14480
+ * members those blocks do not have (`ReadUnitsPerSecond` vs
14481
+ * `ReadCapacityUnits`), so every declared capacity would read as undeclared.
14482
+ *
14483
+ * An UNINFORMATIVE bag answers TRUE for every block, keeping the pre-#1767
14484
+ * behavior for a caller that supplied nothing to decide with. A bag that IS
14485
+ * informative but names no entry for this index answers FALSE: the index
14486
+ * exists in AWS and not in the template, so nothing cdkd sent can explain any
14487
+ * of its throughput blocks.
14488
+ */
14489
+ function indexDeclares(desired, key, bagInformative) {
14490
+ if (!bagInformative) return true;
14491
+ const value = desired?.[key];
14492
+ return key === "WarmThroughput" ? isSendableWarmThroughput(value) : Boolean(value);
14493
+ }
14494
+ /**
14495
+ * Is this value an index list that DECLARES at least one index (issue #1767)?
14496
+ *
14497
+ * The ONE spelling of that question, shared by {@link desiredIndexEntriesByName}
14498
+ * (which decides what each live index is matched against) and by
14499
+ * {@link DynamoDBTableProvider.getDriftUnknownPaths} (which decides whether the
14500
+ * list is compared at all). Spelled independently they disagreed: a plain
14501
+ * truthiness test reads `[]` and an unresolved `{Fn::If: [...]}` as DECLARED —
14502
+ * so the list stayed in the comparison while the mapper, which needs an ARRAY
14503
+ * to match names against, treated the same value as declaring nothing and
14504
+ * stripped every throughput block from the readback. That is a one-sided
14505
+ * difference manufactured by the pair, which is the failure this file's
14506
+ * `isSendableWarmThroughput` header exists to prevent.
14507
+ *
14508
+ * Consequence worth stating: a template declaring `GlobalSecondaryIndexes: []`
14509
+ * now has that path IGNORED by the comparator, so an index created out of band
14510
+ * on such a table is not reported. That is the same answer the readback gives
14511
+ * (nothing the template declared can explain that index), and consistency
14512
+ * between the two is worth more than detection on a shape where the two used to
14513
+ * contradict each other.
14514
+ */
14515
+ function declaresIndexList(value) {
14516
+ return Array.isArray(value) && value.length > 0;
14517
+ }
14518
+ /**
14519
+ * Index the DESIRED bag's secondary-index list by `IndexName` (issue #1767).
14520
+ *
14521
+ * Returns an empty map for anything {@link declaresIndexList} rejects — an
14522
+ * empty array, an unresolved intrinsic, a mis-nested template value — which
14523
+ * routes every live index through {@link indexDeclares}'s "declares nothing"
14524
+ * arm. Sharing that predicate with `getDriftUnknownPaths` is what keeps the
14525
+ * emission and the comparison from answering differently for the same bag.
14526
+ */
14527
+ function desiredIndexEntriesByName(value) {
14528
+ const byName = /* @__PURE__ */ new Map();
14529
+ if (!declaresIndexList(value)) return byName;
14530
+ for (const entry of value) {
14531
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
14532
+ const name = entry["IndexName"];
14533
+ if (typeof name === "string") byName.set(name, entry);
14534
+ }
14535
+ return byName;
14138
14536
  }
14139
14537
  var DynamoDBTableProvider = class {
14140
14538
  dynamoDBClient;
@@ -14192,12 +14590,17 @@ var DynamoDBTableProvider = class {
14192
14590
  };
14193
14591
  }
14194
14592
  if (properties["OnDemandThroughput"]) createParams.OnDemandThroughput = properties["OnDemandThroughput"];
14195
- if (isSendableWarmThroughput(properties["WarmThroughput"])) createParams.WarmThroughput = properties["WarmThroughput"];
14593
+ const createWarmThroughput = this.coerceWarmThroughputForSend(`AWS::DynamoDB::Table ${logicalId}`, properties["WarmThroughput"]);
14594
+ if (createWarmThroughput) createParams.WarmThroughput = createWarmThroughput;
14595
+ else this.warnRefusedWarmThroughput(`AWS::DynamoDB::Table ${logicalId}`, properties["WarmThroughput"]);
14196
14596
  if (properties["StreamSpecification"]) createParams.StreamSpecification = {
14197
14597
  StreamEnabled: true,
14198
14598
  StreamViewType: properties["StreamSpecification"]["StreamViewType"]
14199
14599
  };
14200
- if (properties["GlobalSecondaryIndexes"]) createParams.GlobalSecondaryIndexes = properties["GlobalSecondaryIndexes"];
14600
+ if (properties["GlobalSecondaryIndexes"]) {
14601
+ const declaredGsis = properties["GlobalSecondaryIndexes"];
14602
+ createParams.GlobalSecondaryIndexes = Array.isArray(declaredGsis) ? declaredGsis.map((entry) => this.coerceIndexWarmThroughputForCreate(logicalId, entry)) : declaredGsis;
14603
+ }
14201
14604
  if (properties["LocalSecondaryIndexes"]) createParams.LocalSecondaryIndexes = properties["LocalSecondaryIndexes"];
14202
14605
  const sse = mapSSESpecification(properties["SSESpecification"]);
14203
14606
  if (sse && Object.keys(sse).length > 0) createParams.SSESpecification = sse;
@@ -14355,10 +14758,12 @@ var DynamoDBTableProvider = class {
14355
14758
  }
14356
14759
  }
14357
14760
  if (JSON.stringify(properties["WarmThroughput"]) !== JSON.stringify(previousProperties["WarmThroughput"])) {
14358
- if (isSendableWarmThroughput(properties["WarmThroughput"])) {
14761
+ this.warnRefusedWarmThroughput(`AWS::DynamoDB::Table ${logicalId}`, properties["WarmThroughput"]);
14762
+ const warmThroughputToSend = this.coerceWarmThroughputForSend(`AWS::DynamoDB::Table ${logicalId}`, properties["WarmThroughput"]);
14763
+ if (warmThroughputToSend !== void 0 && !this.skipWarmThroughputDecrease(logicalId, physicalId, warmThroughputToSend, table?.WarmThroughput)) {
14359
14764
  await this.dynamoDBClient.send(new UpdateTableCommand({
14360
14765
  TableName: physicalId,
14361
- WarmThroughput: properties["WarmThroughput"]
14766
+ WarmThroughput: warmThroughputToSend
14362
14767
  }));
14363
14768
  await this.waitForTableActiveAfterUpdate(physicalId);
14364
14769
  this.logger.debug(`Updated WarmThroughput on DynamoDB table ${physicalId}`);
@@ -14822,6 +15227,32 @@ var DynamoDBTableProvider = class {
14822
15227
  * between GSI mutations because a freshly-created index keeps backfilling
14823
15228
  * after the table itself returns to ACTIVE, and AWS rejects the next GSI op
14824
15229
  * until the prior index settles.
15230
+ *
15231
+ * `WarmThroughput.Status` is deliberately NOT part of the predicate, and that
15232
+ * is MEASURED rather than assumed (PR review of issue #1768 raised it as a
15233
+ * suspected race, since the per-index warm-throughput send path this provider
15234
+ * gained has no other reason to wait). Live, us-east-1 2026-08-13, on a
15235
+ * PAY_PER_REQUEST table with two ACTIVE GSIs: raising `gsi1`'s warm
15236
+ * throughput 12000/4000 -> 14000/5000 left
15237
+ *
15238
+ * ```
15239
+ * TableStatus: ACTIVE | gsi1: IndexStatus ACTIVE, WarmThroughput.Status UPDATING
15240
+ * | gsi2: IndexStatus ACTIVE, WarmThroughput.Status ACTIVE
15241
+ * ```
15242
+ *
15243
+ * for 90+ seconds — so this predicate IS satisfied while a warm update is
15244
+ * still settling, exactly as the review described. What does not follow is
15245
+ * the failure: with `gsi1` in that state, all three next-op shapes were
15246
+ * ACCEPTED, no `ResourceInUseException` —
15247
+ *
15248
+ * - a warm-throughput `Update` on the OTHER index (`gsi2`);
15249
+ * - a warm-throughput `Update` on the SAME index (`gsi1`);
15250
+ * - a GSI `Create` (`gsi3`), the op AWS gates most strictly.
15251
+ *
15252
+ * So warm throughput settles OUT OF BAND of the index lifecycle, and adding
15253
+ * it to this predicate would only make every GSI op wait on a status that
15254
+ * blocks nothing — minutes of wait budget spent per op, with a real risk of
15255
+ * exhausting it on a table whose warm status lingers.
14825
15256
  */
14826
15257
  async waitForTableAndIndexesActive(tableName, maxAttempts = 1800) {
14827
15258
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
@@ -14872,32 +15303,223 @@ var DynamoDBTableProvider = class {
14872
15303
  if (!gsi.KeySchema) throw new ProvisioningError(`GlobalSecondaryIndex ${name} on DynamoDB table ${logicalId} is missing KeySchema`, resourceType, logicalId, physicalId);
14873
15304
  if (live !== void 0 && live.IndexStatus !== "DELETING") {
14874
15305
  this.logger.warn(`GSI ${name} on DynamoDB table ${physicalId} already exists in AWS but is absent from cdkd's recorded previous state, so its Create is being skipped. This usually means an earlier deploy created the index and then failed before state was written.`);
14875
- if (liveCapacityComparable && gsi.ProvisionedThroughput && !handledByBillingFlip.has(name) && !liveCapacityAlreadyMatches(live.ProvisionedThroughput, gsi.ProvisionedThroughput)) ops.push({ Update: {
14876
- IndexName: name,
14877
- ProvisionedThroughput: gsi.ProvisionedThroughput
14878
- } });
15306
+ const adopted = { IndexName: name };
15307
+ let adoptedHasMember = false;
15308
+ if (liveCapacityComparable && gsi.ProvisionedThroughput && !handledByBillingFlip.has(name) && !liveCapacityAlreadyMatches(live.ProvisionedThroughput, gsi.ProvisionedThroughput) && !this.skipZeroCapacityIndexUpdate(name, physicalId, gsi.ProvisionedThroughput)) {
15309
+ adopted.ProvisionedThroughput = gsi.ProvisionedThroughput;
15310
+ adoptedHasMember = true;
15311
+ }
15312
+ const adoptedWarm = this.warmThroughputOpFor(name, physicalId, gsi, void 0, live);
15313
+ if (adoptedWarm) {
15314
+ adopted.WarmThroughput = adoptedWarm;
15315
+ adoptedHasMember = true;
15316
+ }
15317
+ if (adoptedHasMember) ops.push({ Update: adopted });
14879
15318
  this.logger.warn(`GSI ${name} was adopted from AWS rather than created, so cdkd could not verify its KeySchema / Projection match the template. Run \`cdkd drift ${physicalId}\` to confirm, and \`cdkd drift --accept\` if AWS is the source of truth.`);
14880
15319
  continue;
14881
15320
  }
15321
+ this.warnRefusedWarmThroughput(`GSI ${name} on DynamoDB table ${physicalId}`, gsi.WarmThroughput);
15322
+ const createWarm = this.coerceWarmThroughputForSend(`GSI ${name} on DynamoDB table ${physicalId}`, gsi.WarmThroughput);
14882
15323
  ops.push({ Create: {
14883
15324
  IndexName: name,
14884
15325
  KeySchema: gsi.KeySchema,
14885
15326
  Projection: gsi.Projection,
14886
15327
  ...gsi.ProvisionedThroughput ? { ProvisionedThroughput: gsi.ProvisionedThroughput } : {},
14887
- ...gsi.OnDemandThroughput ? { OnDemandThroughput: gsi.OnDemandThroughput } : {}
15328
+ ...gsi.OnDemandThroughput ? { OnDemandThroughput: gsi.OnDemandThroughput } : {},
15329
+ ...createWarm ? { WarmThroughput: createWarm } : {}
14888
15330
  } });
14889
15331
  } else {
14890
15332
  const before = prevByName.get(name);
14891
15333
  if (JSON.stringify(before.KeySchema) !== JSON.stringify(gsi.KeySchema) || JSON.stringify(before.Projection) !== JSON.stringify(gsi.Projection)) throw new ProvisioningError(`GlobalSecondaryIndex ${name} on DynamoDB table ${logicalId} changed its KeySchema or Projection, which DynamoDB cannot modify in place. Rename the index (so it is dropped and re-created) or replace the table.`, resourceType, logicalId, physicalId);
14892
- if (gsi.ProvisionedThroughput && !handledByBillingFlip.has(name) && JSON.stringify(before.ProvisionedThroughput) !== JSON.stringify(gsi.ProvisionedThroughput)) if (liveCapacityComparable && liveCapacityAlreadyMatches(liveIndexByName?.get(name)?.ProvisionedThroughput, gsi.ProvisionedThroughput)) this.logger.debug(`GSI ${name} on DynamoDB table ${physicalId} already carries the requested capacity in AWS; skipping its throughput Update`);
14893
- else ops.push({ Update: {
14894
- IndexName: name,
14895
- ProvisionedThroughput: gsi.ProvisionedThroughput
14896
- } });
15334
+ const update = { IndexName: name };
15335
+ let updateHasMember = false;
15336
+ if (gsi.ProvisionedThroughput && !handledByBillingFlip.has(name) && JSON.stringify(before.ProvisionedThroughput) !== JSON.stringify(gsi.ProvisionedThroughput)) {
15337
+ if (liveCapacityComparable && liveCapacityAlreadyMatches(liveIndexByName?.get(name)?.ProvisionedThroughput, gsi.ProvisionedThroughput)) this.logger.debug(`GSI ${name} on DynamoDB table ${physicalId} already carries the requested capacity in AWS; skipping its throughput Update`);
15338
+ else if (!this.skipZeroCapacityIndexUpdate(name, physicalId, gsi.ProvisionedThroughput)) {
15339
+ update.ProvisionedThroughput = gsi.ProvisionedThroughput;
15340
+ updateHasMember = true;
15341
+ }
15342
+ }
15343
+ const warm = this.warmThroughputOpFor(name, physicalId, gsi, before, liveIndexByName?.get(name));
15344
+ if (warm) {
15345
+ update.WarmThroughput = warm;
15346
+ updateHasMember = true;
15347
+ }
15348
+ if (updateHasMember) ops.push({ Update: update });
14897
15349
  }
14898
15350
  await this.runGsiOps(physicalId, ops, desiredAttributeDefinitions);
14899
15351
  }
14900
15352
  /**
15353
+ * The NUMERIC `WarmThroughput` spec to put on the wire, announcing any member
15354
+ * that had to be dropped (PR review round 5).
15355
+ *
15356
+ * The single send-side entry point for all four write sites, so a template's
15357
+ * quoted `'12000'` reaches AWS as `12000` rather than as a string in a Long
15358
+ * field. Returns `undefined` for a value {@link isSendableWarmThroughput}
15359
+ * refuses — the caller's existing refusal warning covers that case, and the
15360
+ * two messages are disjoint by construction: this one fires only when
15361
+ * something DID resolve.
15362
+ */
15363
+ coerceWarmThroughputForSend(scope, value) {
15364
+ const coerced = coerceWarmThroughput(value);
15365
+ if (coerced === void 0) return void 0;
15366
+ if (coerced.dropped.length > 0) this.logger.warn(`${scope}: WarmThroughput member(s) ${coerced.dropped.join(", ")} in ${JSON.stringify(value)} are not a number DynamoDB accepts, so they were dropped from the request, which leaves ${JSON.stringify(coerced.spec)}. Check for an unresolved intrinsic or a non-numeric value.`);
15367
+ return coerced.spec;
15368
+ }
15369
+ /**
15370
+ * One `CreateTable` index entry with its `WarmThroughput` coerced (PR review
15371
+ * round 6).
15372
+ *
15373
+ * `create()` forwards the declared `GlobalSecondaryIndexes` array to
15374
+ * `CreateTable`, so it is a SEND SITE for the same per-index value the
15375
+ * update path coerces — and it was the one the round-5 sweep missed. Entries
15376
+ * are rebuilt rather than mutated: the input bag belongs to the caller (the
15377
+ * resolved template, which the engine also records into state), and a
15378
+ * provider that edits it in place would change what state reports cdkd sent.
15379
+ *
15380
+ * An entry that is not a plain object, or that declares no `WarmThroughput`,
15381
+ * is returned UNCHANGED — including the identity of the object, so the
15382
+ * common case allocates nothing.
15383
+ */
15384
+ coerceIndexWarmThroughputForCreate(logicalId, entry) {
15385
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return entry;
15386
+ const declared = entry["WarmThroughput"];
15387
+ if (declared === void 0) return entry;
15388
+ const scope = `GSI ${entry.IndexName ?? "<unnamed>"} on AWS::DynamoDB::Table ${logicalId}`;
15389
+ const coerced = this.coerceWarmThroughputForSend(scope, declared);
15390
+ if (coerced === void 0) {
15391
+ this.warnRefusedWarmThroughput(scope, declared);
15392
+ const { WarmThroughput: _dropped, ...rest } = entry;
15393
+ return rest;
15394
+ }
15395
+ return {
15396
+ ...entry,
15397
+ WarmThroughput: coerced
15398
+ };
15399
+ }
15400
+ /**
15401
+ * Announce a `WarmThroughput` the send rule REFUSES (PR review of issue
15402
+ * #1768) — the missing half of tightening {@link isSendableWarmThroughput}.
15403
+ *
15404
+ * Called at all FOUR write sites (`create()`, the table-level `update()`
15405
+ * branch, the GSI `Create` action, and {@link warmThroughputOpFor}) because
15406
+ * the value vanishes at each of them independently. `scope` names the
15407
+ * resource or the index so a table with several GSIs says WHICH one.
15408
+ *
15409
+ * Silent for an ABSENT or FALSY value — see {@link isRefusedWarmThroughput}
15410
+ * for why those two are not this warning's business.
15411
+ */
15412
+ warnRefusedWarmThroughput(scope, value) {
15413
+ if (!isRefusedWarmThroughput(value)) return;
15414
+ this.logger.warn(`${scope}: the declared WarmThroughput ${JSON.stringify(value)} carries no usable ReadUnitsPerSecond or WriteUnitsPerSecond, so it was NOT sent — DynamoDB accepts only those two members and rejects a request without either. Check for a misspelled member name or an unresolved intrinsic. Nothing else on this resource was affected.`);
15415
+ }
15416
+ /**
15417
+ * Should this per-index capacity `Update` be SKIPPED because the value is
15418
+ * AWS's on-demand `{0, 0}` placeholder rather than a capacity anyone asked
15419
+ * for (issue #1767 review)?
15420
+ *
15421
+ * Newly reachable BECAUSE of the #1767 readback change, on the transition
15422
+ * residual `getDriftUnknownPaths` deliberately carries. A pre-#1767
15423
+ * `observedProperties` baseline holds the whole `DescribeTable` index
15424
+ * description, including the `{NumberOfDecreasesToday: 0, ReadCapacityUnits:
15425
+ * 0, WriteCapacityUnits: 0}` block AWS reports for every index of a
15426
+ * PAY_PER_REQUEST table (the #1571 trap). `cdkd drift --revert` hands that
15427
+ * blob to `update()` as the DESIRED side against the trimmed readback as the
15428
+ * previous side, so the values differ, `liveCapacityComparable` is false on
15429
+ * PAY_PER_REQUEST (which disables the #1630 idempotency skip), and the op
15430
+ * that goes out asks AWS for capacity 0 — which AWS rejects, since the
15431
+ * minimum is 1. Before #1767 both sides carried the same blob, compared
15432
+ * equal, and no revert was ever offered.
15433
+ *
15434
+ * Deliberately NOT gated on the live billing mode. A `{0, 0}` request is
15435
+ * refused by AWS in EITHER mode, so gating would leave the same doomed call
15436
+ * reachable for a PROVISIONED table whose stale baseline carries the
15437
+ * placeholder. Scoped tightly to BOTH members resolving to 0: any other
15438
+ * capacity — including a half-declared or malformed one — still goes out and
15439
+ * is answered by AWS, the fail-OPEN direction this file uses everywhere.
15440
+ *
15441
+ * CALLER-BLIND, decided rather than overlooked (PR review). A TEMPLATE-borne
15442
+ * `{0, 0}` warn-skips here while `create()` fails loudly on the same value,
15443
+ * which reads like the "loud failure for a quiet lie" trade the adopted-index
15444
+ * arm below forbids. It stands for three reasons:
15445
+ *
15446
+ * - The discriminator that EXISTS does not separate the callers that matter.
15447
+ * `ResourceProvider.update` DOES take an optional `UpdateContext` — an
15448
+ * earlier draft of this comment claimed the signature carries no context
15449
+ * at all, which is simply false (`s3-bucket-provider.ts` consumes it
15450
+ * today) — but its one field, `desiredFromAwsReadback`, is set ONLY by
15451
+ * `cdkd drift --revert` (`src/cli/commands/drift.ts`). The rollback
15452
+ * executor's revert arms deliberately pass NO context: their desired bag
15453
+ * is `previousState.properties`, a TEMPLATE recorded earlier, so they are
15454
+ * indistinguishable from an ordinary template deploy — and THAT is the
15455
+ * pair this decision turns on. Knowing the value came from a readback
15456
+ * would not license throwing on the other two.
15457
+ * - With the caller unknowable, the repo's rule for the UPDATE path is
15458
+ * WARN-never-throw (issues #1545 / #1552): the desired bag here can BE a
15459
+ * historical cdkd state record, and a refusal would make the table
15460
+ * un-updatable and un-rollbackable with no template-side remedy.
15461
+ * - The lie is bounded and self-surfacing. AWS never accepted `{0, 0}` at
15462
+ * create either, so no live table can be holding it; and where the value
15463
+ * would actually matter — a PROVISIONED table, whose indexes hold a real
15464
+ * capacity — recording `{0, 0}` diverges from the readback and `cdkd drift`
15465
+ * REPORTS it. On a PAY_PER_REQUEST table the recorded `{0, 0}` matches what
15466
+ * `DescribeTable` reports for every index anyway, so there is no divergence
15467
+ * to hide.
15468
+ */
15469
+ skipZeroCapacityIndexUpdate(indexName, physicalId, requested) {
15470
+ if (requested === void 0) return false;
15471
+ if (capacityNumber(requested.ReadCapacityUnits) !== 0 || capacityNumber(requested.WriteCapacityUnits) !== 0) return false;
15472
+ 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.`);
15473
+ return true;
15474
+ }
15475
+ /**
15476
+ * The per-index `WarmThroughput` this update should send, or `undefined`
15477
+ * (issue #1768, one nesting level down from the table-level branch).
15478
+ *
15479
+ * `applyGsiUpdates` used to send only `ProvisionedThroughput` /
15480
+ * `OnDemandThroughput` on both of its arms, while `readCurrentState` emits a
15481
+ * declared per-index `WarmThroughput` — so a template declaring one had it
15482
+ * silently dropped on every index add / change, `cdkd drift` reported the
15483
+ * difference forever, and `--revert` emitted no op at all and exited 0
15484
+ * claiming success. That silent-success shape is worse than the loud
15485
+ * table-level dead end #1768 fixed.
15486
+ *
15487
+ * The rules mirror the table-level branch exactly:
15488
+ * - only a value the write side would SEND at all
15489
+ * ({@link isSendableWarmThroughput});
15490
+ * - only when it CHANGED against the recorded previous entry (an absent
15491
+ * previous — the adopted-index arm — is treated as changed, since nothing
15492
+ * was recorded to compare with);
15493
+ * - never a DECREASE. Measured live (us-east-1, 2026-08-13) on a GSI AWS
15494
+ * reports `{12000, 4000}` for: a `WarmThroughput`-only `Update` action
15495
+ * with `{12000, 4000}` is ACCEPTED (so the action shape needs no
15496
+ * `ProvisionedThroughput` companion), while `{6000, 2000}` is rejected
15497
+ * with `Requested ReadUnitsPerSecond for WarmThroughput for index gsi1 is
15498
+ * lower than current WarmThroughput, decreasing WarmThroughput is not
15499
+ * supported`.
15500
+ */
15501
+ warmThroughputOpFor(indexName, physicalId, desired, previous, live) {
15502
+ const requested = desired.WarmThroughput;
15503
+ if (previous !== void 0 && JSON.stringify(previous.WarmThroughput) === JSON.stringify(requested)) return;
15504
+ const sendable = this.coerceWarmThroughputForSend(`GSI ${indexName} on DynamoDB table ${physicalId}`, requested);
15505
+ if (sendable === void 0) {
15506
+ this.warnRefusedWarmThroughput(`GSI ${indexName} on DynamoDB table ${physicalId}`, requested);
15507
+ return;
15508
+ }
15509
+ if (warmThroughputAlreadyMatches(sendable, live?.WarmThroughput)) {
15510
+ this.logger.debug(`GSI ${indexName} on DynamoDB table ${physicalId} already carries the requested warm throughput in AWS; skipping its WarmThroughput Update`);
15511
+ return;
15512
+ }
15513
+ if (isWarmThroughputDecrease(sendable, live?.WarmThroughput)) {
15514
+ this.logger.warn(`GSI ${indexName} on DynamoDB table ${physicalId}: the requested WarmThroughput ${JSON.stringify(sendable)} is lower than the ${JSON.stringify({
15515
+ ReadUnitsPerSecond: live?.WarmThroughput?.ReadUnitsPerSecond,
15516
+ WriteUnitsPerSecond: live?.WarmThroughput?.WriteUnitsPerSecond
15517
+ })} DynamoDB currently holds for that index. Warm throughput only ever RISES with an index's traffic and cannot be lowered — AWS rejects the UpdateTable with "decreasing WarmThroughput is not supported" — so it was NOT sent and every other change on this table still applied. On a deploy this warning may be the only signal you get: a successful deploy re-captures AWS's current value as the drift baseline. If your template declares this index's WarmThroughput, set it to the value AWS holds (or remove it, which AWS treats as keeping the last-set value); if it does not, the value came from a cdkd state record and 'cdkd deploy' (or 'cdkd drift --accept') refreshes it.`);
15518
+ return;
15519
+ }
15520
+ return sendable;
15521
+ }
15522
+ /**
14901
15523
  * Issue a list of `GlobalSecondaryIndexUpdates` ops, one per `UpdateTable`,
14902
15524
  * waiting for the table AND every index to return to ACTIVE between each.
14903
15525
  *
@@ -14947,6 +15569,33 @@ var DynamoDBTableProvider = class {
14947
15569
  }
14948
15570
  }
14949
15571
  /**
15572
+ * Should this update SKIP its `WarmThroughput` call because the value would
15573
+ * DECREASE what AWS already holds (issue #1768)?
15574
+ *
15575
+ * Thin logging wrapper over the pure {@link isWarmThroughputDecrease}, kept
15576
+ * on the class so the warning is emitted at exactly the one site that skips.
15577
+ *
15578
+ * The warning is what makes the skip honest, and its WORDING is checked
15579
+ * against the code rather than assumed (PR review): the first version
15580
+ * promised `cdkd drift` would "keep reporting the difference", which is TRUE
15581
+ * on the `drift --revert` path — nothing is recorded, so the observed
15582
+ * baseline still holds the declared value — and FALSE on the DEPLOY path.
15583
+ * A successful deploy re-captures `observedProperties` through this same
15584
+ * `readCurrentState` (`deploy-engine.ts`'s `kickOffObservedCapture`), which
15585
+ * for a DECLARING template emits AWS's CURRENT value, so the very deploy
15586
+ * that warned equalises the comparison and the next `cdkd drift` reports the
15587
+ * resource clean. The message therefore says the warning may be the only
15588
+ * signal, and names both numbers so the template edit needs no second look.
15589
+ */
15590
+ skipWarmThroughputDecrease(logicalId, physicalId, desired, live) {
15591
+ if (!isWarmThroughputDecrease(desired, live)) return false;
15592
+ this.logger.warn(`AWS::DynamoDB::Table ${logicalId}: the requested WarmThroughput ${JSON.stringify(desired)} is lower than the ${JSON.stringify({
15593
+ ReadUnitsPerSecond: live?.ReadUnitsPerSecond,
15594
+ WriteUnitsPerSecond: live?.WriteUnitsPerSecond
15595
+ })} DynamoDB currently holds for table ${physicalId}. Warm throughput only ever RISES with a table's traffic and cannot be lowered — AWS rejects the UpdateTable with "decreasing WarmThroughput is not supported" — so it was NOT sent and every other change on this resource still applied. On a deploy this warning may be the only signal you get: a successful deploy re-captures AWS's current value as the drift baseline, so the next 'cdkd drift' reports this table clean (a 'cdkd drift --revert' records nothing and keeps reporting it). To make cdkd and AWS agree, set WarmThroughput to the value AWS holds (or remove the property, which AWS treats as keeping the last-set value).`);
15596
+ return true;
15597
+ }
15598
+ /**
14950
15599
  * `AttributeDefinitions` is a SET, keyed by `AttributeName`: CFn accepts the
14951
15600
  * members in any order and `DescribeTable` returns them in an order matching
14952
15601
  * neither the request nor the template. Measured live (us-east-1,
@@ -14966,6 +15615,29 @@ var DynamoDBTableProvider = class {
14966
15615
  * `KeySchema` is deliberately NOT declared, at the table level or the index
14967
15616
  * level: it is order-SIGNIFICANT (HASH before RANGE), so sorting it would
14968
15617
  * HIDE a real key change rather than remove a phantom one.
15618
+ *
15619
+ * `GlobalSecondaryIndexes` / `LocalSecondaryIndexes` are NOT declared either,
15620
+ * and that is a MECHANISM limit rather than a judgement about the lists —
15621
+ * both really are sets keyed by `IndexName` (issue #1767 proposes declaring
15622
+ * them). Every entry here is a SUBTREE declaration, and unlike
15623
+ * `getDriftUnknownPaths` this walk DESCENDS INTO ARRAY ELEMENTS giving each
15624
+ * the parent's path (`drift-normalize.ts`), so a `'GlobalSecondaryIndexes'`
15625
+ * entry also reaches `GlobalSecondaryIndexes.KeySchema` and would sort the
15626
+ * per-index key schema — reversing the sentence above at the index level
15627
+ * only. Issue #1767 calls the sort and the member reverse-map separable;
15628
+ * this change ships the reverse-map, and the ordering half needed a
15629
+ * leaf-only form of the declaration first (issue
15630
+ * [#1783](https://github.com/go-to-k/cdkd/issues/1783)). **That form now
15631
+ * EXISTS** — `LEAF_ONLY_PATH_SUFFIX` (`[]`) landed in
15632
+ * [#1799](https://github.com/go-to-k/cdkd/pull/1799), so
15633
+ * `'GlobalSecondaryIndexes[]'` would claim the list alone without reaching
15634
+ * the per-index `KeySchema`. It is deliberately NOT declared here yet:
15635
+ * adopting it is its own change with its own real-AWS verification, tracked
15636
+ * as issue [#1812](https://github.com/go-to-k/cdkd/issues/1812). Consequence
15637
+ * until then, stated so it is not mistaken for solved: an index list AWS
15638
+ * returns in a different ORDER than the template declared is still phantom
15639
+ * drift against a `properties` baseline. It is not reachable on the ordinary
15640
+ * `observedProperties` path, where both sides come from this same readback.
14969
15641
  */
14970
15642
  getDriftUnorderedPaths(resourceType) {
14971
15643
  if (resourceType !== "AWS::DynamoDB::Table") return [];
@@ -15009,11 +15681,72 @@ var DynamoDBTableProvider = class {
15009
15681
  * `TlsConfig` (issue #1602): an absent / empty bag falls back to the
15010
15682
  * type-level answer of COMPARING, since hiding a real drift is the worse
15011
15683
  * failure.
15684
+ *
15685
+ * `GlobalSecondaryIndexes` / `LocalSecondaryIndexes` join it under the SAME
15686
+ * rule for the SAME reason (issue #1767), one nesting level up: the readback
15687
+ * now emits the reverse-mapped CFn shape, so every `observedProperties`
15688
+ * baseline an earlier binary wrote — which carries `IndexStatus` /
15689
+ * `ItemCount` / `IndexArn` / the on-demand `{0, 0}` placeholder / the
15690
+ * computed per-index `WarmThroughput` — no longer equals it. Ignoring the
15691
+ * path on both sides when the template declares NO index list covers the
15692
+ * transition and the steady state for that population, exactly as the
15693
+ * `WarmThroughput` arm does: an index list the template never declared is
15694
+ * AWS-authored (created out of band, or by a sibling), which is the #1498
15695
+ * class.
15696
+ *
15697
+ * Known residual, deliberately NOT covered, because covering it costs more
15698
+ * than it buys: a table whose template DOES declare indexes keeps comparing,
15699
+ * so its already-written observed baseline reports a one-sided
15700
+ * `GlobalSecondaryIndexes` difference until the next deploy re-captures it
15701
+ * (or `cdkd drift --accept` does). `cdkd drift --revert` is the third thing a
15702
+ * user may reach for on that report, and for the shapes measured here it
15703
+ * applies NOTHING to AWS — which is a property of three separate skips, not
15704
+ * "by construction", and the distinction is load-bearing because this PR
15705
+ * briefly broke it. The index names match, so no Create / Delete is derived;
15706
+ * the stale blob's `{0, 0}` capacity is refused by
15707
+ * {@link skipZeroCapacityIndexUpdate}; and its per-index `WarmThroughput` —
15708
+ * which the same PR taught `applyGsiUpdates` to SEND (issue #1768), turning
15709
+ * an earlier "by construction" wording false the moment it landed — is
15710
+ * refused by {@link warmThroughputAlreadyMatches}, since a stale blob carries
15711
+ * exactly what AWS holds. Remove any of the three and the residual stops
15712
+ * being harmless: the revert starts issuing one redundant `UpdateTable` plus
15713
+ * a full index-ACTIVE wait per GSI, or a doomed one.
15714
+ *
15715
+ * "Applies nothing" is NOT the same as "reaches those skips" (PR review round
15716
+ * 5). `applyGsiUpdates` compares `KeySchema` / `Projection` between the
15717
+ * recorded previous and the desired entry BEFORE any of them and THROWS on a
15718
+ * difference, because DynamoDB cannot modify either in place. On this path
15719
+ * that compare is a trimmed readback against a stale full description, and
15720
+ * both members survive the #1767 reverse-map unchanged — `KeySchema`
15721
+ * verbatim, `Projection` rebuilt member-for-member — so for every shape
15722
+ * exercised here it matches and the throw does not fire. It is named because
15723
+ * it is the outcome an enumeration of skips would otherwise hide: a stale
15724
+ * blob whose `Projection` AWS has since re-normalized would fail the revert
15725
+ * loudly rather than no-op it. Suppressing the residual instead would mean ignoring the
15726
+ * whole array for the population that HAS indexes — i.e. never detecting an
15727
+ * out-of-band index add / remove / capacity change again, permanently, to
15728
+ * remove a one-time report. No PATH can express the middle ground:
15729
+ * `isIgnoredPath` is never asked about a path that crosses an array
15730
+ * (`diffAt` compares arrays wholesale via `deepEqual`), so a per-MEMBER
15731
+ * ignore path does not exist — the same wall issue #1742 records for the
15732
+ * `AWS::DynamoDB::GlobalTable` twin. **A non-path seam now EXISTS**:
15733
+ * `ResourceProvider.canonicalizeDriftProperties`, applied to BOTH comparison
15734
+ * sides, landed in [#1799](https://github.com/go-to-k/cdkd/pull/1799) (which
15735
+ * closed issue [#1784](https://github.com/go-to-k/cdkd/issues/1784)) and can
15736
+ * strip the AWS-managed member from each bag — converging an already-written
15737
+ * record with a post-fix readback, with no ignore-path and no lost
15738
+ * detection. It is deliberately NOT adopted here yet: doing so is its own
15739
+ * change with its own real-AWS verification, tracked as issue
15740
+ * [#1812](https://github.com/go-to-k/cdkd/issues/1812).
15012
15741
  */
15013
15742
  getDriftUnknownPaths(resourceType, properties) {
15014
15743
  if (resourceType !== "AWS::DynamoDB::Table") return [];
15015
- if (declaresWarmThroughput(properties)) return [];
15016
- return ["WarmThroughput"];
15744
+ const paths = [];
15745
+ if (!declaresWarmThroughput(properties)) paths.push("WarmThroughput");
15746
+ if (desiredBagIsInformative(properties)) {
15747
+ for (const key of ["GlobalSecondaryIndexes", "LocalSecondaryIndexes"]) if (properties !== void 0 && !declaresIndexList(properties[key])) paths.push(key);
15748
+ }
15749
+ return paths;
15017
15750
  }
15018
15751
  /**
15019
15752
  * Read the AWS-current DynamoDB table configuration in CFn-property shape.
@@ -15027,18 +15760,18 @@ var DynamoDBTableProvider = class {
15027
15760
  * - StreamSpecification's CFn shape includes only `StreamViewType`; the
15028
15761
  * API response carries `StreamEnabled` too. We surface both since the
15029
15762
  * drift comparator only descends into keys present in state.
15030
- * - GSI / LSI in the API response include `IndexStatus`, `ItemCount`,
15031
- * `IndexArn` and sizing fields cdkd never sets, and they are forwarded
15032
- * VERBATIM. This used to claim "the comparator filters them" — it does
15033
- * NOT, and issue #1767 quotes that sentence as the bug: the filter is
15034
- * `calculateResourceDrift`'s state-keys-only walk, which only reaches an
15035
- * absent TOP-LEVEL key. These are members of an array under a key the
15036
- * baseline DOES carry, and arrays compare positionally, so every
15037
- * AWS-managed member participates — `ItemCount` MOVES as data lands, so
15038
- * an in-use table with an index drifts against its own
15039
- * `observedProperties` on the ordinary path. Fixing it needs a full
15040
- * reverse-mapper plus the same migration companion this issue's
15041
- * `WarmThroughput` gate needed, hence a separate issue.
15763
+ * - GSI / LSI in the API response include `IndexStatus`, `Backfilling`,
15764
+ * `ItemCount`, `IndexArn` and sizing fields cdkd never sets. They used to
15765
+ * be forwarded VERBATIM under a comment claiming "the comparator filters
15766
+ * them" — it does NOT, and issue #1767 quotes that sentence as the bug:
15767
+ * the filter is `calculateResourceDrift`'s state-keys-only walk, which
15768
+ * only reaches an absent TOP-LEVEL key. These are members of an array
15769
+ * under a key the baseline DOES carry, and arrays compare positionally,
15770
+ * so every AWS-managed member participated — and `ItemCount` MOVES as
15771
+ * data lands, so an in-use table with any index drifted against its own
15772
+ * `observedProperties` on the ORDINARY path, on a schedule set by its own
15773
+ * write traffic. Each entry is now reverse-mapped to its CFn shape by
15774
+ * {@link reverseMapSecondaryIndex}.
15042
15775
  *
15043
15776
  * Returns `undefined` when the table is gone (`ResourceNotFoundException`).
15044
15777
  *
@@ -15050,8 +15783,10 @@ var DynamoDBTableProvider = class {
15050
15783
  * template carries them).
15051
15784
  *
15052
15785
  * `properties` is the DESIRED bag (the state-recorded template intent every
15053
- * caller passes) and is consulted for exactly one field, `WarmThroughput` —
15054
- * see {@link getDriftUnknownPaths} for the measurement and the reasoning.
15786
+ * caller passes). It is consulted for the table-level `WarmThroughput` — see
15787
+ * {@link getDriftUnknownPaths} for the measurement and the reasoning — and,
15788
+ * since issue #1767, for the per-index throughput blocks of each secondary
15789
+ * index, matched by `IndexName`.
15055
15790
  */
15056
15791
  async readCurrentState(physicalId, _logicalId, _resourceType, properties) {
15057
15792
  try {
@@ -15082,8 +15817,15 @@ var DynamoDBTableProvider = class {
15082
15817
  StreamEnabled: true,
15083
15818
  StreamViewType: table.StreamSpecification.StreamViewType
15084
15819
  };
15085
- if (table.GlobalSecondaryIndexes && table.GlobalSecondaryIndexes.length > 0) result["GlobalSecondaryIndexes"] = table.GlobalSecondaryIndexes;
15086
- if (table.LocalSecondaryIndexes && table.LocalSecondaryIndexes.length > 0) result["LocalSecondaryIndexes"] = table.LocalSecondaryIndexes;
15820
+ const bagInformative = desiredBagIsInformative(properties);
15821
+ if (table.GlobalSecondaryIndexes && table.GlobalSecondaryIndexes.length > 0) {
15822
+ const desiredByName = desiredIndexEntriesByName(properties?.["GlobalSecondaryIndexes"]);
15823
+ result["GlobalSecondaryIndexes"] = table.GlobalSecondaryIndexes.map((live) => reverseMapSecondaryIndex(live, live.IndexName === void 0 ? void 0 : desiredByName.get(live.IndexName), bagInformative));
15824
+ }
15825
+ if (table.LocalSecondaryIndexes && table.LocalSecondaryIndexes.length > 0) {
15826
+ const desiredByName = desiredIndexEntriesByName(properties?.["LocalSecondaryIndexes"]);
15827
+ result["LocalSecondaryIndexes"] = table.LocalSecondaryIndexes.map((live) => reverseMapSecondaryIndex(live, live.IndexName === void 0 ? void 0 : desiredByName.get(live.IndexName), bagInformative));
15828
+ }
15087
15829
  if (table.SSEDescription?.Status === "ENABLED") {
15088
15830
  const sse = { SSEEnabled: true };
15089
15831
  if (table.SSEDescription.KMSMasterKeyArn !== void 0) sse["KMSMasterKeyId"] = table.SSEDescription.KMSMasterKeyArn;
@@ -15182,6 +15924,23 @@ var DynamoDBTableProvider = class {
15182
15924
  //#endregion
15183
15925
  //#region src/provisioning/providers/dynamodb-globaltable-provider.ts
15184
15926
  /**
15927
+ * The CLOSED path table {@link DynamoDBGlobalTableProvider.canonicalizeDriftProperties}
15928
+ * strips from BOTH drift comparison sides (issue #1742).
15929
+ *
15930
+ * One entry today: the AWS-computed `GlobalSecondaryIndexes[].WarmThroughput`.
15931
+ * A table rather than an inline check so a second per-array-element member
15932
+ * lands as a row instead of a second walk — and so the scope is READABLE as a
15933
+ * list of paths, which is what "closed table" means here. Deliberately does
15934
+ * NOT include `LocalSecondaryIndexes` (this provider's readback assigns the
15935
+ * raw SDK descriptions there, a wider divergence than one member) or
15936
+ * `Replicas[].GlobalSecondaryIndexes` (whose entries carry only the read-half
15937
+ * throughput blocks — no `WarmThroughput` is emitted or accepted there).
15938
+ */
15939
+ const DRIFT_STRIPPED_INDEX_MEMBERS = [{
15940
+ listKey: "GlobalSecondaryIndexes",
15941
+ member: "WarmThroughput"
15942
+ }];
15943
+ /**
15185
15944
  * AWS DynamoDB GlobalTable Provider
15186
15945
  *
15187
15946
  * Implements resource provisioning for AWS::DynamoDB::GlobalTable using the
@@ -16635,7 +17394,12 @@ var DynamoDBGlobalTableProvider = class {
16635
17394
  const entry = { IndexName: gsi.IndexName };
16636
17395
  if (gsi.KeySchema) entry["KeySchema"] = gsi.KeySchema;
16637
17396
  if (gsi.Projection) entry["Projection"] = gsi.Projection;
16638
- if (gsi.WarmThroughput) entry["WarmThroughput"] = gsi.WarmThroughput;
17397
+ if (gsi.WarmThroughput) {
17398
+ const warm = {};
17399
+ if (gsi.WarmThroughput.ReadUnitsPerSecond !== void 0) warm["ReadUnitsPerSecond"] = gsi.WarmThroughput.ReadUnitsPerSecond;
17400
+ if (gsi.WarmThroughput.WriteUnitsPerSecond !== void 0) warm["WriteUnitsPerSecond"] = gsi.WarmThroughput.WriteUnitsPerSecond;
17401
+ if (Object.keys(warm).length > 0) entry["WarmThroughput"] = warm;
17402
+ }
16639
17403
  const replicaEntry = { IndexName: gsi.IndexName };
16640
17404
  if (billingMode === "PROVISIONED") {
16641
17405
  const writeAutoScaling = await this.readAutoScalingSettings(tableNameForSubs, "dynamodb:index:WriteCapacityUnits", void 0, gsi.IndexName);
@@ -16888,6 +17652,115 @@ var DynamoDBGlobalTableProvider = class {
16888
17652
  return ["AttributeDefinitions"];
16889
17653
  }
16890
17654
  /**
17655
+ * Strip the AWS-computed per-index `WarmThroughput` from BOTH drift
17656
+ * comparison sides (issue #1742 defect 2, on the #1784 seam).
17657
+ *
17658
+ * `DescribeTable` reports a `WarmThroughput` for EVERY index whether or not
17659
+ * the template asked for one — a default `{ReadUnitsPerSecond: 12000,
17660
+ * WriteUnitsPerSecond: 4000, Status: 'ACTIVE'}`, measured us-east-1
17661
+ * 2026-08-13 on the `rollback-replay-effective-props` fixture — so on a
17662
+ * `properties`-only baseline (what a reverse-replacement rollback leaves
17663
+ * behind, since `rollback-executor.ts` strips `observedProperties`) the
17664
+ * member is a permanent one-sided difference: `cdkd drift` reports the table
17665
+ * forever and `--revert` re-issues calls for it.
17666
+ *
17667
+ * Why this mechanism and not the two cheaper ones:
17668
+ *
17669
+ * - `getDriftUnknownPaths` (the #1760 answer for the sibling type's
17670
+ * TOP-LEVEL member) cannot express it. `calculateResourceDrift` compares
17671
+ * arrays wholesale, so `isIgnoredPath` is never asked about a path that
17672
+ * crosses one; the only expressible suppression is the WHOLE
17673
+ * `GlobalSecondaryIndexes` subtree, i.e. never detecting an index add /
17674
+ * remove / capacity change again — the #1420 stopgap already refused.
17675
+ * - Gating the READBACK emission on the desired side (this issue's own
17676
+ * second candidate answer) removes the phantom for the `properties`
17677
+ * baseline and CREATES one for the far larger `observedProperties`
17678
+ * population: every bag already in S3 was written by a binary that emitted
17679
+ * the computed member, so the first `cdkd drift` after upgrading compares
17680
+ * a baseline that HAS it against a readback that does not, and reports the
17681
+ * whole array on an untouched table. It does not self-heal — the observed
17682
+ * capture only runs on CREATE / UPDATE. That attempt was implemented,
17683
+ * reviewed and REVERTED; do not re-propose it.
17684
+ *
17685
+ * Stripping from BOTH sides is what converges the two populations at once: a
17686
+ * stale observed record and a fresh readback both lose the member, and a
17687
+ * `properties` baseline that never had it now faces an AWS side without it.
17688
+ *
17689
+ * ACCEPTED COST, stated because it is a real loss: a template that DECLARES
17690
+ * a per-index `WarmThroughput` no longer has changes to it REPORTED by
17691
+ * `cdkd drift`. The value is still SENT (`toSdkGlobalSecondaryIndexes`
17692
+ * forwards it on create and on a new index), so this is a detection gap, not
17693
+ * a delivery one, and it is bounded by what cdkd could do about a difference
17694
+ * anyway: warm throughput only ever GROWS on the AWS side and cannot be
17695
+ * lowered, so `--revert` would issue a decrease AWS rejects — the residual
17696
+ * issue #1768 records for the sibling type's declared arm. A per-index
17697
+ * declared-gate is not expressible here in any case: this hook sees ONE bag
17698
+ * with no `side` argument and no reference to the desired side, which is the
17699
+ * symmetry the #1784 contract requires and the reason one-sided
17700
+ * normalization is refused.
17701
+ *
17702
+ * Scope is a CLOSED table rather than a walk for any key named
17703
+ * `WarmThroughput`: `Replicas[].GlobalSecondaryIndexes[]` entries carry only
17704
+ * the read-half throughput blocks (no `WarmThroughput` is emitted or
17705
+ * accepted there), and a blanket strip would remove a member from a path
17706
+ * nobody measured. The table is a FLAT list of `{listKey, member}` pairs —
17707
+ * the type is hardcoded one line below, not a key of it — and nothing
17708
+ * mechanically fences it against `readCurrentState`: a future per-replica
17709
+ * `WarmThroughput` emission would desync silently, so a member added to the
17710
+ * readback needs a row added here in the same change.
17711
+ *
17712
+ * The `resourceType` guard is kept but is UNREACHABLE, and the earlier
17713
+ * claim that it followed the #1784 CC-API-fallback caveat was WRONG — that
17714
+ * caveat is about the BAG SHAPE when a type has no `readCurrentState`, and
17715
+ * never produces a foreign `resourceType`. `drift.ts` resolves the provider
17716
+ * by `(resourceType, provisionedBy)` and then passes that SAME type back in,
17717
+ * and this provider is registered for exactly one type, so the `!==` arm
17718
+ * cannot fire. It stays as a cheap shape guard for a future second
17719
+ * registration, not because anything routes a foreign type here.
17720
+ *
17721
+ * UNCOVERED POPULATION, stated because the fix does NOT reach it: a
17722
+ * cc-api-routed GlobalTable gets neither half. Top-level `WarmThroughput` is
17723
+ * a `silentDrop` property for this type, so a template declaring it
17724
+ * auto-routes the whole resource through Cloud Control (the #614 routing)
17725
+ * and the route is STICKY — `drift.ts` then resolves `provider` to
17726
+ * `CloudControlProvider`, this hook is never invoked, and the AWS side comes
17727
+ * from CC's `GetResource`. So the `properties`-only-baseline phantom drift
17728
+ * this closes survives there. Pre-existing, not introduced here.
17729
+ *
17730
+ * Note the trigger is the TOP-LEVEL `WarmThroughput`, NOT the per-index
17731
+ * member this strips: a template declaring only the per-index member keeps
17732
+ * `GlobalSecondaryIndexes` (a handled property), stays SDK-routed, and IS
17733
+ * covered. Do not read this bound as "warm-throughput templates are
17734
+ * uncovered" — an earlier draft of this comment said so and was wrong.
17735
+ *
17736
+ * Pure, non-mutating, and identity-returning when nothing applies: the input
17737
+ * bag is the caller's state record / readback, an unaffected table pays
17738
+ * nothing, and only the index entries that actually carry the member are
17739
+ * rebuilt.
17740
+ */
17741
+ canonicalizeDriftProperties(resourceType, properties) {
17742
+ if (resourceType !== "AWS::DynamoDB::GlobalTable") return properties;
17743
+ let result = properties;
17744
+ for (const { listKey, member } of DRIFT_STRIPPED_INDEX_MEMBERS) {
17745
+ const list = result[listKey];
17746
+ if (!Array.isArray(list)) continue;
17747
+ let stripped = false;
17748
+ const canonical = list.map((element) => {
17749
+ const entry = asRecord$1(element);
17750
+ if (!entry || !(member in entry)) return element;
17751
+ stripped = true;
17752
+ const copy = { ...entry };
17753
+ delete copy[member];
17754
+ return copy;
17755
+ });
17756
+ if (stripped) result = {
17757
+ ...result,
17758
+ [listKey]: canonical
17759
+ };
17760
+ }
17761
+ return result;
17762
+ }
17763
+ /**
16891
17764
  * Adopt an existing DynamoDB GlobalTable into cdkd state.
16892
17765
  *
16893
17766
  * Lookup order:
@@ -77189,7 +78062,7 @@ function createMigrateCommand() {
77189
78062
  */
77190
78063
  function buildProgram() {
77191
78064
  const program = new Command();
77192
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.283.8");
78065
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.283.10");
77193
78066
  program.hook("preAction", (_thisCommand, actionCommand) => {
77194
78067
  const { profile } = actionCommand.optsWithGlobals();
77195
78068
  if (profile !== void 0) process.env["AWS_PROFILE"] = profile;