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