@go-to-k/cdkd 0.97.0 → 0.98.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -335,6 +335,31 @@ full reference. For per-resource-type provisioning support (SDK Providers
335
335
  vs Cloud Control API fallback), see
336
336
  **[docs/supported-resources.md](docs/supported-resources.md)**.
337
337
 
338
+ ### Cross-stack references — strong vs weak
339
+
340
+ `Fn::ImportValue` is a **strong reference**: `cdkd destroy <producer>`
341
+ refuses to delete a stack while any other stack still imports one of
342
+ its exports via `Fn::ImportValue`. Matches CloudFormation's behavior.
343
+ The error message names every offending consumer and points at the
344
+ two valid resolution paths (destroy the consumer first, or remove
345
+ the `Fn::ImportValue` from the consumer's template and redeploy).
346
+
347
+ `Fn::GetStackOutput` (cdkd-specific) is a **weak reference**: the
348
+ producer stays deletable independently of consumers. Use it when you
349
+ intentionally want decoupled lifecycles (cross-region / cross-stage /
350
+ staging environments).
351
+
352
+ A persistent per-region exports index at
353
+ `s3://{state-bucket}/cdkd/_index/{region}/exports.json` makes
354
+ `Fn::ImportValue` resolution O(1) at scale (200-stack environments
355
+ resolve in ~100ms vs minutes with the pre-#343 per-resolve scan).
356
+ The index is a derived view rebuilt from `state.json` on demand —
357
+ state.json remains the canonical source of truth, and strong-reference
358
+ safety checks scan it directly rather than trusting the index.
359
+
360
+ See **[docs/cross-stack-references.md](docs/cross-stack-references.md)**
361
+ for the full design (schema v4, lifecycle, locking, failure modes).
362
+
338
363
  ## Rollback behavior
339
364
 
340
365
  When a deploy fails mid-stack (e.g. a resource hits a validation error
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { a as setAwsClients, i as resetAwsClients, r as getAwsClients, t as AwsClients } from "./aws-clients-CuHRHcyW.js";
3
- import { A as resolveCaptureObservedState, C as stringifyValue, D as getDefaultStateBucketName, E as Synthesizer, F as AssemblyReader, G as ProvisioningError, H as LocalInvokeBuildError, J as RouteDiscoveryError, K as ResourceTimeoutError, L as resolveBucketRegion, M as resolveStateBucketWithDefault, N as resolveStateBucketWithDefaultAndSource, O as getLegacyStateBucketName, P as warnDeprecatedNoPrefixCliFlag, S as AssetPublisher, T as buildDockerImage, W as PartialFailureError, Y as StackTerminationProtectionError, _ as DiffCalculator, a as withRetry, at as runStackBuffered, b as LockManager, c as collectInlinePolicyNamesManagedBySiblings, ct as PATTERN_B_RESOURCE_TYPES, d as normalizeAwsTagsToCfn, dt as withSkipPrefix, et as normalizeAwsError, f as resolveExplicitPhysicalId, ft as withStackName, g as IntrinsicFunctionResolver, h as assertRegionMatch, i as withResourceDeadline, j as resolveSkipPrefix, k as resolveApp, l as CDK_PATH_TAG, lt as generateResourceName, m as CloudControlProvider, n as DEFAULT_RESOURCE_WARN_AFTER_MS, o as IMPLICIT_DELETE_DEPENDENCIES, ot as getLiveRenderer, p as ProviderRegistry, q as ResourceUpdateNotSupportedError, r as DeployEngine, rt as getLogger, s as IAMRoleProvider, st as PATTERN_B_NAME_PROPERTIES, t as DEFAULT_RESOURCE_TIMEOUT_MS, tt as withErrorHandling, u as matchesCdkPath, ut as generateResourceNameWithFallback, v as DagBuilder, w as WorkGraph, x as S3StateBackend, y as TemplateParser, z as CdkdError } from "./deploy-engine-Cl7v7Ml5.js";
3
+ import { A as resolveCaptureObservedState, C as stringifyValue, D as getDefaultStateBucketName, E as Synthesizer, F as AssemblyReader, G as ProvisioningError, H as LocalInvokeBuildError, J as RouteDiscoveryError, K as ResourceTimeoutError, L as resolveBucketRegion, M as resolveStateBucketWithDefault, N as resolveStateBucketWithDefaultAndSource, O as getLegacyStateBucketName, P as warnDeprecatedNoPrefixCliFlag, S as AssetPublisher, T as buildDockerImage, W as PartialFailureError, X as StackTerminationProtectionError, Y as StackHasActiveImportsError, _ as DiffCalculator, a as withRetry, b as LockManager, c as collectInlinePolicyNamesManagedBySiblings, ct as PATTERN_B_NAME_PROPERTIES, d as normalizeAwsTagsToCfn, dt as generateResourceNameWithFallback, f as resolveExplicitPhysicalId, ft as withSkipPrefix, g as IntrinsicFunctionResolver, h as assertRegionMatch, i as withResourceDeadline, it as getLogger, j as resolveSkipPrefix, k as resolveApp, l as CDK_PATH_TAG, lt as PATTERN_B_RESOURCE_TYPES, m as CloudControlProvider, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nt as withErrorHandling, o as IMPLICIT_DELETE_DEPENDENCIES, ot as runStackBuffered, p as ProviderRegistry, pt as withStackName, q as ResourceUpdateNotSupportedError, r as DeployEngine, s as IAMRoleProvider, st as getLiveRenderer, t as DEFAULT_RESOURCE_TIMEOUT_MS, tt as normalizeAwsError, u as matchesCdkPath, ut as generateResourceName, v as DagBuilder, w as WorkGraph, x as S3StateBackend, y as TemplateParser, z as CdkdError } from "./deploy-engine-D44ADMVs.js";
4
4
  import { createHash, createPublicKey, createVerify, randomBytes, randomUUID } from "node:crypto";
5
- import { CopyObjectCommand, CreateBucketCommand, DeleteBucketAnalyticsConfigurationCommand, DeleteBucketCommand, DeleteBucketCorsCommand, DeleteBucketIntelligentTieringConfigurationCommand, DeleteBucketInventoryConfigurationCommand, DeleteBucketLifecycleCommand, DeleteBucketMetricsConfigurationCommand, DeleteBucketPolicyCommand, DeleteBucketReplicationCommand, DeleteBucketTaggingCommand, DeleteBucketWebsiteCommand, DeleteObjectCommand, DeleteObjectsCommand, GetBucketAccelerateConfigurationCommand, GetBucketCorsCommand, GetBucketEncryptionCommand, GetBucketLifecycleConfigurationCommand, GetBucketLocationCommand, GetBucketLoggingCommand, GetBucketNotificationConfigurationCommand, GetBucketPolicyCommand, GetBucketReplicationCommand, GetBucketTaggingCommand, GetBucketVersioningCommand, GetBucketWebsiteCommand, GetObjectCommand, GetObjectLockConfigurationCommand, GetPublicAccessBlockCommand, HeadBucketCommand, ListBucketAnalyticsConfigurationsCommand, ListBucketIntelligentTieringConfigurationsCommand, ListBucketInventoryConfigurationsCommand, ListBucketMetricsConfigurationsCommand, ListBucketsCommand, ListDirectoryBucketsCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchBucket, PutBucketAccelerateConfigurationCommand, PutBucketAnalyticsConfigurationCommand, PutBucketCorsCommand, PutBucketEncryptionCommand, PutBucketIntelligentTieringConfigurationCommand, PutBucketInventoryConfigurationCommand, PutBucketLifecycleConfigurationCommand, PutBucketLoggingCommand, PutBucketMetricsConfigurationCommand, PutBucketNotificationConfigurationCommand, PutBucketOwnershipControlsCommand, PutBucketPolicyCommand, PutBucketReplicationCommand, PutBucketTaggingCommand, PutBucketVersioningCommand, PutBucketWebsiteCommand, PutObjectCommand, PutObjectLockConfigurationCommand, PutPublicAccessBlockCommand, S3Client } from "@aws-sdk/client-s3";
5
+ import { CopyObjectCommand, CreateBucketCommand, DeleteBucketAnalyticsConfigurationCommand, DeleteBucketCommand, DeleteBucketCorsCommand, DeleteBucketIntelligentTieringConfigurationCommand, DeleteBucketInventoryConfigurationCommand, DeleteBucketLifecycleCommand, DeleteBucketMetricsConfigurationCommand, DeleteBucketPolicyCommand, DeleteBucketReplicationCommand, DeleteBucketTaggingCommand, DeleteBucketWebsiteCommand, DeleteObjectCommand, DeleteObjectsCommand, GetBucketAccelerateConfigurationCommand, GetBucketCorsCommand, GetBucketEncryptionCommand, GetBucketLifecycleConfigurationCommand, GetBucketLocationCommand, GetBucketLoggingCommand, GetBucketNotificationConfigurationCommand, GetBucketPolicyCommand, GetBucketReplicationCommand, GetBucketTaggingCommand, GetBucketVersioningCommand, GetBucketWebsiteCommand, GetObjectCommand, GetObjectLockConfigurationCommand, GetPublicAccessBlockCommand, HeadBucketCommand, ListBucketAnalyticsConfigurationsCommand, ListBucketIntelligentTieringConfigurationsCommand, ListBucketInventoryConfigurationsCommand, ListBucketMetricsConfigurationsCommand, ListBucketsCommand, ListDirectoryBucketsCommand, 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";
6
6
  import { AddRoleToInstanceProfileCommand, AddUserToGroupCommand, AttachGroupPolicyCommand, AttachUserPolicyCommand, CreateGroupCommand, CreateInstanceProfileCommand, CreateLoginProfileCommand, CreateUserCommand, DeleteAccessKeyCommand, DeleteGroupCommand, DeleteGroupPolicyCommand, DeleteInstanceProfileCommand, DeleteLoginProfileCommand, DeleteRolePolicyCommand, DeleteUserCommand, DeleteUserPermissionsBoundaryCommand, DeleteUserPolicyCommand, DetachGroupPolicyCommand, DetachUserPolicyCommand, GetGroupCommand, GetGroupPolicyCommand, GetInstanceProfileCommand, GetRolePolicyCommand, GetUserCommand, GetUserPolicyCommand, IAMClient, ListAccessKeysCommand, ListAttachedGroupPoliciesCommand, ListAttachedUserPoliciesCommand, ListGroupPoliciesCommand, ListGroupsForUserCommand, ListInstanceProfilesCommand, ListUserPoliciesCommand, ListUserTagsCommand, ListUsersCommand, NoSuchEntityException, PutGroupPolicyCommand, PutRolePolicyCommand, PutUserPermissionsBoundaryCommand, PutUserPolicyCommand, RemoveRoleFromInstanceProfileCommand, RemoveUserFromGroupCommand, TagUserCommand, UntagUserCommand, UpdateLoginProfileCommand } from "@aws-sdk/client-iam";
7
7
  import { CreateQueueCommand, DeleteQueueCommand, GetQueueAttributesCommand, GetQueueUrlCommand, ListQueueTagsCommand, ListQueuesCommand, QueueDoesNotExist, SQSClient, SetQueueAttributesCommand, TagQueueCommand, UntagQueueCommand } from "@aws-sdk/client-sqs";
8
8
  import { CreateTopicCommand, DeleteTopicCommand, GetSubscriptionAttributesCommand, GetTopicAttributesCommand, ListTagsForResourceCommand, ListTopicsCommand, NotFoundException, SNSClient, SetTopicAttributesCommand, SubscribeCommand, TagResourceCommand, UnsubscribeCommand, UntagResourceCommand } from "@aws-sdk/client-sns";
@@ -870,6 +870,339 @@ function createListCommand() {
870
870
  return cmd;
871
871
  }
872
872
 
873
+ //#endregion
874
+ //#region src/state/export-index-store.ts
875
+ /**
876
+ * Export index store — persistent global index of `Fn::ImportValue`
877
+ * resolvable exports across all cdkd-managed stacks in a region.
878
+ *
879
+ * Concept: CFn's `cloudformation:ListExports` API is internally backed by
880
+ * an index that lets the producer's Output be looked up by export name in
881
+ * O(1) regardless of how many stacks exist. cdkd mirrors that pattern via
882
+ * `s3://{bucket}/{prefix}/_index/{region}/exports.json` so the resolver
883
+ * doesn't pay the O(N) state-bucket scan on every `Fn::ImportValue`.
884
+ *
885
+ * Roles:
886
+ * - **state.json** (per-stack) is the canonical source of truth for a
887
+ * stack's outputs + imports. Always written / read with optimistic
888
+ * locking.
889
+ * - **exports.json** (per-region, in this module) is a DERIVED VIEW used
890
+ * only as a perf hint. It can be rebuilt from state.json at any time
891
+ * and is allowed to drift briefly. Strong-reference safety checks
892
+ * never trust the index — they always re-scan state.json.
893
+ *
894
+ * Failure mode summary:
895
+ * - Index missing → auto-rebuild from state.json on first access.
896
+ * - Index corrupt → log warning, auto-rebuild.
897
+ * - Index stale (post-deploy index update failed) → next resolve's miss
898
+ * triggers fallback scan; if found, the entry is patched into the
899
+ * index incrementally.
900
+ * - Two cdkd processes writing concurrently → S3 If-Match optimistic
901
+ * lock + bounded retry. After exhaustion, the writer logs warn and
902
+ * continues; the index becomes stale until the next deploy/destroy
903
+ * updates it.
904
+ */
905
+ /** Schema version for the exports index file. Separate from state.json's version. */
906
+ const EXPORT_INDEX_VERSION = 1;
907
+ const DEFAULT_OPTIONS = {
908
+ maxWriteRetries: 5,
909
+ initialBackoffMs: 100,
910
+ maxBackoffMs: 1e3
911
+ };
912
+ var ExportIndexStore = class {
913
+ logger = getLogger().child("ExportIndexStore");
914
+ s3Client;
915
+ bucket;
916
+ prefix;
917
+ region;
918
+ stateBackend;
919
+ loadState = { kind: "unloaded" };
920
+ opts;
921
+ /**
922
+ * In-process serializer for write paths (`updateForStack`,
923
+ * `patchEntry`, `removeStack`). The S3 `If-Match` etag prevents
924
+ * cross-process data loss, but within ONE cdkd process the
925
+ * default `cdkd deploy --all --stack-concurrency > 1` topology
926
+ * lets multiple per-stack writes race on the same etag — they
927
+ * would all read the same loaded snapshot, all attempt to write
928
+ * with that etag, all but one fail with PreconditionFailed, all
929
+ * retry, and burn through the bounded retry budget for no good
930
+ * reason. Serializing write paths via this chained promise lets
931
+ * the etag race only fire across processes (cross-app concurrency)
932
+ * where it actually matters. Reads (`lookup`) remain unsynchronized.
933
+ */
934
+ writeChain = Promise.resolve();
935
+ constructor(s3Client, bucket, prefix, region, stateBackend, opts = {}) {
936
+ this.s3Client = s3Client;
937
+ this.bucket = bucket;
938
+ this.prefix = prefix;
939
+ this.region = region;
940
+ this.stateBackend = stateBackend;
941
+ this.opts = {
942
+ ...DEFAULT_OPTIONS,
943
+ ...opts
944
+ };
945
+ }
946
+ /** S3 key for this region's index file. */
947
+ indexKey() {
948
+ return `${this.prefix}/_index/${this.region}/exports.json`;
949
+ }
950
+ /**
951
+ * Look up an exported value by name. Returns the cached entry on hit,
952
+ * or `undefined` on miss. The caller is responsible for falling back
953
+ * to a state.json scan and (if found) patching the index via
954
+ * {@link patchEntry}.
955
+ */
956
+ async lookup(exportName) {
957
+ await this.ensureLoaded();
958
+ if (this.loadState.kind !== "loaded") return;
959
+ return this.loadState.entries.get(exportName);
960
+ }
961
+ /**
962
+ * Replace all entries for `(stackName, producerRegion)` with the
963
+ * supplied `outputs`. Used after a successful deploy save. Writes
964
+ * the updated index to S3 under an If-Match optimistic lock,
965
+ * retrying on conflict.
966
+ *
967
+ * If `outputs` is empty, every entry currently owned by this stack
968
+ * in this region is removed.
969
+ *
970
+ * Best-effort: on persistent retry exhaustion the in-memory map is
971
+ * still updated locally (so this session sees a consistent view) and
972
+ * a warning is logged. The on-disk index will be repaired by the
973
+ * next successful update or by a rebuild on miss.
974
+ */
975
+ async updateForStack(stackName, producerRegion, outputs) {
976
+ await this.enqueueWrite("update", () => this.applyStackUpdate(stackName, producerRegion, outputs));
977
+ }
978
+ /**
979
+ * Patch a single entry into the index after a `lookup` miss fell back
980
+ * to a state.json scan and found the value. Lightweight write that
981
+ * does NOT require a full rebuild.
982
+ */
983
+ async patchEntry(exportName, entry) {
984
+ await this.enqueueWrite("patch", () => this.applyPatch(exportName, entry));
985
+ }
986
+ /**
987
+ * Drop all entries owned by `(stackName, producerRegion)`. Used after
988
+ * a successful destroy. Same retry / best-effort semantics as
989
+ * `updateForStack`. Filtering by both stack AND region is symmetric
990
+ * with the update path so a stack that was re-deployed to a new
991
+ * region keeps its old-region entries (the user must destroy in the
992
+ * old region too to drop them).
993
+ */
994
+ async removeStack(stackName, producerRegion) {
995
+ await this.enqueueWrite("remove", () => this.applyRemoveStack(stackName, producerRegion));
996
+ }
997
+ /**
998
+ * Serialize write paths within a single process. Chains every write
999
+ * onto a tail Promise so two concurrent `updateForStack` calls don't
1000
+ * race on the same etag inside the same cdkd. The S3 If-Match retry
1001
+ * remains as cross-process protection.
1002
+ */
1003
+ async enqueueWrite(label, op) {
1004
+ const next = this.writeChain.then(() => this.runWithRetry(label, op));
1005
+ this.writeChain = next.catch(() => {});
1006
+ return next;
1007
+ }
1008
+ /**
1009
+ * Force a rebuild from state.json files, overwriting whatever is in
1010
+ * the on-disk index. Useful for recovery and for tests.
1011
+ */
1012
+ async rebuild() {
1013
+ const entries = await this.rebuildFromStateBackend();
1014
+ const next = {
1015
+ indexVersion: 1,
1016
+ region: this.region,
1017
+ exports: Object.fromEntries(entries),
1018
+ lastModified: Date.now()
1019
+ };
1020
+ const etag = await this.writeIndex(next, void 0);
1021
+ this.loadState = {
1022
+ kind: "loaded",
1023
+ etag,
1024
+ entries
1025
+ };
1026
+ }
1027
+ async ensureLoaded() {
1028
+ if (this.loadState.kind === "loaded") return;
1029
+ if (this.loadState.kind === "loading") {
1030
+ await this.loadState.promise;
1031
+ return;
1032
+ }
1033
+ const promise = this.doLoad();
1034
+ this.loadState = {
1035
+ kind: "loading",
1036
+ promise
1037
+ };
1038
+ await promise;
1039
+ }
1040
+ async doLoad() {
1041
+ try {
1042
+ const raw = await this.readIndexRaw();
1043
+ if (raw === null) {
1044
+ this.logger.info("Exports index missing; rebuilding from state.json files");
1045
+ await this.rebuild();
1046
+ return;
1047
+ }
1048
+ const { body, etag } = raw;
1049
+ let parsed;
1050
+ try {
1051
+ parsed = JSON.parse(body);
1052
+ } catch (err) {
1053
+ this.logger.warn(`Exports index corrupt (${err instanceof Error ? err.message : String(err)}); rebuilding from state.json files`);
1054
+ await this.rebuild();
1055
+ return;
1056
+ }
1057
+ if (typeof parsed.indexVersion !== "number" || parsed.indexVersion > 1) throw new Error(`Exports index uses indexVersion ${String(parsed.indexVersion)} which is newer than this cdkd binary supports (max ${1}). Upgrade cdkd.`);
1058
+ const entries = /* @__PURE__ */ new Map();
1059
+ for (const [name, entry] of Object.entries(parsed.exports ?? {})) entries.set(name, entry);
1060
+ this.loadState = {
1061
+ kind: "loaded",
1062
+ etag,
1063
+ entries
1064
+ };
1065
+ } catch (err) {
1066
+ this.loadState = { kind: "unloaded" };
1067
+ throw err;
1068
+ }
1069
+ }
1070
+ async readIndexRaw() {
1071
+ try {
1072
+ const response = await this.s3Client.send(new GetObjectCommand({
1073
+ Bucket: this.bucket,
1074
+ Key: this.indexKey()
1075
+ }));
1076
+ return {
1077
+ body: await response.Body?.transformToString() ?? "",
1078
+ etag: response.ETag
1079
+ };
1080
+ } catch (err) {
1081
+ if (this.isNoSuchKey(err)) return null;
1082
+ throw err;
1083
+ }
1084
+ }
1085
+ async writeIndex(next, expectedEtag) {
1086
+ const body = JSON.stringify(next, null, 2);
1087
+ return (await this.s3Client.send(new PutObjectCommand({
1088
+ Bucket: this.bucket,
1089
+ Key: this.indexKey(),
1090
+ Body: body,
1091
+ ContentLength: Buffer.byteLength(body),
1092
+ ContentType: "application/json",
1093
+ ...expectedEtag && { IfMatch: expectedEtag }
1094
+ }))).ETag;
1095
+ }
1096
+ /**
1097
+ * Re-read state.json for every stack in the bucket and assemble a
1098
+ * fresh `exportName → entry` map. Region-scoped — only state files
1099
+ * in this index's region contribute.
1100
+ */
1101
+ async rebuildFromStateBackend() {
1102
+ const inRegion = (await this.stateBackend.listStacks()).filter((ref) => ref.region === this.region);
1103
+ this.logger.debug(`Rebuilding exports index for region '${this.region}' from ${inRegion.length} stack state file(s)`);
1104
+ const entries = /* @__PURE__ */ new Map();
1105
+ const results = await Promise.all(inRegion.map(async (ref) => {
1106
+ try {
1107
+ return {
1108
+ ref,
1109
+ state: (await this.stateBackend.getState(ref.stackName, ref.region ?? this.region))?.state
1110
+ };
1111
+ } catch (err) {
1112
+ this.logger.warn(`Failed to read state for ${ref.stackName} (${ref.region ?? ""}) during index rebuild: ${err instanceof Error ? err.message : String(err)}`);
1113
+ return {
1114
+ ref,
1115
+ state: null
1116
+ };
1117
+ }
1118
+ }));
1119
+ for (const { ref, state } of results) {
1120
+ if (!state || !state.outputs) continue;
1121
+ const region = ref.region ?? this.region;
1122
+ for (const [name, value] of Object.entries(state.outputs)) entries.set(name, {
1123
+ value,
1124
+ producerStack: ref.stackName,
1125
+ producerRegion: region
1126
+ });
1127
+ }
1128
+ return entries;
1129
+ }
1130
+ async applyStackUpdate(stackName, producerRegion, outputs) {
1131
+ await this.ensureLoaded();
1132
+ if (this.loadState.kind !== "loaded") return;
1133
+ const next = new Map(this.loadState.entries);
1134
+ for (const [name, entry] of next) if (entry.producerStack === stackName && entry.producerRegion === producerRegion) next.delete(name);
1135
+ for (const [name, value] of Object.entries(outputs)) next.set(name, {
1136
+ value,
1137
+ producerStack: stackName,
1138
+ producerRegion
1139
+ });
1140
+ await this.persist(next);
1141
+ }
1142
+ async applyPatch(exportName, entry) {
1143
+ await this.ensureLoaded();
1144
+ if (this.loadState.kind !== "loaded") return;
1145
+ const next = new Map(this.loadState.entries);
1146
+ next.set(exportName, entry);
1147
+ await this.persist(next);
1148
+ }
1149
+ async applyRemoveStack(stackName, producerRegion) {
1150
+ await this.ensureLoaded();
1151
+ if (this.loadState.kind !== "loaded") return;
1152
+ const next = new Map(this.loadState.entries);
1153
+ let changed = false;
1154
+ for (const [name, entry] of next) if (entry.producerStack === stackName && entry.producerRegion === producerRegion) {
1155
+ next.delete(name);
1156
+ changed = true;
1157
+ }
1158
+ if (!changed) return;
1159
+ await this.persist(next);
1160
+ }
1161
+ async persist(entries) {
1162
+ if (this.loadState.kind !== "loaded") return;
1163
+ const file = {
1164
+ indexVersion: 1,
1165
+ region: this.region,
1166
+ exports: Object.fromEntries(entries),
1167
+ lastModified: Date.now()
1168
+ };
1169
+ const etag = await this.writeIndex(file, this.loadState.etag);
1170
+ this.loadState = {
1171
+ kind: "loaded",
1172
+ etag,
1173
+ entries
1174
+ };
1175
+ }
1176
+ async runWithRetry(label, op) {
1177
+ let lastErr;
1178
+ for (let attempt = 0; attempt < this.opts.maxWriteRetries; attempt++) try {
1179
+ await op();
1180
+ return;
1181
+ } catch (err) {
1182
+ lastErr = err;
1183
+ if (this.isPreconditionFailed(err)) {
1184
+ this.loadState = { kind: "unloaded" };
1185
+ const backoff = Math.min(this.opts.initialBackoffMs * 2 ** attempt, this.opts.maxBackoffMs);
1186
+ await new Promise((resolve) => setTimeout(resolve, backoff));
1187
+ continue;
1188
+ }
1189
+ this.logger.warn(`Exports index ${label} failed (non-retryable): ${err instanceof Error ? err.message : String(err)}; continuing without index update`);
1190
+ return;
1191
+ }
1192
+ this.logger.warn(`Exports index ${label} exhausted ${this.opts.maxWriteRetries} retries due to concurrent writers; continuing without index update. Last error: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`);
1193
+ }
1194
+ isNoSuchKey(err) {
1195
+ if (!err || typeof err !== "object") return false;
1196
+ const e = err;
1197
+ return e.name === "NoSuchKey" || e.$metadata?.httpStatusCode === 404;
1198
+ }
1199
+ isPreconditionFailed(err) {
1200
+ if (!err || typeof err !== "object") return false;
1201
+ const e = err;
1202
+ return e.name === "PreconditionFailed" || e.$metadata?.httpStatusCode === 412;
1203
+ }
1204
+ };
1205
+
873
1206
  //#endregion
874
1207
  //#region src/provisioning/providers/iam-policy-provider.ts
875
1208
  /**
@@ -27302,13 +27635,15 @@ async function deployCommand(stacks, options) {
27302
27635
  ...options.profile && { profile: options.profile }
27303
27636
  });
27304
27637
  setAwsClients(awsClients);
27305
- await new S3StateBackend(awsClients.s3, {
27638
+ const preflightStateBackend = new S3StateBackend(awsClients.s3, {
27306
27639
  bucket: stateBucket,
27307
27640
  prefix: options.statePrefix
27308
27641
  }, {
27309
27642
  region,
27310
27643
  ...options.profile && { profile: options.profile }
27311
- }).verifyBucketExists();
27644
+ });
27645
+ await preflightStateBackend.verifyBucketExists();
27646
+ const exportIndexStore = new ExportIndexStore(awsClients.s3, stateBucket, options.statePrefix, region, preflightStateBackend);
27312
27647
  let deployInterrupted = false;
27313
27648
  const topLevelSigintHandler = () => {
27314
27649
  if (deployInterrupted) {
@@ -27442,7 +27777,7 @@ async function deployCommand(stacks, options) {
27442
27777
  ...options.resourceTimeout?.globalMs !== void 0 && { resourceTimeoutMs: options.resourceTimeout.globalMs },
27443
27778
  ...options.resourceWarnAfter?.perTypeMs && { resourceWarnAfterByType: options.resourceWarnAfter.perTypeMs },
27444
27779
  ...options.resourceTimeout?.perTypeMs && { resourceTimeoutByType: options.resourceTimeout.perTypeMs }
27445
- }, stackRegion).deploy(stackInfo.stackName, stackInfo.template);
27780
+ }, stackRegion, exportIndexStore).deploy(stackInfo.stackName, stackInfo.template);
27446
27781
  logger.info("\nDeployment Summary:");
27447
27782
  logger.info(` Stack: ${deployResult.stackName}`);
27448
27783
  logger.info(` Created: ${deployResult.created}`);
@@ -27612,7 +27947,7 @@ async function diffCommand(stacks, options) {
27612
27947
  region: stackRegion,
27613
27948
  resources: {},
27614
27949
  outputs: {},
27615
- version: 3,
27950
+ version: 4,
27616
27951
  lastModified: Date.now()
27617
27952
  };
27618
27953
  if (!stateResult) logger.debug(`No existing state for ${stackInfo.stackName} (${stackRegion})`);
@@ -28643,6 +28978,11 @@ async function runDestroyForStack(stackName, state, ctx) {
28643
28978
  result.skippedEmpty = true;
28644
28979
  return result;
28645
28980
  }
28981
+ const needsStrongRefCheck = !!(state.outputs && Object.keys(state.outputs).length > 0);
28982
+ if (needsStrongRefCheck) {
28983
+ const consumers = await scanActiveConsumers(stackName, regionForState, ctx);
28984
+ if (consumers.length > 0) throw new StackHasActiveImportsError(stackName, regionForState, consumers);
28985
+ }
28646
28986
  logger.info(`\nResources to be deleted (${resourceCount}):`);
28647
28987
  for (const [logicalId, resource] of Object.entries(state.resources)) logger.info(` - ${logicalId} (${resource.resourceType})`);
28648
28988
  const protectedCount = ctx.removeProtection ? countProtectedResources(state) : 0;
@@ -28685,6 +29025,17 @@ async function runDestroyForStack(stackName, state, ctx) {
28685
29025
  }
28686
29026
  logger.info(`\nAcquiring lock for stack ${stackName}...`);
28687
29027
  await ctx.lockManager.acquireLock(stackName, regionForState, void 0, "destroy");
29028
+ if (needsStrongRefCheck) {
29029
+ const consumers = await scanActiveConsumers(stackName, regionForState, ctx);
29030
+ if (consumers.length > 0) {
29031
+ try {
29032
+ await ctx.lockManager.releaseLock(stackName, regionForState);
29033
+ } catch (releaseErr) {
29034
+ logger.warn(`Failed to release lock after strong-ref refusal: ${releaseErr instanceof Error ? releaseErr.message : String(releaseErr)}`);
29035
+ }
29036
+ throw new StackHasActiveImportsError(stackName, regionForState, consumers);
29037
+ }
29038
+ }
28688
29039
  const renderer = getLiveRenderer();
28689
29040
  renderer.start();
28690
29041
  try {
@@ -28803,6 +29154,7 @@ async function runDestroyForStack(stackName, state, ctx) {
28803
29154
  if (result.errorCount === 0) {
28804
29155
  await ctx.stateBackend.deleteState(stackName, regionForState);
28805
29156
  logger.debug("State deleted");
29157
+ if (ctx.exportIndexStore) await ctx.exportIndexStore.removeStack(stackName, regionForState);
28806
29158
  } else logger.warn(`${result.errorCount} resource(s) failed to delete. State preserved.`);
28807
29159
  if (result.errorCount === 0) logger.info(`\n✓ Stack ${stackName} destroyed (${result.deletedCount} deleted, ${result.errorCount} errors)`);
28808
29160
  else logger.warn(`\n⚠ Stack ${stackName} partially destroyed (${result.deletedCount} deleted, ${result.errorCount} errors). State preserved — re-run 'cdkd destroy' / 'cdkd state destroy' to clean up.`);
@@ -28819,6 +29171,38 @@ async function runDestroyForStack(stackName, state, ctx) {
28819
29171
  }
28820
29172
  return result;
28821
29173
  }
29174
+ /**
29175
+ * Strong-reference scan: read every other stack's state.json from the
29176
+ * state bucket and check whether any of its `imports[]` entries names
29177
+ * `producerStack`. Returns the list of offending consumers (possibly
29178
+ * empty).
29179
+ *
29180
+ * NEVER trusts the persistent exports index — a stale index could miss
29181
+ * a freshly-recorded consumer and let a destructive destroy through.
29182
+ * The cost is one `listStacks` + N parallel GETs at destroy time only
29183
+ * (not the deploy hot path), which the user-facing UX rationalizes as
29184
+ * the "destroy is slow OK" trade-off (Issue #343).
29185
+ */
29186
+ async function scanActiveConsumers(producerStack, producerRegion, ctx) {
29187
+ const refs = await ctx.stateBackend.listStacks();
29188
+ return (await Promise.all(refs.map(async (ref) => {
29189
+ const region = ref.region ?? ctx.baseRegion;
29190
+ if (ref.stackName === producerStack && region === producerRegion) return null;
29191
+ try {
29192
+ const imports = (await ctx.stateBackend.getState(ref.stackName, region))?.state.imports;
29193
+ if (!imports || imports.length === 0) return null;
29194
+ const matches = imports.filter((entry) => entry.sourceStack === producerStack && entry.sourceRegion === producerRegion);
29195
+ if (matches.length === 0) return null;
29196
+ return matches.map((entry) => ({
29197
+ consumerStack: ref.stackName,
29198
+ consumerRegion: region,
29199
+ exportName: entry.exportName
29200
+ }));
29201
+ } catch {
29202
+ return null;
29203
+ }
29204
+ }))).filter((r) => r !== null).flat();
29205
+ }
28822
29206
 
28823
29207
  //#endregion
28824
29208
  //#region src/cli/commands/destroy.ts
@@ -28861,6 +29245,7 @@ async function destroyCommand(stackArgs, options) {
28861
29245
  });
28862
29246
  await stateBackend.verifyBucketExists();
28863
29247
  const lockManager = new LockManager(awsClients.s3, stateConfig);
29248
+ const exportIndexStore = new ExportIndexStore(awsClients.s3, stateBucket, options.statePrefix, region, stateBackend);
28864
29249
  const providerRegistry = new ProviderRegistry();
28865
29250
  registerAllProviders(providerRegistry);
28866
29251
  providerRegistry.setCustomResourceResponseBucket(stateBucket);
@@ -28957,6 +29342,7 @@ async function destroyCommand(stackArgs, options) {
28957
29342
  stateBucket,
28958
29343
  skipConfirmation: options.yes || options.force,
28959
29344
  removeProtection: options.removeProtection === true,
29345
+ exportIndexStore,
28960
29346
  ...options.resourceWarnAfter?.globalMs !== void 0 && { resourceWarnAfterMs: options.resourceWarnAfter.globalMs },
28961
29347
  ...options.resourceTimeout?.globalMs !== void 0 && { resourceTimeoutMs: options.resourceTimeout.globalMs },
28962
29348
  ...options.resourceWarnAfter?.perTypeMs && { resourceWarnAfterByType: options.resourceWarnAfter.perTypeMs },
@@ -30231,6 +30617,7 @@ async function setupStateBackend(options) {
30231
30617
  region,
30232
30618
  bucket,
30233
30619
  prefix,
30620
+ exportIndexStore: new ExportIndexStore(awsClients.s3, bucket, prefix, region, stateBackend),
30234
30621
  dispose: () => awsClients.destroy()
30235
30622
  };
30236
30623
  }
@@ -30702,6 +31089,7 @@ async function stateDestroyCommand(stackArgs, options) {
30702
31089
  stateBucket: setup.bucket,
30703
31090
  skipConfirmation: options.yes || options.all === true,
30704
31091
  removeProtection: options.removeProtection === true,
31092
+ exportIndexStore: setup.exportIndexStore,
30705
31093
  ...options.resourceWarnAfter?.globalMs !== void 0 && { resourceWarnAfterMs: options.resourceWarnAfter.globalMs },
30706
31094
  ...options.resourceTimeout?.globalMs !== void 0 && { resourceTimeoutMs: options.resourceTimeout.globalMs },
30707
31095
  ...options.resourceWarnAfter?.perTypeMs && { resourceWarnAfterByType: options.resourceWarnAfter.perTypeMs },
@@ -31784,7 +32172,7 @@ function buildStackState(stackName, region, rows, templateParser, template, exis
31784
32172
  };
31785
32173
  }
31786
32174
  return {
31787
- version: 3,
32175
+ version: 4,
31788
32176
  stackName,
31789
32177
  region,
31790
32178
  resources,
@@ -41837,7 +42225,7 @@ function reorderArgs(argv) {
41837
42225
  */
41838
42226
  async function main() {
41839
42227
  const program = new Command();
41840
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.96.1");
42228
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.97.0");
41841
42229
  program.addCommand(createBootstrapCommand());
41842
42230
  program.addCommand(createSynthCommand());
41843
42231
  program.addCommand(createListCommand());