@go-to-k/cdkd 0.7.0 → 0.9.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;
2851
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;
2852
3046
  throw new StateError(
2853
- `Failed to list stacks: ${error instanceof Error ? error.message : String(error)}`,
3047
+ `Failed to get legacy state for stack '${stackName}': ${error instanceof Error ? error.message : String(error)}`,
2854
3048
  error instanceof Error ? error : void 0
2855
3049
  );
2856
3050
  }
2857
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);
3062
+ } catch (error) {
3063
+ throw new StateError(
3064
+ `State file for stack '${stackName}' is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
3065
+ error instanceof Error ? error : void 0
3066
+ );
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;
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
  /**
@@ -5394,6 +5652,29 @@ var JsonPatchGenerator = class {
5394
5652
  }
5395
5653
  };
5396
5654
 
5655
+ // src/provisioning/region-check.ts
5656
+ function assertRegionMatch(clientRegion, expectedRegion, resourceType, logicalId, physicalId) {
5657
+ if (!expectedRegion) {
5658
+ return;
5659
+ }
5660
+ if (!clientRegion) {
5661
+ throw new ProvisioningError(
5662
+ `Refusing to treat NotFound as idempotent delete success for ${logicalId} (${resourceType}): AWS client region is unknown but stack state expects ${expectedRegion}. The resource may exist in ${expectedRegion} and would be silently removed from state if this NotFound were trusted.`,
5663
+ resourceType,
5664
+ logicalId,
5665
+ physicalId
5666
+ );
5667
+ }
5668
+ if (clientRegion !== expectedRegion) {
5669
+ throw new ProvisioningError(
5670
+ `Refusing to treat NotFound as idempotent delete success for ${logicalId} (${resourceType}): AWS client region ${clientRegion} does not match stack state region ${expectedRegion}. The resource likely still exists in ${expectedRegion}; rerun the destroy with the correct region (e.g. --region ${expectedRegion}).`,
5671
+ resourceType,
5672
+ logicalId,
5673
+ physicalId
5674
+ );
5675
+ }
5676
+ }
5677
+
5397
5678
  // src/provisioning/cloud-control-provider.ts
5398
5679
  var JSON_STRING_PROPERTIES = {
5399
5680
  "AWS::Events::Rule": /* @__PURE__ */ new Set(["EventPattern"])
@@ -5569,7 +5850,7 @@ var CloudControlProvider = class {
5569
5850
  /**
5570
5851
  * Delete a resource using Cloud Control API
5571
5852
  */
5572
- async delete(logicalId, physicalId, resourceType, _properties) {
5853
+ async delete(logicalId, physicalId, resourceType, _properties, context) {
5573
5854
  this.logger.debug(
5574
5855
  `Deleting resource ${logicalId} (${resourceType}), physical ID: ${physicalId}`
5575
5856
  );
@@ -5596,6 +5877,14 @@ var CloudControlProvider = class {
5596
5877
  } catch (error) {
5597
5878
  const err = error;
5598
5879
  if (err.name === "ResourceNotFoundException" || err.message?.includes("does not exist") || err.message?.includes("not found") || err.message?.includes("NotFound")) {
5880
+ const clientRegion = await this.cloudControlClient.config.region();
5881
+ assertRegionMatch(
5882
+ clientRegion,
5883
+ context?.expectedRegion,
5884
+ resourceType,
5885
+ logicalId,
5886
+ physicalId
5887
+ );
5599
5888
  this.logger.debug(`Resource ${logicalId} already deleted (not found), treating as success`);
5600
5889
  return;
5601
5890
  }
@@ -6160,7 +6449,7 @@ var CustomResourceProvider = class _CustomResourceProvider {
6160
6449
  /**
6161
6450
  * Delete a custom resource by invoking its Lambda handler
6162
6451
  */
6163
- async delete(logicalId, physicalId, resourceType, properties) {
6452
+ async delete(logicalId, physicalId, resourceType, properties, _context) {
6164
6453
  this.logger.debug(`Deleting custom resource ${logicalId}: ${physicalId} (${resourceType})`);
6165
6454
  if (!properties) {
6166
6455
  this.logger.warn(
@@ -6982,13 +7271,21 @@ var IAMRoleProvider = class {
6982
7271
  * 3. Remove role from all instance profiles
6983
7272
  * 4. Delete the role itself
6984
7273
  */
6985
- async delete(logicalId, physicalId, resourceType, _properties) {
7274
+ async delete(logicalId, physicalId, resourceType, _properties, context) {
6986
7275
  this.logger.debug(`Deleting IAM role ${logicalId}: ${physicalId}`);
6987
7276
  try {
6988
7277
  try {
6989
7278
  await this.iamClient.send(new GetRoleCommand({ RoleName: physicalId }));
6990
7279
  } catch (error) {
6991
7280
  if (error instanceof NoSuchEntityException) {
7281
+ const clientRegion = await this.iamClient.config.region();
7282
+ assertRegionMatch(
7283
+ clientRegion,
7284
+ context?.expectedRegion,
7285
+ resourceType,
7286
+ logicalId,
7287
+ physicalId
7288
+ );
6992
7289
  this.logger.debug(`Role ${physicalId} does not exist, skipping deletion`);
6993
7290
  return;
6994
7291
  }
@@ -7478,7 +7775,11 @@ var DeployEngine = class {
7478
7775
  logger = getLogger().child("DeployEngine");
7479
7776
  resolver;
7480
7777
  interrupted = false;
7481
- /** Target region for this stack (saved in state for cross-region destroy) */
7778
+ /**
7779
+ * Target region for this stack. Required — load-bearing for the
7780
+ * region-prefixed S3 state key and recorded in state.json for
7781
+ * cross-region destroy.
7782
+ */
7482
7783
  stackRegion;
7483
7784
  /**
7484
7785
  * Deploy a CloudFormation template
@@ -7487,7 +7788,7 @@ var DeployEngine = class {
7487
7788
  const startTime = Date.now();
7488
7789
  this.logger.debug(`Starting deployment for stack: ${stackName}`);
7489
7790
  setCurrentStackName(stackName);
7490
- await this.lockManager.acquireLockWithRetry(stackName, void 0, "deploy");
7791
+ await this.lockManager.acquireLockWithRetry(stackName, this.stackRegion, void 0, "deploy");
7491
7792
  const renderer = getLiveRenderer();
7492
7793
  renderer.start();
7493
7794
  this.interrupted = false;
@@ -7501,16 +7802,17 @@ var DeployEngine = class {
7501
7802
  };
7502
7803
  process.on("SIGINT", sigintHandler);
7503
7804
  try {
7504
- const currentStateData = await this.stateBackend.getState(stackName);
7805
+ const currentStateData = await this.stateBackend.getState(stackName, this.stackRegion);
7505
7806
  const currentState = currentStateData?.state ?? {
7506
- version: 1,
7507
- ...this.stackRegion && { region: this.stackRegion },
7807
+ version: STATE_SCHEMA_VERSION_CURRENT,
7808
+ region: this.stackRegion,
7508
7809
  stackName,
7509
7810
  resources: {},
7510
7811
  outputs: {},
7511
7812
  lastModified: Date.now()
7512
7813
  };
7513
7814
  const currentEtag = currentStateData?.etag;
7815
+ const migrationPending = currentStateData?.migrationPending ?? false;
7514
7816
  this.logger.debug(
7515
7817
  `Loaded current state: ${Object.keys(currentState.resources).length} resources`
7516
7818
  );
@@ -7596,9 +7898,10 @@ var DeployEngine = class {
7596
7898
  parameterValues,
7597
7899
  conditions,
7598
7900
  currentEtag,
7599
- progress
7901
+ progress,
7902
+ migrationPending
7600
7903
  );
7601
- const newEtag = await this.stateBackend.saveState(stackName, newState);
7904
+ const newEtag = await this.stateBackend.saveState(stackName, this.stackRegion, newState);
7602
7905
  this.logger.debug(`State saved (ETag: ${newEtag})`);
7603
7906
  const durationMs = Date.now() - startTime;
7604
7907
  const unchangedCount = this.diffCalculator.filterByType(changes, "NO_CHANGE").length + actualCounts.skipped;
@@ -7614,7 +7917,7 @@ var DeployEngine = class {
7614
7917
  renderer.stop();
7615
7918
  process.removeListener("SIGINT", sigintHandler);
7616
7919
  try {
7617
- await this.lockManager.releaseLock(stackName);
7920
+ await this.lockManager.releaseLock(stackName, this.stackRegion);
7618
7921
  this.logger.debug("Lock released");
7619
7922
  } catch (lockError) {
7620
7923
  this.logger.warn(
@@ -7632,11 +7935,12 @@ var DeployEngine = class {
7632
7935
  * - DELETE follows reverse dependency order (a node starts as soon as all
7633
7936
  * resources that depend ON it have finished deleting)
7634
7937
  */
7635
- async executeDeployment(template, currentState, changes, dag, executionLevels, stackName, parameterValues, conditions, currentEtag, progress) {
7938
+ async executeDeployment(template, currentState, changes, dag, executionLevels, stackName, parameterValues, conditions, currentEtag, progress, migrationPending = false) {
7636
7939
  const concurrency = this.options.concurrency;
7637
7940
  const newResources = { ...currentState.resources };
7638
7941
  const actualCounts = { created: 0, updated: 0, deleted: 0, skipped: 0 };
7639
7942
  const completedOperations = [];
7943
+ let pendingMigration = migrationPending;
7640
7944
  let saveChain = Promise.resolve();
7641
7945
  const saveStateAfterResource = (logicalId) => {
7642
7946
  if (currentEtag === void 0)
@@ -7644,14 +7948,23 @@ var DeployEngine = class {
7644
7948
  saveChain = saveChain.then(async () => {
7645
7949
  try {
7646
7950
  const partialState = {
7647
- version: 1,
7648
- ...this.stackRegion && { region: this.stackRegion },
7951
+ version: STATE_SCHEMA_VERSION_CURRENT,
7952
+ region: this.stackRegion,
7649
7953
  stackName: currentState.stackName,
7650
7954
  resources: newResources,
7651
7955
  outputs: currentState.outputs,
7652
7956
  lastModified: Date.now()
7653
7957
  };
7654
- currentEtag = await this.stateBackend.saveState(stackName, partialState, currentEtag);
7958
+ const migrate = pendingMigration;
7959
+ const expectedEtag = migrate ? void 0 : currentEtag;
7960
+ currentEtag = await this.stateBackend.saveState(
7961
+ stackName,
7962
+ this.stackRegion,
7963
+ partialState,
7964
+ { ...expectedEtag !== void 0 && { expectedEtag }, migrateLegacy: migrate }
7965
+ );
7966
+ if (migrate)
7967
+ pendingMigration = false;
7655
7968
  this.logger.debug(`State saved after ${logicalId}`);
7656
7969
  } catch (error) {
7657
7970
  this.logger.warn(
@@ -7785,14 +8098,23 @@ var DeployEngine = class {
7785
8098
  } catch (error) {
7786
8099
  try {
7787
8100
  const preRollbackState = {
7788
- version: 1,
7789
- ...this.stackRegion && { region: this.stackRegion },
8101
+ version: STATE_SCHEMA_VERSION_CURRENT,
8102
+ region: this.stackRegion,
7790
8103
  stackName: currentState.stackName,
7791
8104
  resources: newResources,
7792
8105
  outputs: currentState.outputs,
7793
8106
  lastModified: Date.now()
7794
8107
  };
7795
- currentEtag = await this.stateBackend.saveState(stackName, preRollbackState, currentEtag);
8108
+ const migrate = pendingMigration;
8109
+ const expectedEtag = migrate ? void 0 : currentEtag;
8110
+ currentEtag = await this.stateBackend.saveState(
8111
+ stackName,
8112
+ this.stackRegion,
8113
+ preRollbackState,
8114
+ { ...expectedEtag !== void 0 && { expectedEtag }, migrateLegacy: migrate }
8115
+ );
8116
+ if (migrate)
8117
+ pendingMigration = false;
7796
8118
  this.logger.debug("Partial state saved before rollback (orphaned resource tracking)");
7797
8119
  } catch (saveError) {
7798
8120
  this.logger.warn(
@@ -7813,31 +8135,35 @@ var DeployEngine = class {
7813
8135
  }
7814
8136
  try {
7815
8137
  const postRollbackState = {
7816
- version: 1,
7817
- ...this.stackRegion && { region: this.stackRegion },
8138
+ version: STATE_SCHEMA_VERSION_CURRENT,
8139
+ region: this.stackRegion,
7818
8140
  stackName: currentState.stackName,
7819
8141
  resources: newResources,
7820
8142
  outputs: currentState.outputs,
7821
8143
  lastModified: Date.now()
7822
8144
  };
7823
- await this.stateBackend.saveState(stackName, postRollbackState, currentEtag);
8145
+ await this.stateBackend.saveState(stackName, this.stackRegion, postRollbackState, {
8146
+ ...currentEtag !== void 0 && { expectedEtag: currentEtag }
8147
+ });
7824
8148
  this.logger.debug("State saved after deployment failure");
7825
8149
  } catch (saveError) {
7826
8150
  this.logger.debug(
7827
8151
  `Retrying state save after rollback (ETag mismatch): ${saveError instanceof Error ? saveError.message : String(saveError)}`
7828
8152
  );
7829
8153
  try {
7830
- const freshState = await this.stateBackend.getState(stackName);
8154
+ const freshState = await this.stateBackend.getState(stackName, this.stackRegion);
7831
8155
  const freshEtag = freshState?.etag;
7832
8156
  const postRollbackState = {
7833
- version: 1,
7834
- ...this.stackRegion && { region: this.stackRegion },
8157
+ version: STATE_SCHEMA_VERSION_CURRENT,
8158
+ region: this.stackRegion,
7835
8159
  stackName: currentState.stackName,
7836
8160
  resources: newResources,
7837
8161
  outputs: currentState.outputs,
7838
8162
  lastModified: Date.now()
7839
8163
  };
7840
- await this.stateBackend.saveState(stackName, postRollbackState, freshEtag);
8164
+ await this.stateBackend.saveState(stackName, this.stackRegion, postRollbackState, {
8165
+ ...freshEtag !== void 0 && { expectedEtag: freshEtag }
8166
+ });
7841
8167
  this.logger.debug("State saved after deployment failure (retry succeeded)");
7842
8168
  } catch (retryError) {
7843
8169
  this.logger.warn(
@@ -7856,8 +8182,8 @@ var DeployEngine = class {
7856
8182
  );
7857
8183
  return {
7858
8184
  state: {
7859
- version: 1,
7860
- ...this.stackRegion && { region: this.stackRegion },
8185
+ version: STATE_SCHEMA_VERSION_CURRENT,
8186
+ region: this.stackRegion,
7861
8187
  stackName: currentState.stackName,
7862
8188
  resources: newResources,
7863
8189
  outputs,
@@ -7986,7 +8312,9 @@ var DeployEngine = class {
7986
8312
  ` Rollback: Deleting created resource ${op.logicalId} (${op.resourceType})`
7987
8313
  );
7988
8314
  const provider = this.providerRegistry.getProvider(op.resourceType);
7989
- await provider.delete(op.logicalId, op.physicalId, op.resourceType, op.properties);
8315
+ await provider.delete(op.logicalId, op.physicalId, op.resourceType, op.properties, {
8316
+ expectedRegion: this.stackRegion
8317
+ });
7990
8318
  delete stateResources[op.logicalId];
7991
8319
  this.logger.info(` Rollback: ${op.logicalId} deleted successfully`);
7992
8320
  break;
@@ -8128,7 +8456,8 @@ var DeployEngine = class {
8128
8456
  logicalId,
8129
8457
  currentResource.physicalId,
8130
8458
  resourceType,
8131
- currentResource.properties
8459
+ currentResource.properties,
8460
+ { expectedRegion: this.stackRegion }
8132
8461
  );
8133
8462
  this.logger.info(` \u2713 Old resource deleted`);
8134
8463
  } catch (deleteError) {
@@ -8177,7 +8506,8 @@ var DeployEngine = class {
8177
8506
  logicalId,
8178
8507
  currentResource.physicalId,
8179
8508
  resourceType,
8180
- currentProps
8509
+ currentProps,
8510
+ { expectedRegion: this.stackRegion }
8181
8511
  );
8182
8512
  } catch (deleteError) {
8183
8513
  const deleteMsg = deleteError instanceof Error ? deleteError.message : String(deleteError);
@@ -8248,7 +8578,8 @@ var DeployEngine = class {
8248
8578
  logicalId,
8249
8579
  currentResource.physicalId,
8250
8580
  resourceType,
8251
- currentResource.properties
8581
+ currentResource.properties,
8582
+ { expectedRegion: this.stackRegion }
8252
8583
  ),
8253
8584
  logicalId,
8254
8585
  3,