t3-code-android-nightly/.repos/alchemy-effect/packages/alchemy/test/provider-mode.test.ts
Julius Marminge 6f9cea00ae
chore(refs): sync Effect and Alchemy references to 4.0.1 and beta.80 (#16170)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 13:22:30 -07:00

667 lines
24 KiB
TypeScript

/**
* First-class local vs live provider modes.
*
* Covers the engine behavior added by ProviderMode / ProviderLayer.dual:
* - the resolved mode is stamped on state (`providerMode`) on every commit
* - dev runs resolve local providers; `remote()` opts a resource out of
* local emulation; mode-agnostic providers satisfy any requested mode
* - switching modes plans a REPLACEMENT; the old generation / orphan row
* is deleted with the provider variant of the mode that created it
* - unstamped (legacy) rows are assumed live, unless their attrs carry
* the `dev:` identity marker (then local)
* - conflicting mode decorations on the same FQN die loudly
* - the non-default variant is only constructed when demanded (laziness)
*
* Also unit-tests the LocalProvider.make lifecycle (config-hash noop/restart,
* instanceId-guarded delete, invalidate, stop hook) by driving the generated
* provider service directly.
*/
import { Cli } from "@/Report.ts";
import type { ResourceAnnotated, ResourceStatusChanged } from "@/Report.ts";
import * as LocalProvider from "@/Local/LocalProvider.ts";
import * as Provider from "@/Provider.ts";
import { remote, type ProviderMode } from "@/ProviderMode.ts";
import { Resource } from "@/Resource";
import { Stack } from "@/Stack";
import { State, type ResourceState } from "@/State";
import * as Test from "@/Test/Alchemy";
import { describe, expect } from "alchemy-test";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import {
Bucket,
inDev,
ModalResource,
modalBuilds,
modalCalls,
TestLayers,
} from "./test.resources.ts";
const { test } = Test.make({ providers: TestLayers() });
const getState = Effect.fn(function* (fqn: string) {
const state = yield* yield* State;
const stk = yield* Stack;
return (yield* state.get({
stack: stk.name,
stage: stk.stage,
fqn,
})) as ResourceState | undefined;
});
const setState = Effect.fn(function* (fqn: string, value: ResourceState) {
const state = yield* yield* State;
const stk = yield* Stack;
yield* state.set({ stack: stk.name, stage: stk.stage, fqn, value });
});
const modal = (id: string, value?: string) =>
Effect.gen(function* () {
const a = yield* ModalResource(id, { value });
return { runtime: a.runtime };
});
const callsFor = (stackName: string) =>
modalCalls.filter((c) => c.stack === stackName);
const buildsFor = (stackName: string) =>
modalBuilds.filter((b) => b.stack === stackName);
describe("provider modes", { tags: ["unit", "local"] }, () => {
test.provider(
"lookup resolves concrete modes while registration lookup stays lazy",
(stack) =>
Effect.gen(function* () {
const registration = yield* Provider.tryFindProviderRegistrationByType(
ModalResource.Type,
);
expect(Option.isSome(registration)).toBe(true);
expect(buildsFor(stack.name)).toHaveLength(0);
const live = yield* Provider.findProvider(ModalResource);
expect(live.mode).toBe("live");
expect(typeof live.read).toBe("function");
expect(buildsFor(stack.name).map((build) => build.mode)).toEqual([
"live",
]);
const local = yield* inDev(
Provider.findProviderByType(ModalResource.Type),
);
expect(local.mode).toBe("local");
expect(typeof local.read).toBe("function");
const explicitLive = yield* inDev(
Provider.findProvider(ModalResource, "live"),
);
expect(explicitLive).toBe(live);
expect(buildsFor(stack.name).map((build) => build.mode)).toEqual([
"live",
"local",
]);
expect(
Option.isNone(
yield* Provider.tryFindProviderByType("Test.MissingProvider"),
),
).toBe(true);
}),
);
test.provider(
"default mode is live; providerMode is stamped; local variant is never built",
(stack) =>
Effect.gen(function* () {
const output = yield* modal("A", "v1").pipe(stack.deploy);
expect(output.runtime).toEqual("live");
const state = yield* getState("A");
expect(state?.status).toEqual("created");
expect(state?.providerMode).toEqual("live");
// Laziness: nothing demanded the local variant in a live-default
// run, so it must never have been constructed.
expect(
buildsFor(stack.name).filter((b) => b.mode === "local"),
).toHaveLength(0);
expect(
buildsFor(stack.name).filter((b) => b.mode === "live").length,
).toBeGreaterThan(0);
yield* stack.destroy();
}),
);
test.provider(
"no variant is built until a resource of the type is planned",
(stack) =>
Effect.gen(function* () {
const bucketOnly = Effect.gen(function* () {
yield* Bucket("B", {});
return {};
});
// Registration alone constructs nothing — in either default mode.
// (In dev, the local variant is what spawns a provider sidecar, so
// a stack without the type must not pay for one.)
yield* bucketOnly.pipe(stack.deploy);
expect(buildsFor(stack.name)).toHaveLength(0);
yield* inDev(bucketOnly.pipe(stack.deploy));
expect(buildsFor(stack.name)).toHaveLength(0);
yield* stack.destroy();
}),
);
test.provider(
"a dev run resolves the local provider; live-only resources stay live",
(stack) =>
Effect.gen(function* () {
const output = yield* inDev(modal("A", "v1").pipe(stack.deploy));
expect(output.runtime).toEqual("local");
expect((yield* getState("A"))?.providerMode).toEqual("local");
// A mode-agnostic resource in the same dev run deploys with its
// single implementation and stays unstamped — constructs mixing
// emulatable and live-only resources just work.
yield* inDev(
Effect.gen(function* () {
yield* ModalResource("A", { value: "v1" });
yield* Bucket("B", {});
return {};
}).pipe(stack.deploy),
);
expect((yield* getState("B"))?.providerMode).toBeUndefined();
yield* stack.destroy();
}),
);
test.provider(
"remote() opts a resource out of local emulation during dev",
(stack) =>
Effect.gen(function* () {
const output = yield* inDev(
modal("A", "v1").pipe(remote(), stack.deploy),
);
expect(output.runtime).toEqual("live");
expect((yield* getState("A"))?.providerMode).toEqual("live");
// Dropping remote() in a later dev run switches it back to local —
// a replacement like any other mode switch.
const back = yield* inDev(modal("A", "v1").pipe(stack.deploy));
expect(back.runtime).toEqual("local");
expect((yield* getState("A"))?.providerMode).toEqual("local");
yield* stack.destroy();
}),
);
test.provider(
"switching modes replaces: new mode creates, old mode deletes",
(stack) =>
Effect.gen(function* () {
// 1. dev run: local instance.
yield* inDev(modal("A", "v1").pipe(stack.deploy));
expect((yield* getState("A"))?.providerMode).toEqual("local");
const localInstanceId = (yield* getState("A"))?.instanceId;
// 2. same props, deploy (live) run → the plan must be a
// replacement even though nothing about the props changed.
const plan = yield* modal("A", "v1").pipe(stack.plan);
expect(plan.resources["A"].action).toEqual("replace");
// 3. apply: the live variant reconciles the new generation, the
// LOCAL variant (the mode that created it) deletes the old one.
const before = callsFor(stack.name).length;
const output = yield* modal("A", "v1").pipe(stack.deploy);
expect(output.runtime).toEqual("live");
const state = yield* getState("A");
expect(state?.status).toEqual("created");
expect(state?.providerMode).toEqual("live");
expect(state?.instanceId).not.toEqual(localInstanceId);
const calls = callsFor(stack.name).slice(before);
expect(calls).toContainEqual({
stack: stack.name,
mode: "live",
op: "reconcile",
id: "A",
});
expect(calls).toContainEqual({
stack: stack.name,
mode: "local",
op: "delete",
id: "A",
});
// 4. switch back: live → local replaces again, deleted by LIVE.
const beforeBack = callsFor(stack.name).length;
const back = yield* inDev(modal("A", "v1").pipe(stack.deploy));
expect(back.runtime).toEqual("local");
expect((yield* getState("A"))?.providerMode).toEqual("local");
expect(callsFor(stack.name).slice(beforeBack)).toContainEqual({
stack: stack.name,
mode: "live",
op: "delete",
id: "A",
});
yield* stack.destroy();
}),
);
test.provider(
"an orphaned local row is deleted by the local provider during a live run",
(stack) =>
Effect.gen(function* () {
yield* inDev(modal("A", "v1").pipe(stack.deploy));
// Remove the resource from the program and deploy in (default)
// live mode — the orphan delete must still route to the LOCAL
// variant, since that's the mode that created the row.
const before = callsFor(stack.name).length;
yield* Effect.succeed({}).pipe(stack.deploy);
expect(yield* getState("A")).toBeUndefined();
expect(callsFor(stack.name).slice(before)).toContainEqual({
stack: stack.name,
mode: "local",
op: "delete",
id: "A",
});
}),
);
test.provider("unstamped (legacy) rows are assumed live", (stack) =>
Effect.gen(function* () {
yield* modal("A", "v1").pipe(stack.deploy);
const row = yield* getState("A");
expect(row?.providerMode).toEqual("live");
// Simulate a row written before providerMode existed.
yield* setState("A", { ...row!, providerMode: undefined });
// A live run sees no churn: unstamped = live.
const plan = yield* modal("A", "v1").pipe(stack.plan);
expect(plan.resources["A"].action).toEqual("noop");
// A dev run must treat the unstamped row exactly like a stamped
// live row: replacement, with the old generation deleted by the
// LIVE provider. Assuming "current run's mode" instead silently
// adopts the deployed live resource as a local instance — the
// row re-stamps local and the live resource leaks untracked.
const devPlan = yield* inDev(modal("A", "v1").pipe(stack.plan));
expect(devPlan.resources["A"].action).toEqual("replace");
const before = callsFor(stack.name).length;
const output = yield* inDev(modal("A", "v1").pipe(stack.deploy));
expect(output.runtime).toEqual("local");
expect((yield* getState("A"))?.providerMode).toEqual("local");
expect(callsFor(stack.name).slice(before)).toContainEqual({
stack: stack.name,
mode: "live",
op: "delete",
id: "A",
});
yield* stack.destroy();
}),
);
test.provider(
"a legacy unstamped row with dev:-marker identity is deleted by the local provider",
(stack) =>
Effect.gen(function* () {
// Simulate a row written by `alchemy dev` on a pre-stamping version
// (e.g. 2.0.0-beta.64): reconciled by the local provider (so its
// attrs carry the `dev:` identity marker) but persisted without a
// providerMode stamp.
yield* inDev(modal("A", "v1").pipe(stack.deploy));
const row = yield* getState("A");
expect(row?.providerMode).toEqual("local");
expect((row?.attr as { instance: string }).instance).toMatch(/^dev:/);
yield* setState("A", { ...row!, providerMode: undefined });
// A dev run sees no churn: the marker infers "local", matching the
// run's mode — legacy dev rows continue seamlessly under dev.
const devPlan = yield* inDev(modal("A", "v1").pipe(stack.plan));
expect(devPlan.resources["A"].action).toEqual("noop");
// A plain (live-mode) destroy must infer "local" from the marker
// and route the delete to the local variant — never hand the `dev:`
// identity to the live provider (the cloud API rejects it).
const before = callsFor(stack.name).length;
yield* stack.destroy();
expect(yield* getState("A")).toBeUndefined();
const deletes = callsFor(stack.name)
.slice(before)
.filter((c) => c.op === "delete");
expect(deletes).toEqual([
{ stack: stack.name, mode: "local", op: "delete", id: "A" },
]);
}),
);
test.provider(
"a live deploy over a legacy unstamped local row replaces via the marker-inferred mode",
(stack) =>
Effect.gen(function* () {
yield* inDev(modal("A", "v1").pipe(stack.deploy));
const row = yield* getState("A");
yield* setState("A", { ...row!, providerMode: undefined });
// Identical props, but the marker infers the row is local and this
// run resolves live → mode-switch replacement, not a live
// reconcile against the `dev:` identity.
const plan = yield* modal("A", "v1").pipe(stack.plan);
expect(plan.resources["A"].action).toEqual("replace");
const before = callsFor(stack.name).length;
const output = yield* modal("A", "v1").pipe(stack.deploy);
expect(output.runtime).toEqual("live");
expect((yield* getState("A"))?.providerMode).toEqual("live");
const calls = callsFor(stack.name).slice(before);
expect(calls).toContainEqual({
stack: stack.name,
mode: "live",
op: "reconcile",
id: "A",
});
// The old (legacy dev) generation is torn down by the LOCAL variant.
expect(calls).toContainEqual({
stack: stack.name,
mode: "local",
op: "delete",
id: "A",
});
yield* stack.destroy();
}),
);
test.provider(
"mode-agnostic providers never replace on a mode switch",
(stack) =>
Effect.gen(function* () {
const program = Effect.gen(function* () {
yield* Bucket("B", {});
return {};
});
yield* inDev(program.pipe(stack.deploy));
expect((yield* getState("B"))?.providerMode).toBeUndefined();
const plan = yield* program.pipe(stack.plan);
expect(plan.resources["B"].action).toEqual("noop");
yield* stack.destroy();
}),
);
test.provider(
"conflicting mode decorations on the same resource die",
(stack) =>
Effect.gen(function* () {
const exit = yield* Effect.gen(function* () {
yield* ModalResource("A", { value: "v1" });
yield* ModalResource("A", { value: "v1" }).pipe(remote());
return {};
}).pipe(stack.deploy, Effect.exit);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
const defects = exit.cause.reasons.flatMap((reason) =>
reason._tag === "Die" ? [reason.defect as any] : [],
);
expect(
defects.some((d) => d?._tag === "ConflictingProviderModeError"),
).toBe(true);
}
// Re-registering WITHOUT an explicit ambient mode inherits the
// original registration — the common "reference it from elsewhere"
// pattern must keep working.
const output = yield* inDev(
Effect.gen(function* () {
yield* ModalResource("A", { value: "v1" }).pipe(remote());
const again = yield* ModalResource("A", { value: "v1" });
return { runtime: again.runtime };
}).pipe(stack.deploy),
);
expect(output.runtime).toEqual("live");
yield* stack.destroy();
}),
);
test.provider(
"status events carry the resolved mode, the plan carries the run default, and mode switches carry the transition",
(stack) =>
Effect.gen(function* () {
const events: ResourceStatusChanged[] = [];
const notes: ResourceAnnotated[] = [];
let planDefaultMode: ProviderMode | undefined;
const cli = Cli.of({
startPlanningSession: () =>
Effect.succeed({
update: () => Effect.void,
succeed: () => Effect.void,
fail: () => Effect.void,
close: Effect.void,
}),
approvePlan: () => Effect.succeed(true),
displayPlan: () => Effect.void,
startApplySession: (plan) =>
Effect.sync(() => {
planDefaultMode = plan.defaultMode;
return {
done: () => Effect.void,
emit: (event) =>
Effect.sync(() => {
if (event._tag === "apply.resource.status")
events.push(event);
if (event._tag === "apply.resource.note") notes.push(event);
}),
};
}),
});
const withCli = Effect.provide(Layer.succeed(Cli, cli));
// 1. live deploy: the plan's default mode is "live" and every
// status event for the dual-mode resource is stamped with it.
yield* modal("A", "v1").pipe(stack.deploy, withCli);
expect(planDefaultMode).toEqual("live");
const liveEvents = events.filter((e) => e.id === "A");
expect(liveEvents.length).toBeGreaterThan(0);
expect(liveEvents.every((e) => e.providerMode === "live")).toBe(true);
expect(liveEvents.every((e) => e.fromProviderMode === undefined)).toBe(
true,
);
// Live rows never announce a ready-at URL.
expect(notes.some((n) => n.message.startsWith("ready at"))).toBe(false);
// 2. dev run: default flips to "local"; the mode switch is planned
// as a replacement whose events carry the transition
// (fromProviderMode "live" → providerMode "local"). The old
// generation's GC is deliberately silent (progress stays anchored
// on the live replacement), so no delete-status assertions here.
events.length = 0;
notes.length = 0;
yield* inDev(modal("A", "v1").pipe(stack.deploy, withCli));
expect(planDefaultMode).toEqual("local");
const transitions = events.filter(
(e) => e.id === "A" && e.fromProviderMode !== undefined,
);
expect(transitions.length).toBeGreaterThan(0);
expect(
transitions.every(
(e) => e.providerMode === "local" && e.fromProviderMode === "live",
),
).toBe(true);
// A local instance whose attrs carry a `url` announces it.
expect(notes).toContainEqual({
_tag: "apply.resource.note",
fqn: "A",
id: "A",
message: "ready at http://localhost:1337",
});
// 3. destroy while stamped local: delete events carry the row's
// stamped mode even though the run default is live.
events.length = 0;
yield* stack.destroy().pipe(withCli);
const destroyDeletes = events.filter(
(e) => e.id === "A" && e.status === "deleted",
);
expect(destroyDeletes.length).toBeGreaterThan(0);
expect(destroyDeletes.every((e) => e.providerMode === "local")).toBe(
true,
);
}),
);
});
// ── LocalProvider.make lifecycle ────────────────────────────────────────────
//
// Drives the generated provider service directly (no engine) so noop /
// restart / delete semantics are observable across sequential calls against
// ONE provider instance — scratch deploys rebuild providers per deploy, which
// would reset the in-memory instance registry between steps.
interface LocalThing extends Resource<
"Test.LocalThing",
{ value?: string; ignored?: string },
{ value: string }
> {}
const LocalThing = Resource<LocalThing>("Test.LocalThing");
const localThingEvents: string[] = [];
let capturedInvalidate: Effect.Effect<void> | undefined;
type LocalThingConfig = { value: string | undefined };
const localThingProvider = () =>
LocalProvider.make(
LocalThing,
import.meta.url, // unused: no RpcProviderProxy in tests → in-process
Effect.gen(function* () {
return {
// `ignored` is excluded from the restart-relevant config: changing
// it must NOT restart the instance.
resolveConfig: ({
news,
}: LocalProvider.LocalProviderInput<LocalThing>) =>
Effect.succeed<LocalThingConfig>({ value: news?.value }),
start: Effect.fn(function* ({
id,
config,
invalidate,
}: LocalProvider.StartContext<LocalThing, LocalThingConfig>) {
localThingEvents.push(`start:${id}:${config.value ?? ""}`);
capturedInvalidate = invalidate;
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
localThingEvents.push(`kill:${id}`);
}),
);
return { value: config.value ?? id };
}),
stop: ({ id }: LocalProvider.StopContext) =>
Effect.sync(() => {
localThingEvents.push(`stop:${id}`);
}),
};
}),
);
const fakeSession = {
note: () => Effect.void,
emit: () => Effect.void,
} as any;
const lifecycleInput = (instanceId: string, news: LocalThing["Props"]) => ({
id: "A",
fqn: "A",
instanceId,
news,
// The generated diff/reconcile never consult olds — the running instance's
// recorded config is the baseline (state can't know if the process lives).
olds: {} as LocalThing["Props"],
output: undefined,
oldBindings: [] as any[],
newBindings: [] as any[],
bindings: [] as any[],
session: fakeSession,
});
test(
"LocalProvider.make: config-hash noop/restart, guarded delete, invalidate, stop",
Effect.gen(function* () {
const provider = yield* Provider.findProvider(LocalThing);
localThingEvents.length = 0;
// create
const attrs1 = yield* provider.reconcile(
lifecycleInput("i1", {
value: "v1",
}),
);
expect(attrs1).toEqual({ value: "v1" });
expect(localThingEvents).toEqual(["start:A:v1"]);
// same config → diff noop, reconcile joins the running instance
expect(
yield* provider.diff!(lifecycleInput("i1", { value: "v1" })),
).toEqual({ action: "noop" });
yield* provider.reconcile(lifecycleInput("i1", { value: "v1" }));
expect(localThingEvents).toEqual(["start:A:v1"]);
// config change that normalizes away (`ignored` not in resolveConfig)
// → still a noop
expect(
yield* provider.diff!(
lifecycleInput("i1", { value: "v1", ignored: "x" }),
),
).toEqual({ action: "noop" });
// real config change → update; reconcile kills then restarts
expect(
yield* provider.diff!(lifecycleInput("i1", { value: "v2" })),
).toEqual({ action: "update" });
yield* provider.reconcile(lifecycleInput("i1", { value: "v2" }));
expect(localThingEvents).toEqual(["start:A:v1", "kill:A", "start:A:v2"]);
// delete with a STALE instanceId (replacement ordering) must not touch
// the running instance nor run `stop`
yield* provider.delete(lifecycleInput("i0", { value: "v2" }) as any);
expect(localThingEvents).toEqual(["start:A:v1", "kill:A", "start:A:v2"]);
// invalidate (process died on its own) → next diff is an update
yield* capturedInvalidate!;
expect(
yield* provider.diff!(lifecycleInput("i1", { value: "v2" })),
).toEqual({ action: "update" });
yield* provider.reconcile(lifecycleInput("i1", { value: "v2" }));
// matching delete tears down and runs stop
yield* provider.delete(lifecycleInput("i1", { value: "v2" }) as any);
expect(localThingEvents.slice(-2)).toEqual(["kill:A", "stop:A"]);
// idempotent: deleting again only re-runs the (idempotent) stop hook
yield* provider.delete(lifecycleInput("i1", { value: "v2" }) as any);
expect(localThingEvents.slice(-2)).toEqual(["stop:A", "stop:A"]);
}).pipe(
Effect.provide(localThingProvider()),
Effect.provide(
Layer.succeed(Stack, {
name: "local-provider-test",
stage: "test",
resources: {},
bindings: {},
actions: {},
}),
),
),
{ tags: ["unit", "local"] },
);