alchemy 2.0.0-beta.68 → 2.0.0-beta.69

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.
Files changed (50) hide show
  1. package/bin/exec.js +124 -16
  2. package/bin/exec.js.map +1 -1
  3. package/lib/Apply.d.ts.map +1 -1
  4. package/lib/Apply.js +33 -0
  5. package/lib/Apply.js.map +1 -1
  6. package/lib/Cli/LoggingCli.d.ts.map +1 -1
  7. package/lib/Cli/LoggingCli.js +9 -1
  8. package/lib/Cli/LoggingCli.js.map +1 -1
  9. package/lib/Cloudflare/Access/Group.d.ts +11 -2
  10. package/lib/Cloudflare/Access/Group.d.ts.map +1 -1
  11. package/lib/Cloudflare/Access/Group.js.map +1 -1
  12. package/lib/Cloudflare/Access/Policy.d.ts +11 -2
  13. package/lib/Cloudflare/Access/Policy.d.ts.map +1 -1
  14. package/lib/Cloudflare/Access/Policy.js.map +1 -1
  15. package/lib/Cloudflare/Account/Member.d.ts +1 -1
  16. package/lib/Cloudflare/Account/Member.d.ts.map +1 -1
  17. package/lib/Cloudflare/Website/StaticSite.d.ts.map +1 -1
  18. package/lib/Cloudflare/Website/StaticSite.js +8 -1
  19. package/lib/Cloudflare/Website/StaticSite.js.map +1 -1
  20. package/lib/Plan.d.ts +23 -4
  21. package/lib/Plan.d.ts.map +1 -1
  22. package/lib/Plan.js +226 -22
  23. package/lib/Plan.js.map +1 -1
  24. package/lib/Rename.d.ts +132 -0
  25. package/lib/Rename.d.ts.map +1 -0
  26. package/lib/Rename.js +117 -0
  27. package/lib/Rename.js.map +1 -0
  28. package/lib/Resource.d.ts +9 -0
  29. package/lib/Resource.d.ts.map +1 -1
  30. package/lib/Resource.js +11 -0
  31. package/lib/Resource.js.map +1 -1
  32. package/lib/State/HttpStateApi.d.ts +14 -14
  33. package/lib/Sync.d.ts.map +1 -1
  34. package/lib/Sync.js +1 -0
  35. package/lib/Sync.js.map +1 -1
  36. package/lib/index.d.ts +1 -0
  37. package/lib/index.d.ts.map +1 -1
  38. package/lib/index.js +1 -0
  39. package/lib/index.js.map +1 -1
  40. package/package.json +4 -4
  41. package/src/Apply.ts +47 -0
  42. package/src/Cli/LoggingCli.ts +10 -1
  43. package/src/Cloudflare/Access/Group.ts +16 -2
  44. package/src/Cloudflare/Access/Policy.ts +16 -2
  45. package/src/Cloudflare/Website/StaticSite.ts +8 -1
  46. package/src/Plan.ts +284 -28
  47. package/src/Rename.ts +135 -0
  48. package/src/Resource.ts +27 -0
  49. package/src/Sync.ts +1 -0
  50. package/src/index.ts +1 -0
@@ -24,6 +24,20 @@ import type { Providers } from "../Providers.ts";
24
24
  export type GroupRule =
25
25
  zeroTrust.CreateAccessGroupForAccountRequest["include"][number];
26
26
 
27
+ /**
28
+ * One arm of the exclude-side rule union, and its require-side twin.
29
+ * Cloudflare's spec types the exclude/require rule lists separately from
30
+ * include — a few rule kinds (e.g. the GitHub-organization rule) carry the
31
+ * raw wire shape there — so these props use the SDK's own unions rather
32
+ * than reusing {@link GroupRule}.
33
+ */
34
+ export type GroupExcludeRule = NonNullable<
35
+ zeroTrust.CreateAccessGroupForAccountRequest["exclude"]
36
+ >[number];
37
+ export type GroupRequireRule = NonNullable<
38
+ zeroTrust.CreateAccessGroupForAccountRequest["require"]
39
+ >[number];
40
+
27
41
  export type GroupProps = {
28
42
  /**
29
43
  * Display name for the group. Used as a stable identifier so the provider
@@ -42,12 +56,12 @@ export type GroupProps = {
42
56
  * Rules combined with logical NOT. A user matching any Exclude rule does
43
57
  * not match the group, even if they satisfied an Include rule.
44
58
  */
45
- exclude?: GroupRule[];
59
+ exclude?: GroupExcludeRule[];
46
60
  /**
47
61
  * Rules combined with logical AND. A user must satisfy every Require rule
48
62
  * in addition to an Include rule.
49
63
  */
50
- require?: GroupRule[];
64
+ require?: GroupRequireRule[];
51
65
  /**
52
66
  * Whether this is the default group for the Zero Trust organization.
53
67
  *
@@ -21,6 +21,20 @@ import type { Providers } from "../Providers.ts";
21
21
  */
22
22
  export type PolicyRule = zeroTrust.CreateAccessPolicyRequest["include"][number];
23
23
 
24
+ /**
25
+ * One arm of the exclude-side rule union, and its require-side twin.
26
+ * Cloudflare's spec types the exclude/require rule lists separately from
27
+ * include — a few rule kinds (e.g. the GitHub-organization rule) carry the
28
+ * raw wire shape there — so these props use the SDK's own unions rather
29
+ * than reusing {@link PolicyRule}.
30
+ */
31
+ export type PolicyExcludeRule = NonNullable<
32
+ zeroTrust.CreateAccessPolicyRequest["exclude"]
33
+ >[number];
34
+ export type PolicyRequireRule = NonNullable<
35
+ zeroTrust.CreateAccessPolicyRequest["require"]
36
+ >[number];
37
+
24
38
  /**
25
39
  * Decision Cloudflare Access takes when a request matches this policy.
26
40
  *
@@ -61,12 +75,12 @@ export type PolicyProps = {
61
75
  * Rules combined with logical NOT. A request matching any exclude rule is
62
76
  * rejected by the policy even if it satisfied an include rule.
63
77
  */
64
- exclude?: Policy.RuleGroup[];
78
+ exclude?: PolicyExcludeRule[];
65
79
  /**
66
80
  * Rules combined with logical AND. A request must satisfy every require
67
81
  * rule in addition to an include rule.
68
82
  */
69
- require?: Policy.RuleGroup[];
83
+ require?: PolicyRequireRule[];
70
84
  /**
71
85
  * Duration of issued session tokens. Format: `300ms`, `2h45m`, etc. When
72
86
  * unset, applications using this policy fall back to their own configured
@@ -6,6 +6,7 @@ import * as Command from "../../Command/index.ts";
6
6
  import type { Input, InputProps } from "../../Input.ts";
7
7
  import * as Namespace from "../../Namespace.ts";
8
8
  import * as Output from "../../Output.ts";
9
+ import { renamedFrom } from "../../Rename.ts";
9
10
  import {
10
11
  effectClass,
11
12
  isYieldableEffectLike,
@@ -297,6 +298,12 @@ const makeStaticSite = <
297
298
  // Pure-static sites (neither `main` nor `script`) deploy as
298
299
  // assets-only Workers: no script is uploaded and Cloudflare's asset
299
300
  // layer serves every request itself.
301
+ //
302
+ // The Worker's FQN was `<id>/Worker` before #1053 flattened it to
303
+ // `<id>`; `renamedFrom` migrates the pre-existing state row to the new
304
+ // FQN instead of letting the engine plan a create+delete replacement
305
+ // (a new physical name, a torn-down workers.dev URL, and a
306
+ // custom-domain handover that crashes the deploy).
300
307
  return yield* Worker<Bindings, WorkerAssetsConfig, Req>(id, {
301
308
  ...props,
302
309
  assets: build
@@ -311,7 +318,7 @@ const makeStaticSite = <
311
318
  // state with a stub Attributes shape.
312
319
  dev: dev ? { mode: "external", url: dev.url } : undefined,
313
320
  script: props.script,
314
- });
321
+ }).pipe(renamedFrom(`${id}/Worker`));
315
322
  });
316
323
 
317
324
  /**
package/src/Plan.ts CHANGED
@@ -117,9 +117,31 @@ export interface BaseNode<
117
117
  bindings: BindingNode<R["Binding"]>[];
118
118
  }
119
119
 
120
+ /**
121
+ * Base for the apply-side nodes (create/update/replace/noop) — the nodes a
122
+ * DECLARED resource plans to. Only these can carry a rename: a `Delete`
123
+ * node is an orphaned row with no declaration, so nothing can claim to
124
+ * have renamed it (migrating rows are excluded from the orphan pass
125
+ * entirely).
126
+ */
127
+ export interface ApplyNodeBase<
128
+ R extends ResourceLike<string> = ResourceLike<string>,
129
+ > extends BaseNode<R> {
130
+ /**
131
+ * Set when this resource's persisted row was found under former FQNs
132
+ * (`renamedFrom(...)`) — the migration source (no row at the current FQN
133
+ * yet) and/or stale leftovers from interrupted migrations (same
134
+ * `instanceId` as the resource's row; a rename chain with repeated
135
+ * partial failures can leave several). Apply persists the move up-front,
136
+ * before any lifecycle operation runs: commit `state` at the current
137
+ * FQN, then delete every former row.
138
+ */
139
+ renamedFrom?: string[] | undefined;
140
+ }
141
+
120
142
  export interface Create<
121
143
  R extends ResourceLike = ResourceLike,
122
- > extends BaseNode<R> {
144
+ > extends ApplyNodeBase<R> {
123
145
  action: "create";
124
146
  props: R["Props"];
125
147
  state: CreatingResourceState | undefined;
@@ -127,7 +149,7 @@ export interface Create<
127
149
 
128
150
  export interface Update<
129
151
  R extends ResourceLike = ResourceLike,
130
- > extends BaseNode<R> {
152
+ > extends ApplyNodeBase<R> {
131
153
  action: "update";
132
154
  /** True while this is the first reconcile after a cold adoption. */
133
155
  adopting?: boolean;
@@ -151,14 +173,14 @@ export interface Delete<
151
173
 
152
174
  export interface NoopUpdate<
153
175
  R extends ResourceLike = ResourceLike,
154
- > extends BaseNode<R> {
176
+ > extends ApplyNodeBase<R> {
155
177
  action: "noop";
156
178
  state: CreatedResourceState | UpdatedResourceState;
157
179
  }
158
180
 
159
181
  export interface Replace<
160
182
  R extends ResourceLike = ResourceLike,
161
- > extends BaseNode<R> {
183
+ > extends ApplyNodeBase<R> {
162
184
  action: "replace";
163
185
  props: any;
164
186
  deleteFirst: boolean;
@@ -344,6 +366,211 @@ export const make = <A>(
344
366
  { concurrency: "unbounded" },
345
367
  );
346
368
 
369
+ // Snapshot of every persisted row, keyed by FQN. The rename resolution
370
+ // below reads from this map instead of issuing per-FQN `state.get`s —
371
+ // every renamedFrom-decorated resource (each StaticSite carries one
372
+ // forever) would otherwise add two round-trips per resource to every
373
+ // plan against a remote state store. Plan is read-only, so the
374
+ // snapshot cannot go stale within this run.
375
+ const persistedRows = new Map(
376
+ resourceFqns.map((fqn, i) => [fqn, oldResources[i]]),
377
+ );
378
+
379
+ // ── FQN renames ──────────────────────────────────────────────────────
380
+ // Map every former FQN claimed via `renamedFrom(...)` to its claimant's
381
+ // current FQN. Two resources claiming the same former FQN is ambiguous
382
+ // and fatal. A former FQN MAY still be actively declared — that is the
383
+ // "old id reused by a new resource" case: the rename claim wins the
384
+ // row (it is an explicit user statement that the row was theirs), and
385
+ // the reusing resource plans a fresh create.
386
+ const formerFqnClaims = new Map<string, string>();
387
+ for (const resource of resources) {
388
+ for (const formerFqn of resource.FormerFqns ?? []) {
389
+ if (formerFqn === resource.FQN) continue;
390
+ const claimant = formerFqnClaims.get(formerFqn);
391
+ if (claimant !== undefined && claimant !== resource.FQN) {
392
+ return yield* Effect.die(
393
+ new Error(
394
+ `Resources '${claimant}' and '${resource.FQN}' both claim ` +
395
+ `former FQN '${formerFqn}' via renamedFrom(...). A former ` +
396
+ "FQN can migrate to exactly one resource — remove the " +
397
+ "decoration from one of them.",
398
+ ),
399
+ );
400
+ }
401
+ formerFqnClaims.set(formerFqn, resource.FQN);
402
+ }
403
+ }
404
+
405
+ // Resolve every rename migration up-front against the state snapshot,
406
+ // BEFORE any node is built — other resources' planning depends on the
407
+ // outcome (a resource declared at a former FQN whose row is migrating
408
+ // away must start from scratch).
409
+ //
410
+ // For each renamer, in former-id declaration order (most recent
411
+ // first):
412
+ //
413
+ // - `source` is the row that defines the resource's physical identity:
414
+ // the row at its own FQN when present AND type-matching, otherwise
415
+ // the first type-matching former row (→ `moved`).
416
+ // - every OTHER type-matching former row sharing `source.instanceId`
417
+ // is a leftover from an interrupted migration (only migration copies
418
+ // instanceIds) — collected for state-only cleanup.
419
+ // - former rows with a different instanceId belong to someone else and
420
+ // are left to normal orphan handling; former rows with a different
421
+ // resourceType (modulo registered type-aliases) can never be this
422
+ // resource's row and are skipped entirely.
423
+ // - a FOREIGN-typed row at the renamer's own FQN blocks the migration
424
+ // fatally: landing the migrated row there would silently abandon
425
+ // that row's cloud resource.
426
+ const renameMigrations = new Map<
427
+ string,
428
+ { row: ResourceState; renamedFrom: string[]; moved: boolean }
429
+ >();
430
+ const migratedRowFqns = new Set<string>();
431
+
432
+ const resolveRenamer = Effect.fn(function* (resource: ResourceLike) {
433
+ const provider = Option.getOrUndefined(
434
+ yield* tryFindProviderByType(resource.Type),
435
+ );
436
+ const allowedTypes = new Set([
437
+ resource.Type,
438
+ ...(provider?.aliases ?? []),
439
+ ]);
440
+ const persisted = persistedRows.get(resource.FQN);
441
+ const persistedRow = isActionState(persisted)
442
+ ? undefined
443
+ : (persisted as ResourceState | undefined);
444
+ // A row at this resource's own FQN that a PREVIOUSLY RESOLVED
445
+ // renamer claimed (a same-deploy shift: A→B while B→C — C took the
446
+ // row at B) is moving away: it is not ours to keep and it does not
447
+ // block the migration landing here.
448
+ const rowTaken = migratedRowFqns.has(resource.FQN);
449
+ const ownRow =
450
+ !rowTaken &&
451
+ persistedRow !== undefined &&
452
+ allowedTypes.has(persistedRow.resourceType)
453
+ ? persistedRow
454
+ : undefined;
455
+
456
+ let source: ResourceState | undefined = ownRow;
457
+ let adopted: ResourceState | undefined = ownRow;
458
+ const renamedFrom: string[] = [];
459
+ for (const formerFqn of resource.FormerFqns!) {
460
+ if (formerFqnClaims.get(formerFqn) !== resource.FQN) continue;
461
+ // The same former id may be listed twice (or resolve identically);
462
+ // collect each former row once.
463
+ if (renamedFrom.includes(formerFqn)) continue;
464
+ const formerPersisted = persistedRows.get(formerFqn);
465
+ if (formerPersisted === undefined || isActionState(formerPersisted)) {
466
+ continue;
467
+ }
468
+ const formerRow = formerPersisted as ResourceState;
469
+ if (!allowedTypes.has(formerRow.resourceType)) continue;
470
+ if (source === undefined) {
471
+ source = formerRow;
472
+ adopted = {
473
+ ...formerRow,
474
+ fqn: resource.FQN,
475
+ logicalId: resource.LogicalId,
476
+ namespace: resource.Namespace,
477
+ } as ResourceState;
478
+ renamedFrom.push(formerFqn);
479
+ } else if (source.instanceId === formerRow.instanceId) {
480
+ renamedFrom.push(formerFqn);
481
+ }
482
+ }
483
+ if (renamedFrom.length === 0) return;
484
+ if (persistedRow !== undefined && ownRow === undefined && !rowTaken) {
485
+ return yield* Effect.die(
486
+ new Error(
487
+ `Cannot migrate '${renamedFrom[0]}' to '${resource.FQN}': a ` +
488
+ `state row of a different type ('${persistedRow.resourceType}') ` +
489
+ `already occupies '${resource.FQN}'. Migrating over it would ` +
490
+ "silently abandon that row's cloud resource. Delete or " +
491
+ "rename the conflicting resource first, then re-deploy.",
492
+ ),
493
+ );
494
+ }
495
+ renameMigrations.set(resource.FQN, {
496
+ row: adopted!,
497
+ renamedFrom,
498
+ moved: adopted !== ownRow,
499
+ });
500
+ for (const formerFqn of renamedFrom) migratedRowFqns.add(formerFqn);
501
+ });
502
+
503
+ // Resolve renamers in claim-dependency order: when resource R's OWN
504
+ // FQN is claimed as a former id by resource S (a same-deploy shift:
505
+ // A→B while B→C), S must resolve first — whether S takes R's row
506
+ // decides whether R still owns it, or falls back to ITS former rows.
507
+ // Iterate to fixpoint; anything left is a claim CYCLE (a swap: A⇄B),
508
+ // which cannot be persisted safely — the migrations would overwrite
509
+ // and delete each other's rows — and dies loudly.
510
+ let pendingRenamers = resources.filter((r) => r.FormerFqns?.length);
511
+ const resolvedRenamers = new Set<string>();
512
+ while (pendingRenamers.length > 0) {
513
+ const ready = pendingRenamers.filter((resource) => {
514
+ const claimant = formerFqnClaims.get(resource.FQN);
515
+ return claimant === undefined || resolvedRenamers.has(claimant);
516
+ });
517
+ if (ready.length === 0) {
518
+ return yield* Effect.die(
519
+ new Error(
520
+ `Rename cycle detected among [${pendingRenamers
521
+ .map((r) => `'${r.FQN}'`)
522
+ .join(
523
+ ", ",
524
+ )}]: their renamedFrom(...) declarations claim each other's ` +
525
+ "FQNs. Swapping ids in one deploy is not supported — rename " +
526
+ "through a temporary id across two deploys instead.",
527
+ ),
528
+ );
529
+ }
530
+ for (const resource of ready) {
531
+ yield* resolveRenamer(resource);
532
+ resolvedRenamers.add(resource.FQN);
533
+ }
534
+ pendingRenamers = pendingRenamers.filter(
535
+ (r) => !resolvedRenamers.has(r.FQN),
536
+ );
537
+ }
538
+
539
+ /**
540
+ * Fetch the persisted row for a declared resource, resolving renames
541
+ * (see the pre-computed `renameMigrations` above):
542
+ *
543
+ * - a renamer plans from its migrated row (`renamedFrom` rides onto
544
+ * the plan node; apply persists the move before any lifecycle op)
545
+ * - a resource declared at a former FQN whose row is migrating away
546
+ * starts from scratch — the row is NOT its state, whatever the FQN
547
+ * says
548
+ */
549
+ const getPersistedRow = Effect.fn(function* (
550
+ resource: Pick<ResourceLike, "FQN">,
551
+ ) {
552
+ const migration = renameMigrations.get(resource.FQN);
553
+ if (migration !== undefined) {
554
+ return {
555
+ row: migration.row,
556
+ renamedFrom: migration.renamedFrom,
557
+ renameMoved: migration.moved,
558
+ };
559
+ }
560
+ const persisted = yield* state.get({
561
+ stack: stackName,
562
+ stage: stage,
563
+ fqn: resource.FQN,
564
+ });
565
+ const row = isActionState(persisted)
566
+ ? undefined
567
+ : (persisted as ResourceState | undefined);
568
+ if (row !== undefined && migratedRowFqns.has(resource.FQN)) {
569
+ return { row: undefined, renamedFrom: undefined, renameMoved: false };
570
+ }
571
+ return { row, renamedFrom: undefined, renameMoved: false };
572
+ });
573
+
347
574
  const resolvedResources: Record<string, Effect.Effect<any>> = {};
348
575
 
349
576
  const resolveResource = (
@@ -367,16 +594,10 @@ export const make = <A>(
367
594
  const props = materializeStableRefs(
368
595
  yield* resolveInput(resource.Props),
369
596
  );
370
- const persisted = yield* state.get({
371
- stack: stackName,
372
- stage: stage,
373
- fqn: resource.FQN,
374
- });
375
- const oldState: ResourceState | undefined = isActionState(
376
- persisted,
377
- )
378
- ? undefined
379
- : (persisted as ResourceState | undefined);
597
+ // Falls back to the row at a former FQN (`renamedFrom`) so a
598
+ // renamed resource's stable attributes keep flowing to
599
+ // downstream diffs across the migration.
600
+ const { row: oldState } = yield* getPersistedRow(resource);
380
601
 
381
602
  if (!oldState || oldState.status === "creating") {
382
603
  return resourceExpr;
@@ -865,17 +1086,18 @@ export const make = <A>(
865
1086
  // `bindingOutputs`, not these plan-time shapes.
866
1087
  const newBindings: ResourceBinding[] =
867
1088
  materializeStableRefs(applyBindings);
868
- const persisted = yield* state.get({
869
- stack: stackName,
870
- stage: stage,
871
- fqn,
872
- });
873
- // A Task previously held this FQN. Treat as if there were no
874
- // prior state — the Task's row will be reaped by `actionDeletions`
875
- // below and the resource starts from scratch.
876
- let oldState: ResourceState | undefined = isActionState(persisted)
877
- ? undefined
878
- : (persisted as ResourceState | undefined);
1089
+ // The row is looked up at the resource's FQN with a fallback to
1090
+ // its former FQNs (`renamedFrom`); a row found under a former
1091
+ // FQN arrives here already remapped to the new identity, and
1092
+ // `renamedFrom` rides onto the plan node so apply persists the
1093
+ // move. (A Task previously holding this FQN is treated as no
1094
+ // prior state — its row is reaped by `actionDeletions` below.)
1095
+ const {
1096
+ row: persistedRow,
1097
+ renamedFrom,
1098
+ renameMoved,
1099
+ } = yield* getPersistedRow(resource);
1100
+ let oldState: ResourceState | undefined = persistedRow;
879
1101
 
880
1102
  // Engine-level adoption. When there is no prior state, always
881
1103
  // consult `provider.read` (if implemented) so the engine — not
@@ -925,8 +1147,20 @@ export const make = <A>(
925
1147
  // SDK protocol layer. Resources whose props depend on
926
1148
  // not-yet-created upstreams cannot themselves be pre-existing
927
1149
  // — there's nothing to adopt.
1150
+ // A resource declared at a former FQN whose row just migrated
1151
+ // away is genuinely NEW by declaration — skip the probe. Its
1152
+ // predecessor's physical resource still carries tags branded
1153
+ // with THIS logical id (the migrated row's reconcile hasn't
1154
+ // re-branded them yet), so a tag-based `read` would find it
1155
+ // and silently adopt the very resource that was renamed away.
1156
+ const reusesMigratedFqn = migratedRowFqns.has(fqn);
928
1157
  let forceUpdateAfterAdoption = false;
929
- if (oldState === undefined && provider.read && isResolved(news)) {
1158
+ if (
1159
+ oldState === undefined &&
1160
+ provider.read &&
1161
+ isResolved(news) &&
1162
+ !reusesMigratedFqn
1163
+ ) {
930
1164
  const adoptInstanceId = yield* generateInstanceId();
931
1165
  const readResult = yield* provider
932
1166
  .read({
@@ -1019,6 +1253,7 @@ export const make = <A>(
1019
1253
  bindings: bindingDiffs,
1020
1254
  downstream,
1021
1255
  mode,
1256
+ renamedFrom,
1022
1257
  }) as any as T;
1023
1258
 
1024
1259
  // Plan against the persisted state we have, not the ideal final state we
@@ -1159,8 +1394,16 @@ export const make = <A>(
1159
1394
  // would noop and any drift between the existing cloud
1160
1395
  // resource and `news` — including foreign-owned tags after a
1161
1396
  // takeover — would persist).
1397
+ //
1398
+ // A row that just migrated from a former FQN (`renameMoved`)
1399
+ // gets the same treatment: its cloud resource is still
1400
+ // branded with the OLD logical id's tags, and if the old id
1401
+ // is being reused by a new resource, leaving them stale
1402
+ // would let the reuser's future adoption probes match the
1403
+ // wrong physical resource.
1162
1404
  Effect.map((diff) =>
1163
- forceUpdateAfterAdoption && diff.action === "noop"
1405
+ (forceUpdateAfterAdoption || renameMoved) &&
1406
+ diff.action === "noop"
1164
1407
  ? ({ action: "update" } satisfies UpdateDiff)
1165
1408
  : diff,
1166
1409
  ),
@@ -1519,6 +1762,15 @@ export const make = <A>(
1519
1762
  if (isActionState(persisted)) return;
1520
1763
  const oldState = persisted as ResourceState | undefined;
1521
1764
  if (oldState) {
1765
+ // A row being migrated by a rename (`renameMigrations`) is
1766
+ // moving, not orphaned — apply drops it state-only after
1767
+ // committing the migrated row at its new FQN. Rows at former
1768
+ // FQNs that did NOT migrate (foreign type, different
1769
+ // instanceId, unclaimed) are absent from this set and fall
1770
+ // through to normal orphan deletion.
1771
+ if (migratedRowFqns.has(fqn)) {
1772
+ return;
1773
+ }
1522
1774
  const { logicalId } = parseFqn(fqn);
1523
1775
  const resourceType = oldState.resourceType;
1524
1776
  // A "zombie" row references a type with no registered provider
@@ -1566,6 +1818,7 @@ export const make = <A>(
1566
1818
  RemovalPolicy: oldState.removalPolicy,
1567
1819
  Adopt: undefined,
1568
1820
  Mode: oldState.providerMode,
1821
+ FormerFqns: undefined,
1569
1822
  RuntimeContext: undefined!,
1570
1823
  Providers: undefined,
1571
1824
  } as ResourceLike,
@@ -1752,7 +2005,10 @@ export const printPlan = (plan: Plan): string => {
1752
2005
  const downstream = node.state?.downstream?.length
1753
2006
  ? ` → [${node.state?.downstream.join(", ")}]`
1754
2007
  : "";
1755
- lines.push(`│ [${symbol}] ${id} (${type})${downstream}`);
2008
+ const renamed = node.renamedFrom?.length
2009
+ ? ` (renamed from ${node.renamedFrom.join(", ")})`
2010
+ : "";
2011
+ lines.push(`│ [${symbol}] ${id} (${type})${downstream}${renamed}`);
1756
2012
  }
1757
2013
  if (resourceIds.length === 0) {
1758
2014
  lines.push("│ (none)");
package/src/Rename.ts ADDED
@@ -0,0 +1,135 @@
1
+ import * as Context from "effect/Context";
2
+ import * as Effect from "effect/Effect";
3
+
4
+ /**
5
+ * A former id a resource was previously declared under:
6
+ *
7
+ * - a bare `string` resolves against the ambient namespace, exactly like
8
+ * the resource's own `id` argument does
9
+ * - `{ fqn: "..." }` is absolute — the full FQN as persisted in state,
10
+ * ignoring any surrounding namespace. Needed when a resource moved
11
+ * BETWEEN namespaces (a relative id can only address the current
12
+ * namespace's subtree).
13
+ */
14
+ export type FormerId = string | { fqn: string };
15
+
16
+ /**
17
+ * RenamePolicy carries the former ids a resource was previously declared
18
+ * under. It is captured from the ambient context at registration time (like
19
+ * `AdoptPolicy` / `RemovalPolicy`) and resolved against the same namespace
20
+ * as the resource's own id — see `ResourceLike.FormerFqns`.
21
+ */
22
+ export class RenamePolicy extends Context.Service<
23
+ RenamePolicy,
24
+ readonly FormerId[]
25
+ >()("RenamePolicy") {}
26
+
27
+ /**
28
+ * Declare the logical id(s) this resource was previously registered under,
29
+ * so the engine migrates its persisted state row instead of planning a
30
+ * create+delete replacement when the id changes.
31
+ *
32
+ * ```ts
33
+ * // was: Bucket("Bucket") — the row migrates and the deploy plans a noop
34
+ * const bucket = yield* Bucket("Assets").pipe(renamedFrom("Bucket"));
35
+ * ```
36
+ *
37
+ * A bare string resolves against the ambient namespace, exactly like the
38
+ * resource's own `id`:
39
+ *
40
+ * ```ts
41
+ * // FQN `Site/Assets`, former FQN `Site/Bucket`
42
+ * yield* Bucket("Assets").pipe(
43
+ * renamedFrom("Bucket"),
44
+ * Namespace.push("Site"),
45
+ * );
46
+ * ```
47
+ *
48
+ * Moved between namespaces? Pass `{ fqn }` — the absolute FQN exactly as
49
+ * persisted in state, ignoring the ambient namespace:
50
+ *
51
+ * ```ts
52
+ * // former FQN `LegacySite/Assets` (NOT `NewSite/LegacySite/Assets`)
53
+ * yield* Bucket("Assets").pipe(
54
+ * renamedFrom({ fqn: "LegacySite/Assets" }),
55
+ * Namespace.push("NewSite"),
56
+ * );
57
+ * ```
58
+ *
59
+ * Renamed more than once? List every former id, most recent first — the
60
+ * planner checks them in order and migrates from the first matching row:
61
+ *
62
+ * ```ts
63
+ * // rename history: Bucket → StaticAssets → Assets
64
+ * yield* Bucket("Assets").pipe(renamedFrom("StaticAssets", "Bucket"));
65
+ * ```
66
+ *
67
+ * Migration semantics, by state-row shape (see Plan's rename resolution;
68
+ * `new` = the row at the resource's FQN, `old` = a row at a former FQN):
69
+ *
70
+ * ```text
71
+ * new old → outcome
72
+ * ──────────────────────────────────────────────────────────────────────
73
+ * — row → migrate: the old row
74
+ * IS the resource's
75
+ * state; apply moves it
76
+ * before any lifecycle
77
+ * op, and ONE update
78
+ * reconcile re-brands
79
+ * the physical resource
80
+ * under the new logical
81
+ * id (never a create)
82
+ * row, same instanceId row → interrupted
83
+ * migration: leftovers
84
+ * dropped state-only —
85
+ * ALL of them in one
86
+ * apply — the physical
87
+ * resource is never
88
+ * touched
89
+ * row, diff instanceId row → someone else's row (a
90
+ * resource reused the
91
+ * old name after the
92
+ * rename shipped):
93
+ * ignored, normal
94
+ * orphan handling
95
+ * row, diff resourceType row → FATAL: migrating over
96
+ * the foreign-typed row
97
+ * would silently
98
+ * abandon its cloud
99
+ * resource — resolve
100
+ * the collision first
101
+ * any row, diff resourceType → never migrated —
102
+ * cannot be this
103
+ * resource's row,
104
+ * whatever its FQN says
105
+ * ```
106
+ *
107
+ * The old id can be REUSED by a new resource in the same deploy — the
108
+ * rename claim wins the row (it is an explicit statement that the row was
109
+ * the renamer's), and the reusing resource is created fresh:
110
+ *
111
+ * ```ts
112
+ * // `Assets` keeps the physical resource previously known as `Bucket`;
113
+ * // this `Bucket` is a brand-new one.
114
+ * yield* Bucket("Assets").pipe(renamedFrom("Bucket"));
115
+ * yield* Bucket("Bucket");
116
+ * ```
117
+ *
118
+ * Renames may SHIFT through each other in one deploy — each row follows
119
+ * its resource (resolved in claim-dependency order):
120
+ *
121
+ * ```ts
122
+ * // the resource at `A` is now `B`; the resource at `B` is now `C`
123
+ * yield* Bucket("B").pipe(renamedFrom("A"));
124
+ * yield* Bucket("C").pipe(renamedFrom("B"));
125
+ * ```
126
+ *
127
+ * A SWAP (`A` ⇄ `B`) is a rename cycle and fails the plan loudly — the two
128
+ * migrations would overwrite and delete each other's rows. Rename through
129
+ * a temporary id across two deploys instead. Two resources claiming the
130
+ * same former FQN also fail loudly.
131
+ */
132
+ export const renamedFrom =
133
+ (...formerIds: [FormerId, ...FormerId[]]) =>
134
+ <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
135
+ Effect.provideService(effect, RenamePolicy, formerIds);