1import * as Output from "@/Output";
2import { ref as makeRef } from "@/Ref";
3import type { ResourceLike } from "@/Resource";
4import { Stack } from "@/Stack";
5import { Stage } from "@/Stage";
6import { inMemoryState } from "@/State/InMemoryState";
7import type { ResourceState } from "@/State/ResourceState";
8import { describe, expect, it } from "alchemy-test";
9import * as Cause from "effect/Cause";
10import * as Config from "effect/Config";
11import * as ConfigProvider from "effect/ConfigProvider";
12import * as Context from "effect/Context";
13import * as Effect from "effect/Effect";
14import * as Exit from "effect/Exit";
15import * as Layer from "effect/Layer";
16import * as Redacted from "effect/Redacted";
17
18const provideState = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
19 effect.pipe(Effect.provide(inMemoryState()));
20
21const fakeResource = <T extends string, A extends object>(
22 type: T,
23 fqn: string,
24 logicalId: string = fqn,
25): ResourceLike<T, any, A> =>
26 ({
27 Type: type,
28 FQN: fqn,
29 LogicalId: logicalId,
30 Namespace: undefined,
31 }) as any;
32
33describe("Output.evaluate", { tags: ["unit", "local"] }, () => {
34 describe("primitives and plain values", () => {
35 it.effect("returns primitive values as-is", () =>
36 provideState(
37 Effect.gen(function* () {
38 expect(yield* Output.evaluate(42, {})).toBe(42);
39 expect(yield* Output.evaluate("hello", {})).toBe("hello");
40 expect(yield* Output.evaluate(true, {})).toBe(true);
41 expect(yield* Output.evaluate(null, {})).toBe(null);
42 expect(yield* Output.evaluate(undefined, {})).toBe(undefined);
43 }),
44 ),
45 );
46
47 it.effect("recursively evaluates plain objects", () =>
48 provideState(
49 Effect.gen(function* () {
50 const result = yield* Output.evaluate({ a: 1, b: { c: "x" } }, {});
51 expect(result).toEqual({ a: 1, b: { c: "x" } });
52 }),
53 ),
54 );
55
56 it.effect("recursively evaluates arrays", () =>
57 provideState(
58 Effect.gen(function* () {
59 const result = yield* Output.evaluate([1, "two", { k: 3 }], {});
60 expect(result).toEqual([1, "two", { k: 3 }]);
61 }),
62 ),
63 );
64 });
65
66 describe("Redacted", () => {
67 it.effect("preserves Redacted values at the top level", () =>
68 provideState(
69 Effect.gen(function* () {
70 const secret = Redacted.make("hunter2");
71 const result = yield* Output.evaluate(secret, {});
72 expect(Redacted.isRedacted(result)).toBe(true);
73 expect(Redacted.value(result as Redacted.Redacted<string>)).toBe(
74 "hunter2",
75 );
76 }),
77 ),
78 );
79
80 it.effect("preserves Redacted values nested inside an object", () =>
81 provideState(
82 Effect.gen(function* () {
83 const secret = Redacted.make("hunter2");
84 const result = yield* Output.evaluate(
85 { value: secret, name: "x" },
86 {},
87 );
88 expect(result.name).toBe("x");
89 expect(Redacted.isRedacted(result.value)).toBe(true);
90 expect(Redacted.value(result.value)).toBe("hunter2");
91 }),
92 ),
93 );
94
95 it.effect("preserves Redacted values nested inside an array", () =>
96 provideState(
97 Effect.gen(function* () {
98 const secret = Redacted.make("hunter2");
99 const [result] = yield* Output.evaluate([secret], {});
100 expect(Redacted.isRedacted(result)).toBe(true);
101 expect(Redacted.value(result)).toBe("hunter2");
102 }),
103 ),
104 );
105 });
106
107 describe("Config", () => {
108 it.effect("resolves a Config value at the top level", () =>
109 provideState(
110 Effect.gen(function* () {
111 const result = yield* Output.evaluate(Config.succeed(1337), {});
112 expect(result).toBe(1337);
113 }),
114 ),
115 );
116
117 it.effect("resolves a Config value nested inside an object", () =>
118 provideState(
119 Effect.gen(function* () {
120 const result = yield* Output.evaluate(
121 { port: Config.succeed(8080), host: "localhost" },
122 {},
123 );
124 expect(result).toEqual({ port: 8080, host: "localhost" });
125 }),
126 ),
127 );
128
129 it.effect("resolves a Config value nested inside an array", () =>
130 provideState(
131 Effect.gen(function* () {
132 const [result] = yield* Output.evaluate([Config.succeed(42)], {});
133 expect(result).toBe(42);
134 }),
135 ),
136 );
137
138 it.effect("resolves a Config against the ConfigProvider environment", () =>
139 provideState(
140 Effect.gen(function* () {
141 const result = yield* Output.evaluate(
142 { port: Config.Number("PORT").pipe(Config.withDefault(1337)) },
143 {},
144 ).pipe(
145 Effect.provide(
146 ConfigProvider.layer(
147 ConfigProvider.fromEnv({ env: { PORT: "8080" } }),
148 ),
149 ),
150 );
151 expect(result).toEqual({ port: 8080 });
152 }),
153 ),
154 );
155
156 it.effect("a Config resolving to a Redacted keeps it wrapped", () =>
157 provideState(
158 Effect.gen(function* () {
159 const result = yield* Output.evaluate(
160 Config.succeed(Redacted.make("hunter2")),
161 {},
162 );
163 expect(Redacted.isRedacted(result)).toBe(true);
164 expect(
165 Redacted.value(result as unknown as Redacted.Redacted<string>),
166 ).toBe("hunter2");
167 }),
168 ),
169 );
170 });
171
172 describe("LiteralExpr", () => {
173 it.effect("evaluates Output.literal(value)", () =>
174 provideState(
175 Effect.gen(function* () {
176 const expr = Output.literal("foo");
177 expect(yield* Output.evaluate(expr, {})).toBe("foo");
178 }),
179 ),
180 );
181
182 it.effect("evaluates a literal nested within an object", () =>
183 provideState(
184 Effect.gen(function* () {
185 const result = yield* Output.evaluate(
186 { greeting: Output.literal("hi") },
187 {},
188 );
189 expect(result).toEqual({ greeting: "hi" });
190 }),
191 ),
192 );
193 });
194
195 describe("ResourceExpr", () => {
196 it.effect("resolves to the upstream value keyed by FQN", () =>
197 provideState(
198 Effect.gen(function* () {
199 const src = fakeResource("Test.Bucket", "MyBucket");
200 const expr = Output.of(src);
201 const result = yield* Output.evaluate(expr, {
202 MyBucket: { name: "my-bucket" },
203 });
204 expect(result).toEqual({ name: "my-bucket" });
205 }),
206 ),
207 );
208
209 it.effect("fails with MissingSourceError when upstream is absent", () =>
210 provideState(
211 Effect.gen(function* () {
212 const src = fakeResource("Test.Bucket", "Missing");
213 const expr = Output.of(src);
214 const exit = yield* Effect.exit(Output.evaluate(expr, {}));
215 expect(Exit.isFailure(exit)).toBe(true);
216 if (Exit.isFailure(exit)) {
217 const failure = exit.cause.toJSON() as any;
218 expect(JSON.stringify(failure)).toContain("MissingSourceError");
219 }
220 }),
221 ),
222 );
223
224 it.effect("evaluates a raw resource (isResource branch)", () =>
225 provideState(
226 Effect.gen(function* () {
227 const src = fakeResource("Test.Bucket", "RawBucket");
228 const result = yield* Output.evaluate(src as any, {
229 RawBucket: { ok: true },
230 });
231 expect(result).toEqual({ ok: true });
232 }),
233 ),
234 );
235
236 it.effect("raw resource with missing upstream fails", () =>
237 provideState(
238 Effect.gen(function* () {
239 const src = fakeResource("Test.Bucket", "Gone");
240 const exit = yield* Effect.exit(Output.evaluate(src as any, {}));
241 expect(Exit.isFailure(exit)).toBe(true);
242 }),
243 ),
244 );
245
246 it("classifies resource expressions when a stable kind shadows the discriminator", () => {
247 const src = fakeResource("Test.Database", "Database");
248 const expr = new Output.ResourceExpr(src, { kind: "postgresql" });
249
250 expect((expr as any).kind).toBe("postgresql");
251 expect(Output.isResourceExpr(expr)).toBe(true);
252 });
253 });
254
255 describe("PropExpr", () => {
256 it.effect("accesses a property on a resource expression", () =>
257 provideState(
258 Effect.gen(function* () {
259 const src = fakeResource<"Test.Bucket", { name: string }>(
260 "Test.Bucket",
261 "B",
262 );
263 const expr = Output.of(src) as any;
264 const result = yield* Output.evaluate(expr.name, {
265 B: { name: "the-name" },
266 });
267 expect(result).toBe("the-name");
268 }),
269 ),
270 );
271
272 it.effect("returns undefined when accessing missing property", () =>
273 provideState(
274 Effect.gen(function* () {
275 const src = fakeResource("Test.Bucket", "B2");
276 const expr = Output.of(src) as any;
277 const result = yield* Output.evaluate(expr.missing, {
278 B2: { other: 1 },
279 });
280 expect(result).toBeUndefined();
281 }),
282 ),
283 );
284
285 it.effect("supports nested property access", () =>
286 provideState(
287 Effect.gen(function* () {
288 const src = fakeResource("Test.Bucket", "B3");
289 const expr = Output.of(src) as any;
290 const result = yield* Output.evaluate(expr.nested.deep, {
291 B3: { nested: { deep: "value" } },
292 });
293 expect(result).toBe("value");
294 }),
295 ),
296 );
297 });
298
299 describe("ApplyExpr (map)", () => {
300 it.effect("applies a synchronous function over a literal", () =>
301 provideState(
302 Effect.gen(function* () {
303 const expr = Output.map(Output.literal(2), (n) => n * 3);
304 expect(yield* Output.evaluate(expr, {})).toBe(6);
305 }),
306 ),
307 );
308
309 it.effect("composes multiple maps", () =>
310 provideState(
311 Effect.gen(function* () {
312 const expr = Output.literal(2).pipe(
313 Output.map((n: number) => n + 1),
314 Output.map((n: number) => n * 10),
315 );
316 expect(yield* Output.evaluate(expr, {})).toBe(30);
317 }),
318 ),
319 );
320
321 it.effect("maps over a resource attribute", () =>
322 provideState(
323 Effect.gen(function* () {
324 const src = fakeResource("Test.Bucket", "B4");
325 const expr = (Output.of(src) as any).name.pipe(
326 Output.map((s: string) => s.toUpperCase()),
327 );
328 const result = yield* Output.evaluate(expr, {
329 B4: { name: "abc" },
330 });
331 expect(result).toBe("ABC");
332 }),
333 ),
334 );
335 });
336
337 describe("EffectExpr (mapEffect)", () => {
338 it.effect("evaluates an effectful transformation", () =>
339 provideState(
340 Effect.gen(function* () {
341 const expr = Output.literal(5).pipe(
342 Output.mapEffect((n: number) => Effect.succeed(n * 2)),
343 );
344 expect(yield* Output.evaluate(expr, {})).toBe(10);
345 }),
346 ),
347 );
348
349 it.effect("chains multiple effectful transformations", () =>
350 provideState(
351 Effect.gen(function* () {
352 const expr = Output.literal("a").pipe(
353 Output.mapEffect((s: string) => Effect.succeed(s + "b")),
354 Output.mapEffect((s) => Effect.succeed(s + "c")),
355 );
356 expect(yield* Output.evaluate(expr, {})).toBe("abc");
357 }),
358 ),
359 );
360 });
361
362 describe("FlatMapExpr (flatMap)", () => {
363 it.effect("flattens an Output returned from the function", () =>
364 provideState(
365 Effect.gen(function* () {
366 const expr = Output.literal(5).pipe(
367 Output.flatMap((n: number) => Output.literal(n * 2)),
368 );
369 expect(yield* Output.evaluate(expr, {})).toBe(10);
370 }),
371 ),
372 );
373
374 it.effect("supports the data-first form", () =>
375 provideState(
376 Effect.gen(function* () {
377 const expr = Output.flatMap(Output.literal("a"), (s: string) =>
378 Output.literal(s + "b"),
379 );
380 expect(yield* Output.evaluate(expr, {})).toBe("ab");
381 }),
382 ),
383 );
384
385 it.effect("chains multiple flatMaps", () =>
386 provideState(
387 Effect.gen(function* () {
388 const expr = Output.literal(1).pipe(
389 Output.flatMap((n: number) => Output.literal(n + 1)),
390 Output.flatMap((n: number) => Output.literal(n * 10)),
391 );
392 expect(yield* Output.evaluate(expr, {})).toBe(20);
393 }),
394 ),
395 );
396
397 it.effect("flatMaps into another resource's output", () =>
398 provideState(
399 Effect.gen(function* () {
400 const a = fakeResource("Test.A", "FA");
401 const b = fakeResource("Test.B", "FB");
402 const expr = (Output.of(a) as any).name.pipe(
403 Output.flatMap(() => (Output.of(b) as any).name),
404 );
405 const result = yield* Output.evaluate(expr, {
406 FA: { name: "a-name" },
407 FB: { name: "b-name" },
408 });
409 expect(result).toBe("b-name");
410 }),
411 ),
412 );
413
414 it.effect("can flatMap into a non-Output literal value", () =>
415 provideState(
416 Effect.gen(function* () {
417 const expr = Output.literal(3).pipe(
418 Output.flatMap((n: number) => Output.asOutput(n + 1)),
419 );
420 expect(yield* Output.evaluate(expr, {})).toBe(4);
421 }),
422 ),
423 );
424
425 it("tracks only the source expression as upstream", () => {
426 const a = fakeResource("Test.A", "UA");
427 const expr = (Output.of(a) as any).name.pipe(
428 Output.flatMap((s: string) => Output.literal(s)),
429 );
430 expect(Object.keys(Output.upstream(expr))).toEqual(["UA"]);
431 });
432 });
433
434 describe("method-style combinators on a proxied Output", () => {
435 // Resource outputs are Proxies, so `.map(fn)` / `.mapEffect(fn)` /
436 // `.flatMap(fn)` / `.apply(fn)` / `.effect(fn)` must be callable as
437 // methods (not just via `Output.map(output, fn)` / `.pipe(...)`).
438 const resourceOutput = () => {
439 const src = fakeResource<"Test.Bucket", { name: string }>(
440 "Test.Bucket",
441 "M",
442 );
443 return (Output.of(src) as any).name as Output.Output<string>;
444 };
445
446 it.effect(".map(fn) builds an ApplyExpr", () =>
447 provideState(
448 Effect.gen(function* () {
449 const expr = (resourceOutput() as any).map((s: string) =>
450 s.toUpperCase(),
451 );
452 expect(Output.isApplyExpr(expr)).toBe(true);
453 expect(yield* Output.evaluate(expr, { M: { name: "abc" } })).toBe(
454 "ABC",
455 );
456 }),
457 ),
458 );
459
460 it.effect(".apply(fn) builds an ApplyExpr", () =>
461 provideState(
462 Effect.gen(function* () {
463 const expr = (resourceOutput() as any).apply((s: string) =>
464 s.toUpperCase(),
465 );
466 expect(Output.isApplyExpr(expr)).toBe(true);
467 expect(yield* Output.evaluate(expr, { M: { name: "abc" } })).toBe(
468 "ABC",
469 );
470 }),
471 ),
472 );
473
474 it.effect(".mapEffect(fn) builds an EffectExpr", () =>
475 provideState(
476 Effect.gen(function* () {
477 const expr = (resourceOutput() as any).mapEffect((s: string) =>
478 Effect.succeed(s + "!"),
479 );
480 expect(Output.isEffectExpr(expr)).toBe(true);
481 expect(yield* Output.evaluate(expr, { M: { name: "abc" } })).toBe(
482 "abc!",
483 );
484 }),
485 ),
486 );
487
488 it.effect(".effect(fn) builds an EffectExpr", () =>
489 provideState(
490 Effect.gen(function* () {
491 const expr = (resourceOutput() as any).effect((s: string) =>
492 Effect.succeed(s + "!"),
493 );
494 expect(Output.isEffectExpr(expr)).toBe(true);
495 expect(yield* Output.evaluate(expr, { M: { name: "abc" } })).toBe(
496 "abc!",
497 );
498 }),
499 ),
500 );
501
502 it.effect(".flatMap(fn) builds a FlatMapExpr", () =>
503 provideState(
504 Effect.gen(function* () {
505 const expr = (resourceOutput() as any).flatMap((s: string) =>
506 Output.literal(s + "?"),
507 );
508 expect(Output.isFlatMapExpr(expr)).toBe(true);
509 expect(yield* Output.evaluate(expr, { M: { name: "abc" } })).toBe(
510 "abc?",
511 );
512 }),
513 ),
514 );
515 });
516
517 describe("AllExpr", () => {
518 it.effect("evaluates all wrapped outputs in parallel", () =>
519 provideState(
520 Effect.gen(function* () {
521 const expr = Output.all(
522 Output.literal(1),
523 Output.literal("two"),
524 Output.literal(true),
525 );
526 const result = yield* Output.evaluate(expr, {});
527 expect(result).toEqual([1, "two", true]);
528 }),
529 ),
530 );
531
532 it.effect("evaluates all with resource expressions", () =>
533 provideState(
534 Effect.gen(function* () {
535 const a = fakeResource("Test.A", "A");
536 const b = fakeResource("Test.B", "B");
537 const expr = Output.all(Output.of(a), Output.of(b));
538 const result = yield* Output.evaluate(expr, {
539 A: { x: 1 },
540 B: { y: 2 },
541 });
542 expect(result).toEqual([{ x: 1 }, { y: 2 }]);
543 }),
544 ),
545 );
546 });
547
548 describe("RefExpr", () => {
549 const provideStackStage = <A, E, R>(
550 effect: Effect.Effect<A, E, R>,
551 stack = "myStack",
552 stage = "myStage",
553 ) =>
554 effect.pipe(
555 Effect.provide(
556 Layer.mergeAll(
557 Layer.succeed(Stack, { name: stack } as any),
558 Layer.succeed(Stage, stage),
559 ),
560 ),
561 );
562
563 it.effect("resolves a Ref against in-memory state", () =>
564 Effect.gen(function* () {
565 const initial = {
566 myStack: {
567 myStage: {
568 myResource: {
569 fqn: "myResource",
570 attr: { hello: "world" },
571 } as unknown as ResourceState,
572 },
573 },
574 };
575 const r = makeRef<ResourceLike>("myResource");
576 const expr = Output.of(r);
577 const result = yield* provideStackStage(
578 Output.evaluate(expr, {}).pipe(
579 Effect.provide(inMemoryState(initial)),
580 ),
581 );
582 expect(result).toEqual({ hello: "world" });
583 }),
584 );
585
586 it.effect("uses explicit stack/stage when provided on the ref", () =>
587 Effect.gen(function* () {
588 const initial = {
589 otherStack: {
590 otherStage: {
591 someResource: {
592 fqn: "someResource",
593 attr: { v: 1 },
594 } as unknown as ResourceState,
595 },
596 },
597 };
598 const r = makeRef<ResourceLike>("someResource", {
599 stack: "otherStack",
600 stage: "otherStage",
601 });
602 const expr = Output.of(r);
603 // No Stack/Stage layers needed since the ref carries them.
604 const result = yield* Output.evaluate(expr, {}).pipe(
605 Effect.provide(inMemoryState(initial)),
606 );
607 expect(result).toEqual({ v: 1 });
608 }),
609 );
610
611 it.effect(
612 "fails with InvalidReferenceError when ref target is missing",
613 () =>
614 Effect.gen(function* () {
615 const r = makeRef<ResourceLike>("ghost", {
616 stack: "s",
617 stage: "t",
618 });
619 const expr = Output.of(r);
620 const exit = yield* Effect.exit(
621 Output.evaluate(expr, {}).pipe(Effect.provide(inMemoryState())),
622 );
623 expect(Exit.isFailure(exit)).toBe(true);
624 if (Exit.isFailure(exit)) {
625 expect(JSON.stringify(exit.cause.toJSON())).toContain(
626 "InvalidReferenceError",
627 );
628 }
629 }),
630 );
631
632 it.effect(
633 "PropExpr on a Ref reads the attribute from persisted state",
634 () =>
635 Effect.gen(function* () {
636 const initial = {
637 myStack: {
638 myStage: {
639 shared: {
640 fqn: "shared",
641 attr: { url: "https://example.com", name: "shared" },
642 } as unknown as ResourceState,
643 },
644 },
645 };
646 const r = makeRef<ResourceLike>("shared");
647 const expr = (Output.of(r) as any).url as Output.Output<string>;
648 const result = yield* provideStackStage(
649 Output.evaluate(expr, {}).pipe(
650 Effect.provide(inMemoryState(initial)),
651 ),
652 );
653 expect(result).toBe("https://example.com");
654 }),
655 );
656 });
657
658 describe("StackRefExpr", () => {
659 const provideStage = <A, E, R>(
660 effect: Effect.Effect<A, E, R>,
661 stage = "myStage",
662 ) => effect.pipe(Effect.provide(Layer.succeed(Stage, stage)));
663
664 it.effect("Output.stackRef resolves to the persisted stack output", () =>
665 Effect.gen(function* () {
666 const expr = yield* Output.stackRef<{ url: string }>("Backend");
667 const result = yield* provideStage(
668 Output.evaluate(expr, {}).pipe(
669 Effect.provide(
670 inMemoryState(
671 {},
672 { Backend: { myStage: { url: "https://api.example.com" } } },
673 ),
674 ),
675 ),
676 );
677 expect(result).toEqual({ url: "https://api.example.com" });
678 }),
679 );
680
681 it.effect("explicit stage overrides the ambient Stage", () =>
682 Effect.gen(function* () {
683 const expr = yield* Output.stackRef<{ url: string }>("Backend", {
684 stage: "prod",
685 });
686 // No Stage layer is provided — the ref carries it explicitly.
687 const result = yield* Output.evaluate(expr, {}).pipe(
688 Effect.provide(
689 inMemoryState(
690 {},
691 { Backend: { prod: { url: "https://prod.example.com" } } },
692 ),
693 ),
694 );
695 expect(result).toEqual({ url: "https://prod.example.com" });
696 }),
697 );
698
699 it.effect("PropExpr off a stackRef reads a single attribute", () =>
700 Effect.gen(function* () {
701 const backend = yield* Output.stackRef<{ url: string }>("Backend");
702 const expr = (backend as any).url as Output.Output<string>;
703 const result = yield* provideStage(
704 Output.evaluate(expr, {}).pipe(
705 Effect.provide(
706 inMemoryState(
707 {},
708 { Backend: { myStage: { url: "https://api.example.com" } } },
709 ),
710 ),
711 ),
712 );
713 expect(result).toBe("https://api.example.com");
714 }),
715 );
716
717 it.effect(
718 "fails with InvalidReferenceError when the target stack/stage has no persisted output",
719 () =>
720 Effect.gen(function* () {
721 const expr = yield* Output.stackRef<{ url: string }>("Backend", {
722 stage: "ghost",
723 });
724 const exit = yield* Effect.exit(
725 Output.evaluate(expr, {}).pipe(Effect.provide(inMemoryState())),
726 );
727 expect(Exit.isFailure(exit)).toBe(true);
728 if (Exit.isFailure(exit)) {
729 const err = Cause.squash(
730 exit.cause,
731 ) as Output.InvalidReferenceError;
732 expect(err._tag).toBe("InvalidReferenceError");
733 expect(err.stack).toBe("Backend");
734 expect(err.stage).toBe("ghost");
735 }
736 }),
737 );
738 });
739
740 describe("composition", () => {
741 it.effect("evaluates outputs nested inside arrays and objects", () =>
742 provideState(
743 Effect.gen(function* () {
744 const a = fakeResource("Test.A", "RA");
745 const b = fakeResource("Test.B", "RB");
746 const value = {
747 list: [Output.of(a), Output.literal("lit")],
748 nested: {
749 prop: (Output.of(b) as any).name.pipe(
750 Output.map((s: string) => `name=${s}`),
751 ),
752 },
753 scalar: 42,
754 };
755 const result = yield* Output.evaluate(value, {
756 RA: { foo: "f" },
757 RB: { name: "bee" },
758 });
759 expect(result).toEqual({
760 list: [{ foo: "f" }, "lit"],
761 nested: { prop: "name=bee" },
762 scalar: 42,
763 });
764 }),
765 ),
766 );
767 });
768});
769
770describe("Output.interpolate", { tags: ["unit", "local"] }, () => {
771 it.effect("interpolates literal values into a template", () =>
772 provideState(
773 Effect.gen(function* () {
774 const expr = Output.interpolate`hello ${Output.literal("world")}!`;
775 expect(yield* Output.evaluate(expr, {})).toBe("hello world!");
776 }),
777 ),
778 );
779
780 it.effect("interpolates resource attributes", () =>
781 provideState(
782 Effect.gen(function* () {
783 const src = fakeResource("Test.Bucket", "Buck");
784 // @ts-expect-error
785 const name = Output.of(src).name;
786 const expr = Output.interpolate`s3://${name}/key`;
787 const result = yield* Output.evaluate(expr, {
788 Buck: { name: "my-bucket" },
789 });
790 expect(result).toBe("s3://my-bucket/key");
791 }),
792 ),
793 );
794
795 it.effect("renders nullish args as empty strings", () =>
796 provideState(
797 Effect.gen(function* () {
798 const expr = Output.interpolate`a${Output.literal(null)}b${Output.literal(
799 undefined,
800 )}c`;
801 expect(yield* Output.evaluate(expr, {})).toBe("abc");
802 }),
803 ),
804 );
805
806 it("derives its binding id from the template and arguments only", () => {
807 // The runtime bundler reprints function source, so a binding id that
808 // embeds the mapper's source differs between deploy and runtime.
809 const src = fakeResource("Test.Service", "Web");
810 // @ts-expect-error
811 const service = Output.of(src).serviceName;
812 const expr = Output.interpolate`http://${service}:${Output.literal(80)}`;
813 expect(String(expr[Symbol.for("nodejs.util.inspect.custom")]())).toBe(
814 "interpolate(http://${Web.serviceName}:${80})",
815 );
816 });
817});
818
819describe("Output coercion guard", { tags: ["unit", "local"] }, () => {
820 // These guard against the long-standing footgun where coercing an
821 // unresolved Output silently produced a placeholder — the inspect
822 // string for string-hints, or NaN for number-hints. Both let bogus
823 // values flow into resource props and ultimately into the cloud.
824
825 it("throws on template-literal interpolation of a raw Output", () => {
826 const src = fakeResource("Test.Bucket", "Buck");
827 // @ts-expect-error — synthetic prop access
828 const name = Output.of(src).name;
829 expect(() => `${name}`).toThrow(/Output\.interpolate/);
830 });
831
832 it("throws on string concatenation with a raw Output", () => {
833 const src = fakeResource("Test.Bucket", "Buck");
834 // @ts-expect-error — synthetic prop access
835 const name = Output.of(src).name;
836 expect(() => "s3://" + name).toThrow(/Output\.interpolate/);
837 });
838
839 it("throws on number coercion of a raw Output", () => {
840 const src = fakeResource("Test.Bucket", "Buck");
841 // @ts-expect-error — synthetic prop access
842 const name = Output.of(src).name;
843 expect(() => +name).toThrow(/Output\.(interpolate|map)/);
844 });
845
846 it("throws on arithmetic with a raw Output", () => {
847 const src = fakeResource("Test.Bucket", "Buck");
848 // @ts-expect-error — synthetic prop access
849 const name = Output.of(src).name;
850 expect(() => (name as unknown as number) * 2).toThrow(
851 /Output\.(interpolate|map)/,
852 );
853 });
854});
855
856describe(
857 "Redacted stack-output serialization (regression #598)",
858 { tags: ["unit", "local"] },
859 () => {
860 // A resource whose attribute is a `Redacted<string>` — e.g. `Random.text`.
861 // When such an attribute flows
862 // into a Stack output it is JSON-serialized for persistence to state /
863 // Doppler / GH secrets. `JSON.stringify(Redacted)` returns the literal
864 // string "<redacted>", so a publisher reading the output would send
865 // `Bearer <redacted>` instead of the real token. The fix is to unwrap with
866 // `Output.map(Redacted.value)` before returning it from the stack.
867 const SECRET =
868 "1486c434bd35732a185d1712c587ddfafd9e1c8d7a94fb15cf6ece51128";
869 const redactedResource = () => {
870 const src = fakeResource<
871 "Alchemy.Random",
872 { text: Redacted.Redacted<string> }
873 >("Alchemy.Random", "AuthTokenValue");
874 return (Output.of(src) as any).text as Output.Output<
875 Redacted.Redacted<string>
876 >;
877 };
878 const env = { AuthTokenValue: { text: Redacted.make(SECRET) } };
879
880 it.effect(
881 'a raw Redacted output serializes to the literal "<redacted>" (the bug)',
882 () =>
883 provideState(
884 Effect.gen(function* () {
885 const result = yield* Output.evaluate(redactedResource(), env);
886 // The evaluated value is still a Redacted, and persisting it as a
887 // stack output (JSON) loses the real token.
888 expect(Redacted.isRedacted(result)).toBe(true);
889 expect(JSON.stringify({ authToken: result })).toBe(
890 '{"authToken":"<redacted>"}',
891 );
892 }),
893 ),
894 );
895
896 it.effect("Output.map(Redacted.value) emits the real token (the fix)", () =>
897 provideState(
898 Effect.gen(function* () {
899 const expr = redactedResource().pipe(Output.map(Redacted.value));
900 const result = yield* Output.evaluate(expr, env);
901 expect(result).toBe(SECRET);
902 expect(JSON.stringify({ authToken: result })).toBe(
903 `{"authToken":"${SECRET}"}`,
904 );
905 }),
906 ),
907 );
908 },
909);
910
911describe("Output.isOutput / isExpr", { tags: ["unit", "local"] }, () => {
912 it("identifies Output expressions", () => {
913 expect(Output.isOutput(Output.literal(1))).toBe(true);
914 expect(Output.isOutput(Output.all(Output.literal(1)))).toBe(true);
915 expect(Output.isExpr(Output.literal(1))).toBe(true);
916 });
917
918 it("rejects non-Output values", () => {
919 expect(Output.isOutput(1)).toBeFalsy();
920 expect(Output.isOutput("x")).toBeFalsy();
921 expect(Output.isOutput(null)).toBeFalsy();
922 expect(Output.isOutput(undefined)).toBeFalsy();
923 expect(Output.isOutput({})).toBeFalsy();
924 expect(Output.isOutput([])).toBeFalsy();
925 });
926});
927
928describe("Output.asOutput", { tags: ["unit", "local"] }, () => {
929 it.effect("wraps a plain value as a literal Output", () =>
930 provideState(
931 Effect.gen(function* () {
932 const o = Output.asOutput("foo");
933 expect(Output.isOutput(o)).toBe(true);
934 expect(yield* Output.evaluate(o, {})).toBe("foo");
935 }),
936 ),
937 );
938
939 it.effect("wraps an Effect as an EffectExpr", () =>
940 provideState(
941 Effect.gen(function* () {
942 const o = Output.asOutput(Effect.succeed(123));
943 expect(Output.isOutput(o)).toBe(true);
944 expect(yield* Output.evaluate(o, {})).toBe(123);
945 }),
946 ),
947 );
948
949 it("returns the same Output if already an Output", () => {
950 const o = Output.literal("x");
951 expect(Output.asOutput(o)).toBe(o);
952 });
953});
954
955describe(
956 "Output.upstream / hasOutputs / resolveUpstream",
957 { tags: ["unit", "local"] },
958 () => {
959 it("returns upstream resources from a ResourceExpr", () => {
960 const src = fakeResource("Test.A", "FQN-A");
961 const expr = Output.of(src);
962 const up = Output.upstream(expr);
963 expect(Object.keys(up)).toEqual(["FQN-A"]);
964 });
965
966 it("returns upstream resources from a PropExpr", () => {
967 const src = fakeResource("Test.A", "FQN-A");
968 const expr = (Output.of(src) as any).foo;
969 expect(Object.keys(Output.upstream(expr))).toEqual(["FQN-A"]);
970 });
971
972 it("merges upstream resources from AllExpr", () => {
973 const a = fakeResource("Test.A", "A");
974 const b = fakeResource("Test.B", "B");
975 const expr = Output.all(Output.of(a), Output.of(b));
976 expect(Object.keys(Output.upstream(expr)).sort()).toEqual(["A", "B"]);
977 });
978
979 it("returns empty upstream for literals", () => {
980 expect(Output.upstream(Output.literal(1))).toEqual({});
981 });
982
983 it("treats a raw Resource passed directly as an upstream dependency", () => {
984 const src = fakeResource("Test.A", "RawA");
985 expect(Object.keys(Output.upstream(src as any))).toEqual(["RawA"]);
986 });
987
988 it("upstreamAny detects a raw Resource passed directly as a prop value", () => {
989 const a = fakeResource("Test.A", "FQN-A");
990 const b = fakeResource("Test.B", "FQN-B");
991 const props = { image: a, network: b };
992 expect(Object.keys(Output.upstreamAny(props)).sort()).toEqual([
993 "FQN-A",
994 "FQN-B",
995 ]);
996 });
997
998 it("upstreamAny detects raw Resources nested in arrays/objects", () => {
999 const a = fakeResource("Test.A", "FQN-A");
1000 const b = fakeResource("Test.B", "FQN-B");
1001 const props = {
1002 volumes: [{ source: a }],
1003 env: { ref: b },
1004 };
1005 expect(Object.keys(Output.upstreamAny(props)).sort()).toEqual([
1006 "FQN-A",
1007 "FQN-B",
1008 ]);
1009 });
1010
1011 it("upstreamAny detects raw Resource at the top level", () => {
1012 const a = fakeResource("Test.A", "Top");
1013 expect(Object.keys(Output.upstreamAny(a))).toEqual(["Top"]);
1014 });
1015
1016 it("resolveUpstream picks up raw Resources alongside Output expressions", () => {
1017 const a = fakeResource("Test.A", "A1");
1018 const b = fakeResource("Test.B", "B1");
1019 const result = Output.resolveUpstream({
1020 raw: a,
1021 via: Output.of(b),
1022 });
1023 expect(Object.keys(result).sort()).toEqual(["A1", "B1"]);
1024 });
1025
1026 it("hasOutputs is true when an object contains an Output referencing a resource", () => {
1027 const src = fakeResource("Test.A", "X");
1028 expect(Output.hasOutputs({ k: Output.of(src) })).toBe(true);
1029 });
1030
1031 it("hasOutputs is false for plain values", () => {
1032 expect(Output.hasOutputs({ k: 1, b: "x" })).toBe(false);
1033 expect(Output.hasOutputs([1, 2, 3])).toBe(false);
1034 });
1035
1036 it("resolveUpstream walks arrays and objects to gather resources", () => {
1037 const a = fakeResource("Test.A", "RA");
1038 const b = fakeResource("Test.B", "RB");
1039 const result = Output.resolveUpstream({
1040 arr: [Output.of(a)],
1041 nested: { prop: Output.of(b) },
1042 scalar: 1,
1043 });
1044 expect(Object.keys(result).sort()).toEqual(["RA", "RB"]);
1045 });
1046 },
1047);
1048
1049// effect ≥4.0.0-beta.103's Context is self-referential (cacheRoot points back
1050// at itself), which sent the prop walkers into unbounded recursion during
1051// `Plan.make` of the Cloudflare state-store bootstrap stack (#1082). The rule
1052// pinned here: Resources/Outputs are dependencies, plain data (arrays, plain
1053// objects) is traversed to find them, and every other value — ANY class
1054// instance, effect's or otherwise — is a leaf. Plus WeakSet cycle guards so
1055// cyclic plain data terminates.
1056describe(
1057 "upstream walkers: cycles and non-plain leaves (#1082)",
1058 { tags: ["unit", "local"] },
1059 () => {
1060 it("upstreamAny terminates on a cyclic plain object and still finds resources", () => {
1061 const a = fakeResource("Test.A", "CY-A");
1062 const cyclic: any = { name: "cycle" };
1063 cyclic.self = cyclic;
1064 const props = { config: cyclic, dep: Output.of(a) };
1065 expect(Object.keys(Output.upstreamAny(props))).toEqual(["CY-A"]);
1066 });
1067
1068 it("resolveUpstream terminates on mutually-cyclic objects and arrays", () => {
1069 const x: any = { tag: "x" };
1070 const y: any = { tag: "y", x };
1071 x.y = y;
1072 const arr: any[] = [x];
1073 x.arr = arr;
1074 expect(Output.resolveUpstream({ x, y, arr })).toEqual({});
1075 });
1076
1077 it("a shared (diamond) sub-object still contributes its resources", () => {
1078 const a = fakeResource("Test.A", "DI-A");
1079 const shared = { dep: Output.of(a) };
1080 const result = Output.upstreamAny({ left: shared, right: shared });
1081 expect(Object.keys(result)).toEqual(["DI-A"]);
1082 });
1083
1084 it("treats Effect, Layer, and Context values as leaves", () => {
1085 const effect = Effect.succeed(1);
1086 const layer = Layer.succeed(Stage, "test");
1087 const context = Context.empty();
1088 expect(Output.upstreamAny({ effect, layer, context })).toEqual({});
1089 expect(Output.resolveUpstream({ effect, layer, context })).toEqual({});
1090 expect(Output.hasOutputs({ effect, layer, context })).toBe(false);
1091 });
1092
1093 it("traverses null-prototype objects and treats functions as leaves", () => {
1094 const a = fakeResource("Test.A", "NP-A");
1095 const nullProto = Object.assign(Object.create(null), {
1096 dep: Output.of(a),
1097 });
1098 expect(Object.keys(Output.upstreamAny({ nullProto }))).toEqual(["NP-A"]);
1099 // A function in props is a leaf — a closure capturing a resource is not
1100 // a declared dependency.
1101 expect(Output.upstreamAny({ fn: () => a })).toEqual({});
1102 });
1103
1104 it("treats any class instance as a leaf (never walks its internals)", () => {
1105 const a = fakeResource("Test.A", "CL-A");
1106 class SdkConfig {
1107 // A dependency smuggled inside a foreign class instance is invisible
1108 // by design — the engine can't evaluate or persist through it.
1109 dep = Output.of(a);
1110 date = new Date(0);
1111 }
1112 expect(Output.upstreamAny({ config: new SdkConfig() })).toEqual({});
1113 // ...but the same dependency in plain data right next to it is found.
1114 expect(
1115 Object.keys(
1116 Output.upstreamAny({ config: new SdkConfig(), dep: Output.of(a) }),
1117 ),
1118 ).toEqual(["CL-A"]);
1119 });
1120
1121 it("mimics a Worker `exports` entry (constructor Effect + services Context)", () => {
1122 const a = fakeResource("Test.A", "EX-A");
1123 const props = {
1124 name: "worker",
1125 exports: {
1126 Store: {
1127 kind: "durableObject",
1128 constructor: Effect.void,
1129 services: Context.empty(),
1130 },
1131 },
1132 dep: Output.of(a),
1133 };
1134 expect(Object.keys(Output.upstreamAny(props))).toEqual(["EX-A"]);
1135 });
1136
1137 it.effect("evaluate passes Effect/Layer/Context through by identity", () =>
1138 provideState(
1139 Effect.gen(function* () {
1140 const effect = Effect.succeed(1);
1141 const layer = Layer.succeed(Stage, "test");
1142 const context = Context.empty();
1143 const result = yield* Output.evaluate({ effect, layer, context }, {});
1144 expect(result.effect).toBe(effect);
1145 expect(result.layer).toBe(layer);
1146 expect(result.context).toBe(context);
1147 }),
1148 ),
1149 );
1150
1151 it.effect(
1152 "evaluate passes Date/Redacted through by identity (prototype intact)",
1153 () =>
1154 provideState(
1155 Effect.gen(function* () {
1156 const date = new Date(0);
1157 const secret = Redacted.make("hunter2");
1158 const result = yield* Output.evaluate({ date, secret }, {});
1159 expect(result.date).toBe(date);
1160 expect(result.secret).toBe(secret);
1161 }),
1162 ),
1163 );
1164
1165 it.effect(
1166 "evaluate still resolves Config values (Configs are Effects)",
1167 () =>
1168 provideState(
1169 Effect.gen(function* () {
1170 const result = yield* Output.evaluate(
1171 { value: Config.succeed("resolved") },
1172 {},
1173 );
1174 expect(result.value).toBe("resolved");
1175 }),
1176 ),
1177 );
1178
1179 it.effect(
1180 "evaluate resolves outputs at every nesting depth of plain data",
1181 () =>
1182 provideState(
1183 Effect.gen(function* () {
1184 const src = fakeResource("Test.A", "EV-A");
1185 const out = (Output.of(src) as any).name;
1186 const result = yield* Output.evaluate(
1187 {
1188 layers: [
1189 { config: { hosts: [{ url: out }, { url: "static" }] } },
1190 ],
1191 matrix: [[out]],
1192 mixed: [1, "x", { deep: [out] }, null],
1193 nullProto: Object.assign(Object.create(null), { ref: out }),
1194 },
1195 { "EV-A": { name: "resolved-name" } },
1196 );
1197 expect(result.layers[0].config.hosts[0].url).toBe("resolved-name");
1198 expect(result.layers[0].config.hosts[1].url).toBe("static");
1199 expect(result.matrix[0][0]).toBe("resolved-name");
1200 expect(result.mixed).toEqual([
1201 1,
1202 "x",
1203 { deep: ["resolved-name"] },
1204 null,
1205 ]);
1206 expect(result.nullProto.ref).toBe("resolved-name");
1207 }),
1208 ),
1209 );
1210
1211 it.effect("evaluate cuts cyclic plain data but resolves siblings", () =>
1212 provideState(
1213 Effect.gen(function* () {
1214 const src = fakeResource("Test.A", "CY-EV");
1215 const out = (Output.of(src) as any).name;
1216 const cyclic: any = { tag: "c" };
1217 cyclic.self = cyclic;
1218 const arr: any[] = [cyclic];
1219 cyclic.arr = arr;
1220 const result = yield* Output.evaluate(
1221 { cyc: cyclic, url: out },
1222 { "CY-EV": { name: "sibling" } },
1223 );
1224 expect(result.url).toBe("sibling");
1225 expect(result.cyc.tag).toBe("c");
1226 expect(result.cyc.self).toBeUndefined();
1227 expect(result.cyc.arr[0]).toBeUndefined();
1228 }),
1229 ),
1230 );
1231
1232 it.effect(
1233 "evaluate preserves diamonds — shared objects evaluate in both positions",
1234 () =>
1235 provideState(
1236 Effect.gen(function* () {
1237 const src = fakeResource("Test.A", "DI-EV");
1238 const shared = { url: (Output.of(src) as any).name };
1239 const result = yield* Output.evaluate(
1240 { left: shared, right: shared, list: [shared] },
1241 { "DI-EV": { name: "diamond" } },
1242 );
1243 expect(result.left).toEqual({ url: "diamond" });
1244 expect(result.right).toEqual({ url: "diamond" });
1245 expect(result.list[0]).toEqual({ url: "diamond" });
1246 }),
1247 ),
1248 );
1249 },
1250);
1251
1252describe("Output.toEnvKey / toUpper", { tags: ["unit", "local"] }, () => {
1253 it("uppercases strings", () => {
1254 expect(Output.toUpper("hello")).toBe("HELLO");
1255 });
1256
1257 it("joins id + suffix and replaces dashes with underscores", () => {
1258 expect(Output.toEnvKey("my-bucket", "name")).toBe("MY_BUCKET_NAME");
1259 expect(Output.toEnvKey("svc", "api-key")).toBe("SVC_API_KEY");
1260 });
1261});