1import { Data } from "effect";
2import * as Config from "effect/Config";
3import * as ConfigProvider from "effect/ConfigProvider";
4import * as Context from "effect/Context";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as HttpServerResponse from "effect/http/HttpServerResponse";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Redacted from "effect/Redacted";
11import { Unowned } from "@/AdoptPolicy";
12import { AlchemyContext } from "@/AlchemyContext.ts";
13import { Artifacts } from "@/Artifacts";
14import * as Binding from "@/Binding.ts";
15import { isResolved } from "@/Diff.ts";
16import * as ProviderLayer from "@/Local/ProviderLayer.ts";
17import { Platform, type Main, type PlatformProps } from "@/Platform.ts";
18import * as Provider from "@/Provider.ts";
19import { LOCAL_ID_PREFIX, type ProviderMode } from "@/ProviderMode.ts";
20import { Resource, type ResourceBinding } from "@/Resource";
21import {
22 createHostRuntimeContext,
23 type HostRuntimeContext,
24 type ServerHost,
25} from "@/Runtime/Process.ts";
26import { unpackEnvValue } from "@/RuntimeContext.ts";
27import { Stack } from "@/Stack";
28import * as State from "@/State/index";
29import { isUnknown } from "@/Util/unknown";
30
31// Bucket
32export type BucketProps = {
33 name?: string;
34};
35
36export interface Bucket extends Resource<
37 "Test.Bucket",
38 BucketProps,
39 {
40 name: string;
41 bucketArn: string;
42 }
43> {}
44
45export const Bucket = Resource<Bucket>("Test.Bucket");
46
47const bucketProvider = () =>
48 Provider.succeed(Bucket, {
49 list: () => Effect.succeed([]),
50 diff: Effect.fn(function* ({ id, news, output }) {
51 if (!isResolved(news)) return undefined;
52 }),
53 reconcile: Effect.fn(function* ({ id, news = {}, output }) {
54 if (output !== undefined) return output;
55 return {
56 name: news.name ?? id,
57 bucketArn: `arn:test:bucket:us-east-1:123456789:${id}`,
58 };
59 }),
60 delete: Effect.fn(function* ({ output }) {
61 return;
62 }),
63 });
64
65// Queue
66export type Props = {
67 name?: string;
68};
69
70export interface Queue extends Resource<
71 "Test.Queue",
72 Props,
73 {
74 name: string;
75 queueUrl: string;
76 }
77> {}
78
79export const Queue = Resource<Queue>("Test.Queue");
80
81export const queueProvider = () =>
82 Provider.succeed(Queue, {
83 list: () => Effect.succeed([]),
84 diff: Effect.fn(function* ({ id, news = {}, output }) {
85 if (!isResolved(news)) return undefined;
86 }),
87 reconcile: Effect.fn(function* ({ id, news = {} }) {
88 const name = news.name ?? id;
89 return {
90 name,
91 queueUrl: `https://test.queue.com/${name}`,
92 };
93 }),
94 delete: Effect.fn(function* ({ output }) {}),
95 });
96
97export type FunctionProps = {
98 name?: string;
99 env?: Record<string, string>;
100};
101
102export interface Function extends Resource<
103 "Test.Function",
104 FunctionProps,
105 {
106 name: string;
107 env: Record<string, string>;
108 functionArn: string;
109 }
110> {}
111
112export const Function = Resource<Function>("Test.Function");
113
114export const functionProvider = () =>
115 Provider.succeed(Function, {
116 list: () => Effect.succeed([]),
117 diff: Effect.fn(function* ({ id, news, output }) {
118 if (!isResolved(news)) return undefined;
119 }),
120 reconcile: Effect.fn(function* ({ id, news = {} }) {
121 return {
122 name: news.name ?? id,
123 env: news.env ?? {},
124 functionArn: `arn:aws:lambda:us-west-2:084828582823:function:${id}`,
125 };
126 }),
127 delete: Effect.fn(function* ({ output }) {}),
128 });
129
130export type BindingTargetProps = {
131 name?: string;
132 string?: string;
133 replaceString?: string;
134};
135
136export interface BindingTarget extends Resource<
137 "Test.BindingTarget",
138 BindingTargetProps,
139 {
140 name: string;
141 string: string;
142 env: Record<string, string>;
143 replaceString: BindingTargetProps["replaceString"];
144 },
145 {
146 env?: Record<string, string>;
147 }
148> {}
149
150export const BindingTarget = Resource<BindingTarget>("Test.BindingTarget");
151
152export const bindingTargetProvider = () =>
153 Provider.effect(
154 BindingTarget,
155 Effect.gen(function* () {
156 return {
157 list: () => Effect.succeed([]),
158 diff: Effect.fn(function* ({ id, news = {}, olds = {}, newBindings }) {
159 if (!isResolved(news)) return undefined;
160 const hooks = Option.getOrUndefined(yield* Effect.serviceOption(TestResourceHooks));
161 if (hooks?.diff && isResolved(newBindings)) {
162 yield* hooks.diff(id, newBindings as ResourceBinding[]);
163 }
164 const n = news as BindingTargetProps;
165 const o = olds as BindingTargetProps;
166 if (n.replaceString !== o.replaceString) {
167 return {
168 action: "replace",
169 };
170 }
171 if (n.name !== o.name || n.string !== o.string) {
172 return {
173 action: "update",
174 };
175 }
176 return undefined;
177 }),
178 precreate: Effect.fn(function* ({ id, news = {} }) {
179 return {
180 name: news.name ?? id,
181 string: news.string ?? id,
182 env: {},
183 replaceString: news.replaceString,
184 };
185 }),
186 reconcile: Effect.fn(function* ({ id, news = {}, olds, bindings }) {
187 // The hook routing tracks the engine's create-vs-update intent.
188 // `olds === undefined` covers greenfield AND replacement-create
189 // (engine resets olds when minting a new instance), which is
190 // exactly when the test wants `failOn("X", "create")` to fire.
191 // `output === undefined` would miss replacements with `precreate`
192 // because precreate populates `output` before reconcile runs.
193 const hooks = Option.getOrUndefined(yield* Effect.serviceOption(TestResourceHooks));
194 if (olds === undefined) {
195 if (hooks?.create) {
196 yield* hooks.create(id, news as TestResourceProps);
197 }
198 } else {
199 if (hooks?.update) {
200 yield* hooks.update(id, news as TestResourceProps);
201 }
202 }
203 return {
204 name: news.name ?? id,
205 string: news.string ?? id,
206 env: Object.assign(
207 {},
208 ...bindings.map((binding: any) => binding.env ?? binding.data?.env ?? {}),
209 ),
210 replaceString: news.replaceString,
211 };
212 }),
213 delete: Effect.fn(function* ({ id }) {
214 const hooks = Option.getOrUndefined(yield* Effect.serviceOption(TestResourceHooks));
215 if (hooks?.delete) {
216 yield* hooks.delete(id);
217 }
218 return;
219 }),
220 };
221 }),
222 );
223
224export type DeletedBindingRegressionProps = {
225 name?: string;
226};
227
228export interface DeletedBindingRegressionTarget extends Resource<
229 "Test.DeletedBindingRegressionTarget",
230 DeletedBindingRegressionProps,
231 {
232 name: string;
233 env: Record<string, string>;
234 },
235 {
236 env?: Record<string, string>;
237 }
238> {}
239
240export const DeletedBindingRegressionTarget = Resource<DeletedBindingRegressionTarget>(
241 "Test.DeletedBindingRegressionTarget",
242);
243
244export const deletedBindingRegressionProvider = () =>
245 Provider.succeed(DeletedBindingRegressionTarget, {
246 list: () => Effect.succeed([]),
247 diff: Effect.fn(function* () {}),
248 precreate: Effect.fn(function* ({ id, news = {} }) {
249 return {
250 name: news.name ?? id,
251 env: {},
252 };
253 }),
254 reconcile: Effect.fn(function* ({ id, news = {}, bindings }) {
255 return {
256 name: news.name ?? id,
257 env: Object.assign(
258 {},
259 ...bindings.map((binding: any) => binding.env ?? binding.data?.env ?? {}),
260 ),
261 };
262 }),
263 delete: Effect.fn(function* () {}),
264 });
265
266export type ArtifactProbeProps = {
267 value: string;
268};
269
270export interface ArtifactProbe extends Resource<
271 "Test.ArtifactProbe",
272 ArtifactProbeProps,
273 {
274 value: string;
275 artifactValue: string | undefined;
276 }
277> {}
278
279export const ArtifactProbe = Resource<ArtifactProbe>("Test.ArtifactProbe");
280
281export const artifactProbeProvider = () =>
282 Provider.succeed(ArtifactProbe, {
283 list: () => Effect.succeed([]),
284 diff: Effect.fn(function* ({ news, olds }) {
285 const next = news as ArtifactProbeProps;
286 const prev = olds as ArtifactProbeProps | undefined;
287 const artifacts = yield* Artifacts;
288 const previous = yield* artifacts.get<string>("memo");
289 if (previous !== undefined && previous !== next.value && previous !== prev?.value) {
290 return { action: "replace" as const };
291 }
292 yield* artifacts.set("memo", next.value);
293 return next.value !== prev?.value ? { action: "update" as const } : undefined;
294 }),
295 reconcile: Effect.fn(function* ({ news }) {
296 const props = news as ArtifactProbeProps;
297 const artifacts = yield* Artifacts;
298 return {
299 value: props.value,
300 artifactValue: yield* artifacts.get<string>("memo"),
301 };
302 }),
303 delete: Effect.fn(function* () {}),
304 });
305
306// TestResource
307
308export type TestResourceProps = {
309 string?: string;
310 stringArray?: string[];
311 object?: {
312 string: string;
313 };
314 replaceString?: string;
315 redacted?: Redacted.Redacted<string>;
316 redactedArray?: Redacted.Redacted<string>[];
317};
318
319export interface TestResource extends Resource<
320 "Test.TestResource",
321 TestResourceProps,
322 {
323 string: string;
324 stringArray: string[];
325 stableString: string;
326 stableArray: string[];
327 replaceString: TestResourceProps["replaceString"];
328 redacted: Redacted.Redacted<string> | undefined;
329 redactedArray: Redacted.Redacted<string>[] | undefined;
330 }
331> {}
332
333export class TestResourceHooks extends Context.Service<
334 TestResourceHooks,
335 {
336 create?: (id: string, props: TestResourceProps) => Effect.Effect<void, any>;
337 update?: (id: string, props: TestResourceProps) => Effect.Effect<void, any>;
338 delete?: (id: string) => Effect.Effect<void, any>;
339 /**
340 * If provided, invoked from a binding-aware provider's `diff` with the
341 * exact `newBindings` array the engine handed it. Lets a test assert what
342 * the plan stage observes (e.g. that duplicates were collapsed by sid).
343 */
344 diff?: (id: string, newBindings: ResourceBinding[]) => Effect.Effect<void, any>;
345 /**
346 * If provided, the read hook is invoked for the resource's `read` lifecycle
347 * operation. Return:
348 * - attrs (any object) to simulate an existing cloud resource that is
349 * adoptable
350 * - `undefined` to simulate a resource that does not exist
351 * - fail with `OwnedBySomeoneElse` to reject adoption
352 */
353 read?: (id: string) => Effect.Effect<TestResource["Attributes"] | undefined, any>;
354 }
355>()("TestResourceHooks") {}
356
357export const TestResource = Resource<TestResource>("Test.TestResource");
358
359export const testResourceProvider = () =>
360 Provider.effect(
361 TestResource,
362 Effect.gen(function* () {
363 return {
364 list: () => Effect.succeed([]),
365 read: Effect.fn(function* ({ id, output }) {
366 const hooks = Option.getOrUndefined(yield* Effect.serviceOption(TestResourceHooks));
367 if (hooks?.read) {
368 return (yield* hooks.read(id)) as any;
369 }
370 return output;
371 }),
372 diff: Effect.fn(function* ({ id, news = {}, olds = {} }) {
373 if (!isResolved(news)) return undefined;
374 const n = news as TestResourceProps;
375 const o = olds as TestResourceProps;
376 if (n.replaceString !== o.replaceString) {
377 return {
378 action: "replace",
379 };
380 }
381 const redactedValue = (r: Redacted.Redacted<string> | undefined): string | undefined =>
382 r && Redacted.isRedacted(r) ? Redacted.value(r) : undefined;
383 const redactedArrayValues = (
384 arr: Redacted.Redacted<string>[] | undefined,
385 ): string[] | undefined => arr?.map((r) => Redacted.value(r));
386 const oldRedactedArr = redactedArrayValues(o.redactedArray);
387 const newRedactedArr = redactedArrayValues(n.redactedArray);
388 return isUnknown(n.string) ||
389 isUnknown(n.stringArray) ||
390 n.string !== o.string ||
391 n.stringArray?.length !== o.stringArray?.length ||
392 !!n.stringArray !== !!o.stringArray ||
393 n.stringArray?.some(isUnknown) ||
394 n.stringArray?.some((s, i) => s !== o.stringArray?.[i]) ||
395 redactedValue(n.redacted) !== redactedValue(o.redacted) ||
396 oldRedactedArr?.length !== newRedactedArr?.length ||
397 !!oldRedactedArr !== !!newRedactedArr ||
398 newRedactedArr?.some((s, i) => s !== oldRedactedArr?.[i])
399 ? {
400 action: "update",
401 stables: ["stableString", "stableArray"],
402 }
403 : undefined;
404 }),
405 reconcile: Effect.fn(function* ({ id, news = {}, olds }) {
406 const hooks = Option.getOrUndefined(yield* Effect.serviceOption(TestResourceHooks));
407 // Branch on `olds` (engine's create-vs-update intent), not
408 // `output` — replacements arrive with a precreate stub in
409 // `output` but `olds === undefined`.
410 if (olds === undefined) {
411 if (hooks?.create) {
412 yield* hooks.create(id, news);
413 }
414 } else {
415 if (hooks?.update) {
416 yield* hooks.update(id, news);
417 }
418 }
419 return {
420 string: news.string ?? id,
421 stringArray: news.stringArray ?? [],
422 stableString: id,
423 stableArray: [id],
424 replaceString: news.replaceString,
425 redacted: news.redacted,
426 redactedArray: news.redactedArray,
427 };
428 }),
429 delete: Effect.fn(function* ({ id }) {
430 const hooks = Option.getOrUndefined(yield* Effect.serviceOption(TestResourceHooks));
431 if (hooks?.delete) {
432 yield* hooks.delete(id);
433 }
434 return;
435 }),
436 };
437 }),
438 );
439
440// StaticStablesResource - A test resource that has static stables on the provider
441// This simulates resources like VPC, Subnet, etc. where certain properties (e.g., vpcId, subnetId)
442// are always stable and defined on the provider itself, not returned dynamically by diff()
443
444export type StaticStablesResourceProps = {
445 string?: string;
446 tags?: Record<string, string>;
447 replaceString?: string;
448};
449
450export interface StaticStablesResource extends Resource<
451 "Test.StaticStablesResource",
452 StaticStablesResourceProps,
453 {
454 string: string;
455 tags: Record<string, string>;
456 stableId: string;
457 stableArn: string;
458 replaceString: StaticStablesResourceProps["replaceString"];
459 }
460> {}
461
462export class StaticStablesResourceHooks extends Context.Service<
463 StaticStablesResourceHooks,
464 {
465 create?: (id: string, props: StaticStablesResourceProps) => Effect.Effect<void, any>;
466 update?: (id: string, props: StaticStablesResourceProps) => Effect.Effect<void, any>;
467 delete?: (id: string) => Effect.Effect<void, any>;
468 }
469>()("StaticStablesResourceHooks") {}
470
471export const StaticStablesResource = Resource<StaticStablesResource>("Test.StaticStablesResource");
472
473export const staticStablesResourceProvider = () =>
474 Provider.succeed(StaticStablesResource, {
475 list: () => Effect.succeed([]),
476 // KEY DIFFERENCE: Static stables defined on the provider itself
477 // These are always stable regardless of what diff() returns
478 stables: ["stableId", "stableArn"],
479 diff: Effect.fn(function* ({ id, news = {}, olds = {} }) {
480 if (!isResolved(news)) return undefined;
481 const n = news as StaticStablesResourceProps;
482 const o = olds as StaticStablesResourceProps;
483 // Replace when replaceString changes
484 if (n.replaceString !== o.replaceString) {
485 return { action: "replace" };
486 }
487 // For string changes, return update action
488 if (n.string !== o.string) {
489 return { action: "update" };
490 }
491 // For tag-only changes, return undefined (no action)
492 // This simulates the VPC bug: tags changed, arePropsChanged returns true,
493 // but diff() returns undefined because provider doesn't explicitly handle tags
494 return undefined;
495 }),
496 reconcile: Effect.fn(function* ({ id, news = {}, olds, output }) {
497 const hooks = Option.getOrUndefined(yield* Effect.serviceOption(StaticStablesResourceHooks));
498 // Branch on `olds` (engine create vs update intent). Replacements
499 // pass `output` from the previous generation if any, but engine
500 // resets `olds` to `undefined` for the new instance.
501 if (olds === undefined) {
502 if (hooks?.create) {
503 yield* hooks.create(id, news);
504 }
505 return {
506 string: news.string ?? id,
507 tags: news.tags ?? {},
508 stableId: output?.stableId ?? `stable-${id}`,
509 stableArn: output?.stableArn ?? (`arn:test:resource:us-east-1:123456789:${id}` as const),
510 replaceString: news.replaceString,
511 };
512 }
513 if (hooks?.update) {
514 yield* hooks.update(id, news);
515 }
516 return {
517 string: news.string ?? id,
518 tags: news.tags ?? {},
519 stableId: output?.stableId ?? `stable-${id}`,
520 stableArn: output?.stableArn ?? (`arn:test:resource:us-east-1:123456789:${id}` as const),
521 replaceString: news.replaceString,
522 };
523 }),
524 delete: Effect.fn(function* ({ id, output }) {
525 yield* Effect.logDebug(output.string);
526 const hooks = Option.getOrUndefined(yield* Effect.serviceOption(StaticStablesResourceHooks));
527 if (hooks?.delete) {
528 yield* hooks.delete(id);
529 }
530 return;
531 }),
532 });
533
534// KindStablesResource - reproduces providers whose stable output attributes
535// include a `kind` field, such as PlanetScale databases.
536
537export type KindStablesResourceProps = {
538 value: string;
539 upstream?: KindStablesResource;
540};
541
542export interface KindStablesResource extends Resource<
543 "Test.KindStablesResource",
544 KindStablesResourceProps,
545 {
546 kind: "postgresql";
547 value: string;
548 upstreamKind: "postgresql" | undefined;
549 }
550> {}
551
552export const KindStablesResource = Resource<KindStablesResource>("Test.KindStablesResource");
553
554export const kindStablesResourceProvider = () =>
555 Provider.succeed(KindStablesResource, {
556 list: () => Effect.succeed([]),
557 stables: ["kind"],
558 diff: Effect.fn(function* ({ news, olds }) {
559 if (!isResolved(news)) return undefined;
560 if (news.value !== olds?.value) {
561 return { action: "update" };
562 }
563 return undefined;
564 }),
565 reconcile: Effect.fn(function* ({ news }) {
566 const upstream = news.upstream as KindStablesResource["Attributes"] | undefined;
567 return {
568 kind: "postgresql",
569 value: news.value,
570 upstreamKind: upstream?.kind,
571 };
572 }),
573 delete: Effect.fn(function* () {}),
574 });
575
576// OverrideStablesResource — declares BOTH a provider-level `stables` list AND
577// a `diff` that returns its own `stables` list that DISAGREES with it. Used to
578// assert that a present `diff.stables` OVERRIDES `provider.stables` during plan
579// (rather than being merged with it):
580// - `providerStable` is only in `provider.stables` (omitted by `diff.stables`)
581// - `diffStable` is only in `diff.stables` (omitted by `provider.stables`)
582// - `sharedStable` is in both
583// Under override semantics, on a `string` change `providerStable` must be
584// treated as CHANGED (downstream re-plans) while `diffStable`/`sharedStable`
585// stay stable. Under the old merge, `providerStable` would wrongly stay stable.
586// When `string` is unchanged the diff returns a noop that still carries its
587// `stables`, which a `--force` deploy must honor (#1832).
588
589export type OverrideStablesResourceProps = {
590 string?: string;
591};
592
593export interface OverrideStablesResource extends Resource<
594 "Test.OverrideStablesResource",
595 OverrideStablesResourceProps,
596 {
597 string: string;
598 providerStable: string;
599 diffStable: string;
600 sharedStable: string;
601 }
602> {}
603
604export const OverrideStablesResource = Resource<OverrideStablesResource>(
605 "Test.OverrideStablesResource",
606);
607
608export const overrideStablesResourceProvider = () =>
609 Provider.succeed(OverrideStablesResource, {
610 list: () => Effect.succeed([]),
611 stables: ["providerStable", "sharedStable"],
612 diff: Effect.fn(function* ({ news = {}, olds = {} }) {
613 if (!isResolved(news)) return undefined;
614 const n = news as OverrideStablesResourceProps;
615 const o = olds as OverrideStablesResourceProps;
616 if (n.string !== o.string) {
617 return {
618 action: "update",
619 stables: ["diffStable", "sharedStable"],
620 } as const;
621 }
622 // Unchanged: still advertise the diff-level stables so a `--force`
623 // deploy (which upgrades this noop to an update) keeps them (#1832).
624 return { action: "noop", stables: ["diffStable", "sharedStable"] } as const;
625 }),
626 reconcile: Effect.fn(function* ({ id, news = {} }) {
627 return {
628 string: news.string ?? id,
629 providerStable: `provider-${id}`,
630 diffStable: `diff-${id}`,
631 sharedStable: `shared-${id}`,
632 };
633 }),
634 delete: Effect.fn(function* () {}),
635 });
636
637export type PhasedTargetProps = {
638 desired: string;
639 replaceKey?: string;
640};
641
642export interface PhasedTarget extends Resource<
643 "Test.PhasedTarget",
644 PhasedTargetProps,
645 {
646 stableId: string;
647 value: string;
648 env: Record<string, string>;
649 replaceKey: string | undefined;
650 },
651 {
652 env?: Record<string, string>;
653 }
654> {}
655
656export const PhasedTarget = Resource<PhasedTarget>("Test.PhasedTarget");
657
658const phasedStableId = (replaceKey?: string) => `stable:${replaceKey ?? "default"}`;
659
660const mergeBindingEnv = (bindings: Array<any>) =>
661 Object.assign({}, ...bindings.map((binding) => binding.env ?? binding.data?.env ?? {}));
662
663export const phasedTargetProvider = () =>
664 Provider.effect(
665 PhasedTarget,
666 Effect.gen(function* () {
667 return {
668 list: () => Effect.succeed([]),
669 diff: Effect.fn(function* ({ news, olds }) {
670 if (!isResolved(news)) return undefined;
671 const n = news as PhasedTargetProps;
672 const o = olds as PhasedTargetProps;
673 if (n.replaceKey !== o.replaceKey) {
674 return { action: "replace" } as const;
675 }
676 if (n.desired !== o.desired) {
677 return { action: "update" } as const;
678 }
679 }),
680 precreate: Effect.fn(function* ({ news }) {
681 return {
682 stableId: phasedStableId(news.replaceKey),
683 value: `pre:${news.desired}`,
684 env: {},
685 replaceKey: news.replaceKey,
686 };
687 }),
688 reconcile: Effect.fn(function* ({ id, news, olds, bindings }) {
689 const hooks = Option.getOrUndefined(yield* Effect.serviceOption(TestResourceHooks));
690 // Branch on `olds` not `output`: replacement-create has
691 // precreate-populated `output` but engine-cleared `olds`.
692 if (olds === undefined) {
693 if (hooks?.create) {
694 yield* hooks.create(id, {
695 string: news.desired,
696 replaceString: news.replaceKey,
697 });
698 }
699 } else {
700 if (hooks?.update) {
701 yield* hooks.update(id, {
702 string: news.desired,
703 replaceString: news.replaceKey,
704 });
705 }
706 }
707 return {
708 stableId: phasedStableId(news.replaceKey),
709 value: news.desired,
710 env: mergeBindingEnv(bindings),
711 replaceKey: news.replaceKey,
712 };
713 }),
714 delete: Effect.fn(function* ({ id }) {
715 const hooks = Option.getOrUndefined(yield* Effect.serviceOption(TestResourceHooks));
716 if (hooks?.delete) {
717 yield* hooks.delete(id);
718 }
719 }),
720 };
721 }),
722 );
723
724// NoPrecreateBindingTarget - like BindingTarget but without precreate,
725// used to test cycle detection for resources that cannot break cycles.
726
727export type NoPrecreateBindingTargetProps = {
728 string?: string;
729};
730
731export interface NoPrecreateBindingTarget extends Resource<
732 "Test.NoPrecreateBindingTarget",
733 NoPrecreateBindingTargetProps,
734 {
735 string: string;
736 env: Record<string, string>;
737 },
738 {
739 env?: Record<string, string>;
740 }
741> {}
742
743export const NoPrecreateBindingTarget = Resource<NoPrecreateBindingTarget>(
744 "Test.NoPrecreateBindingTarget",
745);
746
747export const noPrecreateBindingTargetProvider = () =>
748 Provider.succeed(NoPrecreateBindingTarget, {
749 list: () => Effect.succeed([]),
750 diff: Effect.fn(function* () {}),
751 reconcile: Effect.fn(function* ({ id, news = {}, bindings }) {
752 return {
753 string: news.string ?? id,
754 env: Object.assign(
755 {},
756 ...bindings.map((binding: any) => binding.env ?? binding.data?.env ?? {}),
757 ),
758 };
759 }),
760 delete: Effect.fn(function* () {}),
761 });
762
763// DurationResource — exercises Duration round-tripping through state.
764// Input Duration must arrive at reconcile as a real Duration object (so the
765// resolver doesn't shred its prototype); output Duration must re-hydrate from
766// state on a subsequent deploy as a real Duration object too.
767
768export type DurationResourceProps = {
769 timeout: Duration.Duration;
770};
771
772export interface DurationResource extends Resource<
773 "Test.DurationResource",
774 DurationResourceProps,
775 {
776 /** Echoes the input Duration so we can assert what reconcile observed. */
777 observedTimeout: Duration.Duration;
778 /** Authoritative Duration the provider produces; persisted to state. */
779 computedTimeout: Duration.Duration;
780 }
781> {}
782
783export const DurationResource = Resource<DurationResource>("Test.DurationResource");
784
785export const durationResourceProvider = () =>
786 Provider.succeed(DurationResource, {
787 list: () => Effect.succeed([]),
788 diff: Effect.fn(function* ({ news }) {
789 if (!isResolved(news)) return undefined;
790 return undefined;
791 }),
792 reconcile: Effect.fn(function* ({ news }) {
793 // If `news.timeout` was shredded by the resolver into a plain object,
794 // these calls would throw or produce nonsense. The test asserts the
795 // numeric output so a regression surfaces as a failed assertion.
796 const observed = news.timeout;
797 const computed = Duration.millis(Duration.toMillis(observed) + 1_000);
798 return {
799 observedTimeout: observed,
800 computedTimeout: computed,
801 };
802 }),
803 delete: Effect.fn(function* () {}),
804 });
805
806// DeleteFirstResource — exercises `{ action: "replace", deleteFirst: true }`.
807//
808// Models a resource whose replacement cannot coexist with the original (a
809// fixed physical name / singleton). When `replaceString` changes it asks the
810// engine to tear the old generation down BEFORE creating the new one.
811//
812// Two test affordances:
813// - create/update/delete route through `TestResourceHooks` so a test can
814// record the order the engine invokes them in.
815// - if a `CollisionRegistry` is in context, create fails when an instance
816// with the same physical `name` is still live. Under create-first ordering
817// a same-name replacement would collide here (reproducing the real Docker
818// "network already exists" / no-op `volume create` bug); under delete-first
819// it succeeds.
820
821export class CollisionRegistry extends Context.Service<
822 CollisionRegistry,
823 { readonly live: Set<string> }
824>()("CollisionRegistry") {}
825
826export class CollisionError extends Data.TaggedError("CollisionError")<{
827 name: string;
828}> {}
829
830export type DeleteFirstResourceProps = {
831 string?: string;
832 replaceString?: string;
833 name?: string;
834};
835
836export interface DeleteFirstResource extends Resource<
837 "Test.DeleteFirstResource",
838 DeleteFirstResourceProps,
839 {
840 name: string;
841 string: string;
842 replaceString: DeleteFirstResourceProps["replaceString"];
843 }
844> {}
845
846export const DeleteFirstResource = Resource<DeleteFirstResource>("Test.DeleteFirstResource");
847
848export const deleteFirstResourceProvider = () =>
849 Provider.succeed(DeleteFirstResource, {
850 list: () => Effect.succeed([]),
851 diff: Effect.fn(function* ({ news = {}, olds = {} }) {
852 if (!isResolved(news)) return undefined;
853 const n = news as DeleteFirstResourceProps;
854 const o = olds as DeleteFirstResourceProps;
855 if (n.replaceString !== o.replaceString) {
856 return { action: "replace", deleteFirst: true } as const;
857 }
858 if (n.string !== o.string) {
859 return { action: "update" } as const;
860 }
861 return undefined;
862 }),
863 reconcile: Effect.fn(function* ({ id, news = {}, olds }) {
864 const name = news.name ?? id;
865 const hooks = Option.getOrUndefined(yield* Effect.serviceOption(TestResourceHooks));
866 const registry = Option.getOrUndefined(yield* Effect.serviceOption(CollisionRegistry));
867 // `olds === undefined` ⇒ create (greenfield OR replacement-create); the
868 // engine clears `olds` when minting the new replacement generation.
869 if (olds === undefined) {
870 if (registry?.live.has(name)) {
871 return yield* Effect.fail(new CollisionError({ name }));
872 }
873 registry?.live.add(name);
874 if (hooks?.create) {
875 yield* hooks.create(id, {
876 string: news.string,
877 replaceString: news.replaceString,
878 });
879 }
880 } else if (hooks?.update) {
881 yield* hooks.update(id, {
882 string: news.string,
883 replaceString: news.replaceString,
884 });
885 }
886 return {
887 name,
888 string: news.string ?? id,
889 replaceString: news.replaceString,
890 };
891 }),
892 delete: Effect.fn(function* ({ id, output }) {
893 const hooks = Option.getOrUndefined(yield* Effect.serviceOption(TestResourceHooks));
894 const registry = Option.getOrUndefined(yield* Effect.serviceOption(CollisionRegistry));
895 registry?.live.delete(output.name);
896 if (hooks?.delete) {
897 yield* hooks.delete(id);
898 }
899 }),
900 });
901
902// ── DriftResource — exercises `alchemy drift --repair`.
903//
904// Models a cloud with an inspectable, mutable backing store (`TestCloud`):
905// `reconcile` upserts the resource into the cloud map, `read` observes it,
906// `delete` removes it. Tests mutate the map out-of-band to simulate drift
907// (or delete entries to simulate out-of-band deletion) and assert that
908// `sync` converges the cloud back to the last-deployed desired state.
909//
910// The map stores deep copies — the in-memory state store keeps references,
911// so aliasing the persisted `attr` would make out-of-band mutations
912// invisible to drift detection.
913
914export interface TestCloudService {
915 /** Live cloud state keyed by logical id. Mutate/delete to simulate drift. */
916 readonly resources: Map<string, Record<string, any>>;
917 /** Ids whose `read` result is branded {@link Unowned} (foreign tags). */
918 readonly unowned: Set<string>;
919 /**
920 * Lifecycle invocations, in order. Clear between phases to scope asserts.
921 * `reconcile` calls record the sids of the bindings they received.
922 */
923 readonly calls: {
924 op: "read" | "precreate" | "reconcile" | "delete";
925 id: string;
926 bindings?: string[];
927 }[];
928 /** Creates per id; a recreated resource gets a fresh `arn`. */
929 readonly generations: Map<string, number>;
930 /** Runs inside every `read`, before the cloud is observed. */
931 onRead?: (id: string) => Effect.Effect<void>;
932}
933
934export class TestCloud extends Context.Service<TestCloud, TestCloudService>()("TestCloud") {}
935
936export const makeTestCloud = (): TestCloudService => ({
937 resources: new Map(),
938 unowned: new Set(),
939 calls: [],
940 generations: new Map(),
941});
942
943export type DriftResourceProps = {
944 value?: string;
945 tags?: Record<string, string>;
946 /**
947 * Ids of resources that must exist in the TestCloud for reconcile to
948 * succeed — models a prop pointing at another cloud resource (an SNS
949 * subscription's topic).
950 */
951 dependsOn?: string[];
952 /** Echoed into the `ref` attribute — e.g. an upstream's `arn`. */
953 ref?: string;
954};
955
956export interface DriftResource extends Resource<
957 "Test.DriftResource",
958 DriftResourceProps,
959 {
960 id: string;
961 value: string;
962 tags: Record<string, string>;
963 env: Record<string, string>;
964 /**
965 * `id#generation` — changes when the resource is recreated, like an
966 * AWS-assigned ARN. Only set with a TestCloud in context.
967 */
968 arn?: string;
969 ref?: string;
970 },
971 {
972 env?: Record<string, string>;
973 /**
974 * Ids of resources this binding points at, which must exist in the
975 * TestCloud — models a binding validated by the cloud (an S3
976 * notification targeting a topic).
977 */
978 targets?: string[];
979 }
980> {}
981
982export const DriftResource = Resource<DriftResource>("Test.DriftResource");
983
984export const driftResourceProvider = () =>
985 Provider.effect(
986 DriftResource,
987 Effect.gen(function* () {
988 const cloudOf = Effect.serviceOption(TestCloud).pipe(Effect.map(Option.getOrUndefined));
989 const copy = (attrs: Record<string, any>) =>
990 JSON.parse(JSON.stringify(attrs)) as DriftResource["Attributes"];
991 return {
992 list: () => Effect.succeed([]),
993 read: Effect.fn(function* ({ id, output }) {
994 const cloud = yield* cloudOf;
995 // Without a TestCloud in context the resource behaves like
996 // TestResource: read reflects the persisted output back.
997 if (!cloud) return output;
998 cloud.calls.push({ op: "read", id });
999 if (cloud.onRead) yield* cloud.onRead(id);
1000 const live = cloud.resources.get(id);
1001 if (live === undefined) return undefined;
1002 const attrs = copy(live);
1003 return cloud.unowned.has(id) ? Unowned(attrs) : attrs;
1004 }),
1005 // A bare stub so deploys can break binding cycles (like S3 Bucket).
1006 precreate: Effect.fn(function* ({ id, news = {} }) {
1007 const cloud = yield* cloudOf;
1008 cloud?.calls.push({ op: "precreate", id });
1009 if (cloud && !cloud.resources.has(id)) {
1010 cloud.generations.set(id, (cloud.generations.get(id) ?? 0) + 1);
1011 }
1012 const attrs = {
1013 id,
1014 value: news.value ?? id,
1015 tags: news.tags ?? {},
1016 ...(cloud ? { arn: `${id}#${cloud.generations.get(id)}` } : {}),
1017 env: {},
1018 };
1019 cloud?.resources.set(id, copy(attrs));
1020 return attrs;
1021 }),
1022 reconcile: Effect.fn(function* ({ id, news = {}, olds, bindings }) {
1023 const cloud = yield* cloudOf;
1024 cloud?.calls.push({
1025 op: "reconcile",
1026 id,
1027 bindings: bindings.map((binding: any) => binding.sid),
1028 });
1029 const hooks = Option.getOrUndefined(yield* Effect.serviceOption(TestResourceHooks));
1030 const required = [
1031 ...(news.dependsOn ?? []),
1032 ...bindings.flatMap((binding: any) => binding.data?.targets ?? []),
1033 ];
1034 const absent = cloud ? required.filter((dep) => !cloud.resources.has(dep)) : [];
1035 if (absent.length > 0) {
1036 return yield* Effect.fail(
1037 new ResourceFailure(`${id} depends on missing ${absent.join(", ")}`),
1038 );
1039 }
1040 if (olds === undefined) {
1041 if (hooks?.create) {
1042 yield* hooks.create(id, { string: news.value });
1043 }
1044 } else if (hooks?.update) {
1045 yield* hooks.update(id, { string: news.value });
1046 }
1047 const live = cloud?.resources.get(id);
1048 if (cloud && live === undefined) {
1049 cloud.generations.set(id, (cloud.generations.get(id) ?? 0) + 1);
1050 }
1051 const attrs = {
1052 id,
1053 value: news.value ?? id,
1054 tags: news.tags ?? {},
1055 ...(cloud ? { arn: `${id}#${cloud.generations.get(id)}` } : {}),
1056 ...(news.ref !== undefined ? { ref: news.ref } : {}),
1057 env: Object.assign(
1058 {},
1059 ...bindings.map((binding: any) => binding.env ?? binding.data?.env ?? {}),
1060 ),
1061 };
1062 cloud?.resources.set(id, copy(attrs));
1063 return attrs;
1064 }),
1065 delete: Effect.fn(function* ({ id }) {
1066 const cloud = yield* cloudOf;
1067 cloud?.calls.push({ op: "delete", id });
1068 cloud?.resources.delete(id);
1069 }),
1070 };
1071 }),
1072 );
1073
1074// AliasedWidget — a resource whose type was "renamed" from `Test.Widget` to
1075// `Test.Widgets.Widget`. The legacy name is carried as an alias so state
1076// persisted under the old type still resolves to this provider. Its provider
1077// is intentionally NOT part of `TestLayers` — alias tests provide it as a
1078// bare layer or wrapped in a `ProviderCollection` to exercise both lookup
1079// paths in isolation.
1080export interface AliasedWidget extends Resource<
1081 "Test.Widgets.Widget",
1082 { name?: string },
1083 {
1084 name: string;
1085 }
1086> {}
1087
1088export const AliasedWidget = Resource<AliasedWidget>("Test.Widgets.Widget", {
1089 aliases: ["Test.Widget"],
1090});
1091
1092/** Logical IDs whose provider `delete` ran — proves deletion went through the provider. */
1093export const aliasedWidgetDeletes: string[] = [];
1094
1095export const aliasedWidgetProvider = () =>
1096 Provider.succeed(AliasedWidget, {
1097 list: () => Effect.succeed([]),
1098 reconcile: Effect.fn(function* ({ id, news }) {
1099 return { name: news?.name ?? id };
1100 }),
1101 delete: Effect.fn(function* ({ id }) {
1102 aliasedWidgetDeletes.push(id);
1103 }),
1104 });
1105
1106// FqnProbe — echoes the identity the engine threads into each handler input
1107// (`id` and `fqn`) back out as attributes. Lets a test assert that the engine
1108// passes the resource's real fully-qualified name — namespace path + logical
1109// id — which differs from the bare logical `id` for namespaced resources.
1110
1111export interface FqnProbe extends Resource<
1112 "Test.FqnProbe",
1113 {},
1114 {
1115 id: string;
1116 fqn: string;
1117 }
1118> {}
1119
1120export const FqnProbe = Resource<FqnProbe>("Test.FqnProbe");
1121
1122export const fqnProbeProvider = () =>
1123 Provider.succeed(FqnProbe, {
1124 list: () => Effect.succeed([]),
1125 diff: Effect.fn(function* ({ news }) {
1126 if (!isResolved(news)) return undefined;
1127 return undefined;
1128 }),
1129 reconcile: Effect.fn(function* ({ id, fqn }) {
1130 return { id, fqn };
1131 }),
1132 delete: Effect.fn(function* () {}),
1133 });
1134
1135/**
1136 * Run `eff` as if under `alchemy dev`: overrides `AlchemyContext.dev` so
1137 * plans and applies inside resolve the LOCAL provider mode by default —
1138 * exactly what the `alchemy dev` command does for a whole run.
1139 */
1140export const inDev = <A, E, R>(
1141 eff: Effect.Effect<A, E, R>,
1142): Effect.Effect<A, E, R | AlchemyContext> =>
1143 AlchemyContext.pipe(
1144 Effect.flatMap((ctx) => eff.pipe(Effect.provideService(AlchemyContext, { ...ctx, dev: true }))),
1145 );
1146
1147// ModalResource — a resource registered via `ProviderLayer.dual` with
1148// distinct live and local implementations. Used by provider-mode tests to
1149// assert: mode stamping in state, replace-on-mode-switch, mode-correct
1150// deletes of old generations/orphans, and lazy non-default-variant builds.
1151//
1152// Both recorders are module-level (like `aliasedWidgetDeletes`) and tag
1153// every entry with the scratch stack name so concurrent tests in the same
1154// file can filter to their own activity.
1155
1156export type ModalResourceProps = {
1157 value?: string;
1158 /**
1159 * Changing this forces an ordinary (same-mode) replacement — used to
1160 * assert that `providerMode` survives provider-diff replacements and
1161 * that old generations are deleted with their own stamped mode.
1162 */
1163 replaceValue?: string;
1164 /**
1165 * Changing this forces a `deleteFirst` replacement — used to assert that
1166 * `deleteOldGenerations` (the pre-create teardown) resolves each old
1167 * generation's provider by ITS stamped mode.
1168 */
1169 deleteFirstValue?: string;
1170};
1171
1172export interface ModalResource extends Resource<
1173 "Test.ModalResource",
1174 ModalResourceProps,
1175 {
1176 value: string;
1177 /** Which provider variant reconciled this instance. */
1178 runtime: ProviderMode;
1179 /**
1180 * Physical identity marker, mirroring real local providers: the local
1181 * variant fabricates a `dev:`-prefixed id, the live variant a plain
1182 * one. Legacy-row tests rely on this to exercise the marker-inferred
1183 * mode fallback for unstamped rows.
1184 */
1185 instance: string;
1186 /**
1187 * Local dev URL — returned by the LOCAL variant only, mirroring how a
1188 * local Worker's attrs carry its dev-proxy URL. Apply announces it as
1189 * a `ready at <url>` note on local-mode rows.
1190 */
1191 url?: string;
1192 }
1193> {}
1194
1195export const ModalResource = Resource<ModalResource>("Test.ModalResource");
1196
1197/** Variant layer constructions, in order. */
1198export const modalBuilds: { stack: string; mode: ProviderMode }[] = [];
1199/** Lifecycle calls dispatched to each variant, in order. */
1200export const modalCalls: {
1201 stack: string;
1202 mode: ProviderMode;
1203 op: "reconcile" | "delete";
1204 id: string;
1205}[] = [];
1206
1207// Some plan-level test harnesses build `TestLayers()` without a `Stack`
1208// service in context, so the discriminator is resolved as an option with a
1209// fallback rather than required at layer build.
1210const modalStackName = Effect.serviceOption(Stack).pipe(
1211 Effect.map(Option.match({ onNone: () => "unknown", onSome: (s) => s.name })),
1212);
1213
1214const modalVariant = (mode: ProviderMode) =>
1215 Provider.effect(
1216 ModalResource,
1217 Effect.gen(function* () {
1218 modalBuilds.push({ stack: yield* modalStackName, mode });
1219 return {
1220 list: () => Effect.succeed([]),
1221 read: Effect.fn(function* ({ id, output }) {
1222 // With a TestCloud in context (drift tests), observe it so rows
1223 // can be deleted out of band.
1224 const cloud = Option.getOrUndefined(yield* Effect.serviceOption(TestCloud));
1225 if (cloud) {
1226 cloud.calls.push({ op: "read", id });
1227 return cloud.resources.get(id) as typeof output;
1228 }
1229 // Echo persisted attrs (the TestResource default): no cloud to
1230 // observe, and a cold-start probe (output undefined) finds nothing.
1231 return output;
1232 }),
1233 diff: Effect.fn(function* ({ news, olds }) {
1234 if (!isResolved(news)) return undefined;
1235 const n = news as ModalResourceProps;
1236 const o = olds as ModalResourceProps | undefined;
1237 if (n.deleteFirstValue !== o?.deleteFirstValue) {
1238 return { action: "replace", deleteFirst: true } as const;
1239 }
1240 if (n.replaceValue !== o?.replaceValue) {
1241 return { action: "replace" } as const;
1242 }
1243 return n.value !== o?.value
1244 ? ({ action: "update" } as const)
1245 : ({ action: "noop" } as const);
1246 }),
1247 reconcile: Effect.fn(function* ({ id, news = {}, olds }) {
1248 // Failure injection via the shared TestResourceHooks (same pattern
1249 // as bindingTargetProvider): `olds === undefined` is the engine's
1250 // create intent — greenfield or replacement-create.
1251 const hooks = Option.getOrUndefined(yield* Effect.serviceOption(TestResourceHooks));
1252 if (olds === undefined) {
1253 if (hooks?.create) {
1254 yield* hooks.create(id, {
1255 string: (news as ModalResourceProps).value,
1256 });
1257 }
1258 } else if (hooks?.update) {
1259 yield* hooks.update(id, {
1260 string: (news as ModalResourceProps).value,
1261 });
1262 }
1263 modalCalls.push({
1264 stack: yield* modalStackName,
1265 mode,
1266 op: "reconcile",
1267 id,
1268 });
1269 const attrs = {
1270 value: (news as ModalResourceProps).value ?? id,
1271 runtime: mode,
1272 instance: mode === "local" ? `${LOCAL_ID_PREFIX}${id}` : id,
1273 ...(mode === "local" ? { url: "http://localhost:1337" } : {}),
1274 };
1275 Option.getOrUndefined(yield* Effect.serviceOption(TestCloud))?.resources.set(id, {
1276 ...attrs,
1277 });
1278 return attrs;
1279 }),
1280 delete: Effect.fn(function* ({ id }) {
1281 const hooks = Option.getOrUndefined(yield* Effect.serviceOption(TestResourceHooks));
1282 if (hooks?.delete) {
1283 yield* hooks.delete(id);
1284 }
1285 modalCalls.push({
1286 stack: yield* modalStackName,
1287 mode,
1288 op: "delete",
1289 id,
1290 });
1291 }),
1292 };
1293 }),
1294 );
1295
1296export const modalResourceProvider = () =>
1297 ProviderLayer.dual(ModalResource, {
1298 live: () => modalVariant("live"),
1299 local: () => modalVariant("local"),
1300 dataPlane: () => modalLocalDataPlane,
1301 liveDataPlane: () => modalLiveDataPlane,
1302 });
1303
1304/**
1305 * Which data-plane override a deploy-time binding client ran under.
1306 * The local/live layers below stamp this; ambient (no wrap) is `"ambient"`.
1307 */
1308export class DataPlaneTag extends Context.Service<DataPlaneTag, "local" | "live">()(
1309 "Test.DataPlaneTag",
1310) {}
1311
1312export const modalLocalDataPlane = Layer.succeed(DataPlaneTag, "local");
1313export const modalLiveDataPlane = Layer.succeed(DataPlaneTag, "live");
1314
1315/**
1316 * Deploy-time binding used to pin Binding client routing: the returned
1317 * client reads {@link DataPlaneTag}, which is only in context when the wrap
1318 * provided the matching plane layer closest.
1319 */
1320export interface ProbeBinding extends Binding.Service<
1321 ProbeBinding,
1322 "Test.ProbeBinding",
1323 (
1324 resource: ModalResource | readonly ModalResource[],
1325 ) => Effect.Effect<() => Effect.Effect<"local" | "live" | "ambient">>
1326> {}
1327export const ProbeBinding = Binding.Service<ProbeBinding>("Test.ProbeBinding");
1328
1329export const ProbeBindingLive = Layer.succeed(
1330 ProbeBinding,
1331 Effect.fn(function* (_resource: ModalResource | readonly ModalResource[]) {
1332 return () =>
1333 Effect.serviceOption(DataPlaneTag).pipe(
1334 Effect.map((opt) => (Option.isSome(opt) ? opt.value : "ambient")),
1335 );
1336 }),
1337);
1338
1339// CapturedConfigHost - a Platform whose Init reads `Config.String("CAPTURED_MODE")`.
1340// Its provider's diff always returns `noop` without comparing `env`, so only
1341// the engine's comparison of Init-captured values can plan an update (#1831).
1342// `reconcile` echoes the delivered value as the `mode` attribute.
1343export interface CapturedConfigHostProps extends PlatformProps {
1344 main?: string;
1345 env?: Record<string, unknown>;
1346}
1347
1348export interface CapturedConfigHost extends Resource<
1349 "Test.CapturedConfigHost",
1350 CapturedConfigHostProps,
1351 { mode: unknown }
1352> {}
1353
1354export const CapturedConfigHost: Platform<
1355 CapturedConfigHost,
1356 ServerHost,
1357 Main<ServerHost>,
1358 HostRuntimeContext
1359> = Platform("Test.CapturedConfigHost", {
1360 createRuntimeContext: createHostRuntimeContext("Test.CapturedConfigHost"),
1361});
1362
1363export const capturedConfigHostProvider = () =>
1364 Provider.succeed(CapturedConfigHost, {
1365 list: () => Effect.succeed([]),
1366 diff: () => Effect.succeed({ action: "noop" as const }),
1367 reconcile: Effect.fn(function* ({ news }) {
1368 const mode = unpackEnvValue(news.env?.CAPTURED_MODE as string);
1369 return { mode: Redacted.isRedacted(mode) ? Redacted.value(mode) : mode };
1370 }),
1371 delete: Effect.fn(function* () {}),
1372 });
1373
1374/** Declare the host with `mode` served to its Init's `Config` reads. */
1375export const capturedConfigHost = (mode: string) =>
1376 Effect.gen(function* () {
1377 const ambient = yield* ConfigProvider.ConfigProvider;
1378 return yield* CapturedConfigHost(
1379 "Host",
1380 { main: "index.ts" },
1381 Effect.gen(function* () {
1382 const value = yield* Config.String("CAPTURED_MODE");
1383 return { fetch: Effect.succeed(HttpServerResponse.text(value)) };
1384 }),
1385 ).pipe(
1386 Effect.provideService(
1387 ConfigProvider.ConfigProvider,
1388 ConfigProvider.orElse(ConfigProvider.fromUnknown({ CAPTURED_MODE: mode }), ambient),
1389 ),
1390 );
1391 });
1392
1393// Layers
1394export const TestLayers = () =>
1395 Layer.mergeAll(
1396 bucketProvider(),
1397 fqnProbeProvider(),
1398 queueProvider(),
1399 functionProvider(),
1400 bindingTargetProvider(),
1401 deletedBindingRegressionProvider(),
1402 artifactProbeProvider(),
1403 testResourceProvider(),
1404 staticStablesResourceProvider(),
1405 kindStablesResourceProvider(),
1406 overrideStablesResourceProvider(),
1407 phasedTargetProvider(),
1408 noPrecreateBindingTargetProvider(),
1409 durationResourceProvider(),
1410 deleteFirstResourceProvider(),
1411 driftResourceProvider(),
1412 modalResourceProvider(),
1413 capturedConfigHostProvider(),
1414 ProbeBindingLive,
1415 );
1416
1417export const InMemoryTestLayers = () => Layer.mergeAll(TestLayers(), State.inMemoryState());
1418
1419// ── Failure injection helpers ──────────────────────────────────────────────
1420//
1421// These helpers produce TestResourceHooks records that can be passed to the
1422// `hook(...)` test combinator to inject failures or defects into specific
1423// resource lifecycle methods. `failOn` produces typed failures, while
1424// `dieOn` and `throwOn` produce defects (uncaught/thrown errors).
1425
1426export class ResourceFailure extends Data.TaggedError("ResourceFailure")<{
1427 message: string;
1428}> {
1429 constructor(message = "Failed to create") {
1430 super({ message });
1431 }
1432}
1433
1434type LifecycleHook = "create" | "update" | "delete";
1435
1436export type LifecycleHooks = {
1437 create?: (id: string, props: TestResourceProps) => Effect.Effect<void, any>;
1438 update?: (id: string, props: TestResourceProps) => Effect.Effect<void, any>;
1439 delete?: (id: string) => Effect.Effect<void, any>;
1440};
1441
1442export const failOn = (resourceId: string, hook: LifecycleHook): LifecycleHooks => ({
1443 [hook]: (id: string) =>
1444 id === resourceId ? Effect.fail(new ResourceFailure()) : Effect.succeed(undefined),
1445});
1446
1447export const failOnMultiple = (
1448 failures: Array<{ id: string; hook: LifecycleHook }>,
1449): LifecycleHooks => {
1450 const idsFor = (hook: LifecycleHook) => failures.filter((f) => f.hook === hook).map((f) => f.id);
1451 const createFailures = idsFor("create");
1452 const updateFailures = idsFor("update");
1453 const deleteFailures = idsFor("delete");
1454 return {
1455 create: (id: string) =>
1456 createFailures.includes(id) ? Effect.fail(new ResourceFailure()) : Effect.succeed(undefined),
1457 update: (id: string) =>
1458 updateFailures.includes(id) ? Effect.fail(new ResourceFailure()) : Effect.succeed(undefined),
1459 delete: (id: string) =>
1460 deleteFailures.includes(id) ? Effect.fail(new ResourceFailure()) : Effect.succeed(undefined),
1461 };
1462};
1463
1464export const dieOn = (
1465 resourceId: string,
1466 hook: LifecycleHook,
1467 message = `dieOn:${resourceId}:${hook}`,
1468): LifecycleHooks => ({
1469 [hook]: (id: string) =>
1470 id === resourceId ? Effect.die(new Error(message)) : Effect.succeed(undefined),
1471});
1472
1473export const throwOn = (
1474 resourceId: string,
1475 hook: LifecycleHook,
1476 message = `throwOn:${resourceId}:${hook}`,
1477): LifecycleHooks => ({
1478 [hook]: (id: string) =>
1479 Effect.sync(() => {
1480 if (id === resourceId) {
1481 throw new Error(message);
1482 }
1483 }),
1484});