alchemy-effect 0.6.4 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Plan.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import * as Data from "effect/Data";
2
2
  import * as Effect from "effect/Effect";
3
3
  import { asEffect } from ".//Util/types.ts";
4
+ import {
5
+ Artifacts,
6
+ ArtifactStore,
7
+ createArtifactStore,
8
+ ensureArtifactStore,
9
+ makeScopedArtifacts,
10
+ } from "./Artifacts.ts";
11
+ import * as Option from "effect/Option";
4
12
  import {
5
13
  diffBindings,
6
14
  havePropsChanged,
@@ -144,648 +152,661 @@ export const make = <A>(
144
152
  stack: StackSpec<A>,
145
153
  ): Effect.Effect<Plan<A>, never, State> =>
146
154
  // @ts-expect-error
147
- Effect.gen(function* () {
148
- const state = yield* State;
149
-
150
- const resources = Object.values(stack.resources);
151
-
152
- // TODO(sam): rename terminology to Stack
153
- const stackName = stack.name;
154
- const stage = stack.stage;
155
-
156
- const resourceFqns = yield* state.list({
157
- stack: stackName,
158
- stage: stage,
159
- });
160
- const oldResources = yield* Effect.all(
161
- resourceFqns.map((fqn) =>
162
- state.get({ stack: stackName, stage: stage, fqn }),
163
- ),
164
- { concurrency: "unbounded" },
165
- );
155
+ ensureArtifactStore(
156
+ Effect.gen(function* () {
157
+ const state = yield* State;
158
+
159
+ const resources = Object.values(stack.resources);
160
+
161
+ // TODO(sam): rename terminology to Stack
162
+ const stackName = stack.name;
163
+ const stage = stack.stage;
164
+
165
+ const resourceFqns = yield* state.list({
166
+ stack: stackName,
167
+ stage: stage,
168
+ });
169
+ const oldResources = yield* Effect.all(
170
+ resourceFqns.map((fqn) =>
171
+ state.get({ stack: stackName, stage: stage, fqn }),
172
+ ),
173
+ { concurrency: "unbounded" },
174
+ );
175
+
176
+ const resolvedResources: Record<string, Effect.Effect<any>> = {};
177
+
178
+ const resolveResource = (
179
+ resourceExpr: Output.ResourceExpr<any, any>,
180
+ ): Effect.Effect<any> =>
181
+ Effect.gen(function* () {
182
+ // @ts-expect-error
183
+ return yield* (resolvedResources[resourceExpr.src.FQN] ??=
184
+ yield* Effect.cached(
185
+ Effect.gen(function* () {
186
+ const resource = resourceExpr.src;
187
+
188
+ const provider = yield* Provider(resource.Type);
189
+ const props = yield* resolveInput(resource.Props);
190
+ const oldState = yield* state.get({
191
+ stack: stackName,
192
+ stage: stage,
193
+ fqn: resource.FQN,
194
+ });
195
+
196
+ if (!oldState || oldState.status === "creating") {
197
+ return resourceExpr;
198
+ }
199
+
200
+ const oldProps =
201
+ oldState.status === "updating"
202
+ ? oldState.old.props
203
+ : oldState.props;
204
+
205
+ const oldBindings = oldState.bindings ?? [];
206
+ const newBindings = stack.bindings[resource.FQN] ?? [];
207
+
208
+ const diff = yield* provider.diff
209
+ ? provider
210
+ .diff({
211
+ id: resource.LogicalId,
212
+ olds: oldProps,
213
+ instanceId: oldState.instanceId,
214
+ news: props,
215
+ output: oldState.attr,
216
+ oldBindings,
217
+ newBindings,
218
+ })
219
+ .pipe(providePlanScope(resource.FQN, oldState.instanceId))
220
+ : Effect.succeed(undefined);
221
+
222
+ const stables: string[] = [
223
+ ...(provider.stables ?? []),
224
+ ...(diff?.stables ?? []),
225
+ ];
226
+
227
+ const withStables = (output: any) =>
228
+ stables.length > 0
229
+ ? new Output.ResourceExpr(
230
+ resourceExpr.src,
231
+ Object.fromEntries(
232
+ stables.map((stable) => [stable, output?.[stable]]),
233
+ ),
234
+ )
235
+ : // if there are no stable properties, treat every property as changed
236
+ resourceExpr;
237
+
238
+ if (diff == null) {
239
+ if (havePropsChanged(oldProps, props)) {
240
+ // the props have changed but the provider did not provide any hints as to what is stable
241
+ // so we must assume everything has changed
242
+ return withStables(oldState?.attr);
243
+ }
244
+ } else if (diff.action === "update") {
245
+ return withStables(oldState?.attr);
246
+ } else if (diff.action === "replace") {
247
+ return resourceExpr;
248
+ }
249
+ if (
250
+ oldState.status === "created" ||
251
+ oldState.status === "updated" ||
252
+ oldState.status === "replaced"
253
+ ) {
254
+ // we can safely return the attributes if we know they have stabilized
255
+ return oldState?.attr;
256
+ } else {
257
+ // we must assume the resource doesn't exist if it hasn't stabilized
258
+ return resourceExpr;
259
+ }
260
+ }),
261
+ ));
262
+ });
263
+
264
+ const resolveInput = (input: any): Effect.Effect<any> =>
265
+ Effect.gen(function* () {
266
+ if (!input) {
267
+ return input;
268
+ } else if (Output.isExpr(input)) {
269
+ return yield* resolveOutput(input);
270
+ } else if (Array.isArray(input)) {
271
+ return yield* Effect.all(input.map(resolveInput), {
272
+ concurrency: "unbounded",
273
+ });
274
+ } else if (isResource(input)) {
275
+ // Resource objects have dynamic properties (path, hash, etc.) that are
276
+ // created on-demand by a Proxy getter and aren't enumerable via Object.entries.
277
+ // Resolve the ResourceExpr to get the actual resource output, then continue
278
+ // resolving any nested outputs in the result.
279
+ const resourceExpr = Output.of(input);
280
+ const resolved = yield* resolveOutput(resourceExpr);
281
+ return yield* resolveInput(resolved);
282
+ } else if (typeof input === "object") {
283
+ return Object.fromEntries(
284
+ yield* Effect.all(
285
+ Object.entries(input).map(([key, value]) =>
286
+ resolveInput(value).pipe(Effect.map((value) => [key, value])),
287
+ ),
288
+ { concurrency: "unbounded" },
289
+ ),
290
+ );
291
+ }
292
+ return input;
293
+ });
294
+
295
+ const resolveOutput = (expr: Output.Expr<any>): Effect.Effect<any> =>
296
+ Effect.gen(function* () {
297
+ if (Output.isResourceExpr(expr)) {
298
+ return yield* resolveResource(expr);
299
+ } else if (Output.isPropExpr(expr)) {
300
+ const upstream = yield* resolveOutput(expr.expr);
301
+ return upstream?.[expr.identifier];
302
+ } else if (Output.isApplyExpr(expr)) {
303
+ const upstream = yield* resolveOutput(expr.expr);
304
+ return Output.hasOutputs(upstream) ? expr : expr.f(upstream);
305
+ } else if (Output.isEffectExpr(expr)) {
306
+ const upstream = yield* resolveOutput(expr.expr);
307
+ return Output.hasOutputs(upstream) ? expr : yield* expr.f(upstream);
308
+ } else if (Output.isAllExpr(expr)) {
309
+ return yield* Effect.all(expr.outs.map(resolveOutput), {
310
+ concurrency: "unbounded",
311
+ });
312
+ } else if (Output.isLiteralExpr(expr)) {
313
+ return expr.value;
314
+ }
315
+ return yield* Effect.die(new Error("Not implemented yet"));
316
+ });
317
+
318
+ // map of resource FQN -> its downstream dependencies (resources that depend on it)
319
+ const oldDownstreamDependencies: {
320
+ [fqn: string]: string[];
321
+ } = Object.fromEntries(
322
+ oldResources
323
+ .filter((resource) => !!resource)
324
+ .map((resource) => [resource.fqn, resource.downstream]),
325
+ );
326
+
327
+ // Build a set of FQNs for the new resources to detect orphans
328
+ const newResourceFqns = new Set(resources.map((r) => r.FQN));
329
+
330
+ // Map FQN -> list of upstream FQNs (resources this one depends on via props)
331
+ const newUpstreamDependencies: {
332
+ [fqn: string]: string[];
333
+ } = Object.fromEntries(
334
+ resources.map((resource) => [
335
+ resource.FQN,
336
+ Object.values(Output.upstreamAny(resource.Props)).map((r) => r.FQN),
337
+ ]),
338
+ );
339
+
340
+ // Map FQN -> list of upstream FQNs from bindings
341
+ const bindingUpstreamDependencies: {
342
+ [fqn: string]: string[];
343
+ } = Object.fromEntries(
344
+ resources.map((resource) => [
345
+ resource.FQN,
346
+ Object.values(
347
+ Output.upstreamAny(stack.bindings[resource.FQN] ?? []),
348
+ ).map((r) => r.FQN),
349
+ ]),
350
+ );
166
351
 
167
- const resolvedResources: Record<string, Effect.Effect<any>> = {};
352
+ // Combined prop + binding upstream for the desired graph, including
353
+ // references to resources outside the current graph so delete validation can
354
+ // tell whether any surviving resource still points at an orphan.
355
+ const rawUpstreamDependencies: {
356
+ [fqn: string]: string[];
357
+ } = Object.fromEntries(
358
+ resources.map((resource) => {
359
+ const fqn = resource.FQN;
360
+ const propDeps = newUpstreamDependencies[fqn] ?? [];
361
+ const bindDeps = bindingUpstreamDependencies[fqn] ?? [];
362
+ return [fqn, [...new Set([...propDeps, ...bindDeps])]];
363
+ }),
364
+ );
168
365
 
169
- const resolveResource = (
170
- resourceExpr: Output.ResourceExpr<any, any>,
171
- ): Effect.Effect<any> =>
172
- Effect.gen(function* () {
173
- // @ts-expect-error
174
- return yield* (resolvedResources[resourceExpr.src.FQN] ??=
175
- yield* Effect.cached(
176
- Effect.gen(function* () {
177
- const resource = resourceExpr.src;
366
+ // Combined prop + binding upstream, filtered to resources in this graph for
367
+ // scheduling and cycle detection.
368
+ const allUpstreamDependencies: {
369
+ [fqn: string]: string[];
370
+ } = Object.fromEntries(
371
+ resources.map((resource) => {
372
+ const fqn = resource.FQN;
373
+ const deps = rawUpstreamDependencies[fqn] ?? [];
374
+ return [fqn, deps.filter((dep) => newResourceFqns.has(dep))];
375
+ }),
376
+ );
178
377
 
378
+ // Map FQN -> list of downstream FQNs (resources that depend on this one)
379
+ const newDownstreamDependencies: {
380
+ [fqn: string]: string[];
381
+ } = Object.fromEntries(
382
+ resources.map((resource) => [
383
+ resource.FQN,
384
+ Object.entries(newUpstreamDependencies)
385
+ .filter(([_, upstream]) => upstream.includes(resource.FQN))
386
+ .map(([depFqn]) => depFqn),
387
+ ]),
388
+ );
389
+
390
+ const resourceGraph = Object.fromEntries(
391
+ (yield* Effect.all(
392
+ resources.map(
393
+ Effect.fn(function* (resource) {
179
394
  const provider = yield* Provider(resource.Type);
180
- const props = yield* resolveInput(resource.Props);
395
+ const id = resource.LogicalId;
396
+ const fqn = resource.FQN;
397
+ const news = yield* resolveInput(resource.Props);
398
+ const downstream = newDownstreamDependencies[fqn] ?? [];
399
+
400
+ const newBindings: ResourceBinding[] = yield* resolveInput(
401
+ stack.bindings[fqn] ?? [],
402
+ );
181
403
  const oldState = yield* state.get({
182
404
  stack: stackName,
183
405
  stage: stage,
184
- fqn: resource.FQN,
406
+ fqn,
185
407
  });
186
-
187
- if (!oldState || oldState.status === "creating") {
188
- return resourceExpr;
189
- }
190
-
191
- const oldProps =
192
- oldState.status === "updating"
193
- ? oldState.old.props
194
- : oldState.props;
195
-
196
- const oldBindings = oldState.bindings ?? [];
197
- const newBindings = stack.bindings[resource.FQN] ?? [];
198
-
199
- const diff = yield* provider.diff
200
- ? provider
201
- .diff({
202
- id: resource.LogicalId,
203
- olds: oldProps,
408
+ const oldBindings = oldState?.bindings ?? [];
409
+ const bindingDiffs = diffBindings(oldBindings, newBindings);
410
+
411
+ const Node = <T extends Apply>(
412
+ node: Omit<
413
+ T,
414
+ "provider" | "resource" | "bindings" | "downstream"
415
+ >,
416
+ ) =>
417
+ ({
418
+ ...node,
419
+ provider,
420
+ resource,
421
+ bindings: bindingDiffs,
422
+ downstream,
423
+ }) as any as T;
424
+
425
+ // Plan against the persisted state we have, not the ideal final state we
426
+ // hoped to reach last time. Recovery is expressed by mapping each
427
+ // intermediate state back onto a fresh CRUD action.
428
+ if (oldState === undefined) {
429
+ return Node<Create>({
430
+ action: "create",
431
+ props: news,
432
+ state: oldState,
433
+ });
434
+ } else if (
435
+ oldState.status === "creating" &&
436
+ oldState.attr === undefined
437
+ ) {
438
+ // A create may have succeeded before state persistence failed. If the
439
+ // provider can recover an attribute snapshot, keep driving the same
440
+ // create instead of starting over blindly.
441
+ if (provider.read) {
442
+ const attr = yield* provider
443
+ .read({
444
+ id,
204
445
  instanceId: oldState.instanceId,
205
- news: props,
446
+ olds: oldState.props,
206
447
  output: oldState.attr,
207
- oldBindings,
208
- newBindings,
209
448
  })
210
- .pipe(
211
- Effect.provideService(InstanceId, oldState.instanceId),
212
- )
213
- : Effect.succeed(undefined);
214
-
215
- const stables: string[] = [
216
- ...(provider.stables ?? []),
217
- ...(diff?.stables ?? []),
218
- ];
219
-
220
- const withStables = (output: any) =>
221
- stables.length > 0
222
- ? new Output.ResourceExpr(
223
- resourceExpr.src,
224
- Object.fromEntries(
225
- stables.map((stable) => [stable, output?.[stable]]),
226
- ),
227
- )
228
- : // if there are no stable properties, treat every property as changed
229
- resourceExpr;
230
-
231
- if (diff == null) {
232
- if (havePropsChanged(oldProps, props)) {
233
- // the props have changed but the provider did not provide any hints as to what is stable
234
- // so we must assume everything has changed
235
- return withStables(oldState?.attr);
449
+ .pipe(providePlanScope(fqn, oldState.instanceId));
450
+ if (attr) {
451
+ return Node<Create>({
452
+ action: "create",
453
+ props: news,
454
+ state: { ...oldState, attr },
455
+ });
456
+ }
236
457
  }
237
- } else if (diff.action === "update") {
238
- return withStables(oldState?.attr);
239
- } else if (diff.action === "replace") {
240
- return resourceExpr;
241
458
  }
242
- if (
243
- oldState.status === "created" ||
244
- oldState.status === "updated" ||
245
- oldState.status === "replaced"
246
- ) {
247
- // we can safely return the attributes if we know they have stabilized
248
- return oldState?.attr;
249
- } else {
250
- // we must assume the resource doesn't exist if it hasn't stabilized
251
- return resourceExpr;
252
- }
253
- }),
254
- ));
255
- });
256
459
 
257
- const resolveInput = (input: any): Effect.Effect<any> =>
258
- Effect.gen(function* () {
259
- if (!input) {
260
- return input;
261
- } else if (Output.isExpr(input)) {
262
- return yield* resolveOutput(input);
263
- } else if (Array.isArray(input)) {
264
- return yield* Effect.all(input.map(resolveInput), {
265
- concurrency: "unbounded",
266
- });
267
- } else if (isResource(input)) {
268
- // Resource objects have dynamic properties (path, hash, etc.) that are
269
- // created on-demand by a Proxy getter and aren't enumerable via Object.entries.
270
- // Resolve the ResourceExpr to get the actual resource output, then continue
271
- // resolving any nested outputs in the result.
272
- const resourceExpr = Output.of(input);
273
- const resolved = yield* resolveOutput(resourceExpr);
274
- return yield* resolveInput(resolved);
275
- } else if (typeof input === "object") {
276
- return Object.fromEntries(
277
- yield* Effect.all(
278
- Object.entries(input).map(([key, value]) =>
279
- resolveInput(value).pipe(Effect.map((value) => [key, value])),
280
- ),
281
- { concurrency: "unbounded" },
282
- ),
283
- );
284
- }
285
- return input;
286
- });
460
+ // Diff against whatever props represent the best-known current attempt.
461
+ // For replacement recovery that means the top-level replacement props,
462
+ // not the older generations stored under `old`.
463
+ const oldProps = oldState.props;
287
464
 
288
- const resolveOutput = (expr: Output.Expr<any>): Effect.Effect<any> =>
289
- Effect.gen(function* () {
290
- if (Output.isResourceExpr(expr)) {
291
- return yield* resolveResource(expr);
292
- } else if (Output.isPropExpr(expr)) {
293
- const upstream = yield* resolveOutput(expr.expr);
294
- return upstream?.[expr.identifier];
295
- } else if (Output.isApplyExpr(expr)) {
296
- const upstream = yield* resolveOutput(expr.expr);
297
- return Output.hasOutputs(upstream) ? expr : expr.f(upstream);
298
- } else if (Output.isEffectExpr(expr)) {
299
- const upstream = yield* resolveOutput(expr.expr);
300
- return Output.hasOutputs(upstream) ? expr : yield* expr.f(upstream);
301
- } else if (Output.isAllExpr(expr)) {
302
- return yield* Effect.all(expr.outs.map(resolveOutput), {
303
- concurrency: "unbounded",
304
- });
305
- } else if (Output.isLiteralExpr(expr)) {
306
- return expr.value;
307
- }
308
- return yield* Effect.die(new Error("Not implemented yet"));
309
- });
310
-
311
- // map of resource FQN -> its downstream dependencies (resources that depend on it)
312
- const oldDownstreamDependencies: {
313
- [fqn: string]: string[];
314
- } = Object.fromEntries(
315
- oldResources
316
- .filter((resource) => !!resource)
317
- .map((resource) => [resource.fqn, resource.downstream]),
318
- );
319
-
320
- // Build a set of FQNs for the new resources to detect orphans
321
- const newResourceFqns = new Set(resources.map((r) => r.FQN));
322
-
323
- // Map FQN -> list of upstream FQNs (resources this one depends on via props)
324
- const newUpstreamDependencies: {
325
- [fqn: string]: string[];
326
- } = Object.fromEntries(
327
- resources.map((resource) => [
328
- resource.FQN,
329
- Object.values(Output.upstreamAny(resource.Props)).map((r) => r.FQN),
330
- ]),
331
- );
332
-
333
- // Map FQN -> list of upstream FQNs from bindings
334
- const bindingUpstreamDependencies: {
335
- [fqn: string]: string[];
336
- } = Object.fromEntries(
337
- resources.map((resource) => [
338
- resource.FQN,
339
- Object.values(
340
- Output.upstreamAny(stack.bindings[resource.FQN] ?? []),
341
- ).map((r) => r.FQN),
342
- ]),
343
- );
344
-
345
- // Combined prop + binding upstream for the desired graph, including
346
- // references to resources outside the current graph so delete validation can
347
- // tell whether any surviving resource still points at an orphan.
348
- const rawUpstreamDependencies: {
349
- [fqn: string]: string[];
350
- } = Object.fromEntries(
351
- resources.map((resource) => {
352
- const fqn = resource.FQN;
353
- const propDeps = newUpstreamDependencies[fqn] ?? [];
354
- const bindDeps = bindingUpstreamDependencies[fqn] ?? [];
355
- return [fqn, [...new Set([...propDeps, ...bindDeps])]];
356
- }),
357
- );
358
-
359
- // Combined prop + binding upstream, filtered to resources in this graph for
360
- // scheduling and cycle detection.
361
- const allUpstreamDependencies: {
362
- [fqn: string]: string[];
363
- } = Object.fromEntries(
364
- resources.map((resource) => {
365
- const fqn = resource.FQN;
366
- const deps = rawUpstreamDependencies[fqn] ?? [];
367
- return [fqn, deps.filter((dep) => newResourceFqns.has(dep))];
368
- }),
369
- );
370
-
371
- // Map FQN -> list of downstream FQNs (resources that depend on this one)
372
- const newDownstreamDependencies: {
373
- [fqn: string]: string[];
374
- } = Object.fromEntries(
375
- resources.map((resource) => [
376
- resource.FQN,
377
- Object.entries(newUpstreamDependencies)
378
- .filter(([_, upstream]) => upstream.includes(resource.FQN))
379
- .map(([depFqn]) => depFqn),
380
- ]),
381
- );
382
-
383
- const resourceGraph = Object.fromEntries(
384
- (yield* Effect.all(
385
- resources.map(
386
- Effect.fn(function* (resource) {
387
- const provider = yield* Provider(resource.Type);
388
- const id = resource.LogicalId;
389
- const fqn = resource.FQN;
390
- const news = yield* resolveInput(resource.Props);
391
- const downstream = newDownstreamDependencies[fqn] ?? [];
392
-
393
- const newBindings: ResourceBinding[] = yield* resolveInput(
394
- stack.bindings[fqn] ?? [],
395
- );
396
- const oldState = yield* state.get({
397
- stack: stackName,
398
- stage: stage,
399
- fqn,
400
- });
401
- const oldBindings = oldState?.bindings ?? [];
402
- const bindingDiffs = diffBindings(oldBindings, newBindings);
403
-
404
- const Node = <T extends Apply>(
405
- node: Omit<
406
- T,
407
- "provider" | "resource" | "bindings" | "downstream"
408
- >,
409
- ) =>
410
- ({
411
- ...node,
412
- provider,
413
- resource,
414
- bindings: bindingDiffs,
415
- downstream,
416
- }) as any as T;
417
-
418
- // Plan against the persisted state we have, not the ideal final state we
419
- // hoped to reach last time. Recovery is expressed by mapping each
420
- // intermediate state back onto a fresh CRUD action.
421
- if (oldState === undefined) {
422
- return Node<Create>({
423
- action: "create",
424
- props: news,
425
- state: oldState,
426
- });
427
- } else if (
428
- oldState.status === "creating" &&
429
- oldState.attr === undefined
430
- ) {
431
- // A create may have succeeded before state persistence failed. If the
432
- // provider can recover an attribute snapshot, keep driving the same
433
- // create instead of starting over blindly.
434
- if (provider.read) {
435
- const attr = yield* provider
436
- .read({
465
+ const diff = yield* asEffect(
466
+ provider
467
+ ?.diff?.({
437
468
  id,
469
+ olds: oldProps,
438
470
  instanceId: oldState.instanceId,
439
- olds: oldState.props,
440
471
  output: oldState.attr,
472
+ news,
473
+ oldBindings,
474
+ newBindings,
441
475
  })
442
- .pipe(Effect.provideService(InstanceId, oldState.instanceId));
443
- if (attr) {
476
+ .pipe(providePlanScope(fqn, oldState.instanceId)),
477
+ ).pipe(
478
+ Effect.map(
479
+ (diff) =>
480
+ diff ??
481
+ ({
482
+ action:
483
+ havePropsChanged(oldProps, news) ||
484
+ bindingDiffs.some((b) => b.action !== "noop")
485
+ ? "update"
486
+ : "noop",
487
+ } as UpdateDiff | NoopDiff),
488
+ ),
489
+ );
490
+
491
+ if (oldState.status === "creating") {
492
+ if (diff.action === "noop") {
493
+ // we're in the creating state and props are un-changed
494
+ // let's just continue where we left off
444
495
  return Node<Create>({
445
496
  action: "create",
446
497
  props: news,
447
- state: { ...oldState, attr },
498
+ state: oldState,
499
+ });
500
+ } else if (diff.action === "update") {
501
+ // props have changed in a way that is updatable
502
+ // again, just continue with the create
503
+ // TODO(sam): should we maybe try an update instead?
504
+ return Node<Create>({
505
+ action: "create",
506
+ props: news,
507
+ state: oldState,
508
+ });
509
+ } else {
510
+ // props have changed in an incompatible way
511
+ // because it's possible that an un-updatable resource has already been created
512
+ // we must use a replace step to create a new one and delete the potential old one
513
+ return Node<Replace>({
514
+ action: "replace",
515
+ props: news,
516
+ deleteFirst: diff.deleteFirst ?? false,
517
+ state: oldState,
448
518
  });
449
519
  }
450
- }
451
- }
452
-
453
- // Diff against whatever props represent the best-known current attempt.
454
- // For replacement recovery that means the top-level replacement props,
455
- // not the older generations stored under `old`.
456
- const oldProps = oldState.props;
457
-
458
- const diff = yield* asEffect(
459
- provider
460
- ?.diff?.({
461
- id,
462
- olds: oldProps,
463
- instanceId: oldState.instanceId,
464
- output: oldState.attr,
465
- news,
466
- oldBindings,
467
- newBindings,
468
- })
469
- .pipe(Effect.provideService(InstanceId, oldState.instanceId)),
470
- ).pipe(
471
- Effect.map(
472
- (diff) =>
473
- diff ??
474
- ({
475
- action:
476
- havePropsChanged(oldProps, news) ||
477
- bindingDiffs.some((b) => b.action !== "noop")
478
- ? "update"
479
- : "noop",
480
- } as UpdateDiff | NoopDiff),
481
- ),
482
- );
483
-
484
- if (oldState.status === "creating") {
485
- if (diff.action === "noop") {
486
- // we're in the creating state and props are un-changed
487
- // let's just continue where we left off
520
+ } else if (oldState.status === "updating") {
521
+ // Updating already targets the live resource, so noop/update both mean
522
+ // "finish the interrupted update". Only a replace diff escalates it
523
+ // into a fresh replacement.
524
+ if (diff.action === "update" || diff.action === "noop") {
525
+ // we can continue where we left off
526
+ return Node<Update>({
527
+ action: "update",
528
+ props: news,
529
+ state: oldState,
530
+ });
531
+ } else {
532
+ // we started to update a resource but now believe we should replace it
533
+ return Node<Replace>({
534
+ action: "replace",
535
+ deleteFirst: diff.deleteFirst ?? false,
536
+ props: news,
537
+ // TODO(sam): can Apply handle replacements when the oldState is UpdatingResourceState?
538
+ // -> or should we do a provider.read to try and reconcile back to UpdatedResourceState?
539
+ state: oldState,
540
+ });
541
+ }
542
+ } else if (oldState.status === "replacing") {
543
+ // The replacement candidate is still being created. Noop/update keep
544
+ // driving the same generation; replace means that candidate itself is
545
+ // now obsolete and must be wrapped in a new outer generation.
546
+ if (diff.action === "noop") {
547
+ // this is the stable case - noop means just continue with the replacement
548
+ return Node<Replace>({
549
+ action: "replace",
550
+ deleteFirst: oldState.deleteFirst,
551
+ props: news,
552
+ state: oldState,
553
+ });
554
+ } else if (diff.action === "update") {
555
+ // potential problem here - the props have changed since we tried to replace,
556
+ // but not enough to trigger another replacement. the resource provider should
557
+ // be designed as idempotent to converge to the right state when creating the new resource
558
+ // the newly generated instanceId is intended to assist with this
559
+ return Node<Replace>({
560
+ action: "replace",
561
+ deleteFirst: oldState.deleteFirst,
562
+ props: news,
563
+ state: oldState,
564
+ });
565
+ } else {
566
+ // The in-flight replacement candidate itself now needs replacement.
567
+ // Mark this as a restart so Apply creates a fresh generation instead
568
+ // of resuming the old replacement instance.
569
+ return Node<Replace>({
570
+ restart: true,
571
+ action: "replace",
572
+ deleteFirst: diff.deleteFirst ?? oldState.deleteFirst,
573
+ props: news,
574
+ state: oldState,
575
+ });
576
+ }
577
+ } else if (oldState.status === "replaced") {
578
+ // The new resource already exists. Noop means "just let GC finish",
579
+ // update means "mutate the current replacement before GC finishes",
580
+ // and replace means "the current replacement also became obsolete".
581
+ if (diff.action === "noop") {
582
+ // this is the stable case - noop means just continue cleaning up the replacement
583
+ return Node<Replace>({
584
+ action: "replace",
585
+ deleteFirst: oldState.deleteFirst,
586
+ props: news,
587
+ state: oldState,
588
+ });
589
+ } else if (diff.action === "update") {
590
+ // the replacement has been created but now also needs to be updated
591
+ // the resource provider should:
592
+ // 1. Update the newly created replacement resource
593
+ // 2. Then proceed as normal to delete the replaced resources (after all downstream references are updated)
594
+ return Node<Update>({
595
+ action: "update",
596
+ props: news,
597
+ state: oldState,
598
+ });
599
+ } else {
600
+ // Cleanup is still pending, but the current "new" resource has already
601
+ // become obsolete. Start another replacement generation and preserve
602
+ // the existing replaced node as part of the recursive old chain.
603
+ return Node<Replace>({
604
+ restart: true,
605
+ action: "replace",
606
+ deleteFirst: diff.deleteFirst ?? oldState.deleteFirst,
607
+ props: news,
608
+ state: oldState,
609
+ });
610
+ }
611
+ } else if (oldState.status === "deleting") {
612
+ // we're in a partially deleted state, it is unclear whether it was or was not deleted
613
+ // so continue by re-creating it with the same instanceId and desired props
488
614
  return Node<Create>({
489
615
  action: "create",
490
616
  props: news,
491
- state: oldState,
617
+ state: {
618
+ ...oldState,
619
+ status: "creating",
620
+ props: news,
621
+ },
492
622
  });
493
623
  } else if (diff.action === "update") {
494
- // props have changed in a way that is updatable
495
- // again, just continue with the create
496
- // TODO(sam): should we maybe try an update instead?
497
- return Node<Create>({
498
- action: "create",
499
- props: news,
500
- state: oldState,
501
- });
502
- } else {
503
- // props have changed in an incompatible way
504
- // because it's possible that an un-updatable resource has already been created
505
- // we must use a replace step to create a new one and delete the potential old one
506
- return Node<Replace>({
507
- action: "replace",
508
- props: news,
509
- deleteFirst: diff.deleteFirst ?? false,
510
- state: oldState,
511
- });
512
- }
513
- } else if (oldState.status === "updating") {
514
- // Updating already targets the live resource, so noop/update both mean
515
- // "finish the interrupted update". Only a replace diff escalates it
516
- // into a fresh replacement.
517
- if (diff.action === "update" || diff.action === "noop") {
518
- // we can continue where we left off
624
+ // Stable created/updated resources follow the normal CRUD mapping.
519
625
  return Node<Update>({
520
626
  action: "update",
521
627
  props: news,
522
628
  state: oldState,
523
629
  });
524
- } else {
525
- // we started to update a resource but now believe we should replace it
526
- return Node<Replace>({
527
- action: "replace",
528
- deleteFirst: diff.deleteFirst ?? false,
529
- props: news,
530
- // TODO(sam): can Apply handle replacements when the oldState is UpdatingResourceState?
531
- // -> or should we do a provider.read to try and reconcile back to UpdatedResourceState?
532
- state: oldState,
533
- });
534
- }
535
- } else if (oldState.status === "replacing") {
536
- // The replacement candidate is still being created. Noop/update keep
537
- // driving the same generation; replace means that candidate itself is
538
- // now obsolete and must be wrapped in a new outer generation.
539
- if (diff.action === "noop") {
540
- // this is the stable case - noop means just continue with the replacement
541
- return Node<Replace>({
542
- action: "replace",
543
- deleteFirst: oldState.deleteFirst,
544
- props: news,
545
- state: oldState,
546
- });
547
- } else if (diff.action === "update") {
548
- // potential problem here - the props have changed since we tried to replace,
549
- // but not enough to trigger another replacement. the resource provider should
550
- // be designed as idempotent to converge to the right state when creating the new resource
551
- // the newly generated instanceId is intended to assist with this
552
- return Node<Replace>({
553
- action: "replace",
554
- deleteFirst: oldState.deleteFirst,
555
- props: news,
556
- state: oldState,
557
- });
558
- } else {
559
- // The in-flight replacement candidate itself now needs replacement.
560
- // Mark this as a restart so Apply creates a fresh generation instead
561
- // of resuming the old replacement instance.
562
- return Node<Replace>({
563
- restart: true,
564
- action: "replace",
565
- deleteFirst: diff.deleteFirst ?? oldState.deleteFirst,
566
- props: news,
567
- state: oldState,
568
- });
569
- }
570
- } else if (oldState.status === "replaced") {
571
- // The new resource already exists. Noop means "just let GC finish",
572
- // update means "mutate the current replacement before GC finishes",
573
- // and replace means "the current replacement also became obsolete".
574
- if (diff.action === "noop") {
575
- // this is the stable case - noop means just continue cleaning up the replacement
630
+ } else if (diff.action === "replace") {
576
631
  return Node<Replace>({
577
632
  action: "replace",
578
- deleteFirst: oldState.deleteFirst,
579
- props: news,
580
- state: oldState,
581
- });
582
- } else if (diff.action === "update") {
583
- // the replacement has been created but now also needs to be updated
584
- // the resource provider should:
585
- // 1. Update the newly created replacement resource
586
- // 2. Then proceed as normal to delete the replaced resources (after all downstream references are updated)
587
- return Node<Update>({
588
- action: "update",
589
633
  props: news,
590
634
  state: oldState,
635
+ deleteFirst: diff?.deleteFirst ?? false,
591
636
  });
592
637
  } else {
593
- // Cleanup is still pending, but the current "new" resource has already
594
- // become obsolete. Start another replacement generation and preserve
595
- // the existing replaced node as part of the recursive old chain.
596
- return Node<Replace>({
597
- restart: true,
598
- action: "replace",
599
- deleteFirst: diff.deleteFirst ?? oldState.deleteFirst,
600
- props: news,
638
+ return Node<NoopUpdate>({
639
+ action: "noop",
601
640
  state: oldState,
602
641
  });
603
642
  }
604
- } else if (oldState.status === "deleting") {
605
- // we're in a partially deleted state, it is unclear whether it was or was not deleted
606
- // so continue by re-creating it with the same instanceId and desired props
607
- return Node<Create>({
608
- action: "create",
609
- props: news,
610
- state: {
611
- ...oldState,
612
- status: "creating",
613
- props: news,
614
- },
615
- });
616
- } else if (diff.action === "update") {
617
- // Stable created/updated resources follow the normal CRUD mapping.
618
- return Node<Update>({
619
- action: "update",
620
- props: news,
621
- state: oldState,
622
- });
623
- } else if (diff.action === "replace") {
624
- return Node<Replace>({
625
- action: "replace",
626
- props: news,
627
- state: oldState,
628
- deleteFirst: diff?.deleteFirst ?? false,
629
- });
630
- } else {
631
- return Node<NoopUpdate>({
632
- action: "noop",
633
- state: oldState,
634
- });
635
- }
636
- }),
637
- ),
638
- { concurrency: "unbounded" },
639
- )).map((update) => [update.resource.FQN, update]),
640
- ) as Plan["resources"];
641
-
642
- // Detect unsatisfiable dependency cycles among create/replace nodes.
643
- // Update/noop nodes signal their Deferred before waitForDeps so they
644
- // cannot deadlock. Create/replace nodes only signal early when they
645
- // have a precreate handler. Simulate the concurrent execution:
646
- // precreate nodes are immediately "resolved", then iteratively resolve
647
- // any node whose deps are all resolved. Remaining nodes would deadlock.
648
- {
649
- const createReplaceNodes = new Set(
650
- Object.entries(resourceGraph)
651
- .filter(
652
- ([_, node]) =>
653
- node.action === "create" || node.action === "replace",
654
- )
655
- .map(([fqn]) => fqn),
656
- );
657
-
658
- if (createReplaceNodes.size > 0) {
659
- const hasPrecreate = new Set(
660
- [...createReplaceNodes].filter(
661
- (fqn) => !!resourceGraph[fqn]?.provider?.precreate,
643
+ }),
662
644
  ),
645
+ { concurrency: "unbounded" },
646
+ )).map((update) => [update.resource.FQN, update]),
647
+ ) as Plan["resources"];
648
+
649
+ // Detect unsatisfiable dependency cycles among create/replace nodes.
650
+ // Update/noop nodes signal their Deferred before waitForDeps so they
651
+ // cannot deadlock. Create/replace nodes only signal early when they
652
+ // have a precreate handler. Simulate the concurrent execution:
653
+ // precreate nodes are immediately "resolved", then iteratively resolve
654
+ // any node whose deps are all resolved. Remaining nodes would deadlock.
655
+ {
656
+ const createReplaceNodes = new Set(
657
+ Object.entries(resourceGraph)
658
+ .filter(
659
+ ([_, node]) =>
660
+ node.action === "create" || node.action === "replace",
661
+ )
662
+ .map(([fqn]) => fqn),
663
663
  );
664
664
 
665
- const resolved = new Set(hasPrecreate);
666
- let changed = true;
667
- while (changed) {
668
- changed = false;
669
- for (const fqn of createReplaceNodes) {
670
- if (resolved.has(fqn)) continue;
671
- const deps = (allUpstreamDependencies[fqn] ?? []).filter((dep) =>
672
- createReplaceNodes.has(dep),
673
- );
674
- if (deps.every((dep) => resolved.has(dep))) {
675
- resolved.add(fqn);
676
- changed = true;
665
+ if (createReplaceNodes.size > 0) {
666
+ const hasPrecreate = new Set(
667
+ [...createReplaceNodes].filter(
668
+ (fqn) => !!resourceGraph[fqn]?.provider?.precreate,
669
+ ),
670
+ );
671
+
672
+ const resolved = new Set(hasPrecreate);
673
+ let changed = true;
674
+ while (changed) {
675
+ changed = false;
676
+ for (const fqn of createReplaceNodes) {
677
+ if (resolved.has(fqn)) continue;
678
+ const deps = (allUpstreamDependencies[fqn] ?? []).filter((dep) =>
679
+ createReplaceNodes.has(dep),
680
+ );
681
+ if (deps.every((dep) => resolved.has(dep))) {
682
+ resolved.add(fqn);
683
+ changed = true;
684
+ }
677
685
  }
678
686
  }
679
- }
680
687
 
681
- const deadlocked = [...createReplaceNodes].filter(
682
- (fqn) => !resolved.has(fqn),
683
- );
684
- if (deadlocked.length > 0) {
685
- const missingPrecreate = deadlocked.filter(
686
- (fqn) => !hasPrecreate.has(fqn),
687
- );
688
- return yield* Effect.die(
689
- new UnsatisfiedResourceCycle({
690
- message:
691
- `Circular dependency detected that cannot be resolved: [${deadlocked.join(", ")}]. ` +
692
- `Resources lacking a precreate handler: [${missingPrecreate.join(", ")}]. ` +
693
- `All resources in a dependency cycle must implement precreate to allow early signaling.`,
694
- cycle: deadlocked,
695
- missingPrecreate,
696
- }),
688
+ const deadlocked = [...createReplaceNodes].filter(
689
+ (fqn) => !resolved.has(fqn),
697
690
  );
691
+ if (deadlocked.length > 0) {
692
+ const missingPrecreate = deadlocked.filter(
693
+ (fqn) => !hasPrecreate.has(fqn),
694
+ );
695
+ return yield* Effect.die(
696
+ new UnsatisfiedResourceCycle({
697
+ message:
698
+ `Circular dependency detected that cannot be resolved: [${deadlocked.join(", ")}]. ` +
699
+ `Resources lacking a precreate handler: [${missingPrecreate.join(", ")}]. ` +
700
+ `All resources in a dependency cycle must implement precreate to allow early signaling.`,
701
+ cycle: deadlocked,
702
+ missingPrecreate,
703
+ }),
704
+ );
705
+ }
698
706
  }
699
707
  }
700
- }
701
708
 
702
- const deletions = Object.fromEntries(
703
- (yield* Effect.all(
704
- (yield* state.list({ stack: stackName, stage: stage })).map(
705
- Effect.fn(function* (fqn) {
706
- // Check if this FQN is in the new resources
707
- if (newResourceFqns.has(fqn)) {
708
- return;
709
- }
710
- const oldState = yield* state.get({
711
- stack: stackName,
712
- stage: stage,
713
- fqn,
714
- });
715
- let attr: any = oldState?.attr;
716
- if (oldState) {
717
- const { logicalId } = parseFqn(fqn);
718
- const resourceType = oldState.resourceType;
719
- const provider = yield* getProviderByType(resourceType);
720
- if (oldState.attr === undefined) {
721
- if (provider.read) {
722
- attr = yield* provider
723
- .read({
724
- id: logicalId,
725
- instanceId: oldState.instanceId,
726
- olds: oldState.props as never,
727
- output: oldState.attr as never,
728
- })
729
- .pipe(
730
- Effect.provideService(InstanceId, oldState.instanceId),
731
- );
732
- }
709
+ const deletions = Object.fromEntries(
710
+ (yield* Effect.all(
711
+ (yield* state.list({ stack: stackName, stage: stage })).map(
712
+ Effect.fn(function* (fqn) {
713
+ // Check if this FQN is in the new resources
714
+ if (newResourceFqns.has(fqn)) {
715
+ return;
733
716
  }
734
- return [
717
+ const oldState = yield* state.get({
718
+ stack: stackName,
719
+ stage: stage,
735
720
  fqn,
736
- {
737
- action: "delete",
738
- state: { ...oldState, attr },
739
- provider: provider,
740
- resource: {
741
- Namespace: oldState.namespace,
742
- FQN: fqn,
743
- LogicalId: logicalId,
744
- Type: oldState.resourceType,
745
- Attributes: attr,
746
- Props: oldState.props,
747
- Binding: undefined!,
748
- Provider: Provider(resourceType),
749
- RemovalPolicy: oldState.removalPolicy,
750
- ExecutionContext: undefined!,
751
- } as ResourceLike,
752
- downstream: oldDownstreamDependencies[fqn] ?? [],
753
- bindings: oldState.bindings.map((binding) => ({
754
- sid: binding.sid,
755
- action: "delete" as const,
756
- data: binding.data,
757
- })),
758
- } satisfies Delete,
759
- ] as const;
760
- }
761
- }),
762
- ),
763
- { concurrency: "unbounded" },
764
- )).filter((v) => !!v),
765
- );
766
-
767
- for (const resourceFqn of Object.keys(deletions)) {
768
- const dependencies = Object.entries(rawUpstreamDependencies)
769
- .filter(
770
- ([survivorFqn, upstream]) =>
771
- survivorFqn in resourceGraph && upstream.includes(resourceFqn),
772
- )
773
- .map(([survivorFqn]) => survivorFqn);
774
- if (dependencies.length > 0) {
775
- return yield* new DeleteResourceHasDownstreamDependencies({
776
- message: `Resource ${resourceFqn} has downstream dependencies`,
777
- resourceId: resourceFqn,
778
- dependencies,
779
- });
721
+ });
722
+ let attr: any = oldState?.attr;
723
+ if (oldState) {
724
+ const { logicalId } = parseFqn(fqn);
725
+ const resourceType = oldState.resourceType;
726
+ const provider = yield* getProviderByType(resourceType);
727
+ if (oldState.attr === undefined) {
728
+ if (provider.read) {
729
+ attr = yield* provider
730
+ .read({
731
+ id: logicalId,
732
+ instanceId: oldState.instanceId,
733
+ olds: oldState.props as never,
734
+ output: oldState.attr as never,
735
+ })
736
+ .pipe(providePlanScope(fqn, oldState.instanceId));
737
+ }
738
+ }
739
+ return [
740
+ fqn,
741
+ {
742
+ action: "delete",
743
+ state: { ...oldState, attr },
744
+ provider: provider,
745
+ resource: {
746
+ Namespace: oldState.namespace,
747
+ FQN: fqn,
748
+ LogicalId: logicalId,
749
+ Type: oldState.resourceType,
750
+ Attributes: attr,
751
+ Props: oldState.props,
752
+ Binding: undefined!,
753
+ Provider: Provider(resourceType),
754
+ RemovalPolicy: oldState.removalPolicy,
755
+ ExecutionContext: undefined!,
756
+ } as ResourceLike,
757
+ downstream: oldDownstreamDependencies[fqn] ?? [],
758
+ bindings: oldState.bindings.map((binding) => ({
759
+ sid: binding.sid,
760
+ action: "delete" as const,
761
+ data: binding.data,
762
+ })),
763
+ } satisfies Delete,
764
+ ] as const;
765
+ }
766
+ }),
767
+ ),
768
+ { concurrency: "unbounded" },
769
+ )).filter((v) => !!v),
770
+ );
771
+
772
+ for (const resourceFqn of Object.keys(deletions)) {
773
+ const dependencies = Object.entries(rawUpstreamDependencies)
774
+ .filter(
775
+ ([survivorFqn, upstream]) =>
776
+ survivorFqn in resourceGraph && upstream.includes(resourceFqn),
777
+ )
778
+ .map(([survivorFqn]) => survivorFqn);
779
+ if (dependencies.length > 0) {
780
+ return yield* new DeleteResourceHasDownstreamDependencies({
781
+ message: `Resource ${resourceFqn} has downstream dependencies`,
782
+ resourceId: resourceFqn,
783
+ dependencies,
784
+ });
785
+ }
780
786
  }
781
- }
782
787
 
783
- return {
784
- resources: resourceGraph,
785
- deletions,
786
- output: stack.output,
787
- } satisfies Plan<A> as Plan<A>;
788
- });
788
+ return {
789
+ resources: resourceGraph,
790
+ deletions,
791
+ output: stack.output,
792
+ } satisfies Plan<A> as Plan<A>;
793
+ }),
794
+ );
795
+
796
+ const providePlanScope =
797
+ (fqn: string, instanceId: string) =>
798
+ <A, E, R>(
799
+ effect: Effect.Effect<A, E, R>,
800
+ ): Effect.Effect<A, E, Exclude<R, InstanceId | Artifacts>> =>
801
+ Effect.serviceOption(ArtifactStore).pipe(
802
+ Effect.map(Option.getOrElse(createArtifactStore)),
803
+ Effect.flatMap((store) =>
804
+ effect.pipe(
805
+ Effect.provideService(Artifacts, makeScopedArtifacts(store, fqn)),
806
+ Effect.provideService(InstanceId, instanceId),
807
+ ),
808
+ ),
809
+ ) as Effect.Effect<A, E, Exclude<R, InstanceId | Artifacts>>;
789
810
 
790
811
  export class DeleteResourceHasDownstreamDependencies extends Data.TaggedError(
791
812
  "DeleteResourceHasDownstreamDependencies",