1import * as Context from "effect/Context";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Formatter from "effect/Formatter";
5import * as Schedule from "effect/Schedule";
6import picomatch from "picomatch";
7import {
8 isProviderCollectionService,
9 isProviderService,
10 providerForMode,
11 type ProviderService,
12} from "./Provider.ts";
13import type { ProviderMode } from "./ProviderMode.ts";
14import { stronglyConnectedComponents } from "./Util/scc.ts";
15
16// Account-wide teardown: enumerate everything the registered providers can see
17// and delete it, ignoring stack state entirely. This is the engine behind
18// `alchemy nuke` — a blunt instrument for cleaning a test account, not part of
19// any stack's lifecycle.
20
21/** A provider call that failed. These are collected into results, never thrown. */
22export interface ProviderFailure {
23 readonly provider: string;
24 readonly operation: "list" | "delete";
25 readonly message: string;
26}
27
28/** One cloud object a provider's `list()` turned up, bound to its provider. */
29export interface Target {
30 readonly providerId: string;
31 readonly displayName: string;
32 readonly attributes: Record<string, unknown>;
33 readonly provider: ProviderService;
34}
35
36export interface DiscoverOptions {
37 readonly mode: ProviderMode;
38 /** Provider-id globs to include. Omitted means every provider. */
39 readonly include?: ReadonlyArray<string>;
40 /** Provider-id globs to exclude. Applied after {@link DiscoverOptions.include}. */
41 readonly exclude?: ReadonlyArray<string>;
42}
43
44export interface ListOptions extends DiscoverOptions {
45 /** The built provider context — what a stack's `providers` layer produced. */
46 readonly context: Context.Context<never>;
47 readonly concurrency?: number | "unbounded";
48 readonly timeoutSeconds?: number;
49 /** Called once with the selected provider count before enumeration starts. */
50 readonly onScan?: (total: number) => Effect.Effect<void>;
51 /** Called when a provider starts resolving/listing, to identify slow scans. */
52 readonly onProviderStarted?: (provider: string) => Effect.Effect<void>;
53 /**
54 * Called as each provider's listing settles (0 resources when it failed),
55 * for progress reporting — scans across many providers are the slowest
56 * part of a nuke and would otherwise be silent until the summary.
57 */
58 readonly onProvider?: (
59 provider: string,
60 resources: number,
61 error?: string,
62 ) => Effect.Effect<void>;
63}
64
65export type Strategy =
66 /** Delete in dependency order, retrying a wave until it stops making progress. */
67 | { readonly _tag: "coordinated" }
68 /** Delete everything at once, retrying each object on its own. */
69 | { readonly _tag: "independent"; readonly retries: number };
70
71export interface DestroyOptions {
72 readonly targets: ReadonlyArray<Target>;
73 readonly context: Context.Context<never>;
74 readonly strategy: Strategy;
75 readonly concurrency?: number | "unbounded";
76 readonly timeoutSeconds?: number;
77 /** Called before each coordinated pass, or once for independent deletion. */
78 readonly onPass?: (pass: number) => Effect.Effect<void>;
79 /** Called before each deletion attempt. */
80 readonly onDeleting?: (resource: Target) => Effect.Effect<void>;
81 /** Called when dependency failures prevent a resource from being attempted. */
82 readonly onHeld?: (
83 resource: Target,
84 blockedBy: ReadonlyArray<string>,
85 ) => Effect.Effect<void>;
86 /** Called as each object is confirmed gone, for progress reporting. */
87 readonly onDeleted?: (resource: Target) => Effect.Effect<void>;
88 /** Called as a deletion attempt fails permanently (the run keeps going). */
89 readonly onFailed?: (
90 resource: Target,
91 message: string,
92 ) => Effect.Effect<void>;
93}
94
95export interface Result {
96 readonly requested: number;
97 readonly deleted: ReadonlyArray<Target>;
98 readonly failed: ReadonlyArray<{
99 readonly resource: Target;
100 readonly failure: ProviderFailure;
101 }>;
102 /** Skipped because something they depend on could not be deleted first. */
103 readonly held: ReadonlyArray<{
104 readonly resource: Target;
105 readonly blockedBy: ReadonlyArray<string>;
106 }>;
107 readonly passes: number;
108}
109
110interface DiscoveredProvider {
111 readonly id: string;
112 readonly provider: ProviderService;
113}
114
115const failure = (
116 provider: string,
117 operation: ProviderFailure["operation"],
118 cause: unknown,
119): ProviderFailure => ({
120 provider,
121 operation,
122 message: typeof cause === "string" ? cause : Formatter.format(cause),
123});
124
125/** Deletions here run out-of-band, so nothing reports through an apply session. */
126const silent = {
127 emit: () => Effect.void,
128 done: () => Effect.void,
129 note: () => Effect.void,
130};
131
132/**
133 * Walk the built provider context for everything deletable. A provider opts
134 * out with `nuke.skip` (not ours to delete) or `nuke.singleton` (an
135 * account-level object that has to survive).
136 *
137 * Dual registrations (`ProviderLayer.dual`) build nothing until asked and
138 * keep their `nuke` metadata on the variants, so each provider is resolved
139 * to the scan mode's variant first — mode-agnostic providers resolve to
140 * themselves in a live scan and are not part of a local one.
141 */
142const discover = Effect.fn(function* (
143 context: Context.Context<never>,
144 { mode, include, exclude }: DiscoverOptions,
145) {
146 const registered = new Map<string, ProviderService>();
147 for (const [key, value] of context.mapUnsafe.entries()) {
148 if (isProviderCollectionService(value)) {
149 for (const [id, provider] of Object.entries(value.providers)) {
150 registered.set(id, provider);
151 }
152 } else if (
153 typeof key === "string" &&
154 key.includes(".") &&
155 isProviderService(value)
156 ) {
157 registered.set(key, value);
158 }
159 }
160 const included = include?.length ? picomatch([...include]) : () => true;
161 const excluded = exclude?.length ? picomatch([...exclude]) : () => false;
162 const output: DiscoveredProvider[] = [];
163 for (const [id, registration] of registered) {
164 if (!included(id) || excluded(id)) continue;
165 if (mode === "local" && registration.modes === undefined) continue;
166 const provider = yield* providerForMode(registration, mode);
167 if (provider.nuke?.singleton || provider.nuke?.skip) continue;
168 output.push({ id, provider });
169 }
170 return output.sort((a, b) => a.id.localeCompare(b.id));
171});
172const nameKeys = [
173 "workerName",
174 "functionName",
175 "bucketName",
176 "tableName",
177 "queueName",
178 "repositoryName",
179 "databaseName",
180 "projectName",
181 "domainName",
182 "hostname",
183 "displayName",
184 "name",
185];
186
187const displayName = (attributes: Record<string, unknown>) =>
188 nameKeys
189 .map((key) => attributes[key])
190 .find(
191 (value): value is string => typeof value === "string" && value.length > 0,
192 ) ??
193 Object.values(attributes).find(
194 (value): value is string => typeof value === "string" && value.length > 0,
195 ) ??
196 "unknown";
197
198const groupBy = <T>(items: ReadonlyArray<T>, key: (item: T) => string) => {
199 const groups = new Map<string, T[]>();
200 for (const item of items) {
201 const id = key(item);
202 groups.set(id, [...(groups.get(id) ?? []), item]);
203 }
204 return groups;
205};
206
207const addEdge = (edges: Map<string, Set<string>>, from: string, to: string) => {
208 const values = edges.get(from) ?? new Set<string>();
209 values.add(to);
210 edges.set(from, values);
211};
212
213/** Ask every selected provider what exists. Provider failures are collected, not thrown. */
214export const list = (
215 options: ListOptions,
216): Effect.Effect<{
217 readonly resources: ReadonlyArray<Target>;
218 readonly failures: ReadonlyArray<ProviderFailure>;
219}> =>
220 Effect.gen(function* () {
221 const failures: ProviderFailure[] = [];
222 const resources: Target[] = [];
223 const providers = yield* discover(options.context, options).pipe(
224 Effect.provide(options.context),
225 );
226 yield* options.onScan?.(providers.length) ?? Effect.void;
227 yield* Effect.forEach(
228 providers,
229 ({ id, provider }) =>
230 Effect.gen(function* () {
231 yield* options.onProviderStarted?.(id) ?? Effect.void;
232 const result = yield* Effect.result(
233 Effect.gen(function* () {
234 const listed = yield* provider
235 .list()
236 .pipe(
237 Effect.timeout(
238 Duration.seconds(options.timeoutSeconds ?? 120),
239 ),
240 );
241 return { provider, listed };
242 }).pipe(Effect.provide(options.context)),
243 );
244 if (result._tag === "Failure") {
245 const error = failure(id, "list", result.failure);
246 failures.push(error);
247 yield* options.onProvider?.(id, 0, error.message) ?? Effect.void;
248 return;
249 }
250 for (const raw of result.success.listed) {
251 const attributes = (raw ?? {}) as Record<string, unknown>;
252 resources.push({
253 providerId: id,
254 displayName: displayName(attributes),
255 attributes,
256 provider: result.success.provider,
257 });
258 }
259 yield* (
260 options.onProvider?.(id, result.success.listed.length) ??
261 Effect.void
262 );
263 }),
264 { concurrency: options.concurrency ?? 16, discard: true },
265 );
266 return { resources, failures };
267 });
268
269/** Delete the given targets under the chosen {@link Strategy}. */
270export const destroy = ({
271 targets,
272 context,
273 strategy,
274 concurrency = 16,
275 timeoutSeconds = 120,
276 onDeleted = () => Effect.void,
277 onFailed = () => Effect.void,
278 onPass = () => Effect.void,
279 onDeleting = () => Effect.void,
280 onHeld = () => Effect.void,
281}: DestroyOptions): Effect.Effect<Result> =>
282 Effect.gen(function* () {
283 const deleted: Target[] = [];
284 const failed: Array<Result["failed"][number]> = [];
285 const held: Array<Result["held"][number]> = [];
286 let passes = 0;
287 // Account-wide scans have no state rows: delete must derive identity from
288 // `output` alone — `olds` just mirrors it and `instanceId` is unavailable.
289 const attempt = (resource: Target) =>
290 Effect.gen(function* () {
291 yield* onDeleting(resource);
292 return yield* resource.provider
293 .delete({
294 id: resource.displayName,
295 fqn: resource.displayName,
296 instanceId: "",
297 olds: resource.attributes as never,
298 output: resource.attributes as never,
299 session: silent,
300 bindings: [],
301 force: true,
302 })
303 .pipe(
304 Effect.timeout(Duration.seconds(timeoutSeconds)),
305 Effect.provide(context),
306 );
307 });
308
309 if (strategy._tag === "independent") {
310 passes = 1;
311 yield* onPass(passes);
312 yield* Effect.forEach(
313 targets,
314 (resource) =>
315 Effect.result(
316 attempt(resource).pipe(
317 Effect.retry({
318 schedule: Schedule.min([
319 Schedule.exponential("1 second"),
320 Schedule.spaced("15 seconds"),
321 ]),
322 times: strategy.retries,
323 }),
324 ),
325 ).pipe(
326 Effect.tap((result) => {
327 if (result._tag === "Success") {
328 deleted.push(resource);
329 return onDeleted(resource);
330 }
331 const why = failure(
332 resource.providerId,
333 "delete",
334 result.failure,
335 );
336 failed.push({ resource, failure: why });
337 return onFailed(resource, why.message);
338 }),
339 ),
340 { concurrency: concurrency, discard: true },
341 );
342 } else {
343 const typeIds = [...new Set(targets.map(({ providerId }) => providerId))];
344 const providerOf = new Map(
345 targets.map(({ providerId, provider }) => [providerId, provider]),
346 );
347 const successors = new Map<string, Set<string>>();
348 const predecessors = new Map<string, Set<string>>();
349 for (const id of typeIds) {
350 const globs = providerOf.get(id)?.nuke?.dependsOn;
351 if (!globs?.length) continue;
352 const matches = picomatch([...globs]);
353 for (const other of typeIds) {
354 if (other === id || !matches(other)) continue;
355 addEdge(successors, id, other);
356 addEdge(predecessors, other, id);
357 }
358 }
359 const grouped = stronglyConnectedComponents(
360 typeIds,
361 (id) => successors.get(id) ?? [],
362 );
363 const componentOf = new Map<string, number>();
364 grouped.forEach((component, index) =>
365 component.forEach((id) => componentOf.set(id, index)),
366 );
367 const layerOf = grouped.map(() => 0);
368 for (let index = grouped.length - 1; index >= 0; index--) {
369 for (const id of grouped[index]!) {
370 for (const predecessor of predecessors.get(id) ?? []) {
371 const predecessorComponent = componentOf.get(predecessor)!;
372 if (predecessorComponent !== index) {
373 layerOf[index] = Math.max(
374 layerOf[index]!,
375 layerOf[predecessorComponent]! + 1,
376 );
377 }
378 }
379 }
380 }
381 const waves: string[][] = [];
382 grouped.forEach((component, index) =>
383 (waves[layerOf[index]!] ??= []).push(...component),
384 );
385 const byType = groupBy(targets, ({ providerId }) => providerId);
386 const remainingCount = new Map(
387 [...byType].map(([id, resources]) => [id, resources.length]),
388 );
389 for (const wave of waves) {
390 let runnable: Target[] = [];
391 const heldTypes = new Set<string>();
392 for (const typeId of wave) {
393 const resources = byType.get(typeId) ?? [];
394 const blockers = [...(predecessors.get(typeId) ?? [])].filter(
395 (predecessor) =>
396 componentOf.get(predecessor) !== componentOf.get(typeId) &&
397 (remainingCount.get(predecessor) ?? 0) > 0,
398 );
399 if (blockers.length > 0) {
400 heldTypes.add(typeId);
401 yield* Effect.forEach(resources, (resource) =>
402 onHeld(resource, blockers),
403 );
404 held.push(
405 ...resources.map((resource) => ({
406 resource,
407 blockedBy: blockers,
408 })),
409 );
410 } else runnable = [...runnable, ...resources];
411 }
412 let remaining = runnable;
413 while (remaining.length > 0) {
414 passes += 1;
415 yield* onPass(passes);
416 const results = yield* Effect.forEach(
417 remaining,
418 (resource) =>
419 Effect.gen(function* () {
420 const result = yield* Effect.result(attempt(resource));
421 if (result._tag === "Success") {
422 deleted.push(resource);
423 yield* onDeleted(resource);
424 }
425 return { resource, result };
426 }),
427 { concurrency: concurrency },
428 );
429 const next: Target[] = [];
430 for (const { resource, result } of results) {
431 if (result._tag === "Failure") next.push(resource);
432 }
433 if (next.length === remaining.length) {
434 for (const { resource, result } of results) {
435 if (result._tag !== "Failure") continue;
436 const why = failure(
437 resource.providerId,
438 "delete",
439 result.failure,
440 );
441 failed.push({ resource, failure: why });
442 yield* onFailed(resource, why.message);
443 }
444 break;
445 }
446 remaining = next;
447 }
448 const failuresByType = groupBy(
449 failed.map(({ resource }) => resource),
450 ({ providerId }) => providerId,
451 );
452 for (const typeId of wave) {
453 // Held types were never attempted: keep their full count so waves
454 // gated on them stay blocked instead of seeing a spurious zero.
455 if (heldTypes.has(typeId)) continue;
456 remainingCount.set(typeId, failuresByType.get(typeId)?.length ?? 0);
457 }
458 }
459 }
460 return {
461 requested: targets.length,
462 deleted,
463 failed,
464 held,
465 passes,
466 } satisfies Result;
467 });