1import type {
2 ResourceSelection,
3 SelectionOutput,
4} from "../ResourceSelection.ts";
5/** @effect-diagnostics anyUnknownInErrorContext:off */
6
7import * as Floci from "@alchemy.run/floci";
8import * as Config from "effect/Config";
9import { ConfigProvider } from "effect/ConfigProvider";
10import * as Context from "effect/Context";
11import * as Effect from "effect/Effect";
12import * as Exit from "effect/Exit";
13import * as Layer from "effect/Layer";
14import * as Option from "effect/Option";
15import * as Scope from "effect/Scope";
16import * as FetchHttpClient from "effect/http/FetchHttpClient";
17import * as HttpClient from "effect/http/HttpClient";
18import * as HttpClientRequest from "effect/http/HttpClientRequest";
19
20import { DEFAULT_LOCAL_ENDPOINT } from "../AWS/AuthProvider.ts";
21import { flociServices } from "../AWS/Local/FlociServices.ts";
22import { AdoptPolicy } from "../AdoptPolicy.ts";
23import { AlchemyContext, AlchemyContextLive } from "../AlchemyContext.ts";
24import { apply } from "../Apply.ts";
25import { provideFreshArtifactStore } from "../Artifacts.ts";
26import { AuthProviders } from "../Auth/AuthProvider.ts";
27import { CredentialsStoreLive } from "../Auth/Credentials.ts";
28import { ProfileStoreLive } from "../Auth/Profile.ts";
29import { withProfileOverride } from "../Auth/Resolve.ts";
30import * as Interaction from "../Interaction.ts";
31import { userStage } from "../Cli/commands/flags.ts";
32import { LoggingCli } from "../Cli/LoggingCli.ts";
33import { deploy as _deploy } from "../Deploy.ts";
34import { destroy as _destroy } from "../Destroy.ts";
35import type { Input } from "../Input.ts";
36import * as RpcProviderProxy from "../Local/RpcProviderProxy.ts";
37import * as RpcSpawner from "../Local/RpcSpawner.ts";
38import { ALCHEMY_DEV } from "../Phase.ts";
39import * as Plan from "../Plan.ts";
40import {
41 type CompiledStack,
42 make as makeStack,
43 Stack,
44 type StackEffect,
45 type StackServices,
46} from "../Stack.ts";
47import { Stage } from "../Stage.ts";
48import * as State from "../State/index.ts";
49import { TelemetryLive } from "../Telemetry/Layer.ts";
50import {
51 loadConfigProvider,
52 StackConfigOverrides,
53} from "../Util/ConfigProvider.ts";
54import { PlatformServices } from "../Util/PlatformServices.ts";
55
56/**
57 * Configuration shared by every test in a file. Pass to `Test.make(...)`.
58 */
59export interface MakeOptions<ROut = any> {
60 /** Provider layer for the stack — e.g. `AWS.providers()`, `Cloudflare.providers()`. */
61 providers: Layer.Layer<ROut, never, StackServices>;
62 /** State store for top-level `deploy(Stack)` / `destroy(Stack)`; defaults to {@link State.localState}. */
63 state?: Layer.Layer<State.State, never, StackServices>;
64 /** Override the current profile; otherwise resolved from env or the built-in `default`. */
65 profile?: string;
66 /**
67 * Default stage for deploy/destroy. Defaults to `test_$USER` (or
68 * `test_$USERNAME` on Windows, `test_unknown` if neither is set) so two
69 * people running the same suite against one account don't collide.
70 * Does **not** read `$ALCHEMY_STAGE` — that is the CLI deploy/dev stage.
71 */
72 stage?: string;
73 /**
74 * Engine-level adoption policy for this test run. When `true`, resources
75 * without prior state will be adopted from the cloud via `provider.read`
76 * (matching the CLI's `--adopt` flag). Defaults to `false`.
77 */
78 adopt?: boolean;
79 /**
80 * Run providers in local-dev mode (matching the CLI's `alchemy dev` flag).
81 * When `true`, resources like Cloudflare Workers run locally via workerd
82 * instead of being deployed to the cloud. When omitted, falls back to the
83 * `ALCHEMY_DEV` environment variable (`"1"` / `"true"` enable it).
84 *
85 * {@link ALCHEMY_TEST_DEV} (`ALCHEMY_TEST_DEV=1`) overrides this — use it
86 * to force an entire existing live suite through local providers without
87 * editing each `Test.make({ dev: true })`.
88 */
89 dev?: boolean;
90 /**
91 * Run local providers behind the RPC sidecar proxy, matching the process
92 * topology of the real `alchemy dev` command: an {@link RpcProviderProxy}
93 * is installed, so `RpcProvider.providerServicesEffect` layers (e.g.
94 * Cloudflare's `localRuntimeServices()`) are EMPTY in the test process and
95 * RPC-backed providers run their lifecycle in a spawned sidecar process.
96 *
97 * Defaults to the resolved `dev` flag — dev tests run the real `alchemy
98 * dev` topology unless they opt out. In-process dev (`sidecar: false`)
99 * masks missing main-process lifecycle dependencies (the class of bug
100 * behind #1007, where D1's local migrations only failed under `alchemy
101 * dev`); it remains available because it IS still a real production path —
102 * a plain `alchemy deploy` deleting a `providerMode: "local"` state row
103 * runs the local provider in-process — and because in-process runs are
104 * easier to debug.
105 *
106 * Fully lazy: only the proxy facade is installed up front. The spawner
107 * HTTP server starts — and a sidecar child process is forked — on the
108 * first provider session request (a deploy/destroy building an RPC-backed
109 * local provider); a dev file that never does starts no processes at all.
110 * Once started, the sidecar lives for the whole test file (its own scope,
111 * closed by the adapter's final cleanup hook), so `beforeAll(deploy(Stack))`
112 * + per-test requests work the same way they do under a real `alchemy dev`
113 * session.
114 */
115 sidecar?: boolean;
116}
117
118/**
119 * The RPC sidecar topology used by the real `alchemy dev` command, in one
120 * process-local layer: an {@link RpcSpawner} HTTP server that forks sidecar
121 * processes on demand, and an {@link RpcProviderProxy} pointed at it.
122 */
123export const sidecarProxy = (options: { profile?: string }) =>
124 Layer.unwrap(
125 Effect.map(RpcSpawner.RpcSpawner, (spawner) =>
126 RpcProviderProxy.layer(spawner.url),
127 ),
128 ).pipe(
129 Layer.provideMerge(
130 RpcSpawner.layerServer({
131 profile: options.profile ?? process.env.ALCHEMY_PROFILE,
132 envFile: undefined,
133 }),
134 ),
135 );
136
137/**
138 * Force every `Test.make` into (or out of) local-dev mode, regardless of
139 * the file's `dev` option. Unset leaves the option / `ALCHEMY_DEV` fallback
140 * in place. Accepts the usual truthy/falsey strings (`true`/`1`/`yes`/`on`,
141 * `false`/`0`/`no`/`off`).
142 */
143export const ALCHEMY_TEST_DEV = Config.Boolean("ALCHEMY_TEST_DEV").pipe(
144 Config.option,
145);
146
147/** The `ALCHEMY_TEST_DEV` override, if the env var is set. */
148export const alchemyTestDevOverride = (): Option.Option<boolean> =>
149 Effect.runSync(ALCHEMY_TEST_DEV);
150
151/** Resolve the effective `dev` flag: `ALCHEMY_TEST_DEV`, then options, then `ALCHEMY_DEV`. */
152export const resolveDev = (options: { dev?: boolean }): boolean => {
153 const override = alchemyTestDevOverride();
154 if (Option.isSome(override)) return override.value;
155 if (options.dev !== undefined) return options.dev;
156 return Effect.runSync(ALCHEMY_DEV);
157};
158
159/** Resolve the effective `sidecar` flag: defaults to the resolved `dev` flag. */
160export const resolveSidecar = (options: MakeOptions): boolean =>
161 options.sidecar ?? resolveDev(options);
162
163/**
164 * Default test stage: `ALCHEMY_TEST_STAGE`, then `test_$USER` (or
165 * `test_$USERNAME` / `test_unknown`). Set an explicit test stage to isolate
166 * concurrent worktrees that share a cloud account.
167 * Matches the CLI's `live_$USER` / `dev_$USER` per-developer isolation.
168 */
169export const defaultStage = (): string =>
170 process.env.ALCHEMY_TEST_STAGE || Effect.runSync(userStage("test"));
171
172/** File-level stage: a string override, otherwise {@link defaultStage}. */
173export const resolveStage = (options: { stage?: string }): string =>
174 // Configured stacks carry a cross-stack reference proxy, not a stage override.
175 typeof options.stage === "string" ? options.stage : defaultStage();
176
177/**
178 * The sidecar runtime handed to each adapter's `make(...)`.
179 *
180 * `provide` installs a lazy {@link RpcProviderProxy} facade into an effect.
181 * Installing the facade is free: the spawner HTTP server only starts (and,
182 * downstream of it, a sidecar child process is only forked) when a provider
183 * actually requests a session — i.e. when a deploy/destroy builds an
184 * RPC-backed local provider. A dev file that never does starts nothing.
185 *
186 * The spawner (and the sidecar children it forks) is a PROCESS-WIDE
187 * SINGLETON shared by every test file, refcounted per handle: all files run
188 * in one bun process, and a per-file sidecar means a per-file bun child that
189 * imports the entire alchemy + distilled module graph — dozens of concurrent
190 * files at hundreds of MB each OOMs the machine. Stack isolation is
191 * preserved because each RPC session carries its own stack environment (see
192 * `SESSION_ENV_PARAM` in Local/RpcServerEnvironment.ts) and the child builds
193 * a provider context per stack. The singleton's scope closes when the LAST
194 * handle closes; `Test.make` runs at collection time (before any test), so
195 * the refcount cannot dip to zero while later files still need it. Adapters
196 * run `close` from the same final cleanup hook that closes the shared scope.
197 */
198export interface SidecarHandle {
199 readonly provide: <A, E, R>(
200 eff: Effect.Effect<A, E, R>,
201 ) => Effect.Effect<A, any, any>;
202 readonly close: Effect.Effect<void>;
203}
204
205interface SidecarSingleton {
206 readonly lazy: Layer.Layer<RpcProviderProxy.RpcProviderProxy>;
207 readonly scope: Scope.Closeable;
208 refs: number;
209}
210
211const sidecarSingletons = new Map<string, SidecarSingleton>();
212
213export const makeSidecarHandle = <ROut = any>(
214 options: MakeOptions<ROut>,
215): SidecarHandle | undefined => {
216 if (!resolveSidecar(options)) return undefined;
217 const key = options.profile ?? process.env.ALCHEMY_PROFILE ?? "";
218 let singleton = sidecarSingletons.get(key);
219 if (singleton === undefined) {
220 const scope = Scope.makeUnsafe("sequential");
221 const memoMap = Layer.makeMemoMapUnsafe();
222 const real = sidecarProxy(options);
223 const lazy = Layer.effect(
224 RpcProviderProxy.RpcProviderProxy,
225 Effect.gen(function* () {
226 // Capture the ambient platform context (provided by `toEffect`) so
227 // the deferred spawner build can run inside a provider's `get`
228 // without leaking platform requirements onto the RpcProviderProxy
229 // interface. Omit Scope: that key is the calling file's sharedScope
230 // (closed in afterAll). Merging it in would pin the process-wide
231 // spawner HTTP server to a file that exits while others still need
232 // it. Provide the sidecar singleton scope instead.
233 const ambient = Context.omit(Scope.Scope)(
234 yield* Effect.context<never>(),
235 );
236 const realProxy = Layer.buildWithMemoMap(real, memoMap, scope).pipe(
237 Effect.map((built) =>
238 Context.get(built, RpcProviderProxy.RpcProviderProxy),
239 ),
240 Effect.provideContext(ambient as Context.Context<any>),
241 Scope.provide(scope),
242 Effect.orDie,
243 );
244 return RpcProviderProxy.RpcProviderProxy.of({
245 get: (providersUrl, providerName) =>
246 Effect.flatMap(realProxy, (proxy) =>
247 proxy.get(providersUrl, providerName),
248 ),
249 });
250 }),
251 );
252 singleton = { lazy, scope, refs: 0 };
253 sidecarSingletons.set(key, singleton);
254 }
255 singleton.refs += 1;
256 const instance = singleton;
257 let closed = false;
258 return {
259 provide: (eff) => Effect.provide(eff, instance.lazy),
260 close: Effect.suspend(() => {
261 // Idempotent per handle: destroy(Stack) and the fallback afterAll can
262 // both run it without double-decrementing.
263 if (closed) return Effect.void;
264 closed = true;
265 instance.refs -= 1;
266 if (instance.refs > 0) return Effect.void;
267 if (sidecarSingletons.get(key) === instance) {
268 sidecarSingletons.delete(key);
269 }
270 return Scope.close(instance.scope, Exit.void);
271 }).pipe(Effect.ignore),
272 };
273};
274
275const overrideAlchemyContext = (overrides: { dev: boolean }) =>
276 Layer.effect(
277 AlchemyContext,
278 AlchemyContext.pipe(Effect.map((ctx) => ({ ...ctx, ...overrides }))),
279 );
280
281export type TestEffect<A, Req = never> = StackEffect<A, any, Req>;
282
283/**
284 * Floci serves virtual-host data planes on the gateway when the Host
285 * header is the AWS hostname (`{bucket}.s3-website-{region}.amazonaws.com`,
286 * `{apiId}.appsync-api.{region}.amazonaws.com`,
287 * `{apiId}.execute-api.{region}.amazonaws.com`,
288 * `{distributionId}.cloudfront.net`). Live tests GET those hosts; under
289 * {@link ALCHEMY_TEST_DEV} rewrite the URL to the emulator and keep the
290 * Host so the virtual-host filter still fires.
291 */
292const rewriteAwsVirtualHostToFloci = (
293 request: HttpClientRequest.HttpClientRequest,
294): HttpClientRequest.HttpClientRequest => {
295 let url: URL;
296 try {
297 url = new URL(request.url);
298 } catch {
299 return request;
300 }
301 if (
302 !/\.s3-website-[a-z0-9-]+\.amazonaws\.com$/i.test(url.hostname) &&
303 !/\.appsync-api\.[a-z0-9-]+\.amazonaws\.com$/i.test(url.hostname) &&
304 !/\.execute-api\.[a-z0-9-]+\.amazonaws\.com$/i.test(url.hostname) &&
305 !/\.cloudfront\.net$/i.test(url.hostname)
306 ) {
307 return request;
308 }
309 const rewritten = new URL(url.href);
310 const endpoint = new URL(DEFAULT_LOCAL_ENDPOINT);
311 rewritten.protocol = endpoint.protocol;
312 rewritten.hostname = endpoint.hostname;
313 rewritten.port = endpoint.port;
314 return request.pipe(
315 HttpClientRequest.setUrl(rewritten.toString()),
316 HttpClientRequest.setHeader("host", url.hostname),
317 );
318};
319
320if (Option.getOrElse(alchemyTestDevOverride(), () => false)) {
321 // Floci rewrites WebSocket invoke URLs onto wss://127.0.0.1:4566/ws/...
322 // The gateway cert is self-signed; Bun/Node would otherwise reject the
323 // upgrade. Scoped to ALCHEMY_TEST_DEV only.
324 process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
325 // Local workerd trusts no self-signed certs and ignores the flag above
326 // (it's C++, not Node). Its runtime DOES fold `NODE_EXTRA_CA_CERTS` into
327 // workerd's outbound `trustedCertificates` (see cloudflare-runtime
328 // Internet.ts), so point it at the emulator CA bundle that `ensureFloci`
329 // refreshes on every health check. Set here — before the RPC spawner or
330 // any vite child forks — so the whole dev process tree inherits it.
331 process.env.NODE_EXTRA_CA_CERTS ??= Floci.FLOCI_CA_PATH;
332}
333
334const flociWebsiteHttp = Layer.effect(
335 HttpClient.HttpClient,
336 Effect.map(HttpClient.HttpClient, (client) =>
337 HttpClient.mapRequest(client, rewriteAwsVirtualHostToFloci),
338 ),
339).pipe(Layer.provide(FetchHttpClient.layer));
340
341const platformLayer = () =>
342 Layer.mergeAll(
343 PlatformServices,
344 Option.getOrElse(alchemyTestDevOverride(), () => false)
345 ? flociWebsiteHttp
346 : FetchHttpClient.layer,
347 Layer.provide(ProfileStoreLive, PlatformServices),
348 Layer.provide(CredentialsStoreLive, PlatformServices),
349 );
350
351const alchemyLayer = Layer.mergeAll(
352 LoggingCli,
353 Interaction.layerNonInteractive(),
354 AlchemyContextLive,
355);
356
357/**
358 * Build the per-test runtime and return a self-contained Effect.
359 *
360 * Mirrors {@link "../bin/alchemy.js"} composition: ConfigProvider via
361 * `loadConfigProvider` + `withProfileOverride`, an empty `AuthProviders`
362 * registry that the user's `providers` layer populates, the platform layers,
363 * and the configured state store. Adapters wrap this into runner-specific
364 * thunks (`bun.test` -> `runPromise`, `it.live` -> as-is).
365 *
366 * When `scope` is provided, scoped resources (like the Cloudflare dev
367 * sidecar) survive past this effect and are tied to the lifetime of the
368 * provided scope instead. The runner is responsible for closing it.
369 *
370 * When `scope` is omitted, the effect runs with `Effect.scoped` and any
371 * scoped resources are torn down as soon as it resolves.
372 */
373export const toEffect = <A, ROut = any>(
374 effect: TestEffect<A>,
375 options: MakeOptions<ROut>,
376 scope?: Scope.Scope,
377 sidecar?: SidecarHandle,
378): Effect.Effect<A, any, never> => {
379 const base = Effect.gen(function* () {
380 const cfg = yield* loadConfigProvider(Option.none());
381 const configProvider = withProfileOverride(cfg, options.profile);
382 // `ALCHEMY_TEST_DEV=1` forces local providers AND points the test
383 // process's distilled AWS clients at the emulator. Otherwise
384 // out-of-band `describeTable` / `getFunction` calls still hit the
385 // live account and fail with ResourceNotFound. Existing
386 // `Test.make({ dev: true })` files are unchanged (mixed
387 // `Alchemy.remote()` suites keep their live SDK).
388 const body = sidecar ? sidecar.provide(effect) : effect;
389 const locally =
390 Option.getOrElse(alchemyTestDevOverride(), () => false) === true
391 ? Effect.provide(body, flociServices())
392 : body;
393 return yield* locally.pipe(
394 provideFreshArtifactStore,
395 Effect.provide(Layer.succeed(ConfigProvider, configProvider)),
396 Effect.provideService(StackConfigOverrides, {
397 profile: options.profile,
398 }),
399 );
400 }).pipe(
401 Effect.provideService(AdoptPolicy, options.adopt ?? false),
402 Effect.provide(overrideAlchemyContext({ dev: resolveDev(options) })),
403 // `options.state` (e.g. `Cloudflare.state()`) itself requires
404 // `AuthProviders` to read credentials, so AuthProviders must be provided
405 // AFTER the state layer or the state layer's requirement is never
406 // satisfied — which surfaces as `Service not found: AuthProviders`.
407 Effect.provide(options.state ?? State.localState()),
408 Effect.provideService(AuthProviders, {}),
409 Effect.provide(Layer.provideMerge(alchemyLayer, platformLayer())),
410 );
411
412 return (
413 scope === undefined ? Effect.scoped(base) : Scope.provide(base, scope)
414 ) as Effect.Effect<A, any, never>;
415};
416
417/** Promise wrapper around {@link toEffect} for `bun.test`-style runners. */
418export const run = <A, ROut = any>(
419 effect: TestEffect<A>,
420 options: MakeOptions<ROut>,
421 scope?: Scope.Scope,
422 sidecar?: SidecarHandle,
423): Promise<A> => Effect.runPromise(toEffect(effect, options, scope, sidecar));
424
425/**
426 * Wrap an effect so it runs with `options.providers` + a placeholder Stack +
427 * Stage in scope. Used by `test.provider` so user code can call provider SDK
428 * APIs (e.g. `DynamoDB.describeTable`) directly inside the test body.
429 */
430export const withProviders = <A, E, R, ROut>(
431 effect: Effect.Effect<A, E, R>,
432 options: MakeOptions<ROut>,
433 stackName: string,
434): Effect.Effect<A, E, Exclude<R, ROut | Stack | Stage>> => {
435 // Closest wins: when `ALCHEMY_TEST_DEV=1`, pin the test body's
436 // distilled AWS clients to the emulator BEFORE `options.providers`
437 // (which still carries the live `AWSEnvironment`).
438 const body =
439 Option.getOrElse(alchemyTestDevOverride(), () => false) === true
440 ? Effect.provide(effect, flociServices())
441 : effect;
442 const stage = resolveStage(options);
443 return body.pipe(
444 Effect.provide(
445 (options.providers as Layer.Layer<any, never, any>).pipe(
446 Layer.provideMerge(
447 Layer.succeed(Stack, {
448 name: stackName,
449 stage,
450 resources: {},
451 bindings: {},
452 actions: {},
453 }),
454 ),
455 Layer.provideMerge(Layer.succeed(Stage, stage)),
456 ),
457 ),
458 ) as Effect.Effect<A, E, Exclude<R, ROut | Stack | Stage>>;
459};
460
461/**
462 * Curried `deploy` for the test factory: bakes in the configured stage and
463 * adds the telemetry layer the CLI uses, so `beforeAll(deploy(Stack))` works
464 * the same way as `alchemy deploy`.
465 *
466 * `scope`, when supplied, is forwarded down so the dev sidecar (and other
467 * scoped resources) lives until the caller closes it instead of dying as
468 * soon as `deploy` resolves. The test harness uses this to keep workerd
469 * alive across `beforeAll` → tests → `afterAll`.
470 */
471export interface DeployCallOptions extends Plan.MakePlanOptions {
472 stage?: string;
473 scope?: Scope.Scope;
474}
475
476export type DeployResult<A> = Effect.Effect<
477 Input.Resolve<A>,
478 Effect.Error<ReturnType<typeof deployStack<A>>>,
479 Effect.Services<ReturnType<typeof deployStack<A>>>
480>;
481
482export interface FilteredDeployCallOptions
483 extends Omit<DeployCallOptions, keyof ResourceSelection>, ResourceSelection {}
484
485type FullDeployCall = [options?: DeployCallOptions];
486
487/**
488 * Filtered deploys return void; the whole declaration still evaluates.
489 * With an explicit output type, filtering also requires the Call tuple type.
490 */
491export interface Deploy {
492 <A, Call extends [options?: FilteredDeployCallOptions] = FullDeployCall>(
493 stack: TestEffect<CompiledStack<A>, Stage | AlchemyContext>,
494 ...call: Call
495 ): DeployResult<SelectionOutput<A, Call[0]>>;
496}
497
498export function deploy<
499 A,
500 Call extends [options?: FilteredDeployCallOptions] = FullDeployCall,
501>(
502 options: MakeOptions,
503 stack: TestEffect<CompiledStack<A>, Stage | AlchemyContext>,
504 ...call: Call
505): DeployResult<SelectionOutput<A, Call[0]>>;
506export function deploy<A>(
507 options: MakeOptions,
508 stack: TestEffect<CompiledStack<A>, Stage | AlchemyContext>,
509 callOptions?: FilteredDeployCallOptions,
510) {
511 return deployStack(options, stack, callOptions);
512}
513
514const deployStack = <A>(
515 options: MakeOptions,
516 stack: TestEffect<CompiledStack<A>, Stage | AlchemyContext>,
517 callOptions?: FilteredDeployCallOptions,
518) =>
519 _deploy({
520 stack: stack as Effect.Effect<CompiledStack<A>, never, any>,
521 stage: callOptions?.stage ?? resolveStage(options),
522 dev: resolveDev(options),
523 scope: callOptions?.scope,
524 force: callOptions?.force,
525 include: callOptions?.include,
526 exclude: callOptions?.exclude,
527 }).pipe(Effect.provide(TelemetryLive));
528
529/** Bind test-file options and the shared runtime scope without erasing output types. */
530export const makeDeploy = (
531 options: MakeOptions,
532 scope: Scope.Scope,
533): Deploy => {
534 function run<
535 A,
536 Call extends [options?: FilteredDeployCallOptions] = FullDeployCall,
537 >(
538 stack: TestEffect<CompiledStack<A>, Stage | AlchemyContext>,
539 ...call: Call
540 ): DeployResult<SelectionOutput<A, Call[0]>>;
541 function run<A>(
542 stack: TestEffect<CompiledStack<A>, Stage | AlchemyContext>,
543 callOptions?: FilteredDeployCallOptions,
544 ) {
545 return deployStack(options, stack, { ...callOptions, scope });
546 }
547 return run;
548};
549
550export const destroy = (
551 options: MakeOptions,
552 stack: TestEffect<CompiledStack, Stage | AlchemyContext>,
553 callOptions?: {
554 stage?: string;
555 scope?: Scope.Scope;
556 include?: never;
557 exclude?: never;
558 },
559) =>
560 _destroy({
561 include: callOptions?.include,
562 exclude: callOptions?.exclude,
563 stack: stack as Effect.Effect<CompiledStack, never, any>,
564 stage: callOptions?.stage ?? resolveStage(options),
565 dev: resolveDev(options),
566 scope: callOptions?.scope,
567 }).pipe(Effect.provide(TelemetryLive));
568
569/**
570 * In-test scratch stack handed to `test.provider(name, (stack) => ...)`.
571 *
572 * Each scratch stack owns a private state store that is shared between
573 * successive `deploy`/`destroy` calls AND visible to the user's test body
574 * (so `yield* State` / `state.get(...)` see the same store the deploys
575 * mutated). This makes create / update / replace / delete paths exercisable
576 * without polluting other tests in the same file.
577 *
578 * When the adapter can name the test file (the alchemy-test runner), the
579 * store is DURABLE — rows live under `.alchemy/state/{file}-{test}/{stage}`
580 * on disk. Durability is what makes an interrupted destroy recoverable: the
581 * engine persists a `deleting` row before every `provider.delete` and only
582 * drops it on success, so a run killed mid-delete (e.g. the runner's
583 * teardown-abandonment after a test timeout) leaves resumable rows that the
584 * NEXT run's leading `stack.destroy()` (or the `Effect.ensuring` teardown)
585 * picks up and drains. An in-memory scratch dies with the process, silently
586 * orphaning every cloud resource whose delete was still in flight.
587 */
588export interface ScratchStack<ROut = any> {
589 readonly name: string;
590 /** Stage this scratch deploys to ({@link resolveStage} of the file options). */
591 readonly stage: string;
592 /** The shared in-memory state Layer for this scratch. @internal */
593 readonly state: Layer.Layer<State.State, never, never>;
594 /** Filters leave other rows and stack outputs untouched; declaration still runs. */
595 deploy<A, E, R, Call extends [options?: Plan.FilteredPlanOptions] = []>(
596 effect: Effect.Effect<A, E, R>,
597 ...call: Call
598 ): Effect.Effect<
599 SelectionOutput<Input.Resolve<A>, Call[0]>,
600 any,
601 Exclude<R, ROut | StackServices>
602 >;
603 /**
604 * Build a plan against the scratch's shared state WITHOUT applying it.
605 *
606 * Use this to assert on the planned action for a resource (e.g. that a
607 * downstream dependency stays `noop` when only an upstream resource
608 * changes) without mutating the cloud. Plans run against whatever state
609 * prior `deploy(...)` calls persisted.
610 */
611 plan<A, E, R, Call extends [options?: Plan.FilteredPlanOptions] = []>(
612 effect: Effect.Effect<A, E, R>,
613 ...call: Call
614 ): Effect.Effect<
615 Plan.Plan<SelectionOutput<A, Call[0]>>,
616 any,
617 Exclude<R, ROut | StackServices>
618 >;
619 destroy(options?: {
620 include?: never;
621 exclude?: never;
622 }): Effect.Effect<void, any, never>;
623}
624
625const sanitizeStackName = (name: string) =>
626 name.replaceAll(/[^a-zA-Z0-9_]/g, "-").replace(/-+/g, "-");
627
628/**
629 * Turn a test-file path (relative to the run root) into a stack-name
630 * namespace: `test/AWS/Website/Router.test.ts` -> `AWS/Website/Router`.
631 * Test names repeat across files (e.g. "create and delete bucket with
632 * default props"), so the durable per-test store MUST be namespaced by file
633 * or two concurrently-running same-named tests would read, write and — far
634 * worse — destroy each other's rows.
635 */
636const scratchNamespace = (file: string) =>
637 file.replace(/^test[/\\]/, "").replace(/\.test\.ts$/, "");
638
639/**
640 * Build a fresh `ScratchStack` for `test.provider`.
641 *
642 * With `file` (the registration-time test file, supplied by the
643 * alchemy-test adapter): the store is the durable `.alchemy/state` local
644 * store and the stack name is namespaced by file so interrupted destroys
645 * leave resumable rows for the next run (see {@link ScratchStack}).
646 *
647 * Without `file` (bun/vitest adapters, which cannot name their file at
648 * registration time): falls back to a private in-memory store — isolated,
649 * but discarded with the process.
650 */
651export const scratchStack = <ROut>(
652 options: MakeOptions<ROut>,
653 name: string,
654 file?: string,
655): ScratchStack<ROut> => {
656 const stage = resolveStage(options);
657 const stackName = sanitizeStackName(
658 file === undefined ? name : `${scratchNamespace(file)}-${name}`,
659 );
660 const stateLayer: Layer.Layer<State.State> =
661 file === undefined
662 ? Layer.succeed(State.State, State.InMemoryService({}))
663 : Layer.provide(State.localState(), PlatformServices);
664
665 // `withProviders` already pins the test body to Floci, but the stack program
666 // and its later plan/apply phase run under `AWS.providers()`'s live services.
667 // Pin both phases separately: Actions execute during apply, after the stack
668 // program has finished. This override must be inside `compiled.services` so
669 // Effect's closest-layer precedence selects Floci for Action data-plane calls.
670 const pinToFloci = <A, E, R>(
671 effect: Effect.Effect<A, E, R>,
672 ): Effect.Effect<A, E, R> =>
673 Option.getOrElse(alchemyTestDevOverride(), () => false)
674 ? (Effect.provide(effect, flociServices()) as Effect.Effect<A, E, R>)
675 : effect;
676
677 const buildAndApply = (
678 effect: Effect.Effect<any, any, any>,
679 planOptions?: Plan.FilteredPlanOptions,
680 ) =>
681 (pinToFloci(effect) as Effect.Effect<any, any, never>).pipe(
682 makeStack({
683 name: stackName,
684 providers: options.providers,
685 state: stateLayer,
686 } as any) as any,
687 Effect.flatMap((compiled: any) =>
688 Plan.make(compiled, planOptions ?? {}).pipe(
689 Effect.flatMap(apply),
690 pinToFloci,
691 Effect.provide(compiled.services),
692 ),
693 ),
694 Effect.provide(Layer.succeed(Stage, stage)),
695 provideFreshArtifactStore,
696 );
697
698 const buildPlan = (
699 effect: Effect.Effect<any, any, any>,
700 planOptions?: Plan.FilteredPlanOptions,
701 ) =>
702 (pinToFloci(effect) as Effect.Effect<any, any, never>).pipe(
703 makeStack({
704 name: stackName,
705 providers: options.providers,
706 state: stateLayer,
707 } as any) as any,
708 Effect.flatMap((compiled: any) =>
709 pinToFloci(Plan.make(compiled, planOptions ?? {})).pipe(
710 Effect.provide(compiled.services),
711 ),
712 ),
713 Effect.provide(Layer.succeed(Stage, stage)),
714 provideFreshArtifactStore,
715 );
716
717 return {
718 name: stackName,
719 stage,
720 state: stateLayer,
721 deploy: ((
722 effect: Effect.Effect<any, any, any>,
723 planOptions?: Plan.FilteredPlanOptions,
724 ) => buildAndApply(effect, planOptions)) as ScratchStack<ROut>["deploy"],
725 plan: ((
726 effect: Effect.Effect<any, any, any>,
727 planOptions?: Plan.FilteredPlanOptions,
728 ) => buildPlan(effect, planOptions)) as ScratchStack<ROut>["plan"],
729 destroy: (callOptions) =>
730 callOptions?.include !== undefined || callOptions?.exclude !== undefined
731 ? Effect.die(
732 new Plan.InvalidResourceSelection({
733 message: "Filtered destroy is not supported.",
734 }),
735 )
736 : (Plan.destroy({ name: stackName, stage }).pipe(
737 Effect.flatMap(apply),
738 Effect.asVoid,
739 Effect.provide(
740 stateLayer.pipe(
741 Layer.provideMerge(
742 options.providers as Layer.Layer<any, never, any>,
743 ),
744 Layer.provideMerge(
745 Layer.succeed(Stack, {
746 name: stackName,
747 stage,
748 resources: {},
749 bindings: {},
750 actions: {},
751 }),
752 ),
753 Layer.provideMerge(Layer.succeed(Stage, stage)),
754 ),
755 ),
756 provideFreshArtifactStore,
757 ) as Effect.Effect<void, any, never>),
758 };
759};