1/**
2 * First-class local vs live provider modes.
3 *
4 * Covers the engine behavior added by ProviderMode / ProviderLayer.dual:
5 * - the resolved mode is stamped on state (`providerMode`) on every commit
6 * - dev runs resolve local providers; `remote()` opts a resource out of
7 * local emulation; mode-agnostic providers satisfy any requested mode
8 * - switching modes plans a REPLACEMENT; the old generation / orphan row
9 * is deleted with the provider variant of the mode that created it
10 * - unstamped (legacy) rows are assumed live, unless their attrs carry
11 * the `dev:` identity marker (then local)
12 * - conflicting mode decorations on the same FQN die loudly
13 * - the non-default variant is only constructed when demanded (laziness)
14 *
15 * Also unit-tests the LocalProvider.make lifecycle (config-hash noop/restart,
16 * instanceId-guarded delete, invalidate, stop hook) by driving the generated
17 * provider service directly.
18 */
19import { Cli } from "@/Report.ts";
20import type { ResourceAnnotated, ResourceStatusChanged } from "@/Report.ts";
21import * as LocalProvider from "@/Local/LocalProvider.ts";
22import * as Provider from "@/Provider.ts";
23import { remote, type ProviderMode } from "@/ProviderMode.ts";
24import { Resource } from "@/Resource";
25import { Stack } from "@/Stack";
26import { State, type ResourceState } from "@/State";
27import * as Test from "@/Test/Alchemy";
28import { describe, expect } from "alchemy-test";
29import * as Effect from "effect/Effect";
30import * as Exit from "effect/Exit";
31import * as Layer from "effect/Layer";
32import * as Option from "effect/Option";
33import {
34 Bucket,
35 inDev,
36 ModalResource,
37 modalBuilds,
38 modalCalls,
39 TestLayers,
40} from "./test.resources.ts";
41
42const { test } = Test.make({ providers: TestLayers() });
43
44const getState = Effect.fn(function* (fqn: string) {
45 const state = yield* yield* State;
46 const stk = yield* Stack;
47 return (yield* state.get({
48 stack: stk.name,
49 stage: stk.stage,
50 fqn,
51 })) as ResourceState | undefined;
52});
53
54const setState = Effect.fn(function* (fqn: string, value: ResourceState) {
55 const state = yield* yield* State;
56 const stk = yield* Stack;
57 yield* state.set({ stack: stk.name, stage: stk.stage, fqn, value });
58});
59
60const modal = (id: string, value?: string) =>
61 Effect.gen(function* () {
62 const a = yield* ModalResource(id, { value });
63 return { runtime: a.runtime };
64 });
65
66const callsFor = (stackName: string) =>
67 modalCalls.filter((c) => c.stack === stackName);
68const buildsFor = (stackName: string) =>
69 modalBuilds.filter((b) => b.stack === stackName);
70
71describe("provider modes", { tags: ["unit", "local"] }, () => {
72 test.provider(
73 "lookup resolves concrete modes while registration lookup stays lazy",
74 (stack) =>
75 Effect.gen(function* () {
76 const registration = yield* Provider.tryFindProviderRegistrationByType(
77 ModalResource.Type,
78 );
79 expect(Option.isSome(registration)).toBe(true);
80 expect(buildsFor(stack.name)).toHaveLength(0);
81
82 const live = yield* Provider.findProvider(ModalResource);
83 expect(live.mode).toBe("live");
84 expect(typeof live.read).toBe("function");
85 expect(buildsFor(stack.name).map((build) => build.mode)).toEqual([
86 "live",
87 ]);
88
89 const local = yield* inDev(
90 Provider.findProviderByType(ModalResource.Type),
91 );
92 expect(local.mode).toBe("local");
93 expect(typeof local.read).toBe("function");
94 const explicitLive = yield* inDev(
95 Provider.findProvider(ModalResource, "live"),
96 );
97 expect(explicitLive).toBe(live);
98 expect(buildsFor(stack.name).map((build) => build.mode)).toEqual([
99 "live",
100 "local",
101 ]);
102 expect(
103 Option.isNone(
104 yield* Provider.tryFindProviderByType("Test.MissingProvider"),
105 ),
106 ).toBe(true);
107 }),
108 );
109
110 test.provider(
111 "default mode is live; providerMode is stamped; local variant is never built",
112 (stack) =>
113 Effect.gen(function* () {
114 const output = yield* modal("A", "v1").pipe(stack.deploy);
115 expect(output.runtime).toEqual("live");
116
117 const state = yield* getState("A");
118 expect(state?.status).toEqual("created");
119 expect(state?.providerMode).toEqual("live");
120
121 // Laziness: nothing demanded the local variant in a live-default
122 // run, so it must never have been constructed.
123 expect(
124 buildsFor(stack.name).filter((b) => b.mode === "local"),
125 ).toHaveLength(0);
126 expect(
127 buildsFor(stack.name).filter((b) => b.mode === "live").length,
128 ).toBeGreaterThan(0);
129
130 yield* stack.destroy();
131 }),
132 );
133
134 test.provider(
135 "no variant is built until a resource of the type is planned",
136 (stack) =>
137 Effect.gen(function* () {
138 const bucketOnly = Effect.gen(function* () {
139 yield* Bucket("B", {});
140 return {};
141 });
142 // Registration alone constructs nothing — in either default mode.
143 // (In dev, the local variant is what spawns a provider sidecar, so
144 // a stack without the type must not pay for one.)
145 yield* bucketOnly.pipe(stack.deploy);
146 expect(buildsFor(stack.name)).toHaveLength(0);
147 yield* inDev(bucketOnly.pipe(stack.deploy));
148 expect(buildsFor(stack.name)).toHaveLength(0);
149
150 yield* stack.destroy();
151 }),
152 );
153
154 test.provider(
155 "a dev run resolves the local provider; live-only resources stay live",
156 (stack) =>
157 Effect.gen(function* () {
158 const output = yield* inDev(modal("A", "v1").pipe(stack.deploy));
159 expect(output.runtime).toEqual("local");
160 expect((yield* getState("A"))?.providerMode).toEqual("local");
161
162 // A mode-agnostic resource in the same dev run deploys with its
163 // single implementation and stays unstamped — constructs mixing
164 // emulatable and live-only resources just work.
165 yield* inDev(
166 Effect.gen(function* () {
167 yield* ModalResource("A", { value: "v1" });
168 yield* Bucket("B", {});
169 return {};
170 }).pipe(stack.deploy),
171 );
172 expect((yield* getState("B"))?.providerMode).toBeUndefined();
173
174 yield* stack.destroy();
175 }),
176 );
177
178 test.provider(
179 "remote() opts a resource out of local emulation during dev",
180 (stack) =>
181 Effect.gen(function* () {
182 const output = yield* inDev(
183 modal("A", "v1").pipe(remote(), stack.deploy),
184 );
185 expect(output.runtime).toEqual("live");
186 expect((yield* getState("A"))?.providerMode).toEqual("live");
187
188 // Dropping remote() in a later dev run switches it back to local —
189 // a replacement like any other mode switch.
190 const back = yield* inDev(modal("A", "v1").pipe(stack.deploy));
191 expect(back.runtime).toEqual("local");
192 expect((yield* getState("A"))?.providerMode).toEqual("local");
193
194 yield* stack.destroy();
195 }),
196 );
197
198 test.provider(
199 "switching modes replaces: new mode creates, old mode deletes",
200 (stack) =>
201 Effect.gen(function* () {
202 // 1. dev run: local instance.
203 yield* inDev(modal("A", "v1").pipe(stack.deploy));
204 expect((yield* getState("A"))?.providerMode).toEqual("local");
205 const localInstanceId = (yield* getState("A"))?.instanceId;
206
207 // 2. same props, deploy (live) run → the plan must be a
208 // replacement even though nothing about the props changed.
209 const plan = yield* modal("A", "v1").pipe(stack.plan);
210 expect(plan.resources["A"].action).toEqual("replace");
211
212 // 3. apply: the live variant reconciles the new generation, the
213 // LOCAL variant (the mode that created it) deletes the old one.
214 const before = callsFor(stack.name).length;
215 const output = yield* modal("A", "v1").pipe(stack.deploy);
216 expect(output.runtime).toEqual("live");
217
218 const state = yield* getState("A");
219 expect(state?.status).toEqual("created");
220 expect(state?.providerMode).toEqual("live");
221 expect(state?.instanceId).not.toEqual(localInstanceId);
222
223 const calls = callsFor(stack.name).slice(before);
224 expect(calls).toContainEqual({
225 stack: stack.name,
226 mode: "live",
227 op: "reconcile",
228 id: "A",
229 });
230 expect(calls).toContainEqual({
231 stack: stack.name,
232 mode: "local",
233 op: "delete",
234 id: "A",
235 });
236
237 // 4. switch back: live → local replaces again, deleted by LIVE.
238 const beforeBack = callsFor(stack.name).length;
239 const back = yield* inDev(modal("A", "v1").pipe(stack.deploy));
240 expect(back.runtime).toEqual("local");
241 expect((yield* getState("A"))?.providerMode).toEqual("local");
242 expect(callsFor(stack.name).slice(beforeBack)).toContainEqual({
243 stack: stack.name,
244 mode: "live",
245 op: "delete",
246 id: "A",
247 });
248
249 yield* stack.destroy();
250 }),
251 );
252
253 test.provider(
254 "an orphaned local row is deleted by the local provider during a live run",
255 (stack) =>
256 Effect.gen(function* () {
257 yield* inDev(modal("A", "v1").pipe(stack.deploy));
258
259 // Remove the resource from the program and deploy in (default)
260 // live mode — the orphan delete must still route to the LOCAL
261 // variant, since that's the mode that created the row.
262 const before = callsFor(stack.name).length;
263 yield* Effect.succeed({}).pipe(stack.deploy);
264
265 expect(yield* getState("A")).toBeUndefined();
266 expect(callsFor(stack.name).slice(before)).toContainEqual({
267 stack: stack.name,
268 mode: "local",
269 op: "delete",
270 id: "A",
271 });
272 }),
273 );
274
275 test.provider("unstamped (legacy) rows are assumed live", (stack) =>
276 Effect.gen(function* () {
277 yield* modal("A", "v1").pipe(stack.deploy);
278 const row = yield* getState("A");
279 expect(row?.providerMode).toEqual("live");
280
281 // Simulate a row written before providerMode existed.
282 yield* setState("A", { ...row!, providerMode: undefined });
283
284 // A live run sees no churn: unstamped = live.
285 const plan = yield* modal("A", "v1").pipe(stack.plan);
286 expect(plan.resources["A"].action).toEqual("noop");
287
288 // A dev run must treat the unstamped row exactly like a stamped
289 // live row: replacement, with the old generation deleted by the
290 // LIVE provider. Assuming "current run's mode" instead silently
291 // adopts the deployed live resource as a local instance — the
292 // row re-stamps local and the live resource leaks untracked.
293 const devPlan = yield* inDev(modal("A", "v1").pipe(stack.plan));
294 expect(devPlan.resources["A"].action).toEqual("replace");
295
296 const before = callsFor(stack.name).length;
297 const output = yield* inDev(modal("A", "v1").pipe(stack.deploy));
298 expect(output.runtime).toEqual("local");
299 expect((yield* getState("A"))?.providerMode).toEqual("local");
300 expect(callsFor(stack.name).slice(before)).toContainEqual({
301 stack: stack.name,
302 mode: "live",
303 op: "delete",
304 id: "A",
305 });
306
307 yield* stack.destroy();
308 }),
309 );
310
311 test.provider(
312 "a legacy unstamped row with dev:-marker identity is deleted by the local provider",
313 (stack) =>
314 Effect.gen(function* () {
315 // Simulate a row written by `alchemy dev` on a pre-stamping version
316 // (e.g. 2.0.0-beta.64): reconciled by the local provider (so its
317 // attrs carry the `dev:` identity marker) but persisted without a
318 // providerMode stamp.
319 yield* inDev(modal("A", "v1").pipe(stack.deploy));
320 const row = yield* getState("A");
321 expect(row?.providerMode).toEqual("local");
322 expect((row?.attr as { instance: string }).instance).toMatch(/^dev:/);
323 yield* setState("A", { ...row!, providerMode: undefined });
324
325 // A dev run sees no churn: the marker infers "local", matching the
326 // run's mode — legacy dev rows continue seamlessly under dev.
327 const devPlan = yield* inDev(modal("A", "v1").pipe(stack.plan));
328 expect(devPlan.resources["A"].action).toEqual("noop");
329
330 // A plain (live-mode) destroy must infer "local" from the marker
331 // and route the delete to the local variant — never hand the `dev:`
332 // identity to the live provider (the cloud API rejects it).
333 const before = callsFor(stack.name).length;
334 yield* stack.destroy();
335
336 expect(yield* getState("A")).toBeUndefined();
337 const deletes = callsFor(stack.name)
338 .slice(before)
339 .filter((c) => c.op === "delete");
340 expect(deletes).toEqual([
341 { stack: stack.name, mode: "local", op: "delete", id: "A" },
342 ]);
343 }),
344 );
345
346 test.provider(
347 "a live deploy over a legacy unstamped local row replaces via the marker-inferred mode",
348 (stack) =>
349 Effect.gen(function* () {
350 yield* inDev(modal("A", "v1").pipe(stack.deploy));
351 const row = yield* getState("A");
352 yield* setState("A", { ...row!, providerMode: undefined });
353
354 // Identical props, but the marker infers the row is local and this
355 // run resolves live → mode-switch replacement, not a live
356 // reconcile against the `dev:` identity.
357 const plan = yield* modal("A", "v1").pipe(stack.plan);
358 expect(plan.resources["A"].action).toEqual("replace");
359
360 const before = callsFor(stack.name).length;
361 const output = yield* modal("A", "v1").pipe(stack.deploy);
362 expect(output.runtime).toEqual("live");
363 expect((yield* getState("A"))?.providerMode).toEqual("live");
364
365 const calls = callsFor(stack.name).slice(before);
366 expect(calls).toContainEqual({
367 stack: stack.name,
368 mode: "live",
369 op: "reconcile",
370 id: "A",
371 });
372 // The old (legacy dev) generation is torn down by the LOCAL variant.
373 expect(calls).toContainEqual({
374 stack: stack.name,
375 mode: "local",
376 op: "delete",
377 id: "A",
378 });
379
380 yield* stack.destroy();
381 }),
382 );
383
384 test.provider(
385 "mode-agnostic providers never replace on a mode switch",
386 (stack) =>
387 Effect.gen(function* () {
388 const program = Effect.gen(function* () {
389 yield* Bucket("B", {});
390 return {};
391 });
392 yield* inDev(program.pipe(stack.deploy));
393 expect((yield* getState("B"))?.providerMode).toBeUndefined();
394
395 const plan = yield* program.pipe(stack.plan);
396 expect(plan.resources["B"].action).toEqual("noop");
397
398 yield* stack.destroy();
399 }),
400 );
401
402 test.provider(
403 "conflicting mode decorations on the same resource die",
404 (stack) =>
405 Effect.gen(function* () {
406 const exit = yield* Effect.gen(function* () {
407 yield* ModalResource("A", { value: "v1" });
408 yield* ModalResource("A", { value: "v1" }).pipe(remote());
409 return {};
410 }).pipe(stack.deploy, Effect.exit);
411
412 expect(Exit.isFailure(exit)).toBe(true);
413 if (Exit.isFailure(exit)) {
414 const defects = exit.cause.reasons.flatMap((reason) =>
415 reason._tag === "Die" ? [reason.defect as any] : [],
416 );
417 expect(
418 defects.some((d) => d?._tag === "ConflictingProviderModeError"),
419 ).toBe(true);
420 }
421
422 // Re-registering WITHOUT an explicit ambient mode inherits the
423 // original registration — the common "reference it from elsewhere"
424 // pattern must keep working.
425 const output = yield* inDev(
426 Effect.gen(function* () {
427 yield* ModalResource("A", { value: "v1" }).pipe(remote());
428 const again = yield* ModalResource("A", { value: "v1" });
429 return { runtime: again.runtime };
430 }).pipe(stack.deploy),
431 );
432 expect(output.runtime).toEqual("live");
433
434 yield* stack.destroy();
435 }),
436 );
437
438 test.provider(
439 "status events carry the resolved mode, the plan carries the run default, and mode switches carry the transition",
440 (stack) =>
441 Effect.gen(function* () {
442 const events: ResourceStatusChanged[] = [];
443 const notes: ResourceAnnotated[] = [];
444 let planDefaultMode: ProviderMode | undefined;
445 const cli = Cli.of({
446 startPlanningSession: () =>
447 Effect.succeed({
448 update: () => Effect.void,
449 succeed: () => Effect.void,
450 fail: () => Effect.void,
451 close: Effect.void,
452 }),
453 approvePlan: () => Effect.succeed(true),
454 displayPlan: () => Effect.void,
455 startApplySession: (plan) =>
456 Effect.sync(() => {
457 planDefaultMode = plan.defaultMode;
458 return {
459 done: () => Effect.void,
460 emit: (event) =>
461 Effect.sync(() => {
462 if (event._tag === "apply.resource.status")
463 events.push(event);
464 if (event._tag === "apply.resource.note") notes.push(event);
465 }),
466 };
467 }),
468 });
469 const withCli = Effect.provide(Layer.succeed(Cli, cli));
470
471 // 1. live deploy: the plan's default mode is "live" and every
472 // status event for the dual-mode resource is stamped with it.
473 yield* modal("A", "v1").pipe(stack.deploy, withCli);
474 expect(planDefaultMode).toEqual("live");
475 const liveEvents = events.filter((e) => e.id === "A");
476 expect(liveEvents.length).toBeGreaterThan(0);
477 expect(liveEvents.every((e) => e.providerMode === "live")).toBe(true);
478 expect(liveEvents.every((e) => e.fromProviderMode === undefined)).toBe(
479 true,
480 );
481 // Live rows never announce a ready-at URL.
482 expect(notes.some((n) => n.message.startsWith("ready at"))).toBe(false);
483
484 // 2. dev run: default flips to "local"; the mode switch is planned
485 // as a replacement whose events carry the transition
486 // (fromProviderMode "live" → providerMode "local"). The old
487 // generation's GC is deliberately silent (progress stays anchored
488 // on the live replacement), so no delete-status assertions here.
489 events.length = 0;
490 notes.length = 0;
491 yield* inDev(modal("A", "v1").pipe(stack.deploy, withCli));
492 expect(planDefaultMode).toEqual("local");
493 const transitions = events.filter(
494 (e) => e.id === "A" && e.fromProviderMode !== undefined,
495 );
496 expect(transitions.length).toBeGreaterThan(0);
497 expect(
498 transitions.every(
499 (e) => e.providerMode === "local" && e.fromProviderMode === "live",
500 ),
501 ).toBe(true);
502 // A local instance whose attrs carry a `url` announces it.
503 expect(notes).toContainEqual({
504 _tag: "apply.resource.note",
505 fqn: "A",
506 id: "A",
507 message: "ready at http://localhost:1337",
508 });
509
510 // 3. destroy while stamped local: delete events carry the row's
511 // stamped mode even though the run default is live.
512 events.length = 0;
513 yield* stack.destroy().pipe(withCli);
514 const destroyDeletes = events.filter(
515 (e) => e.id === "A" && e.status === "deleted",
516 );
517 expect(destroyDeletes.length).toBeGreaterThan(0);
518 expect(destroyDeletes.every((e) => e.providerMode === "local")).toBe(
519 true,
520 );
521 }),
522 );
523});
524
525// ── LocalProvider.make lifecycle ────────────────────────────────────────────
526//
527// Drives the generated provider service directly (no engine) so noop /
528// restart / delete semantics are observable across sequential calls against
529// ONE provider instance — scratch deploys rebuild providers per deploy, which
530// would reset the in-memory instance registry between steps.
531
532interface LocalThing extends Resource<
533 "Test.LocalThing",
534 { value?: string; ignored?: string },
535 { value: string }
536> {}
537const LocalThing = Resource<LocalThing>("Test.LocalThing");
538
539const localThingEvents: string[] = [];
540let capturedInvalidate: Effect.Effect<void> | undefined;
541
542type LocalThingConfig = { value: string | undefined };
543
544const localThingProvider = () =>
545 LocalProvider.make(
546 LocalThing,
547 import.meta.url, // unused: no RpcProviderProxy in tests → in-process
548 Effect.gen(function* () {
549 return {
550 // `ignored` is excluded from the restart-relevant config: changing
551 // it must NOT restart the instance.
552 resolveConfig: ({
553 news,
554 }: LocalProvider.LocalProviderInput<LocalThing>) =>
555 Effect.succeed<LocalThingConfig>({ value: news?.value }),
556 start: Effect.fn(function* ({
557 id,
558 config,
559 invalidate,
560 }: LocalProvider.StartContext<LocalThing, LocalThingConfig>) {
561 localThingEvents.push(`start:${id}:${config.value ?? ""}`);
562 capturedInvalidate = invalidate;
563 yield* Effect.addFinalizer(() =>
564 Effect.sync(() => {
565 localThingEvents.push(`kill:${id}`);
566 }),
567 );
568 return { value: config.value ?? id };
569 }),
570 stop: ({ id }: LocalProvider.StopContext) =>
571 Effect.sync(() => {
572 localThingEvents.push(`stop:${id}`);
573 }),
574 };
575 }),
576 );
577
578const fakeSession = {
579 note: () => Effect.void,
580 emit: () => Effect.void,
581} as any;
582
583const lifecycleInput = (instanceId: string, news: LocalThing["Props"]) => ({
584 id: "A",
585 fqn: "A",
586 instanceId,
587 news,
588 // The generated diff/reconcile never consult olds — the running instance's
589 // recorded config is the baseline (state can't know if the process lives).
590 olds: {} as LocalThing["Props"],
591 output: undefined,
592 oldBindings: [] as any[],
593 newBindings: [] as any[],
594 bindings: [] as any[],
595 session: fakeSession,
596});
597
598test(
599 "LocalProvider.make: config-hash noop/restart, guarded delete, invalidate, stop",
600 Effect.gen(function* () {
601 const provider = yield* Provider.findProvider(LocalThing);
602 localThingEvents.length = 0;
603
604 // create
605 const attrs1 = yield* provider.reconcile(
606 lifecycleInput("i1", {
607 value: "v1",
608 }),
609 );
610 expect(attrs1).toEqual({ value: "v1" });
611 expect(localThingEvents).toEqual(["start:A:v1"]);
612
613 // same config → diff noop, reconcile joins the running instance
614 expect(
615 yield* provider.diff!(lifecycleInput("i1", { value: "v1" })),
616 ).toEqual({ action: "noop" });
617 yield* provider.reconcile(lifecycleInput("i1", { value: "v1" }));
618 expect(localThingEvents).toEqual(["start:A:v1"]);
619
620 // config change that normalizes away (`ignored` not in resolveConfig)
621 // → still a noop
622 expect(
623 yield* provider.diff!(
624 lifecycleInput("i1", { value: "v1", ignored: "x" }),
625 ),
626 ).toEqual({ action: "noop" });
627
628 // real config change → update; reconcile kills then restarts
629 expect(
630 yield* provider.diff!(lifecycleInput("i1", { value: "v2" })),
631 ).toEqual({ action: "update" });
632 yield* provider.reconcile(lifecycleInput("i1", { value: "v2" }));
633 expect(localThingEvents).toEqual(["start:A:v1", "kill:A", "start:A:v2"]);
634
635 // delete with a STALE instanceId (replacement ordering) must not touch
636 // the running instance nor run `stop`
637 yield* provider.delete(lifecycleInput("i0", { value: "v2" }) as any);
638 expect(localThingEvents).toEqual(["start:A:v1", "kill:A", "start:A:v2"]);
639
640 // invalidate (process died on its own) → next diff is an update
641 yield* capturedInvalidate!;
642 expect(
643 yield* provider.diff!(lifecycleInput("i1", { value: "v2" })),
644 ).toEqual({ action: "update" });
645 yield* provider.reconcile(lifecycleInput("i1", { value: "v2" }));
646
647 // matching delete tears down and runs stop
648 yield* provider.delete(lifecycleInput("i1", { value: "v2" }) as any);
649 expect(localThingEvents.slice(-2)).toEqual(["kill:A", "stop:A"]);
650
651 // idempotent: deleting again only re-runs the (idempotent) stop hook
652 yield* provider.delete(lifecycleInput("i1", { value: "v2" }) as any);
653 expect(localThingEvents.slice(-2)).toEqual(["stop:A", "stop:A"]);
654 }).pipe(
655 Effect.provide(localThingProvider()),
656 Effect.provide(
657 Layer.succeed(Stack, {
658 name: "local-provider-test",
659 stage: "test",
660 resources: {},
661 bindings: {},
662 actions: {},
663 }),
664 ),
665 ),
666 { tags: ["unit", "local"] },
667);