@go-to-k/cdkd 0.97.0 → 0.98.1

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,357 @@ 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
+ /**
908
+ * Shallow-deep equality on the two `name → ExportIndexEntry` maps used
909
+ * to detect no-op writes in `applyStackUpdate`. Values are compared via
910
+ * JSON.stringify (Output values are always JSON-serializable).
911
+ */
912
+ function mapsEqual(a, b) {
913
+ if (a.size !== b.size) return false;
914
+ for (const [name, entry] of a) {
915
+ const other = b.get(name);
916
+ if (!other) return false;
917
+ if (other.producerStack !== entry.producerStack || other.producerRegion !== entry.producerRegion) return false;
918
+ if (other.value !== entry.value) {
919
+ if (JSON.stringify(other.value) !== JSON.stringify(entry.value)) return false;
920
+ }
921
+ }
922
+ return true;
923
+ }
924
+ const DEFAULT_OPTIONS = {
925
+ maxWriteRetries: 5,
926
+ initialBackoffMs: 100,
927
+ maxBackoffMs: 1e3
928
+ };
929
+ var ExportIndexStore = class {
930
+ logger = getLogger().child("ExportIndexStore");
931
+ s3Client;
932
+ bucket;
933
+ prefix;
934
+ region;
935
+ stateBackend;
936
+ loadState = { kind: "unloaded" };
937
+ opts;
938
+ /**
939
+ * In-process serializer for write paths (`updateForStack`,
940
+ * `patchEntry`, `removeStack`). The S3 `If-Match` etag prevents
941
+ * cross-process data loss, but within ONE cdkd process the
942
+ * default `cdkd deploy --all --stack-concurrency > 1` topology
943
+ * lets multiple per-stack writes race on the same etag — they
944
+ * would all read the same loaded snapshot, all attempt to write
945
+ * with that etag, all but one fail with PreconditionFailed, all
946
+ * retry, and burn through the bounded retry budget for no good
947
+ * reason. Serializing write paths via this chained promise lets
948
+ * the etag race only fire across processes (cross-app concurrency)
949
+ * where it actually matters. Reads (`lookup`) remain unsynchronized.
950
+ */
951
+ writeChain = Promise.resolve();
952
+ constructor(s3Client, bucket, prefix, region, stateBackend, opts = {}) {
953
+ this.s3Client = s3Client;
954
+ this.bucket = bucket;
955
+ this.prefix = prefix;
956
+ this.region = region;
957
+ this.stateBackend = stateBackend;
958
+ this.opts = {
959
+ ...DEFAULT_OPTIONS,
960
+ ...opts
961
+ };
962
+ }
963
+ /** S3 key for this region's index file. */
964
+ indexKey() {
965
+ return `${this.prefix}/_index/${this.region}/exports.json`;
966
+ }
967
+ /**
968
+ * Look up an exported value by name. Returns the cached entry on hit,
969
+ * or `undefined` on miss. The caller is responsible for falling back
970
+ * to a state.json scan and (if found) patching the index via
971
+ * {@link patchEntry}.
972
+ */
973
+ async lookup(exportName) {
974
+ await this.ensureLoaded();
975
+ if (this.loadState.kind !== "loaded") return;
976
+ return this.loadState.entries.get(exportName);
977
+ }
978
+ /**
979
+ * Replace all entries for `(stackName, producerRegion)` with the
980
+ * supplied `outputs`. Used after a successful deploy save. Writes
981
+ * the updated index to S3 under an If-Match optimistic lock,
982
+ * retrying on conflict.
983
+ *
984
+ * If `outputs` is empty, every entry currently owned by this stack
985
+ * in this region is removed.
986
+ *
987
+ * Best-effort: on persistent retry exhaustion the in-memory map is
988
+ * still updated locally (so this session sees a consistent view) and
989
+ * a warning is logged. The on-disk index will be repaired by the
990
+ * next successful update or by a rebuild on miss.
991
+ */
992
+ async updateForStack(stackName, producerRegion, outputs) {
993
+ await this.enqueueWrite("update", () => this.applyStackUpdate(stackName, producerRegion, outputs));
994
+ }
995
+ /**
996
+ * Patch a single entry into the index after a `lookup` miss fell back
997
+ * to a state.json scan and found the value. Lightweight write that
998
+ * does NOT require a full rebuild.
999
+ */
1000
+ async patchEntry(exportName, entry) {
1001
+ await this.enqueueWrite("patch", () => this.applyPatch(exportName, entry));
1002
+ }
1003
+ /**
1004
+ * Drop all entries owned by `(stackName, producerRegion)`. Used after
1005
+ * a successful destroy. Same retry / best-effort semantics as
1006
+ * `updateForStack`. Filtering by both stack AND region is symmetric
1007
+ * with the update path so a stack that was re-deployed to a new
1008
+ * region keeps its old-region entries (the user must destroy in the
1009
+ * old region too to drop them).
1010
+ */
1011
+ async removeStack(stackName, producerRegion) {
1012
+ await this.enqueueWrite("remove", () => this.applyRemoveStack(stackName, producerRegion));
1013
+ }
1014
+ /**
1015
+ * Serialize write paths within a single process. Chains every write
1016
+ * onto a tail Promise so two concurrent `updateForStack` calls don't
1017
+ * race on the same etag inside the same cdkd. The S3 If-Match retry
1018
+ * remains as cross-process protection.
1019
+ */
1020
+ async enqueueWrite(label, op) {
1021
+ const next = this.writeChain.then(() => this.runWithRetry(label, op));
1022
+ this.writeChain = next.catch(() => {});
1023
+ return next;
1024
+ }
1025
+ /**
1026
+ * Force a rebuild from state.json files, overwriting whatever is in
1027
+ * the on-disk index. Useful for recovery and for tests.
1028
+ */
1029
+ async rebuild() {
1030
+ const entries = await this.rebuildFromStateBackend();
1031
+ const next = {
1032
+ indexVersion: 1,
1033
+ region: this.region,
1034
+ exports: Object.fromEntries(entries),
1035
+ lastModified: Date.now()
1036
+ };
1037
+ const etag = await this.writeIndex(next, void 0);
1038
+ this.loadState = {
1039
+ kind: "loaded",
1040
+ etag,
1041
+ entries
1042
+ };
1043
+ }
1044
+ async ensureLoaded() {
1045
+ if (this.loadState.kind === "loaded") return;
1046
+ if (this.loadState.kind === "loading") {
1047
+ await this.loadState.promise;
1048
+ return;
1049
+ }
1050
+ const promise = this.doLoad();
1051
+ this.loadState = {
1052
+ kind: "loading",
1053
+ promise
1054
+ };
1055
+ await promise;
1056
+ }
1057
+ async doLoad() {
1058
+ try {
1059
+ const raw = await this.readIndexRaw();
1060
+ if (raw === null) {
1061
+ this.logger.info("Exports index missing; rebuilding from state.json files");
1062
+ await this.rebuild();
1063
+ return;
1064
+ }
1065
+ const { body, etag } = raw;
1066
+ let parsed;
1067
+ try {
1068
+ parsed = JSON.parse(body);
1069
+ } catch (err) {
1070
+ this.logger.warn(`Exports index corrupt (${err instanceof Error ? err.message : String(err)}); rebuilding from state.json files`);
1071
+ await this.rebuild();
1072
+ return;
1073
+ }
1074
+ 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.`);
1075
+ const entries = /* @__PURE__ */ new Map();
1076
+ for (const [name, entry] of Object.entries(parsed.exports ?? {})) entries.set(name, entry);
1077
+ this.loadState = {
1078
+ kind: "loaded",
1079
+ etag,
1080
+ entries
1081
+ };
1082
+ } catch (err) {
1083
+ this.loadState = { kind: "unloaded" };
1084
+ throw err;
1085
+ }
1086
+ }
1087
+ async readIndexRaw() {
1088
+ try {
1089
+ const response = await this.s3Client.send(new GetObjectCommand({
1090
+ Bucket: this.bucket,
1091
+ Key: this.indexKey()
1092
+ }));
1093
+ return {
1094
+ body: await response.Body?.transformToString() ?? "",
1095
+ etag: response.ETag
1096
+ };
1097
+ } catch (err) {
1098
+ if (this.isNoSuchKey(err)) return null;
1099
+ throw err;
1100
+ }
1101
+ }
1102
+ async writeIndex(next, expectedEtag) {
1103
+ const body = JSON.stringify(next, null, 2);
1104
+ return (await this.s3Client.send(new PutObjectCommand({
1105
+ Bucket: this.bucket,
1106
+ Key: this.indexKey(),
1107
+ Body: body,
1108
+ ContentLength: Buffer.byteLength(body),
1109
+ ContentType: "application/json",
1110
+ ...expectedEtag && { IfMatch: expectedEtag }
1111
+ }))).ETag;
1112
+ }
1113
+ /**
1114
+ * Re-read state.json for every stack in the bucket and assemble a
1115
+ * fresh `exportName → entry` map. Region-scoped — only state files
1116
+ * in this index's region contribute.
1117
+ */
1118
+ async rebuildFromStateBackend() {
1119
+ const inRegion = (await this.stateBackend.listStacks()).filter((ref) => ref.region === this.region);
1120
+ this.logger.debug(`Rebuilding exports index for region '${this.region}' from ${inRegion.length} stack state file(s)`);
1121
+ const entries = /* @__PURE__ */ new Map();
1122
+ const results = await Promise.all(inRegion.map(async (ref) => {
1123
+ try {
1124
+ return {
1125
+ ref,
1126
+ state: (await this.stateBackend.getState(ref.stackName, ref.region ?? this.region))?.state
1127
+ };
1128
+ } catch (err) {
1129
+ this.logger.warn(`Failed to read state for ${ref.stackName} (${ref.region ?? ""}) during index rebuild: ${err instanceof Error ? err.message : String(err)}`);
1130
+ return {
1131
+ ref,
1132
+ state: null
1133
+ };
1134
+ }
1135
+ }));
1136
+ for (const { ref, state } of results) {
1137
+ if (!state || !state.outputs) continue;
1138
+ const region = ref.region ?? this.region;
1139
+ for (const [name, value] of Object.entries(state.outputs)) entries.set(name, {
1140
+ value,
1141
+ producerStack: ref.stackName,
1142
+ producerRegion: region
1143
+ });
1144
+ }
1145
+ return entries;
1146
+ }
1147
+ async applyStackUpdate(stackName, producerRegion, outputs) {
1148
+ await this.ensureLoaded();
1149
+ if (this.loadState.kind !== "loaded") return;
1150
+ const next = new Map(this.loadState.entries);
1151
+ for (const [name, entry] of next) if (entry.producerStack === stackName && entry.producerRegion === producerRegion) next.delete(name);
1152
+ for (const [name, value] of Object.entries(outputs)) next.set(name, {
1153
+ value,
1154
+ producerStack: stackName,
1155
+ producerRegion
1156
+ });
1157
+ if (mapsEqual(this.loadState.entries, next)) return;
1158
+ await this.persist(next);
1159
+ }
1160
+ async applyPatch(exportName, entry) {
1161
+ await this.ensureLoaded();
1162
+ if (this.loadState.kind !== "loaded") return;
1163
+ const next = new Map(this.loadState.entries);
1164
+ next.set(exportName, entry);
1165
+ await this.persist(next);
1166
+ }
1167
+ async applyRemoveStack(stackName, producerRegion) {
1168
+ await this.ensureLoaded();
1169
+ if (this.loadState.kind !== "loaded") return;
1170
+ const next = new Map(this.loadState.entries);
1171
+ let changed = false;
1172
+ for (const [name, entry] of next) if (entry.producerStack === stackName && entry.producerRegion === producerRegion) {
1173
+ next.delete(name);
1174
+ changed = true;
1175
+ }
1176
+ if (!changed) return;
1177
+ await this.persist(next);
1178
+ }
1179
+ async persist(entries) {
1180
+ if (this.loadState.kind !== "loaded") return;
1181
+ const file = {
1182
+ indexVersion: 1,
1183
+ region: this.region,
1184
+ exports: Object.fromEntries(entries),
1185
+ lastModified: Date.now()
1186
+ };
1187
+ const etag = await this.writeIndex(file, this.loadState.etag);
1188
+ this.loadState = {
1189
+ kind: "loaded",
1190
+ etag,
1191
+ entries
1192
+ };
1193
+ }
1194
+ async runWithRetry(label, op) {
1195
+ let lastErr;
1196
+ for (let attempt = 0; attempt < this.opts.maxWriteRetries; attempt++) try {
1197
+ await op();
1198
+ return;
1199
+ } catch (err) {
1200
+ lastErr = err;
1201
+ if (this.isPreconditionFailed(err)) {
1202
+ this.loadState = { kind: "unloaded" };
1203
+ const backoff = Math.min(this.opts.initialBackoffMs * 2 ** attempt, this.opts.maxBackoffMs);
1204
+ await new Promise((resolve) => setTimeout(resolve, backoff));
1205
+ continue;
1206
+ }
1207
+ this.logger.warn(`Exports index ${label} failed (non-retryable): ${err instanceof Error ? err.message : String(err)}; continuing without index update`);
1208
+ return;
1209
+ }
1210
+ 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)}`);
1211
+ }
1212
+ isNoSuchKey(err) {
1213
+ if (!err || typeof err !== "object") return false;
1214
+ const e = err;
1215
+ return e.name === "NoSuchKey" || e.$metadata?.httpStatusCode === 404;
1216
+ }
1217
+ isPreconditionFailed(err) {
1218
+ if (!err || typeof err !== "object") return false;
1219
+ const e = err;
1220
+ return e.name === "PreconditionFailed" || e.$metadata?.httpStatusCode === 412;
1221
+ }
1222
+ };
1223
+
873
1224
  //#endregion
874
1225
  //#region src/provisioning/providers/iam-policy-provider.ts
875
1226
  /**
@@ -27302,13 +27653,15 @@ async function deployCommand(stacks, options) {
27302
27653
  ...options.profile && { profile: options.profile }
27303
27654
  });
27304
27655
  setAwsClients(awsClients);
27305
- await new S3StateBackend(awsClients.s3, {
27656
+ const preflightStateBackend = new S3StateBackend(awsClients.s3, {
27306
27657
  bucket: stateBucket,
27307
27658
  prefix: options.statePrefix
27308
27659
  }, {
27309
27660
  region,
27310
27661
  ...options.profile && { profile: options.profile }
27311
- }).verifyBucketExists();
27662
+ });
27663
+ await preflightStateBackend.verifyBucketExists();
27664
+ const exportIndexStore = new ExportIndexStore(awsClients.s3, stateBucket, options.statePrefix, region, preflightStateBackend);
27312
27665
  let deployInterrupted = false;
27313
27666
  const topLevelSigintHandler = () => {
27314
27667
  if (deployInterrupted) {
@@ -27442,7 +27795,7 @@ async function deployCommand(stacks, options) {
27442
27795
  ...options.resourceTimeout?.globalMs !== void 0 && { resourceTimeoutMs: options.resourceTimeout.globalMs },
27443
27796
  ...options.resourceWarnAfter?.perTypeMs && { resourceWarnAfterByType: options.resourceWarnAfter.perTypeMs },
27444
27797
  ...options.resourceTimeout?.perTypeMs && { resourceTimeoutByType: options.resourceTimeout.perTypeMs }
27445
- }, stackRegion).deploy(stackInfo.stackName, stackInfo.template);
27798
+ }, stackRegion, exportIndexStore).deploy(stackInfo.stackName, stackInfo.template);
27446
27799
  logger.info("\nDeployment Summary:");
27447
27800
  logger.info(` Stack: ${deployResult.stackName}`);
27448
27801
  logger.info(` Created: ${deployResult.created}`);
@@ -27612,7 +27965,7 @@ async function diffCommand(stacks, options) {
27612
27965
  region: stackRegion,
27613
27966
  resources: {},
27614
27967
  outputs: {},
27615
- version: 3,
27968
+ version: 4,
27616
27969
  lastModified: Date.now()
27617
27970
  };
27618
27971
  if (!stateResult) logger.debug(`No existing state for ${stackInfo.stackName} (${stackRegion})`);
@@ -28643,6 +28996,11 @@ async function runDestroyForStack(stackName, state, ctx) {
28643
28996
  result.skippedEmpty = true;
28644
28997
  return result;
28645
28998
  }
28999
+ const needsStrongRefCheck = !!(state.outputs && Object.keys(state.outputs).length > 0);
29000
+ if (needsStrongRefCheck) {
29001
+ const consumers = await scanActiveConsumers(stackName, regionForState, ctx);
29002
+ if (consumers.length > 0) throw new StackHasActiveImportsError(stackName, regionForState, consumers);
29003
+ }
28646
29004
  logger.info(`\nResources to be deleted (${resourceCount}):`);
28647
29005
  for (const [logicalId, resource] of Object.entries(state.resources)) logger.info(` - ${logicalId} (${resource.resourceType})`);
28648
29006
  const protectedCount = ctx.removeProtection ? countProtectedResources(state) : 0;
@@ -28685,6 +29043,17 @@ async function runDestroyForStack(stackName, state, ctx) {
28685
29043
  }
28686
29044
  logger.info(`\nAcquiring lock for stack ${stackName}...`);
28687
29045
  await ctx.lockManager.acquireLock(stackName, regionForState, void 0, "destroy");
29046
+ if (needsStrongRefCheck) {
29047
+ const consumers = await scanActiveConsumers(stackName, regionForState, ctx);
29048
+ if (consumers.length > 0) {
29049
+ try {
29050
+ await ctx.lockManager.releaseLock(stackName, regionForState);
29051
+ } catch (releaseErr) {
29052
+ logger.warn(`Failed to release lock after strong-ref refusal: ${releaseErr instanceof Error ? releaseErr.message : String(releaseErr)}`);
29053
+ }
29054
+ throw new StackHasActiveImportsError(stackName, regionForState, consumers);
29055
+ }
29056
+ }
28688
29057
  const renderer = getLiveRenderer();
28689
29058
  renderer.start();
28690
29059
  try {
@@ -28803,6 +29172,7 @@ async function runDestroyForStack(stackName, state, ctx) {
28803
29172
  if (result.errorCount === 0) {
28804
29173
  await ctx.stateBackend.deleteState(stackName, regionForState);
28805
29174
  logger.debug("State deleted");
29175
+ if (ctx.exportIndexStore) await ctx.exportIndexStore.removeStack(stackName, regionForState);
28806
29176
  } else logger.warn(`${result.errorCount} resource(s) failed to delete. State preserved.`);
28807
29177
  if (result.errorCount === 0) logger.info(`\n✓ Stack ${stackName} destroyed (${result.deletedCount} deleted, ${result.errorCount} errors)`);
28808
29178
  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 +29189,38 @@ async function runDestroyForStack(stackName, state, ctx) {
28819
29189
  }
28820
29190
  return result;
28821
29191
  }
29192
+ /**
29193
+ * Strong-reference scan: read every other stack's state.json from the
29194
+ * state bucket and check whether any of its `imports[]` entries names
29195
+ * `producerStack`. Returns the list of offending consumers (possibly
29196
+ * empty).
29197
+ *
29198
+ * NEVER trusts the persistent exports index — a stale index could miss
29199
+ * a freshly-recorded consumer and let a destructive destroy through.
29200
+ * The cost is one `listStacks` + N parallel GETs at destroy time only
29201
+ * (not the deploy hot path), which the user-facing UX rationalizes as
29202
+ * the "destroy is slow OK" trade-off (Issue #343).
29203
+ */
29204
+ async function scanActiveConsumers(producerStack, producerRegion, ctx) {
29205
+ const refs = await ctx.stateBackend.listStacks();
29206
+ return (await Promise.all(refs.map(async (ref) => {
29207
+ const region = ref.region ?? ctx.baseRegion;
29208
+ if (ref.stackName === producerStack && region === producerRegion) return null;
29209
+ try {
29210
+ const imports = (await ctx.stateBackend.getState(ref.stackName, region))?.state.imports;
29211
+ if (!imports || imports.length === 0) return null;
29212
+ const matches = imports.filter((entry) => entry.sourceStack === producerStack && entry.sourceRegion === producerRegion);
29213
+ if (matches.length === 0) return null;
29214
+ return matches.map((entry) => ({
29215
+ consumerStack: ref.stackName,
29216
+ consumerRegion: region,
29217
+ exportName: entry.exportName
29218
+ }));
29219
+ } catch {
29220
+ return null;
29221
+ }
29222
+ }))).filter((r) => r !== null).flat();
29223
+ }
28822
29224
 
28823
29225
  //#endregion
28824
29226
  //#region src/cli/commands/destroy.ts
@@ -28861,6 +29263,7 @@ async function destroyCommand(stackArgs, options) {
28861
29263
  });
28862
29264
  await stateBackend.verifyBucketExists();
28863
29265
  const lockManager = new LockManager(awsClients.s3, stateConfig);
29266
+ const exportIndexStore = new ExportIndexStore(awsClients.s3, stateBucket, options.statePrefix, region, stateBackend);
28864
29267
  const providerRegistry = new ProviderRegistry();
28865
29268
  registerAllProviders(providerRegistry);
28866
29269
  providerRegistry.setCustomResourceResponseBucket(stateBucket);
@@ -28957,6 +29360,7 @@ async function destroyCommand(stackArgs, options) {
28957
29360
  stateBucket,
28958
29361
  skipConfirmation: options.yes || options.force,
28959
29362
  removeProtection: options.removeProtection === true,
29363
+ exportIndexStore,
28960
29364
  ...options.resourceWarnAfter?.globalMs !== void 0 && { resourceWarnAfterMs: options.resourceWarnAfter.globalMs },
28961
29365
  ...options.resourceTimeout?.globalMs !== void 0 && { resourceTimeoutMs: options.resourceTimeout.globalMs },
28962
29366
  ...options.resourceWarnAfter?.perTypeMs && { resourceWarnAfterByType: options.resourceWarnAfter.perTypeMs },
@@ -30231,6 +30635,7 @@ async function setupStateBackend(options) {
30231
30635
  region,
30232
30636
  bucket,
30233
30637
  prefix,
30638
+ exportIndexStore: new ExportIndexStore(awsClients.s3, bucket, prefix, region, stateBackend),
30234
30639
  dispose: () => awsClients.destroy()
30235
30640
  };
30236
30641
  }
@@ -30702,6 +31107,7 @@ async function stateDestroyCommand(stackArgs, options) {
30702
31107
  stateBucket: setup.bucket,
30703
31108
  skipConfirmation: options.yes || options.all === true,
30704
31109
  removeProtection: options.removeProtection === true,
31110
+ exportIndexStore: setup.exportIndexStore,
30705
31111
  ...options.resourceWarnAfter?.globalMs !== void 0 && { resourceWarnAfterMs: options.resourceWarnAfter.globalMs },
30706
31112
  ...options.resourceTimeout?.globalMs !== void 0 && { resourceTimeoutMs: options.resourceTimeout.globalMs },
30707
31113
  ...options.resourceWarnAfter?.perTypeMs && { resourceWarnAfterByType: options.resourceWarnAfter.perTypeMs },
@@ -31784,7 +32190,7 @@ function buildStackState(stackName, region, rows, templateParser, template, exis
31784
32190
  };
31785
32191
  }
31786
32192
  return {
31787
- version: 3,
32193
+ version: 4,
31788
32194
  stackName,
31789
32195
  region,
31790
32196
  resources,
@@ -41837,7 +42243,7 @@ function reorderArgs(argv) {
41837
42243
  */
41838
42244
  async function main() {
41839
42245
  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");
42246
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.98.0");
41841
42247
  program.addCommand(createBootstrapCommand());
41842
42248
  program.addCommand(createSynthCommand());
41843
42249
  program.addCommand(createListCommand());