1import * as Alchemy from "@/index.ts";
2import * as Deploy from "@/Deploy.ts";
3import * as Destroy from "@/Destroy.ts";
4import * as Plan from "@/Plan.ts";
5import * as Cause from "effect/Cause";
6import * as EffectExit from "effect/Exit";
7import { TestLayers, TestResource } from "./test.resources.ts";
8import { Stage } from "@/Stage.ts";
9import * as State from "@/State/index.ts";
10import * as Test from "@/Test/Alchemy.ts";
11import * as TestCore from "@/Test/Core.ts";
12import type { TestApi as BunTestApi } from "@/Test/Bun.ts";
13import type { TestApi as VitestTestApi } from "@/Test/Vitest.ts";
14import { describe, expect, it } from "alchemy-test";
15import type { ConfigError } from "effect/Config";
16import * as Config from "effect/Config";
17import * as Effect from "effect/Effect";
18import * as Layer from "effect/Layer";
19
20// These tests are compile-time assertions: they verify that the
21// `Alchemy.Stack` effect permits a `ConfigError` in its body without
22// forcing the user to `Effect.orDie`. See
23// https://github.com/alchemy-run/alchemy/issues/479
24describe("Alchemy.Stack error channel", { tags: ["unit", "local"] }, () => {
25 it("allows ConfigError in the stack body", () => {
26 // A stack body that reads from `effect/Config` fails with `ConfigError`.
27 // Before #479 this required `Effect.orDie`; now it type-checks directly.
28 const stack = Alchemy.Stack(
29 "ConfigErrorStack",
30 {
31 providers: Layer.empty as any,
32 state: Layer.empty as any,
33 },
34 Effect.gen(function* () {
35 const value = yield* Config.String("SOME_CONFIG");
36 return { value };
37 }),
38 );
39
40 // The resulting effect surfaces `ConfigError` in its error channel rather
41 // than `never` — the whole point of the change.
42 type ErrorOf<T> = T extends Effect.Effect<any, infer E, any> ? E : never;
43 const _assertError: ErrorOf<typeof stack> extends ConfigError
44 ? true
45 : false = true;
46 expect(_assertError).toBe(true);
47 });
48
49 it("still accepts an infallible stack body", () => {
50 const stack = Alchemy.Stack(
51 "InfallibleStack",
52 {
53 providers: Layer.empty as any,
54 state: Layer.empty as any,
55 },
56 Effect.succeed({ value: "ok" }),
57 );
58
59 const _stackName: string = stack.stackName;
60 expect(_stackName).toBe("InfallibleStack");
61 expect(typeof stack).toBe("object");
62 });
63});
64
65describe("Alchemy.Stack runtime metadata", { tags: ["unit", "local"] }, () => {
66 it("exposes stackName, providers, and state on a configured stack", () => {
67 const providers = Layer.empty;
68 const state = State.inMemoryState();
69 const stack = Alchemy.Stack(
70 "MetaStack",
71 { providers, state },
72 Effect.succeed({ value: "ok" }),
73 );
74
75 expect(stack.stackName).toBe("MetaStack");
76 expect(stack.providers).toBe(providers);
77 expect(stack.state).toBe(state);
78 });
79
80 it("exposes stackName on a class reference", () => {
81 class NamedStack extends Alchemy.Stack<NamedStack, { value: string }>()(
82 "NamedStack",
83 ) {}
84
85 expect(NamedStack.stackName).toBe("NamedStack");
86 });
87
88 it("exposes configured metadata identities on a class reference's make result", () => {
89 class NamedStack extends Alchemy.Stack<NamedStack, { value: string }>()(
90 "MadeStack",
91 ) {}
92 const providers = Layer.empty;
93 const state = State.inMemoryState();
94 const stack = NamedStack.make(
95 { providers, state },
96 Effect.succeed({ value: "ok" }),
97 );
98
99 expect(stack.stackName).toBe("MadeStack");
100 expect(stack.providers).toBe(providers);
101 expect(stack.state).toBe(state);
102 });
103
104 it("exposes only name metadata on the inline class reference form", () => {
105 class InlineStack extends Alchemy.Stack<InlineStack>()(
106 "InlineStack",
107 { providers: Layer.empty, state: State.inMemoryState() },
108 Effect.succeed({ value: "ok" }),
109 ) {}
110
111 expect(InlineStack.stackName).toBe("InlineStack");
112 expect(InlineStack).toHaveProperty("providers", undefined);
113 expect(InlineStack).toHaveProperty("state", undefined);
114 });
115});
116
117describe("Test.make configured stack", { tags: ["unit", "local"] }, () => {
118 const store = State.InMemoryService();
119 const configured = Alchemy.Stack(
120 "TestMetadataStack",
121 { providers: Layer.empty, state: Layer.succeed(State.State, store) },
122 Effect.map(Stage, (stage) => ({ stage })),
123 );
124 const defaults = Test.make(configured);
125 const explicit = Test.make({ ...configured, stage: "metadata-explicit" });
126
127 defaults.test(
128 "uses a default stage string instead of the stack reference proxy",
129 Effect.gen(function* () {
130 expect(yield* State.State).toBe(store);
131 const output = yield* defaults.deploy(configured);
132 expect(typeof output.stage).toBe("string");
133 expect(output.stage).toBe(Test.defaultStage());
134 const state = yield* store;
135 expect(
136 yield* state.getOutput({
137 stack: configured.stackName,
138 stage: output.stage,
139 }),
140 ).toEqual(output);
141 }).pipe(Effect.ensuring(defaults.destroy(configured).pipe(Effect.orDie))),
142 );
143
144 explicit.test(
145 "honors file-level and per-call stage overrides",
146 Effect.gen(function* () {
147 const output = yield* explicit.deploy(configured);
148 expect(output.stage).toBe("metadata-explicit");
149 const override = yield* explicit.deploy(configured, {
150 stage: "metadata-call",
151 });
152 expect(override.stage).toBe("metadata-call");
153 }).pipe(
154 Effect.ensuring(
155 explicit
156 .destroy(configured, { stage: "metadata-call" })
157 .pipe(Effect.orDie),
158 ),
159 Effect.ensuring(explicit.destroy(configured).pipe(Effect.orDie)),
160 ),
161 );
162});
163
164describe("filtered deployment API", { tags: ["unit", "local"] }, () => {
165 const store = State.InMemoryService();
166 const state = Layer.succeed(State.State, store);
167 const providers = TestLayers();
168 const api = Test.make({
169 providers,
170 state,
171 stage: "filtered-api",
172 sidecar: false,
173 });
174 const program = Alchemy.Stack(
175 "FilteredApi",
176 { providers, state },
177 Effect.gen(function* () {
178 const branch = yield* TestResource("Branch", { string: "database" });
179 const worker = yield* TestResource("Worker", { string: branch.string });
180 return { branch: branch.string, worker: worker.string };
181 }),
182 );
183
184 it("preserves legacy annotated options and exclude output inference across adapters", () => {
185 type Equal<A, B> =
186 (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
187 ? true
188 : false;
189 type Output = { branch: string; worker: string };
190 type DeclaredOutput = Effect.Success<typeof program>["output"];
191 const options: TestCore.DeployCallOptions = {
192 stage: "override",
193 force: true,
194 };
195 const full = api.deploy(program, options);
196 const bun: BunTestApi["deploy"] = api.deploy;
197 const vitest: VitestTestApi["deploy"] = api.deploy;
198 const excluded = api.deploy(program, { exclude: ["Worker"] });
199 const bunExcluded = bun(program, { exclude: ["Worker"] });
200 const vitestExcluded = vitest(program, { exclude: ["Worker"] });
201 const optional = (options?: TestCore.FilteredDeployCallOptions) =>
202 api.deploy(program, options);
203 const absent = api.deploy(program, {
204 include: undefined,
205 exclude: undefined,
206 });
207 const legacy: Deploy.DeployOptions<DeclaredOutput> = {
208 stack: program,
209 stage: "override",
210 force: true,
211 };
212 const direct = Deploy.deploy(legacy);
213 const piped = Effect.succeed(legacy).pipe(Effect.flatMap(Deploy.deploy));
214 const filter = { ...legacy, exclude: ["Worker"] };
215 const filtered = Effect.succeed(filter).pipe(Effect.flatMap(Deploy.deploy));
216 const optionalDirect = (exclude?: ReadonlyArray<string>) =>
217 Deploy.deploy({ ...legacy, exclude });
218 const scratch = TestCore.scratchStack({ providers }, "ExcludedInference");
219 const declaration = Effect.succeed({ value: "value" });
220 const scratchExcluded = scratch.deploy(declaration, { exclude: ["Other"] });
221 const scratchPlan = scratch.plan(declaration, { exclude: ["Other"] });
222 const assertions: [
223 Equal<Effect.Success<typeof full>, Output>,
224 Equal<Effect.Success<typeof excluded>, undefined>,
225 Equal<Effect.Success<typeof bunExcluded>, undefined>,
226 Equal<Effect.Success<typeof vitestExcluded>, undefined>,
227 Equal<Effect.Success<ReturnType<typeof optional>>, Output | undefined>,
228 Equal<Effect.Success<typeof absent>, Output>,
229 Equal<Effect.Success<typeof direct>, Output>,
230 Equal<Effect.Success<typeof piped>, Output>,
231 Equal<Effect.Success<typeof filtered>, undefined>,
232 Equal<
233 Effect.Success<ReturnType<typeof optionalDirect>>,
234 Output | undefined
235 >,
236 Equal<Effect.Success<typeof scratchExcluded>, undefined>,
237 Equal<Effect.Success<typeof scratchPlan>["output"], undefined>,
238 ] = [
239 true,
240 true,
241 true,
242 true,
243 true,
244 true,
245 true,
246 true,
247 true,
248 true,
249 true,
250 true,
251 ];
252 expect(assertions.every(Boolean)).toBe(true);
253 });
254
255 it("full deployment and adapter options cannot erase known or optional filters", () => {
256 type Assignable<A, B> = [A] extends [B] ? true : false;
257 type DeclaredOutput = Effect.Success<typeof program>["output"];
258 type Full = Deploy.DeployOptions<DeclaredOutput>;
259 type Base = Omit<Full, "include" | "exclude">;
260 type CoreFull = TestCore.DeployCallOptions;
261 type CoreBase = Omit<CoreFull, "include" | "exclude">;
262 const assignable: [
263 Assignable<Base & { include: ReadonlyArray<string> }, Full>,
264 Assignable<Base & { exclude: ReadonlyArray<string> }, Full>,
265 Assignable<Base & { include?: ReadonlyArray<string> }, Full>,
266 Assignable<Base & { exclude?: ReadonlyArray<string> }, Full>,
267 Assignable<Deploy.FilteredDeployOptions<DeclaredOutput>, Full>,
268 Assignable<CoreBase & { include: ReadonlyArray<string> }, CoreFull>,
269 Assignable<CoreBase & { exclude: ReadonlyArray<string> }, CoreFull>,
270 Assignable<CoreBase & { include?: ReadonlyArray<string> }, CoreFull>,
271 Assignable<CoreBase & { exclude?: ReadonlyArray<string> }, CoreFull>,
272 Assignable<TestCore.FilteredDeployCallOptions, CoreFull>,
273 ] = [false, false, false, false, false, false, false, false, false, false];
274 expect(assignable.some(Boolean)).toBe(false);
275 });
276
277 for (const field of ["include", "exclude"]) {
278 api.test(
279 `destroy rejects injected ${field} before declaration or state effects`,
280 Effect.gen(function* () {
281 const options: { include?: never; exclude?: never } = {};
282 yield* Effect.sync(() => Reflect.set(options, field, ["Branch"]));
283 const blocked = Alchemy.Stack(
284 "BlockedDestroy",
285 { providers, state },
286 Effect.die("declaration ran"),
287 );
288 const scratch = TestCore.scratchStack({ providers }, "BlockedScratch");
289 const effects = [
290 Plan.destroy({ name: "Blocked", stage: "test", ...options }).pipe(
291 Effect.asVoid,
292 ),
293 Destroy.destroy({ stack: blocked, stage: "test", ...options }),
294 api.destroy(blocked, options),
295 scratch.destroy(options),
296 ];
297 for (const effect of effects) {
298 const exit = yield* effect.pipe(Effect.exit);
299 expect(EffectExit.isFailure(exit)).toBe(true);
300 if (EffectExit.isFailure(exit))
301 expect(Cause.pretty(exit.cause)).toContain(
302 "Filtered destroy is not supported",
303 );
304 }
305 }),
306 );
307 }
308
309 it("preserves exact direct and piped output inference", () => {
310 type Equal<A, B> =
311 (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
312 ? true
313 : false;
314 type Output = { branch: string; worker: string };
315 const full = api.deploy(program);
316 const piped = program.pipe(api.deploy);
317 const forced = api.deploy(program, { force: true });
318 const stageOnly = api.deploy(program, { stage: "override" });
319 const coreStageOnly = TestCore.deploy({ providers }, program, {
320 stage: "override",
321 });
322 const selected = api.deploy(program, { include: ["Branch"] });
323 const optional = (options: TestCore.FilteredDeployCallOptions) =>
324 api.deploy(program, options);
325 const core = TestCore.deploy({ providers }, program);
326 const scratch = TestCore.scratchStack({ providers }, "OutputInference");
327 const declaration = Effect.map(
328 TestResource("Branch", { string: "database" }),
329 (branch) => ({ branch: branch.string }),
330 );
331 const scratchFull = scratch.deploy(declaration);
332 const scratchPiped = declaration.pipe(scratch.deploy);
333 const scratchForced = scratch.deploy(declaration, { force: true });
334 const scratchSelected = scratch.deploy(declaration, {
335 include: ["Branch"],
336 });
337 const scratchOptional = (options: TestCore.FilteredDeployCallOptions) =>
338 scratch.deploy(declaration, options);
339 const plan = declaration.pipe(scratch.plan);
340 const selectedPlan = scratch.plan(declaration, { include: ["Branch"] });
341 const assertions: [
342 Equal<Effect.Success<typeof full>, Output>,
343 Equal<Effect.Success<typeof piped>, Output>,
344 Equal<Effect.Success<typeof forced>, Output>,
345 Equal<Effect.Success<typeof stageOnly>, Output>,
346 Equal<Effect.Success<typeof coreStageOnly>, Output>,
347 Equal<Effect.Success<typeof selected>, undefined>,
348 Equal<Effect.Success<ReturnType<typeof optional>>, Output | undefined>,
349 Equal<Effect.Success<typeof core>, Output>,
350 Equal<Effect.Success<typeof scratchFull>, { branch: string }>,
351 Equal<Effect.Success<typeof scratchPiped>, { branch: string }>,
352 Equal<Effect.Success<typeof scratchForced>, { branch: string }>,
353 Equal<Effect.Success<typeof scratchSelected>, undefined>,
354 Equal<
355 Effect.Success<ReturnType<typeof scratchOptional>>,
356 { branch: string } | undefined
357 >,
358 Equal<
359 Effect.Success<typeof plan>["output"],
360 Effect.Success<typeof declaration>
361 >,
362 Equal<Effect.Success<typeof selectedPlan>["output"], undefined>,
363 ] = [
364 true,
365 true,
366 true,
367 true,
368 true,
369 true,
370 true,
371 true,
372 true,
373 true,
374 true,
375 true,
376 true,
377 true,
378 true,
379 ];
380 expect(assertions.every(Boolean)).toBe(true);
381 });
382
383 it("preserves exact direct and higher-order Deploy output types", () => {
384 type Equal<A, B> =
385 (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
386 ? true
387 : false;
388 type DeclaredOutput = Effect.Success<typeof program>["output"];
389 type Output = { branch: string; worker: string };
390 const options: Deploy.DeployOptions<DeclaredOutput> = {
391 stack: program,
392 stage: "override",
393 };
394 const direct = Deploy.deploy({ stack: program, stage: "override" });
395 const piped = Effect.succeed(options).pipe(Effect.flatMap(Deploy.deploy));
396 const explicit = Deploy.deploy<DeclaredOutput>({
397 stack: program,
398 stage: "override",
399 });
400 const forced = Deploy.deploy({
401 stack: program,
402 stage: "override",
403 force: true,
404 });
405 const filteredOptions = {
406 stack: program,
407 stage: "override",
408 include: ["Branch"],
409 };
410 const filtered = Deploy.deploy(filteredOptions);
411 const filteredPiped = Effect.succeed(filteredOptions).pipe(
412 Effect.flatMap(Deploy.deploy),
413 );
414 const optional = (options: Deploy.FilteredDeployOptions<DeclaredOutput>) =>
415 Deploy.deploy(options);
416 const optionalPiped = (
417 options: Deploy.FilteredDeployOptions<DeclaredOutput>,
418 ) => Effect.succeed(options).pipe(Effect.flatMap(Deploy.deploy));
419 const assertions: [
420 Equal<Effect.Success<typeof direct>, Output>,
421 Equal<Effect.Success<typeof piped>, Output>,
422 Equal<Effect.Success<typeof explicit>, Output>,
423 Equal<Effect.Success<typeof forced>, Output>,
424 Equal<Effect.Success<typeof filtered>, undefined>,
425 Equal<Effect.Success<typeof filteredPiped>, undefined>,
426 Equal<Effect.Success<ReturnType<typeof optional>>, Output | undefined>,
427 Equal<
428 Effect.Success<ReturnType<typeof optionalPiped>>,
429 Output | undefined
430 >,
431 ] = [true, true, true, true, true, true, true, true];
432 expect(assertions.every(Boolean)).toBe(true);
433 });
434
435 it("preserves explicit output types with stage options across test adapters", () => {
436 type Equal<A, B> =
437 (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
438 ? true
439 : false;
440 type DeclaredOutput = Effect.Success<typeof program>["output"];
441 type Output = { branch: string; worker: string };
442 const bunDeploy: BunTestApi["deploy"] = api.deploy;
443 const vitestDeploy: VitestTestApi["deploy"] = api.deploy;
444 const adapter = api.deploy<DeclaredOutput>(program, { stage: "override" });
445 const core = TestCore.deploy<DeclaredOutput>({ providers }, program, {
446 stage: "override",
447 });
448 const bun = bunDeploy<DeclaredOutput>(program, { stage: "override" });
449 const vitest = vitestDeploy<DeclaredOutput>(program, { stage: "override" });
450 const full = api.deploy<DeclaredOutput>(program);
451 const filtered = api.deploy<
452 DeclaredOutput,
453 [options: { include: ReadonlyArray<string> }]
454 >(program, { include: ["Branch"] });
455 const optional = (options: TestCore.FilteredDeployCallOptions) =>
456 api.deploy<
457 DeclaredOutput,
458 [options?: TestCore.FilteredDeployCallOptions]
459 >(program, options);
460 const bunPiped = program.pipe(bunDeploy);
461 const vitestPiped = program.pipe(vitestDeploy);
462 const bunFiltered = bunDeploy(program, { include: ["Branch"] });
463 const vitestFiltered = vitestDeploy(program, { include: ["Branch"] });
464 const assertions: [
465 Equal<Effect.Success<typeof adapter>, Output>,
466 Equal<Effect.Success<typeof core>, Output>,
467 Equal<Effect.Success<typeof bun>, Output>,
468 Equal<Effect.Success<typeof vitest>, Output>,
469 Equal<Effect.Success<typeof full>, Output>,
470 Equal<Effect.Success<typeof filtered>, undefined>,
471 Equal<Effect.Success<ReturnType<typeof optional>>, Output | undefined>,
472 Equal<Effect.Success<typeof bunPiped>, Output>,
473 Equal<Effect.Success<typeof vitestPiped>, Output>,
474 Equal<Effect.Success<typeof bunFiltered>, undefined>,
475 Equal<Effect.Success<typeof vitestFiltered>, undefined>,
476 ] = [true, true, true, true, true, true, true, true, true, true, true];
477 expect(assertions.every(Boolean)).toBe(true);
478 });
479
480 api.test(
481 "selected then full deployment reuses the same persisted resource",
482 Effect.gen(function* () {
483 yield* api.destroy(program);
484 const selected: void = yield* api.deploy(program, {
485 include: ["Branch"],
486 });
487 expect(selected).toBeUndefined();
488 const state = yield* store;
489 const key = { stack: program.stackName, stage: "filtered-api" };
490 const branch = yield* state.get({ ...key, fqn: "Branch" });
491 expect(branch).toBeDefined();
492 if (!branch || State.isActionState(branch)) {
493 return yield* Effect.die("Expected a persisted Branch resource");
494 }
495 expect(yield* state.get({ ...key, fqn: "Worker" })).toBeUndefined();
496 expect(yield* state.getOutput(key)).toBeUndefined();
497 const full: { branch: string; worker: string } =
498 yield* api.deploy(program);
499 expect(full).toEqual({ branch: "database", worker: "database" });
500 const piped: { branch: string; worker: string } = yield* program.pipe(
501 api.deploy,
502 );
503 expect(piped).toEqual(full);
504 expect(yield* state.get({ ...key, fqn: "Branch" })).toMatchObject({
505 instanceId: branch.instanceId,
506 });
507 expect(yield* state.getOutput(key)).toEqual(full);
508 const direct: void = yield* Deploy.deploy({
509 stack: program,
510 stage: key.stage,
511 include: ["Branch"],
512 });
513 expect(direct).toBeUndefined();
514 expect(yield* state.getOutput(key)).toEqual(full);
515 const directFull: { branch: string; worker: string } =
516 yield* Deploy.deploy({ stack: program, stage: key.stage });
517 expect(directFull).toEqual(full);
518 const invalid = yield* api
519 .deploy(program, { include: [] })
520 .pipe(Effect.exit);
521 expect(EffectExit.isFailure(invalid)).toBe(true);
522 expect(yield* state.getOutput(key)).toEqual(full);
523 yield* api.destroy(program);
524 }),
525 );
526});