@go-to-k/cdkd 0.281.23 → 0.281.25

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 configStringRefusal, $t as MIGRATE_TMP_PREFIX, A as green, At as validateContainerRepoName, B as slowCcOperationTimeoutMs, Bt as getDefaultStateBucketName, C as MULTI_REGION_RECREATE_BLOCKED_TYPES, Cn as PartialFailureError, Ct as rewriteTemplateAssetReferences, D as bold, Dn as StackHasActiveImportsError, Dt as getBootstrapMarkerKey, E as formatResourceLine, En as ResourceUpdateNotSupportedError, Et as ensureAssetStorage, F as clearOnUpdateRemoval, Ft as runDockerStreaming, G as getAccountInfo, Gt as resolveSkipPrefix, H as isTerminationProtectionPropagationError, Ht as resolveApp, I as ProviderRegistry, It as AssetManifestLoader, J as normalizeAwsTagsToCfn, Jt as resolveUseCdkBootstrapAssets, K as refStateLookupFromResource, Kt as resolveStateBucketWithDefault, L as findActionableSilentDrops, Lt as getDockerImageBySourceHash, M as yellow, Mt as formatDockerLoginError, N as IAMRoleProvider, Nn as normalizeAwsError, Nt as getDockerCmd, O as cyan, On as StackTerminationProtectionError, Ot as parseBootstrapMarker, P as collectInlinePolicyNamesManagedBySiblings, Pn as withErrorHandling, Pt as runDockerForeground, Q as configBooleanRefusal, Qt as CFN_TEMPLATE_URL_LIMIT, R as findSilentDropProperties, Rt as Synthesizer, S as extractDeploymentEventError, Sn as NestedStackChildDirectDestroyError, St as loadPublishableAssetManifest, T as renderStatefulReason, Tn as ResourceTimeoutError, Tt as BOOTSTRAP_MARKER_PREFIX, U as IntrinsicFunctionResolver, Ut as resolveAutoAssetStorage, V as disableInstanceApiTermination, Vt as getLegacyStateBucketName, W as cfnRefValueFromPhysicalId, Wt as resolveCaptureObservedState, X as assertRegionMatch, Xt as warnDeprecatedNoPrefixCliFlag, Y as resolveExplicitPhysicalId, Yt as stateBucketExistenceConfirmed, Z as coerceCfnBoolean, Zt as CFN_TEMPLATE_BODY_LIMIT, _ as createPreDeleteFinalSnapshot, _n as LocalInvokeBuildError$1, _t as AssetPublisher, a as DeploymentEventsStore, an as processStackMessages, at as applyRoleArnIfSet, b as unsupportedFinalSnapshotError, bt as buildAssetRedirectMap, c as replayFailedOperations, cn as AwsClients, ct as withRetry, d as IMPLICIT_DELETE_DEPENDENCIES, dn as setAwsClients, dt as DagBuilder, en as findLargeInlineResources, et as readConfigString, f as computeImplicitDeleteEdges, ft as TemplateParser, g as ccRoutedFinalSnapshotError, gn as DeployCancelledError, gt as shouldRetainResource, h as buildFinalSnapshotIdentifier, ht as rebuildClientForBucketRegion, i as DeploymentEventsReader, in as AssemblyReader, it as requireConfigString, j as red, jt as buildDockerImage, k as gray, kt as validateAssetBucketName, l as replayRollback, ln as getAwsClients, lt as isRetryableTransientError, m as PRE_DELETE_SNAPSHOT_TYPES, mt as S3StateBackend, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as expectedOwnerParam, nt as requireConfigArray, o as planFailedOps, ot as DiffCalculator, p as ATOMIC_FINAL_SNAPSHOT_TYPES, pn as CdkdError, pt as LockManager, q as WAFv2WebACLProvider, qt as resolveStateBucketWithDefaultAndSource, r as DeployEngine, rn as derivePartitionAndUrlSuffix, rt as requireConfigObject, s as planRollback, sn as resolveBucketRegion, st as describeTypeWithThrottleRetry, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as uploadCfnTemplate, tt as replayWarn, u as withResourceDeadline, un as resetAwsClients, ut as isThrottlingError, v as isFinalSnapshotError, vn as LocalMigrateError, vt as stringifyValue, w as isStatefulRecreateTargetSync, wn as ProvisioningError, wt as AssetModeResolver, x as makeCanonicalizePropertiesFn, xn as MissingCdkCliError, xt as createAssetRedirectResolver, y as refusesFinalSnapshot, yn as LocalStartServiceError, yt as WorkGraph, z as CloudControlProvider, zt as synthesisStatusMessage } from "./deploy-engine-uxo_FCje.js";
2
+ import { $ as configStringRefusal, $t as MIGRATE_TMP_PREFIX, A as green, At as validateContainerRepoName, B as slowCcOperationTimeoutMs, Bt as getDefaultStateBucketName, C as MULTI_REGION_RECREATE_BLOCKED_TYPES, Cn as PartialFailureError, Ct as rewriteTemplateAssetReferences, D as bold, Dn as StackHasActiveImportsError, Dt as getBootstrapMarkerKey, E as formatResourceLine, En as ResourceUpdateNotSupportedError, Et as ensureAssetStorage, F as clearOnUpdateRemoval, Ft as runDockerStreaming, G as getAccountInfo, Gt as resolveSkipPrefix, H as isTerminationProtectionPropagationError, Ht as resolveApp, I as ProviderRegistry, It as AssetManifestLoader, J as normalizeAwsTagsToCfn, Jt as resolveUseCdkBootstrapAssets, K as refStateLookupFromResource, Kt as resolveStateBucketWithDefault, L as findActionableSilentDrops, Lt as getDockerImageBySourceHash, M as yellow, Mt as formatDockerLoginError, N as IAMRoleProvider, Nn as normalizeAwsError, Nt as getDockerCmd, O as cyan, On as StackTerminationProtectionError, Ot as parseBootstrapMarker, P as collectInlinePolicyNamesManagedBySiblings, Pn as withErrorHandling, Pt as runDockerForeground, Q as configBooleanRefusal, Qt as CFN_TEMPLATE_URL_LIMIT, R as findSilentDropProperties, Rt as Synthesizer, S as extractDeploymentEventError, Sn as NestedStackChildDirectDestroyError, St as loadPublishableAssetManifest, T as renderStatefulReason, Tn as ResourceTimeoutError, Tt as BOOTSTRAP_MARKER_PREFIX, U as IntrinsicFunctionResolver, Ut as resolveAutoAssetStorage, V as disableInstanceApiTermination, Vt as getLegacyStateBucketName, W as cfnRefValueFromPhysicalId, Wt as resolveCaptureObservedState, X as assertRegionMatch, Xt as warnDeprecatedNoPrefixCliFlag, Y as resolveExplicitPhysicalId, Yt as stateBucketExistenceConfirmed, Z as coerceCfnBoolean, Zt as CFN_TEMPLATE_BODY_LIMIT, _ as createPreDeleteFinalSnapshot, _n as LocalInvokeBuildError$1, _t as AssetPublisher, a as DeploymentEventsStore, an as processStackMessages, at as applyRoleArnIfSet, b as unsupportedFinalSnapshotError, bt as buildAssetRedirectMap, c as replayFailedOperations, cn as AwsClients, ct as withRetry, d as IMPLICIT_DELETE_DEPENDENCIES, dn as setAwsClients, dt as DagBuilder, en as findLargeInlineResources, et as readConfigString, f as computeImplicitDeleteEdges, ft as TemplateParser, g as ccRoutedFinalSnapshotError, gn as DeployCancelledError, gt as shouldRetainResource, h as buildFinalSnapshotIdentifier, ht as rebuildClientForBucketRegion, i as DeploymentEventsReader, in as AssemblyReader, it as requireConfigString, j as red, jt as buildDockerImage, k as gray, kt as validateAssetBucketName, l as replayRollback, ln as getAwsClients, lt as isRetryableTransientError, m as PRE_DELETE_SNAPSHOT_TYPES, mt as S3StateBackend, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as expectedOwnerParam, nt as requireConfigArray, o as planFailedOps, ot as DiffCalculator, p as ATOMIC_FINAL_SNAPSHOT_TYPES, pn as CdkdError, pt as LockManager, q as WAFv2WebACLProvider, qt as resolveStateBucketWithDefaultAndSource, r as DeployEngine, rn as derivePartitionAndUrlSuffix, rt as requireConfigObject, s as planRollback, sn as resolveBucketRegion, st as describeTypeWithThrottleRetry, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as uploadCfnTemplate, tt as replayWarn, u as withResourceDeadline, un as resetAwsClients, ut as isThrottlingError, v as isFinalSnapshotError, vn as LocalMigrateError, vt as stringifyValue, w as isStatefulRecreateTargetSync, wn as ProvisioningError, wt as AssetModeResolver, x as makeCanonicalizePropertiesFn, xn as MissingCdkCliError, xt as createAssetRedirectResolver, y as refusesFinalSnapshot, yn as LocalStartServiceError, yt as WorkGraph, z as CloudControlProvider, zt as synthesisStatusMessage } from "./deploy-engine-C8Kq3Cei.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-CCSQkGwO.js";
4
+ import { t as ASGProvider } from "./asg-provider-Cm5r4o8r.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";
@@ -5172,10 +5172,81 @@ function foldToleratedAliases(bag, aliases) {
5172
5172
  }
5173
5173
  return out ?? bag;
5174
5174
  }
5175
+ /** The transition date member, held in a CONST so the fold writes it COMPUTED. */
5176
+ const TRANSITION_DATE_KEY = "TransitionDate";
5175
5177
  /** The lifecycle `Transitions[]` aliases the applier accepts (issue #1748). */
5176
5178
  const TRANSITION_ALIASES = [["TransitionInDays", "Days"], ["TransitionDate", "Date"]];
5177
5179
  /** The lifecycle `NoncurrentVersionTransitions[]` alias (issue #1748). */
5178
5180
  const NONCURRENT_VERSION_TRANSITION_ALIASES = [["TransitionInDays", "NoncurrentDays"]];
5181
+ /** The refusal path for the notification EventBridge boolean (issue #1759). */
5182
+ const EVENTBRIDGE_CONFIG_PATH = "AWS::S3::Bucket NotificationConfiguration.EventBridgeConfiguration";
5183
+ /**
5184
+ * The block's own key, held in a CONST so the fold below writes it as a
5185
+ * COMPUTED key.
5186
+ *
5187
+ * Load-bearing, not style: `gen-nested-key-coverage`'s write-evidence walk reads
5188
+ * a bag-derived spread-and-patch literal that NAMES a member
5189
+ * (`{ ...config, EventBridgeConfiguration: … }`) as the #1475 whole-blob
5190
+ * HAND-OFF and wildcard-credits everything beneath that path as DELIVERED — so
5191
+ * a DIFF-side fold spelled that way would switch the write pass off for the
5192
+ * whole notification subtree, and the critic's only visible complaint would be
5193
+ * that some allow-list entries went "stale". Same reason
5194
+ * {@link canonicalizeBlock} exists.
5195
+ */
5196
+ const EVENTBRIDGE_CONFIG_KEY = "EventBridgeConfiguration";
5197
+ /**
5198
+ * What a DECLARED `EventBridgeConfiguration` block with no `EventBridgeEnabled`
5199
+ * key means. CFn marks the member required, so the block's presence is the
5200
+ * affirmative declaration; this is also the pre-#1759 behavior for that shape.
5201
+ */
5202
+ const EVENTBRIDGE_DEFAULT_ENABLED = true;
5203
+ /**
5204
+ * The boolean the wire derives from a USABLE `EventBridgeConfiguration` block
5205
+ * (issue #1759) — the ONE definition `applyNotificationConfiguration` and
5206
+ * {@link effectiveNotificationConfiguration} share, so the value that goes on
5207
+ * the wire and the value recorded into state cannot drift apart.
5208
+ *
5209
+ * Callers MUST have cleared `configBooleanRefusal` first; the `??` is only
5210
+ * correct once every declared-but-unusable value has been refused.
5211
+ */
5212
+ function eventBridgeEnabledOf(eb) {
5213
+ return coerceCfnBoolean(isPlainObject$1(eb) ? eb["EventBridgeEnabled"] : void 0) ?? EVENTBRIDGE_DEFAULT_ENABLED;
5214
+ }
5215
+ /** The notification families whose empty declaration is never SENT (issue #1759). */
5216
+ const NOTIFICATION_LIST_KEYS = [
5217
+ "TopicConfigurations",
5218
+ "QueueConfigurations",
5219
+ "LambdaConfigurations"
5220
+ ];
5221
+ /**
5222
+ * Drop a declared-but-EMPTY collection the applier never sends (issue #1759).
5223
+ *
5224
+ * MEASURED before choosing this answer, because the sibling shape answers the
5225
+ * opposite way: the lifecycle / CORS empty-collection skip RECORDS its
5226
+ * placeholder (issue #1718) precisely because `readLifecycle` / `readCors`
5227
+ * always emit `{Rules: []}` / `{CorsRules: []}` for an unconfigured bucket, so
5228
+ * the declared empty collection already equals the readback. `readNotification`
5229
+ * does NOT: each family is emitted only under
5230
+ * `resp.<Family> && resp.<Family>.length > 0`, so an unconfigured bucket comes
5231
+ * back with the key ABSENT. A record keeping `[]` therefore describes a key set
5232
+ * AWS can never report, and the honest answer here is the DROP.
5233
+ *
5234
+ * Scoped to a genuinely EMPTY ARRAY. A non-array declaration is skipped by the
5235
+ * applier's `Array.isArray(...)` gate too, but it is MALFORMED: dropping it
5236
+ * would make the desired side compare equal and stop the difference being
5237
+ * reported, which is the concealment `.claude/rules/providers.md` finding 3
5238
+ * refuses. It is left visible instead.
5239
+ */
5240
+ function dropEmptyNotificationFamilies(config) {
5241
+ let out;
5242
+ for (const listKey of NOTIFICATION_LIST_KEYS) {
5243
+ const list = config[listKey];
5244
+ if (!Array.isArray(list) || list.length > 0) continue;
5245
+ out ??= { ...config };
5246
+ delete out[listKey];
5247
+ }
5248
+ return out ?? config;
5249
+ }
5179
5250
  /** The `NotificationConfiguration` ARN aliases, per family (issue #1748). */
5180
5251
  const NOTIFICATION_ARN_ALIASES = [
5181
5252
  ["TopicConfigurations", [["Topic", "TopicArn"]]],
@@ -5248,6 +5319,439 @@ function effectiveNotificationConfiguration(config) {
5248
5319
  if (!isPlainObject$1(config)) return config;
5249
5320
  let out = config;
5250
5321
  for (const [listKey, aliases] of NOTIFICATION_ARN_ALIASES) out = canonicalizeItemList(out, listKey, (item) => foldNotificationEvents(foldToleratedAliases(item, aliases)));
5322
+ out = dropEmptyNotificationFamilies(out);
5323
+ return foldEventBridgeConfiguration(out);
5324
+ }
5325
+ /**
5326
+ * Normalize `EventBridgeConfiguration` onto the shape `readNotification` emits
5327
+ * (issue #1759, items 2 and 3 of the same block).
5328
+ *
5329
+ * CFn is stringly typed, so `EventBridgeEnabled: 'true'` is a legitimate
5330
+ * declaration; the wire turns the value into presence/absence and
5331
+ * `readNotification` emits the BOOLEAN (`{EventBridgeEnabled: <bool>}`, the
5332
+ * #1430 shape). A record holding the STRING can therefore never match the
5333
+ * readback. This is #1633's lossless-coercion arm and it meets the #1643 bar —
5334
+ * what AWS reports back is exactly the boolean cdkd derived at send time. A
5335
+ * declared `null` folds to `false` for the same reason: the applier reads it as
5336
+ * "block omitted" and sends nothing.
5337
+ *
5338
+ * Keyed off the DECLARED shape and off the wire's own predicate, never off a
5339
+ * refusal: a value `configBooleanRefusal` rejects is left COMPLETELY alone (no
5340
+ * patch, and the key not dropped), because the wire SKIPS the whole Put for it
5341
+ * and AWS keeps the previous configuration. Folding it would collapse the
5342
+ * desired side onto the retained value, the provider would never be called, and
5343
+ * the skip warning would stop — the "mirror what the wire does" rule.
5344
+ *
5345
+ * MEASURED AND DELIBERATELY NOT FOLDED (1) — the per-item `Id`, which issue
5346
+ * #1755 asked to be measured rather than assumed. The applier sends
5347
+ * `Id: t['Id']`, so a template declaring none sends none, AWS GENERATES one, and
5348
+ * `readNotification` emits it (`if (t.Id !== undefined) e['Id'] = t.Id`). That is
5349
+ * an AWS-COMPUTED value, not a spelling: it belongs in `observedProperties`,
5350
+ * which `readCurrentState` already populates, and `.claude/rules/providers.md` is
5351
+ * explicit that a computed value in `properties` makes the DESIRED baseline drift
5352
+ * from the template and disables the #1160 absent-field removal derivation. There
5353
+ * is also nothing to fold TOWARDS — the value does not exist at send time, which
5354
+ * is the #1643 "knowable at send time" test failing. So: no fold, by measurement
5355
+ * rather than by omission.
5356
+ *
5357
+ * MEASURED AND DELIBERATELY NOT FOLDED (2): `readNotification` emits
5358
+ * `EventBridgeConfiguration` UNCONDITIONALLY, so a notification configuration
5359
+ * that declares no EventBridge block at all is one key short of the readback.
5360
+ * That is NOT this class — nothing was declared, so there is no spelling to
5361
+ * normalize, and supplying the key would record one the template does not have
5362
+ * on EVERY notification-configured bucket (the shape #1723 records as the
5363
+ * reason its own arm was left unanswered). `readNotification`'s own comment
5364
+ * already scopes that difference: it is a ONE-TIME, self-clearing drift on the
5365
+ * `properties`-fallback baseline, deliberately accepted by #1430, not the
5366
+ * permanent phantom drift this fold exists to remove.
5367
+ *
5368
+ * @returns `config` BY IDENTITY when nothing is declared, when the declared
5369
+ * block is already the emitted shape, or when it is unfoldable.
5370
+ */
5371
+ function foldEventBridgeConfiguration(config) {
5372
+ const eb = config[EVENTBRIDGE_CONFIG_KEY];
5373
+ if (eb === void 0) return config;
5374
+ if (eb !== null && configBooleanRefusal(eb, "EventBridgeEnabled", EVENTBRIDGE_CONFIG_PATH)) return config;
5375
+ const enabled = eb === null ? false : eventBridgeEnabledOf(eb);
5376
+ const declared = isPlainObject$1(eb) ? eb["EventBridgeEnabled"] : void 0;
5377
+ if (isPlainObject$1(eb) && Object.keys(eb).length === 1 && Object.is(declared, enabled)) return config;
5378
+ return {
5379
+ ...config,
5380
+ [EVENTBRIDGE_CONFIG_KEY]: { EventBridgeEnabled: enabled }
5381
+ };
5382
+ }
5383
+ /** The refusal paths the lifecycle applier and its fold SHARE (issue #1755). */
5384
+ const LIFECYCLE_RULE_PATH = "AWS::S3::Bucket LifecycleConfiguration.Rules[]";
5385
+ const LIFECYCLE_FILTER_PATH = `${LIFECYCLE_RULE_PATH}.Filter`;
5386
+ const LIFECYCLE_TAG_FILTERS_PATH = `${LIFECYCLE_RULE_PATH}.TagFilters`;
5387
+ /** What `readConfigString(rule, 'Status', ...)` substitutes for an ABSENT key. */
5388
+ const LIFECYCLE_DEFAULT_STATUS = "Enabled";
5389
+ /** The rule's status member, held in a CONST so the fold writes it COMPUTED. */
5390
+ const LIFECYCLE_STATUS_KEY = "Status";
5391
+ /**
5392
+ * Gather a rule's full location scope — module-level so the applier and the
5393
+ * fold run the SAME function rather than two copies that can disagree about
5394
+ * which key wins (`.claude/rules/providers.md`: share ONE helper with the
5395
+ * provisioning path, or state and template end up narrowed to different keys
5396
+ * and the fix becomes the bug).
5397
+ *
5398
+ * `TagFilters` lives at the RULE level in the CFn schema and CDK synthesizes it
5399
+ * there; the `Filter`-side reads are cdkd's accommodation for SDK-shaped /
5400
+ * imported input.
5401
+ */
5402
+ function lifecycleRuleScope(rule) {
5403
+ const filter = rule["Filter"];
5404
+ return {
5405
+ prefix: filter?.["Prefix"] ?? rule["Prefix"],
5406
+ tagFilters: filter?.["TagFilters"] ?? rule["TagFilters"],
5407
+ sizeGt: filter?.["ObjectSizeGreaterThan"] ?? rule["ObjectSizeGreaterThan"],
5408
+ sizeLt: filter?.["ObjectSizeLessThan"] ?? rule["ObjectSizeLessThan"]
5409
+ };
5410
+ }
5411
+ /**
5412
+ * Can this rule stay in the deprecated V1 (top-level `Prefix`) form? See
5413
+ * {@link lifecycleUsesFilterForm} — S3 forbids MIXING the two forms in one
5414
+ * configuration, so the answer is decided across the whole rule list.
5415
+ */
5416
+ function lifecycleRuleIsPlainPrefixOnly(rule) {
5417
+ if (rule["Filter"] != null) return false;
5418
+ const s = lifecycleRuleScope(rule);
5419
+ return s.prefix !== void 0 && (s.tagFilters == null || s.tagFilters.length === 0) && s.sizeGt === void 0 && s.sizeLt === void 0;
5420
+ }
5421
+ /** V1 vs V2 is decided ONCE for the whole configuration — see the applier's note. */
5422
+ function lifecycleUsesFilterForm(rules) {
5423
+ return !rules.every(lifecycleRuleIsPlainPrefixOnly);
5424
+ }
5425
+ /** Discards the message; the fold only needs the PREDICATE's answer. */
5426
+ const IGNORE_REFUSAL_MESSAGE = () => {};
5427
+ /**
5428
+ * Would `applyLifecycleConfiguration` REFUSE this rule, and therefore skip the
5429
+ * whole Put? (issue #1755)
5430
+ *
5431
+ * Runs the applier's own three guards rather than restating them — the
5432
+ * "do not HAND-ROLL the mirror, run the wire's own predicate" rule, whose
5433
+ * cautionary tale is the S3 schedule fold, where an approximation disagreed
5434
+ * with the wire on three of nine container shapes.
5435
+ */
5436
+ function lifecycleRuleRefused(rule) {
5437
+ const rawFilter = rule["Filter"];
5438
+ if (rawFilter != null && requireConfigObject(rawFilter, LIFECYCLE_FILTER_PATH, { onUnusable: IGNORE_REFUSAL_MESSAGE }) === void 0) return true;
5439
+ const rawTagFilters = rawFilter?.["TagFilters"] ?? rule["TagFilters"];
5440
+ if (rawTagFilters != null && requireConfigArray(rawTagFilters, LIFECYCLE_TAG_FILTERS_PATH, { onUnusable: IGNORE_REFUSAL_MESSAGE }) === void 0) return true;
5441
+ return configStringRefusal(rule, "Status", LIFECYCLE_DEFAULT_STATUS, LIFECYCLE_RULE_PATH) !== void 0;
5442
+ }
5443
+ /**
5444
+ * Deep, key-ORDER-insensitive equality — the identity test for a fold that
5445
+ * rebuilds nested objects (`Filter`) even when nothing about them changed.
5446
+ *
5447
+ * `Object.is` alone would report every folded rule as changed and rewrite the
5448
+ * state record of every lifecycle-configured bucket; `JSON.stringify` would
5449
+ * report a pure reordering as a change, which is not one worth recording.
5450
+ */
5451
+ function deepSameValue(a, b) {
5452
+ if (Object.is(a, b)) return true;
5453
+ if (Array.isArray(a) || Array.isArray(b)) {
5454
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
5455
+ return a.every((element, index) => deepSameValue(element, b[index]));
5456
+ }
5457
+ if (isPlainObject$1(a) && isPlainObject$1(b)) {
5458
+ const keysA = Object.keys(a);
5459
+ if (keysA.length !== Object.keys(b).length) return false;
5460
+ return keysA.every((key) => key in b && deepSameValue(a[key], b[key]));
5461
+ }
5462
+ return false;
5463
+ }
5464
+ /**
5465
+ * The ISO string `readLifecycle` emits for a date the applier sends as
5466
+ * `new Date(value)`.
5467
+ *
5468
+ * DETERMINISM IS A PRECONDITION, not a nicety (PR review). `new Date(str)`
5469
+ * resolves an offset-less string in the HOST's local zone — `new Date('0')` is
5470
+ * `1999-12-31T15:00:00Z` under JST and `2000-01-01T00:00:00Z` under UTC, and
5471
+ * `'2030-01-01T00:00:00'` (ISO, no offset) is local by spec. The APPLIER shares
5472
+ * that dependence, so the wire and the readback still agree on any one machine —
5473
+ * but this feeds `canonicalizeDesiredProperties`, a DIFF-side pure function that
5474
+ * runs wherever the CLI runs. A CI runner in UTC and a laptop in JST would
5475
+ * canonicalize the SAME template to different ISO strings and manufacture a diff
5476
+ * neither machine can clear, which is strictly worse than the phantom drift the
5477
+ * fold exists to remove. So only the forms whose meaning is fixed by the ISO
5478
+ * 8601 spec are folded: a bare `YYYY-MM-DD` (spec-UTC), a date-time carrying
5479
+ * `Z` or a `±HH:MM` offset, and an epoch NUMBER. Everything else — `'Jan 1
5480
+ * 2030'`, `'0'`, an offset-less date-time — is left ALONE, which keeps the
5481
+ * record machine-independent at the cost of not normalizing a shape almost no
5482
+ * template uses.
5483
+ *
5484
+ * @returns `undefined` for a value that cannot become a valid `Date`, or whose
5485
+ * `Date` would depend on the host timezone — in both cases there is no
5486
+ * effective value this can claim AWS will report, so the fold leaves the rule
5487
+ * alone.
5488
+ */
5489
+ const TIMEZONE_INDEPENDENT_DATE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2}))?$/;
5490
+ function cfnDateToIso(value) {
5491
+ if (typeof value === "string" && !TIMEZONE_INDEPENDENT_DATE.test(value)) return void 0;
5492
+ if (typeof value !== "string" && typeof value !== "number") return void 0;
5493
+ const parsed = new Date(value);
5494
+ return Number.isNaN(parsed.getTime()) ? void 0 : parsed.toISOString();
5495
+ }
5496
+ /** The legacy SINGULAR action objects the CFn schema still declares (issue #1754). */
5497
+ const LEGACY_SINGULAR_TRANSITIONS = [["Transitions", "Transition"], ["NoncurrentVersionTransitions", "NoncurrentVersionTransition"]];
5498
+ /**
5499
+ * Merge the legacy singular `Transition` / `NoncurrentVersionTransition` into
5500
+ * the plural array, exactly as the wire does (issue #1754).
5501
+ *
5502
+ * The CFn `Rule` definition still declares BOTH spellings and
5503
+ * `applyLifecycleConfiguration` folds the singular into the plural array via
5504
+ * {@link mergeLegacySingular}, so `readLifecycle` can only ever emit the plural.
5505
+ * A rule declaring the singular therefore recorded a key AWS can never
5506
+ * report — the #1686 never-emitted-KEY class through the SHAPE — with no
5507
+ * warning anywhere, because nothing is malformed and nothing is substituted.
5508
+ *
5509
+ * It shares `mergeLegacySingular` itself rather than re-deriving the collision
5510
+ * rule, per the share-ONE-helper rule. What it does NOT do is pass a no-op
5511
+ * callback and fold regardless: on a `StorageClass` COLLISION the wire DROPS the
5512
+ * singular and WARNS, so a colliding rule is left COMPLETELY alone (both keys
5513
+ * kept). The NON-colliding case, which is the population this exists for, has
5514
+ * no fault to report and folds.
5515
+ *
5516
+ * Be precise about WHICH signal that preserves, because the obvious answer is
5517
+ * wrong (PR review). It is NOT the deploy-time warning: the record and the
5518
+ * canonicalized template are folded by the same function either way, so they
5519
+ * compare equal after the first deploy and the Put stops re-firing whichever
5520
+ * choice is made. What leaving it alone preserves is the `cdkd drift` report —
5521
+ * state keeps a `Transition` key `readLifecycle` can never emit, so drift
5522
+ * re-reports the rule until the template drops one of the two declarations.
5523
+ * Folding it would make state match the readback exactly and render a
5524
+ * template fault S3 itself rejects completely invisible.
5525
+ *
5526
+ * @returns `rule` BY IDENTITY when no legacy singular is declared.
5527
+ */
5528
+ function foldLegacySingularTransitions(rule) {
5529
+ let out;
5530
+ for (const [pluralKey, singularKey] of LEGACY_SINGULAR_TRANSITIONS) {
5531
+ const singular = rule[singularKey];
5532
+ const hasSingular = isPlainObject$1(singular);
5533
+ const plural = rule[pluralKey];
5534
+ const needsFilter = Array.isArray(plural) && plural.some((element) => !isPlainObject$1(element));
5535
+ if (!hasSingular && !needsFilter) continue;
5536
+ let collided = false;
5537
+ const merged = mergeLegacySingular(plural, hasSingular ? singular : void 0, () => {
5538
+ collided = true;
5539
+ });
5540
+ out ??= { ...rule };
5541
+ if (collided) {
5542
+ if (needsFilter) out[pluralKey] = merged;
5543
+ continue;
5544
+ }
5545
+ out[pluralKey] = merged;
5546
+ if (hasSingular) delete out[singularKey];
5547
+ }
5548
+ return out ?? rule;
5549
+ }
5550
+ /**
5551
+ * Emit a transition's `TransitionDate` as the ISO string the readback emits
5552
+ * (PR review of issues #1754 / #1755).
5553
+ *
5554
+ * The alias fold above only RENAMES `Date` -> `TransitionDate`, but the applier
5555
+ * sends `new Date(t['TransitionDate'] ?? t['Date'])` and `readLifecycle` emits
5556
+ * `t.Date.toISOString()` — so a hand-written / imported `TransitionDate:
5557
+ * '2030-01-01'` recorded a value the readback can never match, with no warning
5558
+ * and nothing malformed. Exactly the class {@link foldLifecycleExpiration}
5559
+ * closes for the RULE-level `ExpirationDate` one member over; found by review
5560
+ * because the fold and the readback were compared per member rather than
5561
+ * per item.
5562
+ *
5563
+ * `NoncurrentVersionTransitions` needs no twin of this: `toSdkNvt` has no date
5564
+ * member at all.
5565
+ *
5566
+ * @returns the item BY IDENTITY when no date is declared, or when it is one the
5567
+ * applier would put on the wire as an Invalid Date (nothing to record).
5568
+ */
5569
+ function foldTransitionDate(item) {
5570
+ const declared = item[TRANSITION_DATE_KEY];
5571
+ if (!declared) return item;
5572
+ const iso = cfnDateToIso(declared);
5573
+ if (iso === void 0 || iso === declared) return item;
5574
+ const out = { ...item };
5575
+ out[TRANSITION_DATE_KEY] = iso;
5576
+ return out;
5577
+ }
5578
+ /**
5579
+ * Drop a declared-but-EMPTY transition list (issue #1755).
5580
+ *
5581
+ * The applier sets `sdkRule.Transitions` / `.NoncurrentVersionTransitions` only
5582
+ * when the merged array is non-empty, and `readLifecycle` emits each only under
5583
+ * `r.<List> && r.<List>.length > 0` — so an empty declaration is one key the
5584
+ * readback can never carry. Measured on the reverse-mapper, not assumed: the
5585
+ * lifecycle block's OWN empty-collection placeholder answers the opposite way
5586
+ * (issue #1718) because `readLifecycle` DOES emit `{Rules: []}`.
5587
+ */
5588
+ function dropEmptyTransitionLists(rule) {
5589
+ let out;
5590
+ for (const [pluralKey] of LEGACY_SINGULAR_TRANSITIONS) {
5591
+ const list = rule[pluralKey];
5592
+ if (!Array.isArray(list) || list.length > 0) continue;
5593
+ out ??= { ...rule };
5594
+ delete out[pluralKey];
5595
+ }
5596
+ return out ?? rule;
5597
+ }
5598
+ /**
5599
+ * Reshape the rule's expiration onto the CFn spellings `readLifecycle` emits
5600
+ * (issue #1755 item 1).
5601
+ *
5602
+ * The CFn `Rule` definition declares `ExpirationDate` / `ExpirationInDays` and
5603
+ * the RULE-level `ExpiredObjectDeleteMarker`, and has NO `Expiration` member;
5604
+ * the applier additionally accepts a cdkd-only `Expiration` OBJECT as a third
5605
+ * source. The readback used to emit only the SDK-shaped `Expiration` object, so
5606
+ * state and the readback carried different key sets for essentially every
5607
+ * lifecycle-configured bucket CDK creates. The fix is split, per the issue: the
5608
+ * READBACK now emits the CFn scalars (recording the SDK shape instead would put
5609
+ * a spelling in state that no CFn template can declare), and this is the desired
5610
+ * side folding onto the same choice.
5611
+ *
5612
+ * The rule is left COMPLETELY alone in three cases, each because the wire does
5613
+ * something this fold must not conceal:
5614
+ *
5615
+ * - a rule-level `ExpiredObjectDeleteMarker` the wire cannot read
5616
+ * (`coerceCfnBoolean` answers `undefined`): the wire silently ignores it, and
5617
+ * nothing warns, so the difference is the user's only signal;
5618
+ * - a date the wire would send as an Invalid Date, or one whose `Date` depends
5619
+ * on the host timezone (see {@link cfnDateToIso});
5620
+ * - a rule-level `ExpiredObjectDeleteMarker: true` ALONGSIDE a `Days` / `Date`:
5621
+ * S3 forbids the combination, so the applier sends the `Days` / `Date` and
5622
+ * WARNS that the marker was not applied. Folding the marker away on both sides
5623
+ * would stop that difference being reported, so `cdkd drift` re-reporting it
5624
+ * until the template drops one of the two is the correct standing signal.
5625
+ *
5626
+ * @returns `rule` BY IDENTITY when nothing expiration-shaped is declared.
5627
+ */
5628
+ function foldLifecycleExpiration(rule) {
5629
+ const declaredMarker = rule["ExpiredObjectDeleteMarker"];
5630
+ const ruleLevelMarker = coerceCfnBoolean(declaredMarker);
5631
+ if (declaredMarker !== void 0 && ruleLevelMarker === void 0) return rule;
5632
+ if (rule["ExpirationInDays"] === void 0 && rule["ExpirationDate"] === void 0 && rule["Expiration"] === void 0 && declaredMarker === void 0) return rule;
5633
+ const expiration = rule["ExpirationInDays"] || rule["ExpirationDate"] || rule["Expiration"];
5634
+ let days;
5635
+ let date;
5636
+ let marker;
5637
+ if (typeof expiration === "number") days = expiration;
5638
+ else if (typeof expiration === "string") {
5639
+ const iso = cfnDateToIso(expiration);
5640
+ if (iso === void 0) return rule;
5641
+ date = iso;
5642
+ } else if (isPlainObject$1(expiration)) {
5643
+ if (expiration["Days"] !== void 0) days = expiration["Days"];
5644
+ if (expiration["Date"]) {
5645
+ const iso = cfnDateToIso(expiration["Date"]);
5646
+ if (iso === void 0) return rule;
5647
+ date = iso;
5648
+ }
5649
+ const nested = expiration["ExpiredObjectDeleteMarker"];
5650
+ if (nested !== void 0) {
5651
+ if (typeof nested !== "boolean") return rule;
5652
+ marker = nested;
5653
+ }
5654
+ } else if (expiration) return rule;
5655
+ if (ruleLevelMarker === true) {
5656
+ if (days !== void 0 || date !== void 0) return rule;
5657
+ days = void 0;
5658
+ date = void 0;
5659
+ marker = true;
5660
+ }
5661
+ const out = { ...rule };
5662
+ delete out["ExpirationInDays"];
5663
+ delete out["ExpirationDate"];
5664
+ delete out["Expiration"];
5665
+ delete out["ExpiredObjectDeleteMarker"];
5666
+ if (days !== void 0) out["ExpirationInDays"] = days;
5667
+ if (date !== void 0) out["ExpirationDate"] = date;
5668
+ if (marker !== void 0) out["ExpiredObjectDeleteMarker"] = marker;
5669
+ return out;
5670
+ }
5671
+ /**
5672
+ * Fold the legacy scalar `NoncurrentVersionExpirationInDays` onto the OBJECT
5673
+ * form `readLifecycle` emits (issue #1755 item 2).
5674
+ *
5675
+ * CFn declares BOTH spellings here — unlike the expiration sibling above — so
5676
+ * the readback has to pick one and the desired side has to fold to the same
5677
+ * choice. The OBJECT wins because it is the only one that can express
5678
+ * `NewerNoncurrentVersions`, so folding towards it is lossless in both
5679
+ * directions; and it is also what the wire already prefers when both are
5680
+ * declared, which is why a rule carrying both simply DROPS the scalar here.
5681
+ *
5682
+ * `coerceCfnNumber`, not `typeof === 'number'`: it is the predicate the wire
5683
+ * runs, and the wire sends the COERCED number, so a hand-written `"365"` is
5684
+ * recorded as `365` — exactly what AWS reports back.
5685
+ */
5686
+ function foldNoncurrentVersionExpiration(rule) {
5687
+ const legacy = rule["NoncurrentVersionExpirationInDays"];
5688
+ if (legacy === void 0) return rule;
5689
+ const declaredObject = rule["NoncurrentVersionExpiration"];
5690
+ if (declaredObject !== void 0 && !isPlainObject$1(declaredObject)) return rule;
5691
+ const out = { ...rule };
5692
+ delete out["NoncurrentVersionExpirationInDays"];
5693
+ if (isPlainObject$1(declaredObject)) return out;
5694
+ const days = coerceCfnNumber(legacy);
5695
+ if (days === void 0) return rule;
5696
+ out["NoncurrentVersionExpiration"] = { NoncurrentDays: days };
5697
+ return out;
5698
+ }
5699
+ /**
5700
+ * Rebuild the rule's location scope in the shape `readLifecycle` emits for the
5701
+ * `Filter` the applier actually sends (issue #1755 item 3).
5702
+ *
5703
+ * This is not a spelling at all but a defaulted-but-SENT member (#1718's
5704
+ * class): a rule with no declared scope is SENT `Filter: {Prefix: ''}` in a V2
5705
+ * configuration, AWS holds it, the readback reports it, and the record was one
5706
+ * key short. The fold is wider than the issue's headline because the same round
5707
+ * trip moves three more shapes — a bare top-level `Prefix` becomes
5708
+ * `Filter: {Prefix}`, rule-level `TagFilters` become `Filter: {TagFilters}`, and
5709
+ * rule-level `ObjectSize*` move under `Filter` — all of which the readback emits
5710
+ * only under `Filter`.
5711
+ *
5712
+ * `useFilterForm` is decided ACROSS ALL RULES, which is why this takes it as a
5713
+ * parameter instead of being a pure per-rule function: in a V1 configuration
5714
+ * (every rule scoped by exactly one top-level `Prefix`) nothing moves at all.
5715
+ *
5716
+ * WHY THIS RECORDS `Filter` WHEN THE EXPIRATION SIBLING REFUSED THE SDK SHAPE,
5717
+ * since the two answer the same objection opposite ways one member apart (PR
5718
+ * review — without this, the next author "fixes" one to match the other). The
5719
+ * CFn `Rule` definition declares no `Filter` member either, so by the
5720
+ * `Expiration` argument this too should have been fixed on the READBACK side.
5721
+ * It is not, for a reason that does not apply there: the rule-level CFn scalars
5722
+ * can express every `Expiration` the SDK returns, and so the reverse map is
5723
+ * LOSSLESS — whereas the V2 `Filter` carries a distinction with no rule-level
5724
+ * spelling at all. A scope-less rule is SENT `Filter: {Prefix: ''}`, and the
5725
+ * only rule-level rendering of that is a top-level `Prefix: ''`, which is the V1
5726
+ * form — a different configuration that S3 forbids mixing with V2. Reverse-
5727
+ * mapping would therefore destroy the V1/V2 distinction state needs to
5728
+ * reproduce the call. `readLifecycle` has emitted `Filter` since issue #1388, so
5729
+ * both sides already meet there; this fold makes the desired side agree rather
5730
+ * than moving the meeting point.
5731
+ */
5732
+ function foldLifecycleScope(rule, useFilterForm) {
5733
+ const { prefix, tagFilters, sizeGt, sizeLt } = lifecycleRuleScope(rule);
5734
+ const hasTags = tagFilters != null && tagFilters.length > 0;
5735
+ const componentCount = (hasTags ? 1 : 0) + (prefix !== void 0 ? 1 : 0) + (sizeGt !== void 0 ? 1 : 0) + (sizeLt !== void 0 ? 1 : 0);
5736
+ let filter;
5737
+ if (componentCount > 1) {
5738
+ filter = {};
5739
+ if (prefix !== void 0) filter["Prefix"] = prefix;
5740
+ if (hasTags) filter["TagFilters"] = tagFilters;
5741
+ if (sizeGt !== void 0) filter["ObjectSizeGreaterThan"] = sizeGt;
5742
+ if (sizeLt !== void 0) filter["ObjectSizeLessThan"] = sizeLt;
5743
+ } else if (hasTags) filter = { TagFilters: tagFilters };
5744
+ else if (sizeGt !== void 0) filter = { ObjectSizeGreaterThan: sizeGt };
5745
+ else if (sizeLt !== void 0) filter = { ObjectSizeLessThan: sizeLt };
5746
+ else if (useFilterForm) filter = { Prefix: prefix ?? "" };
5747
+ const out = { ...rule };
5748
+ delete out["Filter"];
5749
+ delete out["Prefix"];
5750
+ delete out["TagFilters"];
5751
+ delete out["ObjectSizeGreaterThan"];
5752
+ delete out["ObjectSizeLessThan"];
5753
+ if (filter !== void 0) out["Filter"] = filter;
5754
+ else if (prefix !== void 0) out["Prefix"] = prefix;
5251
5755
  return out;
5252
5756
  }
5253
5757
  /**
@@ -5268,29 +5772,65 @@ function effectiveNotificationConfiguration(config) {
5268
5772
  * the same two item shapes, so fixing only the reported one would have left its
5269
5773
  * siblings broken — the "diff the WHOLE blob, not the reported key" rule.
5270
5774
  *
5271
- * Two things deliberately NOT folded here, tracked rather than left unstated:
5272
- * the legacy SINGULAR `Transition` / `NoncurrentVersionTransition` objects,
5273
- * which `mergeLegacySingular` folds into the plural on the wire so the readback
5274
- * can never emit them either — a MERGE with a collision arm that WARNS, not an
5275
- * alias rename, so it needs its own decision (issue #1754); and the RULE-level
5276
- * divergences the round-trip fence measured beside these
5277
- * (`ExpirationInDays` / `NoncurrentVersionExpirationInDays` recorded against an
5278
- * `Expiration` / `NoncurrentVersionExpiration` readback, and the sent-but-
5279
- * unrecorded `Filter: {Prefix: ''}`), which are reshapes belonging on the
5280
- * READBACK side and are wider than this class (issue #1755). So a rule using
5281
- * these aliases converges on the aliases and still differs on those keys until
5282
- * #1755 lands — which is the honest state, not a claim this makes lifecycle
5283
- * whole.
5284
- *
5285
- * @returns `config` BY IDENTITY when no tolerated spelling is declared.
5775
+ * Issues #1754 / #1755 finished the block, so the "still differs on those keys"
5776
+ * caveat this doc carried is gone: the legacy SINGULAR `Transition` /
5777
+ * `NoncurrentVersionTransition` objects are MERGED here exactly as the wire
5778
+ * merges them ({@link foldLegacySingularTransitions}), the rule-level
5779
+ * `Expiration` / `NoncurrentVersionExpiration` reshapes are folded onto the
5780
+ * spellings the readback emits ({@link foldLifecycleExpiration} /
5781
+ * {@link foldNoncurrentVersionExpiration}), and the sent-but-unrecorded
5782
+ * `Filter` is supplied from the rule's full gathered SCOPE
5783
+ * ({@link foldLifecycleScope}).
5784
+ *
5785
+ * @returns `config` BY IDENTITY when the declared bag IS what goes on the wire.
5286
5786
  */
5287
5787
  function effectiveLifecycleConfiguration(config) {
5288
5788
  if (!isPlainObject$1(config)) return config;
5289
- return canonicalizeItemList(config, "Rules", (rule) => {
5290
- let out = canonicalizeItemList(rule, "Transitions", (t) => foldToleratedAliases(t, TRANSITION_ALIASES));
5291
- out = canonicalizeItemList(out, "NoncurrentVersionTransitions", (nvt) => foldToleratedAliases(nvt, NONCURRENT_VERSION_TRANSITION_ALIASES));
5292
- return out;
5293
- });
5789
+ const rules = config["Rules"];
5790
+ if (!Array.isArray(rules)) return config;
5791
+ const objectRules = rules.filter(isPlainObject$1);
5792
+ if (objectRules.length !== rules.length || objectRules.length === 0) return config;
5793
+ if (objectRules.some(lifecycleRuleRefused)) return config;
5794
+ const useFilterForm = lifecycleUsesFilterForm(objectRules);
5795
+ return canonicalizeItemList(config, "Rules", (rule) => effectiveLifecycleRule(rule, useFilterForm));
5796
+ }
5797
+ /**
5798
+ * The one rule fold both sides share — the CFn-shaped bag `readLifecycle` emits
5799
+ * for the SDK rule `applyLifecycleConfiguration` builds from this rule.
5800
+ *
5801
+ * Ordered the way the wire is: the legacy singular is merged into the plural
5802
+ * BEFORE the alias fold runs, because the singular can carry the SDK day/date
5803
+ * spellings too.
5804
+ *
5805
+ * MEASURED against the REAL synthesized `s3-lifecycle` fixture templates (every
5806
+ * bucket, both phases): the recorded rule and the emitted one carry identical
5807
+ * KEYS and identical VALUES, and differ only in key ORDER — the record keeps the
5808
+ * template's, the readback uses the reverse-mapper's. That is pre-existing and
5809
+ * unchanged here (the record was always template-ordered), and it does not reach
5810
+ * `cdkd drift`, whose baseline is `observedProperties` — captured from this very
5811
+ * reverse-mapper, so both sides carry its order. It is stated because a reader
5812
+ * comparing the two by `JSON.stringify` will see a difference and should know it
5813
+ * was measured rather than missed.
5814
+ *
5815
+ * @returns `rule` BY IDENTITY when the declared rule already IS that bag, so an
5816
+ * ordinary CFn-spelled template records byte-for-byte as before. The compare
5817
+ * is deep and key-ORDER-insensitive for the same reason: a reordering is not a
5818
+ * difference worth rewriting a state record for.
5819
+ */
5820
+ function effectiveLifecycleRule(rule, useFilterForm) {
5821
+ let out = foldLegacySingularTransitions(rule);
5822
+ out = canonicalizeItemList(out, "Transitions", (t) => foldTransitionDate(foldToleratedAliases(t, TRANSITION_ALIASES)));
5823
+ out = canonicalizeItemList(out, "NoncurrentVersionTransitions", (nvt) => foldToleratedAliases(nvt, NONCURRENT_VERSION_TRANSITION_ALIASES));
5824
+ out = dropEmptyTransitionLists(out);
5825
+ out = foldLifecycleExpiration(out);
5826
+ out = foldNoncurrentVersionExpiration(out);
5827
+ out = foldLifecycleScope(out, useFilterForm);
5828
+ if (out[LIFECYCLE_STATUS_KEY] === void 0) {
5829
+ const withStatus = { ...out };
5830
+ withStatus[LIFECYCLE_STATUS_KEY] = LIFECYCLE_DEFAULT_STATUS;
5831
+ out = withStatus;
5832
+ }
5833
+ return deepSameValue(rule, out) ? rule : out;
5294
5834
  }
5295
5835
  /**
5296
5836
  * Apply a WHOLE-BLOCK fold to one top-level property on BOTH sides of a diff —
@@ -5649,32 +6189,18 @@ var S3BucketProvider = class S3BucketProvider {
5649
6189
  async applyLifecycleConfiguration(bucketName, lifecycleConfig, onUnusable) {
5650
6190
  for (const rule of lifecycleConfig.Rules) {
5651
6191
  const rawFilter = rule["Filter"];
5652
- if (rawFilter != null && requireConfigObject(rawFilter, "AWS::S3::Bucket LifecycleConfiguration.Rules[].Filter", { ...onUnusable ? { onUnusable } : {} }) === void 0) return false;
6192
+ if (rawFilter != null && requireConfigObject(rawFilter, LIFECYCLE_FILTER_PATH, { ...onUnusable ? { onUnusable } : {} }) === void 0) return false;
5653
6193
  const rawTagFilters = rawFilter?.["TagFilters"] ?? rule["TagFilters"];
5654
6194
  if (rawTagFilters != null) {
5655
- if (requireConfigArray(rawTagFilters, "AWS::S3::Bucket LifecycleConfiguration.Rules[].TagFilters", { ...onUnusable ? { onUnusable } : {} }) === void 0) return false;
6195
+ if (requireConfigArray(rawTagFilters, LIFECYCLE_TAG_FILTERS_PATH, { ...onUnusable ? { onUnusable } : {} }) === void 0) return false;
5656
6196
  }
5657
- if (this.skipOnUnusableConfigString(rule, "Status", "Enabled", "AWS::S3::Bucket LifecycleConfiguration.Rules[]", "Leaving the whole lifecycle configuration unapplied here", onUnusable)) return false;
6197
+ if (this.skipOnUnusableConfigString(rule, "Status", LIFECYCLE_DEFAULT_STATUS, LIFECYCLE_RULE_PATH, "Leaving the whole lifecycle configuration unapplied here", onUnusable)) return false;
5658
6198
  }
5659
- const gatherScope = (rule) => {
5660
- const filter = rule["Filter"];
5661
- return {
5662
- prefix: filter?.["Prefix"] ?? rule["Prefix"],
5663
- tagFilters: filter?.["TagFilters"] ?? rule["TagFilters"],
5664
- sizeGt: filter?.["ObjectSizeGreaterThan"] ?? rule["ObjectSizeGreaterThan"],
5665
- sizeLt: filter?.["ObjectSizeLessThan"] ?? rule["ObjectSizeLessThan"]
5666
- };
5667
- };
5668
- const isPlainPrefixOnly = (rule) => {
5669
- if (rule["Filter"] != null) return false;
5670
- const s = gatherScope(rule);
5671
- return s.prefix !== void 0 && (s.tagFilters == null || s.tagFilters.length === 0) && s.sizeGt === void 0 && s.sizeLt === void 0;
5672
- };
5673
- const useFilterForm = !lifecycleConfig.Rules.every(isPlainPrefixOnly);
6199
+ const useFilterForm = lifecycleUsesFilterForm(lifecycleConfig.Rules);
5674
6200
  const rules = lifecycleConfig.Rules.map((rule) => {
5675
6201
  const sdkRule = {
5676
6202
  ID: rule["Id"],
5677
- Status: readConfigString(rule, "Status", "Enabled", "AWS::S3::Bucket LifecycleConfiguration.Rules[]"),
6203
+ Status: readConfigString(rule, "Status", LIFECYCLE_DEFAULT_STATUS, LIFECYCLE_RULE_PATH),
5678
6204
  Prefix: useFilterForm ? void 0 : rule["Prefix"]
5679
6205
  };
5680
6206
  const expiration = rule["ExpirationInDays"] || rule["ExpirationDate"] || rule["Expiration"];
@@ -5720,7 +6246,7 @@ var S3BucketProvider = class S3BucketProvider {
5720
6246
  if (allTransitions.length > 0) sdkRule.Transitions = allTransitions.map(toSdkTransition);
5721
6247
  const abort = isPlainObject$1(rule["AbortIncompleteMultipartUpload"]) ? rule["AbortIncompleteMultipartUpload"] : void 0;
5722
6248
  if (abort) sdkRule.AbortIncompleteMultipartUpload = { DaysAfterInitiation: abort["DaysAfterInitiation"] };
5723
- const { prefix, tagFilters, sizeGt, sizeLt } = gatherScope(rule);
6249
+ const { prefix, tagFilters, sizeGt, sizeLt } = lifecycleRuleScope(rule);
5724
6250
  const hasTags = tagFilters != null && tagFilters.length > 0;
5725
6251
  if ((hasTags ? 1 : 0) + (prefix !== void 0 ? 1 : 0) + (sizeGt !== void 0 ? 1 : 0) + (sizeLt !== void 0 ? 1 : 0) > 1) sdkRule.Filter = { And: {
5726
6252
  Prefix: prefix,
@@ -5906,7 +6432,7 @@ var S3BucketProvider = class S3BucketProvider {
5906
6432
  * SDK uses the same structure (PutBucketNotificationConfiguration replaces
5907
6433
  * the entire notification configuration in one call).
5908
6434
  */
5909
- async applyNotificationConfiguration(bucketName, notifConfig) {
6435
+ async applyNotificationConfiguration(bucketName, notifConfig, onUnusable) {
5910
6436
  const cfg = {};
5911
6437
  if (notifConfig) {
5912
6438
  const topics = notifConfig["TopicConfigurations"];
@@ -5931,13 +6457,22 @@ var S3BucketProvider = class S3BucketProvider {
5931
6457
  Filter: this.cfnNotifFilterToSdk(l["Filter"])
5932
6458
  }));
5933
6459
  const eb = notifConfig["EventBridgeConfiguration"];
5934
- if (eb !== void 0 && (!isPlainObject$1(eb) || coerceCfnBoolean(eb["EventBridgeEnabled"]) !== false)) cfg.EventBridgeConfiguration = {};
6460
+ if (eb != null) {
6461
+ const ebRefusal = configBooleanRefusal(eb, "EventBridgeEnabled", EVENTBRIDGE_CONFIG_PATH);
6462
+ if (ebRefusal !== void 0) {
6463
+ if (!onUnusable) throw new Error(`${ebRefusal}. Omit the block entirely to leave EventBridge delivery disabled`);
6464
+ onUnusable(`${ebRefusal}. Leaving the whole notification configuration unapplied here: PutBucketNotificationConfiguration is a full replace, and treating an unreadable value as ${EVENTBRIDGE_DEFAULT_ENABLED} would ENABLE EventBridge delivery the template may have been disabling; the same value is REFUSED on a template-path create`);
6465
+ return false;
6466
+ }
6467
+ if (eventBridgeEnabledOf(eb)) cfg.EventBridgeConfiguration = {};
6468
+ }
5935
6469
  }
5936
6470
  await this.s3Client.send(new PutBucketNotificationConfigurationCommand({
5937
6471
  Bucket: bucketName,
5938
6472
  NotificationConfiguration: cfg
5939
6473
  }));
5940
6474
  this.logger.debug(`Applied notification configuration to bucket ${bucketName}`);
6475
+ return true;
5941
6476
  }
5942
6477
  /**
5943
6478
  * Convert CFn notification Filter ({ S3Key: { Rules: [{ Name, Value }] } })
@@ -6951,9 +7486,11 @@ var S3BucketProvider = class S3BucketProvider {
6951
7486
  await this.applyLoggingConfiguration(bucketName, void 0);
6952
7487
  });
6953
7488
  await this.diffSubConfig(bucketName, previousProperties["NotificationConfiguration"], properties["NotificationConfiguration"], async (cfg) => {
6954
- await this.applyNotificationConfiguration(bucketName, cfg);
6955
- recordEffectiveFold("NotificationConfiguration", effectiveNotificationConfiguration(cfg));
6956
- }, async () => this.applyNotificationConfiguration(bucketName, void 0));
7489
+ if (!await this.applyNotificationConfiguration(bucketName, cfg, (m) => this.logger.warn(m))) retainPrevious("NotificationConfiguration");
7490
+ else recordEffectiveFold("NotificationConfiguration", effectiveNotificationConfiguration(cfg));
7491
+ }, async () => {
7492
+ await this.applyNotificationConfiguration(bucketName, void 0);
7493
+ });
6957
7494
  await this.diffSubConfig(bucketName, previousProperties["ReplicationConfiguration"], properties["ReplicationConfiguration"], async (cfg) => {
6958
7495
  if (!await this.applyReplicationConfiguration(bucketName, cfg, (m) => this.logger.warn(m))) retainPrevious("ReplicationConfiguration");
6959
7496
  }, async () => {
@@ -7067,10 +7604,8 @@ var S3BucketProvider = class S3BucketProvider {
7067
7604
  };
7068
7605
  const replayOnUnusable = replayWarn(this.logger, context).onUnusable;
7069
7606
  const notifConfig = properties["NotificationConfiguration"];
7070
- if (notifConfig) {
7071
- await this.applyNotificationConfiguration(bucketName, notifConfig);
7072
- recordEffectiveFold("NotificationConfiguration", effectiveNotificationConfiguration(notifConfig));
7073
- }
7607
+ if (notifConfig) if (!await this.applyNotificationConfiguration(bucketName, notifConfig, replayOnUnusable)) overrides.set("NotificationConfiguration", void 0);
7608
+ else recordEffectiveFold("NotificationConfiguration", effectiveNotificationConfiguration(notifConfig));
7074
7609
  const corsConfig = properties["CorsConfiguration"];
7075
7610
  if (corsConfig?.CorsRules && Array.isArray(corsConfig.CorsRules) && corsConfig.CorsRules.length > 0) await this.applyCorsConfiguration(bucketName, corsConfig);
7076
7611
  else if (corsConfig != null) this.announceCreateEmptyCollectionSkip("CorsConfiguration", "CorsConfiguration.CorsRules");
@@ -7421,11 +7956,9 @@ var S3BucketProvider = class S3BucketProvider {
7421
7956
  if (r.Status !== void 0) out["Status"] = r.Status;
7422
7957
  if (r.Prefix !== void 0) out["Prefix"] = r.Prefix;
7423
7958
  if (r.Expiration) {
7424
- const exp = {};
7425
- if (r.Expiration.Days !== void 0) exp["Days"] = r.Expiration.Days;
7426
- if (r.Expiration.Date !== void 0) exp["Date"] = r.Expiration.Date.toISOString();
7427
- if (r.Expiration.ExpiredObjectDeleteMarker !== void 0) exp["ExpiredObjectDeleteMarker"] = r.Expiration.ExpiredObjectDeleteMarker;
7428
- out["Expiration"] = exp;
7959
+ if (r.Expiration.Days !== void 0) out["ExpirationInDays"] = r.Expiration.Days;
7960
+ if (r.Expiration.Date !== void 0) out["ExpirationDate"] = r.Expiration.Date.toISOString();
7961
+ if (r.Expiration.ExpiredObjectDeleteMarker !== void 0) out["ExpiredObjectDeleteMarker"] = r.Expiration.ExpiredObjectDeleteMarker;
7429
7962
  }
7430
7963
  if (r.Transitions && r.Transitions.length > 0) out["Transitions"] = r.Transitions.map((t) => {
7431
7964
  const item = {};
@@ -58338,9 +58871,17 @@ function stackTreeToJson(roots) {
58338
58871
  * PR B2 — until that lands, the orchestrator hard-errors at submission
58339
58872
  * time with a clear pointer.
58340
58873
  *
58341
- * The list is intentionally narrow. Other resource types CFn may not yet
58342
- * support for import are surfaced as errors by the CreateChangeSet call
58343
- * itself; we do not try to maintain a closed allowlist here.
58874
+ * The list is intentionally narrow, and stays hand-written because each member
58875
+ * is a cdkd / CDK construct rather than something the registry describes.
58876
+ *
58877
+ * It is NOT the only refusal, though — the "defer to the CreateChangeSet
58878
+ * error" note this comment used to end on was revisited in issue
58879
+ * [#1659](https://github.com/go-to-k/cdkd/issues/1659). Types AWS refuses for
58880
+ * IMPORT because their registry schema declares no `read` handler are now
58881
+ * caught by {@link buildImportPlan}'s pre-flight instead, which needs no
58882
+ * hand-maintained list (the signal is derived from the schema cdkd already
58883
+ * fetches for the identifier) and names every offender in one pass — AWS's own
58884
+ * error names only some of them.
58344
58885
  */
58345
58886
  const NEVER_IMPORTABLE_TYPES = /* @__PURE__ */ new Set(["AWS::CDK::Metadata"]);
58346
58887
  /**
@@ -58513,6 +59054,16 @@ const COMPOSITE_ID_SPLITTERS = {
58513
59054
  propertiesOverlay: { ApiId: apiId }
58514
59055
  };
58515
59056
  },
59057
+ "AWS::S3Tables::Namespace": (physicalId) => {
59058
+ const parts = physicalId.split("|");
59059
+ if (parts.length !== 2) throw new Error(`expected 2 parts ('<tableBucketARN>|<namespaceName>'), got ${parts.length}: '${physicalId}'`);
59060
+ const [tableBucketARN, namespace] = parts;
59061
+ if (!tableBucketARN || !namespace) throw new Error(`empty part in '<tableBucketARN>|<namespaceName>': '${physicalId}'`);
59062
+ return { resourceIdentifier: {
59063
+ TableBucketARN: tableBucketARN,
59064
+ Namespace: namespace
59065
+ } };
59066
+ },
58516
59067
  "AWS::Lambda::Permission": (physicalId, properties) => {
58517
59068
  const functionName = readStringProperty(properties, "FunctionName", "AWS::Lambda::Permission");
58518
59069
  return {
@@ -58598,6 +59149,144 @@ function readStringProperty(properties, key, resourceType) {
58598
59149
  return v;
58599
59150
  }
58600
59151
  /**
59152
+ * Types whose cdkd physicalId is a COMPOSITE (`|`-joined) string while
59153
+ * CloudFormation's `primaryIdentifier` is a SINGLE field — so the physicalId is
59154
+ * NOT the identifier value, and it is not a SPLIT of the composite either
59155
+ * (issue [#1659](https://github.com/go-to-k/cdkd/issues/1659)).
59156
+ *
59157
+ * ## Why this table exists next to `COMPOSITE_ID_SPLITTERS`
59158
+ *
59159
+ * `resolveResourceIdentifier` used to conflate two independent questions: the
59160
+ * ARITY of the CFn identifier, and the FORMAT of cdkd's physical id. The
59161
+ * single-field branch assumed "the physicalId IS the identifier value", which
59162
+ * holds for every type whose id is a scalar and silently produces a wrong value
59163
+ * for a composite-id type whose CFn identifier happens to be single-field —
59164
+ * `{ TableARN: 'arn:aws:s3tables:...|analytics|events' }` for an
59165
+ * `AWS::S3Tables::Table`. `COMPOSITE_ID_SPLITTERS` cannot fix that: it is only
59166
+ * consulted on the multi-field branch, so registering a type there would add an
59167
+ * entry no code path reaches.
59168
+ *
59169
+ * This table is consulted BEFORE the arity branch, so the two concerns are
59170
+ * separated: a registered type never takes the "physicalId is the identifier"
59171
+ * assumption regardless of what the schema's field count says.
59172
+ *
59173
+ * ## Why a RESOLVE and not a split
59174
+ *
59175
+ * The correct identifier for each member is a different value entirely, not a
59176
+ * segment of the composite — an ARN AWS minted, or an `sgr-` rule id. So each
59177
+ * entry reads the value cdkd recorded in state (`attributes`), and refuses when
59178
+ * it is absent rather than guessing.
59179
+ *
59180
+ * ## Live measurement (us-east-1, 2026-08-13)
59181
+ *
59182
+ * Every field below is BOTH the type's single-field `primaryIdentifier` AND
59183
+ * `readOnlyProperties`, which is why each resolution returns an EMPTY
59184
+ * `propertiesOverlay`: writing a read-only property into the synth template's
59185
+ * `Properties` block is rejected by CFn at changeset-create.
59186
+ *
59187
+ * | Type | `primaryIdentifier` | recorded as |
59188
+ * |---|---|---|
59189
+ * | `AWS::AppSync::DataSource` | `DataSourceArn` | `attributes.DataSourceArn` |
59190
+ * | `AWS::AppSync::Resolver` | `ResolverArn` | `attributes.ResolverArn` |
59191
+ * | `AWS::S3Tables::Table` | `TableARN` | `attributes.TableARN` |
59192
+ * | `AWS::EC2::SecurityGroupIngress` | `Id` | NOTHING (issue #1761) |
59193
+ *
59194
+ * All four declare a `read` handler, so CFn genuinely does accept them for
59195
+ * IMPORT — unlike `AWS::Glue::Table`, the type issue #1659's title names, which
59196
+ * CFn rejects outright (see {@link typeIsImportUnsupported}).
59197
+ */
59198
+ const COMPOSITE_PHYSICAL_ID_IDENTIFIERS = {
59199
+ "AWS::AppSync::DataSource": recordedArnIdentifier({
59200
+ resourceType: "AWS::AppSync::DataSource",
59201
+ field: "DataSourceArn",
59202
+ physicalIdShape: "<apiId>|<name>",
59203
+ arnServiceSegment: "appsync"
59204
+ }),
59205
+ "AWS::AppSync::Resolver": recordedArnIdentifier({
59206
+ resourceType: "AWS::AppSync::Resolver",
59207
+ field: "ResolverArn",
59208
+ physicalIdShape: "<apiId>|<typeName>|<fieldName>",
59209
+ arnServiceSegment: "appsync"
59210
+ }),
59211
+ "AWS::S3Tables::Table": recordedArnIdentifier({
59212
+ resourceType: "AWS::S3Tables::Table",
59213
+ field: "TableARN",
59214
+ physicalIdShape: "<tableBucketARN>|<namespace>|<name>",
59215
+ arnServiceSegment: "s3tables"
59216
+ }),
59217
+ "AWS::EC2::SecurityGroupIngress": {
59218
+ field: "Id",
59219
+ resolve: ({ logicalId }) => {
59220
+ throw new Error(`cdkd's physical id for AWS::EC2::SecurityGroupIngress is the composite '<groupId>|<ipProtocol>|<fromPort>|<toPort>', but CloudFormation IMPORT identifies the rule by the 'sgr-...' security-group rule id AWS assigns, which cdkd does not record in state (tracked in https://github.com/go-to-k/cdkd/issues/1761). Remove '${logicalId}' from the stack before exporting (the rule stays in AWS and can be re-declared in CloudFormation afterwards), or destroy it first and let CloudFormation create it fresh.`);
59221
+ }
59222
+ }
59223
+ };
59224
+ /**
59225
+ * Build the {@link CompositePhysicalIdIdentifier} for a type whose CFn
59226
+ * identifier is an ARN cdkd records in `attributes` under the SAME name as the
59227
+ * `primaryIdentifier` field.
59228
+ *
59229
+ * Two accepted sources, mirroring the splitter family's "both shapes exist in
59230
+ * state in the wild" contract:
59231
+ *
59232
+ * 1. the recorded attribute, which is what a fresh deploy and a cdkd `import`
59233
+ * both write; and
59234
+ * 2. a physicalId that is ALREADY the bare ARN. Two live producers:
59235
+ * `S3TablesProvider.importTable` records it for a Cloud-Control-routed
59236
+ * table (CC's identifier for the type IS the ARN), and an AppSync child
59237
+ * adopted through `cdkd import --migrate-from-cloudformation` records
59238
+ * CloudFormation's `PhysicalResourceId` verbatim, which is likewise the
59239
+ * ARN.
59240
+ *
59241
+ * **Both sources are validated by the SAME predicate.** An earlier cut checked
59242
+ * the ARN's shape only on source 2 — the rare one — while source 1, which
59243
+ * fires on essentially every export, accepted any non-blank string verbatim.
59244
+ * That is the wrong way round: a hand-edited state row, a row written by an
59245
+ * older binary that recorded the composite under the attribute name, or an ARN
59246
+ * for a different service sitting under `attributes.<field>` would each have
59247
+ * been shipped as the IMPORT identifier and silently adopted the WRONG
59248
+ * resource, which is precisely what this table exists to prevent.
59249
+ *
59250
+ * Anything failing both sources is refused. That covers the degraded record
59251
+ * `docs/state-management.md` describes — written by a cdkd older than the fix
59252
+ * that started recording the ARN, or by an import that could not reach STS —
59253
+ * and the remedy is the one that doc already gives.
59254
+ */
59255
+ function recordedArnIdentifier(options) {
59256
+ const { resourceType, field, physicalIdShape, arnServiceSegment } = options;
59257
+ /**
59258
+ * Is this value usable AS the type's CFn identifier?
59259
+ *
59260
+ * - `arn:` prefix and the type's own SERVICE segment: an ARN naming another
59261
+ * service is a different value entirely. Partition-agnostic by
59262
+ * construction, so `arn:aws-cn:s3tables:...` matches.
59263
+ * - no `|`: cdkd's composite for `AWS::S3Tables::Table` is built FROM the
59264
+ * bucket ARN, so it satisfies both checks above — the separator test is
59265
+ * what distinguishes the composite from the identifier, on BOTH sources.
59266
+ */
59267
+ const isUsableIdentifier = (value) => {
59268
+ const trimmed = value.trim();
59269
+ return trimmed.startsWith("arn:") && trimmed.includes(`:${arnServiceSegment}:`) && !trimmed.includes("|");
59270
+ };
59271
+ return {
59272
+ field,
59273
+ resolve: ({ logicalId, physicalId, attributes }) => {
59274
+ const recorded = attributes[field];
59275
+ if (typeof recorded === "string" && isUsableIdentifier(recorded)) return recorded.trim();
59276
+ if (isUsableIdentifier(physicalId)) return physicalId.trim();
59277
+ const recordedNote = typeof recorded === "string" && recorded.trim() ? `attributes.${field} is recorded as '${recorded.trim()}', which is not a ${arnServiceSegment} ARN` : `attributes.${field} is missing or empty`;
59278
+ throw new Error(`cdkd's physical id for ${resourceType} is the composite '${physicalIdShape}', but CloudFormation IMPORT identifies the resource by its ${field}, which cdkd state does not record usably for '${logicalId}' (${recordedNote}). Re-deploy the stack once so cdkd records ${field}, then re-run cdkd export.`);
59279
+ }
59280
+ };
59281
+ }
59282
+ /**
59283
+ * Every type registered in {@link COMPOSITE_PHYSICAL_ID_IDENTIFIERS}. Exported
59284
+ * so a test can assert the table's SIZE, not just membership — a new entry
59285
+ * needs a measurement row in the table's doc comment, and a size-blind test
59286
+ * would let one land silently.
59287
+ */
59288
+ const COMPOSITE_PHYSICAL_ID_IDENTIFIER_TYPES = Object.keys(COMPOSITE_PHYSICAL_ID_IDENTIFIERS);
59289
+ /**
58601
59290
  * Resource types AWS does NOT support in IMPORT changesets but DOES
58602
59291
  * support normal CREATE for. cdkd handles these via a pre-delete + phase-2
58603
59292
  * CREATE dance (see {@link RecreateBeforePhase2Entry}). When the user
@@ -58751,17 +59440,20 @@ async function exportCommand(stackArg, options) {
58751
59440
  }
58752
59441
  }
58753
59442
  }
59443
+ const { phase1Imports, phase2Creates, recreateBeforePhase2, nestedStackRows, blocked } = await buildImportPlan(state, template, awsClients.cloudFormation, resolvedStackName, {
59444
+ recreateImportUnsupported: options.recreateImportUnsupported,
59445
+ skipImportSupportPreflight: options.skipImportSupportPreflight
59446
+ });
59447
+ if (blocked.length > 0) {
59448
+ logger.error("The following resources block migration:");
59449
+ for (const b of blocked) logger.error(` - ${b.logicalId} (${b.resourceType}): ${b.reason}`);
59450
+ throw new Error(`${blocked.length} resource(s) block migration. Either destroy them first (cdkd destroy / cdkd state destroy cherry-picked), or remove them from the CDK app and re-synthesize.`);
59451
+ }
58754
59452
  const owner = `${process.env["USER"] || "unknown"}@${process.env["HOSTNAME"] || "host"}:${process.pid}`;
58755
59453
  if (!options.dryRun) {
58756
59454
  if (!await lockManager.acquireLock(resolvedStackName, targetRegion, owner, "export")) throw new Error(`Could not acquire lock for stack '${resolvedStackName}' (${targetRegion}) — another cdkd process holds it. Wait for it to finish, or run 'cdkd force-unlock ${resolvedStackName}' if you are certain no other process is active.`);
58757
59455
  }
58758
59456
  try {
58759
- const { phase1Imports, phase2Creates, recreateBeforePhase2, nestedStackRows, blocked } = await buildImportPlan(state, template, awsClients.cloudFormation, resolvedStackName, { recreateImportUnsupported: options.recreateImportUnsupported });
58760
- if (blocked.length > 0) {
58761
- logger.error("The following resources block migration:");
58762
- for (const b of blocked) logger.error(` - ${b.logicalId} (${b.resourceType}): ${b.reason}`);
58763
- throw new Error(`${blocked.length} resource(s) block migration. Either destroy them first (cdkd destroy / cdkd state destroy cherry-picked), or remove them from the CDK app and re-synthesize.`);
58764
- }
58765
59457
  if (nestedStackRows.length > 0) {
58766
59458
  const nestedStackTree = await buildCdkdStateStackTree(resolvedStackName, targetRegion, stateBackend, state);
58767
59459
  reportDriftBaselineGaps(state, logger);
@@ -58804,7 +59496,8 @@ async function exportCommand(stackArg, options) {
58804
59496
  dryRun: options.dryRun,
58805
59497
  yes: options.yes,
58806
59498
  includeNonImportable: options.includeNonImportable,
58807
- recreateImportUnsupported: options.recreateImportUnsupported
59499
+ recreateImportUnsupported: options.recreateImportUnsupported,
59500
+ skipImportSupportPreflight: options.skipImportSupportPreflight
58808
59501
  }
58809
59502
  })).outcome === "success") printNextSteps({
58810
59503
  cfnStackName: options.cfnStackName ?? cdkd2cfnStackName(resolvedStackName),
@@ -59343,7 +60036,16 @@ async function buildImportPlan(state, template, cfnClient, parentStackName, opti
59343
60036
  }
59344
60037
  let resolved;
59345
60038
  try {
59346
- resolved = await resolveResourceIdentifier(resourceType, stateEntry.physicalId, stateEntry.properties ?? {}, cfnClient, identifierCache);
60039
+ const schemaInfo = await cachedTypeSchemaInfo(resourceType, cfnClient, identifierCache);
60040
+ if (!options.skipImportSupportPreflight && typeIsImportUnsupported(schemaInfo)) {
60041
+ blocked.push({
60042
+ logicalId,
60043
+ resourceType,
60044
+ reason: `AWS CloudFormation does not support ${resourceType} in IMPORT changesets: its CloudFormation registry schema declares no 'read' handler (and reports the type as NON_PROVISIONABLE), and IMPORT needs that handler to look the resource up by identifier. CreateChangeSet would reject the whole export with "ResourceTypes [${resourceType}] are not supported for Import". Remove the resource from the stack before exporting (it stays in AWS and can be re-declared in CloudFormation afterwards), or destroy it first and let CloudFormation create it fresh. If AWS has since added IMPORT support for this type, please open a cdkd issue.`
60045
+ });
60046
+ continue;
60047
+ }
60048
+ resolved = resolveResourceIdentifier(resourceType, logicalId, stateEntry.physicalId, stateEntry.properties ?? {}, stateEntry.attributes ?? {}, schemaInfo);
59347
60049
  } catch (err) {
59348
60050
  blocked.push({
59349
60051
  logicalId,
@@ -59373,6 +60075,11 @@ async function buildImportPlan(state, template, cfnClient, parentStackName, opti
59373
60075
  * `ResourcesToImport[].ResourceIdentifier` for the given resource type
59374
60076
  * and cdkd state's physical ID.
59375
60077
  *
60078
+ * Types whose cdkd physicalId is COMPOSITE while the CFn identifier is a
60079
+ * single field (`COMPOSITE_PHYSICAL_ID_IDENTIFIERS`): resolved from cdkd
60080
+ * state's recorded attributes, BEFORE the field-count branch below — the
60081
+ * physicalId is neither the identifier nor a source to split (issue #1659).
60082
+ *
59376
60083
  * Single-key types: the map has a single entry keyed by the schema's
59377
60084
  * primaryIdentifier field name (e.g. `{ BucketName: 'my-bucket' }`).
59378
60085
  *
@@ -59382,16 +60089,27 @@ async function buildImportPlan(state, template, cfnClient, parentStackName, opti
59382
60089
  * into one entry per field. Composite types without a registered
59383
60090
  * splitter surface a clear error pointing at where to add one.
59384
60091
  *
59385
- * Prefers `DescribeType` (the authoritative AWS-internal registry) and
59386
- * falls back to a hardcoded single-key table when DescribeType fails
59387
- * (insufficient permissions, throttling, obscure type without a registry
59388
- * entry).
59389
- */
59390
- async function resolveResourceIdentifier(resourceType, physicalId, properties, cfnClient, cache) {
59391
- let entry = cache.get(resourceType);
59392
- if (entry === void 0) {
59393
- entry = await fetchPrimaryIdentifier(resourceType, cfnClient);
59394
- cache.set(resourceType, entry);
60092
+ * `entry` is the caller's already-resolved registry-schema info — see
60093
+ * {@link cachedTypeSchemaInfo}, which prefers `DescribeType` (the authoritative
60094
+ * AWS-internal registry) and falls back to a hardcoded single-key table when
60095
+ * DescribeType fails (insufficient permissions, throttling, obscure type
60096
+ * without a registry entry). Taking it as a parameter rather than fetching is
60097
+ * what keeps `buildImportPlan` to ONE DescribeType per type across both the
60098
+ * IMPORT pre-flight and this resolution.
60099
+ */
60100
+ function resolveResourceIdentifier(resourceType, logicalId, physicalId, properties, attributes, entry) {
60101
+ const compositeIdentifier = COMPOSITE_PHYSICAL_ID_IDENTIFIERS[resourceType];
60102
+ if (compositeIdentifier) {
60103
+ if (entry.fields.length !== 1 || entry.fields[0] !== compositeIdentifier.field) throw new Error(`${resourceType} is registered in COMPOSITE_PHYSICAL_ID_IDENTIFIERS as identified by '${compositeIdentifier.field}', but CloudFormation now reports primaryIdentifier [${entry.fields.join(", ")}]. The registry schema changed under cdkd — update the entry in src/cli/commands/export.ts (or move the type to COMPOSITE_ID_SPLITTERS if its identifier is now composite too)`);
60104
+ const value = compositeIdentifier.resolve({
60105
+ logicalId,
60106
+ physicalId,
60107
+ attributes
60108
+ });
60109
+ return {
60110
+ resourceIdentifier: { [compositeIdentifier.field]: value },
60111
+ propertiesOverlay: {}
60112
+ };
59395
60113
  }
59396
60114
  if (entry.fields.length === 1) return { resourceIdentifier: { [entry.fields[0]]: physicalId } };
59397
60115
  const splitter = COMPOSITE_ID_SPLITTERS[resourceType];
@@ -59406,6 +60124,53 @@ async function resolveResourceIdentifier(resourceType, physicalId, properties, c
59406
60124
  return result;
59407
60125
  }
59408
60126
  /**
60127
+ * Cache-consulting wrapper around {@link fetchPrimaryIdentifier}. Shared by
60128
+ * `resolveResourceIdentifier` and `buildImportPlan`'s pre-flight so one
60129
+ * DescribeType per type answers both questions.
60130
+ */
60131
+ async function cachedTypeSchemaInfo(resourceType, cfnClient, cache) {
60132
+ const cached = cache.get(resourceType);
60133
+ if (cached !== void 0) return cached;
60134
+ const entry = await fetchPrimaryIdentifier(resourceType, cfnClient);
60135
+ cache.set(resourceType, entry);
60136
+ return entry;
60137
+ }
60138
+ /**
60139
+ * True when the registry schema says CloudFormation will refuse the type in an
60140
+ * IMPORT changeset (`ValidationError: ResourceTypes [<T>] are not supported
60141
+ * for Import`) — IMPORT looks the resource up by identifier through the type's
60142
+ * `read` handler, so a type without one cannot be adopted.
60143
+ *
60144
+ * Live-measured (us-east-1, 2026-08-13) against `CreateChangeSet
60145
+ * --change-set-type IMPORT`: `AWS::Glue::Table` / `AWS::Route53::RecordSet` /
60146
+ * `AWS::AppSync::ApiKey` / `AWS::EC2::NetworkAclEntry` /
60147
+ * `AWS::Route53::RecordSetGroup` / `AWS::SQS::QueuePolicy` /
60148
+ * `AWS::SNS::TopicPolicy` all lack a `read` handler, are all
60149
+ * `NON_PROVISIONABLE`, and were all rejected; `AWS::S3::Bucket`
60150
+ * (`FULLY_MUTABLE`, read handler present) passed the type check. Both shapes
60151
+ * of "no read handler" occur and both count: NO `handlers` block at all (the
60152
+ * legacy types) and a `handlers` block whose keys are only create / update /
60153
+ * delete (`AWS::EC2::NetworkAclEntry`).
60154
+ *
60155
+ * **Why the verdict needs TWO agreeing fields.** Reading "the `handlers` key is
60156
+ * missing" as a refusal on its own makes any partial or unusual response block
60157
+ * an export that works today — the one regression this pre-flight could cause,
60158
+ * and strictly worse than the status quo of letting AWS answer. So the refusal
60159
+ * also requires `ProvisioningType: NON_PROVISIONABLE`; a response satisfying
60160
+ * only one of the two resolves to `unknown` and is left to CreateChangeSet.
60161
+ *
60162
+ * The two were perfectly CORRELATED across the 21 types sampled on 2026-08-13
60163
+ * (every no-read-handler type was NON_PROVISIONABLE and vice versa), so on
60164
+ * today's registry the conjunction's real work is requiring a COMPLETE
60165
+ * DescribeType response before refusing. The `read` handler stays the primary
60166
+ * signal because it is the mechanistic reason CFn accepts a type for IMPORT —
60167
+ * refusing on `ProvisioningType` alone would reject a NON_PROVISIONABLE type
60168
+ * that does declare one, should AWS ever publish that combination.
60169
+ */
60170
+ function typeIsImportUnsupported(entry) {
60171
+ return entry.importSupport === "unsupported";
60172
+ }
60173
+ /**
59409
60174
  * Fetch the primary identifier field names for a resource type, with a
59410
60175
  * hardcoded single-key fallback when DescribeType is unavailable.
59411
60176
  */
@@ -59413,15 +60178,26 @@ async function fetchPrimaryIdentifier(resourceType, cfnClient) {
59413
60178
  try {
59414
60179
  const resp = await describeTypeWithThrottleRetry(resourceType, cfnClient);
59415
60180
  if (resp.Schema) {
59416
- const primary = JSON.parse(resp.Schema).primaryIdentifier;
59417
- if (Array.isArray(primary) && primary.length > 0 && primary.every((p) => typeof p === "string")) return { fields: primary.map((p) => p.replace(/^\/properties\//, "")) };
60181
+ const parsed = JSON.parse(resp.Schema);
60182
+ const primary = parsed.primaryIdentifier;
60183
+ if (Array.isArray(primary) && primary.length > 0 && primary.every((p) => typeof p === "string")) {
60184
+ const fields = primary.map((p) => p.replace(/^\/properties\//, ""));
60185
+ const handlers = parsed.handlers;
60186
+ return {
60187
+ fields,
60188
+ importSupport: typeof handlers === "object" && handlers !== null && Object.prototype.hasOwnProperty.call(handlers, "read") ? "importable" : resp.ProvisioningType === "NON_PROVISIONABLE" ? "unsupported" : "unknown"
60189
+ };
60190
+ }
59418
60191
  }
59419
60192
  } catch (err) {
59420
60193
  const msg = err instanceof Error ? err.message : String(err);
59421
60194
  getLogger().debug(`DescribeType failed for ${resourceType}: ${msg} — using fallback`);
59422
60195
  }
59423
60196
  const fallback = PRIMARY_IDENTIFIER_FALLBACK[resourceType];
59424
- if (fallback) return { fields: [fallback] };
60197
+ if (fallback) return {
60198
+ fields: [fallback],
60199
+ importSupport: "unknown"
60200
+ };
59425
60201
  throw new Error(`primary identifier unknown (DescribeType returned no usable schema and no fallback is registered). Add ${resourceType} to PRIMARY_IDENTIFIER_FALLBACK in export.ts, or open an issue.`);
59426
60202
  }
59427
60203
  /**
@@ -60347,7 +61123,10 @@ async function runPerStackImportLoop(args) {
60347
61123
  const perStackPlans = [];
60348
61124
  for (const node of leafFirst) {
60349
61125
  const meta = nodesByCdkdName.get(node.stackName);
60350
- const plan = await buildImportPlan(meta.state, meta.template, deps.cfnClient, meta.cdkdStackName, { recreateImportUnsupported: options.recreateImportUnsupported });
61126
+ const plan = await buildImportPlan(meta.state, meta.template, deps.cfnClient, meta.cdkdStackName, {
61127
+ recreateImportUnsupported: options.recreateImportUnsupported,
61128
+ skipImportSupportPreflight: options.skipImportSupportPreflight === true
61129
+ });
60351
61130
  if (plan.blocked.length > 0) {
60352
61131
  const lines = plan.blocked.map((b) => ` - ${b.logicalId} (${b.resourceType}): ${b.reason}`);
60353
61132
  throw new Error(`Stack '${meta.cdkdStackName}' has ${plan.blocked.length} resource(s) that block migration:\n${lines.join("\n")}`);
@@ -60763,7 +61542,7 @@ async function confirmPrompt$3(prompt) {
60763
61542
  }
60764
61543
  }
60765
61544
  function createExportCommand() {
60766
- const cmd = new Command("export").description("Hand a cdkd-managed stack over to CloudFormation via CFn IMPORT (changeset). AWS resources are unchanged; cdkd state for the stack is deleted on success. Mirror of `cdkd import` (AWS → cdkd) in the reverse direction (cdkd → CFn). Accepts JSON and YAML templates (YAML via a CFn-aware codec that preserves !Ref / !GetAtt / !Sub shorthand). Aborts if any resource is not CFn-importable.").argument("[stack]", "Stack name to export (auto-detected for single-stack apps)").option("--cfn-stack-name <name>", "Name of the destination CloudFormation stack for the root stack. Defaults to the cdkd stack name (or the cdkd2cfnStackName mapping when the name contains '~'). For per-nested-child overrides, use --cfn-child-stack-name.").option("--cfn-child-stack-name <pair...>", "Per-nested-child CFn stack-name override. Repeatable. Format: '<cdkdName>=<cfnName>' (e.g. --cfn-child-stack-name 'MyApp~Database=my-app-db'). The cdkd stack name for a nested child is '<parent>~<childLogicalId>' (v6 state-key form). Without an override the CFn name is derived by replacing '~' with '-'. Only consulted when the exported stack tree contains nested children; ignored for flat stacks.").option("--template <path>", "Path to a pre-rendered CloudFormation template (JSON or YAML — format auto-detected). Skips synth.").option("--stack-region <region>", "Region of the cdkd state record to operate on. Required when the same stack name has state in multiple regions.").option("--dry-run", "Print the import plan without creating a changeset.", false).option("--accept-transient-context", "Allow CLI -c key=value overrides at export time even though they are not persisted to cdk.json / cdk.context.json (default: refuse). When set, the user is responsible for passing the same -c flags to every future cdk deploy.", false).option("--include-non-importable", "Run a 2-phase migration when the stack contains non-importable resources (Custom::*). Phase 1 imports the importable resources; phase 2 CFn-CREATEs the non-importable ones, which re-invokes each Custom Resource's onCreate handler. Make sure onCreate is idempotent before enabling.", false).option("--parameter <key=value...>", "CFn template Parameter override, repeatable. Required when the synthesized template has Parameters without Default values; otherwise overrides the template's default value. Format: --parameter Key=Value.").option("--strict-cross-stack", "Refuse to export when sibling cdkd stacks in the same CDK app reference the exporting stack via Fn::GetStackOutput. Without the flag, cdkd warns and proceeds — the consumers keep resolving via the CloudFormation fallback (weak reference) unless they deploy with --no-cfn-fallback.", false).option("--no-recreate-import-unsupported", "Block instead of auto-handling resource types AWS does NOT support in IMPORT changesets (currently only AWS::ApiGatewayV2::Stage, emitted by CDK HttpApi). Default behavior: cdkd skips these from phase 1, deletes the AWS-side resource between phases, and lets CFn re-CREATE in phase 2 (brief unavailability window). With this flag, the export aborts with a clear error instead.").action(withErrorHandling(exportCommand));
61545
+ const cmd = new Command("export").description("Hand a cdkd-managed stack over to CloudFormation via CFn IMPORT (changeset). AWS resources are unchanged; cdkd state for the stack is deleted on success. Mirror of `cdkd import` (AWS → cdkd) in the reverse direction (cdkd → CFn). Accepts JSON and YAML templates (YAML via a CFn-aware codec that preserves !Ref / !GetAtt / !Sub shorthand). Aborts if any resource is not CFn-importable.").argument("[stack]", "Stack name to export (auto-detected for single-stack apps)").option("--cfn-stack-name <name>", "Name of the destination CloudFormation stack for the root stack. Defaults to the cdkd stack name (or the cdkd2cfnStackName mapping when the name contains '~'). For per-nested-child overrides, use --cfn-child-stack-name.").option("--cfn-child-stack-name <pair...>", "Per-nested-child CFn stack-name override. Repeatable. Format: '<cdkdName>=<cfnName>' (e.g. --cfn-child-stack-name 'MyApp~Database=my-app-db'). The cdkd stack name for a nested child is '<parent>~<childLogicalId>' (v6 state-key form). Without an override the CFn name is derived by replacing '~' with '-'. Only consulted when the exported stack tree contains nested children; ignored for flat stacks.").option("--template <path>", "Path to a pre-rendered CloudFormation template (JSON or YAML — format auto-detected). Skips synth.").option("--stack-region <region>", "Region of the cdkd state record to operate on. Required when the same stack name has state in multiple regions.").option("--dry-run", "Print the import plan without creating a changeset.", false).option("--accept-transient-context", "Allow CLI -c key=value overrides at export time even though they are not persisted to cdk.json / cdk.context.json (default: refuse). When set, the user is responsible for passing the same -c flags to every future cdk deploy.", false).option("--include-non-importable", "Run a 2-phase migration when the stack contains non-importable resources (Custom::*). Phase 1 imports the importable resources; phase 2 CFn-CREATEs the non-importable ones, which re-invokes each Custom Resource's onCreate handler. Make sure onCreate is idempotent before enabling.", false).option("--parameter <key=value...>", "CFn template Parameter override, repeatable. Required when the synthesized template has Parameters without Default values; otherwise overrides the template's default value. Format: --parameter Key=Value.").option("--strict-cross-stack", "Refuse to export when sibling cdkd stacks in the same CDK app reference the exporting stack via Fn::GetStackOutput. Without the flag, cdkd warns and proceeds — the consumers keep resolving via the CloudFormation fallback (weak reference) unless they deploy with --no-cfn-fallback.", false).option("--skip-import-support-preflight", "Skip the pre-flight that refuses resource types whose CloudFormation registry schema says CFn cannot IMPORT them (no read handler AND NON_PROVISIONABLE), and let CreateChangeSet answer instead. The pre-flight is a registry HEURISTIC, not AWS's supported-for-import list, so this is the escape hatch for a type AWS has since made importable. Expect the changeset to be rejected if the heuristic was right.", false).option("--no-recreate-import-unsupported", "Block instead of auto-handling resource types AWS does NOT support in IMPORT changesets (currently only AWS::ApiGatewayV2::Stage, emitted by CDK HttpApi). Default behavior: cdkd skips these from phase 1, deletes the AWS-side resource between phases, and lets CFn re-CREATE in phase 2 (brief unavailability window). With this flag, the export aborts with a clear error instead.").action(withErrorHandling(exportCommand));
60767
61546
  [
60768
61547
  ...commonOptions,
60769
61548
  ...appOptions,
@@ -74608,7 +75387,7 @@ function createMigrateCommand() {
74608
75387
  */
74609
75388
  function buildProgram() {
74610
75389
  const program = new Command();
74611
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.281.23");
75390
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.281.25");
74612
75391
  program.hook("preAction", (_thisCommand, actionCommand) => {
74613
75392
  const { profile } = actionCommand.optsWithGlobals();
74614
75393
  if (profile !== void 0) process.env["AWS_PROFILE"] = profile;