1import { Action } from "@/Action";
2import * as Plan from "@/Plan";
3import * as Stack from "@/Stack";
4import { Stage } from "@/Stage";
5import {
6 InMemoryService,
7 inMemoryState,
8 State,
9 type ActionState,
10 type RanActionState,
11} from "@/State";
12import * as Test from "@/Test/Alchemy";
13import { describe, expect } from "alchemy-test";
14import * as Context from "effect/Context";
15import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";
18import * as Ref from "effect/Ref";
19import { Bucket, TestLayers } from "./test.resources";
20
21const TEST_STACK = "task-test";
22const TEST_STAGE = "test";
23
24// Fresh in-memory state per test so persisted task rows don't leak between
25// runs in the same file.
26const freshState = () =>
27 Layer.effect(
28 State,
29 Effect.sync(() => InMemoryService({})),
30 );
31
32const { test } = Test.make({
33 providers: TestLayers(),
34 state: freshState(),
35});
36
37const resolveStackId = Effect.gen(function* () {
38 const ambient = yield* Effect.serviceOption(Stack.Stack);
39 return Option.match(ambient, {
40 onNone: () => ({ name: TEST_STACK, stage: TEST_STAGE }),
41 onSome: (s) => ({ name: s.name, stage: s.stage }),
42 });
43});
44
45const seed = (rows: Record<string, ActionState>) =>
46 Effect.gen(function* () {
47 const { name, stage } = yield* resolveStackId;
48 const state = yield* yield* State;
49 for (const [fqn, value] of Object.entries(rows)) {
50 yield* state.set({ stack: name, stage, fqn, value });
51 }
52 });
53
54const makePlan = <A, Err = never, Req = never>(
55 effect: Effect.Effect<A, Err, Req>,
56 options?: Plan.MakePlanOptions,
57): Effect.Effect<Plan.Plan<A>, Err, State> =>
58 // @ts-expect-error - Stack.make's typing erases R unsoundly here
59 Effect.gen(function* () {
60 const { name, stage } = yield* resolveStackId;
61 // @ts-expect-error
62 return yield* effect.pipe(
63 // @ts-expect-error
64 Stack.make({
65 name,
66 providers: Layer.empty,
67 state: inMemoryState(),
68 }),
69 Effect.provideService(Stage, stage),
70 Effect.flatMap((stackSpec: any) => Plan.make(stackSpec, options)),
71 Effect.provide(TestLayers()),
72 );
73 });
74
75// ── Plan tests ────────────────────────────────────────────────────────────
76
77describe("Plan", { tags: ["unit", "local"] }, () => {
78 test(
79 "first-time task -> run",
80 Effect.gen(function* () {
81 const Sync = Action("Sync", (input: { table: string }) =>
82 Effect.succeed({ rows: 1, table: input.table }),
83 );
84
85 const plan = yield* Effect.gen(function* () {
86 return yield* Sync({ table: "users" });
87 }).pipe(makePlan);
88
89 expect(plan.actions.Sync).toMatchObject({
90 kind: "action",
91 action: "run",
92 state: undefined,
93 forced: false,
94 });
95 expect(plan.actions.Sync.def.LogicalId).toBe("Sync");
96 }),
97 );
98
99 test(
100 "same input hash -> noop (skip)",
101 Effect.gen(function* () {
102 const Sync = Action("Sync", (_: { table: string }) =>
103 Effect.succeed({ rows: 1 }),
104 );
105
106 // Pre-seed a `ran` row with a hash that matches { table: "users" }.
107 const { hashInput } = yield* Effect.promise(
108 () => import("@/Util/sha256"),
109 );
110 const inputHash = yield* hashInput({ table: "users" });
111 yield* seed({
112 Sync: {
113 kind: "action",
114 status: "ran",
115 fqn: "Sync",
116 logicalId: "Sync",
117 namespace: undefined,
118 actionType: "Sync",
119 inputHash,
120 input: { table: "users" },
121 output: { rows: 1 },
122 downstream: [],
123 } satisfies RanActionState,
124 });
125
126 const plan = yield* Effect.gen(function* () {
127 return yield* Sync({ table: "users" });
128 }).pipe(makePlan);
129
130 expect(plan.actions.Sync.action).toBe("noop");
131 }),
132 );
133
134 test(
135 "changed input hash -> run",
136 Effect.gen(function* () {
137 const Sync = Action("Sync", (_: { table: string }) =>
138 Effect.succeed({ rows: 1 }),
139 );
140
141 const { hashInput } = yield* Effect.promise(
142 () => import("@/Util/sha256"),
143 );
144 const oldHash = yield* hashInput({ table: "users" });
145 yield* seed({
146 Sync: {
147 kind: "action",
148 status: "ran",
149 fqn: "Sync",
150 logicalId: "Sync",
151 namespace: undefined,
152 actionType: "Sync",
153 inputHash: oldHash,
154 input: { table: "users" },
155 output: { rows: 1 },
156 downstream: [],
157 } satisfies RanActionState,
158 });
159
160 const plan = yield* Effect.gen(function* () {
161 return yield* Sync({ table: "orders" });
162 }).pipe(makePlan);
163
164 expect(plan.actions.Sync.action).toBe("run");
165 }),
166 );
167
168 test(
169 "force flips noop -> run",
170 Effect.gen(function* () {
171 const Sync = Action("Sync", (_: { table: string }) =>
172 Effect.succeed({ rows: 1 }),
173 );
174
175 const { hashInput } = yield* Effect.promise(
176 () => import("@/Util/sha256"),
177 );
178 const inputHash = yield* hashInput({ table: "users" });
179 yield* seed({
180 Sync: {
181 kind: "action",
182 status: "ran",
183 fqn: "Sync",
184 logicalId: "Sync",
185 namespace: undefined,
186 actionType: "Sync",
187 inputHash,
188 input: { table: "users" },
189 output: { rows: 1 },
190 downstream: [],
191 } satisfies RanActionState,
192 });
193
194 const plan = yield* Effect.gen(function* () {
195 return yield* Sync({ table: "users" });
196 }).pipe((eff) => makePlan(eff, { force: true }));
197
198 expect(plan.actions.Sync.action).toBe("run");
199 expect((plan.actions.Sync as Plan.ActionRun).forced).toBe(true);
200 }),
201 );
202
203 test(
204 "task removed from stack -> taskDeletions",
205 Effect.gen(function* () {
206 const { hashInput } = yield* Effect.promise(
207 () => import("@/Util/sha256"),
208 );
209 const inputHash = yield* hashInput({ table: "users" });
210 yield* seed({
211 Sync: {
212 kind: "action",
213 status: "ran",
214 fqn: "Sync",
215 logicalId: "Sync",
216 namespace: undefined,
217 actionType: "Sync",
218 inputHash,
219 input: { table: "users" },
220 output: { rows: 1 },
221 downstream: [],
222 } satisfies RanActionState,
223 });
224
225 // The new stack has no tasks at all.
226 const plan = yield* Effect.gen(function* () {
227 return undefined;
228 }).pipe(makePlan);
229
230 expect(plan.actionDeletions.Sync).toMatchObject({
231 kind: "action",
232 action: "delete",
233 });
234 expect(plan.actions.Sync).toBeUndefined();
235 }),
236 );
237
238 test(
239 "resource depends on task: task is upstream of resource",
240 Effect.gen(function* () {
241 const Compute = Action("Compute", (_: {}) =>
242 Effect.succeed({ value: "computed" }),
243 );
244
245 const plan = yield* Effect.gen(function* () {
246 const computed = yield* Compute({});
247 const bucket = yield* Bucket("MyBucket", { name: computed.value });
248 return bucket;
249 }).pipe(makePlan);
250
251 // Action is run (first time), bucket is created and lists Compute as upstream.
252 expect(plan.actions.Compute.action).toBe("run");
253 expect(plan.actions.Compute.downstream).toContain("MyBucket");
254 }),
255 );
256
257 test(
258 "task depends on resource: resource is upstream of task",
259 Effect.gen(function* () {
260 const Sync = Action("Sync", (_: { name: string }) =>
261 Effect.succeed({ ok: true }),
262 );
263
264 const plan = yield* Effect.gen(function* () {
265 const bucket = yield* Bucket("MyBucket", { name: "b" });
266 return yield* Sync({ name: bucket.name });
267 }).pipe(makePlan);
268
269 expect(plan.resources.MyBucket.action).toBe("create");
270 expect(plan.actions.Sync.action).toBe("run");
271 // MyBucket's downstream includes the task FQN.
272 expect(plan.resources.MyBucket.downstream).toContain("Sync");
273 }),
274 );
275
276 test(
277 "explicit logical id allows multiple instances",
278 Effect.gen(function* () {
279 const Sync = Action("Sync", (_: { which: string }) =>
280 Effect.succeed({ ok: true }),
281 );
282
283 const plan = yield* Effect.gen(function* () {
284 yield* Sync("nightly", { which: "n" });
285 yield* Sync("hourly", { which: "h" });
286 }).pipe(makePlan);
287
288 expect(plan.actions.nightly.action).toBe("run");
289 expect(plan.actions.hourly.action).toBe("run");
290 expect(plan.actions.Sync).toBeUndefined();
291 }),
292 );
293});
294
295// ── Apply tests ───────────────────────────────────────────────────────────
296
297describe("Apply", { tags: ["unit", "local"] }, () => {
298 test.provider("first run invokes body and persists ran state", (stack) =>
299 Effect.gen(function* () {
300 const counter = yield* Ref.make(0);
301 const Sync = Action("Sync", (input: { n: number }) =>
302 Effect.gen(function* () {
303 yield* Ref.update(counter, (c) => c + 1);
304 return { doubled: input.n * 2 };
305 }),
306 );
307
308 const out = yield* stack.deploy(
309 Effect.gen(function* () {
310 return yield* Sync({ n: 21 });
311 }),
312 );
313
314 expect(out).toEqual({ doubled: 42 });
315 expect(yield* Ref.get(counter)).toBe(1);
316
317 // Persisted state is `ran` with the materialized output.
318 const state = yield* yield* State;
319 const persisted = yield* state.get({
320 stack: stack.name,
321 stage: stack.stage,
322 fqn: "Sync",
323 });
324 expect(persisted).toMatchObject({
325 kind: "action",
326 status: "ran",
327 output: { doubled: 42 },
328 });
329 }),
330 );
331
332 test.provider("same input across deploys -> body not re-invoked", (stack) =>
333 Effect.gen(function* () {
334 const counter = yield* Ref.make(0);
335 const program = Effect.gen(function* () {
336 const Sync = Action("Sync", (input: { n: number }) =>
337 Effect.gen(function* () {
338 yield* Ref.update(counter, (c) => c + 1);
339 return { doubled: input.n * 2 };
340 }),
341 );
342 return yield* Sync({ n: 21 });
343 });
344
345 const first = yield* stack.deploy(program);
346 const second = yield* stack.deploy(program);
347
348 expect(first).toEqual({ doubled: 42 });
349 expect(second).toEqual({ doubled: 42 });
350 expect(yield* Ref.get(counter)).toBe(1);
351 }),
352 );
353
354 test.provider("changed input -> body re-invoked", (stack) =>
355 Effect.gen(function* () {
356 const counter = yield* Ref.make(0);
357 const programFor = (n: number) =>
358 Effect.gen(function* () {
359 const Sync = Action("Sync", (input: { n: number }) =>
360 Effect.gen(function* () {
361 yield* Ref.update(counter, (c) => c + 1);
362 return { doubled: input.n * 2 };
363 }),
364 );
365 return yield* Sync({ n });
366 });
367
368 yield* stack.deploy(programFor(21));
369 const second = yield* stack.deploy(programFor(50));
370
371 expect(second).toEqual({ doubled: 100 });
372 expect(yield* Ref.get(counter)).toBe(2);
373 }),
374 );
375
376 test.provider("task output flows to downstream resource input", (stack) =>
377 Effect.gen(function* () {
378 const Name = Action("Name", (_: {}) =>
379 Effect.succeed({ name: "computed-bucket-name" }),
380 );
381
382 const out = yield* stack.deploy(
383 Effect.gen(function* () {
384 const computed = yield* Name({});
385 return yield* Bucket("MyBucket", { name: computed.name });
386 }),
387 );
388
389 expect(out.name).toBe("computed-bucket-name");
390 }),
391 );
392
393 test.provider("resource attr flows to task input", (stack) =>
394 Effect.gen(function* () {
395 const Echo = Action("Echo", (input: { name: string }) =>
396 Effect.succeed({ echoed: input.name }),
397 );
398
399 const out = yield* stack.deploy(
400 Effect.gen(function* () {
401 const bucket = yield* Bucket("MyBucket", { name: "from-resource" });
402 return yield* Echo({ name: bucket.name });
403 }),
404 );
405
406 expect(out).toEqual({ echoed: "from-resource" });
407 }),
408 );
409
410 test.provider(
411 "removing task from stack drops state without invoking body",
412 (stack) =>
413 Effect.gen(function* () {
414 const deleteSpy = yield* Ref.make(0);
415 const Sync = Action("Sync", (_: { n: number }) =>
416 Effect.succeed({ ok: true }),
417 );
418
419 yield* stack.deploy(
420 Effect.gen(function* () {
421 return yield* Sync({ n: 1 });
422 }),
423 );
424
425 const state = yield* yield* State;
426 expect(
427 yield* state.get({
428 stack: stack.name,
429 stage: stack.stage,
430 fqn: "Sync",
431 }),
432 ).toMatchObject({ kind: "action", status: "ran" });
433
434 // Re-deploy WITHOUT the task — state should be dropped.
435 // (Use a tracker hook to confirm body wasn't called.)
436 yield* stack.deploy(Effect.succeed(undefined));
437 void deleteSpy;
438
439 expect(
440 yield* state.get({
441 stack: stack.name,
442 stage: stack.stage,
443 fqn: "Sync",
444 }),
445 ).toBeUndefined();
446 }),
447 );
448
449 test.provider(
450 "init captures a resource Output; body resolves it at apply",
451 (stack) =>
452 Effect.gen(function* () {
453 const out = yield* stack.deploy(
454 Effect.gen(function* () {
455 const bucket = yield* Bucket("Cap", { name: "cap-bucket" });
456
457 const Seed = Action(
458 "Seed",
459 Effect.gen(function* () {
460 // Capture the resource Output at init — before the bucket
461 // exists. `arn` is a deferred accessor.
462 const arn = yield* bucket.bucketArn;
463 const name = yield* bucket.name;
464 return Effect.fn(function* () {
465 // Resolve at apply, after the bucket is materialized.
466 return { arn: yield* arn, name: yield* name };
467 });
468 }),
469 );
470
471 return yield* Seed({});
472 }),
473 );
474
475 expect(out).toEqual({
476 arn: "arn:test:bucket:us-east-1:123456789:Cap",
477 name: "cap-bucket",
478 });
479 }),
480 );
481
482 test.provider("init-effect form: deps satisfied at apply", (stack) =>
483 Effect.gen(function* () {
484 class Multiplier extends Context.Service<Multiplier, number>()(
485 "test/Multiplier",
486 ) {}
487
488 const Sync = Action(
489 "Sync",
490 Effect.gen(function* () {
491 const m = yield* Multiplier;
492 return (input: { n: number }) =>
493 Effect.succeed({ result: input.n * m });
494 }),
495 );
496
497 const out = yield* stack
498 .deploy(
499 Effect.gen(function* () {
500 return yield* Sync({ n: 21 });
501 }),
502 )
503 .pipe(Effect.provideService(Multiplier, 3));
504
505 expect(out).toEqual({ result: 63 });
506 }),
507 );
508
509 test.provider(
510 "tagged .make form: init captures a resource Output; body resolves it",
511 (stack) =>
512 Effect.gen(function* () {
513 interface SeedAction extends Action<"Seed", {}, { arn: string }> {}
514 const Seed = Action<SeedAction, {}, { arn: string }>()("Seed");
515
516 const out = yield* stack.deploy(
517 Effect.gen(function* () {
518 const bucket = yield* Bucket("Cap", { name: "cap-bucket" });
519
520 // `.make` called inside the builder with `bucket` in scope, then
521 // provided locally — its init runs under the capture context.
522 const SeedLive = Seed.make(
523 Effect.gen(function* () {
524 const arn = yield* bucket.bucketArn;
525 return Effect.fn(function* () {
526 return { arn: yield* arn };
527 });
528 }),
529 );
530
531 return yield* Seed({}).pipe(Effect.provide(SeedLive));
532 }),
533 );
534
535 expect(out).toEqual({
536 arn: "arn:test:bucket:us-east-1:123456789:Cap",
537 });
538 }),
539 );
540});