alchemy-effect 0.3.0 → 0.5.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 (167) hide show
  1. package/bin/alchemy-effect.js +618 -260
  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 +444 -159
  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 +11 -2
  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.map +1 -1
  52. package/lib/aws/ec2/vpc.provider.js +32 -10
  53. package/lib/aws/ec2/vpc.provider.js.map +1 -1
  54. package/lib/aws/index.d.ts +2 -3
  55. package/lib/aws/index.d.ts.map +1 -1
  56. package/lib/aws/index.js +2 -1
  57. package/lib/aws/index.js.map +1 -1
  58. package/lib/aws/lambda/function.provider.d.ts +2 -2
  59. package/lib/aws/lambda/function.provider.d.ts.map +1 -1
  60. package/lib/aws/lambda/function.provider.js +39 -42
  61. package/lib/aws/lambda/function.provider.js.map +1 -1
  62. package/lib/aws/sqs/queue.provider.d.ts +3 -4
  63. package/lib/aws/sqs/queue.provider.d.ts.map +1 -1
  64. package/lib/aws/sqs/queue.provider.js +17 -9
  65. package/lib/aws/sqs/queue.provider.js.map +1 -1
  66. package/lib/cli/index.d.ts +183 -100
  67. package/lib/cli/index.d.ts.map +1 -1
  68. package/lib/cloudflare/kv/namespace.client.d.ts +1 -1
  69. package/lib/cloudflare/kv/namespace.provider.d.ts.map +1 -1
  70. package/lib/cloudflare/kv/namespace.provider.js +12 -6
  71. package/lib/cloudflare/kv/namespace.provider.js.map +1 -1
  72. package/lib/cloudflare/r2/bucket.binding.js +1 -1
  73. package/lib/cloudflare/r2/bucket.binding.js.map +1 -1
  74. package/lib/cloudflare/r2/bucket.d.ts +1 -1
  75. package/lib/cloudflare/r2/bucket.d.ts.map +1 -1
  76. package/lib/cloudflare/r2/bucket.provider.d.ts +1 -2
  77. package/lib/cloudflare/r2/bucket.provider.d.ts.map +1 -1
  78. package/lib/cloudflare/r2/bucket.provider.js +23 -12
  79. package/lib/cloudflare/r2/bucket.provider.js.map +1 -1
  80. package/lib/cloudflare/worker/worker.d.ts +2 -2
  81. package/lib/cloudflare/worker/worker.d.ts.map +1 -1
  82. package/lib/cloudflare/worker/worker.provider.d.ts +2 -3
  83. package/lib/cloudflare/worker/worker.provider.d.ts.map +1 -1
  84. package/lib/cloudflare/worker/worker.provider.js +44 -13
  85. package/lib/cloudflare/worker/worker.provider.js.map +1 -1
  86. package/lib/diff.d.ts +8 -6
  87. package/lib/diff.d.ts.map +1 -1
  88. package/lib/diff.js +13 -0
  89. package/lib/diff.js.map +1 -1
  90. package/lib/event.d.ts +1 -1
  91. package/lib/event.d.ts.map +1 -1
  92. package/lib/instance-id.d.ts +12 -0
  93. package/lib/instance-id.d.ts.map +1 -0
  94. package/lib/instance-id.js +16 -0
  95. package/lib/instance-id.js.map +1 -0
  96. package/lib/output.d.ts +4 -2
  97. package/lib/output.d.ts.map +1 -1
  98. package/lib/output.js +18 -4
  99. package/lib/output.js.map +1 -1
  100. package/lib/physical-name.d.ts +25 -1
  101. package/lib/physical-name.d.ts.map +1 -1
  102. package/lib/physical-name.js +50 -2
  103. package/lib/physical-name.js.map +1 -1
  104. package/lib/plan.d.ts +49 -42
  105. package/lib/plan.d.ts.map +1 -1
  106. package/lib/plan.js +417 -137
  107. package/lib/plan.js.map +1 -1
  108. package/lib/provider.d.ts +26 -10
  109. package/lib/provider.d.ts.map +1 -1
  110. package/lib/provider.js +9 -0
  111. package/lib/provider.js.map +1 -1
  112. package/lib/resource.d.ts +3 -2
  113. package/lib/resource.d.ts.map +1 -1
  114. package/lib/resource.js.map +1 -1
  115. package/lib/state.d.ts +86 -9
  116. package/lib/state.d.ts.map +1 -1
  117. package/lib/state.js +21 -18
  118. package/lib/state.js.map +1 -1
  119. package/lib/tags.d.ts +15 -0
  120. package/lib/tags.d.ts.map +1 -1
  121. package/lib/tags.js +27 -0
  122. package/lib/tags.js.map +1 -1
  123. package/lib/test.d.ts +2 -2
  124. package/lib/test.d.ts.map +1 -1
  125. package/lib/test.js +4 -4
  126. package/lib/test.js.map +1 -1
  127. package/lib/todo.d.ts +3 -0
  128. package/lib/todo.d.ts.map +1 -0
  129. package/lib/todo.js +3 -0
  130. package/lib/todo.js.map +1 -0
  131. package/lib/tsconfig.test.tsbuildinfo +1 -1
  132. package/package.json +2 -2
  133. package/src/apply.ts +742 -348
  134. package/src/aws/dynamodb/table.provider.ts +16 -4
  135. package/src/aws/ec2/index.ts +8 -0
  136. package/src/aws/ec2/internet-gateway.provider.ts +316 -0
  137. package/src/aws/ec2/internet-gateway.ts +79 -0
  138. package/src/aws/ec2/route-table-association.provider.ts +214 -0
  139. package/src/aws/ec2/route-table-association.ts +82 -0
  140. package/src/aws/ec2/route-table.provider.ts +306 -0
  141. package/src/aws/ec2/route-table.ts +175 -0
  142. package/src/aws/ec2/route.provider.ts +213 -0
  143. package/src/aws/ec2/route.ts +192 -0
  144. package/src/aws/ec2/subnet.provider.ts +2 -2
  145. package/src/aws/ec2/vpc.provider.ts +36 -11
  146. package/src/aws/ec2/vpc.ts +2 -0
  147. package/src/aws/index.ts +4 -1
  148. package/src/aws/lambda/function.provider.ts +66 -53
  149. package/src/aws/sqs/queue.provider.ts +18 -11
  150. package/src/cloudflare/kv/namespace.provider.ts +19 -14
  151. package/src/cloudflare/r2/bucket.binding.ts +1 -1
  152. package/src/cloudflare/r2/bucket.provider.ts +34 -24
  153. package/src/cloudflare/r2/bucket.ts +1 -1
  154. package/src/cloudflare/worker/worker.provider.ts +43 -13
  155. package/src/cloudflare/worker/worker.ts +2 -2
  156. package/src/diff.ts +35 -17
  157. package/src/event.ts +6 -0
  158. package/src/instance-id.ts +20 -0
  159. package/src/output.ts +29 -5
  160. package/src/physical-name.ts +79 -2
  161. package/src/plan.ts +566 -214
  162. package/src/provider.ts +46 -10
  163. package/src/resource.ts +65 -8
  164. package/src/state.ts +150 -35
  165. package/src/tags.ts +31 -0
  166. package/src/test.ts +5 -5
  167. package/src/todo.ts +4 -0
package/src/apply.ts CHANGED
@@ -1,27 +1,43 @@
1
1
  import * as Context from "effect/Context";
2
2
  import * as Effect from "effect/Effect";
3
3
  import type { Simplify } from "effect/Types";
4
+ import { App } from "./app.ts";
4
5
  import type { AnyBinding, BindingService } from "./binding.ts";
6
+ import {
7
+ type PlanStatusSession,
8
+ CLI,
9
+ type ScopedPlanStatusSession,
10
+ } from "./cli/service.ts";
5
11
  import type { ApplyStatus } from "./event.ts";
12
+ import { generateInstanceId, InstanceId } from "./instance-id.ts";
6
13
  import * as Output from "./output.ts";
7
14
  import {
15
+ type Apply,
8
16
  plan,
9
17
  type BindNode,
10
- type Create,
11
- type CRUD,
12
18
  type Delete,
13
19
  type DerivePlan,
14
20
  type IPlan,
15
21
  type Providers,
16
- type Update,
17
22
  } from "./plan.ts";
18
23
  import type { Instance } from "./policy.ts";
19
24
  import type { AnyResource, Resource } from "./resource.ts";
20
25
  import type { AnyService } from "./service.ts";
21
- import { State } from "./state.ts";
22
- import { App } from "./app.ts";
26
+ import {
27
+ type CreatedResourceState,
28
+ type CreatingResourceState,
29
+ type DeletingResourceState,
30
+ type ReplacedResourceState,
31
+ type ReplacingResourceState,
32
+ type ResourceState,
33
+ type UpdatedResourceState,
34
+ type UpdatingReourceState,
35
+ State,
36
+ StateStoreError,
37
+ } from "./state.ts";
23
38
  import { asEffect } from "./util.ts";
24
- import { type ScopedPlanStatusSession, CLI } from "./cli/service.ts";
39
+ import { getProviderByType } from "./provider.ts";
40
+ import { Layer } from "effect";
25
41
 
26
42
  export type ApplyEffect<
27
43
  P extends IPlan,
@@ -53,217 +69,372 @@ export const apply = <
53
69
  never,
54
70
  State | Providers<Instance<Resources[number]>>
55
71
  // TODO(sam): don't cast to any
56
- > => plan(...resources).pipe(Effect.flatMap(applyPlan)) as any;
72
+ > =>
73
+ plan(...resources).pipe(
74
+ Effect.flatMap((p) => applyPlan(p as any as IPlan)),
75
+ ) as any;
57
76
 
58
77
  export const applyPlan = <P extends IPlan>(plan: P) =>
59
78
  Effect.gen(function* () {
60
- const state = yield* State;
61
- // TODO(sam): rename terminology to Stack
62
- const app = yield* App;
63
- const outputs = {} as Record<string, Effect.Effect<any, any, State>>;
64
-
65
79
  const cli = yield* CLI;
66
-
67
80
  const session = yield* cli.startApplySession(plan);
68
- const { emit, done } = session;
69
-
70
- const resolveUpstream = Effect.fn(function* (resourceId: string) {
71
- const upstreamNode = plan.resources[resourceId];
72
- const upstreamAttr = upstreamNode
73
- ? yield* apply(upstreamNode)
74
- : yield* Effect.dieMessage(`Resource ${resourceId} not found`);
75
- return {
76
- resourceId,
77
- upstreamAttr,
78
- upstreamNode,
79
- };
80
- });
81
-
82
- const resolveBindingUpstream = Effect.fn(function* ({
83
- node,
84
- }: {
85
- node: BindNode;
86
- resource: Resource;
87
- }) {
88
- const binding = node.binding as AnyBinding & {
89
- // smuggled property (because it interacts poorly with inference)
90
- Tag: Context.Tag<never, BindingService>;
91
- };
92
- const provider = yield* binding.Tag;
93
-
94
- const resourceId: string = node.binding.capability.resource.id;
95
- const { upstreamAttr, upstreamNode } = yield* resolveUpstream(resourceId);
96
-
97
- return {
98
- resourceId,
99
- upstreamAttr,
100
- upstreamNode,
101
- provider,
102
- };
103
- });
104
-
105
- const attachBindings = ({
106
- resource,
107
- bindings,
108
- target,
109
- }: {
110
- resource: Resource;
111
- bindings: BindNode[];
112
- target: {
113
- id: string;
114
- props: any;
115
- attr: any;
116
- };
117
- }) =>
118
- Effect.all(
119
- bindings.map(
120
- Effect.fn(function* (node) {
121
- const { resourceId, upstreamAttr, upstreamNode, provider } =
122
- yield* resolveBindingUpstream({ node, resource });
123
-
124
- const input = {
125
- source: {
126
- id: resourceId,
127
- attr: upstreamAttr,
128
- props: upstreamNode.resource.props,
129
- },
130
- props: node.binding.props,
131
- attr: node.attr,
132
- target,
133
- } as const;
134
- if (node.action === "attach") {
135
- return yield* asEffect(provider.attach(input));
136
- } else if (node.action === "reattach") {
137
- // reattach is optional, we fall back to attach if it's not available
138
- return yield* asEffect(
139
- (provider.reattach ? provider.reattach : provider.attach)(
140
- input,
141
- ),
142
- );
143
- } else if (node.action === "detach" && provider.detach) {
144
- return yield* asEffect(
145
- provider.detach({
146
- ...input,
147
- target,
148
- }),
149
- );
150
- }
151
- return node.attr;
152
- }),
153
- ),
154
- );
155
-
156
- const postAttachBindings = ({
157
- bindings,
158
- bindingOutputs,
159
- resource,
160
- target,
161
- }: {
162
- bindings: BindNode[];
163
- bindingOutputs: any[];
164
- resource: Resource;
165
- target: {
166
- id: string;
167
- props: any;
168
- attr: any;
169
- };
170
- }) =>
171
- Effect.all(
172
- bindings.map(
173
- Effect.fn(function* (node, i) {
174
- const { resourceId, upstreamAttr, upstreamNode, provider } =
175
- yield* resolveBindingUpstream({ node, resource });
176
-
177
- const oldBindingOutput = bindingOutputs[i];
178
-
179
- if (
180
- provider.postattach &&
181
- (node.action === "attach" || node.action === "reattach")
182
- ) {
183
- const bindingOutput = yield* asEffect(
184
- provider.postattach({
185
- source: {
186
- id: resourceId,
187
- attr: upstreamAttr,
188
- props: upstreamNode.resource.props,
189
- },
190
- props: node.binding.props,
191
- attr: oldBindingOutput,
192
- target,
193
- } as const),
194
- );
195
- return {
196
- ...oldBindingOutput,
197
- ...bindingOutput,
198
- };
199
- }
200
- return oldBindingOutput;
201
- }),
202
- ),
203
- );
204
-
205
- const apply: (node: CRUD) => Effect.Effect<any, never, never> = (node) =>
206
- Effect.gen(function* () {
207
- const saveState = <Output>({
208
- output,
209
- bindings = node.bindings,
210
- news,
211
- }: {
212
- output: Output;
213
- bindings?: BindNode[];
214
- news: any;
215
- }) =>
216
- state
217
- .set({
218
- stack: app.name,
219
- stage: app.stage,
220
- resourceId: node.resource.id,
221
- value: {
222
- id: node.resource.id,
223
- type: node.resource.type,
224
- status: node.action === "create" ? "created" : "updated",
225
- props: news,
226
- output,
227
- bindings,
228
- },
229
- })
230
- .pipe(Effect.map(() => output));
231
81
 
232
- const id = node.resource.id;
233
- const resource = node.resource;
82
+ // 1. expand the graph (create new resources, update existing and create replacements)
83
+ const resources = yield* expandAndPivot(plan, session);
84
+ // TODO(sam): support roll back to previous state if errors occur during expansion
85
+ // -> RISK: some UPDATEs may not be reverisble (i.e. trigger replacements)
86
+ // TODO(sam): should pivot be done separately? E.g shift traffic?
87
+
88
+ // 2. delete orphans and replaced resources
89
+ yield* collectGarbage(plan, session);
90
+
91
+ yield* session.done();
92
+
93
+ if (Object.keys(plan.resources).length === 0) {
94
+ // all resources are deleted, return undefined
95
+ return undefined;
96
+ }
97
+ return resources as {
98
+ [k in keyof AppliedPlan<P>]: AppliedPlan<P>[k];
99
+ };
100
+ });
101
+
102
+ const expandAndPivot = Effect.fnUntraced(function* (
103
+ plan: IPlan,
104
+ session: PlanStatusSession,
105
+ ) {
106
+ const state = yield* State;
107
+ const app = yield* App;
108
+
109
+ const outputs = {} as Record<string, Effect.Effect<any, any, State>>;
110
+ const resolveUpstream = Effect.fn(function* (resourceId: string) {
111
+ const upstreamNode = plan.resources[resourceId];
112
+ const upstreamAttr = upstreamNode
113
+ ? yield* apply(upstreamNode)
114
+ : yield* Effect.dieMessage(`Resource ${resourceId} not found`);
115
+ return {
116
+ resourceId,
117
+ upstreamAttr,
118
+ upstreamNode,
119
+ };
120
+ });
234
121
 
235
- const scopedSession = {
236
- ...session,
237
- note: (note: string) =>
122
+ const resolveBindingUpstream = Effect.fn(function* ({
123
+ node,
124
+ }: {
125
+ node: BindNode;
126
+ resource: Resource;
127
+ }) {
128
+ const binding = node.binding as AnyBinding & {
129
+ // smuggled property (because it interacts poorly with inference)
130
+ Tag: Context.Tag<never, BindingService>;
131
+ };
132
+ const provider = yield* binding.Tag;
133
+ const resourceId: string = node.binding.capability.resource.id;
134
+ const { upstreamAttr, upstreamNode } = yield* resolveUpstream(resourceId);
135
+
136
+ return {
137
+ resourceId,
138
+ upstreamAttr,
139
+ upstreamNode,
140
+ provider,
141
+ };
142
+ });
143
+
144
+ const attachBindings = ({
145
+ resource,
146
+ bindings,
147
+ target,
148
+ }: {
149
+ resource: Resource;
150
+ bindings: BindNode[];
151
+ target: {
152
+ id: string;
153
+ props: any;
154
+ attr: any;
155
+ };
156
+ }) =>
157
+ Effect.all(
158
+ bindings.map(
159
+ Effect.fn(function* (node) {
160
+ const { resourceId, upstreamAttr, upstreamNode, provider } =
161
+ yield* resolveBindingUpstream({ node, resource });
162
+
163
+ const input = {
164
+ source: {
165
+ id: resourceId,
166
+ attr: upstreamAttr,
167
+ props: upstreamNode.resource.props,
168
+ },
169
+ props: node.binding.props,
170
+ attr: node.attr,
171
+ target,
172
+ } as const;
173
+ if (node.action === "attach") {
174
+ return yield* asEffect(provider.attach(input));
175
+ } else if (node.action === "reattach") {
176
+ // reattach is optional, we fall back to attach if it's not available
177
+ return yield* asEffect(
178
+ (provider.reattach ? provider.reattach : provider.attach)(input),
179
+ );
180
+ } else if (node.action === "detach" && provider.detach) {
181
+ return yield* asEffect(
182
+ provider.detach({
183
+ ...input,
184
+ target,
185
+ }),
186
+ );
187
+ }
188
+ return node.attr;
189
+ }),
190
+ ),
191
+ );
192
+
193
+ const postAttachBindings = ({
194
+ bindings,
195
+ bindingOutputs,
196
+ resource,
197
+ target,
198
+ }: {
199
+ bindings: BindNode[];
200
+ bindingOutputs: any[];
201
+ resource: Resource;
202
+ target: {
203
+ id: string;
204
+ props: any;
205
+ attr: any;
206
+ };
207
+ }) =>
208
+ Effect.all(
209
+ bindings.map(
210
+ Effect.fn(function* (node, i) {
211
+ const { resourceId, upstreamAttr, upstreamNode, provider } =
212
+ yield* resolveBindingUpstream({ node, resource });
213
+
214
+ const oldBindingOutput = bindingOutputs[i];
215
+
216
+ if (
217
+ provider.postattach &&
218
+ (node.action === "attach" || node.action === "reattach")
219
+ ) {
220
+ const bindingOutput = yield* asEffect(
221
+ provider.postattach({
222
+ source: {
223
+ id: resourceId,
224
+ attr: upstreamAttr,
225
+ props: upstreamNode.resource.props,
226
+ },
227
+ props: node.binding.props,
228
+ attr: oldBindingOutput,
229
+ target,
230
+ } as const),
231
+ );
232
+ return {
233
+ ...oldBindingOutput,
234
+ ...bindingOutput,
235
+ };
236
+ }
237
+ return oldBindingOutput;
238
+ }),
239
+ ),
240
+ );
241
+
242
+ const apply: (node: Apply) => Effect.Effect<any, never, never> = (node) =>
243
+ Effect.gen(function* () {
244
+ const commit = <State extends ResourceState>(value: State) =>
245
+ state.set({
246
+ stack: app.name,
247
+ stage: app.stage,
248
+ resourceId: node.resource.id,
249
+ value,
250
+ });
251
+
252
+ const id = node.resource.id;
253
+ const resource = node.resource;
254
+
255
+ const scopedSession = {
256
+ ...session,
257
+ note: (note: string) =>
258
+ session.emit({
259
+ id,
260
+ kind: "annotate",
261
+ message: note,
262
+ }),
263
+ } satisfies ScopedPlanStatusSession;
264
+
265
+ return yield* (outputs[id] ??= yield* Effect.cached(
266
+ Effect.gen(function* () {
267
+ const report = (status: ApplyStatus) =>
238
268
  session.emit({
269
+ kind: "status-change",
239
270
  id,
240
- kind: "annotate",
241
- message: note,
242
- }),
243
- } satisfies ScopedPlanStatusSession;
244
-
245
- return yield* (outputs[id] ??= yield* Effect.cached(
246
- Effect.gen(function* () {
247
- const report = (status: ApplyStatus) =>
248
- emit({
249
- kind: "status-change",
271
+ type: node.resource.type,
272
+ status,
273
+ });
274
+
275
+ if (node.action === "noop") {
276
+ return node.state.attr;
277
+ }
278
+
279
+ // resolve upstream dependencies before committing any changes to state
280
+ const upstream = Object.fromEntries(
281
+ yield* Effect.all(
282
+ Object.entries(Output.resolveUpstream(node.props)).map(([id]) =>
283
+ resolveUpstream(id).pipe(
284
+ Effect.map(({ upstreamAttr }) => [id, upstreamAttr]),
285
+ ),
286
+ ),
287
+ ),
288
+ );
289
+
290
+ const instanceId = yield* Effect.gen(function* () {
291
+ if (node.action === "create" && !node.state?.instanceId) {
292
+ const instanceId = yield* generateInstanceId();
293
+ yield* commit<CreatingResourceState>({
294
+ status: "creating",
295
+ instanceId,
296
+ logicalId: id,
297
+ downstream: node.downstream,
298
+ props: node.props,
299
+ providerVersion: node.provider.version ?? 0,
300
+ resourceType: node.resource.type,
301
+ bindings: node.bindings,
302
+ });
303
+ return instanceId;
304
+ } else if (node.action === "replace") {
305
+ if (
306
+ node.state.status === "replaced" ||
307
+ node.state.status === "replacing"
308
+ ) {
309
+ // replace has already begun and we have the new instanceId, do not re-create it
310
+ return node.state.instanceId;
311
+ }
312
+ const instanceId = yield* generateInstanceId();
313
+ yield* commit<ReplacingResourceState>({
314
+ status: "replacing",
315
+ instanceId,
316
+ logicalId: id,
317
+ downstream: node.downstream,
318
+ props: node.props,
319
+ providerVersion: node.provider.version ?? 0,
320
+ resourceType: node.resource.type,
321
+ bindings: node.bindings,
322
+ old: node.state,
323
+ deleteFirst: node.deleteFirst,
324
+ });
325
+ return instanceId;
326
+ } else if (node.state?.instanceId) {
327
+ // we're in a create, update or delete state with a stable instanceId, use it
328
+ return node.state.instanceId;
329
+ }
330
+ // this should never happen
331
+ return yield* Effect.dieMessage(
332
+ `Instance ID not found for resource '${id}' and action is '${node.action}'`,
333
+ );
334
+ });
335
+
336
+ const apply = Effect.gen(function* () {
337
+ if (node.action === "create") {
338
+ const news = (yield* Output.evaluate(
339
+ node.props,
340
+ upstream,
341
+ )) as Record<string, any>;
342
+
343
+ const checkpoint = (attr: any) =>
344
+ commit<CreatingResourceState>({
345
+ status: "creating",
346
+ logicalId: id,
347
+ instanceId,
348
+ resourceType: node.resource.type,
349
+ props: news,
350
+ attr,
351
+ providerVersion: node.provider.version ?? 0,
352
+ bindings: node.bindings,
353
+ downstream: node.downstream,
354
+ });
355
+
356
+ if (!node.state) {
357
+ yield* checkpoint(undefined);
358
+ }
359
+
360
+ let attr: any;
361
+ if (
362
+ node.action === "create" &&
363
+ node.provider.precreate &&
364
+ // pre-create is only designed to ensure the resource exists, if we have state.attr, then it already exists and should be skipped
365
+ node.state?.attr === undefined
366
+ ) {
367
+ yield* report("pre-creating");
368
+
369
+ // stub the resource prior to resolving upstream resources or bindings if a stub is available
370
+ attr = yield* node.provider.precreate({
371
+ id,
372
+ news: node.props,
373
+ session: scopedSession,
374
+ instanceId,
375
+ });
376
+
377
+ yield* checkpoint(attr);
378
+ }
379
+
380
+ yield* report("attaching");
381
+
382
+ let bindingOutputs = yield* attachBindings({
383
+ resource,
384
+ bindings: node.bindings,
385
+ target: {
386
+ id,
387
+ props: news,
388
+ attr,
389
+ },
390
+ });
391
+
392
+ yield* report("creating");
393
+
394
+ attr = yield* node.provider.create({
250
395
  id,
251
- type: node.resource.type,
252
- status,
396
+ news,
397
+ instanceId,
398
+ bindings: bindingOutputs,
399
+ session: scopedSession,
253
400
  });
254
401
 
255
- const createOrUpdate = Effect.fn(function* ({
256
- node,
257
- attr,
258
- phase,
259
- }: {
260
- node: Create | Update;
261
- attr: any;
262
- phase: "create" | "update";
263
- }) {
402
+ yield* checkpoint(attr);
403
+
404
+ yield* report("post-attach");
405
+ bindingOutputs = yield* postAttachBindings({
406
+ resource,
407
+ bindings: node.bindings,
408
+ bindingOutputs,
409
+ target: {
410
+ id,
411
+ props: news,
412
+ attr,
413
+ },
414
+ });
415
+
416
+ yield* commit<CreatedResourceState>({
417
+ status: "created",
418
+ logicalId: id,
419
+ instanceId,
420
+ resourceType: node.resource.type,
421
+ props: news,
422
+ attr,
423
+ bindings: node.bindings.map((binding, i) => ({
424
+ ...binding,
425
+ attr: bindingOutputs[i],
426
+ })),
427
+ providerVersion: node.provider.version ?? 0,
428
+ downstream: node.downstream,
429
+ });
430
+
431
+ yield* report("created");
432
+
433
+ return attr;
434
+ } else if (node.action === "update") {
264
435
  const upstream = Object.fromEntries(
265
436
  yield* Effect.all(
266
- Object.entries(Output.resolveUpstream(node.news)).map(
437
+ Object.entries(Output.resolveUpstream(node.props)).map(
267
438
  ([id]) =>
268
439
  resolveUpstream(id).pipe(
269
440
  Effect.map(({ upstreamAttr }) => [id, upstreamAttr]),
@@ -271,9 +442,40 @@ export const applyPlan = <P extends IPlan>(plan: P) =>
271
442
  ),
272
443
  ),
273
444
  );
274
- const news = yield* Output.evaluate(node.news, upstream);
445
+ const news = (yield* Output.evaluate(
446
+ node.props,
447
+ upstream,
448
+ )) as Record<string, any>;
449
+
450
+ const checkpoint = (attr: any) => {
451
+ if (node.state.status === "replaced") {
452
+ return commit<ReplacedResourceState>({
453
+ ...node.state,
454
+ attr,
455
+ props: news,
456
+ });
457
+ } else {
458
+ return commit<UpdatingReourceState>({
459
+ status: "updating",
460
+ logicalId: id,
461
+ instanceId,
462
+ resourceType: node.resource.type,
463
+ props: news,
464
+ attr,
465
+ providerVersion: node.provider.version ?? 0,
466
+ bindings: node.bindings,
467
+ downstream: node.downstream,
468
+ old:
469
+ node.state.status === "updating"
470
+ ? node.state.old
471
+ : node.state,
472
+ });
473
+ }
474
+ };
475
+
476
+ yield* checkpoint(node.state.attr);
275
477
 
276
- yield* report(phase === "create" ? "creating" : "updating");
478
+ yield* report("attaching");
277
479
 
278
480
  let bindingOutputs = yield* attachBindings({
279
481
  resource,
@@ -281,30 +483,30 @@ export const applyPlan = <P extends IPlan>(plan: P) =>
281
483
  target: {
282
484
  id,
283
485
  props: news,
284
- attr,
486
+ attr: node.state.attr,
285
487
  },
286
488
  });
287
489
 
288
- const output: any = yield* (
289
- phase === "create" ? node.provider.create : node.provider.update
290
- )({
490
+ yield* report("updating");
491
+
492
+ const attr = yield* node.provider.update({
291
493
  id,
292
494
  news,
495
+ instanceId,
293
496
  bindings: bindingOutputs,
294
497
  session: scopedSession,
295
- ...(node.action === "update"
296
- ? {
297
- output: node.output,
298
- olds: node.olds,
299
- }
300
- : {}),
301
- }).pipe(
302
- // TODO(sam): partial checkpoints
303
- // checkpoint,
304
- Effect.tap(() =>
305
- report(phase === "create" ? "created" : "updated"),
306
- ),
307
- );
498
+ olds:
499
+ node.state.status === "created" ||
500
+ node.state.status === "updated" ||
501
+ node.state.status === "replaced"
502
+ ? node.state.props
503
+ : node.state.old.props,
504
+ output: node.state.attr,
505
+ });
506
+
507
+ yield* checkpoint(attr);
508
+
509
+ yield* report("post-attach");
308
510
 
309
511
  bindingOutputs = yield* postAttachBindings({
310
512
  resource,
@@ -317,148 +519,340 @@ export const applyPlan = <P extends IPlan>(plan: P) =>
317
519
  },
318
520
  });
319
521
 
320
- yield* saveState({
321
- news,
322
- output,
323
- bindings: node.bindings.map((binding, i) => ({
324
- ...binding,
325
- attr: bindingOutputs[i],
326
- })),
327
- });
522
+ if (node.state.status === "replaced") {
523
+ yield* commit<ReplacedResourceState>({
524
+ ...node.state,
525
+ attr,
526
+ props: news,
527
+ });
528
+ } else {
529
+ yield* commit<UpdatedResourceState>({
530
+ status: "updated",
531
+ logicalId: id,
532
+ instanceId,
533
+ resourceType: node.resource.type,
534
+ props: news,
535
+ attr,
536
+ bindings: node.bindings.map((binding, i) => ({
537
+ ...binding,
538
+ attr: bindingOutputs[i],
539
+ })),
540
+ providerVersion: node.provider.version ?? 0,
541
+ downstream: node.downstream,
542
+ });
543
+ }
328
544
 
329
- return output;
330
- });
545
+ yield* report("updated");
546
+
547
+ return attr;
548
+ } else if (node.action === "replace") {
549
+ if (node.state.status === "replaced") {
550
+ // we've already created the replacement resource, return the output
551
+ return node.state.attr;
552
+ }
553
+ let state: ReplacingResourceState;
554
+ if (node.state.status !== "replacing") {
555
+ yield* commit<ReplacingResourceState>(
556
+ (state = {
557
+ status: "replacing",
558
+ logicalId: id,
559
+ instanceId,
560
+ resourceType: node.resource.type,
561
+ props: node.props,
562
+ attr: node.state.attr,
563
+ providerVersion: node.provider.version ?? 0,
564
+ deleteFirst: node.deleteFirst,
565
+ old: node.state,
566
+ downstream: node.downstream,
567
+ }),
568
+ );
569
+ } else {
570
+ state = node.state;
571
+ }
572
+ const upstream = Object.fromEntries(
573
+ yield* Effect.all(
574
+ Object.entries(Output.resolveUpstream(node.props)).map(
575
+ ([id]) =>
576
+ resolveUpstream(id).pipe(
577
+ Effect.map(({ upstreamAttr }) => [id, upstreamAttr]),
578
+ ),
579
+ ),
580
+ ),
581
+ );
582
+ const news = (yield* Output.evaluate(
583
+ node.props,
584
+ upstream,
585
+ )) as Record<string, any>;
586
+
587
+ const checkpoint = <
588
+ S extends ReplacingResourceState | ReplacedResourceState,
589
+ >({
590
+ status,
591
+ attr,
592
+ bindings,
593
+ }: Pick<S, "status" | "attr" | "bindings">) =>
594
+ commit<S>({
595
+ status,
596
+ logicalId: id,
597
+ instanceId,
598
+ resourceType: node.resource.type,
599
+ props: news,
600
+ attr,
601
+ providerVersion: node.provider.version ?? 0,
602
+ bindings: bindings ?? node.bindings,
603
+ downstream: node.downstream,
604
+ old: state.old,
605
+ deleteFirst: node.deleteFirst,
606
+ } as S);
331
607
 
332
- if (node.action === "noop") {
333
- return (yield* state.get({
334
- stack: app.name,
335
- stage: app.stage,
336
- resourceId: id,
337
- }))?.output;
338
- } else if (node.action === "create") {
339
608
  let attr: any;
340
- if (node.provider.precreate) {
341
- yield* Effect.logDebug("precreate", id);
609
+ if (
610
+ node.provider.precreate &&
611
+ // pre-create is only designed to ensure the resource exists, if we have state.attr, then it already exists and should be skipped
612
+ node.state?.attr === undefined
613
+ ) {
614
+ yield* report("pre-creating");
615
+
342
616
  // stub the resource prior to resolving upstream resources or bindings if a stub is available
343
617
  attr = yield* node.provider.precreate({
344
618
  id,
345
- news: node.news,
619
+ news: node.props,
346
620
  session: scopedSession,
621
+ instanceId,
622
+ });
623
+
624
+ yield* checkpoint({
625
+ status: "replacing",
626
+ attr,
347
627
  });
348
628
  }
349
629
 
350
- yield* Effect.logDebug("create", id);
351
- return yield* createOrUpdate({
352
- node,
353
- attr,
354
- phase: "create",
630
+ yield* report("attaching");
631
+
632
+ let bindingOutputs = yield* attachBindings({
633
+ resource,
634
+ bindings: node.bindings,
635
+ target: {
636
+ id,
637
+ props: news,
638
+ attr,
639
+ },
355
640
  });
356
- } else if (node.action === "update") {
357
- yield* Effect.logDebug("update", id);
358
- return yield* createOrUpdate({
359
- node,
360
- attr: node.attributes,
361
- phase: "update",
641
+
642
+ yield* report("creating replacement");
643
+
644
+ attr = yield* node.provider.create({
645
+ id,
646
+ news,
647
+ instanceId,
648
+ bindings: bindingOutputs,
649
+ session: scopedSession,
362
650
  });
363
- } else if (node.action === "delete") {
364
- yield* Effect.logDebug("delete", id);
365
- yield* Effect.all(
366
- node.downstream.map((dep) =>
367
- dep in plan.resources
368
- ? apply(plan.resources[dep] as any)
369
- : Effect.void,
370
- ),
371
- );
372
- yield* report("deleting");
373
651
 
374
- return yield* node.provider
375
- .delete({
376
- id,
377
- olds: node.olds,
378
- output: node.output,
379
- session: scopedSession,
380
- bindings: [],
381
- })
382
- .pipe(
383
- Effect.flatMap(() =>
384
- state.delete({
385
- stack: app.name,
386
- stage: app.stage,
387
- resourceId: id,
388
- }),
389
- ),
390
- Effect.tap(() => report("deleted")),
391
- );
392
- } else if (node.action === "replace") {
393
- const destroy = Effect.gen(function* () {
394
- yield* report("deleting");
395
- return yield* node.provider.delete({
652
+ yield* checkpoint({
653
+ status: "replacing",
654
+ attr,
655
+ });
656
+
657
+ yield* report("post-attach");
658
+
659
+ bindingOutputs = yield* postAttachBindings({
660
+ resource,
661
+ bindings: node.bindings,
662
+ bindingOutputs,
663
+ target: {
396
664
  id,
397
- olds: node.olds,
398
- output: node.output,
399
- session: scopedSession,
400
- bindings: [],
401
- });
665
+ props: news,
666
+ attr,
667
+ },
402
668
  });
403
- const create = Effect.gen(function* () {
404
- yield* report("creating");
405
-
406
- // TODO(sam): delete and create will conflict here, we need to extend the state store for replace
407
- return yield* node.provider
408
- .create({
409
- id,
410
- news: node.news,
411
- // TODO(sam): these need to only include attach actions
412
- bindings: yield* attachBindings({
413
- resource,
414
- bindings: node.bindings,
415
- target: {
416
- id,
417
- // TODO(sam): resolve the news
418
- props: node.news,
419
- attr: node.attributes,
420
- },
421
- }),
422
- session: scopedSession,
423
- })
424
- .pipe(
425
- Effect.tap((output) =>
426
- saveState({ news: node.news, output }),
427
- ),
428
- );
669
+
670
+ yield* checkpoint<ReplacedResourceState>({
671
+ status: "replaced",
672
+ attr,
673
+ bindings: node.bindings.map((binding, i) => ({
674
+ ...binding,
675
+ attr: bindingOutputs[i],
676
+ })),
429
677
  });
430
- if (!node.deleteFirst) {
431
- yield* destroy;
432
- return outputs;
433
- } else {
434
- yield* destroy;
435
- return yield* create;
436
- }
678
+
679
+ yield* report("created");
680
+ return attr;
437
681
  }
438
- }),
439
- ));
440
- }) as Effect.Effect<any, never, never>;
441
-
442
- const nodes = [
443
- ...Object.entries(plan.resources),
444
- ...Object.entries(plan.deletions),
445
- ];
446
-
447
- const resources: any = Object.fromEntries(
448
- yield* Effect.all(
449
- nodes.map(
450
- Effect.fn(function* ([id, node]) {
451
- return [id, yield* apply(node as CRUD)];
452
- }),
453
- ),
682
+ // @ts-expect-error
683
+ return yield* Effect.dieMessage(`Unknown action: ${node.action}`);
684
+ });
685
+
686
+ // provide the resource-specific context (InstanceId, etc.)
687
+ return yield* apply.pipe(
688
+ Effect.provide(Layer.succeed(InstanceId, instanceId)),
689
+ );
690
+ }),
691
+ ));
692
+ }) as Effect.Effect<any, never, never>;
693
+
694
+ return Object.fromEntries(
695
+ yield* Effect.all(
696
+ Object.entries(plan.resources).map(
697
+ Effect.fn(function* ([id, node]) {
698
+ return [id, yield* apply(node)];
699
+ }),
454
700
  ),
455
- );
456
- yield* done();
457
- if (Object.keys(plan.resources).length === 0) {
458
- // all resources are deleted, return undefined
459
- return undefined;
460
- }
461
- return resources as {
462
- [k in keyof AppliedPlan<P>]: AppliedPlan<P>[k];
463
- };
701
+ ),
702
+ );
703
+ });
704
+
705
+ const collectGarbage = Effect.fnUntraced(function* (
706
+ plan: IPlan,
707
+ session: PlanStatusSession,
708
+ ) {
709
+ const state = yield* State;
710
+ const app = yield* App;
711
+
712
+ const deletions: {
713
+ [logicalId in string]: Effect.Effect<void, StateStoreError, never>;
714
+ } = {};
715
+
716
+ // delete all replaced resources
717
+ const replacedResources = yield* state.getReplacedResources({
718
+ stack: app.name,
719
+ stage: app.stage,
464
720
  });
721
+
722
+ const deletionGraph = {
723
+ ...plan.deletions,
724
+ ...Object.fromEntries(
725
+ replacedResources.map((replaced) => [replaced.logicalId, replaced]),
726
+ ),
727
+ };
728
+
729
+ const deleteResource: (
730
+ node: Delete<Resource> | ReplacedResourceState,
731
+ ) => Effect.Effect<void, StateStoreError, never> = Effect.fnUntraced(
732
+ function* (node: Delete<Resource> | ReplacedResourceState) {
733
+ const isDeleteNode = (
734
+ node: Delete<Resource> | ReplacedResourceState,
735
+ ): node is Delete<Resource> => "action" in node;
736
+
737
+ const {
738
+ logicalId,
739
+ resourceType,
740
+ instanceId,
741
+ downstream,
742
+ props,
743
+ attr,
744
+ provider,
745
+ } = isDeleteNode(node)
746
+ ? {
747
+ logicalId: node.resource.id,
748
+ resourceType: node.resource.type,
749
+ instanceId: node.state.instanceId,
750
+ downstream: node.downstream,
751
+ props: node.state.props,
752
+ attr: node.state.attr,
753
+ provider: node.provider,
754
+ }
755
+ : {
756
+ logicalId: node.logicalId,
757
+ resourceType: node.old.resourceType,
758
+ instanceId: node.old.instanceId,
759
+ downstream: node.old.downstream,
760
+ props: node.old.props,
761
+ attr: node.old.attr,
762
+ provider: yield* getProviderByType(node.old.resourceType),
763
+ };
764
+
765
+ const commit = <State extends ResourceState>(value: State) =>
766
+ state.set({
767
+ stack: app.name,
768
+ stage: app.stage,
769
+ resourceId: logicalId,
770
+ value,
771
+ });
772
+
773
+ const report = (status: ApplyStatus) =>
774
+ session.emit({
775
+ kind: "status-change",
776
+ id: logicalId,
777
+ type: resourceType,
778
+ status,
779
+ });
780
+
781
+ const scopedSession = {
782
+ ...session,
783
+ note: (note: string) =>
784
+ session.emit({
785
+ id: logicalId,
786
+ kind: "annotate",
787
+ message: note,
788
+ }),
789
+ } satisfies ScopedPlanStatusSession;
790
+
791
+ return yield* (deletions[logicalId] ??= yield* Effect.cached(
792
+ Effect.gen(function* () {
793
+ yield* Effect.all(
794
+ downstream.map((dep) =>
795
+ dep in deletionGraph
796
+ ? deleteResource(deletionGraph[dep] as Delete<Resource>)
797
+ : Effect.void,
798
+ ),
799
+ );
800
+
801
+ yield* report("deleting");
802
+
803
+ if (isDeleteNode(node)) {
804
+ yield* commit<DeletingResourceState>({
805
+ status: "deleting",
806
+ logicalId,
807
+ instanceId,
808
+ resourceType,
809
+ props,
810
+ attr,
811
+ downstream,
812
+ providerVersion: provider.version ?? 0,
813
+ bindings: node.bindings,
814
+ });
815
+ }
816
+
817
+ yield* provider.delete({
818
+ id: logicalId,
819
+ instanceId,
820
+ olds: props as never,
821
+ output: attr,
822
+ session: scopedSession,
823
+ bindings: [],
824
+ });
825
+
826
+ if (isDeleteNode(node)) {
827
+ // TODO(sam): should we commit a tombstone instead? and then clean up tombstones after all deletions are complete?
828
+ yield* state.delete({
829
+ stack: app.name,
830
+ stage: app.stage,
831
+ resourceId: logicalId,
832
+ });
833
+ yield* report("deleted");
834
+ } else {
835
+ yield* commit<CreatedResourceState>({
836
+ status: "created",
837
+ logicalId,
838
+ instanceId,
839
+ resourceType,
840
+ props: node.props,
841
+ attr: node.attr,
842
+ providerVersion: provider.version ?? 0,
843
+ downstream: node.downstream,
844
+ bindings: node.bindings,
845
+ });
846
+ yield* report("replaced");
847
+ }
848
+ }).pipe(Effect.provide(Layer.succeed(InstanceId, instanceId))),
849
+ ));
850
+ },
851
+ );
852
+
853
+ yield* Effect.all(
854
+ Object.values(deletionGraph)
855
+ .filter((node) => node !== undefined)
856
+ .map(deleteResource),
857
+ );
858
+ });