@go-to-k/cdkd 0.6.0 → 0.8.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/dist/index.js CHANGED
@@ -2661,6 +2661,14 @@ import {
2661
2661
  ListObjectsV2Command,
2662
2662
  NoSuchKey
2663
2663
  } from "@aws-sdk/client-s3";
2664
+
2665
+ // src/types/state.ts
2666
+ var STATE_SCHEMA_VERSION_LEGACY = 1;
2667
+ var STATE_SCHEMA_VERSION_CURRENT = 2;
2668
+
2669
+ // src/state/s3-state-backend.ts
2670
+ var LEGACY_KEY_DEPTH = 2;
2671
+ var NEW_KEY_DEPTH = 3;
2664
2672
  var S3StateBackend = class {
2665
2673
  constructor(s3Client, config) {
2666
2674
  this.s3Client = s3Client;
@@ -2668,9 +2676,16 @@ var S3StateBackend = class {
2668
2676
  }
2669
2677
  logger = getLogger().child("S3StateBackend");
2670
2678
  /**
2671
- * Get the S3 key for a stack's state file
2679
+ * Get the new (region-scoped) S3 key for a stack's state file.
2680
+ */
2681
+ getStateKey(stackName, region) {
2682
+ return `${this.config.prefix}/${stackName}/${region}/state.json`;
2683
+ }
2684
+ /**
2685
+ * Get the legacy (pre-region-prefix) S3 key for a stack's state file.
2686
+ * Used for backwards-compatible reads and for the migration delete.
2672
2687
  */
2673
- getStateKey(stackName) {
2688
+ getLegacyStateKey(stackName) {
2674
2689
  return `${this.config.prefix}/${stackName}/state.json`;
2675
2690
  }
2676
2691
  /**
@@ -2696,101 +2711,143 @@ var S3StateBackend = class {
2696
2711
  }
2697
2712
  }
2698
2713
  /**
2699
- * Check if state exists for a stack
2700
- */
2701
- async stateExists(stackName) {
2702
- const key = this.getStateKey(stackName);
2703
- try {
2704
- await this.s3Client.send(
2705
- new HeadObjectCommand2({
2706
- Bucket: this.config.bucket,
2707
- Key: key
2708
- })
2709
- );
2714
+ * Check if state exists for a stack in the given region.
2715
+ *
2716
+ * Returns true for either layout: the new region-scoped key, or the legacy
2717
+ * key when its embedded `region` matches the requested region. This lets
2718
+ * `cdkd state rm <stack> --region X` and `cdkd destroy <stack>` see legacy
2719
+ * state without forcing a write-through migration first.
2720
+ */
2721
+ async stateExists(stackName, region) {
2722
+ const newKey = this.getStateKey(stackName, region);
2723
+ if (await this.headObject(newKey)) {
2710
2724
  return true;
2711
- } catch (error) {
2712
- if (error instanceof NoSuchKey || error.name === "NotFound") {
2713
- return false;
2714
- }
2715
- throw new StateError(
2716
- `Failed to check if state exists for stack '${stackName}': ${error instanceof Error ? error.message : String(error)}`,
2717
- error instanceof Error ? error : void 0
2718
- );
2719
2725
  }
2726
+ return this.legacyMatchesRegion(stackName, region);
2720
2727
  }
2721
2728
  /**
2722
- * Get state for a stack
2729
+ * Get state for a stack, transparently falling back to the legacy key.
2723
2730
  *
2724
- * Note: S3 returns ETag with surrounding quotes (e.g., "abc123").
2725
- * We preserve the quotes as they are required for IfMatch conditions.
2731
+ * Lookup order:
2732
+ * 1. `{prefix}/{stackName}/{region}/state.json` (current `version: 2` key).
2733
+ * 2. `{prefix}/{stackName}/state.json` (legacy `version: 1` key) — only
2734
+ * accepted if its embedded `region` matches the requested region.
2735
+ *
2736
+ * When a legacy hit is returned, `migrationPending` is `true`. Callers that
2737
+ * subsequently `saveState` automatically migrate by writing the new key and
2738
+ * deleting the legacy one (see `saveState`'s `legacyMigration` argument).
2739
+ *
2740
+ * Note: S3 returns ETag with surrounding quotes (e.g., `"abc123"`). We
2741
+ * preserve the quotes — they are required for `IfMatch` conditions.
2726
2742
  */
2727
- async getState(stackName) {
2728
- const key = this.getStateKey(stackName);
2743
+ async getState(stackName, region) {
2744
+ const newKey = this.getStateKey(stackName, region);
2729
2745
  try {
2730
- this.logger.debug(`Getting state for stack: ${stackName}`);
2746
+ this.logger.debug(`Getting state for stack: ${stackName} (${region})`);
2731
2747
  const response = await this.s3Client.send(
2732
2748
  new GetObjectCommand({
2733
2749
  Bucket: this.config.bucket,
2734
- Key: key
2750
+ Key: newKey
2735
2751
  })
2736
2752
  );
2737
2753
  if (!response.Body) {
2738
- throw new StateError(`State file for stack '${stackName}' has no body`);
2754
+ throw new StateError(`State file for stack '${stackName}' (${region}) has no body`);
2739
2755
  }
2740
2756
  if (!response.ETag) {
2741
- throw new StateError(`State file for stack '${stackName}' has no ETag`);
2757
+ throw new StateError(`State file for stack '${stackName}' (${region}) has no ETag`);
2742
2758
  }
2743
2759
  const bodyString = await response.Body.transformToString();
2744
- const state = JSON.parse(bodyString);
2745
- this.logger.debug(`Retrieved state for stack: ${stackName}, ETag: ${response.ETag}`);
2746
- return {
2747
- state,
2748
- etag: response.ETag
2749
- };
2760
+ const state = this.parseStateBody(bodyString, stackName);
2761
+ this.logger.debug(`Retrieved state: ${stackName} (${region}), ETag: ${response.ETag}`);
2762
+ return { state, etag: response.ETag };
2750
2763
  } catch (error) {
2751
- if (error instanceof NoSuchKey || error.name === "NoSuchKey") {
2752
- this.logger.debug(`No existing state for stack: ${stackName}`);
2753
- return null;
2754
- }
2755
- if (error instanceof StateError) {
2756
- throw error;
2764
+ if (!isNoSuchKey(error)) {
2765
+ if (error instanceof StateError)
2766
+ throw error;
2767
+ throw new StateError(
2768
+ `Failed to get state for stack '${stackName}' (${region}): ${error instanceof Error ? error.message : String(error)}`,
2769
+ error instanceof Error ? error : void 0
2770
+ );
2757
2771
  }
2758
- throw new StateError(
2759
- `Failed to get state for stack '${stackName}': ${error instanceof Error ? error.message : String(error)}`,
2760
- error instanceof Error ? error : void 0
2772
+ this.logger.debug(`No state at new key for stack: ${stackName} (${region})`);
2773
+ }
2774
+ const legacy = await this.tryGetLegacy(stackName, region);
2775
+ if (legacy) {
2776
+ this.logger.warn(
2777
+ `Loaded legacy state for stack '${stackName}' from '${this.getLegacyStateKey(stackName)}'. It will be migrated to the region-scoped layout on next save.`
2761
2778
  );
2779
+ return { ...legacy, migrationPending: true };
2762
2780
  }
2781
+ return null;
2763
2782
  }
2764
2783
  /**
2765
- * Save state for a stack with optimistic locking
2784
+ * Save state for a stack with optimistic locking.
2785
+ *
2786
+ * Always writes to the new region-scoped key. The state body is rewritten
2787
+ * with `version: 2` and the supplied region.
2788
+ *
2789
+ * If the caller observed `migrationPending: true` from `getState`, it
2790
+ * should pass the legacy ETag back via `expectedEtag` AND set
2791
+ * `migrateLegacy: true`. After the new key is written successfully, the
2792
+ * legacy key is deleted to complete migration. The legacy delete is a
2793
+ * best-effort follow-up — a failure is logged but does not unwind the new
2794
+ * write.
2766
2795
  *
2767
2796
  * @param stackName Stack name
2797
+ * @param region Target region (load-bearing — part of the S3 key)
2768
2798
  * @param state State to save
2769
- * @param expectedEtag Expected ETag for optimistic locking (optional for new state).
2770
- * Must include quotes if provided (e.g., "abc123")
2771
- * @returns New ETag (with quotes, e.g., "abc123")
2772
- */
2773
- async saveState(stackName, state, expectedEtag) {
2774
- const key = this.getStateKey(stackName);
2799
+ * @param options Optimistic-lock ETag + legacy-migration flag
2800
+ * @returns New ETag (with quotes, e.g., `"abc123"`)
2801
+ */
2802
+ async saveState(stackName, region, state, options = {}) {
2803
+ const newKey = this.getStateKey(stackName, region);
2804
+ const { expectedEtag, migrateLegacy } = options;
2805
+ const body = {
2806
+ ...state,
2807
+ version: STATE_SCHEMA_VERSION_CURRENT,
2808
+ stackName,
2809
+ region
2810
+ };
2775
2811
  try {
2776
2812
  this.logger.debug(
2777
- `Saving state for stack: ${stackName}${expectedEtag ? `, expected ETag: ${expectedEtag}` : ""}`
2813
+ `Saving state: ${stackName} (${region})${expectedEtag ? `, expected ETag: ${expectedEtag}` : ""}`
2778
2814
  );
2779
- const body = JSON.stringify(state, null, 2);
2815
+ const bodyString = JSON.stringify(body, null, 2);
2780
2816
  const response = await this.s3Client.send(
2781
2817
  new PutObjectCommand2({
2782
2818
  Bucket: this.config.bucket,
2783
- Key: key,
2784
- Body: body,
2785
- ContentLength: Buffer.byteLength(body),
2819
+ Key: newKey,
2820
+ Body: bodyString,
2821
+ ContentLength: Buffer.byteLength(bodyString),
2786
2822
  ContentType: "application/json",
2787
- ...expectedEtag && { IfMatch: expectedEtag }
2823
+ // The legacy ETag is for a different key; only forward it when we're
2824
+ // updating in-place at the new key.
2825
+ ...!migrateLegacy && expectedEtag && { IfMatch: expectedEtag }
2788
2826
  })
2789
2827
  );
2790
2828
  if (!response.ETag) {
2791
- throw new StateError(`No ETag returned after saving state for stack '${stackName}'`);
2829
+ throw new StateError(
2830
+ `No ETag returned after saving state for stack '${stackName}' (${region})`
2831
+ );
2832
+ }
2833
+ this.logger.debug(`State saved: ${stackName} (${region}), new ETag: ${response.ETag}`);
2834
+ if (migrateLegacy) {
2835
+ try {
2836
+ await this.s3Client.send(
2837
+ new DeleteObjectCommand({
2838
+ Bucket: this.config.bucket,
2839
+ Key: this.getLegacyStateKey(stackName)
2840
+ })
2841
+ );
2842
+ this.logger.info(
2843
+ `Migrated state for stack '${stackName}' to region-scoped layout (${region})`
2844
+ );
2845
+ } catch (deleteError) {
2846
+ this.logger.warn(
2847
+ `Migrated stack '${stackName}' to new key, but failed to delete legacy key: ${deleteError instanceof Error ? deleteError.message : String(deleteError)}`
2848
+ );
2849
+ }
2792
2850
  }
2793
- this.logger.debug(`State saved for stack: ${stackName}, new ETag: ${response.ETag}`);
2794
2851
  return response.ETag;
2795
2852
  } catch (error) {
2796
2853
  if (error.name === "PreconditionFailed") {
@@ -2799,63 +2856,230 @@ var S3StateBackend = class {
2799
2856
  );
2800
2857
  }
2801
2858
  throw new StateError(
2802
- `Failed to save state for stack '${stackName}': ${error instanceof Error ? error.message : String(error)}`,
2859
+ `Failed to save state for stack '${stackName}' (${region}): ${error instanceof Error ? error.message : String(error)}`,
2803
2860
  error instanceof Error ? error : void 0
2804
2861
  );
2805
2862
  }
2806
2863
  }
2807
2864
  /**
2808
- * Delete state for a stack
2865
+ * Delete state for a stack in the given region.
2866
+ *
2867
+ * Removes both the new key and the legacy key (if present). Legacy removal
2868
+ * is region-conditional: a legacy state file with a different `region`
2869
+ * field is left alone.
2809
2870
  */
2810
- async deleteState(stackName) {
2811
- const key = this.getStateKey(stackName);
2871
+ async deleteState(stackName, region) {
2812
2872
  try {
2813
- this.logger.debug(`Deleting state for stack: ${stackName}`);
2873
+ this.logger.debug(`Deleting state: ${stackName} (${region})`);
2814
2874
  await this.s3Client.send(
2815
2875
  new DeleteObjectCommand({
2816
2876
  Bucket: this.config.bucket,
2817
- Key: key
2877
+ Key: this.getStateKey(stackName, region)
2818
2878
  })
2819
2879
  );
2820
- this.logger.debug(`State deleted for stack: ${stackName}`);
2880
+ if (await this.legacyMatchesRegion(stackName, region)) {
2881
+ await this.s3Client.send(
2882
+ new DeleteObjectCommand({
2883
+ Bucket: this.config.bucket,
2884
+ Key: this.getLegacyStateKey(stackName)
2885
+ })
2886
+ );
2887
+ this.logger.debug(`Deleted legacy state for stack: ${stackName}`);
2888
+ }
2889
+ this.logger.debug(`State deleted: ${stackName} (${region})`);
2821
2890
  } catch (error) {
2822
2891
  throw new StateError(
2823
- `Failed to delete state for stack '${stackName}': ${error instanceof Error ? error.message : String(error)}`,
2892
+ `Failed to delete state for stack '${stackName}' (${region}): ${error instanceof Error ? error.message : String(error)}`,
2824
2893
  error instanceof Error ? error : void 0
2825
2894
  );
2826
2895
  }
2827
2896
  }
2828
2897
  /**
2829
- * List all stacks with state
2898
+ * List all stacks with state in the bucket.
2899
+ *
2900
+ * Returns one `{stackName, region}` pair per state file. Both layouts
2901
+ * are enumerated:
2902
+ *
2903
+ * - `{prefix}/{stackName}/{region}/state.json` (new) — `region` is the
2904
+ * path segment.
2905
+ * - `{prefix}/{stackName}/state.json` (legacy) — `region` is read from the
2906
+ * state body when present, otherwise `undefined`.
2907
+ *
2908
+ * Pairs are deduplicated by `(stackName, region)` so a stack mid-migration
2909
+ * shows up exactly once.
2830
2910
  */
2831
2911
  async listStacks() {
2832
2912
  try {
2833
2913
  this.logger.debug("Listing all stacks");
2914
+ const prefix = `${this.config.prefix}/`;
2915
+ const refs = [];
2916
+ const seen = /* @__PURE__ */ new Set();
2917
+ let continuationToken;
2918
+ do {
2919
+ const response = await this.s3Client.send(
2920
+ new ListObjectsV2Command({
2921
+ Bucket: this.config.bucket,
2922
+ Prefix: prefix,
2923
+ ...continuationToken && { ContinuationToken: continuationToken }
2924
+ })
2925
+ );
2926
+ for (const obj of response.Contents ?? []) {
2927
+ const key = obj.Key;
2928
+ if (!key)
2929
+ continue;
2930
+ if (!key.endsWith("/state.json"))
2931
+ continue;
2932
+ const rest = key.slice(prefix.length);
2933
+ const segments = rest.split("/");
2934
+ if (segments.length === NEW_KEY_DEPTH) {
2935
+ const [stackName, region] = segments;
2936
+ if (!stackName || !region)
2937
+ continue;
2938
+ const dedupeKey = `${stackName}\0${region}`;
2939
+ if (!seen.has(dedupeKey)) {
2940
+ seen.add(dedupeKey);
2941
+ refs.push({ stackName, region });
2942
+ }
2943
+ continue;
2944
+ }
2945
+ if (segments.length === LEGACY_KEY_DEPTH) {
2946
+ const [stackName] = segments;
2947
+ if (!stackName)
2948
+ continue;
2949
+ const region = await this.readLegacyRegion(stackName);
2950
+ const dedupeKey = `${stackName}\0${region ?? ""}`;
2951
+ if (!seen.has(dedupeKey)) {
2952
+ seen.add(dedupeKey);
2953
+ refs.push({ stackName, ...region ? { region } : {} });
2954
+ }
2955
+ }
2956
+ }
2957
+ continuationToken = response.IsTruncated ? response.NextContinuationToken : void 0;
2958
+ } while (continuationToken);
2959
+ this.logger.debug(`Found ${refs.length} stack(s) across regions`);
2960
+ return refs;
2961
+ } catch (error) {
2962
+ throw new StateError(
2963
+ `Failed to list stacks: ${error instanceof Error ? error.message : String(error)}`,
2964
+ error instanceof Error ? error : void 0
2965
+ );
2966
+ }
2967
+ }
2968
+ /**
2969
+ * HeadObject probe — returns true on 200, false on NotFound. Other errors
2970
+ * propagate so we don't accidentally swallow IAM denials.
2971
+ */
2972
+ async headObject(key) {
2973
+ try {
2974
+ await this.s3Client.send(
2975
+ new HeadObjectCommand2({
2976
+ Bucket: this.config.bucket,
2977
+ Key: key
2978
+ })
2979
+ );
2980
+ return true;
2981
+ } catch (error) {
2982
+ if (isNoSuchKey(error) || error.name === "NotFound") {
2983
+ return false;
2984
+ }
2985
+ throw error;
2986
+ }
2987
+ }
2988
+ /**
2989
+ * Read the legacy state's `region` field. Used for region matching during
2990
+ * `stateExists` / `deleteState` and for assigning a region to legacy
2991
+ * entries during `listStacks`.
2992
+ */
2993
+ async readLegacyRegion(stackName) {
2994
+ try {
2834
2995
  const response = await this.s3Client.send(
2835
- new ListObjectsV2Command({
2996
+ new GetObjectCommand({
2836
2997
  Bucket: this.config.bucket,
2837
- Prefix: `${this.config.prefix}/`,
2838
- Delimiter: "/"
2998
+ Key: this.getLegacyStateKey(stackName)
2839
2999
  })
2840
3000
  );
2841
- if (!response.CommonPrefixes) {
2842
- return [];
2843
- }
2844
- const stackNames = response.CommonPrefixes.map((prefix) => {
2845
- const prefixStr = prefix.Prefix || "";
2846
- const parts = prefixStr.split("/");
2847
- return parts[parts.length - 2];
2848
- }).filter((name) => Boolean(name));
2849
- this.logger.debug(`Found ${stackNames.length} stacks`);
2850
- return stackNames;
3001
+ if (!response.Body)
3002
+ return void 0;
3003
+ const bodyString = await response.Body.transformToString();
3004
+ const state = JSON.parse(bodyString);
3005
+ return typeof state.region === "string" ? state.region : void 0;
3006
+ } catch (error) {
3007
+ if (isNoSuchKey(error))
3008
+ return void 0;
3009
+ this.logger.debug(
3010
+ `Could not read legacy state region for '${stackName}': ${error instanceof Error ? error.message : String(error)}`
3011
+ );
3012
+ return void 0;
3013
+ }
3014
+ }
3015
+ async legacyMatchesRegion(stackName, region) {
3016
+ const legacyRegion = await this.readLegacyRegion(stackName);
3017
+ return legacyRegion === region;
3018
+ }
3019
+ /**
3020
+ * Try to read the legacy `version: 1` state. Returns null when the legacy
3021
+ * key is missing or its embedded region does not match the caller's region.
3022
+ */
3023
+ async tryGetLegacy(stackName, region) {
3024
+ try {
3025
+ const response = await this.s3Client.send(
3026
+ new GetObjectCommand({
3027
+ Bucket: this.config.bucket,
3028
+ Key: this.getLegacyStateKey(stackName)
3029
+ })
3030
+ );
3031
+ if (!response.Body || !response.ETag) {
3032
+ return null;
3033
+ }
3034
+ const bodyString = await response.Body.transformToString();
3035
+ const state = this.parseStateBody(bodyString, stackName);
3036
+ if (state.region && state.region !== region) {
3037
+ this.logger.debug(
3038
+ `Legacy state for stack '${stackName}' has region '${state.region}', not '${region}' \u2014 skipping legacy fallback.`
3039
+ );
3040
+ return null;
3041
+ }
3042
+ return { state, etag: response.ETag };
3043
+ } catch (error) {
3044
+ if (isNoSuchKey(error))
3045
+ return null;
3046
+ throw new StateError(
3047
+ `Failed to get legacy state for stack '${stackName}': ${error instanceof Error ? error.message : String(error)}`,
3048
+ error instanceof Error ? error : void 0
3049
+ );
3050
+ }
3051
+ }
3052
+ /**
3053
+ * Parse a state body and validate the schema version. Future-proofs against
3054
+ * a binary that predates schema version `N` reading a `version: N+1` blob:
3055
+ * the old binary would otherwise treat unknown fields as defaults and
3056
+ * silently lose data on the next save.
3057
+ */
3058
+ parseStateBody(bodyString, stackName) {
3059
+ let parsed;
3060
+ try {
3061
+ parsed = JSON.parse(bodyString);
2851
3062
  } catch (error) {
2852
3063
  throw new StateError(
2853
- `Failed to list stacks: ${error instanceof Error ? error.message : String(error)}`,
3064
+ `State file for stack '${stackName}' is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
2854
3065
  error instanceof Error ? error : void 0
2855
3066
  );
2856
3067
  }
3068
+ const v = parsed.version;
3069
+ if (v !== STATE_SCHEMA_VERSION_LEGACY && v !== STATE_SCHEMA_VERSION_CURRENT && v !== void 0) {
3070
+ throw new StateError(
3071
+ `Unsupported state schema version ${String(v)} for stack '${stackName}'. This cdkd binary supports versions ${String(STATE_SCHEMA_VERSION_LEGACY)} and ${String(STATE_SCHEMA_VERSION_CURRENT)}. Upgrade cdkd to a version that supports schema ${String(v)}.`
3072
+ );
3073
+ }
3074
+ return parsed;
2857
3075
  }
2858
3076
  };
3077
+ function isNoSuchKey(error) {
3078
+ if (error instanceof NoSuchKey)
3079
+ return true;
3080
+ const name = error?.name;
3081
+ return name === "NoSuchKey";
3082
+ }
2859
3083
 
2860
3084
  // src/state/lock-manager.ts
2861
3085
  import {
@@ -2876,10 +3100,24 @@ var LockManager = class {
2876
3100
  logger = getLogger().child("LockManager");
2877
3101
  ttlMs;
2878
3102
  /**
2879
- * Get the S3 key for a stack's lock file
3103
+ * Get the S3 key for a stack's lock file.
3104
+ *
3105
+ * Locks are region-scoped, mirroring the state key layout
3106
+ * (`{prefix}/{stackName}/{region}/lock.json`). Two regions of the same
3107
+ * stackName can therefore be operated on in parallel without contention,
3108
+ * matching cdkd's parallel execution model.
3109
+ *
3110
+ * The `region` argument is required for new callers; for backwards
3111
+ * compatibility with `state list --long` (which only sees stack names),
3112
+ * passing `undefined` falls back to the legacy `{prefix}/{stackName}/lock.json`
3113
+ * key — that mode is purely for legacy lock cleanup and is NOT used by
3114
+ * deploy / destroy / diff anymore.
2880
3115
  */
2881
- getLockKey(stackName) {
2882
- return `${this.config.prefix}/${stackName}/lock.json`;
3116
+ getLockKey(stackName, region) {
3117
+ if (region === void 0) {
3118
+ return `${this.config.prefix}/${stackName}/lock.json`;
3119
+ }
3120
+ return `${this.config.prefix}/${stackName}/${region}/lock.json`;
2883
3121
  }
2884
3122
  /**
2885
3123
  * Get default lock owner identifier
@@ -2918,11 +3156,12 @@ var LockManager = class {
2918
3156
  * If an expired lock exists, it will be cleaned up and re-acquired.
2919
3157
  *
2920
3158
  * @param stackName Stack name
3159
+ * @param region Target region (lock key is region-scoped)
2921
3160
  * @param owner Lock owner identifier (defaults to user@hostname:pid)
2922
3161
  * @param operation Operation being performed (e.g., "deploy", "destroy")
2923
3162
  */
2924
- async acquireLock(stackName, owner, operation) {
2925
- const key = this.getLockKey(stackName);
3163
+ async acquireLock(stackName, region, owner, operation) {
3164
+ const key = this.getLockKey(stackName, region);
2926
3165
  const lockOwner = owner || this.getDefaultOwner();
2927
3166
  const now = Date.now();
2928
3167
  const lockInfo = {
@@ -2932,7 +3171,7 @@ var LockManager = class {
2932
3171
  ...operation && { operation }
2933
3172
  };
2934
3173
  try {
2935
- this.logger.debug(`Attempting to acquire lock for stack: ${stackName}`);
3174
+ this.logger.debug(`Attempting to acquire lock for stack: ${stackName} (${region})`);
2936
3175
  const lockBody = JSON.stringify(lockInfo, null, 2);
2937
3176
  await this.s3Client.send(
2938
3177
  new PutObjectCommand3({
@@ -2945,17 +3184,17 @@ var LockManager = class {
2945
3184
  // Only succeed if object doesn't exist
2946
3185
  })
2947
3186
  );
2948
- this.logger.debug(`Lock acquired for stack: ${stackName}, owner: ${lockOwner}`);
3187
+ this.logger.debug(`Lock acquired for stack: ${stackName} (${region}), owner: ${lockOwner}`);
2949
3188
  return true;
2950
3189
  } catch (error) {
2951
3190
  if (error instanceof S3ServiceException && error.name === "PreconditionFailed") {
2952
- this.logger.debug(`Lock already exists for stack: ${stackName}`);
2953
- const existingLock = await this.getLockInfo(stackName);
3191
+ this.logger.debug(`Lock already exists for stack: ${stackName} (${region})`);
3192
+ const existingLock = await this.getLockInfo(stackName, region);
2954
3193
  if (existingLock && this.isLockExpired(existingLock)) {
2955
3194
  this.logger.info(
2956
- `Expired lock detected for stack: ${stackName} (owner: ${existingLock.owner}, expired ${this.formatDuration(now - existingLock.expiresAt)} ago). Cleaning up...`
3195
+ `Expired lock detected for stack: ${stackName} (${region}, owner: ${existingLock.owner}, expired ${this.formatDuration(now - existingLock.expiresAt)} ago). Cleaning up...`
2957
3196
  );
2958
- await this.deleteLock(stackName);
3197
+ await this.deleteLock(stackName, region);
2959
3198
  try {
2960
3199
  const retryBody = JSON.stringify(lockInfo, null, 2);
2961
3200
  await this.s3Client.send(
@@ -2969,13 +3208,13 @@ var LockManager = class {
2969
3208
  })
2970
3209
  );
2971
3210
  this.logger.debug(
2972
- `Lock acquired for stack: ${stackName} after expired lock cleanup, owner: ${lockOwner}`
3211
+ `Lock acquired for stack: ${stackName} (${region}) after expired lock cleanup, owner: ${lockOwner}`
2973
3212
  );
2974
3213
  return true;
2975
3214
  } catch (retryError) {
2976
3215
  if (retryError instanceof S3ServiceException && retryError.name === "PreconditionFailed") {
2977
3216
  this.logger.debug(
2978
- `Lock was acquired by another process during expired lock cleanup for stack: ${stackName}`
3217
+ `Lock was acquired by another process during expired lock cleanup for stack: ${stackName} (${region})`
2979
3218
  );
2980
3219
  return false;
2981
3220
  }
@@ -2985,16 +3224,20 @@ var LockManager = class {
2985
3224
  return false;
2986
3225
  }
2987
3226
  throw new LockError(
2988
- `Failed to acquire lock for stack '${stackName}': ${error instanceof Error ? error.message : String(error)}`,
3227
+ `Failed to acquire lock for stack '${stackName}' (${region}): ${error instanceof Error ? error.message : String(error)}`,
2989
3228
  error instanceof Error ? error : void 0
2990
3229
  );
2991
3230
  }
2992
3231
  }
2993
3232
  /**
2994
- * Get current lock information
3233
+ * Get current lock information.
3234
+ *
3235
+ * `region` is required for the new region-scoped lock layout. Pass
3236
+ * `undefined` only to inspect a legacy `{prefix}/{stackName}/lock.json`
3237
+ * file (e.g. for state-listing tools that don't yet know the region).
2995
3238
  */
2996
- async getLockInfo(stackName) {
2997
- const key = this.getLockKey(stackName);
3239
+ async getLockInfo(stackName, region) {
3240
+ const key = this.getLockKey(stackName, region);
2998
3241
  try {
2999
3242
  this.logger.debug(`Getting lock info for stack: ${stackName}`);
3000
3243
  const response = await this.s3Client.send(
@@ -3032,27 +3275,27 @@ var LockManager = class {
3032
3275
  * not for acquisition decisions — use `acquireLock` for that, which has its
3033
3276
  * own expired-lock cleanup logic.
3034
3277
  */
3035
- async isLocked(stackName) {
3036
- const lockInfo = await this.getLockInfo(stackName);
3278
+ async isLocked(stackName, region) {
3279
+ const lockInfo = await this.getLockInfo(stackName, region);
3037
3280
  return lockInfo !== null;
3038
3281
  }
3039
3282
  /**
3040
3283
  * Release a lock for a stack
3041
3284
  */
3042
- async releaseLock(stackName) {
3043
- const key = this.getLockKey(stackName);
3285
+ async releaseLock(stackName, region) {
3286
+ const key = this.getLockKey(stackName, region);
3044
3287
  try {
3045
- this.logger.debug(`Releasing lock for stack: ${stackName}`);
3288
+ this.logger.debug(`Releasing lock for stack: ${stackName} (${region})`);
3046
3289
  await this.s3Client.send(
3047
3290
  new DeleteObjectCommand2({
3048
3291
  Bucket: this.config.bucket,
3049
3292
  Key: key
3050
3293
  })
3051
3294
  );
3052
- this.logger.debug(`Lock released for stack: ${stackName}`);
3295
+ this.logger.debug(`Lock released for stack: ${stackName} (${region})`);
3053
3296
  } catch (error) {
3054
3297
  throw new LockError(
3055
- `Failed to release lock for stack '${stackName}': ${error instanceof Error ? error.message : String(error)}`,
3298
+ `Failed to release lock for stack '${stackName}' (${region}): ${error instanceof Error ? error.message : String(error)}`,
3056
3299
  error instanceof Error ? error : void 0
3057
3300
  );
3058
3301
  }
@@ -3062,23 +3305,28 @@ var LockManager = class {
3062
3305
  *
3063
3306
  * This is intended for CLI usage (e.g., --force-unlock flag) when a lock
3064
3307
  * is stuck and needs manual intervention.
3308
+ *
3309
+ * Pass `region: undefined` to operate on a legacy
3310
+ * `{prefix}/{stackName}/lock.json` file.
3065
3311
  */
3066
- async forceReleaseLock(stackName) {
3067
- const lockInfo = await this.getLockInfo(stackName);
3312
+ async forceReleaseLock(stackName, region) {
3313
+ const lockInfo = await this.getLockInfo(stackName, region);
3068
3314
  if (!lockInfo) {
3069
- this.logger.warn(`No lock to force release for stack: ${stackName}`);
3315
+ this.logger.warn(
3316
+ `No lock to force release for stack: ${stackName}${region ? ` (${region})` : ""}`
3317
+ );
3070
3318
  return;
3071
3319
  }
3072
3320
  this.logger.warn(
3073
- `Force releasing lock for stack: ${stackName}, owner: ${lockInfo.owner}${lockInfo.operation ? `, operation: ${lockInfo.operation}` : ""}, expired: ${this.isLockExpired(lockInfo)}`
3321
+ `Force releasing lock for stack: ${stackName}${region ? ` (${region})` : ""}, owner: ${lockInfo.owner}${lockInfo.operation ? `, operation: ${lockInfo.operation}` : ""}, expired: ${this.isLockExpired(lockInfo)}`
3074
3322
  );
3075
- await this.deleteLock(stackName);
3323
+ await this.deleteLock(stackName, region);
3076
3324
  }
3077
3325
  /**
3078
3326
  * Internal method to delete the lock file from S3
3079
3327
  */
3080
- async deleteLock(stackName) {
3081
- const key = this.getLockKey(stackName);
3328
+ async deleteLock(stackName, region) {
3329
+ const key = this.getLockKey(stackName, region);
3082
3330
  await this.s3Client.send(
3083
3331
  new DeleteObjectCommand2({
3084
3332
  Bucket: this.config.bucket,
@@ -3099,28 +3347,28 @@ var LockManager = class {
3099
3347
  * @param maxRetries Maximum number of retries (default: 3)
3100
3348
  * @param retryDelay Delay between retries in milliseconds (default: 2000)
3101
3349
  */
3102
- async acquireLockWithRetry(stackName, owner, operation, maxRetries = 3, retryDelay = 2e3) {
3350
+ async acquireLockWithRetry(stackName, region, owner, operation, maxRetries = 3, retryDelay = 2e3) {
3103
3351
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
3104
- const acquired = await this.acquireLock(stackName, owner, operation);
3352
+ const acquired = await this.acquireLock(stackName, region, owner, operation);
3105
3353
  if (acquired) {
3106
3354
  return;
3107
3355
  }
3108
- const lockInfo2 = await this.getLockInfo(stackName);
3356
+ const lockInfo2 = await this.getLockInfo(stackName, region);
3109
3357
  if (lockInfo2) {
3110
3358
  const remainingMs = lockInfo2.expiresAt - Date.now();
3111
3359
  if (attempt < maxRetries) {
3112
3360
  this.logger.info(
3113
- `Stack '${stackName}' is locked by ${lockInfo2.owner}${lockInfo2.operation ? ` (operation: ${lockInfo2.operation})` : ""}. Lock expires in ${this.formatDuration(remainingMs)}. Retrying in ${this.formatDuration(retryDelay)}... (attempt ${attempt + 1}/${maxRetries})`
3361
+ `Stack '${stackName}' (${region}) is locked by ${lockInfo2.owner}${lockInfo2.operation ? ` (operation: ${lockInfo2.operation})` : ""}. Lock expires in ${this.formatDuration(remainingMs)}. Retrying in ${this.formatDuration(retryDelay)}... (attempt ${attempt + 1}/${maxRetries})`
3114
3362
  );
3115
3363
  await new Promise((resolve4) => setTimeout(resolve4, retryDelay));
3116
3364
  continue;
3117
3365
  }
3118
3366
  }
3119
3367
  }
3120
- const lockInfo = await this.getLockInfo(stackName);
3368
+ const lockInfo = await this.getLockInfo(stackName, region);
3121
3369
  const expiresIn = lockInfo ? this.formatDuration(lockInfo.expiresAt - Date.now()) : "unknown";
3122
3370
  throw new LockError(
3123
- `Failed to acquire lock for stack '${stackName}' after ${maxRetries + 1} attempts. ` + (lockInfo ? `Locked by: ${lockInfo.owner}${lockInfo.operation ? `, operation: ${lockInfo.operation}` : ""}, expires in: ${expiresIn}. Use --force-unlock to manually release the lock.` : "Lock exists but could not read lock info.")
3371
+ `Failed to acquire lock for stack '${stackName}' (${region}) after ${maxRetries + 1} attempts. ` + (lockInfo ? `Locked by: ${lockInfo.owner}${lockInfo.operation ? `, operation: ${lockInfo.operation}` : ""}, expires in: ${expiresIn}. Use --force-unlock to manually release the lock.` : "Lock exists but could not read lock info.")
3124
3372
  );
3125
3373
  }
3126
3374
  };
@@ -4903,35 +5151,45 @@ var IntrinsicFunctionResolver = class {
4903
5151
  }
4904
5152
  this.logger.debug(`Resolving Fn::ImportValue: ${exportName}`);
4905
5153
  const allStacks = await context.stateBackend.listStacks();
4906
- this.logger.debug(`Found ${allStacks.length} stacks to search for export: ${exportName}`);
4907
- for (const stackName of allStacks) {
4908
- if (context.stackName && stackName === context.stackName) {
4909
- this.logger.debug(`Skipping current stack: ${stackName}`);
5154
+ this.logger.debug(
5155
+ `Found ${allStacks.length} state record(s) to search for export: ${exportName}`
5156
+ );
5157
+ for (const ref of allStacks) {
5158
+ const { stackName: refStack, region: refRegion } = ref;
5159
+ if (context.stackName && refStack === context.stackName) {
5160
+ this.logger.debug(`Skipping current stack: ${refStack}`);
4910
5161
  continue;
4911
5162
  }
4912
5163
  try {
4913
- const stateData = await context.stateBackend.getState(stackName);
5164
+ const lookupRegion = refRegion ?? this.resolverRegion ?? "";
5165
+ if (!lookupRegion) {
5166
+ this.logger.debug(
5167
+ `No region available for stack '${refStack}' \u2014 skipping (cdkd cannot read state without a region)`
5168
+ );
5169
+ continue;
5170
+ }
5171
+ const stateData = await context.stateBackend.getState(refStack, lookupRegion);
4914
5172
  if (!stateData) {
4915
- this.logger.debug(`No state found for stack: ${stackName}`);
5173
+ this.logger.debug(`No state found for stack: ${refStack} (${lookupRegion})`);
4916
5174
  continue;
4917
5175
  }
4918
5176
  const { state } = stateData;
4919
5177
  if (state.outputs && exportName in state.outputs) {
4920
5178
  const value = state.outputs[exportName];
4921
5179
  this.logger.info(
4922
- `Resolved Fn::ImportValue: ${exportName} = ${JSON.stringify(value)} (from stack: ${stackName})`
5180
+ `Resolved Fn::ImportValue: ${exportName} = ${JSON.stringify(value)} (from stack: ${refStack} / ${lookupRegion})`
4923
5181
  );
4924
5182
  return value;
4925
5183
  }
4926
5184
  } catch (error) {
4927
5185
  this.logger.warn(
4928
- `Failed to read state for stack ${stackName}: ${error instanceof Error ? error.message : String(error)}`
5186
+ `Failed to read state for stack ${refStack}: ${error instanceof Error ? error.message : String(error)}`
4929
5187
  );
4930
5188
  continue;
4931
5189
  }
4932
5190
  }
4933
5191
  throw new Error(
4934
- `Fn::ImportValue: export '${exportName}' not found in any stack. Searched ${allStacks.length} stacks. Make sure the exporting stack has been deployed and the Output has an Export.Name property.`
5192
+ `Fn::ImportValue: export '${exportName}' not found in any stack. Searched ${allStacks.length} state record(s). Make sure the exporting stack has been deployed and the Output has an Export.Name property.`
4935
5193
  );
4936
5194
  }
4937
5195
  /**
@@ -7478,7 +7736,11 @@ var DeployEngine = class {
7478
7736
  logger = getLogger().child("DeployEngine");
7479
7737
  resolver;
7480
7738
  interrupted = false;
7481
- /** Target region for this stack (saved in state for cross-region destroy) */
7739
+ /**
7740
+ * Target region for this stack. Required — load-bearing for the
7741
+ * region-prefixed S3 state key and recorded in state.json for
7742
+ * cross-region destroy.
7743
+ */
7482
7744
  stackRegion;
7483
7745
  /**
7484
7746
  * Deploy a CloudFormation template
@@ -7487,7 +7749,7 @@ var DeployEngine = class {
7487
7749
  const startTime = Date.now();
7488
7750
  this.logger.debug(`Starting deployment for stack: ${stackName}`);
7489
7751
  setCurrentStackName(stackName);
7490
- await this.lockManager.acquireLockWithRetry(stackName, void 0, "deploy");
7752
+ await this.lockManager.acquireLockWithRetry(stackName, this.stackRegion, void 0, "deploy");
7491
7753
  const renderer = getLiveRenderer();
7492
7754
  renderer.start();
7493
7755
  this.interrupted = false;
@@ -7501,16 +7763,17 @@ var DeployEngine = class {
7501
7763
  };
7502
7764
  process.on("SIGINT", sigintHandler);
7503
7765
  try {
7504
- const currentStateData = await this.stateBackend.getState(stackName);
7766
+ const currentStateData = await this.stateBackend.getState(stackName, this.stackRegion);
7505
7767
  const currentState = currentStateData?.state ?? {
7506
- version: 1,
7507
- ...this.stackRegion && { region: this.stackRegion },
7768
+ version: STATE_SCHEMA_VERSION_CURRENT,
7769
+ region: this.stackRegion,
7508
7770
  stackName,
7509
7771
  resources: {},
7510
7772
  outputs: {},
7511
7773
  lastModified: Date.now()
7512
7774
  };
7513
7775
  const currentEtag = currentStateData?.etag;
7776
+ const migrationPending = currentStateData?.migrationPending ?? false;
7514
7777
  this.logger.debug(
7515
7778
  `Loaded current state: ${Object.keys(currentState.resources).length} resources`
7516
7779
  );
@@ -7596,9 +7859,10 @@ var DeployEngine = class {
7596
7859
  parameterValues,
7597
7860
  conditions,
7598
7861
  currentEtag,
7599
- progress
7862
+ progress,
7863
+ migrationPending
7600
7864
  );
7601
- const newEtag = await this.stateBackend.saveState(stackName, newState);
7865
+ const newEtag = await this.stateBackend.saveState(stackName, this.stackRegion, newState);
7602
7866
  this.logger.debug(`State saved (ETag: ${newEtag})`);
7603
7867
  const durationMs = Date.now() - startTime;
7604
7868
  const unchangedCount = this.diffCalculator.filterByType(changes, "NO_CHANGE").length + actualCounts.skipped;
@@ -7614,7 +7878,7 @@ var DeployEngine = class {
7614
7878
  renderer.stop();
7615
7879
  process.removeListener("SIGINT", sigintHandler);
7616
7880
  try {
7617
- await this.lockManager.releaseLock(stackName);
7881
+ await this.lockManager.releaseLock(stackName, this.stackRegion);
7618
7882
  this.logger.debug("Lock released");
7619
7883
  } catch (lockError) {
7620
7884
  this.logger.warn(
@@ -7632,11 +7896,12 @@ var DeployEngine = class {
7632
7896
  * - DELETE follows reverse dependency order (a node starts as soon as all
7633
7897
  * resources that depend ON it have finished deleting)
7634
7898
  */
7635
- async executeDeployment(template, currentState, changes, dag, executionLevels, stackName, parameterValues, conditions, currentEtag, progress) {
7899
+ async executeDeployment(template, currentState, changes, dag, executionLevels, stackName, parameterValues, conditions, currentEtag, progress, migrationPending = false) {
7636
7900
  const concurrency = this.options.concurrency;
7637
7901
  const newResources = { ...currentState.resources };
7638
7902
  const actualCounts = { created: 0, updated: 0, deleted: 0, skipped: 0 };
7639
7903
  const completedOperations = [];
7904
+ let pendingMigration = migrationPending;
7640
7905
  let saveChain = Promise.resolve();
7641
7906
  const saveStateAfterResource = (logicalId) => {
7642
7907
  if (currentEtag === void 0)
@@ -7644,14 +7909,23 @@ var DeployEngine = class {
7644
7909
  saveChain = saveChain.then(async () => {
7645
7910
  try {
7646
7911
  const partialState = {
7647
- version: 1,
7648
- ...this.stackRegion && { region: this.stackRegion },
7912
+ version: STATE_SCHEMA_VERSION_CURRENT,
7913
+ region: this.stackRegion,
7649
7914
  stackName: currentState.stackName,
7650
7915
  resources: newResources,
7651
7916
  outputs: currentState.outputs,
7652
7917
  lastModified: Date.now()
7653
7918
  };
7654
- currentEtag = await this.stateBackend.saveState(stackName, partialState, currentEtag);
7919
+ const migrate = pendingMigration;
7920
+ const expectedEtag = migrate ? void 0 : currentEtag;
7921
+ currentEtag = await this.stateBackend.saveState(
7922
+ stackName,
7923
+ this.stackRegion,
7924
+ partialState,
7925
+ { ...expectedEtag !== void 0 && { expectedEtag }, migrateLegacy: migrate }
7926
+ );
7927
+ if (migrate)
7928
+ pendingMigration = false;
7655
7929
  this.logger.debug(`State saved after ${logicalId}`);
7656
7930
  } catch (error) {
7657
7931
  this.logger.warn(
@@ -7785,14 +8059,23 @@ var DeployEngine = class {
7785
8059
  } catch (error) {
7786
8060
  try {
7787
8061
  const preRollbackState = {
7788
- version: 1,
7789
- ...this.stackRegion && { region: this.stackRegion },
8062
+ version: STATE_SCHEMA_VERSION_CURRENT,
8063
+ region: this.stackRegion,
7790
8064
  stackName: currentState.stackName,
7791
8065
  resources: newResources,
7792
8066
  outputs: currentState.outputs,
7793
8067
  lastModified: Date.now()
7794
8068
  };
7795
- currentEtag = await this.stateBackend.saveState(stackName, preRollbackState, currentEtag);
8069
+ const migrate = pendingMigration;
8070
+ const expectedEtag = migrate ? void 0 : currentEtag;
8071
+ currentEtag = await this.stateBackend.saveState(
8072
+ stackName,
8073
+ this.stackRegion,
8074
+ preRollbackState,
8075
+ { ...expectedEtag !== void 0 && { expectedEtag }, migrateLegacy: migrate }
8076
+ );
8077
+ if (migrate)
8078
+ pendingMigration = false;
7796
8079
  this.logger.debug("Partial state saved before rollback (orphaned resource tracking)");
7797
8080
  } catch (saveError) {
7798
8081
  this.logger.warn(
@@ -7813,31 +8096,35 @@ var DeployEngine = class {
7813
8096
  }
7814
8097
  try {
7815
8098
  const postRollbackState = {
7816
- version: 1,
7817
- ...this.stackRegion && { region: this.stackRegion },
8099
+ version: STATE_SCHEMA_VERSION_CURRENT,
8100
+ region: this.stackRegion,
7818
8101
  stackName: currentState.stackName,
7819
8102
  resources: newResources,
7820
8103
  outputs: currentState.outputs,
7821
8104
  lastModified: Date.now()
7822
8105
  };
7823
- await this.stateBackend.saveState(stackName, postRollbackState, currentEtag);
8106
+ await this.stateBackend.saveState(stackName, this.stackRegion, postRollbackState, {
8107
+ ...currentEtag !== void 0 && { expectedEtag: currentEtag }
8108
+ });
7824
8109
  this.logger.debug("State saved after deployment failure");
7825
8110
  } catch (saveError) {
7826
8111
  this.logger.debug(
7827
8112
  `Retrying state save after rollback (ETag mismatch): ${saveError instanceof Error ? saveError.message : String(saveError)}`
7828
8113
  );
7829
8114
  try {
7830
- const freshState = await this.stateBackend.getState(stackName);
8115
+ const freshState = await this.stateBackend.getState(stackName, this.stackRegion);
7831
8116
  const freshEtag = freshState?.etag;
7832
8117
  const postRollbackState = {
7833
- version: 1,
7834
- ...this.stackRegion && { region: this.stackRegion },
8118
+ version: STATE_SCHEMA_VERSION_CURRENT,
8119
+ region: this.stackRegion,
7835
8120
  stackName: currentState.stackName,
7836
8121
  resources: newResources,
7837
8122
  outputs: currentState.outputs,
7838
8123
  lastModified: Date.now()
7839
8124
  };
7840
- await this.stateBackend.saveState(stackName, postRollbackState, freshEtag);
8125
+ await this.stateBackend.saveState(stackName, this.stackRegion, postRollbackState, {
8126
+ ...freshEtag !== void 0 && { expectedEtag: freshEtag }
8127
+ });
7841
8128
  this.logger.debug("State saved after deployment failure (retry succeeded)");
7842
8129
  } catch (retryError) {
7843
8130
  this.logger.warn(
@@ -7856,8 +8143,8 @@ var DeployEngine = class {
7856
8143
  );
7857
8144
  return {
7858
8145
  state: {
7859
- version: 1,
7860
- ...this.stackRegion && { region: this.stackRegion },
8146
+ version: STATE_SCHEMA_VERSION_CURRENT,
8147
+ region: this.stackRegion,
7861
8148
  stackName: currentState.stackName,
7862
8149
  resources: newResources,
7863
8150
  outputs,