alchemy 2.0.0-beta.64 → 2.0.0-beta.65

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 (165) hide show
  1. package/lib/AWS/EC2/InternetGateway.d.ts.map +1 -1
  2. package/lib/AWS/EC2/InternetGateway.js +8 -3
  3. package/lib/AWS/EC2/InternetGateway.js.map +1 -1
  4. package/lib/AWS/EC2/LingeringEnis.d.ts +26 -0
  5. package/lib/AWS/EC2/LingeringEnis.d.ts.map +1 -0
  6. package/lib/AWS/EC2/LingeringEnis.js +128 -0
  7. package/lib/AWS/EC2/LingeringEnis.js.map +1 -0
  8. package/lib/AWS/EC2/SecurityGroup.d.ts.map +1 -1
  9. package/lib/AWS/EC2/SecurityGroup.js +14 -14
  10. package/lib/AWS/EC2/SecurityGroup.js.map +1 -1
  11. package/lib/AWS/EC2/Subnet.d.ts.map +1 -1
  12. package/lib/AWS/EC2/Subnet.js +13 -21
  13. package/lib/AWS/EC2/Subnet.js.map +1 -1
  14. package/lib/AWS/ECR/Image.d.ts +1 -0
  15. package/lib/AWS/ECR/Image.d.ts.map +1 -1
  16. package/lib/AWS/EKS/Addon.d.ts.map +1 -1
  17. package/lib/AWS/EKS/Addon.js +15 -3
  18. package/lib/AWS/EKS/Addon.js.map +1 -1
  19. package/lib/AWS/EKS/Deployment.d.ts +10 -1
  20. package/lib/AWS/EKS/Deployment.d.ts.map +1 -1
  21. package/lib/AWS/EKS/Deployment.js +10 -4
  22. package/lib/AWS/EKS/Deployment.js.map +1 -1
  23. package/lib/AWS/EKS/HyperPod.d.ts +57 -0
  24. package/lib/AWS/EKS/HyperPod.d.ts.map +1 -0
  25. package/lib/AWS/EKS/HyperPod.js +46 -0
  26. package/lib/AWS/EKS/HyperPod.js.map +1 -0
  27. package/lib/AWS/EKS/Job.d.ts +10 -1
  28. package/lib/AWS/EKS/Job.d.ts.map +1 -1
  29. package/lib/AWS/EKS/Job.js +16 -6
  30. package/lib/AWS/EKS/Job.js.map +1 -1
  31. package/lib/AWS/EKS/internal/client.d.ts +4 -3
  32. package/lib/AWS/EKS/internal/client.d.ts.map +1 -1
  33. package/lib/AWS/EKS/internal/client.js +33 -2
  34. package/lib/AWS/EKS/internal/client.js.map +1 -1
  35. package/lib/AWS/Lambda/Function.d.ts.map +1 -1
  36. package/lib/AWS/Lambda/Function.js +1 -1
  37. package/lib/AWS/Lambda/Function.js.map +1 -1
  38. package/lib/AWS/Providers.d.ts.map +1 -1
  39. package/lib/AWS/Providers.js +4 -1
  40. package/lib/AWS/Providers.js.map +1 -1
  41. package/lib/AWS/SageMaker/Cluster.d.ts +327 -0
  42. package/lib/AWS/SageMaker/Cluster.d.ts.map +1 -0
  43. package/lib/AWS/SageMaker/Cluster.js +469 -0
  44. package/lib/AWS/SageMaker/Cluster.js.map +1 -0
  45. package/lib/AWS/SageMaker/ClusterSchedulerConfig.d.ts +96 -0
  46. package/lib/AWS/SageMaker/ClusterSchedulerConfig.d.ts.map +1 -0
  47. package/lib/AWS/SageMaker/ClusterSchedulerConfig.js +270 -0
  48. package/lib/AWS/SageMaker/ClusterSchedulerConfig.js.map +1 -0
  49. package/lib/AWS/SageMaker/ComputeQuota.d.ts +106 -0
  50. package/lib/AWS/SageMaker/ComputeQuota.d.ts.map +1 -0
  51. package/lib/AWS/SageMaker/ComputeQuota.js +268 -0
  52. package/lib/AWS/SageMaker/ComputeQuota.js.map +1 -0
  53. package/lib/AWS/SageMaker/index.d.ts +3 -0
  54. package/lib/AWS/SageMaker/index.d.ts.map +1 -1
  55. package/lib/AWS/SageMaker/index.js +3 -0
  56. package/lib/AWS/SageMaker/index.js.map +1 -1
  57. package/lib/Bundle/PurePlugin.d.ts +29 -7
  58. package/lib/Bundle/PurePlugin.d.ts.map +1 -1
  59. package/lib/Bundle/PurePlugin.js +56 -33
  60. package/lib/Bundle/PurePlugin.js.map +1 -1
  61. package/lib/Cli/commands/nuke.d.ts.map +1 -1
  62. package/lib/Cli/commands/nuke.js +231 -37
  63. package/lib/Cli/commands/nuke.js.map +1 -1
  64. package/lib/Cloudflare/StateStore/Api.d.ts +1 -1
  65. package/lib/Cloudflare/Workers/Assets.d.ts +26 -8
  66. package/lib/Cloudflare/Workers/Assets.d.ts.map +1 -1
  67. package/lib/Cloudflare/Workers/Assets.js +46 -1
  68. package/lib/Cloudflare/Workers/Assets.js.map +1 -1
  69. package/lib/Cloudflare/Workers/LocalWorkerProvider.d.ts +2 -2
  70. package/lib/Cloudflare/Workers/LocalWorkerProvider.d.ts.map +1 -1
  71. package/lib/Cloudflare/Workers/LocalWorkerProvider.js +39 -6
  72. package/lib/Cloudflare/Workers/LocalWorkerProvider.js.map +1 -1
  73. package/lib/Cloudflare/Workers/Worker.d.ts +7 -5
  74. package/lib/Cloudflare/Workers/Worker.d.ts.map +1 -1
  75. package/lib/Cloudflare/Workers/Worker.js +0 -1
  76. package/lib/Cloudflare/Workers/Worker.js.map +1 -1
  77. package/lib/Cloudflare/Workers/WorkerBinding.d.ts +2 -2
  78. package/lib/Cloudflare/Workers/WorkerBundle.d.ts +20 -1
  79. package/lib/Cloudflare/Workers/WorkerBundle.d.ts.map +1 -1
  80. package/lib/Cloudflare/Workers/WorkerBundle.js +11 -0
  81. package/lib/Cloudflare/Workers/WorkerBundle.js.map +1 -1
  82. package/lib/Cloudflare/Workers/WorkerProvider.d.ts.map +1 -1
  83. package/lib/Cloudflare/Workers/WorkerProvider.js +10 -4
  84. package/lib/Cloudflare/Workers/WorkerProvider.js.map +1 -1
  85. package/lib/Cloudflare/Workflows/WorkflowBridge.d.ts +2 -1
  86. package/lib/Cloudflare/Workflows/WorkflowBridge.d.ts.map +1 -1
  87. package/lib/Cloudflare/Workflows/WorkflowBridge.js +5 -5
  88. package/lib/Cloudflare/Workflows/WorkflowBridge.js.map +1 -1
  89. package/lib/Cloudflare/Workflows/index.d.ts +1 -1
  90. package/lib/Cloudflare/Workflows/index.d.ts.map +1 -1
  91. package/lib/Cloudflare/Workflows/index.js +1 -1
  92. package/lib/Cloudflare/Workflows/index.js.map +1 -1
  93. package/lib/Docker/Container.d.ts +26 -0
  94. package/lib/Docker/Container.d.ts.map +1 -1
  95. package/lib/Docker/Container.js +20 -1
  96. package/lib/Docker/Container.js.map +1 -1
  97. package/lib/Docker/Docker.d.ts +2 -0
  98. package/lib/Docker/Docker.d.ts.map +1 -1
  99. package/lib/Docker/Docker.js.map +1 -1
  100. package/lib/Output.d.ts +4 -1
  101. package/lib/Output.d.ts.map +1 -1
  102. package/lib/Output.js.map +1 -1
  103. package/lib/Provider.d.ts +20 -0
  104. package/lib/Provider.d.ts.map +1 -1
  105. package/lib/Provider.js.map +1 -1
  106. package/lib/Random.d.ts +1 -1
  107. package/lib/Random.d.ts.map +1 -1
  108. package/lib/Resource.d.ts +14 -1
  109. package/lib/Resource.d.ts.map +1 -1
  110. package/lib/Resource.js.map +1 -1
  111. package/lib/SQL/D1.d.ts +1 -1
  112. package/lib/SQL/D1.js +1 -1
  113. package/lib/SQL/Postgres.d.ts +1 -1
  114. package/lib/SQL/Postgres.js +1 -1
  115. package/lib/State/HttpStateApi.d.ts +11 -11
  116. package/lib/Test/Alchemy.d.ts +1 -1
  117. package/lib/Test/Alchemy.js +1 -1
  118. package/lib/Test/Vitest.d.ts +58 -0
  119. package/lib/Test/Vitest.d.ts.map +1 -0
  120. package/lib/Test/Vitest.js +114 -0
  121. package/lib/Test/Vitest.js.map +1 -0
  122. package/lib/Util/proxy-chain.d.ts +5 -0
  123. package/lib/Util/proxy-chain.d.ts.map +1 -1
  124. package/lib/Util/proxy-chain.js +52 -4
  125. package/lib/Util/proxy-chain.js.map +1 -1
  126. package/package.json +23 -15
  127. package/src/AWS/EC2/InternetGateway.ts +8 -3
  128. package/src/AWS/EC2/LingeringEnis.ts +169 -0
  129. package/src/AWS/EC2/SecurityGroup.ts +24 -28
  130. package/src/AWS/EC2/Subnet.ts +23 -34
  131. package/src/AWS/EKS/Addon.ts +17 -4
  132. package/src/AWS/EKS/Deployment.ts +26 -5
  133. package/src/AWS/EKS/HyperPod.ts +97 -0
  134. package/src/AWS/EKS/Job.ts +32 -7
  135. package/src/AWS/EKS/internal/client.ts +40 -3
  136. package/src/AWS/Lambda/Function.ts +5 -3
  137. package/src/AWS/Providers.ts +6 -0
  138. package/src/AWS/SageMaker/Cluster.ts +775 -0
  139. package/src/AWS/SageMaker/ClusterSchedulerConfig.ts +430 -0
  140. package/src/AWS/SageMaker/ComputeQuota.ts +426 -0
  141. package/src/AWS/SageMaker/index.ts +3 -0
  142. package/src/Bundle/PurePlugin.ts +92 -39
  143. package/src/Cli/commands/nuke.ts +295 -56
  144. package/src/Cloudflare/Workers/Assets.ts +53 -1
  145. package/src/Cloudflare/Workers/LocalWorkerProvider.ts +46 -7
  146. package/src/Cloudflare/Workers/Worker.ts +7 -5
  147. package/src/Cloudflare/Workers/WorkerBundle.ts +31 -1
  148. package/src/Cloudflare/Workers/WorkerProvider.ts +18 -4
  149. package/src/Cloudflare/Workflows/WorkflowBridge.ts +5 -5
  150. package/src/Cloudflare/Workflows/index.ts +1 -1
  151. package/src/Docker/Container.ts +30 -1
  152. package/src/Docker/Docker.ts +2 -0
  153. package/src/Output.ts +19 -11
  154. package/src/Provider.ts +20 -0
  155. package/src/Resource.ts +18 -1
  156. package/src/SQL/D1.ts +1 -1
  157. package/src/SQL/Postgres.ts +1 -1
  158. package/src/Test/Alchemy.ts +1 -1
  159. package/src/Test/Vitest.ts +244 -0
  160. package/src/Util/proxy-chain.ts +73 -4
  161. package/lib/SQL/index.d.ts +0 -4
  162. package/lib/SQL/index.d.ts.map +0 -1
  163. package/lib/SQL/index.js +0 -4
  164. package/lib/SQL/index.js.map +0 -1
  165. package/src/SQL/index.ts +0 -3
@@ -4,6 +4,7 @@ import * as Effect from "effect/Effect";
4
4
  import * as Layer from "effect/Layer";
5
5
  import * as Logger from "effect/Logger";
6
6
  import { MinimumLogLevel } from "effect/References";
7
+ import * as Schedule from "effect/Schedule";
7
8
  import { Command, Flag } from "effect/unstable/cli";
8
9
  import picomatch from "picomatch";
9
10
 
@@ -75,6 +76,26 @@ const timeoutFlag = Flag.integer("timeout").pipe(
75
76
  Flag.withDefault(120),
76
77
  );
77
78
 
79
+ const independentFlag = Flag.boolean("independent").pipe(
80
+ Flag.withDescription(
81
+ "Delete every resource independently instead of in coordinated passes. " +
82
+ "In pass mode a single slow or hanging delete delays the next pass for " +
83
+ "everything; with --independent each resource retries its own delete " +
84
+ "with backoff, in parallel, until it succeeds or --retries is " +
85
+ "exhausted. Dependency violations resolve naturally as the blocking " +
86
+ "resources are deleted concurrently.",
87
+ ),
88
+ Flag.withDefault(false),
89
+ );
90
+
91
+ const retriesFlag = Flag.integer("retries").pipe(
92
+ Flag.withDescription(
93
+ "With --independent: retries per resource after the initial delete " +
94
+ "attempt (each attempt still bounded by --timeout). Default: 10.",
95
+ ),
96
+ Flag.withDefault(10),
97
+ );
98
+
78
99
  interface DiscoveredProvider {
79
100
  id: string;
80
101
  provider: ProviderService;
@@ -262,6 +283,59 @@ const groupBy = <T>(items: T[], key: (item: T) => string): Map<string, T[]> => {
262
283
  return out;
263
284
  };
264
285
 
286
+ const addEdge = (map: Map<string, Set<string>>, from: string, to: string) => {
287
+ const set = map.get(from) ?? new Set<string>();
288
+ set.add(to);
289
+ map.set(from, set);
290
+ };
291
+
292
+ /**
293
+ * Strongly-connected components of the type-level teardown-dependency graph
294
+ * (Tarjan). Components are emitted in reverse topological order of the
295
+ * condensation — every component's successors are emitted before it — so
296
+ * callers can compute layers by iterating the result backwards.
297
+ */
298
+ const stronglyConnectedComponents = (
299
+ nodes: readonly string[],
300
+ successors: Map<string, Set<string>>,
301
+ ): string[][] => {
302
+ const index = new Map<string, number>();
303
+ const low = new Map<string, number>();
304
+ const onStack = new Set<string>();
305
+ const stack: string[] = [];
306
+ const components: string[][] = [];
307
+ let counter = 0;
308
+ const strongConnect = (v: string): void => {
309
+ index.set(v, counter);
310
+ low.set(v, counter);
311
+ counter += 1;
312
+ stack.push(v);
313
+ onStack.add(v);
314
+ for (const w of successors.get(v) ?? []) {
315
+ if (!index.has(w)) {
316
+ strongConnect(w);
317
+ low.set(v, Math.min(low.get(v)!, low.get(w)!));
318
+ } else if (onStack.has(w)) {
319
+ low.set(v, Math.min(low.get(v)!, index.get(w)!));
320
+ }
321
+ }
322
+ if (low.get(v) === index.get(v)) {
323
+ const component: string[] = [];
324
+ for (;;) {
325
+ const w = stack.pop()!;
326
+ onStack.delete(w);
327
+ component.push(w);
328
+ if (w === v) break;
329
+ }
330
+ components.push(component);
331
+ }
332
+ };
333
+ for (const v of nodes) {
334
+ if (!index.has(v)) strongConnect(v);
335
+ }
336
+ return components;
337
+ };
338
+
265
339
  const nukeSession: ScopedPlanStatusSession = {
266
340
  emit: () => Effect.void,
267
341
  done: () => Effect.void,
@@ -278,6 +352,8 @@ const nukeCommand = Command.make(
278
352
  verbose: verboseFlag,
279
353
  concurrency: concurrencyFlag,
280
354
  timeout: timeoutFlag,
355
+ independent: independentFlag,
356
+ retries: retriesFlag,
281
357
  include: includeFlag,
282
358
  exclude: excludeFlag,
283
359
  filter: filterFlag,
@@ -295,6 +371,8 @@ const nukeCommand = Command.make(
295
371
  verbose,
296
372
  concurrency,
297
373
  timeout,
374
+ independent,
375
+ retries,
298
376
  include,
299
377
  exclude,
300
378
  filter,
@@ -476,69 +554,223 @@ const nukeCommand = Command.make(
476
554
  const emitDelete = (event: NukeUI.DeleteEvent) =>
477
555
  deleteUI ? Effect.sync(() => deleteUI.emit(event)) : Effect.void;
478
556
 
479
- let remaining = targets;
557
+ // One delete attempt for a single resource, bounded by --timeout, with
558
+ // the failure cause logged to the stack's file logger. Failure stays on
559
+ // the error channel so callers can retry or fold it as they see fit.
560
+ const attemptDelete = (item: (typeof targets)[number]) =>
561
+ item.provider
562
+ .delete({
563
+ id: displayName(item.attr),
564
+ // Enumerated straight from the cloud, so there is no
565
+ // Alchemy namespace — an un-namespaced fqn is just the id.
566
+ fqn: displayName(item.attr),
567
+ instanceId: "",
568
+ olds: item.attr as never,
569
+ output: item.attr as never,
570
+ session: nukeSession,
571
+ bindings: [],
572
+ // Nuke is an explicit, operator-confirmed account
573
+ // teardown: allow destructive prerequisites (e.g.
574
+ // emptying a bucket) that normal destroys gate behind
575
+ // props such as `forceDestroy` — `olds` here is cloud
576
+ // Attributes, not the originally-deployed Props.
577
+ force: true,
578
+ })
579
+ .pipe(
580
+ Effect.timeout(`${timeout} seconds`),
581
+ Effect.tapCause((cause) =>
582
+ Effect.logWarning(
583
+ `nuke: delete failed for ${item.id} ${displayName(item.attr)}`,
584
+ cause,
585
+ ),
586
+ ),
587
+ Effect.provide(context),
588
+ );
589
+
590
+ const retrySchedule = Schedule.min([
591
+ Schedule.exponential("1 second"),
592
+ Schedule.spaced("15 seconds"),
593
+ ]);
594
+
480
595
  let pass = 0;
481
- while (remaining.length > 0) {
482
- pass += 1;
483
- yield* emitDelete({ kind: "pass", pass });
484
-
485
- const byType = groupBy(remaining, (t) => t.id);
486
- const results = yield* Effect.all(
487
- [...byType.values()].map((items) =>
488
- Effect.all(
489
- items.map((item) =>
490
- item.provider
491
- .delete({
492
- id: displayName(item.attr),
493
- // Enumerated straight from the cloud, so there is no
494
- // Alchemy namespace — an un-namespaced fqn is just the id.
495
- fqn: displayName(item.attr),
496
- instanceId: "",
497
- olds: item.attr as never,
498
- output: item.attr as never,
499
- session: nukeSession,
500
- bindings: [],
501
- // Nuke is an explicit, operator-confirmed account
502
- // teardown: allow destructive prerequisites (e.g.
503
- // emptying a bucket) that normal destroys gate behind
504
- // props such as `forceDestroy` — `olds` here is cloud
505
- // Attributes, not the originally-deployed Props.
506
- force: true,
507
- })
508
- .pipe(
509
- Effect.timeout(`${timeout} seconds`),
510
- Effect.tapCause((cause) =>
511
- Effect.logWarning(
512
- `nuke: delete failed for ${item.id} ${displayName(item.attr)}`,
513
- cause,
596
+
597
+ // Delete one tier of resources to completion, returning the items
598
+ // that could not be deleted.
599
+ const runTier = (tier: typeof targets) =>
600
+ Effect.gen(function* () {
601
+ if (tier.length === 0) return [] as typeof targets;
602
+ if (independent) {
603
+ // Independent mode: no pass barrier. Every resource retries its
604
+ // own delete with capped exponential backoff, all in parallel
605
+ // (provider groups bounded by --concurrency, resources within a
606
+ // provider unbounded, matching pass mode). A dependency
607
+ // violation resolves on a later attempt once the blocking
608
+ // resource's concurrent delete lands — a hanging delete only
609
+ // ever stalls itself.
610
+ pass += 1;
611
+ yield* emitDelete({ kind: "pass", pass });
612
+ const results = yield* Effect.all(
613
+ [...groupBy(tier, (t) => t.id).values()].map((items) =>
614
+ Effect.all(
615
+ items.map((item) =>
616
+ attemptDelete(item).pipe(
617
+ Effect.retry({ schedule: retrySchedule, times: retries }),
618
+ Effect.matchCause({
619
+ onSuccess: () => ({ item, ok: true as const }),
620
+ onFailure: () => ({ item, ok: false as const }),
621
+ }),
622
+ // Only the final outcome is emitted, so the UI's
623
+ // failure count reflects exhausted resources, not
624
+ // transient attempts.
625
+ Effect.tap((r) =>
626
+ r.ok
627
+ ? emitDelete({ kind: "deleted", id: item.id })
628
+ : emitDelete({ kind: "failed", id: item.id }),
514
629
  ),
515
630
  ),
516
- Effect.provide(context),
517
- Effect.matchCause({
518
- onSuccess: () => ({ item, ok: true as const }),
519
- onFailure: () => ({ item, ok: false as const }),
520
- }),
521
- Effect.tap((r) =>
522
- r.ok
523
- ? emitDelete({ kind: "deleted", id: item.id })
524
- : emitDelete({ kind: "failed", id: item.id }),
631
+ ),
632
+ { concurrency: "unbounded" },
633
+ ),
634
+ ),
635
+ { concurrency },
636
+ );
637
+ return results.flat().flatMap((r) => (r.ok ? [] : [r.item]));
638
+ }
639
+ let remaining = tier;
640
+ while (remaining.length > 0) {
641
+ pass += 1;
642
+ yield* emitDelete({ kind: "pass", pass });
643
+
644
+ const byType = groupBy(remaining, (t) => t.id);
645
+ const results = yield* Effect.all(
646
+ [...byType.values()].map((items) =>
647
+ Effect.all(
648
+ items.map((item) =>
649
+ attemptDelete(item).pipe(
650
+ Effect.matchCause({
651
+ onSuccess: () => ({ item, ok: true as const }),
652
+ onFailure: () => ({ item, ok: false as const }),
653
+ }),
654
+ Effect.tap((r) =>
655
+ r.ok
656
+ ? emitDelete({ kind: "deleted", id: item.id })
657
+ : emitDelete({ kind: "failed", id: item.id }),
658
+ ),
525
659
  ),
526
660
  ),
661
+ { concurrency: "unbounded" },
662
+ ),
527
663
  ),
528
- { concurrency: "unbounded" },
529
- ),
530
- ),
531
- { concurrency },
532
- );
664
+ { concurrency },
665
+ );
533
666
 
534
- const failed = results.flat().flatMap((r) => (r.ok ? [] : [r.item]));
535
- // No resource deleted this pass: dependencies can't resolve further,
536
- // so stop instead of looping forever.
537
- if (failed.length === remaining.length) {
538
- remaining = failed;
539
- break;
667
+ const failed = results
668
+ .flat()
669
+ .flatMap((r) => (r.ok ? [] : [r.item]));
670
+ // No resource deleted this pass: dependencies can't resolve
671
+ // further, so stop instead of looping forever.
672
+ if (failed.length === remaining.length) {
673
+ return failed;
674
+ }
675
+ remaining = failed;
676
+ }
677
+ return remaining;
678
+ });
679
+
680
+ // ---- Teardown-dependency ordering ---------------------------------
681
+ // Providers declare `nuke.dependsOn: [globs]` when their cloud-side
682
+ // teardown CONSUMES another type (e.g. SageMaker HyperPod deletes
683
+ // node ENIs by assuming the instance group's execution role inside
684
+ // the cluster's VPC — deleting the role or network mid-teardown
685
+ // wedges the cluster in `Deleting` forever). Edge A→B means every A
686
+ // must be fully GONE before any B is deleted. Only types present in
687
+ // the target set participate, so the constraint costs nothing when no
688
+ // dependent resources exist; declaration cycles collapse into one
689
+ // concurrent wave (handled by the intra-wave retry machinery) with a
690
+ // logged warning instead of failing.
691
+ const typeIds = [...new Set(targets.map((t) => t.id))];
692
+ const providerOfType = new Map(targets.map((t) => [t.id, t.provider]));
693
+ const successors = new Map<string, Set<string>>();
694
+ const predecessors = new Map<string, Set<string>>();
695
+ for (const id of typeIds) {
696
+ const globs = providerOfType.get(id)?.nuke?.dependsOn;
697
+ if (!globs || globs.length === 0) continue;
698
+ const matches = picomatch([...globs]);
699
+ for (const other of typeIds) {
700
+ if (other === id || !matches(other)) continue;
701
+ addEdge(successors, id, other);
702
+ addEdge(predecessors, other, id);
703
+ }
704
+ }
705
+
706
+ const components = stronglyConnectedComponents(typeIds, successors);
707
+ for (const component of components) {
708
+ if (component.length > 1) {
709
+ yield* Effect.logWarning(
710
+ `nuke: teardown-dependency cycle between ${component.join(", ")} — deleting them concurrently`,
711
+ ).pipe(Effect.provide(context));
712
+ }
713
+ }
714
+ const compOf = new Map<string, number>();
715
+ components.forEach((component, i) => {
716
+ for (const node of component) compOf.set(node, i);
717
+ });
718
+ // Layer each component: 0 for sources, else 1 + max predecessor
719
+ // layer. Tarjan emits components in reverse topological order, so
720
+ // iterating backwards visits predecessors before dependents.
721
+ const layerOfComp: number[] = new Array(components.length).fill(0);
722
+ for (let i = components.length - 1; i >= 0; i--) {
723
+ let layer = 0;
724
+ for (const node of components[i]!) {
725
+ for (const pred of predecessors.get(node) ?? []) {
726
+ const predComp = compOf.get(pred)!;
727
+ if (predComp !== i) {
728
+ layer = Math.max(layer, layerOfComp[predComp]! + 1);
729
+ }
730
+ }
731
+ }
732
+ layerOfComp[i] = layer;
733
+ }
734
+ const waves: string[][] = [];
735
+ components.forEach((component, i) => {
736
+ (waves[layerOfComp[i]!] ??= []).push(...component);
737
+ });
738
+
739
+ // ---- Execute waves -------------------------------------------------
740
+ const targetsOfType = groupBy(targets, (t) => t.id);
741
+ const remainingCount = new Map(
742
+ [...targetsOfType.entries()].map(([id, items]) => [id, items.length]),
743
+ );
744
+ const remaining: typeof targets = [];
745
+ const held: string[] = [];
746
+ for (const wave of waves) {
747
+ const runnable: typeof targets = [];
748
+ for (const typeId of wave) {
749
+ const items = targetsOfType.get(typeId) ?? [];
750
+ // A dependent type still has undeleted resources — deleting this
751
+ // type now could wedge their in-flight teardown, which is exactly
752
+ // what the ordering exists to prevent. Hold it back and report.
753
+ const blockers = [...(predecessors.get(typeId) ?? [])].filter(
754
+ (pred) =>
755
+ compOf.get(pred) !== compOf.get(typeId) &&
756
+ (remainingCount.get(pred) ?? 0) > 0,
757
+ );
758
+ if (blockers.length > 0) {
759
+ held.push(`${typeId} (blocked by ${blockers.join(", ")})`);
760
+ remaining.push(...items);
761
+ for (const item of items) {
762
+ yield* emitDelete({ kind: "failed", id: item.id });
763
+ }
764
+ continue;
765
+ }
766
+ runnable.push(...items);
767
+ }
768
+ const failed = yield* runTier(runnable);
769
+ remaining.push(...failed);
770
+ const failedOfType = groupBy(failed, (t) => t.id);
771
+ for (const typeId of new Set(runnable.map((t) => t.id))) {
772
+ remainingCount.set(typeId, failedOfType.get(typeId)?.length ?? 0);
540
773
  }
541
- remaining = failed;
542
774
  }
543
775
 
544
776
  if (deleteUI) {
@@ -549,8 +781,15 @@ const nukeCommand = Command.make(
549
781
  const deleted = targets.length - remaining.length;
550
782
  yield* Console.log("");
551
783
  yield* Console.log(
552
- `Deleted ${deleted} resource(s) over ${pass} pass(es).`,
784
+ independent
785
+ ? `Deleted ${deleted} resource(s).`
786
+ : `Deleted ${deleted} resource(s) over ${pass} pass(es).`,
553
787
  );
788
+ if (held.length > 0) {
789
+ yield* Console.log(
790
+ `Held back (their dependent types could not be fully deleted): ${held.join("; ")}`,
791
+ );
792
+ }
554
793
  if (remaining.length > 0) {
555
794
  yield* Console.log(
556
795
  `${remaining.length} resource(s) could not be deleted.`,
@@ -110,6 +110,54 @@ const createIgnoreMatcher = (patterns: string[]) => {
110
110
  return (file: string) => matcher.ignores(file);
111
111
  };
112
112
 
113
+ /**
114
+ * Read the special `_headers` / `_redirects` files from an assets
115
+ * directory. They are excluded from the upload manifest, but their raw
116
+ * contents must be sent to Cloudflare in the script metadata's asset
117
+ * config (`config._headers` / `config._redirects`) for the rules to
118
+ * apply.
119
+ *
120
+ * The directory is optional: a config-only assets object (e.g. Vite's
121
+ * `assets: { runWorkerFirst: true }`, whose directory is supplied by the
122
+ * build) has no directory to read, and in `dev` there is no build output at
123
+ * all. That yields no rules rather than an error.
124
+ */
125
+ export const readAssetsConfigFiles = Effect.fn(function* (
126
+ directory: string | undefined,
127
+ ) {
128
+ if (directory === undefined) {
129
+ return { _headers: undefined, _redirects: undefined };
130
+ }
131
+ const path = yield* Path.Path;
132
+ const resolvedDirectory = path.resolve(directory);
133
+ const [_headers, _redirects] = yield* Effect.all([
134
+ maybeReadString(path.join(resolvedDirectory, "_headers")),
135
+ maybeReadString(path.join(resolvedDirectory, "_redirects")),
136
+ ]);
137
+ return { _headers, _redirects };
138
+ });
139
+
140
+ /**
141
+ * Merge `_headers` / `_redirects` file contents into an asset config,
142
+ * producing the config to send in the script-upload metadata. Explicit
143
+ * `headers` / `redirects` props win over the files.
144
+ */
145
+ export const mergeAssetsConfigFiles = (
146
+ config: AssetsConfig | undefined,
147
+ files: { _headers: string | undefined; _redirects: string | undefined },
148
+ ): AssetsConfig | undefined => {
149
+ const headers = config?.headers ?? files._headers;
150
+ const redirects = config?.redirects ?? files._redirects;
151
+ if (headers === undefined && redirects === undefined) {
152
+ return config;
153
+ }
154
+ return {
155
+ ...config,
156
+ ...(headers !== undefined ? { headers } : undefined),
157
+ ...(redirects !== undefined ? { redirects } : undefined),
158
+ };
159
+ };
160
+
113
161
  export const readAssets = Effect.fn(function* ({
114
162
  directory,
115
163
  ...config
@@ -193,7 +241,11 @@ export const readAssets = Effect.fn(function* ({
193
241
  });
194
242
  return {
195
243
  directory,
196
- config,
244
+ // Fold the `_headers` / `_redirects` file contents into the config
245
+ // that gets sent to Cloudflare (`metadata.assets.config`). Merged
246
+ // *after* hashing so the hash input shape stays stable for
247
+ // already-deployed workers.
248
+ config: mergeAssetsConfigFiles(config, { _headers, _redirects }),
197
249
  manifest: sortedManifest,
198
250
  _headers,
199
251
  _redirects,
@@ -8,6 +8,7 @@ import {
8
8
  type Assets as RuntimeAssets,
9
9
  type DurableObjectNamespace as RuntimeDurableObject,
10
10
  type QueueConsumer as RuntimeQueueConsumer,
11
+ type Workflow as RuntimeWorkflow,
11
12
  type RuntimeServices,
12
13
  } from "@distilled.cloud/cloudflare-runtime";
13
14
  import {
@@ -65,6 +66,7 @@ import { sha256, unwrapRedacted } from "../../Util/index.ts";
65
66
  import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
66
67
  import { LOCAL_ENTRY_URL, LocalRuntimeState } from "../LocalRuntime.ts";
67
68
  import type { WorkerAssetsConfig, WorkerProps } from "../Workers/Worker.ts";
69
+ import { readAssetsConfigFiles } from "./Assets.ts";
68
70
  import { getCompatibility } from "./Compatibility.ts";
69
71
  import { isPythonMain, watchPythonWorkerBundle } from "./PythonWorkerBundle.ts";
70
72
  import { Worker } from "./Worker.ts";
@@ -293,9 +295,10 @@ export const LocalWorkerProvider = () =>
293
295
  bindings: worker.workerBindings as never,
294
296
  hyperdrives: worker.hyperdrives,
295
297
  durableObjectNamespaces: worker.durableObjectNamespaces,
298
+ workflows: worker.workflows,
296
299
  queueConsumers,
297
300
  modules: yield* toRuntimeModules(bundle),
298
- assets: toRuntimeAssets(worker.assets),
301
+ assets: yield* toRuntimeAssets(worker.assets),
299
302
  })
300
303
  .pipe(Scope.provide(scope));
301
304
  workerdScopes.set(worker.id, scope);
@@ -408,6 +411,7 @@ export const LocalWorkerProvider = () =>
408
411
  string,
409
412
  RuntimeDurableObject & { uniqueKey: string }
410
413
  > = {};
414
+ const workflows: Record<string, RuntimeWorkflow> = {};
411
415
  const hyperdrives: Record<string, Required<HyperdriveOrigin>> = {};
412
416
  const containers: Record<string, ContainerImage> = {};
413
417
  for (const { data } of bindings) {
@@ -435,6 +439,18 @@ export const LocalWorkerProvider = () =>
435
439
  }),
436
440
  );
437
441
  } else {
442
+ if (
443
+ binding.type === "workflow" &&
444
+ // Same ownership rule as DOs: only declare workflows hosted by
445
+ // this worker. Cross-script workflow bindings are routed via
446
+ // the registry proxy.
447
+ (!binding.scriptName || binding.scriptName === name)
448
+ ) {
449
+ workflows[binding.workflowName] = {
450
+ workflowName: binding.workflowName,
451
+ className: binding.className,
452
+ };
453
+ }
438
454
  workerBindings.push(yield* toRuntimeBinding(binding));
439
455
  }
440
456
  }
@@ -479,6 +495,7 @@ export const LocalWorkerProvider = () =>
479
495
  compatibility,
480
496
  workerBindings,
481
497
  durableObjectNamespaces: Object.values(durableObjectNamespaces),
498
+ workflows: Object.values(workflows),
482
499
  viteMain: props.vite?.main,
483
500
  viteEnvironments: props.vite?.viteEnvironments,
484
501
  hyperdrives,
@@ -586,9 +603,10 @@ export const LocalWorkerProvider = () =>
586
603
  name: worker.name,
587
604
  bindings: worker.workerBindings,
588
605
  durableObjectNamespaces: worker.durableObjectNamespaces,
606
+ workflows: worker.workflows,
589
607
  hyperdrives: worker.hyperdrives,
590
608
  queueConsumers: yield* getQueueConsumers(worker.name),
591
- assets: toRuntimeAssets(worker.assets),
609
+ assets: yield* toRuntimeAssets(worker.assets),
592
610
  },
593
611
  context,
594
612
  },
@@ -949,19 +967,40 @@ const structuralSignature = (value: unknown): Effect.Effect<string> => {
949
967
  return sha256(JSON.stringify(normalize(value)));
950
968
  };
951
969
 
952
- const toRuntimeAssets = (
970
+ const toRuntimeAssets = Effect.fn(function* (
953
971
  assets: WorkerAssetsConfig | undefined,
954
- ): RuntimeAssets | undefined => {
972
+ ) {
955
973
  if (!assets) return undefined;
974
+ // Mirror the deploy path: the special `_headers` / `_redirects` files
975
+ // in the assets directory carry the rules unless overridden by
976
+ // explicit `headers` / `redirects` props. The local runtime parses
977
+ // the raw string contents just like Cloudflare does.
978
+ //
979
+ // A Vite website's `assets` is config-only (`{ runWorkerFirst: true }`) —
980
+ // the client output directory is the build's business, and in `dev` the
981
+ // vite plugin serves assets from the dev server, so there is no directory
982
+ // to read here.
983
+ const directory: string | undefined =
984
+ typeof assets === "string" ? assets : assets.directory;
985
+ // An unreadable file just means no rules here — the assets plugin
986
+ // reports directory problems itself.
987
+ const files = yield* readAssetsConfigFiles(directory).pipe(
988
+ Effect.orElseSucceed(() => ({
989
+ _headers: undefined,
990
+ _redirects: undefined,
991
+ })),
992
+ );
956
993
  if (typeof assets === "string") {
957
994
  return {
958
995
  directory: assets,
996
+ headers: files._headers,
997
+ redirects: files._redirects,
959
998
  };
960
999
  }
961
1000
  return {
962
1001
  directory: assets.directory,
963
- headers: assets.headers,
964
- redirects: assets.redirects,
1002
+ headers: assets.headers ?? files._headers,
1003
+ redirects: assets.redirects ?? files._redirects,
965
1004
  // Distilled widened generated string enums to open unions (`string & {}`);
966
1005
  // the API only ever returns the known variants here.
967
1006
  htmlHandling: assets.htmlHandling as
@@ -978,7 +1017,7 @@ const toRuntimeAssets = (
978
1017
  runWorkerFirst: assets.runWorkerFirst,
979
1018
  serveDirectly: assets.serveDirectly,
980
1019
  };
981
- };
1020
+ });
982
1021
 
983
1022
  const moduleTypeFromExtension = (ext: string): Module["type"] | "SourceMap" => {
984
1023
  switch (ext) {
@@ -7,7 +7,6 @@ import * as Data from "effect/Data";
7
7
  import * as Effect from "effect/Effect";
8
8
  import type * as Layer from "effect/Layer";
9
9
  import * as Redacted from "effect/Redacted";
10
- import * as Bundle from "../../Bundle/Bundle.ts";
11
10
  import { type MemoOptions } from "../../Command/Memo.ts";
12
11
  import type { Dependencies } from "../../Dependencies.ts";
13
12
  import type { InputProps } from "../../Input.ts";
@@ -45,7 +44,7 @@ import type {
45
44
  WorkerBindingResource,
46
45
  WorkerBindings,
47
46
  } from "./WorkerBinding.ts";
48
- import { type ModuleRule } from "./WorkerBundle.ts";
47
+ import { type ModuleRule, type WorkerBuildOptions } from "./WorkerBundle.ts";
49
48
  import {
50
49
  makeWorkerRuntimeContext,
51
50
  type WorkerRuntimeContext,
@@ -478,10 +477,13 @@ export interface WorkerProps<
478
477
  */
479
478
  routes?: WorkerRouteConfig[];
480
479
  /**
481
- * Extra bundler options applied on top of the standard rolldown input/output
482
- * options used to build this Worker. See {@link Bundle.BundleExtraOptions}.
480
+ * Extra bundler options applied on top of the standard rolldown
481
+ * input/output options used to build this Worker. Includes the generic
482
+ * bundle extras (pure-annotation packages, bundle analyzer) plus an
483
+ * `output` field of rolldown output overrides (e.g. `codeSplitting`
484
+ * groups) merged over Alchemy's defaults. See {@link WorkerBuildOptions}.
483
485
  */
484
- build?: Bundle.BundleExtraOptions;
486
+ build?: WorkerBuildOptions;
485
487
  /**
486
488
  * Whether to bundle {@link main} with rolldown before upload.
487
489
  *