alchemy-effect 0.3.0 → 0.4.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.
Files changed (158) hide show
  1. package/bin/alchemy-effect.js +539 -223
  2. package/bin/alchemy-effect.js.map +1 -1
  3. package/lib/apply.d.ts +4 -4
  4. package/lib/apply.d.ts.map +1 -1
  5. package/lib/apply.js +411 -131
  6. package/lib/apply.js.map +1 -1
  7. package/lib/aws/dynamodb/table.provider.d.ts.map +1 -1
  8. package/lib/aws/dynamodb/table.provider.js +1 -0
  9. package/lib/aws/dynamodb/table.provider.js.map +1 -1
  10. package/lib/aws/ec2/index.d.ts +8 -0
  11. package/lib/aws/ec2/index.d.ts.map +1 -1
  12. package/lib/aws/ec2/index.js +8 -0
  13. package/lib/aws/ec2/index.js.map +1 -1
  14. package/lib/aws/ec2/internet-gateway.d.ts +65 -0
  15. package/lib/aws/ec2/internet-gateway.d.ts.map +1 -0
  16. package/lib/aws/ec2/internet-gateway.js +4 -0
  17. package/lib/aws/ec2/internet-gateway.js.map +1 -0
  18. package/lib/aws/ec2/internet-gateway.provider.d.ts +6 -0
  19. package/lib/aws/ec2/internet-gateway.provider.d.ts.map +1 -0
  20. package/lib/aws/ec2/internet-gateway.provider.js +193 -0
  21. package/lib/aws/ec2/internet-gateway.provider.js.map +1 -0
  22. package/lib/aws/ec2/route-table-association.d.ts +63 -0
  23. package/lib/aws/ec2/route-table-association.d.ts.map +1 -0
  24. package/lib/aws/ec2/route-table-association.js +4 -0
  25. package/lib/aws/ec2/route-table-association.js.map +1 -0
  26. package/lib/aws/ec2/route-table-association.provider.d.ts +4 -0
  27. package/lib/aws/ec2/route-table-association.provider.d.ts.map +1 -0
  28. package/lib/aws/ec2/route-table-association.provider.js +121 -0
  29. package/lib/aws/ec2/route-table-association.provider.js.map +1 -0
  30. package/lib/aws/ec2/route-table.d.ts +159 -0
  31. package/lib/aws/ec2/route-table.d.ts.map +1 -0
  32. package/lib/aws/ec2/route-table.js +4 -0
  33. package/lib/aws/ec2/route-table.js.map +1 -0
  34. package/lib/aws/ec2/route-table.provider.d.ts +6 -0
  35. package/lib/aws/ec2/route-table.provider.d.ts.map +1 -0
  36. package/lib/aws/ec2/route-table.provider.js +213 -0
  37. package/lib/aws/ec2/route-table.provider.js.map +1 -0
  38. package/lib/aws/ec2/route.d.ts +155 -0
  39. package/lib/aws/ec2/route.d.ts.map +1 -0
  40. package/lib/aws/ec2/route.js +3 -0
  41. package/lib/aws/ec2/route.js.map +1 -0
  42. package/lib/aws/ec2/route.provider.d.ts +4 -0
  43. package/lib/aws/ec2/route.provider.d.ts.map +1 -0
  44. package/lib/aws/ec2/route.provider.js +166 -0
  45. package/lib/aws/ec2/route.provider.js.map +1 -0
  46. package/lib/aws/ec2/subnet.provider.d.ts.map +1 -1
  47. package/lib/aws/ec2/subnet.provider.js +1 -1
  48. package/lib/aws/ec2/subnet.provider.js.map +1 -1
  49. package/lib/aws/ec2/vpc.d.ts +1 -0
  50. package/lib/aws/ec2/vpc.d.ts.map +1 -1
  51. package/lib/aws/ec2/vpc.provider.d.ts +2 -2
  52. package/lib/aws/ec2/vpc.provider.d.ts.map +1 -1
  53. package/lib/aws/ec2/vpc.provider.js +38 -15
  54. package/lib/aws/ec2/vpc.provider.js.map +1 -1
  55. package/lib/aws/index.d.ts +2 -3
  56. package/lib/aws/index.d.ts.map +1 -1
  57. package/lib/aws/index.js +2 -1
  58. package/lib/aws/index.js.map +1 -1
  59. package/lib/aws/lambda/function.provider.d.ts +2 -2
  60. package/lib/aws/lambda/function.provider.d.ts.map +1 -1
  61. package/lib/aws/lambda/function.provider.js +21 -20
  62. package/lib/aws/lambda/function.provider.js.map +1 -1
  63. package/lib/aws/sqs/queue.provider.d.ts +2 -2
  64. package/lib/aws/sqs/queue.provider.d.ts.map +1 -1
  65. package/lib/aws/sqs/queue.provider.js +3 -2
  66. package/lib/aws/sqs/queue.provider.js.map +1 -1
  67. package/lib/cli/index.d.ts +178 -99
  68. package/lib/cli/index.d.ts.map +1 -1
  69. package/lib/cloudflare/kv/namespace.client.d.ts +1 -1
  70. package/lib/cloudflare/kv/namespace.provider.d.ts.map +1 -1
  71. package/lib/cloudflare/kv/namespace.provider.js +1 -0
  72. package/lib/cloudflare/kv/namespace.provider.js.map +1 -1
  73. package/lib/cloudflare/r2/bucket.provider.d.ts.map +1 -1
  74. package/lib/cloudflare/r2/bucket.provider.js +6 -1
  75. package/lib/cloudflare/r2/bucket.provider.js.map +1 -1
  76. package/lib/cloudflare/worker/worker.provider.d.ts +1 -1
  77. package/lib/cloudflare/worker/worker.provider.d.ts.map +1 -1
  78. package/lib/cloudflare/worker/worker.provider.js +6 -2
  79. package/lib/cloudflare/worker/worker.provider.js.map +1 -1
  80. package/lib/diff.d.ts +8 -6
  81. package/lib/diff.d.ts.map +1 -1
  82. package/lib/diff.js +13 -0
  83. package/lib/diff.js.map +1 -1
  84. package/lib/event.d.ts +1 -1
  85. package/lib/event.d.ts.map +1 -1
  86. package/lib/instance-id.d.ts +8 -0
  87. package/lib/instance-id.d.ts.map +1 -0
  88. package/lib/instance-id.js +12 -0
  89. package/lib/instance-id.js.map +1 -0
  90. package/lib/output.d.ts +4 -2
  91. package/lib/output.d.ts.map +1 -1
  92. package/lib/output.js +18 -4
  93. package/lib/output.js.map +1 -1
  94. package/lib/physical-name.d.ts +14 -1
  95. package/lib/physical-name.d.ts.map +1 -1
  96. package/lib/physical-name.js +41 -2
  97. package/lib/physical-name.js.map +1 -1
  98. package/lib/plan.d.ts +49 -42
  99. package/lib/plan.d.ts.map +1 -1
  100. package/lib/plan.js +359 -127
  101. package/lib/plan.js.map +1 -1
  102. package/lib/provider.d.ts +26 -9
  103. package/lib/provider.d.ts.map +1 -1
  104. package/lib/provider.js +9 -0
  105. package/lib/provider.js.map +1 -1
  106. package/lib/resource.d.ts +2 -2
  107. package/lib/resource.d.ts.map +1 -1
  108. package/lib/resource.js.map +1 -1
  109. package/lib/state.d.ts +86 -9
  110. package/lib/state.d.ts.map +1 -1
  111. package/lib/state.js +21 -18
  112. package/lib/state.js.map +1 -1
  113. package/lib/tags.d.ts +15 -0
  114. package/lib/tags.d.ts.map +1 -1
  115. package/lib/tags.js +27 -0
  116. package/lib/tags.js.map +1 -1
  117. package/lib/test.d.ts +2 -2
  118. package/lib/test.d.ts.map +1 -1
  119. package/lib/test.js +4 -4
  120. package/lib/test.js.map +1 -1
  121. package/lib/todo.d.ts +3 -0
  122. package/lib/todo.d.ts.map +1 -0
  123. package/lib/todo.js +3 -0
  124. package/lib/todo.js.map +1 -0
  125. package/lib/tsconfig.test.tsbuildinfo +1 -1
  126. package/package.json +2 -2
  127. package/src/apply.ts +758 -374
  128. package/src/aws/dynamodb/table.provider.ts +1 -0
  129. package/src/aws/ec2/index.ts +8 -0
  130. package/src/aws/ec2/internet-gateway.provider.ts +316 -0
  131. package/src/aws/ec2/internet-gateway.ts +79 -0
  132. package/src/aws/ec2/route-table-association.provider.ts +214 -0
  133. package/src/aws/ec2/route-table-association.ts +82 -0
  134. package/src/aws/ec2/route-table.provider.ts +306 -0
  135. package/src/aws/ec2/route-table.ts +175 -0
  136. package/src/aws/ec2/route.provider.ts +213 -0
  137. package/src/aws/ec2/route.ts +192 -0
  138. package/src/aws/ec2/subnet.provider.ts +2 -2
  139. package/src/aws/ec2/vpc.provider.ts +43 -19
  140. package/src/aws/ec2/vpc.ts +2 -0
  141. package/src/aws/index.ts +4 -1
  142. package/src/aws/lambda/function.provider.ts +25 -23
  143. package/src/aws/sqs/queue.provider.ts +3 -2
  144. package/src/cloudflare/kv/namespace.provider.ts +1 -0
  145. package/src/cloudflare/r2/bucket.provider.ts +7 -1
  146. package/src/cloudflare/worker/worker.provider.ts +6 -2
  147. package/src/diff.ts +35 -17
  148. package/src/event.ts +6 -0
  149. package/src/instance-id.ts +16 -0
  150. package/src/output.ts +29 -5
  151. package/src/physical-name.ts +57 -2
  152. package/src/plan.ts +488 -197
  153. package/src/provider.ts +46 -9
  154. package/src/resource.ts +50 -4
  155. package/src/state.ts +150 -35
  156. package/src/tags.ts +31 -0
  157. package/src/test.ts +5 -5
  158. package/src/todo.ts +4 -0
package/src/plan.ts CHANGED
@@ -1,22 +1,33 @@
1
1
  import * as Context from "effect/Context";
2
- import { App } from "./app.ts";
3
2
  import * as Data from "effect/Data";
4
3
  import * as Effect from "effect/Effect";
5
4
  import { omit } from "effect/Struct";
5
+ import { App } from "./app.ts";
6
6
  import type {
7
7
  AnyBinding,
8
8
  BindingDiffProps,
9
9
  BindingService,
10
10
  } from "./binding.ts";
11
11
  import type { Capability } from "./capability.ts";
12
- import type { Diff } from "./diff.ts";
12
+ import type { Diff, NoopDiff, UpdateDiff } from "./diff.ts";
13
13
  import * as Output from "./output.ts";
14
14
  import type { Instance } from "./policy.ts";
15
15
  import type { Provider } from "./provider.ts";
16
- import { type ProviderService } from "./provider.ts";
16
+ import { type ProviderService, getProviderByType } from "./provider.ts";
17
17
  import type { AnyResource, Resource, ResourceTags } from "./resource.ts";
18
18
  import { isService, type IService, type Service } from "./service.ts";
19
- import { State, StateStoreError, type ResourceState } from "./state.ts";
19
+ import {
20
+ type CreatedResourceState,
21
+ type CreatingResourceState,
22
+ type ReplacedResourceState,
23
+ type ReplacingResourceState,
24
+ type UpdatedResourceState,
25
+ type UpdatingReourceState,
26
+ State,
27
+ StateStoreError,
28
+ type ResourceState,
29
+ } from "./state.ts";
30
+ import { asEffect } from "./util.ts";
20
31
 
21
32
  export type PlanError = never;
22
33
 
@@ -92,65 +103,55 @@ export type Apply<R extends Resource = AnyResource> =
92
103
  | Replace<R>
93
104
  | NoopUpdate<R>;
94
105
 
95
- const Node = <T extends Apply>(node: T) => ({
96
- ...node,
97
- toString(): string {
98
- return `${this.action.charAt(0).toUpperCase()}${this.action.slice(1)}(${this.resource})`;
99
- },
100
- [Symbol.toStringTag]() {
101
- return this.toString();
102
- },
103
- });
104
-
105
- export type Create<R extends Resource = AnyResource> = {
106
- action: "create";
106
+ export interface BaseNode<R extends Resource = AnyResource> {
107
107
  resource: R;
108
- news: any;
109
108
  provider: ProviderService<R>;
110
- attributes: R["attr"];
111
109
  bindings: BindNode[];
112
- };
110
+ downstream: string[];
111
+ }
112
+
113
+ export interface Create<R extends Resource = AnyResource> extends BaseNode<R> {
114
+ action: "create";
115
+ props: any;
116
+ state: CreatingResourceState | undefined;
117
+ }
113
118
 
114
- export type Update<R extends Resource = AnyResource> = {
119
+ export interface Update<R extends Resource = AnyResource> extends BaseNode<R> {
115
120
  action: "update";
116
- resource: R;
117
- olds: any;
118
- news: any;
119
- output: any;
120
- provider: ProviderService<R>;
121
- attributes: R["attr"];
122
- bindings: BindNode[];
123
- };
121
+ props: any;
122
+ state:
123
+ | CreatedResourceState
124
+ | UpdatedResourceState
125
+ | UpdatingReourceState
126
+ // the props can change after creating the replacement resource,
127
+ // so Apply needs to handle updates and then continue with cleaning up the replaced graph
128
+ | ReplacedResourceState;
129
+ }
124
130
 
125
- export type Delete<R extends Resource = AnyResource> = {
131
+ export interface Delete<R extends Resource = AnyResource> extends BaseNode<R> {
126
132
  action: "delete";
127
- resource: R;
128
- olds: any;
129
- output: any;
130
- provider: ProviderService<R>;
131
- bindings: BindNode[];
132
- attributes: R["attr"];
133
- downstream: string[];
134
- };
133
+ // a resource can be deleted no matter what state it's in
134
+ state: ResourceState;
135
+ }
135
136
 
136
- export type NoopUpdate<R extends Resource = AnyResource> = {
137
+ export interface NoopUpdate<R extends Resource = AnyResource>
138
+ extends BaseNode<R> {
137
139
  action: "noop";
138
- resource: R;
139
- attributes: R["attr"];
140
- bindings: BindNode[];
141
- };
140
+ state: CreatedResourceState | UpdatedResourceState;
141
+ }
142
142
 
143
- export type Replace<R extends Resource = AnyResource> = {
143
+ export interface Replace<R extends Resource = AnyResource> extends BaseNode<R> {
144
144
  action: "replace";
145
- resource: R;
146
- olds: any;
147
- news: any;
148
- output: any;
149
- provider: ProviderService<R>;
150
- bindings: BindNode[];
151
- attributes: R["attr"];
152
- deleteFirst?: boolean;
153
- };
145
+ props: any;
146
+ deleteFirst: boolean;
147
+ state:
148
+ | CreatingResourceState
149
+ | CreatedResourceState
150
+ | UpdatingReourceState
151
+ | UpdatedResourceState
152
+ | ReplacingResourceState
153
+ | ReplacedResourceState;
154
+ }
154
155
 
155
156
  export type ResourceGraph<Resources extends Service | Resource> = ToGraph<
156
157
  TraverseResources<Resources>
@@ -236,7 +237,7 @@ export type DerivePlan<Resources extends Service | Resource> = {
236
237
 
237
238
  export type IPlan = {
238
239
  resources: {
239
- [id in string]: CRUD<any>;
240
+ [id in string]: Apply<any>;
240
241
  };
241
242
  deletions: {
242
243
  [id in string]?: Delete<Resource>;
@@ -245,16 +246,39 @@ export type IPlan = {
245
246
 
246
247
  export type Plan<Resources extends Service | Resource> = Effect.Effect<
247
248
  DerivePlan<Resources>,
248
- never,
249
+ | CannotReplacePartiallyReplacedResource
250
+ | DeleteResourceHasDownstreamDependencies,
249
251
  Providers<Resources> | State
250
252
  >;
251
253
 
252
254
  export const plan = <const Resources extends (Service | Resource)[]>(
253
- ...resources: Resources
255
+ ..._resources: Resources
254
256
  ): Plan<Instance<Resources[number]>> =>
255
257
  Effect.gen(function* () {
256
258
  const state = yield* State;
257
259
 
260
+ const findResources = (
261
+ resource: Service | Resource,
262
+ visited: Set<string>,
263
+ ): (Service | Resource)[] => {
264
+ if (visited.has(resource.id)) {
265
+ return [];
266
+ }
267
+ visited.add(resource.id);
268
+ const upstream = Object.values(Output.upstreamAny(resource.props)) as (
269
+ | Service
270
+ | Resource
271
+ )[];
272
+ return [
273
+ resource,
274
+ ...upstream,
275
+ ...upstream.flatMap((r) => findResources(r, visited)),
276
+ ];
277
+ };
278
+ const resources = _resources
279
+ .flatMap((r) => findResources(r, new Set()))
280
+ .filter((r, i, arr) => arr.findIndex((r2) => r2.id === r.id) === i);
281
+
258
282
  // TODO(sam): rename terminology to Stack
259
283
  const app = yield* App;
260
284
 
@@ -262,32 +286,11 @@ export const plan = <const Resources extends (Service | Resource)[]>(
262
286
  stack: app.name,
263
287
  stage: app.stage,
264
288
  });
265
- const resourcesState = yield* Effect.all(
289
+ const oldResources = yield* Effect.all(
266
290
  resourceIds.map((id) =>
267
291
  state.get({ stack: app.name, stage: app.stage, resourceId: id }),
268
292
  ),
269
293
  );
270
- // map of resource ID -> its downstream dependencies (resources that depend on it)
271
- const downstream = resourcesState
272
- .filter(
273
- (
274
- resource,
275
- ): resource is ResourceState & {
276
- bindings: BindNode[];
277
- } => !!resource?.bindings,
278
- )
279
- .flatMap((resource) =>
280
- resource.bindings.flatMap(({ binding }) => [
281
- [binding.capability.resource.id, binding.capability.resource],
282
- ]),
283
- )
284
- .reduce(
285
- (acc, [id, resourceId]) => ({
286
- ...acc,
287
- [id]: [...(acc[id] ?? []), resourceId],
288
- }),
289
- {} as Record<string, string[]>,
290
- );
291
294
 
292
295
  type ResolveEffect<T> = Effect.Effect<T, ResolveErr, ResolveReq>;
293
296
  type ResolveErr = StateStoreError;
@@ -301,9 +304,12 @@ export const plan = <const Resources extends (Service | Resource)[]>(
301
304
 
302
305
  const resolvedResources: Record<
303
306
  string,
304
- ResolveEffect<{
305
- [attr in string]: any;
306
- }>
307
+ ResolveEffect<
308
+ | {
309
+ [attr in string]: any;
310
+ }
311
+ | undefined
312
+ >
307
313
  > = {};
308
314
 
309
315
  const resolveResource = (
@@ -324,41 +330,71 @@ export const plan = <const Resources extends (Service | Resource)[]>(
324
330
  resourceId: resource.id,
325
331
  });
326
332
 
327
- if (!oldState) {
333
+ if (!oldState || oldState.status === "creating") {
328
334
  return resourceExpr;
329
335
  }
330
336
 
337
+ const oldProps =
338
+ oldState.status === "created" ||
339
+ oldState.status === "updated" ||
340
+ oldState.status === "replaced"
341
+ ? // if we're in a stable state, then just use the props
342
+ oldState.props
343
+ : // if we failed to update or replace, compare with the last known stable props
344
+ oldState.status === "updating" ||
345
+ oldState.status === "replacing"
346
+ ? oldState.old.props
347
+ : // TODO(sam): it kinda doesn't make sense to diff with a "deleting" state
348
+ oldState.props;
349
+
331
350
  const diff = yield* provider.diff
332
351
  ? provider.diff({
333
352
  id: resource.id,
334
- olds: oldState.props,
353
+ olds: oldProps,
354
+ instanceId: oldState.instanceId,
335
355
  news: props,
336
- output: oldState.output,
356
+ output: oldState.attr,
337
357
  })
338
358
  : Effect.succeed(undefined);
339
359
 
360
+ const stables: string[] = [
361
+ ...(provider.stables ?? []),
362
+ ...(diff?.stables ?? []),
363
+ ];
364
+
365
+ const withStables = (output: any) =>
366
+ stables.length > 0
367
+ ? new Output.ResourceExpr(
368
+ resourceExpr.src,
369
+ Object.fromEntries(
370
+ stables.map((stable) => [stable, output?.[stable]]),
371
+ ),
372
+ )
373
+ : // if there are no stable properties, treat every property as changed
374
+ resourceExpr;
375
+
340
376
  if (diff == null) {
341
- if (arePropsChanged(oldState, props)) {
377
+ if (arePropsChanged(oldProps, props)) {
342
378
  // the props have changed but the provider did not provide any hints as to what is stable
343
379
  // so we must assume everything has changed
344
- return resourceExpr;
380
+ return withStables(oldState?.attr);
345
381
  }
346
382
  } else if (diff.action === "update") {
347
- const output = oldState?.output;
348
- if (diff.stables) {
349
- return new Output.ResourceExpr(
350
- resourceExpr.src,
351
- Object.fromEntries(
352
- diff.stables.map((stable) => [stable, output?.[stable]]),
353
- ),
354
- );
355
- } else {
356
- // if there are no stable properties, treat every property as changed
357
- return resourceExpr;
358
- }
383
+ return withStables(oldState?.attr);
359
384
  } else if (diff.action === "replace") {
385
+ return resourceExpr;
386
+ }
387
+ if (
388
+ oldState.status === "created" ||
389
+ oldState.status === "updated" ||
390
+ oldState.status === "replaced"
391
+ ) {
392
+ // we can safely return the attributes if we know they have stabilized
393
+ return oldState?.attr;
394
+ } else {
395
+ // we must assume the resource doesn't exist if it hasn't stabilized
396
+ return resourceExpr;
360
397
  }
361
- return oldState?.output;
362
398
  }),
363
399
  ));
364
400
  });
@@ -392,36 +428,49 @@ export const plan = <const Resources extends (Service | Resource)[]>(
392
428
  return upstream?.[expr.identifier];
393
429
  } else if (Output.isApplyExpr(expr)) {
394
430
  const upstream = yield* resolveOutput(expr.expr);
395
- return Output.isOutput(upstream) ? expr : expr.f(upstream);
431
+ return Output.hasOutputs(upstream) ? expr : expr.f(upstream);
396
432
  } else if (Output.isEffectExpr(expr)) {
397
433
  const upstream = yield* resolveOutput(expr.expr);
398
- return Output.isOutput(upstream) ? expr : yield* expr.f(upstream);
434
+ return Output.hasOutputs(upstream) ? expr : yield* expr.f(upstream);
399
435
  } else if (Output.isAllExpr(expr)) {
400
436
  return yield* Effect.all(expr.outs.map(resolveOutput));
401
437
  }
402
438
  return yield* Effect.die(new Error("Not implemented yet"));
403
439
  });
404
440
 
405
- const resolveUpstream = (
406
- value: any,
407
- ): {
408
- [ID in string]: Resource;
409
- } => {
410
- if (Output.isExpr(value)) {
411
- return Output.upstream(value);
412
- } else if (Array.isArray(value)) {
413
- return Object.assign({}, ...value.map(resolveUpstream));
414
- } else if (
415
- value &&
416
- (typeof value === "object" || typeof value === "function")
417
- ) {
418
- return Object.assign(
419
- {},
420
- ...Object.values(value).map((value) => resolveUpstream(value)),
421
- );
422
- }
423
- return {};
424
- };
441
+ // map of resource ID -> its downstream dependencies (resources that depend on it)
442
+ const oldDownstreamDependencies: {
443
+ [resourceId: string]: string[];
444
+ } = Object.fromEntries(
445
+ oldResources
446
+ .filter((resource) => !!resource)
447
+ .map((resource) => [resource.logicalId, resource.downstream]),
448
+ );
449
+
450
+ const newUpstreamDependencies: {
451
+ [resourceId: string]: string[];
452
+ } = Object.fromEntries(
453
+ resources.map((resource) => [
454
+ resource.id,
455
+ [
456
+ ...Object.values(Output.upstreamAny(resource.props)).map((r) => r.id),
457
+ ...(isService(resource)
458
+ ? resource.props.bindings.capabilities.map((cap) => cap.resource.id)
459
+ : []),
460
+ ],
461
+ ]),
462
+ );
463
+
464
+ const newDownstreamDependencies: {
465
+ [resourceId: string]: string[];
466
+ } = Object.fromEntries(
467
+ resources.map((resource) => [
468
+ resource.id,
469
+ Object.entries(newUpstreamDependencies)
470
+ .filter(([_, downstream]) => downstream.includes(resource.id))
471
+ .map(([id]) => id),
472
+ ]),
473
+ );
425
474
 
426
475
  const resourceGraph = Object.fromEntries(
427
476
  (yield* Effect.all(
@@ -432,7 +481,7 @@ export const plan = <const Resources extends (Service | Resource)[]>(
432
481
  (cap: Capability) => cap.resource as Resource,
433
482
  )
434
483
  : []),
435
- ...Object.values(resolveUpstream(resource.props)),
484
+ ...Object.values(Output.upstreamAny(resource.props)),
436
485
  resource,
437
486
  ])
438
487
  .filter(
@@ -453,6 +502,8 @@ export const plan = <const Resources extends (Service | Resource)[]>(
453
502
  });
454
503
  const provider = yield* resource.provider.tag;
455
504
 
505
+ const downstream = newDownstreamDependencies[id] ?? [];
506
+
456
507
  const bindings = isService(node)
457
508
  ? yield* diffBindings({
458
509
  oldState,
@@ -464,79 +515,207 @@ export const plan = <const Resources extends (Service | Resource)[]>(
464
515
  target: {
465
516
  id: node.id,
466
517
  props: node.props,
467
- oldAttr: oldState?.output,
518
+ // TODO(sam): pick the right ones based on old status
519
+ oldAttr: oldState?.attr,
468
520
  oldProps: oldState?.props,
469
521
  },
470
522
  })
471
523
  : []; // TODO(sam): return undefined instead of empty array
472
524
 
473
- if (oldState === undefined || oldState.status === "creating") {
474
- return Node<Create<Resource>>({
475
- action: "create",
476
- news,
525
+ const Node = <T extends Apply>(
526
+ node: Omit<
527
+ T,
528
+ "provider" | "resource" | "bindings" | "downstream"
529
+ >,
530
+ ) =>
531
+ ({
532
+ ...node,
477
533
  provider,
478
534
  resource,
479
535
  bindings,
480
- // phantom
481
- attributes: undefined!,
536
+ downstream,
537
+ }) as any as T;
538
+
539
+ // handle empty and intermediate (non-final) states:
540
+ if (oldState === undefined) {
541
+ return Node<Create<Resource>>({
542
+ action: "create",
543
+ props: news,
544
+ state: oldState,
482
545
  });
483
546
  }
484
547
 
485
- const diff = provider.diff
486
- ? yield* (() => {
487
- const diff = provider.diff({
548
+ // TODO(sam): is this correct for all possible states a resource can be in?
549
+ const oldProps = oldState.props;
550
+
551
+ const diff = yield* asEffect(
552
+ provider.diff
553
+ ? provider.diff({
488
554
  id,
489
- olds: oldState.props,
555
+ olds: oldProps,
556
+ instanceId: oldState.instanceId,
557
+ output: oldState.attr,
490
558
  news,
491
- output: oldState.output,
492
- });
493
- return Effect.isEffect(diff) ? diff : Effect.succeed(diff);
494
- })()
495
- : undefined;
559
+ })
560
+ : undefined,
561
+ ).pipe(
562
+ Effect.map(
563
+ (diff) =>
564
+ diff ??
565
+ ({
566
+ action: arePropsChanged(oldProps, news)
567
+ ? "update"
568
+ : "noop",
569
+ } as UpdateDiff | NoopDiff),
570
+ ),
571
+ );
572
+
573
+ if (oldState.status === "creating") {
574
+ if (diff.action === "noop") {
575
+ // we're in the creating state and props are un-changed
576
+ // let's just continue where we left off
577
+ return Node<Create<Resource>>({
578
+ action: "create",
579
+ props: news,
580
+ state: oldState,
581
+ });
582
+ } else if (diff.action === "update") {
583
+ // props have changed in a way that is updatable
584
+ // again, just continue with the create
585
+ // TODO(sam): should we maybe try an update instead?
586
+ return Node<Create<Resource>>({
587
+ action: "create",
588
+ props: news,
589
+ state: oldState,
590
+ });
591
+ } else {
592
+ // props have changed in an incompatible way
593
+ // because it's possible that an un-updatable resource has already been created
594
+ // we must use a replace step to create a new one and delete the potential old one
595
+ return Node<Replace<Resource>>({
596
+ action: "replace",
597
+ props: news,
598
+ deleteFirst: diff.deleteFirst ?? false,
599
+ state: oldState,
600
+ });
601
+ }
602
+ } else if (oldState.status === "updating") {
603
+ // we started to update a resource but did not complete
604
+ if (diff.action === "update" || diff.action === "noop") {
605
+ return Node<Update<Resource>>({
606
+ action: "update",
607
+ props: news,
608
+ state: oldState,
609
+ });
610
+ } else {
611
+ // we started to update a resource but now believe we should replace it
612
+ return Node<Replace<Resource>>({
613
+ action: "replace",
614
+ deleteFirst: diff.deleteFirst ?? false,
615
+ props: news,
616
+ // TODO(sam): can Apply handle replacements when the oldState is UpdatingResourceState?
617
+ // -> or is there we do a provider.read to try and reconcile back to UpdatedResourceState?
618
+ state: oldState,
619
+ });
620
+ }
621
+ } else if (oldState.status === "replacing") {
622
+ // resource replacement started, but the replacement may or may not have been created
623
+ if (diff.action === "noop") {
624
+ // this is the stable case - noop means just continue with the replacement
625
+ return Node<Replace<Resource>>({
626
+ action: "replace",
627
+ deleteFirst: oldState.deleteFirst,
628
+ props: news,
629
+ state: oldState,
630
+ });
631
+ } else if (diff.action === "update") {
632
+ // potential problem here - the props have changed since we tried to replace,
633
+ // but not enough to trigger another replacement. the resource provider should
634
+ // be designed as idempotent to converge to the right state when creating the new resource
635
+ // the newly generated instanceId is intended to assist with this
636
+ return Node<Replace<Resource>>({
637
+ action: "replace",
638
+ deleteFirst: oldState.deleteFirst,
639
+ props: news,
640
+ state: oldState,
641
+ });
642
+ } else {
643
+ // ah shit, so we tried to replace the resource and then crashed
644
+ // now the props have changed again in such a way that the (maybe, maybe not)
645
+ // created resource should also be replaced
496
646
 
497
- if (!diff && arePropsChanged(oldState, news)) {
647
+ // TODO(sam): what should we do?
648
+ // 1. trigger a deletion of the potentially created resource
649
+ // 2. expect the resource provider to handle it idempotently?
650
+ // -> i don't think this case is fair to put on the resource provider
651
+ // because if the resource was created, it's in a state that can't be updated
652
+ return yield* Effect.fail(
653
+ new CannotReplacePartiallyReplacedResource(id),
654
+ );
655
+ }
656
+ } else if (oldState.status === "replaced") {
657
+ // replacement has been created but we're not done cleaning up the old state
658
+ if (diff.action === "noop") {
659
+ // this is the stable case - noop means just continue cleaning up the replacement
660
+ return Node<Replace<Resource>>({
661
+ action: "replace",
662
+ deleteFirst: oldState.deleteFirst,
663
+ props: news,
664
+ state: oldState,
665
+ });
666
+ } else if (diff.action === "update") {
667
+ // the replacement has been created but now also needs to be updated
668
+ // the resource provider should:
669
+ // 1. Update the newly created replacement resource
670
+ // 2. Then proceed as normal to delete the replaced resources (after all downstream references are updated)
671
+ return Node<Update<Resource>>({
672
+ action: "update",
673
+ props: news,
674
+ state: oldState,
675
+ });
676
+ } else {
677
+ // the replacement has been created but now it needs to be replaced
678
+ // this is the worst-case scenario because downstream resources
679
+ // could have been been updated to point to the replaced resources
680
+ return yield* Effect.fail(
681
+ new CannotReplacePartiallyReplacedResource(id),
682
+ );
683
+ }
684
+ } else if (oldState.status === "deleting") {
685
+ if (diff.action === "noop" || diff.action === "update") {
686
+ // we're in a partially deleted state, it is unclear whether it was or was not deleted
687
+ // it should be safe to re-create it with the same instanceId?
688
+ return Node<Create<Resource>>({
689
+ action: "create",
690
+ props: news,
691
+ state: {
692
+ ...oldState,
693
+ status: "creating",
694
+ props: news,
695
+ },
696
+ });
697
+ } else {
698
+ return yield* Effect.fail(
699
+ new CannotReplacePartiallyReplacedResource(id),
700
+ );
701
+ }
702
+ } else if (diff.action === "update") {
498
703
  return Node<Update<Resource>>({
499
704
  action: "update",
500
- olds: oldState.props,
501
- news,
502
- output: oldState.output,
503
- provider,
504
- resource,
505
- bindings,
506
- // phantom
507
- attributes: undefined!,
705
+ props: news,
706
+ state: oldState,
508
707
  });
509
- } else if (diff?.action === "replace") {
708
+ } else if (diff.action === "replace") {
510
709
  return Node<Replace<Resource>>({
511
710
  action: "replace",
512
- olds: oldState.props,
513
- news,
514
- output: oldState.output,
515
- provider,
516
- resource,
517
- bindings,
518
- // phantom
519
- attributes: undefined!,
520
- });
521
- } else if (diff?.action === "update") {
522
- return Node<Update<Resource>>({
523
- action: "update",
524
- olds: oldState.props,
525
- news,
526
- output: oldState.output,
527
- provider,
528
- resource,
529
- bindings,
530
- // phantom
531
- attributes: undefined!,
711
+ props: news,
712
+ state: oldState,
713
+ deleteFirst: diff?.deleteFirst ?? false,
532
714
  });
533
715
  } else {
534
716
  return Node<NoopUpdate<Resource>>({
535
717
  action: "noop",
536
- resource,
537
- bindings,
538
- // phantom
539
- attributes: undefined!,
718
+ state: oldState,
540
719
  });
541
720
  }
542
721
  }),
@@ -556,33 +735,24 @@ export const plan = <const Resources extends (Service | Resource)[]>(
556
735
  stage: app.stage,
557
736
  resourceId: id,
558
737
  });
559
- const context = yield* Effect.context<never>();
560
738
  if (oldState) {
561
- const provider: ProviderService = context.unsafeMap.get(
562
- oldState?.type,
563
- );
564
- if (!provider) {
565
- yield* Effect.die(
566
- new Error(`Provider not found for ${oldState?.type}`),
567
- );
568
- }
739
+ const provider = yield* getProviderByType(oldState.resourceType);
569
740
  return [
570
741
  id,
571
742
  {
572
743
  action: "delete",
573
- olds: oldState.props,
574
- output: oldState.output,
575
- provider,
576
- attributes: oldState?.output,
577
- // TODO(sam): Support Detach Bindings
744
+ state: oldState,
745
+ // // TODO(sam): Support Detach Bindings
578
746
  bindings: [],
747
+ provider,
579
748
  resource: {
580
749
  id: id,
581
- type: oldState.type,
582
- attr: oldState.output,
750
+ type: oldState.resourceType,
751
+ attr: oldState.attr,
583
752
  props: oldState.props,
584
753
  } as Resource,
585
- downstream: downstream[id] ?? [],
754
+ // TODO(sam): is it enough to just pass through oldState?
755
+ downstream: oldDownstreamDependencies[id] ?? [],
586
756
  } satisfies Delete<Resource>,
587
757
  ] as const;
588
758
  }
@@ -592,7 +762,7 @@ export const plan = <const Resources extends (Service | Resource)[]>(
592
762
  );
593
763
 
594
764
  for (const [resourceId, deletion] of Object.entries(deletions)) {
595
- const dependencies = deletion.downstream.filter(
765
+ const dependencies = deletion.state.downstream.filter(
596
766
  (d) => d in resourceGraph,
597
767
  );
598
768
  if (dependencies.length > 0) {
@@ -612,7 +782,24 @@ export const plan = <const Resources extends (Service | Resource)[]>(
612
782
  } satisfies IPlan as IPlan;
613
783
  }) as any;
614
784
 
615
- class DeleteResourceHasDownstreamDependencies extends Data.TaggedError(
785
+ export class CannotReplacePartiallyReplacedResource extends Data.TaggedError(
786
+ "CannotReplacePartiallyReplacedResource",
787
+ )<{
788
+ message: string;
789
+ logicalId: string;
790
+ }> {
791
+ constructor(logicalId: string) {
792
+ super({
793
+ message:
794
+ `Resource '${logicalId}' did not finish being replaced in a previous deployment ` +
795
+ `and is expected to be replaced again in this deployment. ` +
796
+ `You should revert its properties and try again after a successful deployment.`,
797
+ logicalId,
798
+ });
799
+ }
800
+ }
801
+
802
+ export class DeleteResourceHasDownstreamDependencies extends Data.TaggedError(
616
803
  "DeleteResourceHasDownstreamDependencies",
617
804
  )<{
618
805
  message: string;
@@ -621,12 +808,13 @@ class DeleteResourceHasDownstreamDependencies extends Data.TaggedError(
621
808
  }> {}
622
809
 
623
810
  const arePropsChanged = <R extends Resource>(
624
- oldState: ResourceState | undefined,
811
+ oldProps: R["props"] | undefined,
625
812
  newProps: R["props"],
626
813
  ) => {
627
814
  return (
628
- JSON.stringify(omit(oldState?.props ?? {}, "bindings")) !==
629
- JSON.stringify(omit((newProps ?? {}) as any, "bindings"))
815
+ Output.hasOutputs(newProps) ||
816
+ JSON.stringify(omit((oldProps ?? {}) as any, "bindings")) !==
817
+ JSON.stringify(omit((newProps ?? {}) as any, "bindings"))
630
818
  );
631
819
  };
632
820
 
@@ -641,9 +829,9 @@ const diffBindings = Effect.fn(function* ({
641
829
  }) {
642
830
  // const actions: BindNode[] = [];
643
831
  const oldBindings = oldState?.bindings;
644
- const oldSids = new Set(
645
- oldBindings?.map(({ binding }) => binding.capability.sid),
646
- );
832
+ // const oldSids = new Set(
833
+ // oldBindings?.map(({ binding }) => binding.capability.sid),
834
+ // );
647
835
 
648
836
  const diffBinding: (
649
837
  binding: AnyBinding,
@@ -747,7 +935,7 @@ const isBindingDiff = Effect.fn(function* ({
747
935
  id: oldCap.resource.id,
748
936
  props: newCap.resource.props,
749
937
  oldProps: oldState?.props,
750
- oldAttr: oldState?.output,
938
+ oldAttr: oldState?.attr,
751
939
  },
752
940
  props: newBinding.props,
753
941
  attr: oldBinding.attr,
@@ -769,3 +957,106 @@ const isBindingDiff = Effect.fn(function* ({
769
957
  });
770
958
  // TODO(sam): compare props
771
959
  // oldBinding.props !== newBinding.props;
960
+
961
+ /**
962
+ * Print a plan in a human-readable format that shows the graph topology.
963
+ */
964
+ export const printPlan = (plan: IPlan): string => {
965
+ const lines: string[] = [];
966
+ const allNodes = { ...plan.resources, ...plan.deletions };
967
+
968
+ // Build reverse mapping: upstream -> downstream
969
+ const upstreamMap: Record<string, string[]> = {};
970
+ for (const [id] of Object.entries(allNodes)) {
971
+ upstreamMap[id] = [];
972
+ }
973
+ for (const [id, node] of Object.entries(allNodes)) {
974
+ if (!node) continue;
975
+ for (const downstreamId of node.state?.downstream ?? []) {
976
+ if (upstreamMap[downstreamId]) {
977
+ upstreamMap[downstreamId].push(id);
978
+ }
979
+ }
980
+ }
981
+
982
+ // Action symbols
983
+ const actionSymbol = (action: string) => {
984
+ switch (action) {
985
+ case "create":
986
+ return "+";
987
+ case "update":
988
+ return "~";
989
+ case "delete":
990
+ return "-";
991
+ case "replace":
992
+ return "±";
993
+ case "noop":
994
+ return "=";
995
+ default:
996
+ return "?";
997
+ }
998
+ };
999
+
1000
+ // Print header
1001
+ lines.push(
1002
+ "╔════════════════════════════════════════════════════════════════╗",
1003
+ );
1004
+ lines.push(
1005
+ "║ PLAN ║",
1006
+ );
1007
+ lines.push(
1008
+ "╠════════════════════════════════════════════════════════════════╣",
1009
+ );
1010
+ lines.push(
1011
+ "║ Legend: + create, ~ update, - delete, ± replace, = noop ║",
1012
+ );
1013
+ lines.push(
1014
+ "╚════════════════════════════════════════════════════════════════╝",
1015
+ );
1016
+ lines.push("");
1017
+
1018
+ // Print resources section
1019
+ lines.push(
1020
+ "┌─ Resources ────────────────────────────────────────────────────┐",
1021
+ );
1022
+ const resourceIds = Object.keys(plan.resources).sort();
1023
+ for (const id of resourceIds) {
1024
+ const node = plan.resources[id];
1025
+ const symbol = actionSymbol(node.action);
1026
+ const type = node.resource?.type ?? "unknown";
1027
+ const downstream = node.state?.downstream?.length
1028
+ ? ` → [${node.state?.downstream.join(", ")}]`
1029
+ : "";
1030
+ lines.push(`│ [${symbol}] ${id} (${type})${downstream}`);
1031
+ }
1032
+ if (resourceIds.length === 0) {
1033
+ lines.push("│ (none)");
1034
+ }
1035
+ lines.push(
1036
+ "└────────────────────────────────────────────────────────────────┘",
1037
+ );
1038
+ lines.push("");
1039
+
1040
+ // Print deletions section
1041
+ lines.push(
1042
+ "┌─ Deletions ────────────────────────────────────────────────────┐",
1043
+ );
1044
+ const deletionIds = Object.keys(plan.deletions).sort();
1045
+ for (const id of deletionIds) {
1046
+ const node = plan.deletions[id]!;
1047
+ const type = node.resource?.type ?? "unknown";
1048
+ const downstream = node.state.downstream?.length
1049
+ ? ` → [${node.state.downstream.join(", ")}]`
1050
+ : "";
1051
+ lines.push(`│ [-] ${id} (${type})${downstream}`);
1052
+ }
1053
+ if (deletionIds.length === 0) {
1054
+ lines.push("│ (none)");
1055
+ }
1056
+ lines.push(
1057
+ "└────────────────────────────────────────────────────────────────┘",
1058
+ );
1059
+ lines.push("");
1060
+
1061
+ return lines.join("\n");
1062
+ };