@go-to-k/cdkd 0.283.9 → 0.283.11

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 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-CWt7h7Nc.js";
2
+ import { $ as coerceCfnBoolean, $t as resolveStateBucketWithDefault, A as cyan, An as MissingCdkCliError, At as AssetModeResolver, B as findSilentDropProperties, Bt as getDockerCmd, C as makeCanonicalizePropertiesFn, Ct as stringifyValue, D as renderStatefulReason, Dn as LocalMigrateError, Dt as loadPublishableAssetManifest, E as isStatefulRecreateTargetSync, En as LocalInvokeBuildError$1, Et as createAssetRedirectResolver, F as IAMRoleProvider, Fn as ResourceUpdateNotSupportedError, Ft as validateAssetBucketName, G as IntrinsicFunctionResolver, Gn as isRetryableTransientError, Gt as Synthesizer, H as slowCcOperationTimeoutMs, Hn as normalizeAwsError, Ht as runDockerStreaming, I as collectInlinePolicyNamesManagedBySiblings, In as StackHasActiveImportsError, It as validateContainerRepoName, J as refStateLookupFromResource, Jt as getLegacyStateBucketName, K as cfnRefValueFromPhysicalId, Kn as isThrottlingError, Kt as synthesisStatusMessage, L as clearOnUpdateRemoval, Ln as StackTerminationProtectionError, Lt as buildDenyExternalAccessPolicy, M as green, Mn as PartialFailureError, Mt as ensureAssetStorage, N as red, Nn as ProvisioningError, Nt as getBootstrapMarkerKey, O as formatResourceLine, On as LocalStartServiceError, Ot as rewriteTemplateAssetReferences, P as yellow, Pn as ResourceTimeoutError, Pt as parseBootstrapMarker, Q as assertRegionMatch, Qt as resolveSkipPrefix, R as ProviderRegistry, Rt as buildDockerImage, S as unsupportedFinalSnapshotError, Sn as CdkdError, St as AssetPublisher, T as MULTI_REGION_RECREATE_BLOCKED_TYPES, Tn as DeployCancelledError, Tt as buildAssetRedirectMap, U as disableInstanceApiTermination, Un as withErrorHandling, Ut as AssetManifestLoader, V as CloudControlProvider, Vt as runDockerForeground, W as isTerminationProtectionPropagationError, Wn as isMarkedNonRetryable, Wt as getDockerImageBySourceHash, X as normalizeAwsTagsToCfn, Xt as resolveAutoAssetStorage, Y as WAFv2WebACLProvider, Yt as resolveApp, Z as resolveExplicitPhysicalId, Zt as resolveCaptureObservedState, _ as buildFinalSnapshotIdentifier, _n as AwsClients, _t as TemplateParser, a as DeploymentEventsStore, an as CFN_TEMPLATE_URL_LIMIT, at as requireConfigObject, b as isFinalSnapshotError, bn as setAwsClients, bt as rebuildClientForBucketRegion, c as replayFailedOperations, cn as uploadCfnTemplate, ct as s3BucketDomainName, d as deleteSkipReason, dn as canonicalizeRegion, dt as s3BucketWebsiteUrl, en as resolveStateBucketWithDefaultAndSource, et as configBooleanRefusal, f as withResourceDeadline, fn as derivePartitionAndUrlSuffix, ft as applyRoleArnIfSet, g as PRE_DELETE_SNAPSHOT_TYPES, gn as resolveBucketRegion, gt as DagBuilder, h as ATOMIC_FINAL_SNAPSHOT_TYPES, ht as withRetry, i as DeploymentEventsReader, in as CFN_TEMPLATE_BODY_LIMIT, it as requireConfigArray, j as gray, jn as NestedStackChildDirectDestroyError, jt as BOOTSTRAP_MARKER_PREFIX, k as bold, kt as escapeRegExp, l as replayRollback, ln as expectedOwnerParam, lt as s3BucketDualStackDomainName, m as computeImplicitDeleteEdges, mn as processStackMessages, mt as describeTypeWithThrottleRetry, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as stateBucketExistenceConfirmed, nt as readConfigString, o as planFailedOps, on as MIGRATE_TMP_PREFIX, ot as requireConfigString, p as IMPLICIT_DELETE_DEPENDENCIES, pn as AssemblyReader, pt as DiffCalculator, q as getAccountInfo, qn as markNonRetryable, qt as getDefaultStateBucketName, r as DeployEngine, rn as warnDeprecatedNoPrefixCliFlag, rt as replayWarn, s as planRollback, sn as findLargeInlineResources, st as s3BucketArn, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as resolveUseCdkBootstrapAssets, tt as configStringRefusal, u as UNSPECIFIED_SKIP_REASON, un as PARTITION_TABLE, ut as s3BucketRegionalDomainName, v as ccRoutedFinalSnapshotError, vn as getAwsClients, vt as LockManager, w as extractDeploymentEventError, wt as WorkGraph, x as refusesFinalSnapshot, xt as shouldRetainResource, y as createPreDeleteFinalSnapshot, yn as resetAwsClients, yt as S3StateBackend, z as findActionableSilentDrops, zt as formatDockerLoginError } from "./deploy-engine-DculbwzR.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-D5ZBSgh2.js";
4
+ import { t as ASGProvider } from "./asg-provider-C7weaxqp.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;
@@ -65044,6 +65786,363 @@ function createEventsPruneCommand() {
65044
65786
  })).option("--older-than <duration>", "Delete runs older than this duration (e.g. 24h, 90m)").option("--all", "Delete every recorded run and the index (full purge)", false).action(withErrorHandling((stack, _options, command) => eventsPruneCommand(stack, command.optsWithGlobals())));
65045
65787
  }
65046
65788
 
65789
+ //#endregion
65790
+ //#region src/utils/ecr-uri.ts
65791
+ /**
65792
+ * The ECR registry-host FORM TABLE — the ONE place the host forms AWS serves are
65793
+ * spelled (issue #1793).
65794
+ *
65795
+ * The forms used to be spelled TWICE, here and in `src/cli/commands/gc.ts`,
65796
+ * and the two already disagreed: gc carried the `-fips` and `on.aws` forms
65797
+ * while this module matched only the plain one, so a genuine FIPS or dual-stack
65798
+ * registry was not recognized here AT ALL and `cdkd local invoke` /
65799
+ * `run-task` classified it as a public image (anonymous pull, no
65800
+ * `docker login`).
65801
+ *
65802
+ * **What is shared is this TABLE, not the whole matcher** — read that precisely,
65803
+ * because the unification is deliberately PARTIAL. gc builds its pattern from
65804
+ * the table through {@link ecrRegistryHostPattern}; this module's own
65805
+ * {@link ECR_URI_HOST_REGEX} re-spells the alternation from the table's LABELS
65806
+ * column and cannot use that builder at all (its doc says why). So the shared
65807
+ * fact is which label runs AWS serves and which of them carry a FIXED suffix —
65808
+ * which is exactly what had drifted — while each matcher keeps its own
65809
+ * acceptance rule, deliberately: gc matches the suffix against the union over
65810
+ * every partition and {@link parseEcrRegistryHost} pairs it WITH the region (see
65811
+ * `gc.ts`'s `AWS_URL_SUFFIXES` doc for why the two strictnesses must not merge).
65812
+ *
65813
+ * A THIRD, looser spelling of "this is an ECR registry host" still lives outside
65814
+ * this table — `isCdkAssetImageUri`'s `host.includes('.dkr.ecr.')` in
65815
+ * `src/local/ecs-task-resolver.ts`, which recognizes only the plain form and is
65816
+ * case-SENSITIVE. It is tracked as issue #1846 rather than folded in here: it
65817
+ * must keep tolerating an UNRESOLVED host
65818
+ * (`${AWS::AccountId}.dkr.ecr.${AWS::Region}.${AWS::URLSuffix}`), so it cannot
65819
+ * simply become a call to either matcher.
65820
+ *
65821
+ * Every row is read off the AWS-published `ecr` endpoint list
65822
+ * (https://docs.aws.amazon.com/general/latest/gr/ecr.html) plus the docker
65823
+ * push / pull examples in the ECR user guide's "Making requests to Amazon ECR
65824
+ * registries", rather than inferred:
65825
+ *
65826
+ * - `<acct>.dkr.ecr.<region>.<urlSuffix>` — the IPv4-only registry endpoint,
65827
+ * every partition.
65828
+ * - `<acct>.dkr.ecr-fips.<region>.<urlSuffix>` — the FIPS IPv4 endpoint, served
65829
+ * in `us-east-1` / `us-east-2` / `us-west-1` / `us-west-2` /
65830
+ * `us-gov-east-1` / `us-gov-west-1` — i.e. only in partitions whose suffix is
65831
+ * `amazonaws.com`, which is why pairing it with the region's own partition
65832
+ * suffix is exactly right rather than merely convenient.
65833
+ * - `<acct>.dkr-ecr.<region>.on.aws` — the dual-stack (IPv4 + IPv6) endpoint.
65834
+ * - `<acct>.dkr-ecr-fips.<region>.on.aws` — the dual-stack FIPS endpoint, in the
65835
+ * same six regions as the IPv4 FIPS form. This one was missing from BOTH
65836
+ * copies of the grammar; unifying them is what surfaced it.
65837
+ *
65838
+ * The two dual-stack forms carry a FIXED suffix, so their check is that literal
65839
+ * rather than the region's partition suffix — the TIGHTEST available check for
65840
+ * them, not a relaxation: `on.aws` is an AWS-owned domain, so unlike a captured
65841
+ * suffix it cannot be substituted by a host someone else owns. They are
65842
+ * accepted for ANY region rather than only the partitions AWS documents them
65843
+ * in, deliberately: under-accepting is precisely the issue #1764 failure (a
65844
+ * cdkd table lagging AWS made a GENUINE registry classify as public), and the
65845
+ * suffix being AWS-owned means the widening cannot point a `docker login`
65846
+ * anywhere but AWS.
65847
+ */
65848
+ const ECR_REGISTRY_HOST_FORMS = [
65849
+ { labels: "dkr.ecr" },
65850
+ { labels: "dkr.ecr-fips" },
65851
+ {
65852
+ labels: "dkr-ecr",
65853
+ fixedUrlSuffix: "on.aws"
65854
+ },
65855
+ {
65856
+ labels: "dkr-ecr-fips",
65857
+ fixedUrlSuffix: "on.aws"
65858
+ }
65859
+ ];
65860
+ /**
65861
+ * {@link ECR_REGISTRY_HOST_FORMS} ordered LONGEST LABELS FIRST, so
65862
+ * `dkr.ecr-fips` is tried before its own `dkr.ecr` prefix instead of relying on
65863
+ * backtracking.
65864
+ */
65865
+ const ECR_HOST_FORMS_LONGEST_FIRST = [...ECR_REGISTRY_HOST_FORMS].sort((a, b) => b.labels.length - a.labels.length);
65866
+ /**
65867
+ * {@link ECR_REGISTRY_HOST_FORMS} keyed by its (already lower-case) labels, for
65868
+ * resolving which suffix rule the matched form wants.
65869
+ */
65870
+ const ECR_HOST_FORM_BY_LABELS = new Map(ECR_REGISTRY_HOST_FORMS.map((form) => [form.labels, form]));
65871
+ /**
65872
+ * The host half of an ECR image URI as a regex SOURCE string, built from
65873
+ * {@link ECR_REGISTRY_HOST_FORMS} (issue #1793).
65874
+ *
65875
+ * ONE consumer: `cdkd gc`, which passes loose non-capturing sub-patterns and
65876
+ * matches the suffix against the union over every partition.
65877
+ *
65878
+ * **This module does NOT call it**, and that is structural rather than an
65879
+ * oversight — {@link ECR_URI_HOST_REGEX} re-spells the alternation from the
65880
+ * table's LABELS column instead. The strict matcher needs the suffix CAPTURED,
65881
+ * at a FIXED group index, to pair it with the region; this builder inlines a
65882
+ * `fixedUrlSuffix` as an UNCAPTURED literal, so the capturing-group count would
65883
+ * differ per alternative and the group index of the suffix would depend on which
65884
+ * form matched. Making the fixed suffix capturing to even the count is not the
65885
+ * fix either: it would hand gc a group it does not want, ahead of the tag /
65886
+ * digest groups it reads BY POSITION. So the sharable unit is the table, not the
65887
+ * pattern — and the cross-matcher test in `tests/unit/cli/gc.test.ts` is driven
65888
+ * off the table, so a row added there must work on BOTH sides or the suite reds.
65889
+ *
65890
+ * Every group this function ADDS is non-capturing, and that is load-bearing
65891
+ * rather than stylistic, for the same by-position reason: `gc.ts` reads its ECR
65892
+ * tag / digest by GROUP INDEX, and a capturing group here would silently shift
65893
+ * them. The caller's own three sub-patterns are interpolated verbatim, so
65894
+ * whether THOSE capture is the caller's business.
65895
+ */
65896
+ function ecrRegistryHostPattern(segments) {
65897
+ const forms = ECR_HOST_FORMS_LONGEST_FIRST.map((form) => {
65898
+ const suffix = form.fixedUrlSuffix === void 0 ? segments.partitionUrlSuffix : escapeRegExp(form.fixedUrlSuffix);
65899
+ return `${escapeRegExp(form.labels)}\\.${segments.region}\\.${suffix}`;
65900
+ });
65901
+ return `${segments.accountId}\\.(?:${forms.join("|")})`;
65902
+ }
65903
+ /**
65904
+ * Matching the HOST half of an ECR image URI:
65905
+ * `<acct>.<labels>.<region>.<urlSuffix>/`.
65906
+ *
65907
+ * The suffix is CAPTURED rather than spelled out (issue #1758): the previous
65908
+ * pattern hardcoded `amazonaws.com` with an optional `.cn` tail, so a `us-iso*`
65909
+ * registry (`c2s.ic.gov` / `sc2s.sgov.gov`) never matched and the caller
65910
+ * silently classified a real ECR image as a user-managed one — skipping the
65911
+ * `docker login` it needs.
65912
+ *
65913
+ * The LABELS are captured too, because which suffix is expected depends on
65914
+ * WHICH form matched (issue #1793) — the dual-stack forms carry a fixed
65915
+ * `on.aws` rather than the region's partition suffix.
65916
+ *
65917
+ * Built from {@link ECR_REGISTRY_HOST_FORMS}'s LABELS column rather than through
65918
+ * {@link ecrRegistryHostPattern}: the builder cannot serve this matcher's fixed
65919
+ * capture-group layout (see its doc). The table is what the two matchers share.
65920
+ *
65921
+ * The `i` flag makes the labels case-INSENSITIVE (issue #1792). DNS is
65922
+ * case-insensitive, so `<acct>.DKR.ECR.<region>.<suffix>` names the same host,
65923
+ * but the labels were spelled as case-sensitive literals — and unlike the
65924
+ * issue #1786 defect one layer over, the host then did not match the SHAPE at
65925
+ * all, so `parseEcrRegistryHost` returned `undefined` AND
65926
+ * {@link looksLikeEcrHostWithForeignSuffix} returned `false`, i.e. not even the
65927
+ * issue #1764 diagnostic fired.
65928
+ *
65929
+ * Widening the shape is the right fix rather than a refusal because DOCKER
65930
+ * accepts an upper-cased registry host, so cdkd is not made to `docker login`
65931
+ * for a URI docker will then reject. `distribution/reference`'s grammar spells
65932
+ * `domain-component` as `([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])`
65933
+ * while the repository PATH is `alpha-numeric := [a-z0-9]+`, and
65934
+ * `ParseNormalizedNamed` raises `repository name must be lowercase` for the
65935
+ * REMOTE NAME only — `splitDockerDomain` performs no case folding on the
65936
+ * domain. This change never touches the repository path.
65937
+ *
65938
+ * What docker does NOT fold is its CREDENTIAL STORE, which is keyed on the
65939
+ * hostname verbatim (measured in issue #1801: a pull of an upper-cased host
65940
+ * sent no credentials at all, while the lower-cased spelling authenticated).
65941
+ * That is why the fix here is only the SHAPE match: the login / pull spelling
65942
+ * is reconciled one layer up by `ecr-puller.ts`'s `canonicalizeImageUriHost`,
65943
+ * which every docker-facing consumer already routes through. Issue #1817
65944
+ * tracks moving that fold in here.
65945
+ *
65946
+ * **Every verdict issues #1792 / #1793 changed, in full** — `p` is
65947
+ * {@link parseEcrRegistryHost}, `d` is
65948
+ * {@link looksLikeEcrHostWithForeignSuffix} (the issue #1764 diagnostic), `S`
65949
+ * is the region's own partition suffix and `F` a foreign one. The table is
65950
+ * spelled out because the DIAGNOSTIC-ONLY rows are the easy ones to omit: `p`
65951
+ * is unchanged there, so a summary that tracks only the parse verdict reads as
65952
+ * "no change" while a warning that never used to fire now does.
65953
+ *
65954
+ * ```text
65955
+ * host before after what changed
65956
+ * <a>.dkr.ecr.<r>.S p=ok d=f p=ok d=f nothing
65957
+ * <a>.dkr.ecr.<r>.F p=und d=TRUE p=und d=TRUE nothing
65958
+ * <a>.dkr.ecr.<r>.on.aws p=und d=TRUE p=und d=TRUE nothing (*)
65959
+ * <a>.DKR.ECR.<r>.S p=und d=f p=ok d=f #1792 PARSE
65960
+ * <a>.DKR.ECR.<r>.F p=und d=f p=und d=TRUE #1792 diag only
65961
+ * <a>.dkr.ecr-fips.<r>.S p=und d=f p=ok d=f #1793 PARSE
65962
+ * <a>.dkr.ecr-fips.<r>.F p=und d=f p=und d=TRUE #1793 diag only
65963
+ * <a>.dkr-ecr.<r>.on.aws p=und d=f p=ok d=f #1793 PARSE
65964
+ * <a>.dkr-ecr-fips.<r>.on.aws p=und d=f p=ok d=f #1793 PARSE
65965
+ * <a>.dkr-ecr.<r>.S p=und d=f p=und d=TRUE #1793 diag only
65966
+ * <a>.dkr-ecr-fips.<r>.S p=und d=f p=und d=TRUE #1793 diag only
65967
+ * <a>.dkr.ecr-fips.<r>.on.aws p=und d=f p=und d=TRUE #1793 diag only
65968
+ * ```
65969
+ *
65970
+ * (*) `dkr.ecr` + `on.aws` is the one mispairing that ALREADY reported `true`
65971
+ * before the change: the pre-#1793 pattern matched the plain labels against a
65972
+ * wildcard `([^/]+)` suffix, so `on.aws` was simply a suffix that is not
65973
+ * `amazonaws.com`. Every other row that flips `d` was previously refused at the
65974
+ * SHAPE, which is why both verdicts were quiet.
65975
+ *
65976
+ * The five `d`-only flips are an improvement in kind — a host AWS does not serve
65977
+ * now says so instead of passing silently as a public image — but the caller's
65978
+ * MESSAGE was written for the #1764 partition-gap case and now over-claims for a
65979
+ * form/suffix mispairing (`amazonaws.com` IS `us-east-1`'s partition suffix).
65980
+ * `src/local/ecs-task-resolver.ts` owns that wording; recorded on issue #1846.
65981
+ */
65982
+ const ECR_URI_HOST_REGEX = new RegExp(`^(\\d{12})\\.(${ECR_HOST_FORMS_LONGEST_FIRST.map((form) => escapeRegExp(form.labels)).join("|")})\\.([^.]+)\\.([^/]+)/`, "i");
65983
+ /**
65984
+ * A region segment cdkd is willing to treat as a region id.
65985
+ *
65986
+ * Case is not the only way the `startsWith` partition classification can be
65987
+ * side-stepped: any leading junk defeats it the same way, and the captured
65988
+ * segment does not stay inert — it becomes an `ECRClient({region})` and is
65989
+ * interpolated into the fallback `docker login` endpoint. Measured before this
65990
+ * guard: `…dkr.ecr. us-iso-east-1.amazonaws.com/…` (leading space) and a
65991
+ * combining-mark form both parsed, yielding regions `" us-iso-east-1"` and
65992
+ * `"us-i̇so-east-1"`. Neither leaks credentials — an ACCEPTED suffix is always
65993
+ * an AWS-owned domain — but a region id is ASCII alphanumeric plus `-`, so
65994
+ * anything else is a malformed host and refusing it keeps the classification
65995
+ * honest.
65996
+ *
65997
+ * TESTED AGAINST THE RAW SEGMENT, BEFORE `toLowerCase()`, and that order is the
65998
+ * whole point: `String.prototype.toLowerCase` performs full Unicode case
65999
+ * folding, so the Kelvin sign U+212A folds to a plain ASCII `k`. Checking the
66000
+ * FOLDED segment therefore accepts `us-eKst-1` and yields the region
66001
+ * `us-ekst-1` — a region the host does not name — which is precisely the
66002
+ * class of substitution this guard exists to refuse. Matching `[A-Za-z0-9-]`
66003
+ * on the raw capture keeps case-insensitivity (the point of issue #1786) while
66004
+ * admitting only characters that fold to themselves.
66005
+ *
66006
+ * ONE DELIBERATE SIDE EFFECT, called out because it is a withdrawal rather than
66007
+ * an addition: a malformed-region host is now classified as an ordinary public
66008
+ * image with NO cdkd-side signal at all — neither the ECR verdict nor the #1764
66009
+ * foreign-suffix diagnostic. Both arms change, not just the foreign one:
66010
+ * `<acct>.dkr.ecr.us_east_1.example.com/…` used to log the partition-gap
66011
+ * warning and now logs nothing, and `<acct>.dkr.ecr.us_east_1.amazonaws.com/…`
66012
+ * used to be ACCEPTED as ECR and is now refused. That is the correct verdict
66013
+ * rather than a regression — the diagnostic's whole subject is "this suffix does
66014
+ * not belong to its region's PARTITION", and a segment that is not a region id
66015
+ * has no partition to belong to, so reporting it would send the reader hunting
66016
+ * for a table entry that could never exist — but it does mean a typo'd region
66017
+ * gets no cdkd-side hint, only docker's own pull failure. Both arms are pinned
66018
+ * by tests so a future widening of the guard cannot flip them silently.
66019
+ */
66020
+ const CANONICAL_REGION_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9-]*$/;
66021
+ /**
66022
+ * A URL suffix cdkd is willing to compare against the partition table.
66023
+ *
66024
+ * The SAME raw-capture rule as the region, and for the same reason one layer
66025
+ * over: the suffix is folded before the comparison, so a code point folding
66026
+ * into ASCII can impersonate a partition's suffix. This became reachable the
66027
+ * moment issue #1764 landed `aws-isoe`'s `cloud.adc-e.uk` — that suffix
66028
+ * contains a `k`, so `…dkr.ecr.eu-isoe-west-1.cloud.adc-e.uK/…` (Kelvin
66029
+ * sign) folded onto it and was ACCEPTED. Not a credential leak, since UTS-46
66030
+ * maps U+212A to `k` too and the host resolves to the real AWS domain, but it
66031
+ * falsifies the invariant the region guard's comment above states, so both
66032
+ * captures are held to the same rule rather than only the one that was
66033
+ * measured first.
66034
+ *
66035
+ * Dots are admitted because a suffix is a dotted DNS name; every other
66036
+ * character is refused, so the accepted set stays exactly the partition
66037
+ * table's own literals.
66038
+ */
66039
+ const CANONICAL_URL_SUFFIX = /^[A-Za-z0-9][A-Za-z0-9.-]*$/;
66040
+ /**
66041
+ * The ONE case-normalization boundary of this module (issue #1786).
66042
+ *
66043
+ * DNS is case-INSENSITIVE, so `US-ISO-EAST-1.amazonaws.com` and
66044
+ * `us-iso-east-1.amazonaws.com` name the same host — but both halves of the
66045
+ * #1758 suffix check were case-SENSITIVE, in OPPOSITE directions:
66046
+ *
66047
+ * - an upper-cased REGION fails every `startsWith` prefix test in
66048
+ * `derivePartitionAndUrlSuffix`, so it falls through to the commercial
66049
+ * partition whose suffix is `amazonaws.com` — which is exactly what an ISO
66050
+ * look-alike host carries, so the strict check ACCEPTED it (the bypass);
66051
+ * - an upper-cased region or SUFFIX on a GENUINE host stopped matching its own
66052
+ * partition's suffix, so a real ECR registry was REJECTED and the caller
66053
+ * silently classified it as a user-managed image — the #1758 regression, one
66054
+ * layer up.
66055
+ *
66056
+ * The normalization lives HERE rather than inside `derivePartitionAndUrlSuffix`
66057
+ * because this is the boundary where an untrusted, DNS-shaped string enters
66058
+ * cdkd, and keeping it in ONE function means both exported entry points below —
66059
+ * and the suffix COMPARISON itself — cannot drift apart.
66060
+ *
66061
+ * This does NOT duplicate `canonicalizeRegion`, which issue #1795 added inside
66062
+ * `derivePartitionAndUrlSuffix` so the mapping folds its own input. The two
66063
+ * answer different questions and both are needed: that one makes the partition
66064
+ * LOOKUP case-insensitive for every caller, while these guards decide whether
66065
+ * the captured segments are a region id and a URL suffix AT ALL. Folding alone
66066
+ * cannot do that — folding is precisely what turns `us-eKst-1` into the
66067
+ * plausible `us-ekst-1` (see the guards' own comments), so a fold-only helper
66068
+ * makes the substitution easier to reach, not harder. Double-folding is a
66069
+ * no-op, which is why the redundant `toLowerCase()` calls below are left in
66070
+ * rather than removed: they keep this function the single answer for the
66071
+ * comparison even if the helper's behaviour changes again.
66072
+ *
66073
+ * The derived suffix is lower-cased too. Every entry in the partition table is
66074
+ * a lower-case literal today, so that is a no-op; it is there so this function
66075
+ * stays the single answer even if that table ever gains a mixed-case entry.
66076
+ */
66077
+ function matchEcrRegistryHost(imageUri) {
66078
+ const m = ECR_URI_HOST_REGEX.exec(imageUri);
66079
+ if (!m) return void 0;
66080
+ if (!CANONICAL_REGION_SEGMENT.test(m[3])) return void 0;
66081
+ if (!CANONICAL_URL_SUFFIX.test(m[4])) return void 0;
66082
+ const form = ECR_HOST_FORM_BY_LABELS.get(m[2].toLowerCase());
66083
+ if (!form) return void 0;
66084
+ const region = m[3].toLowerCase();
66085
+ return {
66086
+ accountId: m[1],
66087
+ region,
66088
+ suffix: m[4].toLowerCase(),
66089
+ expectedSuffix: (form.fixedUrlSuffix ?? derivePartitionAndUrlSuffix(region).urlSuffix).toLowerCase()
66090
+ };
66091
+ }
66092
+ /**
66093
+ * The account + region of an ECR image URI, or `undefined` when the URI is not
66094
+ * an ECR registry host — either because it does not have one of the shapes
66095
+ * {@link ECR_REGISTRY_HOST_FORMS} lists, or because its suffix is not the one
66096
+ * the matched form carries for the region it names.
66097
+ *
66098
+ * Lives in `src/utils/` rather than beside its first caller because it has TWO
66099
+ * consumers in different layers — `src/local/ecr-puller.ts` (which needs the
66100
+ * `:tag` tail too) and `src/local/ecs-task-resolver.ts` (which classifies an
66101
+ * image that may carry a digest or no tag at all). Before issue #1758 they each
66102
+ * carried their own copy of the hardcoded commercial pattern, and fixing only
66103
+ * the pull path left `cdkd local run-task` broken outside the commercial
66104
+ * partition; ONE definition is what stops them drifting apart again.
66105
+ *
66106
+ * The module is deliberately free of AWS SDK imports, which is what lets
66107
+ * `ecs-task-resolver.ts` consume it without breaking the invariant that module
66108
+ * documents about itself (it resolves secrets through a separate module for
66109
+ * exactly that reason).
66110
+ *
66111
+ * The returned `region` is LOWER-CASED (issue #1786) — it is consumed as an SDK
66112
+ * client region by `ecr-puller.ts` and as the `region` of a `kind: 'ecr'` image
66113
+ * by `ecs-task-resolver.ts`, and the canonical spelling of an AWS region id is
66114
+ * lower case.
66115
+ */
66116
+ function parseEcrRegistryHost(imageUri) {
66117
+ const m = matchEcrRegistryHost(imageUri);
66118
+ if (!m) return void 0;
66119
+ if (m.suffix !== m.expectedSuffix) return void 0;
66120
+ return {
66121
+ accountId: m.accountId,
66122
+ region: m.region
66123
+ };
66124
+ }
66125
+ /**
66126
+ * True when the URI has one of the ECR HOST SHAPES but carries a suffix that
66127
+ * form does not serve for the region it names — i.e. exactly the case
66128
+ * {@link parseEcrRegistryHost} rejects for a reason the caller may want to
66129
+ * report.
66130
+ *
66131
+ * Since issue #1793 that covers a form / suffix MISPAIRING too, not only a
66132
+ * foreign suffix: `<acct>.dkr-ecr.<region>.amazonaws.com` and
66133
+ * `<acct>.dkr.ecr.<region>.on.aws` each spell one form's labels with the
66134
+ * other's suffix, and AWS serves neither.
66135
+ *
66136
+ * The two rejections are worth telling apart at a call site that degrades
66137
+ * silently: a genuinely public image and a registry in a partition
66138
+ * `derivePartitionAndUrlSuffix` does not know yet (issue #1764) both come back
66139
+ * `undefined`, and only the second is a cdkd gap rather than a user choice.
66140
+ */
66141
+ function looksLikeEcrHostWithForeignSuffix(imageUri) {
66142
+ const m = matchEcrRegistryHost(imageUri);
66143
+ return m !== void 0 && m.suffix !== m.expectedSuffix;
66144
+ }
66145
+
65047
66146
  //#endregion
65048
66147
  //#region src/cli/commands/gc.ts
65049
66148
  /**
@@ -65103,10 +66202,6 @@ function parseOlderThan(value) {
65103
66202
  const multiplier = match[2] === "d" ? 1440 * 60 * 1e3 : 3600 * 1e3;
65104
66203
  return Math.round(num * multiplier);
65105
66204
  }
65106
- /** Escape a literal string for embedding in a RegExp. */
65107
- function escapeRegExp(value) {
65108
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
65109
- }
65110
66205
  /**
65111
66206
  * Characters that terminate an S3 key extracted from a URL-shaped string.
65112
66207
  * Whitespace / quotes never appear in cdkd's content-addressed asset keys
@@ -65144,30 +66239,50 @@ const GC_EXTRA_URL_SUFFIXES = [];
65144
66239
  */
65145
66240
  const URL_SUFFIX_ALTERNATION = `(?:${[...[.../* @__PURE__ */ new Set([...PARTITION_TABLE.map((row) => row.urlSuffix), ...GC_EXTRA_URL_SUFFIXES])]].sort((a, b) => b.length - a.length).map(escapeRegExp).join("|")})`;
65146
66241
  /**
65147
- * The HOST half of an ECR registry, as an alternation over the forms AWS
65148
- * serves: `<acct>.dkr.ecr.<region>.<urlSuffix>`, its FIPS sibling
65149
- * `<acct>.dkr.ecr-fips.<region>.<urlSuffix>`, and the short-form alias
65150
- * `<acct>.dkr-ecr.<region>.on.aws`.
65151
- *
65152
- * Kept DELIBERATELY separate from the S3 matchers' shared
65153
- * {@link URL_SUFFIX_ALTERNATION} rather than folded into it: `on.aws` is an
65154
- * ECR-only endpoint, so adding it to the shared suffix set would also make
65155
- * `https://<assetBucket>.s3.<region>.on.aws/<key>` a recognized S3 reference.
65156
- * Widening either matcher only ever over-PROTECTS, but the two services' host
65157
- * grammars genuinely differ and conflating them is how the next widening goes
65158
- * wrong.
66242
+ * The HOST half of an ECR registry, built from the ONE authoritative FORM TABLE
66243
+ * in `src/utils/ecr-uri.ts` (issue #1793) instead of spelled a second time here.
66244
+ *
66245
+ * That module owns which segment spellings AWS serves — the plain
66246
+ * `<acct>.dkr.ecr.<region>.<urlSuffix>`, its FIPS sibling
66247
+ * `<acct>.dkr.ecr-fips.<region>.<urlSuffix>`, and the two dual-stack forms
66248
+ * `<acct>.dkr-ecr[-fips].<region>.on.aws` — while each matcher keeps its own
66249
+ * suffix-ACCEPTANCE rule on top: gc passes the union over every partition
66250
+ * ({@link URL_SUFFIX_ALTERNATION}), whereas `parseEcrRegistryHost` pairs the
66251
+ * captured suffix WITH the region. See the {@link AWS_URL_SUFFIXES} doc for why
66252
+ * the two strictnesses are deliberate and must not be merged.
66253
+ *
66254
+ * gc is the ONLY consumer of {@link ecrRegistryHostPattern}: the strict matcher
66255
+ * there needs the suffix captured at a fixed group index and so re-spells the
66256
+ * alternation off the same table's labels column. What the two share is the
66257
+ * TABLE, not the pattern — enough to stop the FORMS drifting, which is what had
66258
+ * gone wrong, and the cross-matcher test in `tests/unit/cli/gc.test.ts` is driven
66259
+ * off the table so a row added there must work on both sides.
66260
+ *
66261
+ * Sharing the table immediately brought a form gc was MISSING:
66262
+ * `<acct>.dkr-ecr-fips.<region>.on.aws`, the dual-stack FIPS endpoint. A form
66263
+ * missing here reads a live image as unreferenced and DELETES it, so this is the
66264
+ * irreversible direction — which is the whole argument for one definition.
66265
+ *
66266
+ * `on.aws` still does not reach the S3 matchers, and now cannot: it is an
66267
+ * ECR-only endpoint, so folding it into the shared suffix set would also make
66268
+ * `https://<assetBucket>.s3.<region>.on.aws/<key>` a recognized S3 reference —
66269
+ * and in the form table a fixed suffix travels WITH its own form rather than
66270
+ * sitting in a shared set, so that separation is mechanical instead of
66271
+ * remembered.
65159
66272
  *
65160
66273
  * Account and region are matched loosely on purpose (see the extractor doc):
65161
66274
  * collecting a reference from another account's or region's URI can only KEEP
65162
- * more, never delete more — which is also why the two extra forms are worth
65163
- * carrying even though cdkd's own publisher writes the plain one. Issue #1793
65164
- * tracks deriving these host forms from ONE place shared with
65165
- * `src/utils/ecr-uri.ts` instead of two hand-kept copies — that module matches
65166
- * only the plain `dkr.ecr` form today, so the two already disagree.
66275
+ * more, never delete more — which is also why the extra forms are worth
66276
+ * carrying even though cdkd's own publisher writes the plain one.
65167
66277
  *
65168
- * Every group is non-capturing, for the {@link URL_SUFFIX_ALTERNATION} reason.
66278
+ * Every group is non-capturing, for the {@link URL_SUFFIX_ALTERNATION} reason;
66279
+ * {@link ecrRegistryHostPattern} guarantees it for the groups IT adds.
65169
66280
  */
65170
- const ECR_REGISTRY_HOST = `\\d{12}\\.(?:dkr\\.ecr(?:-fips)?\\.[a-z0-9-]+\\.${URL_SUFFIX_ALTERNATION}|dkr-ecr\\.[a-z0-9-]+\\.on\\.aws)`;
66281
+ const ECR_REGISTRY_HOST = ecrRegistryHostPattern({
66282
+ accountId: "\\d{12}",
66283
+ region: "[a-z0-9-]+",
66284
+ partitionUrlSuffix: URL_SUFFIX_ALTERNATION
66285
+ });
65171
66286
  /**
65172
66287
  * cdkd's publishers write content-addressed keys (`<sha256>.<ext>`). A
65173
66288
  * second, name-independent pass collects every such token from every
@@ -65225,7 +66340,7 @@ function buildReferenceExtractors(marker) {
65225
66340
  const s3UriRe = new RegExp(`s3://${bucket}/(${KEY_TERMINATORS}+)`, "g");
65226
66341
  const virtualHostedRe = new RegExp(`https://${bucket}\\.s3[^/\\s]*\\.${URL_SUFFIX_ALTERNATION}/(${KEY_TERMINATORS}+)`, "g");
65227
66342
  const pathStyleRe = new RegExp(`https://s3[^/\\s]*\\.${URL_SUFFIX_ALTERNATION}/${bucket}/(${KEY_TERMINATORS}+)`, "g");
65228
- const ecrRe = new RegExp(`${ECR_REGISTRY_HOST}/${repo}(?::([A-Za-z0-9_][A-Za-z0-9._-]{0,127}))?(?:@(sha256:[0-9a-f]{64}))?`, "g");
66343
+ const ecrRe = new RegExp(`${ECR_REGISTRY_HOST}/${repo}(?::([A-Za-z0-9_][A-Za-z0-9._-]{0,127}))?(?:@(sha256:[0-9a-f]{64}))?`, "gi");
65229
66344
  const extractOnce = (value, refs) => {
65230
66345
  for (const re of [
65231
66346
  s3UriRe,
@@ -65234,7 +66349,7 @@ function buildReferenceExtractors(marker) {
65234
66349
  ]) for (const match of value.matchAll(re)) if (match[1]) refs.s3Keys.add(match[1]);
65235
66350
  for (const match of value.matchAll(ecrRe)) {
65236
66351
  if (match[1]) refs.imageTags.add(match[1]);
65237
- if (match[2]) refs.imageDigests.add(match[2]);
66352
+ if (match[2]) refs.imageDigests.add(match[2].toLowerCase());
65238
66353
  }
65239
66354
  for (const match of value.matchAll(CONTENT_HASH_KEY_RE)) refs.s3Keys.add(match[0]);
65240
66355
  };
@@ -68441,183 +69556,6 @@ function notFoundError$1(target, stack, resources) {
68441
69556
  return new LocalInvokeResolutionError(msg.trimEnd());
68442
69557
  }
68443
69558
 
68444
- //#endregion
68445
- //#region src/utils/ecr-uri.ts
68446
- /**
68447
- * Matching the HOST half of an ECR image URI:
68448
- * `<acct>.dkr.ecr.<region>.<urlSuffix>/`.
68449
- *
68450
- * The suffix is CAPTURED rather than spelled out (issue #1758): the previous
68451
- * pattern hardcoded `amazonaws.com` with an optional `.cn` tail, so a `us-iso*`
68452
- * registry (`c2s.ic.gov` / `sc2s.sgov.gov`) never matched and the caller
68453
- * silently classified a real ECR image as a user-managed one — skipping the
68454
- * `docker login` it needs.
68455
- *
68456
- * KNOWN BOUND (issue #1792): the `dkr.ecr` LABELS are still matched
68457
- * case-sensitively, so `<acct>.DKR.ECR.<region>.<suffix>/` does not match at
68458
- * all. Audited deliberately while fixing issue #1786 rather than missed — that
68459
- * issue's normalization covers the captured region + suffix, i.e. the strict
68460
- * CHECK, whereas widening the shape MATCHER makes cdkd `docker login` to a
68461
- * host shape it previously skipped, which is a behavior expansion wanting its
68462
- * own decision. The failure direction here is the safe one: NO credentials are
68463
- * sent on any of the four call sites. Traced — `local run-task` and
68464
- * `local invoke-agentcore` fall through to an anonymous `docker pull` that
68465
- * fails, while `local invoke` and `local start-api` REFUSE before any pull.
68466
- */
68467
- const ECR_URI_HOST_REGEX = /^(\d{12})\.dkr\.ecr\.([^.]+)\.([^/]+)\//;
68468
- /**
68469
- * A region segment cdkd is willing to treat as a region id.
68470
- *
68471
- * Case is not the only way the `startsWith` partition classification can be
68472
- * side-stepped: any leading junk defeats it the same way, and the captured
68473
- * segment does not stay inert — it becomes an `ECRClient({region})` and is
68474
- * interpolated into the fallback `docker login` endpoint. Measured before this
68475
- * guard: `…dkr.ecr. us-iso-east-1.amazonaws.com/…` (leading space) and a
68476
- * combining-mark form both parsed, yielding regions `" us-iso-east-1"` and
68477
- * `"us-i̇so-east-1"`. Neither leaks credentials — an ACCEPTED suffix is always
68478
- * an AWS-owned domain — but a region id is ASCII alphanumeric plus `-`, so
68479
- * anything else is a malformed host and refusing it keeps the classification
68480
- * honest.
68481
- *
68482
- * TESTED AGAINST THE RAW SEGMENT, BEFORE `toLowerCase()`, and that order is the
68483
- * whole point: `String.prototype.toLowerCase` performs full Unicode case
68484
- * folding, so the Kelvin sign U+212A folds to a plain ASCII `k`. Checking the
68485
- * FOLDED segment therefore accepts `us-eKst-1` and yields the region
68486
- * `us-ekst-1` — a region the host does not name — which is precisely the
68487
- * class of substitution this guard exists to refuse. Matching `[A-Za-z0-9-]`
68488
- * on the raw capture keeps case-insensitivity (the point of issue #1786) while
68489
- * admitting only characters that fold to themselves.
68490
- *
68491
- * ONE DELIBERATE SIDE EFFECT, called out because it is a withdrawal rather than
68492
- * an addition: a malformed-region host is now classified as an ordinary public
68493
- * image with NO cdkd-side signal at all — neither the ECR verdict nor the #1764
68494
- * foreign-suffix diagnostic. Both arms change, not just the foreign one:
68495
- * `<acct>.dkr.ecr.us_east_1.example.com/…` used to log the partition-gap
68496
- * warning and now logs nothing, and `<acct>.dkr.ecr.us_east_1.amazonaws.com/…`
68497
- * used to be ACCEPTED as ECR and is now refused. That is the correct verdict
68498
- * rather than a regression — the diagnostic's whole subject is "this suffix does
68499
- * not belong to its region's PARTITION", and a segment that is not a region id
68500
- * has no partition to belong to, so reporting it would send the reader hunting
68501
- * for a table entry that could never exist — but it does mean a typo'd region
68502
- * gets no cdkd-side hint, only docker's own pull failure. Both arms are pinned
68503
- * by tests so a future widening of the guard cannot flip them silently.
68504
- */
68505
- const CANONICAL_REGION_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9-]*$/;
68506
- /**
68507
- * A URL suffix cdkd is willing to compare against the partition table.
68508
- *
68509
- * The SAME raw-capture rule as the region, and for the same reason one layer
68510
- * over: the suffix is folded before the comparison, so a code point folding
68511
- * into ASCII can impersonate a partition's suffix. This became reachable the
68512
- * moment issue #1764 landed `aws-isoe`'s `cloud.adc-e.uk` — that suffix
68513
- * contains a `k`, so `…dkr.ecr.eu-isoe-west-1.cloud.adc-e.uK/…` (Kelvin
68514
- * sign) folded onto it and was ACCEPTED. Not a credential leak, since UTS-46
68515
- * maps U+212A to `k` too and the host resolves to the real AWS domain, but it
68516
- * falsifies the invariant the region guard's comment above states, so both
68517
- * captures are held to the same rule rather than only the one that was
68518
- * measured first.
68519
- *
68520
- * Dots are admitted because a suffix is a dotted DNS name; every other
68521
- * character is refused, so the accepted set stays exactly the partition
68522
- * table's own literals.
68523
- */
68524
- const CANONICAL_URL_SUFFIX = /^[A-Za-z0-9][A-Za-z0-9.-]*$/;
68525
- /**
68526
- * The ONE case-normalization boundary of this module (issue #1786).
68527
- *
68528
- * DNS is case-INSENSITIVE, so `US-ISO-EAST-1.amazonaws.com` and
68529
- * `us-iso-east-1.amazonaws.com` name the same host — but both halves of the
68530
- * #1758 suffix check were case-SENSITIVE, in OPPOSITE directions:
68531
- *
68532
- * - an upper-cased REGION fails every `startsWith` prefix test in
68533
- * `derivePartitionAndUrlSuffix`, so it falls through to the commercial
68534
- * partition whose suffix is `amazonaws.com` — which is exactly what an ISO
68535
- * look-alike host carries, so the strict check ACCEPTED it (the bypass);
68536
- * - an upper-cased region or SUFFIX on a GENUINE host stopped matching its own
68537
- * partition's suffix, so a real ECR registry was REJECTED and the caller
68538
- * silently classified it as a user-managed image — the #1758 regression, one
68539
- * layer up.
68540
- *
68541
- * The normalization lives HERE rather than inside `derivePartitionAndUrlSuffix`
68542
- * because this is the boundary where an untrusted, DNS-shaped string enters
68543
- * cdkd, and keeping it in ONE function means both exported entry points below —
68544
- * and the suffix COMPARISON itself — cannot drift apart.
68545
- *
68546
- * This does NOT duplicate `canonicalizeRegion`, which issue #1795 added inside
68547
- * `derivePartitionAndUrlSuffix` so the mapping folds its own input. The two
68548
- * answer different questions and both are needed: that one makes the partition
68549
- * LOOKUP case-insensitive for every caller, while these guards decide whether
68550
- * the captured segments are a region id and a URL suffix AT ALL. Folding alone
68551
- * cannot do that — folding is precisely what turns `us-eKst-1` into the
68552
- * plausible `us-ekst-1` (see the guards' own comments), so a fold-only helper
68553
- * makes the substitution easier to reach, not harder. Double-folding is a
68554
- * no-op, which is why the redundant `toLowerCase()` calls below are left in
68555
- * rather than removed: they keep this function the single answer for the
68556
- * comparison even if the helper's behaviour changes again.
68557
- *
68558
- * The derived suffix is lower-cased too. Every entry in the partition table is
68559
- * a lower-case literal today, so that is a no-op; it is there so this function
68560
- * stays the single answer even if that table ever gains a mixed-case entry.
68561
- */
68562
- function matchEcrRegistryHost(imageUri) {
68563
- const m = ECR_URI_HOST_REGEX.exec(imageUri);
68564
- if (!m) return void 0;
68565
- if (!CANONICAL_REGION_SEGMENT.test(m[2])) return void 0;
68566
- if (!CANONICAL_URL_SUFFIX.test(m[3])) return void 0;
68567
- const region = m[2].toLowerCase();
68568
- return {
68569
- accountId: m[1],
68570
- region,
68571
- suffix: m[3].toLowerCase(),
68572
- expectedSuffix: derivePartitionAndUrlSuffix(region).urlSuffix.toLowerCase()
68573
- };
68574
- }
68575
- /**
68576
- * The account + region of an ECR image URI, or `undefined` when the URI is not
68577
- * an ECR registry host for the region it names.
68578
- *
68579
- * Lives in `src/utils/` rather than beside its first caller because it has TWO
68580
- * consumers in different layers — `src/local/ecr-puller.ts` (which needs the
68581
- * `:tag` tail too) and `src/local/ecs-task-resolver.ts` (which classifies an
68582
- * image that may carry a digest or no tag at all). Before issue #1758 they each
68583
- * carried their own copy of the hardcoded commercial pattern, and fixing only
68584
- * the pull path left `cdkd local run-task` broken outside the commercial
68585
- * partition; ONE definition is what stops them drifting apart again.
68586
- *
68587
- * The module is deliberately free of AWS SDK imports, which is what lets
68588
- * `ecs-task-resolver.ts` consume it without breaking the invariant that module
68589
- * documents about itself (it resolves secrets through a separate module for
68590
- * exactly that reason).
68591
- *
68592
- * The returned `region` is LOWER-CASED (issue #1786) — it is consumed as an SDK
68593
- * client region by `ecr-puller.ts` and as the `region` of a `kind: 'ecr'` image
68594
- * by `ecs-task-resolver.ts`, and the canonical spelling of an AWS region id is
68595
- * lower case.
68596
- */
68597
- function parseEcrRegistryHost(imageUri) {
68598
- const m = matchEcrRegistryHost(imageUri);
68599
- if (!m) return void 0;
68600
- if (m.suffix !== m.expectedSuffix) return void 0;
68601
- return {
68602
- accountId: m.accountId,
68603
- region: m.region
68604
- };
68605
- }
68606
- /**
68607
- * True when the URI has the ECR HOST SHAPE but its suffix does not belong to
68608
- * the region it names — i.e. exactly the case {@link parseEcrRegistryHost}
68609
- * rejects for a reason the caller may want to report.
68610
- *
68611
- * The two rejections are worth telling apart at a call site that degrades
68612
- * silently: a genuinely public image and a registry in a partition
68613
- * `derivePartitionAndUrlSuffix` does not know yet (issue #1764) both come back
68614
- * `undefined`, and only the second is a cdkd gap rather than a user choice.
68615
- */
68616
- function looksLikeEcrHostWithForeignSuffix(imageUri) {
68617
- const m = matchEcrRegistryHost(imageUri);
68618
- return m !== void 0 && m.suffix !== m.expectedSuffix;
68619
- }
68620
-
68621
69559
  //#endregion
68622
69560
  //#region src/local/ecs-task-resolver.ts
68623
69561
  function detectEcsImageResolutionNeeds(stack) {
@@ -77320,7 +78258,7 @@ function createMigrateCommand() {
77320
78258
  */
77321
78259
  function buildProgram() {
77322
78260
  const program = new Command();
77323
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.283.9");
78261
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.283.11");
77324
78262
  program.hook("preAction", (_thisCommand, actionCommand) => {
77325
78263
  const { profile } = actionCommand.optsWithGlobals();
77326
78264
  if (profile !== void 0) process.env["AWS_PROFILE"] = profile;