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