1import { AlchemyContext } from "@/AlchemyContext.ts";
2import * as Artifacts from "@/Artifacts.ts";
3import { InstanceId } from "@/InstanceId.ts";
4import * as RpcProvider from "@/Local/RpcProvider.ts";
5import type { ProviderService } from "@/Provider.ts";
6import { Resource } from "@/Resource.ts";
7import { Stack, type StackSpec } from "@/Stack.ts";
8import { Stage } from "@/Stage.ts";
9import { describe, expect, it } from "alchemy-test";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as Stream from "effect/Stream";
13
14interface TestResource extends Resource<
15 "Local.RpcProvider.Test",
16 {},
17 { ok: boolean; artifact: string }
18> {}
19const TestResource = Resource<TestResource>("Local.RpcProvider.Test");
20
21type StackShape = Omit<StackSpec, "output">;
22
23interface Capture {
24 stack?: StackShape;
25 stage?: string;
26 instanceId?: string;
27 artifact?: string;
28}
29
30const defaultStack: StackShape = {
31 name: "default-stack",
32 stage: "default-stage",
33 resources: {},
34 bindings: {},
35 actions: {},
36};
37
38describe("Local.RpcProvider.effect", { tags: ["unit", "local"] }, () => {
39 it.effect(
40 "provides default Stack, Stage, and InstanceId to lifecycle effects",
41 () =>
42 Effect.gen(function* () {
43 const [capture, result] = yield* useProvider((provider) =>
44 provider.reconcile({
45 id: "r",
46 fqn: "r",
47 instanceId: "inst-from-arg",
48 news: {},
49 olds: undefined,
50 output: undefined,
51 session: undefined as any,
52 bindings: [],
53 }),
54 );
55 expect(result).toMatchObject({ ok: true });
56 expect(capture.stack).toBe(defaultStack);
57 expect(capture.stage).toBe(defaultStack.stage);
58 expect(capture.instanceId).toBe("inst-from-arg");
59 }),
60 );
61
62 it.effect(
63 "does not override Stack, Stage, or InstanceId when already provided",
64 () =>
65 Effect.gen(function* () {
66 const overrideStack: StackShape = {
67 name: "override-stack",
68 stage: "ignored-stage-on-stack",
69 resources: {},
70 bindings: {},
71 actions: {},
72 };
73 const [capture] = yield* useProvider((provider) =>
74 provider
75 .reconcile({
76 id: "r",
77 fqn: "r",
78 instanceId: "inst-from-arg",
79 news: {},
80 olds: undefined,
81 output: undefined,
82 session: undefined as any,
83 bindings: [],
84 })
85 .pipe(
86 Effect.provideService(Stack, overrideStack),
87 Effect.provideService(Stage, "override-stage"),
88 Effect.provideService(InstanceId, "override-instance-id"),
89 ),
90 );
91 expect(capture.stack).toBe(overrideStack);
92 expect(capture.stage).toBe("override-stage");
93 expect(capture.instanceId).toBe("override-instance-id");
94 }),
95 );
96
97 it.effect("provides defaults to Stream-returning lifecycle methods", () =>
98 Effect.gen(function* () {
99 const [capture, items] = yield* useProvider((provider) =>
100 provider.tail!({
101 id: "r",
102 fqn: "r",
103 instanceId: "inst-from-arg",
104 props: {},
105 output: { ok: true, artifact: "artifact" },
106 }).pipe(Stream.runCollect),
107 );
108 expect(items.length).toBe(1);
109 expect(capture.stack).toBe(defaultStack);
110 expect(capture.stage).toBe(defaultStack.stage);
111 expect(capture.instanceId).toBe("inst-from-arg");
112 }),
113 );
114
115 it.effect("omits InstanceId fallback when input has no instanceId", () =>
116 Effect.gen(function* () {
117 const [capture, exit] = yield* useProvider((provider) =>
118 provider
119 .reconcile({
120 id: "r",
121 fqn: "r",
122 news: {},
123 olds: undefined,
124 output: undefined,
125 session: undefined!,
126 instanceId: undefined!,
127 bindings: [],
128 })
129 .pipe(Effect.exit),
130 );
131 expect(exit._tag).toBe("Failure");
132 expect(capture.stack).toBe(defaultStack);
133 expect(capture.stage).toBe(defaultStack.stage);
134 expect(capture.instanceId).toBeUndefined();
135 }),
136 );
137
138 it.effect("caches artifacts", () =>
139 Effect.gen(function* () {
140 const [capture, result] = yield* useProvider((provider) =>
141 provider.diff!({
142 id: "r",
143 fqn: "r",
144 news: {},
145 olds: undefined,
146 output: undefined,
147 oldBindings: [],
148 newBindings: [],
149 instanceId: "inst-from-arg",
150 }).pipe(
151 Effect.andThen(
152 Effect.all({
153 sameInstanceId: provider.reconcile({
154 id: "r",
155 fqn: "r",
156 instanceId: "inst-from-arg",
157 news: {},
158 olds: undefined,
159 output: undefined,
160 session: undefined as any,
161 bindings: [],
162 }),
163 differentInstanceId: provider.reconcile({
164 id: "r",
165 fqn: "r",
166 instanceId: "inst-from-arg-2",
167 news: {},
168 olds: undefined,
169 output: undefined,
170 session: undefined as any,
171 bindings: [],
172 }),
173 }),
174 ),
175 ),
176 );
177 expect(capture.artifact).toBeDefined();
178 expect(result.sameInstanceId.artifact).toBeDefined();
179 expect(result.differentInstanceId.artifact).toBeDefined();
180 expect(capture.artifact).toBe(result.sameInstanceId.artifact);
181 expect(capture.artifact).not.toBe(result.differentInstanceId.artifact);
182 }),
183 );
184});
185
186const artifact = Effect.sync(() => crypto.randomUUID()).pipe(
187 Artifacts.cached("artifact"),
188);
189
190const TestResourceProvider = (capture: Capture) =>
191 RpcProvider.effect(
192 TestResource,
193 "ignored://entry",
194 Effect.succeed({
195 diff: Effect.fn(function* () {
196 capture.artifact = yield* artifact;
197 }),
198 reconcile: Effect.fn(function* (_input: any) {
199 capture.stack = yield* Stack;
200 capture.stage = yield* Stage;
201 capture.instanceId = yield* InstanceId;
202 return { ok: true, artifact: yield* artifact };
203 }),
204 tail: (_input: any) =>
205 Stream.fromEffect(
206 Effect.gen(function* () {
207 capture.stack = yield* Stack;
208 capture.stage = yield* Stage;
209 capture.instanceId = yield* InstanceId;
210 return { timestamp: new Date(0), message: "ok" };
211 }),
212 ),
213 delete: Effect.fn(function* () {}),
214 }),
215 );
216
217const useProvider = <A, E, R>(
218 callback: (provider: ProviderService<TestResource>) => Effect.Effect<A, E, R>,
219) => {
220 const capture: Capture = {};
221 return TestResource.Provider.pipe(
222 Effect.flatMap(callback),
223 Effect.map((result) => [capture, result] as const),
224 Effect.provide(
225 Layer.provide(
226 TestResourceProvider(capture),
227 Layer.mergeAll(
228 Layer.succeed(Stack, defaultStack),
229 Layer.succeed(Stage, defaultStack.stage),
230 Layer.sync(Artifacts.ArtifactStore, Artifacts.createArtifactStore),
231 Layer.succeed(AlchemyContext, {
232 dotAlchemy: "/tmp/.alchemy",
233 dev: false,
234 adopt: false,
235 }),
236 ),
237 ),
238 ),
239 );
240};