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