t3-code-android-nightly/.repos/alchemy-effect/packages/alchemy/test/plan.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

6551 lines
195 KiB
TypeScript

import { Action } from "@/Action";
import { adopt, AdoptPolicy, Unowned } from "@/AdoptPolicy";
import { dedupeBindings } from "@/Diff";
import type { Input, InputProps } from "@/Input";
import * as Namespace from "@/Namespace.ts";
import * as Output from "@/Output";
import * as Plan from "@/Plan";
import * as Provider from "@/Provider";
import { UnsatisfiedResourceCycle } from "@/Plan";
import { remote } from "@/ProviderMode.ts";
import { renamedFrom } from "@/Rename.ts";
import { Progress, type ProgressEvent } from "@/Report.ts";
import { Resource, type ResourceBinding } from "@/Resource";
import { packEnvValue } from "@/RuntimeContext.ts";
import { AuthProviders } from "@/Auth/AuthProvider.ts";
import * as Stack from "@/Stack";
import { Stage } from "@/Stage";
import { hashInput } from "@/Util/sha256";
import {
InMemoryService,
inMemoryState,
State,
type ResourceState,
type ResourceStatus,
} from "@/State";
import * as Test from "@/Test/Alchemy";
import { describe, expect } from "alchemy-test";
import * as Cause from "effect/Cause";
import * as Config from "effect/Config";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Layer from "effect/Layer";
import * as Logger from "effect/Logger";
import * as Option from "effect/Option";
import * as Redacted from "effect/Redacted";
import {
AliasedWidget,
aliasedWidgetProvider,
ArtifactProbe,
BindingTarget,
Bucket,
capturedConfigHost,
Function,
inDev,
KindStablesResource,
ModalResource,
NoPrecreateBindingTarget,
OverrideStablesResource,
ProbeBinding,
Queue,
TestLayers,
TestResource,
TestResourceHooks,
type TestResourceProps,
} from "./test.resources";
const TEST_STACK = "test";
const TEST_STAGE = "test";
// Fresh in-memory state per test run so seeded resources from one test
// don't leak into another in the same file.
const freshState = Layer.effect(
State,
Effect.sync(() => InMemoryService({})),
);
const { test } = Test.make({
providers: TestLayers(),
state: freshState,
});
// Resolve stack name/stage from ambient Stack if present (for test.provider)
// otherwise fall back to the file-level defaults (for plain test()).
const resolveStackId = Effect.gen(function* () {
const ambient = yield* Effect.serviceOption(Stack.Stack);
return Option.match(ambient, {
onNone: () => ({ name: TEST_STACK, stage: TEST_STAGE }),
onSome: (s) => ({ name: s.name, stage: s.stage }),
});
});
const seed = (resources: Record<string, ResourceState>) =>
Effect.gen(function* () {
const { name, stage } = yield* resolveStackId;
const state = yield* yield* State;
for (const [fqn, value] of Object.entries(resources)) {
yield* state.set({ stack: name, stage, fqn, value });
}
});
const instanceId = "852f6ec2e19b66589825efe14dca2971";
const makePlan = <A, Err = never, Req = never>(
effect: Effect.Effect<A, Err, Req>,
options?: Plan.FilteredPlanOptions,
): Effect.Effect<Plan.Plan<A | undefined>, Err, State> =>
// @ts-expect-error - Stack.make's typing erases R unsoundly here
Effect.gen(function* () {
const { name, stage } = yield* resolveStackId;
// @ts-expect-error
return yield* effect.pipe(
// @ts-expect-error
Stack.make({
name,
providers: Layer.empty,
state: inMemoryState(),
}),
Effect.provideService(Stage, stage),
Effect.flatMap((stackSpec: any) => Plan.make(stackSpec, options)),
Effect.provide(TestLayers()),
);
});
const makePlanWithCustomStack =
(stackSpec: any) =>
<A, Err = never, Req = never>(
effect: Effect.Effect<A, Err, Req>,
): Effect.Effect<Plan.Plan<A>, Err, State> =>
// @ts-expect-error
Effect.gen(function* () {
const { name, stage } = yield* resolveStackId;
// @ts-expect-error
return yield* effect.pipe(
// @ts-expect-error
Stack.make({
name,
providers: Layer.empty,
state: inMemoryState(),
stack: stackSpec,
}),
Effect.provideService(Stage, stage),
Effect.flatMap(Plan.make),
Effect.provide(TestLayers()),
);
});
describe("Action output convergence", { tags: ["unit", "local"] }, () => {
const seedAction = (
id: string,
input: Record<string, unknown>,
output: unknown,
status: "ran" | "running" = "ran",
) =>
Effect.gen(function* () {
const { name, stage } = yield* resolveStackId;
const state = yield* yield* State;
const row = {
kind: "action" as const,
fqn: id,
logicalId: id,
namespace: undefined,
actionType: "Compute",
inputHash: yield* hashInput(input),
input,
downstream: ["Host"],
};
yield* state.set({
stack: name,
stage,
fqn: id,
value:
status === "ran" ? { ...row, status, output } : { ...row, status },
});
});
const seedHost = (value: string) =>
seed({
Host: {
instanceId,
providerVersion: 0,
logicalId: "Host",
fqn: "Host",
namespace: undefined,
resourceType: "Test.Function",
status: "created",
props: { name: "host", env: { RESULT: value } },
attr: {
name: "host",
env: { RESULT: value },
functionArn: "arn:test:host",
},
bindings: [],
downstream: [],
},
});
test(
"unchanged Action output in host env produces repeated noop plans without running the body",
Effect.gen(function* () {
let calls = 0;
const Compute = Action("Compute", (_: { revision: string }) =>
Effect.sync(() => {
calls++;
return { value: "v1" };
}),
);
yield* seedAction("Compute", { revision: "one" }, { value: "v1" });
yield* seedHost("v1");
const program = Effect.gen(function* () {
const result = yield* Compute({ revision: "one" });
return yield* Function("Host", {
name: "host",
env: { RESULT: result.value },
});
});
for (const _ of [1, 2]) {
const plan = yield* program.pipe(makePlan);
expect(plan.actions.Compute.action).toBe("noop");
expect(plan.actions.Compute.downstream).toContain("Host");
expect(plan.resources.Host.action).toBe("noop");
expect(calls).toBe(0);
}
}),
);
test(
"a new consumer receives the persisted output of a noop Action and retains its dependency edge",
Effect.gen(function* () {
const Compute = Action("Compute", (_: {}) =>
Effect.succeed({ value: "v1" }),
);
yield* seedAction("Compute", {}, { value: "v1" });
const plan = yield* Effect.gen(function* () {
const result = yield* Compute({});
return yield* Function("Host", { env: { RESULT: result.value } });
}).pipe(makePlan);
expect(plan.actions.Compute.action).toBe("noop");
expect(plan.actions.Compute.downstream).toContain("Host");
expect(plan.resources.Host).toMatchObject({
action: "create",
props: { env: { RESULT: "v1" } },
});
}),
);
test(
"unchanged Action output in binding data does not dirty the host",
Effect.gen(function* () {
const Compute = Action("Compute", (_: {}) =>
Effect.succeed({ value: "v1" }),
);
yield* seedAction("Compute", {}, { value: "v1" });
yield* seed({
Host: {
instanceId,
providerVersion: 0,
logicalId: "Host",
fqn: "Host",
namespace: undefined,
resourceType: "Test.BindingTarget",
status: "created",
props: { name: "host" },
attr: {
name: "host",
string: "",
env: { RESULT: "v1" },
replaceString: undefined,
},
bindings: [{ sid: "Result", data: { env: { RESULT: "v1" } } }],
downstream: [],
},
});
const plan = yield* Effect.gen(function* () {
const result = yield* Compute({});
const host = yield* BindingTarget("Host", { name: "host" });
yield* host.bind("Result", { env: { RESULT: result.value } });
return host;
}).pipe(makePlan);
expect(plan.actions.Compute.action).toBe("noop");
expect(plan.actions.Compute.downstream).toContain("Host");
expect(plan.resources.Host.action).toBe("noop");
expect(plan.resources.Host.bindings).toEqual([
{ sid: "Result", action: "noop", data: { env: { RESULT: "v1" } } },
]);
}),
);
for (const scenario of [
"changed input",
"force",
"unfinished prior run",
] as const) {
test(
`${scenario} keeps the Action output unresolved instead of substituting stale state`,
Effect.gen(function* () {
const Compute = Action("Compute", (input: { revision: string }) =>
Effect.succeed({ value: input.revision }),
);
yield* seedAction(
"Compute",
{ revision: "one" },
{ value: "old" },
scenario === "unfinished prior run" ? "running" : "ran",
);
yield* seedHost("old");
const plan = yield* Effect.gen(function* () {
const result = yield* Compute({
revision: scenario === "changed input" ? "two" : "one",
});
return yield* Function("Host", {
name: "host",
env: { RESULT: result.value },
});
}).pipe((program) =>
makePlan(program, { force: scenario === "force" }),
);
expect(plan.actions.Compute.action).toBe("run");
expect(plan.resources.Host.action).toBe("update");
const host = plan.resources.Host;
if (host.action !== "update") throw new Error("Expected a host update");
expect(Output.hasOutputs(host.props.env.RESULT)).toBe(true);
expect(plan.actions.Compute.downstream).toContain("Host");
}),
);
}
for (const value of [undefined, null, false, 0, ""] as const) {
test(
`persisted ${String(value)} is a valid Action result, not a missing output`,
Effect.gen(function* () {
const Compute = Action("Compute", (_: {}) => Effect.succeed(value));
yield* seedAction("Compute", {}, value);
yield* seedHost(String(value));
const plan = yield* Effect.gen(function* () {
const result = yield* Compute({});
return yield* Function("Host", {
name: "host",
env: { RESULT: Output.map(result, (value) => String(value)) },
});
}).pipe(makePlan);
expect(plan.actions.Compute.action).toBe("noop");
expect(plan.resources.Host.action).toBe("noop");
}),
);
}
for (const changed of [false, true]) {
test(
`Action chains ${changed ? "invalidate downstream consumers when root input changes" : "converge when all inputs are unchanged"}`,
Effect.gen(function* () {
const Compute = Action("Compute", (input: { value: string }) =>
Effect.succeed({ value: input.value }),
);
yield* seedAction("First", { value: "v1" }, { value: "v1" });
yield* seedAction("Second", { value: "v1" }, { value: "v1" });
yield* seedHost("v1");
const plan = yield* Effect.gen(function* () {
const first = yield* Compute("First", {
value: changed ? "v2" : "v1",
});
const second = yield* Compute("Second", { value: first.value });
return yield* Function("Host", {
name: "host",
env: { RESULT: second.value },
});
}).pipe(makePlan);
expect(plan.actions.First.action).toBe(changed ? "run" : "noop");
expect(plan.actions.Second.action).toBe(changed ? "run" : "noop");
expect(plan.resources.Host.action).toBe(changed ? "update" : "noop");
expect(plan.actions.First.downstream).toContain("Second");
expect(plan.actions.Second.downstream).toContain("Host");
}),
);
}
test(
"changing only the Action body preserves the existing input-based invalidation contract",
Effect.gen(function* () {
const Compute = Action("Compute", (_: {}) =>
Effect.succeed({ value: "new-body" }),
);
yield* seedAction("Compute", {}, { value: "old-body" });
const plan = yield* Effect.gen(function* () {
const result = yield* Compute({});
return yield* Function("Host", { env: { RESULT: result.value } });
}).pipe(makePlan);
expect(plan.actions.Compute.action).toBe("noop");
expect(plan.resources.Host).toMatchObject({
action: "create",
props: { env: { RESULT: "old-body" } },
});
}),
);
test(
"a literal environment converges while an unrelated Action remains noop",
Effect.gen(function* () {
const Compute = Action("Compute", (_: {}) =>
Effect.succeed({ value: "v1" }),
);
yield* seedAction("Compute", {}, { value: "v1" });
yield* seedHost("v1");
const plan = yield* Effect.gen(function* () {
yield* Compute({});
return yield* Function("Host", { name: "host", env: { RESULT: "v1" } });
}).pipe(makePlan);
expect(plan.actions.Compute.action).toBe("noop");
expect(plan.resources.Host.action).toBe("noop");
}),
);
test(
"a noop Action does not suppress changes to other consumer props",
Effect.gen(function* () {
const Compute = Action("Compute", (_: {}) =>
Effect.succeed({ value: "v1" }),
);
yield* seedAction("Compute", {}, { value: "v1" });
yield* seedHost("v1");
const plan = yield* Effect.gen(function* () {
const result = yield* Compute({});
return yield* Function("Host", {
name: "changed",
env: { RESULT: result.value },
});
}).pipe(makePlan);
expect(plan.actions.Compute.action).toBe("noop");
expect(plan.resources.Host.action).toBe("update");
}),
);
test(
"an unchanged Action output does not hide a replacement-sensitive prop change",
Effect.gen(function* () {
const Compute = Action("Compute", (_: {}) =>
Effect.succeed({ value: "v1" }),
);
yield* seedAction("Compute", {}, { value: "v1" });
yield* seed({
Host: {
instanceId,
providerVersion: 0,
logicalId: "Host",
fqn: "Host",
namespace: undefined,
resourceType: "Test.BindingTarget",
status: "created",
props: { name: "host", string: "v1", replaceString: "old" },
attr: { name: "host", string: "v1", env: {}, replaceString: "old" },
bindings: [],
downstream: [],
},
});
const plan = yield* Effect.gen(function* () {
const result = yield* Compute({});
return yield* BindingTarget("Host", {
name: "host",
string: result.value,
replaceString: "new",
});
}).pipe(makePlan);
expect(plan.actions.Compute.action).toBe("noop");
expect(plan.resources.Host.action).toBe("replace");
}),
);
for (const stable of [false, true]) {
test.provider(
`resource-backed Action inputs ${stable ? "reuse a stable property" : "invalidate a changing property"}`,
(stack) =>
Effect.gen(function* () {
const Compute = Action("Compute", (input: { value: string }) =>
Effect.succeed(input),
);
const program = (value: string) =>
Effect.gen(function* () {
const source = yield* TestResource("Source", { string: value });
const result = yield* Compute({
value: stable ? source.stableString : source.string,
});
return yield* Function("Host", { env: { RESULT: result.value } });
});
yield* stack.deploy(program("v1"));
const plan = yield* stack.plan(program("v2"));
expect(plan.resources.Source.action).toBe("update");
expect(plan.actions.Compute.action).toBe(stable ? "noop" : "run");
expect(plan.resources.Host.action).toBe(stable ? "noop" : "update");
expect(plan.resources.Source.downstream).toContain("Compute");
expect(plan.actions.Compute.downstream).toContain("Host");
}),
);
}
test(
"partial stable attributes cannot prove that a whole-resource Action input is unchanged",
Effect.gen(function* () {
const source = { stableString: "Source", stableArray: ["Source"] };
yield* seed({
Source: {
instanceId,
providerVersion: 0,
logicalId: "Source",
fqn: "Source",
namespace: undefined,
resourceType: "Test.TestResource",
status: "created",
props: { string: "v1" },
attr: source,
bindings: [],
downstream: ["Compute"],
},
});
yield* seedAction("Compute", { source }, { value: "v1" });
yield* seedHost("v1");
const Compute = Action(
"Compute",
(input: { source: { string?: string } }) =>
Effect.succeed({ value: input.source.string ?? "v1" }),
);
const plan = yield* Effect.gen(function* () {
const source = yield* TestResource("Source", { string: "v2" });
const result = yield* Compute({ source: Output.of(source) });
return yield* Function("Host", {
name: "host",
env: { RESULT: result.value },
});
}).pipe(makePlan);
expect(plan.resources.Source.action).toBe("update");
expect(plan.actions.Compute.action).toBe("run");
expect(plan.resources.Host.action).toBe("update");
}),
);
test(
"cyclic Action inputs remain unresolved instead of waiting on their own cached plans",
Effect.gen(function* () {
const Compute = Action("Compute", (input: { value: string }) =>
Effect.succeed(input),
);
yield* seedAction("First", { value: "v1" }, { value: "v1" });
yield* seedAction("Second", { value: "v1" }, { value: "v1" });
const plan = yield* Effect.gen(function* () {
const input: { value: Input<string> } = { value: "v1" };
const first = yield* Compute("First", input);
const second = yield* Compute("Second", { value: first.value });
input.value = second.value;
return second;
}).pipe(makePlan);
expect(plan.actions.First.action).toBe("run");
expect(plan.actions.Second.action).toBe("run");
}),
{ timeout: 1000 },
);
test(
"a resource prop and Action input cycle does not deadlock output resolution",
Effect.gen(function* () {
const Compute = Action("Compute", (input: { value: string }) =>
Effect.succeed(input),
);
yield* seedAction("Compute", { value: "v1" }, { value: "v1" });
yield* seedHost("v1");
const plan = yield* Effect.gen(function* () {
const input: { value: Input<string> } = { value: "v1" };
const result = yield* Compute(input);
const host = yield* Function("Host", {
name: "host",
env: { RESULT: result.value },
});
input.value = host.env.RESULT;
return host;
}).pipe(makePlan);
expect(plan.actions.Compute.action).toBe("run");
expect(plan.resources.Host.action).toBe("update");
}),
{ timeout: 1000 },
);
test(
"a new Action retains its binding dependency without changing host props",
Effect.gen(function* () {
const Compute = Action("Compute", (_: {}) =>
Effect.succeed({ value: "v1" }),
);
const plan = yield* Effect.gen(function* () {
const result = yield* Compute({});
const host = yield* BindingTarget("Host", { name: "host" });
yield* host.bind("Result", { env: { RESULT: result.value } });
return host;
}).pipe(makePlan);
expect(plan.actions.Compute.downstream).toContain("Host");
expect(plan.resources.Host).toMatchObject({
action: "create",
props: { name: "host" },
});
}),
);
});
test(
"artifacts are isolated by FQN during plan diff for namespaced resources",
Effect.gen(function* () {
yield* seed({
"Left/Shared": {
instanceId: "left-instance",
providerVersion: 0,
logicalId: "Shared",
fqn: "Left/Shared",
namespace: { Id: "Left" },
resourceType: "Test.ArtifactProbe",
status: "created",
props: {
value: "left-v1",
},
attr: {
value: "left-v1",
artifactValue: undefined,
},
bindings: [],
downstream: [],
},
"Right/Shared": {
instanceId: "right-instance",
providerVersion: 0,
logicalId: "Shared",
fqn: "Right/Shared",
namespace: { Id: "Right" },
resourceType: "Test.ArtifactProbe",
status: "created",
props: {
value: "right-v1",
},
attr: {
value: "right-v1",
artifactValue: undefined,
},
bindings: [],
downstream: [],
},
});
const Site = (id: string, props: { value: string }) =>
Effect.gen(function* () {
return yield* ArtifactProbe("Shared", { value: props.value });
}).pipe(Namespace.push(id));
const plan = yield* Effect.gen(function* () {
const left = yield* Site("Left", { value: "left-v2" });
const right = yield* Site("Right", { value: "right-v2" });
return { left, right };
}).pipe(makePlan);
expect(plan.resources["Left/Shared"]?.action).toEqual("update");
expect(plan.resources["Right/Shared"]?.action).toEqual("update");
}),
{ tags: ["unit", "local"] },
);
test(
"create all resources when plan is empty",
Effect.gen(function* () {
expect(
yield* Effect.gen(function* () {
const bucket = yield* Bucket("MyBucket", {
name: "test-bucket",
});
const queue = yield* Queue("MyQueue", {
name: "test-queue",
});
return {
queueUrl: queue.queueUrl,
bucketArn: bucket.bucketArn,
};
}).pipe(makePlan),
).toMatchObject({
resources: {
MyBucket: {
action: "create",
bindings: [],
props: {
name: "test-bucket",
},
state: undefined,
},
MyQueue: {
action: "create",
bindings: [],
props: {
name: "test-queue",
},
state: undefined,
},
},
deletions: expect.toSatisfy(
(d: any) => Object.keys(d).length === 0,
"empty object",
),
});
}),
{ tags: ["unit", "local"] },
);
test(
"reports each diffed resource and action through the ambient Progress reporter",
Effect.gen(function* () {
yield* seed({
A: {
instanceId,
providerVersion: 0,
logicalId: "A",
fqn: "A",
namespace: undefined,
resourceType: "Test.BindingTarget",
status: "created",
props: {
name: "target",
},
attr: {
name: "target",
env: {},
},
bindings: [],
downstream: [],
},
});
const Announce = Action("Announce", (_: { table: string }) =>
Effect.succeed(1),
);
const events: Array<ProgressEvent> = [];
yield* Effect.gen(function* () {
const target = yield* BindingTarget("A", { name: "target" });
yield* target.bind("TestBinding", { env: { FEATURE_FLAG: "on" } });
yield* Queue("MyQueue", { name: "test-queue" });
yield* Announce({ table: "users" });
}).pipe(
makePlan,
Effect.provideService(Progress, (event) =>
Effect.sync(() => {
events.push(event);
}),
),
);
// Phase markers land before any node event: state loads, then diffing.
expect(
events
.filter((event) => event._tag === "plan.phase")
.map(({ phase }) => phase),
).toEqual(["loading-state", "computing-plan"]);
expect(events[0]).toMatchObject({
_tag: "plan.phase",
phase: "loading-state",
});
expect(events[1]).toMatchObject({
_tag: "plan.phase",
phase: "computing-plan",
});
// Each resource diff announces its start before the planned completion.
expect(
events
.filter((event) => event._tag === "plan.resource.started")
.map(({ logicalId }) => logicalId)
.sort(),
).toEqual(["A", "MyQueue"]);
const nodes = events.filter(
(event) =>
event._tag === "plan.resource.completed" ||
event._tag === "plan.action.completed",
);
const byId = Object.fromEntries(
nodes.map((event) => [event.logicalId, event]),
);
// resource rows carry their binding rows across the wire
expect(byId.A).toMatchObject({
_tag: "plan.resource.completed",
action: "update",
bindings: [{ sid: "TestBinding", action: "create" }],
total: 2,
});
expect(byId.MyQueue).toMatchObject({
_tag: "plan.resource.completed",
action: "create",
bindings: [],
total: 2,
});
// stack actions get their own events
expect(byId.Announce).toMatchObject({
_tag: "plan.action.completed",
actionType: "Announce",
action: "run",
completed: 1,
total: 1,
});
expect(
events
.filter((event) => event._tag === "plan.resource.completed")
.map(({ completed }) => completed)
.sort(),
).toEqual([1, 2]);
}),
{ tags: ["unit", "local"] },
);
test(
"destroy plans report deletions through the ambient Progress reporter",
Effect.gen(function* () {
yield* seed({
MyBucket: {
instanceId,
providerVersion: 0,
logicalId: "MyBucket",
fqn: "MyBucket",
namespace: undefined,
resourceType: "Test.Bucket",
status: "created",
props: { name: "test-bucket" },
attr: { name: "test-bucket" },
bindings: [],
downstream: [],
},
});
const events: Array<ProgressEvent> = [];
const { name, stage } = yield* resolveStackId;
const plan = yield* Plan.destroy({ name, stage }).pipe(
Effect.provideService(Progress, (event) =>
Effect.sync(() => {
events.push(event);
}),
),
Effect.provide(TestLayers()),
);
expect(plan.deletions.MyBucket).toMatchObject({ action: "delete" });
expect(
events
.filter((event) => event._tag === "plan.phase")
.map(({ phase }) => phase),
).toEqual(["loading-state", "computing-plan"]);
const nodes = events.filter(
(event) => event._tag === "plan.resource.completed",
);
expect(nodes).toHaveLength(1);
expect(nodes[0]).toMatchObject({
_tag: "plan.resource.completed",
fqn: "MyBucket",
logicalId: "MyBucket",
resourceType: "Test.Bucket",
action: "delete",
bindings: [],
completed: 1,
total: 1,
});
}),
{ tags: ["unit", "local"] },
);
test(
"update the changed resources and no-op un-changed resources",
Effect.gen(function* () {
yield* seed({
MyBucket: {
instanceId,
providerVersion: 0,
logicalId: "MyBucket",
fqn: "MyBucket",
namespace: undefined,
resourceType: "Test.Bucket",
status: "created",
props: {
name: "test-bucket",
},
attr: {
name: "test-bucket",
},
bindings: [],
downstream: [],
},
});
expect(
yield* makePlan(
Effect.gen(function* () {
yield* Bucket("MyBucket", {
name: "test-bucket",
});
yield* Queue("MyQueue", {
name: "test-queue",
});
}),
),
).toMatchObject({
resources: {
MyBucket: {
action: "noop",
bindings: [],
state: {
status: "created",
},
},
MyQueue: {
action: "create",
bindings: [],
props: {
name: "test-queue",
},
state: undefined,
},
},
deletions: expect.toSatisfy(
(d: any) => Object.keys(d).length === 0,
"empty object",
),
});
}),
{ tags: ["unit", "local"] },
);
test(
"plan downstream resources when a stable kind shadows an output discriminator",
Effect.gen(function* () {
yield* seed({
Database: {
instanceId,
providerVersion: 0,
logicalId: "Database",
fqn: "Database",
namespace: undefined,
resourceType: "Test.KindStablesResource",
status: "created",
props: {
value: "v1",
},
attr: {
kind: "postgresql",
value: "v1",
upstreamKind: undefined,
},
bindings: [],
downstream: [],
},
});
const plan = yield* Effect.gen(function* () {
const database = yield* KindStablesResource("Database", {
value: "v2",
});
yield* KindStablesResource("Role", {
value: "role",
upstream: database,
});
}).pipe(makePlan);
expect(plan.resources.Database!.action).toBe("update");
expect(plan.resources.Role!.action).toBe("create");
}),
{ tags: ["unit", "local"] },
);
test(
"force changes noop resources into updates",
Effect.gen(function* () {
yield* seed({
MyBucket: {
instanceId,
providerVersion: 0,
logicalId: "MyBucket",
fqn: "MyBucket",
namespace: undefined,
resourceType: "Test.Bucket",
status: "created",
props: {
name: "test-bucket",
},
attr: {
name: "test-bucket",
},
bindings: [],
downstream: [],
},
});
expect(
yield* makePlan(
Effect.gen(function* () {
yield* Bucket("MyBucket", {
name: "test-bucket",
});
}),
{ force: true },
),
).toMatchObject({
resources: {
MyBucket: {
action: "update",
bindings: [],
props: {
name: "test-bucket",
},
state: {
status: "created",
},
},
},
deletions: expect.toSatisfy(
(d: any) => Object.keys(d).length === 0,
"empty object",
),
});
}),
{ tags: ["unit", "local"] },
);
test(
"no-op resources with undefined props",
Effect.gen(function* () {
yield* seed({
MyQueue: {
instanceId,
providerVersion: 0,
logicalId: "MyQueue",
fqn: "MyQueue",
namespace: undefined,
resourceType: "Test.Queue",
status: "created",
props: undefined as any,
attr: {
name: "MyQueue",
queueUrl: "https://test.queue.com/MyQueue",
},
bindings: [],
downstream: [],
},
});
expect(
yield* makePlan(
Effect.gen(function* () {
yield* Queue("MyQueue");
}),
),
).toMatchObject({
resources: {
MyQueue: {
action: "noop",
bindings: [],
state: {
status: "created",
},
},
},
deletions: expect.toSatisfy(
(d: any) => Object.keys(d).length === 0,
"empty object",
),
});
}),
{ tags: ["unit", "local"] },
);
test(
"no-op resources when object prop key order changes",
Effect.gen(function* () {
yield* seed({
MyFunction: {
instanceId,
providerVersion: 0,
logicalId: "MyFunction",
fqn: "MyFunction",
namespace: undefined,
resourceType: "Test.Function",
status: "created",
props: {
name: "test-function",
env: {
A: "1",
B: "2",
},
},
attr: {
name: "test-function",
env: {
A: "1",
B: "2",
},
functionArn: "arn:test:function:MyFunction",
},
bindings: [],
downstream: [],
},
});
expect(
yield* makePlan(
Effect.gen(function* () {
yield* Function("MyFunction", {
name: "test-function",
env: {
B: "2",
A: "1",
},
});
}),
),
).toMatchObject({
resources: {
MyFunction: {
action: "noop",
bindings: [],
state: {
status: "created",
},
},
},
deletions: expect.toSatisfy(
(d: any) => Object.keys(d).length === 0,
"empty object",
),
});
}),
{ tags: ["unit", "local"] },
);
test(
"plan retained resources as orphaned",
Effect.gen(function* () {
yield* seed({
MyBucket: {
instanceId,
providerVersion: 0,
logicalId: "MyBucket",
fqn: "MyBucket",
namespace: undefined,
resourceType: "Test.Bucket",
status: "created",
props: {
name: "test-bucket",
},
attr: {
name: "test-bucket",
},
bindings: [],
downstream: [],
removalPolicy: "retain",
},
MyQueue: {
instanceId,
providerVersion: 0,
logicalId: "MyQueue",
fqn: "MyQueue",
namespace: undefined,
resourceType: "Test.Queue",
status: "created",
props: {
name: "test-queue",
},
attr: {
name: "test-queue",
},
bindings: [],
downstream: [],
},
});
expect(
yield* makePlan(
Effect.gen(function* () {
yield* Queue("MyQueue", {
name: "test-queue",
});
}),
),
).toMatchObject({
resources: {
MyQueue: {
action: "noop",
bindings: [],
state: {
status: "created",
},
},
},
deletions: {
MyBucket: {
action: "orphaned",
bindings: [],
state: {
status: "created",
attr: {
name: "test-bucket",
},
},
resource: {
LogicalId: "MyBucket",
Type: "Test.Bucket",
Props: {
name: "test-bucket",
},
},
},
},
});
}),
{ tags: ["unit", "local"] },
);
test(
"allow deleting a resource after a surviving consumer removes the dependency",
Effect.gen(function* () {
yield* seed({
Secret: {
instanceId,
providerVersion: 0,
logicalId: "Secret",
fqn: "Secret",
namespace: undefined,
resourceType: "Test.TestResource",
status: "created",
props: {
string: "secret-value",
},
attr: {
string: "secret-value",
stringArray: [],
stableString: "Secret",
stableArray: ["Secret"],
replaceString: undefined,
},
bindings: [],
downstream: ["Worker"],
},
Worker: {
instanceId,
providerVersion: 0,
logicalId: "Worker",
fqn: "Worker",
namespace: undefined,
resourceType: "Test.Function",
status: "created",
props: {
name: "worker",
env: {
SECRET: "secret-value",
},
},
attr: {
name: "worker",
env: {
SECRET: "secret-value",
},
functionArn: "arn:aws:lambda:us-west-2:084828582823:function:Worker",
},
bindings: [],
downstream: [],
},
});
expect(
yield* makePlan(
Effect.gen(function* () {
yield* Function("Worker", {
name: "worker",
});
}),
),
).toMatchObject({
resources: {
Worker: {
action: "update",
props: {
name: "worker",
},
bindings: [],
},
},
deletions: {
Secret: {
action: "delete",
state: {
status: "created",
downstream: ["Worker"],
},
},
},
});
}),
{ tags: ["unit", "local"] },
);
test(
"reject deleting a resource when a surviving consumer still references it",
Effect.gen(function* () {
yield* seed({
Secret: {
instanceId,
providerVersion: 0,
logicalId: "Secret",
fqn: "Secret",
namespace: undefined,
resourceType: "Test.TestResource",
status: "created",
props: {
string: "secret-value",
},
attr: {
string: "secret-value",
stringArray: [],
stableString: "Secret",
stableArray: ["Secret"],
replaceString: undefined,
},
bindings: [],
downstream: ["Worker"],
},
Worker: {
instanceId,
providerVersion: 0,
logicalId: "Worker",
fqn: "Worker",
namespace: undefined,
resourceType: "Test.Function",
status: "created",
props: {
name: "worker",
env: {
SECRET: "secret-value",
},
},
attr: {
name: "worker",
env: {
SECRET: "secret-value",
},
functionArn: "arn:aws:lambda:us-west-2:084828582823:function:Worker",
},
bindings: [],
downstream: [],
},
});
const malformedStack = {
name: TEST_STACK,
stage: TEST_STAGE,
resources: {},
bindings: {},
output: undefined,
};
const exit = yield* Effect.exit(
Effect.gen(function* () {
const secret = yield* TestResource("Secret", {
string: "secret-value",
});
yield* Function("Worker", {
name: "worker",
env: {
SECRET: secret.string,
},
});
const stack = yield* Stack.Stack;
delete stack.resources.Secret;
}).pipe(makePlanWithCustomStack(malformedStack)),
);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
const reason = exit.cause.reasons.find(Cause.isFailReason);
expect(reason).toBeDefined();
expect(reason!.error).toEqual(
new Plan.DeleteResourceHasDownstreamDependencies({
message: "Resource Secret has downstream dependencies",
resourceId: "Secret",
dependencies: ["Worker"],
}),
);
}
}),
{ tags: ["unit", "local"] },
);
// #1831: values a Platform's Init captures reach the provider only through
// `props.env`. `CapturedConfigHost`'s diff always returns `noop`, so these
// actions come from the engine's own comparison of the captured values.
describe("Platform Init-captured config", { tags: ["unit", "local"] }, () => {
const seedHost = (mode: string) =>
seed({
Host: {
instanceId,
providerVersion: 0,
logicalId: "Host",
fqn: "Host",
namespace: undefined,
resourceType: "Test.CapturedConfigHost",
status: "created",
props: {
main: "index.ts",
env: { CAPTURED_MODE: packEnvValue(Redacted.make(mode)) },
},
attr: { mode },
bindings: [],
downstream: [],
},
});
test(
"an unchanged captured value keeps the provider's noop",
Effect.gen(function* () {
yield* seedHost("a");
const plan = yield* capturedConfigHost("a").pipe(makePlan);
expect(plan.resources.Host.action).toBe("noop");
}),
);
test(
"a changed captured value plans an update over the provider's noop",
Effect.gen(function* () {
yield* seedHost("a");
const plan = yield* capturedConfigHost("b").pipe(makePlan);
expect(plan.resources.Host.action).toBe("update");
}),
);
});
describe(
"replace resource when replaceString changes",
{ tags: ["unit", "local"] },
() => {
const stateResources: Record<string, ResourceState> = {
A: {
instanceId,
providerVersion: 0,
logicalId: "A",
fqn: "A",
namespace: undefined,
resourceType: "Test.TestResource",
status: "created",
props: {
replaceString: "A",
},
attr: {},
downstream: [],
bindings: [],
},
};
test(
"noop and replace when replaceString is fully resolved at plan time",
Effect.gen(function* () {
yield* seed(stateResources);
expect(
yield* Effect.gen(function* () {
yield* TestResource("A", {
replaceString: "A",
});
}).pipe(makePlan),
).toMatchObject({
resources: {
A: {
action: "noop",
},
},
deletions: expect.toSatisfy(
(d: any) => Object.keys(d).length === 0,
"empty object",
),
});
expect(
yield* Effect.gen(function* () {
yield* TestResource("A", {
replaceString: "B",
});
}).pipe(makePlan),
).toMatchObject({
resources: {
A: {
action: "replace",
props: {
replaceString: "B",
},
},
},
deletions: expect.toSatisfy(
(d: any) => Object.keys(d).length === 0,
"empty object",
),
});
}),
);
test(
"force preserves replaces",
Effect.gen(function* () {
yield* seed(stateResources);
expect(
yield* Effect.gen(function* () {
yield* TestResource("A", {
replaceString: "B",
});
}).pipe((effect) => makePlan(effect, { force: true })),
).toMatchObject({
resources: {
A: {
action: "replace",
props: {
replaceString: "B",
},
},
},
deletions: expect.toSatisfy(
(d: any) => Object.keys(d).length === 0,
"empty object",
),
});
}),
);
test(
"update when replaceString depends on unresolved output (diff short-circuits)",
Effect.gen(function* () {
yield* seed(stateResources);
let B: TestResource;
expect(
yield* Effect.gen(function* () {
B = yield* TestResource("B", {
string: "A",
});
yield* TestResource("A", {
replaceString: B.string,
});
}).pipe(makePlan),
).toMatchObject({
resources: {
A: {
action: "update",
props: {
replaceString: expect.objectContaining({
kind: "PropExpr",
identifier: "string",
expr: expect.objectContaining({
kind: "ResourceExpr",
src: B!,
}),
}),
},
},
},
deletions: expect.toSatisfy(
(d: any) => Object.keys(d).length === 0,
"empty object",
),
});
}),
);
},
);
test(
"update resource when a binding is added without prop changes",
Effect.gen(function* () {
yield* seed({
A: {
instanceId,
providerVersion: 0,
logicalId: "A",
fqn: "A",
namespace: undefined,
resourceType: "Test.BindingTarget",
status: "created",
props: {
name: "target",
},
attr: {
name: "target",
env: {},
},
bindings: [],
downstream: [],
},
});
expect(
yield* Effect.gen(function* () {
const target = yield* BindingTarget("A", {
name: "target",
});
yield* target.bind("TestBinding", {
env: {
FEATURE_FLAG: "on",
},
});
}).pipe(makePlan),
).toMatchObject({
resources: {
A: {
action: "update",
bindings: [
{
action: "create",
sid: "TestBinding",
data: {
env: {
FEATURE_FLAG: "on",
},
},
},
],
state: {
status: "created",
},
},
},
deletions: expect.toSatisfy(
(d: any) => Object.keys(d).length === 0,
"empty object",
),
});
}),
{ tags: ["unit", "local"] },
);
test(
"update resource when a binding is removed without prop changes",
Effect.gen(function* () {
yield* seed({
A: {
instanceId,
providerVersion: 0,
logicalId: "A",
fqn: "A",
namespace: undefined,
resourceType: "Test.BindingTarget",
status: "created",
props: {
name: "target",
},
attr: {
name: "target",
env: {
FEATURE_FLAG: "on",
},
},
bindings: [
{
sid: "TestBinding",
data: {
env: {
FEATURE_FLAG: "on",
},
},
},
],
downstream: [],
},
});
expect(
yield* Effect.gen(function* () {
yield* BindingTarget("A", {
name: "target",
});
}).pipe(makePlan),
).toMatchObject({
resources: {
A: {
action: "update",
bindings: [
{
action: "delete",
sid: "TestBinding",
data: {
env: {
FEATURE_FLAG: "on",
},
},
},
],
state: {
status: "created",
},
},
},
deletions: expect.toSatisfy(
(d: any) => Object.keys(d).length === 0,
"empty object",
),
});
}),
{ tags: ["unit", "local"] },
);
test.provider(
"binding removals do not keep reappearing after apply",
(scratch) =>
Effect.gen(function* () {
const state = yield* yield* State;
yield* state.set({
stack: scratch.name,
stage: scratch.stage,
fqn: "A",
value: {
instanceId,
providerVersion: 0,
logicalId: "A",
fqn: "A",
namespace: undefined,
resourceType: "Test.BindingTarget",
status: "created",
props: {
name: "target",
},
attr: {
name: "target",
env: {
FEATURE_FLAG: "on",
},
},
bindings: [
{
sid: "TestBinding",
data: {
env: {
FEATURE_FLAG: "on",
},
},
},
],
downstream: [],
},
});
yield* scratch.deploy(
Effect.gen(function* () {
yield* BindingTarget("A", {
name: "target",
});
}),
);
expect(
yield* state.get({
stack: scratch.name,
stage: scratch.stage,
fqn: "A",
}),
).toMatchObject({
bindings: [],
});
expect(
yield* Effect.gen(function* () {
yield* BindingTarget("A", {
name: "target",
});
}).pipe(makePlan),
).toMatchObject({
resources: {
A: {
action: "noop",
bindings: [],
},
},
deletions: expect.toSatisfy(
(d: any) => Object.keys(d).length === 0,
"empty object",
),
});
}),
{ tags: ["unit", "local"] },
);
describe(
"duplicate bindings are collapsed by sid before diff",
{ tags: ["unit", "local"] },
() => {
test(
"dedupeBindings keeps the last occurrence of each sid",
Effect.sync(() => {
const deduped = dedupeBindings([
{ sid: "Shared", data: { env: { K: "first" } } },
{ sid: "Other", data: { env: { K: "x" } } },
{ sid: "Shared", data: { env: { K: "last" } } },
]);
// The duplicated sid takes the last value (matching `diffBindings`'
// `Map`-based collapse) and the result is sid-sorted so binding rows
// are deterministic regardless of registration order.
expect(deduped).toEqual([
{ sid: "Other", data: { env: { K: "x" } } },
{ sid: "Shared", data: { env: { K: "last" } } },
]);
}),
);
test(
"diff observes a single binding when the same sid is bound twice",
Effect.gen(function* () {
yield* seed({
A: {
instanceId,
providerVersion: 0,
logicalId: "A",
fqn: "A",
namespace: undefined,
resourceType: "Test.BindingTarget",
status: "created",
props: {
name: "target",
},
attr: {
name: "target",
env: {},
},
bindings: [],
downstream: [],
},
});
// Capture the exact binding list the provider's `diff` receives.
const observed: ResourceBinding[][] = [];
const plan = yield* Effect.gen(function* () {
const target = yield* BindingTarget("A", {
name: "target",
});
// The same sid is recorded twice — mirrors a single KV namespace
// bound to two consumers that both attach it to the same target,
// which pushes a duplicate into `stack.bindings[fqn]`.
yield* target.bind("Shared", { env: { FEATURE_FLAG: "on" } });
yield* target.bind("Shared", { env: { FEATURE_FLAG: "on" } });
}).pipe(
makePlan,
Effect.provideService(TestResourceHooks, {
diff: (_id, newBindings) =>
Effect.sync(() => {
observed.push(newBindings);
}),
}),
);
// Before the fix, `diff` saw the raw duplicate pair (length 2) while
// `reconcile` saw a deduped list — an inconsistency that made hashing
// unstable. Every diff invocation must now see the collapsed list.
expect(observed.length).toBeGreaterThan(0);
for (const seen of observed) {
expect(seen).toHaveLength(1);
expect(seen[0]).toMatchObject({
sid: "Shared",
data: { env: { FEATURE_FLAG: "on" } },
});
}
// The plan node likewise collapses to a single create binding.
expect(plan.resources.A).toMatchObject({
action: "update",
bindings: [
{
action: "create",
sid: "Shared",
data: { env: { FEATURE_FLAG: "on" } },
},
],
});
}),
);
},
);
describe("construct namespaces", { tags: ["unit", "local"] }, () => {
test(
"namespaced construct bindings resolve into the plan graph",
Effect.gen(function* () {
const Site = (id: string, _props: {}) =>
Effect.gen(function* () {
const bucket = yield* BindingTarget("Bucket", {
name: "bucket",
});
const distribution = yield* BindingTarget("Distribution", {
name: "distribution",
});
yield* bucket.bind("Policy", {
env: {
BUCKET: bucket.string,
DISTRIBUTION: distribution.string,
},
});
return { bucket, distribution };
}).pipe(Namespace.push(id));
const plan = yield* Effect.gen(function* () {
yield* Site("MarketingSite", {});
}).pipe(makePlan);
expect(plan).toMatchObject({
resources: {
"MarketingSite/Bucket": {
action: "create",
bindings: [
{
action: "create",
sid: "Policy",
data: {
env: {
BUCKET: expect.objectContaining({
kind: "PropExpr",
identifier: "string",
expr: expect.objectContaining({
kind: "ResourceExpr",
src: plan.resources["MarketingSite/Bucket"]!.resource,
}),
}),
DISTRIBUTION: expect.objectContaining({
kind: "PropExpr",
identifier: "string",
expr: expect.objectContaining({
kind: "ResourceExpr",
src: plan.resources["MarketingSite/Distribution"]!
.resource,
}),
}),
},
},
},
],
},
"MarketingSite/Distribution": {
action: "create",
bindings: [],
},
},
deletions: expect.toSatisfy(
(d: any) => Object.keys(d).length === 0,
"empty object",
),
});
}),
);
test(
"same child logical ids in different constructs do not collide",
Effect.gen(function* () {
const Site = (id: string, props: { name: string }) =>
Effect.gen(function* () {
return yield* Bucket("Bucket", {
name: props.name,
});
}).pipe(Namespace.push(id));
const plan = yield* Effect.gen(function* () {
yield* Site("MarketingSite", {
name: "marketing-bucket",
});
yield* Site("DocsSite", {
name: "docs-bucket",
});
}).pipe(makePlan);
expect(plan).toMatchObject({
resources: {
"MarketingSite/Bucket": {
action: "create",
props: {
name: "marketing-bucket",
},
},
"DocsSite/Bucket": {
action: "create",
props: {
name: "docs-bucket",
},
},
},
deletions: expect.toSatisfy(
(d: any) => Object.keys(d).length === 0,
"empty object",
),
});
}),
);
test(
"binding-only cycles inside a construct do not become downstream edges",
Effect.gen(function* () {
const Site = (id: string, _props: {}) =>
Effect.gen(function* () {
const A = yield* BindingTarget("A", {
string: "a-value",
});
const B = yield* BindingTarget("B", {
string: "b-value",
});
yield* A.bind("FromB", {
env: {
PEER: B.string,
},
});
yield* B.bind("FromA", {
env: {
PEER: A.string,
},
});
return { A, B };
}).pipe(Namespace.push(id));
const plan = yield* Effect.gen(function* () {
yield* Site("MarketingSite", {});
}).pipe(makePlan);
expect(plan.resources["MarketingSite/A"]?.downstream).toEqual([]);
expect(plan.resources["MarketingSite/B"]?.downstream).toEqual([]);
expect(plan.deletions).toEqual({});
}),
);
});
const createTestResourceState = (options: {
logicalId: string;
status: ResourceStatus;
props: TestResourceProps;
attr?: {};
}) =>
({
instanceId,
providerVersion: 0,
...options,
resourceType: "Test.TestResource",
attr: options.attr ?? {},
downstream: [],
bindings: [],
fqn: options.logicalId,
namespace: undefined,
}) as ResourceState;
const createReplacingState = (options: {
logicalId: string;
props: TestResourceProps;
old: ResourceState;
attr?: {};
}) =>
({
...createTestResourceState({
logicalId: options.logicalId,
status: "replacing",
props: options.props,
attr: options.attr,
}),
old: options.old,
deleteFirst: false,
}) as Extract<ResourceState, { status: "replacing" }>;
const createReplacedState = (options: {
logicalId: string;
props: TestResourceProps;
old: ResourceState;
attr?: {};
}) =>
({
...createTestResourceState({
logicalId: options.logicalId,
status: "replaced",
props: options.props,
attr: options.attr,
}),
old: options.old,
deleteFirst: false,
}) as Extract<ResourceState, { status: "replaced" }>;
const testSimple = (
title: string,
testCase: {
state: {
status: ResourceStatus;
props: TestResourceProps;
attr?: {};
old?: Partial<ResourceState>;
};
props: TestResourceProps;
plan?: any;
fail?: string;
},
) =>
test(
title,
Effect.gen(function* () {
yield* seed({
A: createTestResourceState({
...testCase.state,
logicalId: "A",
}),
});
{
const plan = Effect.gen(function* () {
yield* TestResource("A", testCase.props);
}).pipe(makePlan);
if (testCase.fail) {
const result = plan.pipe(
Effect.map(() => false),
// @ts-expect-error
Effect.catchTag(testCase.fail, () => Effect.succeed(true)),
Effect.catch(() => Effect.succeed(false)),
) as Effect.Effect<boolean>;
if (!result) {
expect.fail(`Expected error '${testCase.fail}`);
}
} else {
expect(yield* plan).toMatchObject({
resources: {
A: testCase.plan,
},
deletions: expect.toSatisfy(
(d: any) => Object.keys(d).length === 0,
"empty object",
),
});
}
}
}),
{ tags: ["unit", "local"] },
);
describe("prior crash in 'creating' state", () => {
testSimple("create if props unchanged", {
state: {
status: "creating",
props: {
string: "A",
},
},
props: {
string: "A",
},
plan: {
action: "create",
props: {
string: "A",
},
},
});
testSimple("create if changed props can be updated", {
state: {
status: "creating",
props: {
string: "A",
},
},
props: {
string: "B",
},
plan: {
action: "create",
props: {
string: "B",
},
},
});
testSimple("replace if changed props cannot be updated", {
state: {
status: "creating",
props: {
replaceString: "A",
},
},
props: {
replaceString: "B",
},
plan: {
action: "replace",
props: {
replaceString: "B",
},
state: {
status: "creating",
props: {
replaceString: "A",
},
},
},
});
});
describe("prior crash in 'updating' state", () => {
testSimple("update if props unchanged", {
state: {
status: "updating",
props: {
string: "A",
},
},
props: {
string: "A",
},
plan: {
action: "update",
props: {
string: "A",
},
state: {
status: "updating",
props: {
string: "A",
},
},
},
});
testSimple("update if changed props can be updated", {
state: {
status: "updating",
props: {
string: "A",
},
},
props: {
string: "B",
},
plan: {
action: "update",
props: {
string: "B",
},
state: {
status: "updating",
props: {
string: "A",
},
},
},
});
testSimple("replace if changed props can not be updated", {
state: {
status: "updating",
props: {
replaceString: "A",
},
},
props: {
replaceString: "B",
},
plan: {
action: "replace",
props: {
replaceString: "B",
},
state: {
status: "updating",
props: {
replaceString: "A",
},
},
},
});
});
describe(
"pending replacement deletion plans",
{ tags: ["unit", "local"] },
() => {
for (const status of ["replacing", "replaced"] as const) {
test(
`preserves the complete ${status} chain and all generation dependencies`,
Effect.gen(function* () {
const oldest: ResourceState = {
status: "created",
fqn: "R",
logicalId: "R",
namespace: undefined,
instanceId: "oldest",
resourceType: "Test.ModalResource",
providerVersion: 0,
props: { value: "one" },
attr: { value: "one", runtime: "live" },
providerMode: "live",
downstream: ["OldDependent"],
bindings: [],
};
const middle: ResourceState = {
...oldest,
status: "replacing",
instanceId: "middle",
props: { value: "two" },
attr: undefined,
providerMode: "local",
downstream: ["MiddleDependent"],
deleteFirst: false,
old: oldest,
};
const pending: ResourceState = {
...oldest,
status,
instanceId: "newest",
props: { value: "three" },
attr: { value: "three", runtime: "live" },
downstream: ["NewDependent"],
deleteFirst: false,
old: middle,
};
yield* seed({ R: pending });
const plan = yield* makePlan(Effect.void);
expect(plan.deletions.R?.action).toBe("delete");
expect(plan.deletions.R?.mode).toBe("live");
expect(plan.deletions.R?.state).toEqual(pending);
expect(plan.deletions.R?.downstream).toEqual([
"NewDependent",
"MiddleDependent",
"OldDependent",
]);
const state = yield* yield* State;
expect(
yield* state.get({
stack: TEST_STACK,
stage: TEST_STAGE,
fqn: "R",
}),
).toEqual(pending);
}),
);
}
},
);
describe("prior crash in 'replacing' state", () => {
const priorStates = ["created", "creating", "updated", "updating"] as const;
const testUnchanged = ({
old,
}: {
old: {
status: ResourceStatus;
};
}) =>
testSimple(
`"continue 'replace' if props are unchanged and previous state is '${old.status}'"`,
{
state: {
status: "replacing",
props: {
string: "A",
},
old,
},
props: {
string: "A",
},
plan: {
action: "replace",
props: {
string: "A",
},
state: {
status: "replacing",
props: {
string: "A",
},
old,
},
},
},
);
priorStates.forEach((status) =>
testUnchanged({
old: {
status,
},
}),
);
const testMinorChange = ({
old,
}: {
old: {
status: ResourceStatus;
};
}) =>
testSimple(
`"continue 'replace' if props can be updated and previous state is '${old.status}'"`,
{
state: {
status: "replacing",
props: {
string: "A",
},
old,
},
props: {
string: "B",
},
plan: {
action: "replace",
props: {
string: "B",
},
state: {
status: "replacing",
props: {
string: "A",
},
old,
},
},
},
);
priorStates.forEach((status) =>
testMinorChange({
old: {
status,
},
}),
);
const testReplacement = (
title: string,
{
old,
plan,
}: {
old: ResourceState;
plan: any;
},
) =>
testSimple(title, {
state: {
status: "replacing",
props: {
replaceString: "A",
},
old,
},
props: {
replaceString: "B",
},
plan,
});
(["replaced", "replacing"] as const).forEach((status) =>
testReplacement(
`continue 'replace' if trying to replace a partially replaced resource in state '${status}'`,
{
old:
status === "replaced"
? createReplacedState({
logicalId: "A_old1",
props: {
replaceString: "A1",
},
old: createTestResourceState({
logicalId: "A_old0",
status: "created",
props: {
replaceString: "A0",
},
}),
})
: createReplacingState({
logicalId: "A_old1",
props: {
replaceString: "A1",
},
old: createTestResourceState({
logicalId: "A_old0",
status: "created",
props: {
replaceString: "A0",
},
}),
}),
plan: {
action: "replace",
props: {
replaceString: "B",
},
state: {
status: "replacing",
props: {
replaceString: "A",
},
old: expect.objectContaining({
status,
props: {
replaceString: "A1",
},
old: expect.objectContaining({
status: "created",
props: {
replaceString: "A0",
},
}),
}),
},
},
},
),
);
});
describe("prior crash in 'replaced' state", () => {
(["replaced", "replacing"] as const).forEach((status) =>
testSimple(
`continue 'replace' if a replaced resource must be replaced again and previous state is '${status}'`,
{
state: {
status: "replaced",
props: {
replaceString: "A1",
},
old:
status === "replaced"
? createReplacedState({
logicalId: "A_old0",
props: {
replaceString: "A0",
},
old: createTestResourceState({
logicalId: "A_old-1",
status: "created",
props: {
replaceString: "A-1",
},
}),
})
: createReplacingState({
logicalId: "A_old0",
props: {
replaceString: "A0",
},
old: createTestResourceState({
logicalId: "A_old-1",
status: "created",
props: {
replaceString: "A-1",
},
}),
}),
},
props: {
replaceString: "B",
},
plan: {
action: "replace",
props: {
replaceString: "B",
},
state: {
status: "replaced",
props: {
replaceString: "A1",
},
old: expect.objectContaining({
status,
props: {
replaceString: "A0",
},
old: expect.objectContaining({
status: "created",
props: {
replaceString: "A-1",
},
}),
}),
},
},
},
),
);
});
describe("prior crash in 'deleting' state", () => {
testSimple(
"create the resource if props are unchanged and the previous state is 'deleting'",
{
state: {
status: "deleting",
props: {
string: "A",
},
},
props: {
string: "A",
},
plan: {
action: "create",
props: {
string: "A",
},
},
},
);
});
test(
"lazy Output queue.queueUrl to Function.env",
Effect.gen(function* () {
let MyQueue: Queue;
let MyFunction: Function;
const plan = yield* Effect.gen(function* () {
MyQueue = yield* Queue("MyQueue");
MyFunction = yield* Function("MyFunction", {
name: "test-function",
env: {
QUEUE_URL: MyQueue.queueUrl,
},
});
}).pipe(makePlan);
expect(plan).toMatchObject({
resources: {
MyFunction: {
action: "create",
bindings: [],
resource: MyFunction!,
props: {
name: "test-function",
env: {
QUEUE_URL: expect.objectContaining({
kind: "PropExpr",
identifier: "queueUrl",
expr: expect.objectContaining({
kind: "ResourceExpr",
src: MyQueue!,
}),
}),
},
},
state: undefined,
},
},
deletions: expect.toSatisfy(
(d: any) => Object.keys(d).length === 0,
"empty object",
),
});
}),
{ tags: ["unit", "local"] },
);
test(
"detect that queueUrl will change and pass through the PropExpr instead of old output",
Effect.gen(function* () {
yield* seed({
MyQueue: {
instanceId,
providerVersion: 0,
logicalId: "MyQueue",
fqn: "MyQueue",
namespace: undefined,
resourceType: "Test.Queue",
status: "created",
props: {
name: "test-queue-old",
},
attr: {
queueUrl: "https://test.queue.com/test-queue-old",
},
downstream: [],
bindings: [],
},
});
let MyQueue: Queue;
let MyFunction: Function;
const plan = yield* Effect.gen(function* () {
MyQueue = yield* Queue("MyQueue");
MyFunction = yield* Function("MyFunction", {
name: "test-function",
env: {
QUEUE_URL: MyQueue.queueUrl,
},
});
}).pipe(makePlan);
expect(plan).toMatchObject({
resources: {
MyFunction: {
action: "create",
bindings: [],
resource: MyFunction!,
props: {
name: "test-function",
env: {
QUEUE_URL: expect.objectContaining({
kind: "PropExpr",
identifier: "queueUrl",
expr: expect.objectContaining({
kind: "ResourceExpr",
src: MyQueue!,
}),
}),
},
},
state: undefined,
},
},
deletions: expect.toSatisfy(
(d: any) => Object.keys(d).length === 0,
"empty object",
),
});
}),
{ tags: ["unit", "local"] },
);
describe(
"Outputs should resolve to old values",
{ tags: ["unit", "local"] },
() => {
const stateResources: Record<string, ResourceState> = {
A: {
instanceId,
providerVersion: 0,
logicalId: "A",
fqn: "A",
namespace: undefined,
resourceType: "Test.TestResource",
status: "created",
props: {
string: "test-string",
stringArray: ["test-string"],
},
attr: {
string: "test-string",
stringArray: ["test-string"],
},
downstream: [],
bindings: [],
},
};
const expected = (props: Input.Resolve<InputProps<TestResourceProps>>) => ({
resources: {
A: {
action: "noop",
bindings: [],
},
B: {
action: "create",
bindings: [],
props: props,
},
},
deletions: expect.toSatisfy(
(d: any) => Object.keys(d).length === 0,
"empty object",
),
});
const subtest = <const I extends InputProps<TestResourceProps>>(
description: string,
input: (resource: TestResource) => I,
attr: Input.Resolve<I>,
) =>
test(
description,
Effect.gen(function* () {
yield* seed(stateResources);
expect(
yield* Effect.gen(function* () {
const A = yield* TestResource("A", {
string: "test-string",
stringArray: ["test-string"],
});
yield* TestResource("B", input(A));
}).pipe(makePlan),
).toMatchObject(expected(attr));
}),
);
subtest(
"string",
(A) => ({
string: A.string,
}),
{
string: "test-string",
},
);
subtest(
"string.apply(string => undefined)",
(A) => ({
string: A.string.pipe(Output.map(() => undefined)),
}),
{
string: undefined,
},
);
subtest(
"string.effect(string => Effect.succeed(undefined))",
(A) => ({
string: A.string.pipe(
Output.mapEffect(() => Effect.succeed(undefined)),
),
}),
{
string: undefined,
},
);
subtest(
"string.flatMap(() => Output.literal(undefined))",
(A) => ({
string: A.string.pipe(Output.flatMap(() => Output.literal(undefined))),
}),
{
string: undefined,
},
);
subtest(
"string.flatMap(string => A.stringArray.map(([first]) => first))",
(A) => ({
string: A.string.pipe(
Output.flatMap(() =>
A.stringArray.pipe(
Output.map((stringArray) => stringArray[0]!.toUpperCase()),
),
),
),
}),
{
string: "TEST-STRING",
},
);
subtest(
"stringArray[0].toUpperCase()",
(A) => ({
string: A.stringArray.pipe(
Output.map((stringArray) => stringArray[0]!.toUpperCase()),
),
}),
{
string: "TEST-STRING",
},
);
subtest(
"resource object",
(A) => ({
object: A as any,
}),
{
object: {
string: "test-string",
},
} as any,
);
},
);
describe(
"raw Resource refs in props are tracked as upstream dependencies",
{ tags: ["unit", "local"] },
() => {
test(
"raw Resource passed directly as a prop value populates the upstream's downstream",
Effect.gen(function* () {
const plan = yield* Effect.gen(function* () {
const A = yield* TestResource("A", { string: "a-value" });
yield* TestResource("B", {
object: A as any,
});
}).pipe(makePlan);
expect(plan.resources.A!.downstream).toEqual(["B"]);
expect(plan.resources.B!.downstream).toEqual([]);
}),
);
test(
"raw Resources nested in arrays/objects are tracked as upstream dependencies",
Effect.gen(function* () {
const plan = yield* Effect.gen(function* () {
const A = yield* TestResource("A", { string: "a-value" });
const B = yield* TestResource("B", { string: "b-value" });
yield* TestResource("C", {
stringArray: [A] as any,
object: { ref: B } as any,
});
}).pipe(makePlan);
expect(plan.resources.A!.downstream).toEqual(["C"]);
expect(plan.resources.B!.downstream).toEqual(["C"]);
expect(plan.resources.C!.downstream).toEqual([]);
}),
);
},
);
describe(
"stable properties should not cause downstream changes",
{ tags: ["unit", "local"] },
() => {
const subtest = (
description: string,
input: (A: TestResource) => InputProps<TestResourceProps>,
) => {
// @ts-expect-error - get the keys
const props = input(Output.of({}));
test(
description,
Effect.gen(function* () {
yield* seed({
A: {
instanceId,
providerVersion: 0,
logicalId: "A",
fqn: "A",
namespace: undefined,
resourceType: "Test.TestResource",
status: "created",
props: {
string: "test-string-old",
},
attr: {
string: "test-string-old",
stableString: "A",
stableArray: ["A"],
},
downstream: [],
bindings: [],
},
B: {
instanceId,
providerVersion: 0,
logicalId: "B",
fqn: "B",
namespace: undefined,
resourceType: "Test.TestResource",
status: "created",
props: Object.fromEntries(
Object.entries({
string: "A",
stringArray: ["A"],
}).filter(([key]) => key in props),
),
attr: {
stableString: "A",
},
downstream: [],
bindings: [],
},
});
expect(
yield* Effect.gen(function* () {
const A = yield* TestResource("A", {
string: "test-string",
});
yield* TestResource("B", input(A));
}).pipe(makePlan),
).toMatchObject({
resources: {
A: {
action: "update",
props: {
string: "test-string",
},
},
B: {
action: "noop",
},
},
deletions: expect.toSatisfy(
(d: any) => Object.keys(d).length === 0,
"empty object",
),
});
}),
);
};
subtest("A.stableString", (A) => ({
string: A.stableString,
}));
subtest("A.stableString.apply((string) => string.toUpperCase())", (A) => ({
string: A.stableString.pipe(Output.map((string) => string.toUpperCase())),
}));
subtest(
"A.stableString.effect((string) => Effect.succeed(string.toUpperCase()))",
(A) => ({
string: A.stableString.pipe(
Output.mapEffect((string) => Effect.succeed(string.toUpperCase())),
),
}),
);
subtest(
"A.stableString.flatMap((string) => Output.literal(string.toUpperCase()))",
(A) => ({
string: A.stableString.pipe(
Output.flatMap((string) => Output.literal(string.toUpperCase())),
),
}),
);
subtest("A.stableArray", (A) => ({
stringArray: A.stableArray,
}));
subtest("A.stableArray[0]", (A) => ({
string: A.stableArray.pipe(Output.map((stableArray) => stableArray[0]!)),
}));
subtest(
"A.stableArray[0].apply((string) => string.toUpperCase())",
(A) => ({
string: A.stableArray.pipe(
Output.map((stableArray) => stableArray[0]!.toUpperCase()),
),
}),
);
subtest(
"A.stableArray[0].effect((string) => Effect.succeed(string.toUpperCase()))",
(A) => ({
string: A.stableArray.pipe(
Output.mapEffect((stableArray) =>
Effect.succeed(stableArray[0]!.toUpperCase()),
),
),
}),
);
},
);
describe(
"whole-resource refs resolve to the upstream's stable attributes",
{ tags: ["unit", "local"] },
() => {
// Regression: when a resource is referenced *whole* (e.g. `object: A`)
// rather than via a single prop (`A.stableString`), and the upstream is
// being updated in place, `resolveResource` returns a `ResourceExpr`
// carrying only the stable attributes. Previously `resolveInput` handed
// that `ResourceExpr` to the downstream verbatim, so its `news` looked
// unresolved (`isResolved(news) === false`) and the stable values never
// reached the downstream `diff`. This forced the Neon `Branch` to manually
// extract `project.projectId` as a workaround. The engine materializes the
// known stable attributes into a plain object for the DIFF-facing `news`
// so the stable values flow into the diff and the downstream can no-op.
//
// The plan node's `props`, however, must keep the reference as an
// evaluable `ResourceExpr`: Apply re-resolves `node.props` against the
// upstream's fresh post-reconcile attributes, and a materialized
// stables-only snapshot would permanently hide every non-stable attribute
// from the downstream's `reconcile` (e.g. a Lambda Alias promoting a
// freshly-published Lambda Version would never see the new version
// number — #993's alias promotion bug).
const seedUpdatingUpstream = () =>
seed({
A: {
instanceId,
providerVersion: 0,
logicalId: "A",
fqn: "A",
namespace: undefined,
resourceType: "Test.TestResource",
status: "created",
props: {
string: "old-value",
},
attr: {
string: "old-value",
stableString: "A",
stableArray: ["A"],
},
downstream: [],
bindings: [],
},
});
test(
"the node's whole-resource ref stays an evaluable Expr carrying the stable attributes",
Effect.gen(function* () {
yield* seedUpdatingUpstream();
let A: TestResource;
const plan = yield* Effect.gen(function* () {
// A is updated in place: `string` changes, but `stableString` /
// `stableArray` are declared stable by its diff.
A = yield* TestResource("A", { string: "new-value" });
// B (created fresh) references the WHOLE upstream resource, not a
// single prop — so its plan node carries the resolved `props`.
yield* TestResource("B", { object: A as any });
}).pipe(makePlan);
expect(plan.resources.A!.action).toBe("update");
const bProps = (plan.resources.B as any).props as TestResourceProps;
// The node's props keep the whole-resource ref as an evaluable
// `ResourceExpr` (so Apply resolves the upstream's FRESH attributes
// after its reconcile), with the stable attributes riding along for
// plan-time consumers.
expect(Output.isExpr(bProps.object)).toBe(true);
expect(Output.isResourceExpr(bProps.object)).toBe(true);
expect(
(bProps.object as any as Output.ResourceExpr<any>).stables,
).toEqual({
stableString: "A",
stableArray: ["A"],
});
}),
);
test(
"a whole-resource ref to an updating upstream does not drag the downstream into an update",
Effect.gen(function* () {
yield* seedUpdatingUpstream();
yield* seed({
B: {
instanceId,
providerVersion: 0,
logicalId: "B",
fqn: "B",
namespace: undefined,
resourceType: "Test.TestResource",
status: "created",
// B's prior props captured the upstream's stable attributes —
// exactly what a materialized whole-resource ref resolves to.
props: {
object: { stableString: "A", stableArray: ["A"] } as any,
},
attr: {
string: "B",
stableString: "B",
stableArray: ["B"],
},
downstream: [],
bindings: [],
},
});
let A: TestResource;
const plan = yield* Effect.gen(function* () {
A = yield* TestResource("A", { string: "new-value" });
yield* TestResource("B", { object: A as any });
}).pipe(makePlan);
expect(plan.resources.A!.action).toBe("update");
// Only stable attributes flow in and they are unchanged, so the
// downstream no-ops instead of being dragged into a needless update.
expect(plan.resources.B!.action).toBe("noop");
}),
);
// The binding path mirrors the props split: `diffBindings` compares the
// materialized (stables-only) view, but the node's binding rows carry the
// apply-faithful payload so `Output.evaluate(node.bindings, outputs)`
// re-resolves the upstream's fresh post-reconcile attributes.
const seedHostWithFullPayload = () =>
seed({
Host: {
instanceId,
providerVersion: 0,
logicalId: "Host",
fqn: "Host",
namespace: undefined,
resourceType: "Test.BindingTarget",
status: "created",
props: { name: "host" },
attr: {
name: "host",
string: "Host",
env: {},
replaceString: undefined,
},
downstream: [],
// Terminal commits persist the payload the provider reconciled
// with — the upstream's FULL attributes (#874), not the plan-time
// stables-only projection.
bindings: [
{
sid: "FromA",
data: {
env: {
A: {
string: "old-value",
stableString: "A",
stableArray: ["A"],
},
},
},
},
],
},
});
const hostProgram = (upstreamString: string) =>
Effect.gen(function* () {
const A = yield* TestResource("A", { string: upstreamString });
const host = yield* BindingTarget("Host", { name: "host" });
// The binding data embeds the WHOLE upstream resource.
yield* host.bind("FromA", { env: { A } } as any);
});
test(
"the node's binding payload keeps the whole-resource ref as an evaluable Expr carrying the stable attributes",
Effect.gen(function* () {
yield* seedUpdatingUpstream();
const plan = yield* hostProgram("new-value").pipe(makePlan);
expect(plan.resources.A!.action).toBe("update");
const rows = (plan.resources.Host as any).bindings;
expect(rows).toHaveLength(1);
expect(rows[0].sid).toBe("FromA");
const payload = rows[0].data.env.A;
expect(Output.isResourceExpr(payload)).toBe(true);
expect((payload as Output.ResourceExpr<any>).stables).toEqual({
stableString: "A",
stableArray: ["A"],
});
}),
);
test(
"an updating upstream marks the binding row 'update' from the materialized comparison while the payload stays evaluable",
Effect.gen(function* () {
yield* seedUpdatingUpstream();
yield* seedHostWithFullPayload();
const plan = yield* hostProgram("new-value").pipe(makePlan);
expect(plan.resources.A!.action).toBe("update");
// The host's own props are unchanged; the binding drift alone drags
// it into the update that re-delivers A's fresh attributes.
expect(plan.resources.Host!.action).toBe("update");
const rows = (plan.resources.Host as any).bindings;
expect(rows).toHaveLength(1);
// Action from the materialized comparison (persisted full attrs vs
// stables-only projection)...
expect(rows[0].action).toBe("update");
// ...payload from the apply-faithful resolution.
expect(Output.isResourceExpr(rows[0].data.env.A)).toBe(true);
}),
);
test(
"an unchanged upstream's full persisted binding payload no-ops instead of churning",
Effect.gen(function* () {
yield* seedUpdatingUpstream();
yield* seedHostWithFullPayload();
// Same props as seeded — A no-ops, so it resolves to its full
// persisted attrs and the materialized binding payload matches the
// persisted row exactly.
const plan = yield* hostProgram("old-value").pipe(makePlan);
expect(plan.resources.A!.action).toBe("noop");
expect(plan.resources.Host!.action).toBe("noop");
const rows = (plan.resources.Host as any).bindings;
expect(rows[0].action).toBe("noop");
// Nothing left to re-evaluate — the payload is the plain full attrs.
expect(Output.isExpr(rows[0].data.env.A)).toBe(false);
expect(rows[0].data.env.A.string).toBe("old-value");
}),
);
},
);
describe(
"diff.stables overrides provider.stables",
{ tags: ["unit", "local"] },
() => {
// `A` is an OverrideStablesResource: provider `stables` is
// ["providerStable", "sharedStable"], but its `diff` returns
// ["diffStable", "sharedStable"] on a `string` change. The two lists
// disagree, so this exercises the override (not merge) semantics.
const seedUpstreamAndDownstream = (downstreamOldString: string) =>
seed({
A: {
instanceId,
providerVersion: 0,
logicalId: "A",
fqn: "A",
namespace: undefined,
resourceType: "Test.OverrideStablesResource",
status: "created",
props: { string: "old" },
attr: {
string: "old",
providerStable: "provider-A",
diffStable: "diff-A",
sharedStable: "shared-A",
},
downstream: [],
bindings: [],
},
B: {
instanceId,
providerVersion: 0,
logicalId: "B",
fqn: "B",
namespace: undefined,
resourceType: "Test.TestResource",
status: "created",
props: { string: downstreamOldString },
attr: {
string: downstreamOldString,
stableString: "B",
stableArray: ["B"],
},
downstream: [],
bindings: [],
},
});
const subtest = (
description: string,
accessor: (A: OverrideStablesResource) => any,
downstreamOldString: string,
expectedBAction: "update" | "noop",
) =>
test(
description,
Effect.gen(function* () {
yield* seedUpstreamAndDownstream(downstreamOldString);
const plan = yield* Effect.gen(function* () {
const A = yield* OverrideStablesResource("A", { string: "new" });
yield* TestResource("B", { string: accessor(A) });
}).pipe(makePlan);
// A always updates: its `string` prop changed.
expect(plan.resources.A!.action).toBe("update");
expect(plan.resources.B!.action).toBe(expectedBAction);
}),
);
// `providerStable` is in `provider.stables` but OMITTED from the
// `diff.stables` returned for this update. Because `diff.stables` now
// overrides `provider.stables`, it is treated as changed and the
// downstream re-plans (update). Under the old merge it would wrongly
// stay stable and the downstream would no-op.
subtest(
"provider-only stable omitted by diff is treated as changed downstream",
(A) => A.providerStable,
"provider-A",
"update",
);
// `diffStable` is only in `diff.stables` -> stays stable -> downstream no-op.
subtest(
"diff-only stable keeps downstream stable",
(A) => A.diffStable,
"diff-A",
"noop",
);
// `sharedStable` is in both lists -> stays stable -> downstream no-op.
subtest(
"shared stable keeps downstream stable",
(A) => A.sharedStable,
"shared-A",
"noop",
);
},
);
describe("unsatisfied cycle detection", { tags: ["unit", "local"] }, () => {
const extractCycleDefect = <A, E>(
exit: Exit.Exit<A, E>,
): UnsatisfiedResourceCycle | undefined => {
if (!Exit.isFailure(exit)) return undefined;
const die = exit.cause.reasons.find(Cause.isDieReason);
return die?.defect as UnsatisfiedResourceCycle | undefined;
};
test(
"binding cycle between resources without precreate dies",
Effect.gen(function* () {
const exit = yield* makePlan(
Effect.gen(function* () {
const A = yield* NoPrecreateBindingTarget("A", {
string: "a-value",
});
const B = yield* NoPrecreateBindingTarget("B", {
string: "b-value",
});
yield* A.bind("FromB", { env: { PEER: B.string } });
yield* B.bind("FromA", { env: { PEER: A.string } });
return { A, B };
}),
).pipe(Effect.exit);
const err = extractCycleDefect(exit);
expect(err).toBeDefined();
expect(err!._tag).toBe("UnsatisfiedResourceCycle");
expect(err!.cycle.sort()).toEqual(["A", "B"]);
expect(err!.missingPrecreate.sort()).toEqual(["A", "B"]);
}),
);
test(
"binding cycle with all precreate resources succeeds",
Effect.gen(function* () {
const exit = yield* makePlan(
Effect.gen(function* () {
const A = yield* BindingTarget("A", { string: "a-value" });
const B = yield* BindingTarget("B", { string: "b-value" });
yield* A.bind("FromB", {
env: { PEER: B.string },
});
yield* B.bind("FromA", {
env: { PEER: A.string },
});
return { A, B };
}),
).pipe(Effect.exit);
expect(Exit.isSuccess(exit)).toBe(true);
}),
);
test(
"mixed cycle succeeds when precreate resource breaks it",
Effect.gen(function* () {
const exit = yield* makePlan(
Effect.gen(function* () {
const A = yield* BindingTarget("A", { string: "a-value" });
const B = yield* NoPrecreateBindingTarget("B", {
string: A.string,
});
yield* A.bind("FromB", {
env: { PEER: B.string },
});
return { A, B };
}),
).pipe(Effect.exit);
expect(Exit.isSuccess(exit)).toBe(true);
}),
);
test(
"three-node binding cycle dies when none have precreate",
Effect.gen(function* () {
const exit = yield* makePlan(
Effect.gen(function* () {
const A = yield* NoPrecreateBindingTarget("A", { string: "a" });
const B = yield* NoPrecreateBindingTarget("B", { string: "b" });
const C = yield* NoPrecreateBindingTarget("C", { string: "c" });
yield* A.bind("FromC", { env: { PEER: C.string } });
yield* B.bind("FromA", { env: { PEER: A.string } });
yield* C.bind("FromB", { env: { PEER: B.string } });
return { A, B, C };
}),
).pipe(Effect.exit);
const err = extractCycleDefect(exit);
expect(err).toBeDefined();
expect(err!._tag).toBe("UnsatisfiedResourceCycle");
expect(err!.cycle.sort()).toEqual(["A", "B", "C"]);
expect(err!.missingPrecreate.sort()).toEqual(["A", "B", "C"]);
}),
);
test(
"acyclic binding graph succeeds even without precreate",
Effect.gen(function* () {
const exit = yield* makePlan(
Effect.gen(function* () {
const A = yield* NoPrecreateBindingTarget("A", {
string: "a-value",
});
const B = yield* NoPrecreateBindingTarget("B", {
string: A.string,
});
yield* B.bind("FromA", { env: { PEER: A.string } });
return { A, B };
}),
).pipe(Effect.exit);
expect(Exit.isSuccess(exit)).toBe(true);
}),
);
});
describe(
"unresolved plan inputs in diff should conservatively update",
{ tags: ["unit", "local"] },
() => {
test(
"update when upstream resource is new and downstream news contains exprs",
Effect.gen(function* () {
yield* seed({
B: {
instanceId,
providerVersion: 0,
logicalId: "B",
fqn: "B",
namespace: undefined,
resourceType: "Test.TestResource",
status: "created",
props: {
string: "old-value",
},
attr: {
string: "old-value",
stableString: "B",
stableArray: ["B"],
},
downstream: [],
bindings: [],
},
});
const plan = yield* Effect.gen(function* () {
const A = yield* TestResource("A", {
string: "hello",
});
yield* TestResource("B", {
string: A.string,
});
}).pipe(makePlan);
expect(plan.resources.A.action).toBe("create");
expect(plan.resources.B.action).toBe("update");
}),
);
},
);
describe(
"Config props are resolved through plan",
{ tags: ["unit", "local"] },
() => {
test(
"a Config prop is resolved to its concrete value in the plan",
Effect.gen(function* () {
const plan = yield* Effect.gen(function* () {
yield* TestResource("A", {
string: Config.succeed("resolved-config-value") as any,
});
}).pipe(makePlan);
const node: any = plan.resources.A!;
expect(node.action).toBe("create");
const props = node.props as TestResourceProps;
expect(Config.isConfig(props.string)).toBe(false);
expect(props.string).toBe("resolved-config-value");
}),
);
test(
"a Config resolving to a Redacted keeps it wrapped in the plan",
Effect.gen(function* () {
const plan = yield* Effect.gen(function* () {
yield* TestResource("A", {
string: "x",
redacted: Config.succeed(Redacted.make("hunter2")) as any,
});
}).pipe(makePlan);
const node: any = plan.resources.A!;
expect(node.action).toBe("create");
const props = node.props as TestResourceProps;
expect(Redacted.isRedacted(props.redacted)).toBe(true);
expect(Redacted.value(props.redacted!)).toBe("hunter2");
}),
);
test(
"a Config nested inside an object prop is resolved in the plan",
Effect.gen(function* () {
const plan = yield* Effect.gen(function* () {
yield* TestResource("A", {
object: { string: Config.succeed("nested") as any },
});
}).pipe(makePlan);
const node: any = plan.resources.A!;
expect(node.action).toBe("create");
const props = node.props as TestResourceProps;
expect(props.object).toEqual({ string: "nested" });
}),
);
},
);
describe(
"Redacted props/outputs are preserved through plan",
{ tags: ["unit", "local"] },
() => {
test(
"Redacted prop on a new resource is preserved as a Redacted in the plan",
Effect.gen(function* () {
const plan = yield* Effect.gen(function* () {
yield* TestResource("A", {
string: "x",
redacted: Redacted.make("hunter2"),
});
}).pipe(makePlan);
const node: any = plan.resources.A!;
expect(node.action).toBe("create");
const props = node.props as TestResourceProps;
expect(Redacted.isRedacted(props.redacted)).toBe(true);
expect(Redacted.value(props.redacted!)).toBe("hunter2");
}),
);
test(
"Redacted prop nested inside an array is preserved through the plan",
Effect.gen(function* () {
const plan = yield* Effect.gen(function* () {
yield* TestResource("A", {
string: "x",
redactedArray: [Redacted.make("a"), Redacted.make("b")],
});
}).pipe(makePlan);
const node: any = plan.resources.A!;
expect(node.action).toBe("create");
const props = node.props as TestResourceProps;
expect(props.redactedArray).toBeDefined();
expect(props.redactedArray!.length).toBe(2);
expect(Redacted.isRedacted(props.redactedArray![0]!)).toBe(true);
expect(Redacted.isRedacted(props.redactedArray![1]!)).toBe(true);
expect(Redacted.value(props.redactedArray![0]!)).toBe("a");
expect(Redacted.value(props.redactedArray![1]!)).toBe("b");
}),
);
test(
"no-op when prior state has the same Redacted value",
Effect.gen(function* () {
yield* seed({
A: {
instanceId,
providerVersion: 0,
logicalId: "A",
fqn: "A",
namespace: undefined,
resourceType: "Test.TestResource",
status: "created",
props: {
string: "x",
redacted: Redacted.make("hunter2"),
},
attr: {
string: "x",
stringArray: [],
stableString: "A",
stableArray: ["A"],
replaceString: undefined,
redacted: Redacted.make("hunter2"),
redactedArray: undefined,
},
downstream: [],
bindings: [],
},
});
const plan = yield* Effect.gen(function* () {
yield* TestResource("A", {
string: "x",
redacted: Redacted.make("hunter2"),
});
}).pipe(makePlan);
expect(plan.resources.A!.action).toBe("noop");
}),
);
test(
"update when Redacted prop value changes",
Effect.gen(function* () {
yield* seed({
A: {
instanceId,
providerVersion: 0,
logicalId: "A",
fqn: "A",
namespace: undefined,
resourceType: "Test.TestResource",
status: "created",
props: {
string: "x",
redacted: Redacted.make("old"),
},
attr: {
string: "x",
stringArray: [],
stableString: "A",
stableArray: ["A"],
replaceString: undefined,
redacted: Redacted.make("old"),
redactedArray: undefined,
},
downstream: [],
bindings: [],
},
});
const plan = yield* Effect.gen(function* () {
yield* TestResource("A", {
string: "x",
redacted: Redacted.make("new"),
});
}).pipe(makePlan);
expect(plan.resources.A!.action).toBe("update");
const node: any = plan.resources.A!;
const props = node.props as TestResourceProps;
expect(Redacted.isRedacted(props.redacted)).toBe(true);
expect(Redacted.value(props.redacted!)).toBe("new");
}),
);
test(
"Redacted output flowing into a downstream resource preserves its redaction",
Effect.gen(function* () {
yield* seed({
A: {
instanceId,
providerVersion: 0,
logicalId: "A",
fqn: "A",
namespace: undefined,
resourceType: "Test.TestResource",
status: "created",
props: {
string: "x",
redacted: Redacted.make("hunter2"),
},
attr: {
string: "x",
stringArray: [],
stableString: "A",
stableArray: ["A"],
replaceString: undefined,
redacted: Redacted.make("hunter2"),
redactedArray: undefined,
},
downstream: [],
bindings: [],
},
});
const plan = yield* Effect.gen(function* () {
const A = yield* TestResource("A", {
string: "x",
redacted: Redacted.make("hunter2"),
});
yield* TestResource("B", {
string: "y",
redacted: A.redacted as any,
});
}).pipe(makePlan);
const bNode: any = plan.resources.B!;
const bProps = bNode.props as TestResourceProps;
expect(Redacted.isRedacted(bProps.redacted)).toBe(true);
expect(Redacted.value(bProps.redacted!)).toBe("hunter2");
}),
);
},
);
describe("engine-level adoption", { tags: ["unit", "local"] }, () => {
// Build a plan, optionally with an explicit AdoptPolicy and a read hook
// that simulates a pre-existing cloud resource.
const ownedAttrs: TestResource["Attributes"] = {
string: "hello",
stringArray: [],
stableString: "Adopted",
stableArray: ["Adopted"],
replaceString: undefined,
redacted: undefined,
redactedArray: undefined,
};
const makeAdoptPlan = <A>(
effect: Effect.Effect<A, any, any>,
opts: {
adopt?: boolean;
readHook?: (
id: string,
) => Effect.Effect<TestResource["Attributes"] | undefined, any>;
},
): Effect.Effect<Plan.Plan<A>, any, State> =>
Effect.gen(function* () {
const { name, stage } = yield* resolveStackId;
const hooksLayer = opts.readHook
? Layer.succeed(TestResourceHooks, { read: opts.readHook })
: Layer.empty;
const adoptLayer =
opts.adopt === undefined
? Layer.empty
: Layer.succeed(AdoptPolicy, opts.adopt);
return yield* (effect as Effect.Effect<A, any, any>).pipe(
Stack.make({
name,
providers: Layer.empty,
state: inMemoryState(),
} as any) as any,
Effect.provideService(Stage, stage),
Effect.flatMap((stackSpec: any) => Plan.make(stackSpec)),
Effect.provide(TestLayers()),
Effect.provide(hooksLayer),
Effect.provide(adoptLayer),
) as Effect.Effect<Plan.Plan<A>, any, State>;
}) as Effect.Effect<Plan.Plan<A>, any, State>;
const creatingWithoutAttrs = {
instanceId,
providerVersion: 0,
logicalId: "Recovering",
fqn: "Recovering",
namespace: undefined,
resourceType: "Test.TestResource",
status: "creating" as const,
props: { string: "hello" },
attr: undefined,
downstream: [],
bindings: [],
} satisfies ResourceState;
test.provider("cold adoption reconciles with olds undefined", (scratch) =>
Effect.gen(function* () {
let creates = 0;
let updates = 0;
const hooks = {
read: () => Effect.succeed(ownedAttrs),
create: () =>
Effect.sync(() => {
creates++;
}),
update: () =>
Effect.sync(() => {
updates++;
}),
};
yield* scratch
.deploy(
Effect.gen(function* () {
yield* TestResource("Adopted", { string: "hello" });
}),
)
.pipe(Effect.provideService(TestResourceHooks, hooks));
expect(creates).toBe(1);
expect(updates).toBe(0);
const state = yield* yield* State;
expect(
yield* state.get({
stack: scratch.name,
stage: scratch.stage,
fqn: "Adopted",
}),
).toMatchObject({
status: "updated",
props: { string: "hello" },
});
}),
);
test.provider(
"cold adoption keeps olds undefined after a failed first reconcile",
(scratch) =>
Effect.gen(function* () {
let creates = 0;
let updates = 0;
const hooks = {
read: () => Effect.succeed(ownedAttrs),
create: () =>
Effect.suspend(() => {
creates++;
return creates === 1
? Effect.fail(new Error("first adoption reconcile failed"))
: Effect.void;
}),
update: () =>
Effect.sync(() => {
updates++;
}),
};
const program = () =>
Effect.gen(function* () {
yield* TestResource("Adopted", { string: "hello" });
});
const first = yield* scratch
.deploy(program())
.pipe(Effect.provideService(TestResourceHooks, hooks), Effect.exit);
expect(Exit.isFailure(first)).toBe(true);
expect(creates).toBe(1);
expect(updates).toBe(0);
const state = yield* yield* State;
expect(
yield* state.get({
stack: scratch.name,
stage: scratch.stage,
fqn: "Adopted",
}),
).toMatchObject({
status: "updating",
adopting: true,
});
yield* scratch
.deploy(program())
.pipe(Effect.provideService(TestResourceHooks, hooks));
expect(creates).toBe(2);
expect(updates).toBe(0);
const completed = yield* state.get({
stack: scratch.name,
stage: scratch.stage,
fqn: "Adopted",
});
expect(completed).toMatchObject({ status: "updated" });
expect((completed as any)?.adopting).toBeUndefined();
}),
);
test(
"recovered Unowned attrs require explicit adoption",
Effect.gen(function* () {
yield* seed({ Recovering: creatingWithoutAttrs });
const exit = yield* makeAdoptPlan(
Effect.gen(function* () {
yield* TestResource("Recovering", { string: "hello" });
}),
{
adopt: false,
readHook: () => Effect.succeed(Unowned(ownedAttrs)),
},
).pipe(Effect.exit);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
const reason = exit.cause.reasons.find(Cause.isFailReason);
expect((reason?.error as any)?._tag).toBe("OwnedBySomeoneElse");
expect((reason?.error as any)?.resourceType).toBe("Test.TestResource");
}
}),
);
test(
"explicit adoption strips Unowned from recovered attrs",
Effect.gen(function* () {
yield* seed({ Recovering: creatingWithoutAttrs });
const plan = yield* makeAdoptPlan(
Effect.gen(function* () {
yield* TestResource("Recovering", { string: "hello" });
}),
{
adopt: true,
readHook: () => Effect.succeed(Unowned(ownedAttrs)),
},
);
const recovered = plan.resources.Recovering!;
expect(recovered.action).toBe("create");
expect(Unowned.is((recovered.state as any).attr)).toBe(false);
expect(
Object.getOwnPropertySymbols((recovered.state as any).attr),
).toEqual([]);
}),
);
test(
"recovered attrs still diff immutable desired changes",
Effect.gen(function* () {
yield* seed({
Recovering: {
...creatingWithoutAttrs,
props: { replaceString: "old" },
},
});
const plan = yield* makeAdoptPlan(
Effect.gen(function* () {
yield* TestResource("Recovering", { replaceString: "new" });
}),
{
readHook: () =>
Effect.succeed({
...ownedAttrs,
replaceString: "old",
}),
},
);
expect(plan.resources.Recovering).toMatchObject({
action: "replace",
props: { replaceString: "new" },
state: {
status: "creating",
attr: { replaceString: "old" },
},
});
}),
);
test(
"owned read result is silently adopted (no AdoptPolicy needed) and forced to update",
Effect.gen(function* () {
const plan = yield* makeAdoptPlan(
Effect.gen(function* () {
yield* TestResource("Adopted", { string: "hello" });
}),
{ readHook: () => Effect.succeed(ownedAttrs) },
);
// Cold-start adoption is surfaced explicitly while the provider re-syncs
// tags / config against `news` — even when read returns plain
// (owned) attrs, the cloud resource may carry drift the engine
// can't detect from `props` alone.
expect(plan.resources.Adopted!.action).toBe("adopted");
expect(plan.resources.Adopted).toMatchObject({
adopting: true,
state: {
status: "created",
attr: { string: "hello" },
},
});
// Planning no longer persists the adopted state (issue #793): it rides
// on the plan node and is only committed to the store at apply time.
const node = plan.resources.Adopted!;
expect(node.state?.status).toBe("created");
expect((node.state as any)?.attr).toMatchObject({ string: "hello" });
const state = yield* yield* State;
expect(
yield* state.get({
stack: TEST_STACK,
stage: TEST_STAGE,
fqn: "Adopted",
}),
).toBeUndefined();
}),
);
test(
"Unowned read result + adopt enabled -> takeover forces an update",
Effect.gen(function* () {
const plan = yield* makeAdoptPlan(
Effect.gen(function* () {
yield* TestResource("Adopted", { string: "hello" });
}),
{
adopt: true,
readHook: () => Effect.succeed(Unowned(ownedAttrs)),
},
);
// Takeover of an Unowned resource is planned as `adopted` so the provider's
// update path can rewrite ownership tags / config to match this
// logical id (a plain noop would leave the resource looking
// foreign-owned to subsequent deploys).
expect(plan.resources.Adopted!.action).toBe("adopted");
expect(plan.resources.Adopted).toMatchObject({
adopting: true,
state: { status: "created" },
});
// The adopted state rides on the plan node, not the store (issue #793).
const node = plan.resources.Adopted!;
expect(node.state?.status).toBe("created");
// The Unowned brand must be fully scrubbed from anything that
// reaches the plan node (and, at apply, the state store) — both via
// the public `Unowned.is` check *and* via direct symbol inspection
// (in case someone accidentally uses `Symbol.for` rather than
// `Unowned.is`).
const adoptedAttr = (node.state as any)?.attr as object;
expect(Unowned.is(adoptedAttr)).toBe(false);
expect(Object.getOwnPropertySymbols(adoptedAttr).length).toBe(0);
expect(JSON.stringify(adoptedAttr)).not.toContain("Unowned");
// Planning wrote nothing to the store.
const state = yield* yield* State;
expect(
yield* state.get({
stack: TEST_STACK,
stage: TEST_STAGE,
fqn: "Adopted",
}),
).toBeUndefined();
}),
);
test(
"Unowned read result + adopt disabled -> OwnedBySomeoneElse",
Effect.gen(function* () {
const exit = yield* makeAdoptPlan(
Effect.gen(function* () {
yield* TestResource("Foreign", { string: "hello" });
}),
{
adopt: false,
readHook: () => Effect.succeed(Unowned(ownedAttrs)),
},
).pipe(Effect.exit);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
const reason = exit.cause.reasons.find(Cause.isFailReason);
expect((reason?.error as any)?._tag).toBe("OwnedBySomeoneElse");
expect((reason?.error as any)?.resourceType).toBe("Test.TestResource");
}
}),
);
test(
"read returns undefined -> ordinary create",
Effect.gen(function* () {
const plan = yield* makeAdoptPlan(
Effect.gen(function* () {
yield* TestResource("Fresh", { string: "hello" });
}),
{ readHook: () => Effect.succeed(undefined) },
);
expect(plan.resources.Fresh!.action).toBe("create");
expect(plan.resources.Fresh!.state).toBeUndefined();
}),
);
test(
"Unowned read result + resource-scoped adopt(true) -> takeover even when the stack default is disabled",
Effect.gen(function* () {
const plan = yield* makeAdoptPlan(
Effect.gen(function* () {
yield* TestResource("Adopted", { string: "hello" }).pipe(adopt(true));
}),
{
// Stack/CLI default is OFF — only the per-resource scope opts in.
adopt: false,
readHook: () => Effect.succeed(Unowned(ownedAttrs)),
},
);
expect(plan.resources.Adopted!.action).toBe("adopted");
expect(plan.resources.Adopted).toMatchObject({
adopting: true,
state: { status: "created" },
});
// Adopted state rides on the plan node; planning persists nothing
// (issue #793).
const node = plan.resources.Adopted!;
expect(node.state?.status).toBe("created");
const state = yield* yield* State;
expect(
yield* state.get({
stack: TEST_STACK,
stage: TEST_STAGE,
fqn: "Adopted",
}),
).toBeUndefined();
}),
);
test(
"Unowned read result + resource-scoped adopt(false) -> OwnedBySomeoneElse even when the stack default is enabled",
Effect.gen(function* () {
const exit = yield* makeAdoptPlan(
Effect.gen(function* () {
yield* TestResource("Foreign", { string: "hello" }).pipe(
adopt(false),
);
}),
{
// Stack/CLI default is ON, but the resource opts out.
adopt: true,
readHook: () => Effect.succeed(Unowned(ownedAttrs)),
},
).pipe(Effect.exit);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
const reason = exit.cause.reasons.find(Cause.isFailReason);
expect((reason?.error as any)?._tag).toBe("OwnedBySomeoneElse");
expect((reason?.error as any)?.resourceType).toBe("Test.TestResource");
}
}),
);
test(
"providers without a `read` method skip the adoption probe entirely",
Effect.gen(function* () {
// Bucket has no `read` implementation. The engine should fall back
// to a normal `create` action without any side effects.
const plan = yield* makeAdoptPlan(
Effect.gen(function* () {
yield* Bucket("FreshBucket", { name: "fresh" });
}),
{ adopt: true },
);
expect(plan.resources.FreshBucket!.action).toBe("create");
}),
);
});
describe("RefExpr resolution", { tags: ["unit", "local"] }, () => {
const seedAt = (
stack: string,
stage: string,
resources: Record<string, ResourceState>,
) =>
Effect.gen(function* () {
const state = yield* yield* State;
for (const [fqn, value] of Object.entries(resources)) {
yield* state.set({ stack, stage, fqn, value });
}
});
const sharedAttr = {
string: "shared-string",
stringArray: ["shared"],
stableString: "shared-stable",
stableArray: ["shared-stable"],
replaceString: undefined,
redacted: undefined,
redactedArray: undefined,
};
const sharedResourceState = {
instanceId,
providerVersion: 0,
logicalId: "Shared",
fqn: "Shared",
namespace: undefined,
resourceType: "Test.TestResource",
status: "created" as ResourceStatus,
props: { string: "shared-string" },
attr: sharedAttr,
bindings: [],
downstream: [],
} as ResourceState;
test(
"resolves a cross-stage Ref to the seeded resource's attributes",
Effect.gen(function* () {
yield* seedAt(TEST_STACK, "other", { Shared: sharedResourceState });
const plan = yield* Effect.gen(function* () {
const shared = yield* TestResource.ref("Shared", { stage: "other" });
yield* TestResource("Consumer", { string: shared.string });
}).pipe(makePlan);
expect(plan.resources.Consumer?.action).toBe("create");
expect((plan.resources.Consumer as any)?.props).toMatchObject({
string: "shared-string",
});
}),
);
test(
"resolves a cross-stack Ref using the explicit stack option",
Effect.gen(function* () {
yield* seedAt("other-stack", TEST_STAGE, {
Shared: sharedResourceState,
});
const plan = yield* Effect.gen(function* () {
const shared = yield* TestResource.ref("Shared", {
stack: "other-stack",
});
yield* TestResource("Consumer", {
string: shared.string,
});
}).pipe(makePlan);
expect((plan.resources.Consumer as any)?.props).toMatchObject({
string: "shared-string",
});
}),
);
test(
"Ref to a resource in the current stack/stage is resolved",
Effect.gen(function* () {
yield* seed({ Shared: sharedResourceState });
const plan = yield* Effect.gen(function* () {
const shared = yield* TestResource.ref("Shared");
yield* TestResource("Consumer", { string: shared.string });
}).pipe(makePlan);
expect((plan.resources.Consumer as any)?.props).toMatchObject({
string: "shared-string",
});
}),
);
test(
"missing Ref target dies with InvalidReferenceError",
Effect.gen(function* () {
const exit = yield* Effect.exit(
Effect.gen(function* () {
const shared = yield* TestResource.ref("Ghost", { stage: "other" });
yield* TestResource("Consumer", { string: shared.string });
}).pipe(makePlan),
);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
const err = Cause.squash(exit.cause) as Output.InvalidReferenceError;
expect(err._tag).toBe("InvalidReferenceError");
expect(err.resourceId).toBe("Ghost");
expect(err.stage).toBe("other");
}
}),
);
});
describe("StackRefExpr resolution", { tags: ["unit", "local"] }, () => {
const setStackOutput = (stack: string, stage: string, value: unknown) =>
Effect.gen(function* () {
const state = yield* yield* State;
yield* state.setOutput({ stack, stage, value });
});
test(
"resolves an Output.stackRef to the persisted stack output",
Effect.gen(function* () {
yield* setStackOutput("Backend", TEST_STAGE, {
url: "https://api.example.com",
});
const plan = yield* Effect.gen(function* () {
const backend = yield* Output.stackRef<{ url: string }>("Backend");
yield* TestResource("Consumer", {
string: (backend as any).url,
});
}).pipe(makePlan);
expect(plan.resources.Consumer?.action).toBe("create");
expect((plan.resources.Consumer as any)?.props).toMatchObject({
string: "https://api.example.com",
});
}),
);
test(
"resolves an explicit stage on the stackRef",
Effect.gen(function* () {
yield* setStackOutput("Backend", "prod", {
url: "https://prod.example.com",
});
const plan = yield* Effect.gen(function* () {
const backend = yield* Output.stackRef<{ url: string }>("Backend", {
stage: "prod",
});
yield* TestResource("Consumer", {
string: (backend as any).url,
});
}).pipe(makePlan);
expect((plan.resources.Consumer as any)?.props).toMatchObject({
string: "https://prod.example.com",
});
}),
);
test(
"missing stack output dies with InvalidReferenceError",
Effect.gen(function* () {
const exit = yield* Effect.exit(
Effect.gen(function* () {
const backend = yield* Output.stackRef<{ url: string }>("Backend", {
stage: "ghost",
});
yield* TestResource("Consumer", {
string: (backend as any).url,
});
}).pipe(makePlan),
);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
const err = Cause.squash(exit.cause) as Output.InvalidReferenceError;
expect(err._tag).toBe("InvalidReferenceError");
expect(err.stack).toBe("Backend");
expect(err.stage).toBe("ghost");
}
}),
);
});
describe("type aliases", { tags: ["unit", "local"] }, () => {
// State rows persisted before a type rename carry the legacy name
// ("Test.Widget"). Provider lookup must fall back to the canonical type
// ("Test.Widgets.Widget") via the alias declared on the resource.
const legacyWidgetState = (fqn: string): ResourceState => ({
instanceId,
providerVersion: 0,
logicalId: fqn,
fqn,
namespace: undefined,
resourceType: "Test.Widget",
status: "created",
props: {
name: "widget",
},
attr: {
name: "widget",
},
bindings: [],
downstream: [],
});
test(
"orphan persisted under a legacy type name plans a delete via alias",
Effect.gen(function* () {
yield* seed({ LegacyOrphan: legacyWidgetState("LegacyOrphan") });
expect(
yield* makePlan(Effect.void).pipe(
Effect.provide(aliasedWidgetProvider()),
),
).toMatchObject({
deletions: {
LegacyOrphan: {
action: "delete",
resource: {
LogicalId: "LegacyOrphan",
Type: "Test.Widget",
},
},
},
});
}),
);
test(
"declared resource with legacy-typed state plans as a noop update",
Effect.gen(function* () {
yield* seed({ MyWidget: legacyWidgetState("MyWidget") });
const plan = yield* makePlan(
Effect.gen(function* () {
yield* AliasedWidget("MyWidget", { name: "widget" });
}),
).pipe(Effect.provide(aliasedWidgetProvider()));
expect(plan).toMatchObject({
resources: {
MyWidget: {
action: "noop",
state: {
resourceType: "Test.Widget",
},
},
},
});
expect(Object.keys(plan.deletions)).toEqual([]);
}),
);
describe("via provider collection", () => {
class AliasPlanProviders extends Provider.ProviderCollection<AliasPlanProviders>()(
"Test.AliasPlanProviders",
) {}
// The bare provider layer is consumed while building the collection and
// is NOT exported — lookup can only succeed through the collection.
const widgetCollection = () =>
Layer.effect(
AliasPlanProviders,
Provider.collection([AliasedWidget]),
).pipe(Layer.provide(aliasedWidgetProvider()));
test(
"orphan persisted under a legacy type name plans a delete via alias",
Effect.gen(function* () {
yield* seed({ LegacyOrphan: legacyWidgetState("LegacyOrphan") });
expect(
yield* makePlan(Effect.void).pipe(Effect.provide(widgetCollection())),
).toMatchObject({
deletions: {
LegacyOrphan: {
action: "delete",
resource: {
LogicalId: "LegacyOrphan",
Type: "Test.Widget",
},
},
},
});
}),
);
});
});
describe("zombie rows", { tags: ["unit", "local"] }, () => {
// A state row whose resource type has no registered provider (the type
// was removed from the program, or renamed without an alias) is FATAL:
// the program and state disagree, and without the provider the row's
// physical resource cannot be deleted anyway. Planning dies with a typed
// MissingProviderError naming the row and the remediation (see
// destroy-robustness.test.ts for the deploy/destroy behavior).
test(
"a row whose resource type has no provider fails the plan",
Effect.gen(function* () {
yield* seed({
Ghost: {
instanceId,
providerVersion: 0,
logicalId: "Ghost",
fqn: "Ghost",
namespace: undefined,
resourceType: "Test.Vanished",
status: "created",
props: { name: "ghost" },
attr: { name: "ghost" },
bindings: [],
downstream: [],
},
});
const exit = yield* makePlan(
Effect.gen(function* () {
yield* Bucket("Survivor", { name: "survivor" });
}),
).pipe(Effect.exit);
expect(Exit.isFailure(exit)).toBe(true);
const defect = Exit.isFailure(exit)
? exit.cause.reasons.find(
(r) =>
Cause.isDieReason(r) &&
r.defect instanceof Provider.MissingProviderError,
)
: undefined;
expect(defect && Cause.isDieReason(defect)).toBe(true);
if (defect && Cause.isDieReason(defect)) {
const error = defect.defect as Provider.MissingProviderError;
expect(error.resourceType).toBe("Test.Vanished");
expect(error.fqn).toBe("Ghost");
expect(error.message).toContain("aliases");
}
}),
);
});
describe(
"read is never handed unresolved persisted props",
{ tags: ["unit", "local"] },
() => {
// A failed create persists `creating` state carrying the RAW plan-time
// props, which may contain unresolved Output expressions (e.g. a prop
// referencing an upstream resource that was never created). Providers
// derive identity from `olds` inside `read` when `output` is undefined,
// so the engine must skip the read probe entirely rather than hand it
// unresolved exprs (see the isResolved guards in Plan.ts).
const creatingWithUnresolvedProps = (fqn: string): ResourceState => ({
instanceId,
providerVersion: 0,
logicalId: fqn,
fqn,
namespace: undefined,
resourceType: "Test.TestResource",
status: "creating",
props: {
// an unresolved Output expression, exactly as persisted by a create
// that failed before its upstream dependencies resolved
string: Output.literal("unresolved") as any,
},
attr: undefined,
bindings: [],
downstream: [],
});
const trackReads = () => {
const reads: string[] = [];
const layer = Layer.succeed(TestResourceHooks, {
read: (id: string) =>
Effect.sync(() => {
reads.push(id);
return undefined;
}),
});
return { reads, layer };
};
test(
"destroy after failed create with unresolved props skips read and deletes with attr undefined",
Effect.gen(function* () {
yield* seed({ Zombie: creatingWithUnresolvedProps("Zombie") });
const { reads, layer } = trackReads();
const plan = yield* makePlan(Effect.void).pipe(Effect.provide(layer));
expect(reads).not.toContain("Zombie");
expect(plan.deletions.Zombie).toMatchObject({ action: "delete" });
expect((plan.deletions.Zombie as any).state.attr).toBeUndefined();
}),
);
test(
"creating-state recovery with unresolved persisted props skips the read probe and re-drives create",
Effect.gen(function* () {
yield* seed({ Half: creatingWithUnresolvedProps("Half") });
const { reads, layer } = trackReads();
const plan = yield* makePlan(
Effect.gen(function* () {
yield* TestResource("Half", { string: "resolved-now" });
}),
).pipe(Effect.provide(layer));
expect(reads).not.toContain("Half");
expect(plan.resources.Half!.action).toBe("create");
}),
);
test(
"destroy of a creating row defers read recovery to apply even with resolved props",
Effect.gen(function* () {
// Plan never probes a deleted attr-less row — resolved or not. Apply's
// `deleteResource` owns the authoritative read-then-delete recovery
// (it also covers replaced-chain old generations that never pass
// through plan); see the apply.test.ts destroy-recovery cases.
yield* seed({
Zombie: {
...creatingWithUnresolvedProps("Zombie"),
props: { string: "resolved" },
},
});
const { reads, layer } = trackReads();
const plan = yield* makePlan(Effect.void).pipe(Effect.provide(layer));
expect(reads).not.toContain("Zombie");
expect(plan.deletions.Zombie).toMatchObject({ action: "delete" });
expect((plan.deletions.Zombie as any).state.attr).toBeUndefined();
}),
);
test(
"a recovery read that crashes degrades to re-driving the create instead of killing the plan",
Effect.gen(function* () {
// Stripped-at-commit props: an unresolved Output persisted as a hole
// still passes `isResolved`, so the read probe DOES run — and a
// provider that dereferences the hole crashes with a defect (e.g. a
// SchemaError deep in its SDK client, see #995). The plan must
// contain the defect to this resource's probe and fall through to
// re-driving the create.
yield* seed({
Half: {
...creatingWithUnresolvedProps("Half"),
props: { string: undefined } as any,
},
});
const layer = Layer.succeed(TestResourceHooks, {
read: () =>
Effect.die(
new Error("SchemaError: Expected string, got undefined"),
),
});
const plan = yield* makePlan(
Effect.gen(function* () {
yield* TestResource("Half", { string: "resolved-now" });
}),
).pipe(Effect.provide(layer));
expect(plan.resources.Half!.action).toBe("create");
}),
);
test(
"resolved persisted creating props still go through read recovery when re-declared (control)",
Effect.gen(function* () {
yield* seed({
Half: {
...creatingWithUnresolvedProps("Half"),
props: { string: "resolved" },
},
});
const { reads, layer } = trackReads();
const plan = yield* makePlan(
Effect.gen(function* () {
yield* TestResource("Half", { string: "resolved-now" });
}),
).pipe(Effect.provide(layer));
expect(reads).toContain("Half");
expect(plan.resources.Half!.action).toBe("create");
}),
);
},
);
describe("provider modes (local ⇄ live)", { tags: ["unit", "local"] }, () => {
// ModalResource registers via `ProviderLayer.dual` with distinct live and
// local implementations. These tests cover the PLAN-level semantics:
// - the resolved mode lands on the plan node (`node.mode`)
// - a persisted mode different from the resolved mode forces a
// REPLACEMENT, overriding whatever the provider diff would say
// - legacy rows (no persisted mode) are assumed live, unless their
// attrs carry the `dev:` identity marker (then local)
// - deletions carry the persisted mode so orphans are torn down by the
// provider that created them
// - a mode-switching upstream invalidates its attrs for downstream diffs
const modalState = (
fqn: string,
overrides?: Partial<ResourceState>,
): ResourceState =>
({
instanceId,
providerVersion: 0,
logicalId: fqn,
fqn,
namespace: undefined,
resourceType: "Test.ModalResource",
status: "created",
props: { value: "v1" },
attr: { value: "v1", runtime: "local" },
downstream: [],
bindings: [],
providerMode: "local",
...overrides,
}) as ResourceState;
test(
"a fresh create resolves the run default (live) onto the node",
Effect.gen(function* () {
const plan = yield* makePlan(ModalResource("A", { value: "v1" }));
expect(plan.resources.A).toMatchObject({
action: "create",
mode: "live",
});
}),
);
test(
"a dev run resolves the local mode onto the node",
Effect.gen(function* () {
const plan = yield* inDev(makePlan(ModalResource("A", { value: "v1" })));
expect(plan.resources.A).toMatchObject({
action: "create",
mode: "local",
});
}),
);
test(
"remote() opts a resource out of local emulation during dev",
Effect.gen(function* () {
const plan = yield* inDev(
makePlan(ModalResource("A", { value: "v1" }).pipe(remote())),
);
expect(plan.resources.A).toMatchObject({
action: "create",
mode: "live",
});
}),
);
test(
"mode-agnostic resources plan with mode undefined even in a dev run",
Effect.gen(function* () {
// A single-implementation provider (no dual registration) satisfies
// any requested mode — constructs that mix emulatable and live-only
// resources just work in dev.
const plan = yield* inDev(
makePlan(
Effect.gen(function* () {
yield* ModalResource("A", { value: "v1" });
yield* TestResource("T", { string: "x" });
}),
),
);
expect(plan.resources.A!.mode).toBe("local");
expect(plan.resources.T!.mode).toBeUndefined();
}),
);
test(
"a persisted mode different from the resolved mode forces a replacement",
Effect.gen(function* () {
yield* seed({ A: modalState("A") }); // providerMode: "local"
// Identical props — the provider diff would report `noop` — but the
// row was reconciled by the LOCAL provider and this run resolves to
// LIVE, so the plan must replace (create-first).
const plan = yield* makePlan(ModalResource("A", { value: "v1" }));
expect(plan.resources.A).toMatchObject({
action: "replace",
deleteFirst: false,
mode: "live",
});
}),
);
test(
"the same mode plans normally (noop on identical props)",
Effect.gen(function* () {
yield* seed({ A: modalState("A") }); // providerMode: "local"
const plan = yield* inDev(makePlan(ModalResource("A", { value: "v1" })));
expect(plan.resources.A).toMatchObject({ action: "noop" });
}),
);
test(
"switching live → local replaces too",
Effect.gen(function* () {
yield* seed({ A: modalState("A", { providerMode: "live" }) });
const plan = yield* inDev(makePlan(ModalResource("A", { value: "v1" })));
expect(plan.resources.A).toMatchObject({
action: "replace",
mode: "local",
});
}),
);
test(
"a mode switch overrides the provider diff (update would have sufficed)",
Effect.gen(function* () {
yield* seed({ A: modalState("A") }); // providerMode: "local"
// Changed value — same-mode planning would produce `update` — but the
// mode switch escalates to `replace` without consulting the diff.
const plan = yield* makePlan(ModalResource("A", { value: "v2" }));
expect(plan.resources.A).toMatchObject({
action: "replace",
mode: "live",
});
}),
);
test(
"legacy rows without a persisted mode are assumed live",
Effect.gen(function* () {
yield* seed({ A: modalState("A", { providerMode: undefined }) });
// An unstamped row was written by a pre-provider-mode engine (or by
// a provider that only became dual later) — its physical resource is
// LIVE. A deploy (live) run sees no churn; a dev run replaces it
// exactly like a stamped live row. Assuming the run's mode instead
// would silently adopt the deployed live resource as a local
// instance and leak it untracked.
const liveDefault = yield* makePlan(ModalResource("A", { value: "v1" }));
expect(liveDefault.resources.A).toMatchObject({ action: "noop" });
const devRun = yield* inDev(
makePlan(ModalResource("A", { value: "v1" })),
);
expect(devRun.resources.A).toMatchObject({
action: "replace",
mode: "local",
});
}),
);
test(
"a mode-agnostic resource never replaces across dev/deploy runs",
Effect.gen(function* () {
yield* seed({
T: {
instanceId,
providerVersion: 0,
logicalId: "T",
fqn: "T",
namespace: undefined,
resourceType: "Test.TestResource",
status: "created",
props: { string: "x" },
attr: {
string: "x",
stringArray: [],
stableString: "T",
stableArray: ["T"],
},
downstream: [],
bindings: [],
// Mode-agnostic providers never stamp a mode.
providerMode: undefined,
},
});
const plan = yield* inDev(makePlan(TestResource("T", { string: "x" })));
expect(plan.resources.T).toMatchObject({ action: "noop" });
}),
);
test(
"an interrupted create still replaces on a mode switch",
Effect.gen(function* () {
// A `creating` row (attr-less, interrupted) normally re-drives its
// create via the read-recovery branch. When the mode switched, that
// branch is skipped — the other runtime's half-created instance must
// be replaced, not resumed.
yield* seed({
A: modalState("A", {
status: "creating",
attr: undefined,
} as Partial<ResourceState>),
});
const plan = yield* makePlan(ModalResource("A", { value: "v1" }));
expect(plan.resources.A).toMatchObject({
action: "replace",
mode: "live",
});
}),
);
test(
"an interrupted update still replaces on a mode switch",
Effect.gen(function* () {
yield* seed({
A: modalState("A", {
status: "updating",
props: { value: "v2" },
old: {
props: { value: "v1" },
bindings: [],
attr: { value: "v1", runtime: "local" },
},
} as Partial<ResourceState>),
});
// Same-mode planning would resume the interrupted update; a mode
// switch must escalate to a replacement of the other runtime's
// instance instead.
const plan = yield* makePlan(ModalResource("A", { value: "v2" }));
expect(plan.resources.A).toMatchObject({
action: "replace",
mode: "live",
});
}),
);
test(
"an in-flight replacement generation hit by a mode switch restarts a new generation",
Effect.gen(function* () {
// `replacing`: the (local) replacement candidate is still being
// created. A mode switch makes that candidate itself obsolete — the
// plan must mint a NEW outer generation (`restart: true`) rather than
// resuming the local candidate under the live provider.
yield* seed({
A: modalState("A", {
status: "replacing",
attr: undefined,
deleteFirst: false,
old: modalState("A", {
instanceId: "00000000000000000000000000000000",
}),
} as Partial<ResourceState>),
});
const plan = yield* makePlan(ModalResource("A", { value: "v1" }));
expect(plan.resources.A).toMatchObject({
action: "replace",
restart: true,
mode: "live",
});
}),
);
test(
"a completed-but-undrained replacement hit by a mode switch restarts; same mode just drains",
Effect.gen(function* () {
const replaced = (providerMode: "local" | "live") =>
modalState("A", {
status: "replaced",
deleteFirst: false,
providerMode,
old: modalState("A", {
instanceId: "00000000000000000000000000000000",
}),
} as Partial<ResourceState>);
// The live replacement of a LOCAL row completed but its old chain
// hasn't drained. Re-planning in the same (live) mode continues the
// existing generation (no restart — GC just finishes).
yield* seed({ A: replaced("live") });
const sameMode = yield* makePlan(ModalResource("A", { value: "v1" }));
expect(sameMode.resources.A).toMatchObject({ action: "replace" });
expect((sameMode.resources.A as any).restart).toBeUndefined();
// But if the completed replacement was LOCAL and this run resolves
// live, the current "new" generation is itself obsolete — restart.
yield* seed({ A: replaced("local") });
const switched = yield* makePlan(ModalResource("A", { value: "v1" }));
expect(switched.resources.A).toMatchObject({
action: "replace",
restart: true,
mode: "live",
});
}),
);
test(
"orphan deletions carry the persisted mode",
Effect.gen(function* () {
yield* seed({
LocalOrphan: modalState("LocalOrphan"), // providerMode: "local"
LegacyOrphan: modalState("LegacyOrphan", {
providerMode: undefined,
}),
});
const plan = yield* makePlan(Effect.void);
// The Delete node records the mode its provider was resolved for —
// Apply deletes the local orphan with the LOCAL provider even though
// this is a live-default run.
expect(plan.deletions.LocalOrphan).toMatchObject({
action: "delete",
mode: "local",
});
expect(plan.deletions.LegacyOrphan).toMatchObject({ action: "delete" });
expect(plan.deletions.LegacyOrphan!.mode).toBeUndefined();
}),
);
test(
"a mode-switching upstream invalidates its attrs for downstream diffs",
Effect.gen(function* () {
yield* seed({
A: modalState("A", { downstream: ["B"] }), // providerMode: "local"
B: {
instanceId,
providerVersion: 0,
logicalId: "B",
fqn: "B",
namespace: undefined,
resourceType: "Test.TestResource",
status: "created",
props: { string: "v1" },
attr: {
string: "v1",
stringArray: [],
stableString: "B",
stableArray: ["B"],
},
downstream: [],
bindings: [],
},
});
const program = Effect.gen(function* () {
const a = yield* ModalResource("A", { value: "v1" });
yield* TestResource("B", { string: a.value });
});
// Mode switch (local row, deploy plan): A is being replaced, so its
// attrs are NOT stable — B must observe an unresolved upstream and
// plan an update rather than nooping against stale values.
const switched = yield* makePlan(program);
expect(switched.resources.A).toMatchObject({ action: "replace" });
expect(switched.resources.B).toMatchObject({ action: "update" });
// Control — same mode (dev run): A noops, its persisted attrs
// resolve, B noops.
const same = yield* inDev(makePlan(program));
expect(same.resources.A).toMatchObject({ action: "noop" });
expect(same.resources.B).toMatchObject({ action: "noop" });
}),
);
});
describe(
"binding client data-plane routing (plan)",
{ tags: ["unit", "local"] },
() => {
// Binding.Service wraps deploy-time clients so they hit the plane the
// bound resource actually lives on. In a `dev` run ambient is the
// emulator; `Alchemy.remote()` must still wrap with the live layer (the
// inverse of the local wrap). Plan-time `yield* client()` is the same
// routing `Service.execute` uses.
test(
"in a dev run, a local resource's binding client hits the emulator plane",
Effect.gen(function* () {
const plan = yield* inDev(
makePlan(
Effect.gen(function* () {
const a = yield* ModalResource("A", { value: "v1" });
const read = yield* ProbeBinding(a);
return yield* read();
}),
),
);
expect(plan.output).toBe("local");
}),
);
test(
"in a dev run, a remote() resource's binding client hits the live plane",
Effect.gen(function* () {
const plan = yield* inDev(
makePlan(
Effect.gen(function* () {
const a = yield* ModalResource("A", { value: "v1" }).pipe(
remote(),
);
const read = yield* ProbeBinding(a);
return yield* read();
}),
),
);
expect(plan.output).toBe("live");
}),
);
test(
"a live-mode run wraps remote/live clients with the live plane (not ambient)",
Effect.gen(function* () {
const plan = yield* makePlan(
Effect.gen(function* () {
const a = yield* ModalResource("A", { value: "v1" });
const read = yield* ProbeBinding(a);
return yield* read();
}),
);
expect(plan.output).toBe("live");
}),
);
test(
"a binding spanning local and remote() resources dies",
Effect.gen(function* () {
const exit = yield* inDev(
makePlan(
Effect.gen(function* () {
const local = yield* ModalResource("A", { value: "v1" });
const live = yield* ModalResource("B", { value: "v1" }).pipe(
remote(),
);
const read = yield* ProbeBinding([local, live]);
return yield* read();
}),
),
).pipe(Effect.exit);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
expect(String(Cause.squash(exit.cause))).toContain(
"mixed data planes",
);
}
}),
);
},
);
// Upstream dependency detection must find a Resource/Output reference at ANY
// nesting depth of plain data — objects in arrays, arrays in objects, and
// arbitrary mixes (#1082 hardened the walkers with a plain-data gate + cycle
// guards; these pin that no nesting shape lost its dependency edge). Each
// case plans `A` (upstream) and `B` whose props embed a reference to `A` in a
// different shape, then asserts the A→B edge exists in the plan DAG.
describe(
"upstream detection across nesting shapes",
{ tags: ["unit", "local"] },
() => {
// Each shape gets the raw resource and an attr Output to embed.
const shapes: [name: string, props: (a: any) => Record<string, any>][] = [
["raw resource at top level", (a) => ({ ref: a })],
["attr output at top level", (a) => ({ name: a.name })],
["raw resource in object", (a) => ({ obj: { ref: a } })],
["attr output in object", (a) => ({ obj: { name: a.name } })],
[
"deeply nested object (4 levels)",
(a) => ({ l1: { l2: { l3: { l4: { name: a.name } } } } }),
],
["raw resource in array", (a) => ({ arr: [a] })],
["attr output in array", (a) => ({ arr: [a.name] })],
[
"output among primitives in array",
(a) => ({ arr: [1, "x", a.name, null, true] }),
],
["array in object in array", (a) => ({ arr: [{ inner: [a.name] }] })],
["object in array in object", (a) => ({ obj: { list: [{ ref: a }] } })],
[
"arrays in objects in arrays in objects",
(a) => ({
layers: [
{ config: { hosts: [{ url: a.name }, { url: "static" }] } },
{ config: { hosts: [] } },
],
}),
],
[
"mixed: raw resource and output at different depths",
(a) => ({
top: a,
nested: { deep: [{ deeper: { name: a.name } }] },
}),
],
[
"nested empty containers alongside the ref",
(a) => ({
empties: [{}, [], { x: [] }],
ref: { arr: [[a.name]] },
}),
],
["array of arrays", (a) => ({ matrix: [[a.name]] })],
];
for (const [name, props] of shapes) {
test(
`finds the dependency: ${name}`,
Effect.gen(function* () {
const plan = yield* Effect.gen(function* () {
const a = yield* Bucket("A", { name: "nest-a" });
yield* TestResource("B", props(a) as any);
}).pipe(makePlan);
expect(plan.resources.A!.action).toBe("create");
expect(plan.resources.B!.action).toBe("create");
// The dependency edge A -> B must exist regardless of nesting shape.
expect(plan.resources.A!.downstream).toContain("B");
expect(plan.resources.B!.downstream).not.toContain("A");
}),
);
}
test(
"a reference inside a foreign class instance is NOT a dependency",
Effect.gen(function* () {
class SdkConfig {
constructor(readonly ref: any) {}
}
const plan = yield* Effect.gen(function* () {
const a = yield* Bucket("A", { name: "nest-a" });
yield* TestResource("B", { config: new SdkConfig(a.name) } as any);
}).pipe(makePlan);
expect(plan.resources.A!.downstream).not.toContain("B");
}),
);
test(
"cyclic plain objects in props do not hang planning",
Effect.gen(function* () {
const cyclic: any = { name: "cycle" };
cyclic.self = cyclic;
const plan = yield* Effect.gen(function* () {
const a = yield* Bucket("A", { name: "nest-a" });
yield* TestResource("B", { config: cyclic, ref: a.name } as any);
}).pipe(makePlan);
// The cycle is tolerated AND the sibling dependency is still found.
expect(plan.resources.A!.downstream).toContain("B");
}),
);
},
);
describe("renamed resources (renamedFrom)", { tags: ["unit", "local"] }, () => {
const bucketRow = (
fqn: string,
rowInstanceId: string = instanceId,
): ResourceState => ({
instanceId: rowInstanceId,
providerVersion: 0,
logicalId: parseFqnLogicalId(fqn),
fqn,
namespace: undefined,
resourceType: "Test.Bucket",
status: "created",
props: { name: "b" },
attr: { name: "b", bucketArn: `arn:test:bucket:${fqn}` },
bindings: [],
downstream: [],
});
const parseFqnLogicalId = (fqn: string) => fqn.split("/").pop()!;
test(
"a row at a former FQN plans as an update at the new FQN, never a create+delete",
Effect.gen(function* () {
yield* seed({ OldBucket: bucketRow("OldBucket") });
const plan = yield* Effect.gen(function* () {
yield* Bucket("NewBucket", { name: "b" }).pipe(
renamedFrom("OldBucket"),
);
return {};
}).pipe(makePlan);
const node = plan.resources.NewBucket!;
// An update, not a noop: the physical resource's tags are still
// branded with the OLD logical id, so a reconcile must run to
// re-brand them under the new identity. Never a create.
expect(node.action).toEqual("update");
expect(node.renamedFrom).toEqual(["OldBucket"]);
// The row rides on the node under its NEW identity (apply persists
// the move before any lifecycle runs).
expect(node.state?.fqn).toEqual("NewBucket");
expect(node.state?.logicalId).toEqual("NewBucket");
expect(node.state?.instanceId).toEqual(instanceId);
// The former row is NOT an orphan.
expect(Object.keys(plan.deletions)).toHaveLength(0);
}),
);
test(
"the alias is ignored when the new FQN already has a row with a different instanceId",
Effect.gen(function* () {
yield* seed({
OldBucket: bucketRow("OldBucket", "0ld00000000000000000000000000000"),
NewBucket: bucketRow("NewBucket"),
});
const plan = yield* Effect.gen(function* () {
yield* Bucket("NewBucket", { name: "b" }).pipe(
renamedFrom("OldBucket"),
);
return {};
}).pipe(makePlan);
// The declared resource plans from its OWN row...
const node = plan.resources.NewBucket!;
expect(node.action).toEqual("noop");
expect(node.renamedFrom).toBeUndefined();
expect(node.state?.instanceId).toEqual(instanceId);
// ...and the former row is a distinct resource: a normal orphan.
expect(plan.deletions.OldBucket?.action).toEqual("delete");
}),
);
test(
"rows at both FQNs with the same instanceId are an in-flight migration, not an orphan",
Effect.gen(function* () {
// Simulates a crash between apply's `state.set` (new FQN) and
// `state.delete` (former FQN).
yield* seed({
OldBucket: bucketRow("OldBucket"),
NewBucket: bucketRow("NewBucket"),
});
const plan = yield* Effect.gen(function* () {
yield* Bucket("NewBucket", { name: "b" }).pipe(
renamedFrom("OldBucket"),
);
return {};
}).pipe(makePlan);
// The node plans from the new row and marks the leftover for
// state-only cleanup at apply; no delete of the physical resource.
const node = plan.resources.NewBucket!;
expect(node.action).toEqual("noop");
expect(node.renamedFrom).toEqual(["OldBucket"]);
expect(Object.keys(plan.deletions)).toHaveLength(0);
}),
);
test(
"the old id can be reused by a new resource in the same deploy",
Effect.gen(function* () {
yield* seed({ OldBucket: bucketRow("OldBucket") });
const plan = yield* Effect.gen(function* () {
// A brand-new resource reuses the old id...
yield* Bucket("OldBucket", { name: "fresh" });
// ...while the original resource (which owns the row) renames.
yield* Bucket("NewBucket", { name: "b" }).pipe(
renamedFrom("OldBucket"),
);
return {};
}).pipe(makePlan);
// The rename claim wins the row: `NewBucket` migrates it...
const renamed = plan.resources.NewBucket!;
expect(renamed.action).toEqual("update");
expect(renamed.renamedFrom).toEqual(["OldBucket"]);
expect(renamed.state?.instanceId).toEqual(instanceId);
// ...and the reusing resource starts from scratch — it must NOT
// inherit the migrated resource's row (or physical resource).
const reuser = plan.resources.OldBucket!;
expect(reuser.action).toEqual("create");
expect(reuser.state).toBeUndefined();
expect(Object.keys(plan.deletions)).toHaveLength(0);
}),
);
test(
"former ids resolve against the ambient namespace (StaticSite's <id>/Worker → <id>)",
Effect.gen(function* () {
// The pre-rename shape: a `Worker` resource declared under the
// `App/Site` namespace chain.
yield* seed({
"App/Site/Worker": {
...bucketRow("App/Site/Worker"),
namespace: { Id: "Site", Parent: { Id: "App" } },
},
});
const plan = yield* Effect.gen(function* () {
// The post-rename shape: the resource is `Site` itself, declared
// inside the same ambient namespace and claiming its former
// namespace-RELATIVE id — `renamedFrom("Site/Worker")` resolves to
// `App/Site/Worker` under `Namespace.push("App")`.
yield* Bucket("Site", { name: "b" }).pipe(
renamedFrom("Site/Worker"),
Namespace.push("App"),
);
return {};
}).pipe(makePlan);
const node = plan.resources["App/Site"]!;
expect(node.action).toEqual("update");
expect(node.renamedFrom).toEqual(["App/Site/Worker"]);
expect(node.state?.fqn).toEqual("App/Site");
expect(node.state?.namespace).toEqual({ Id: "App" });
expect(Object.keys(plan.deletions)).toHaveLength(0);
}),
);
test(
"the absolute { fqn } form claims a former FQN across namespaces",
Effect.gen(function* () {
// The resource used to live at the ROOT of the stack; it moved into
// a namespace. A relative former id cannot express that (it would
// resolve inside the new namespace), so the absolute form is used.
yield* seed({ Thing: bucketRow("Thing") });
const plan = yield* Effect.gen(function* () {
yield* Bucket("Thing", { name: "b" }).pipe(
renamedFrom({ fqn: "Thing" }),
Namespace.push("New"),
);
return {};
}).pipe(makePlan);
const node = plan.resources["New/Thing"]!;
expect(node.action).toEqual("update");
expect(node.renamedFrom).toEqual(["Thing"]);
expect(node.state?.fqn).toEqual("New/Thing");
expect(node.state?.instanceId).toEqual(instanceId);
expect(Object.keys(plan.deletions)).toHaveLength(0);
}),
);
test(
"a rename chain with several same-instanceId leftovers is cleaned in one plan",
Effect.gen(function* () {
// A → B → C rename history with repeated partial failures: rows
// linger at BOTH former FQNs, all copies of the same row (migration
// preserves the instanceId).
yield* seed({
OldA: bucketRow("OldA"),
OldB: bucketRow("OldB"),
});
const plan = yield* Effect.gen(function* () {
// Most recent former id first.
yield* Bucket("NewBucket", { name: "b" }).pipe(
renamedFrom("OldA", "OldB"),
);
return {};
}).pipe(makePlan);
const node = plan.resources.NewBucket!;
expect(node.action).toEqual("update");
// The migration source AND the same-instance leftover are both
// collected — one apply drops them all.
expect(node.renamedFrom).toEqual(["OldA", "OldB"]);
expect(node.state?.instanceId).toEqual(instanceId);
expect(Object.keys(plan.deletions)).toHaveLength(0);
}),
);
test(
"a foreign row at a later former FQN is orphan-deleted, not adopted",
Effect.gen(function* () {
// `OldA` is the real predecessor; `OldB` is someone else's row
// (different instanceId) that happens to sit at an older former FQN.
yield* seed({
OldA: bucketRow("OldA"),
OldB: bucketRow("OldB", "f0re1gn0000000000000000000000000"),
});
const plan = yield* Effect.gen(function* () {
yield* Bucket("NewBucket", { name: "b" }).pipe(
renamedFrom("OldA", "OldB"),
);
return {};
}).pipe(makePlan);
const node = plan.resources.NewBucket!;
expect(node.action).toEqual("update");
expect(node.renamedFrom).toEqual(["OldA"]);
expect(node.state?.instanceId).toEqual(instanceId);
// The foreign row is a normal orphan — deleted in the SAME plan (it
// never enters the migrated set, so the in-memory migration doesn't
// shield it).
expect(plan.deletions.OldB?.action).toEqual("delete");
expect(plan.deletions.OldA).toBeUndefined();
}),
);
test(
"a former row with a different resourceType is never migrated",
Effect.gen(function* () {
// The row at the former FQN was written by a DIFFERENT resource type
// — it cannot be this resource's row, whatever its FQN says.
yield* seed({
OldBucket: {
...bucketRow("OldBucket"),
resourceType: "Test.Queue",
attr: { name: "b", queueUrl: "https://test.queue.com/b" },
},
});
const plan = yield* Effect.gen(function* () {
yield* Bucket("NewBucket", { name: "b" }).pipe(
renamedFrom("OldBucket"),
);
return {};
}).pipe(makePlan);
// Fresh create; the type-mismatched row is a normal orphan.
const node = plan.resources.NewBucket!;
expect(node.action).toEqual("create");
expect(node.renamedFrom).toBeUndefined();
expect(plan.deletions.OldBucket?.action).toEqual("delete");
}),
);
test(
"a foreign-typed row at the NEW FQN blocks the migration loudly",
Effect.gen(function* () {
// A different resource type's row occupies `NewBucket`. Migrating
// over it would silently abandon that row's cloud resource, so the
// plan fails with a clear remediation instead.
yield* seed({
NewBucket: {
...bucketRow("NewBucket", "f0re1gn0000000000000000000000000"),
resourceType: "Test.Queue",
attr: { name: "q", queueUrl: "https://test.queue.com/q" },
},
OldBucket: bucketRow("OldBucket"),
});
const exit = yield* Effect.gen(function* () {
yield* Bucket("NewBucket", { name: "b" }).pipe(
renamedFrom("OldBucket"),
);
return {};
}).pipe(makePlan, Effect.exit);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
const die = exit.cause.reasons.find(Cause.isDieReason);
expect(String(die?.defect)).toContain(
"a state row of a different type ('Test.Queue') already occupies 'NewBucket'",
);
}
}),
);
test(
"a mid-replacement row migrates with its old-generation chain intact",
Effect.gen(function* () {
// The row is in `replaced` status: the new generation is live and
// the old generation is queued for garbage collection. The rename
// must carry the whole row — chain included — so GC still drains it.
yield* seed({
OldBucket: {
...bucketRow("OldBucket"),
status: "replaced",
deleteFirst: false,
old: {
...bucketRow("OldBucket", "01d6e7000000000000000000000000000"),
status: "created",
},
} as ResourceState,
});
const plan = yield* Effect.gen(function* () {
yield* Bucket("NewBucket", { name: "b" }).pipe(
renamedFrom("OldBucket"),
);
return {};
}).pipe(makePlan);
const node = plan.resources.NewBucket!;
expect(node.renamedFrom).toEqual(["OldBucket"]);
expect(node.state?.fqn).toEqual("NewBucket");
expect(node.state?.instanceId).toEqual(instanceId);
// The replacement backlog rides the migration.
expect((node.state as any).old?.instanceId).toEqual(
"01d6e7000000000000000000000000000",
);
expect(Object.keys(plan.deletions)).toHaveLength(0);
}),
);
test(
"renamedFrom on a fresh resource (no rows anywhere) is inert",
Effect.gen(function* () {
// Every new StaticSite carries `renamedFrom(`${id}/Worker`)` forever,
// so a green-field deploy must behave exactly as if the decoration
// were absent: a plain create — and the cold-start adoption probe
// still runs (probe suppression only applies while a row is actually
// migrating away).
const reads: string[] = [];
const plan = yield* Effect.gen(function* () {
yield* TestResource("New", { string: "v" }).pipe(renamedFrom("Old"));
return {};
}).pipe(
makePlan,
Effect.provide(
Layer.succeed(TestResourceHooks, {
read: (id: string) =>
Effect.sync(() => {
reads.push(id);
return undefined;
}),
}),
),
);
const node = plan.resources.New!;
expect(node.action).toEqual("create");
expect(node.renamedFrom).toBeUndefined();
expect(node.state).toBeUndefined();
// The state-loss recovery probe still ran.
expect(reads).toEqual(["New"]);
expect(Object.keys(plan.deletions)).toHaveLength(0);
}),
);
test(
"a rename combined with a replacement-triggering change plans a replace carrying the rename",
Effect.gen(function* () {
yield* seed({
Old: {
instanceId,
providerVersion: 0,
logicalId: "Old",
fqn: "Old",
namespace: undefined,
resourceType: "Test.TestResource",
status: "created",
props: { string: "v", replaceString: "a" },
attr: { string: "v", replaceString: "a" } as any,
bindings: [],
downstream: [],
},
});
const plan = yield* Effect.gen(function* () {
yield* TestResource("New", {
string: "v",
replaceString: "b",
}).pipe(renamedFrom("Old"));
return {};
}).pipe(makePlan);
// The replacement wins the action; the rename rides along so apply
// still moves the row (and the old-generation delete include the
// migrated attrs under the new FQN).
const node = plan.resources.New!;
expect(node.action).toEqual("replace");
expect(node.renamedFrom).toEqual(["Old"]);
expect(node.state?.instanceId).toEqual(instanceId);
expect(Object.keys(plan.deletions)).toHaveLength(0);
}),
);
test(
"an interrupted-create row migrates and resumes the create under the new FQN",
Effect.gen(function* () {
// The pre-rename deploy crashed mid-create: the row is `creating`.
yield* seed({
OldBucket: {
...bucketRow("OldBucket"),
status: "creating",
} as ResourceState,
});
const plan = yield* Effect.gen(function* () {
yield* Bucket("NewBucket", { name: "b" }).pipe(
renamedFrom("OldBucket"),
);
return {};
}).pipe(makePlan);
// Create resumes with the SAME instanceId under the new identity —
// deterministic physical names regenerate identically, so the
// half-created cloud resource is found rather than duplicated.
const node = plan.resources.NewBucket!;
expect(node.action).toEqual("create");
expect(node.renamedFrom).toEqual(["OldBucket"]);
expect(node.state?.instanceId).toEqual(instanceId);
expect(node.state?.fqn).toEqual("NewBucket");
expect(Object.keys(plan.deletions)).toHaveLength(0);
}),
);
test(
"a duplicated former id is collected once",
Effect.gen(function* () {
yield* seed({ OldBucket: bucketRow("OldBucket") });
const plan = yield* Effect.gen(function* () {
yield* Bucket("NewBucket", { name: "b" }).pipe(
renamedFrom("OldBucket", { fqn: "OldBucket" }),
);
return {};
}).pipe(makePlan);
expect(plan.resources.NewBucket?.renamedFrom).toEqual(["OldBucket"]);
}),
);
test(
"a same-deploy rename shift (A→B while B→C) migrates both rows",
Effect.gen(function* () {
// Two existing resources shift names in ONE deploy: the resource at
// `A` becomes `B`, and the resource at `B` becomes `C`. Each row
// must follow ITS resource — B's resolution may not treat the row
// at `B` as its own, because C is claiming it.
const instanceB = "b0000000000000000000000000000000";
yield* seed({
A: bucketRow("A"),
B: bucketRow("B", instanceB),
});
const plan = yield* Effect.gen(function* () {
yield* Bucket("B", { name: "b" }).pipe(renamedFrom("A"));
yield* Bucket("C", { name: "b" }).pipe(renamedFrom("B"));
return {};
}).pipe(makePlan);
// C took B's row...
const c = plan.resources.C!;
expect(c.action).toEqual("update");
expect(c.renamedFrom).toEqual(["B"]);
expect(c.state?.instanceId).toEqual(instanceB);
// ...so B falls back to A's row (never a fresh create)...
const b = plan.resources.B!;
expect(b.action).toEqual("update");
expect(b.renamedFrom).toEqual(["A"]);
expect(b.state?.instanceId).toEqual(instanceId);
// ...and nothing is deleted.
expect(Object.keys(plan.deletions)).toHaveLength(0);
}),
);
test(
"a same-deploy rename swap (A⇄B) fails the plan loudly",
Effect.gen(function* () {
// Swapping two live resources' ids cannot be persisted safely (the
// two migrations would set and delete each other's rows); it must
// die as a rename cycle, never silently half-apply.
yield* seed({
A: bucketRow("A"),
B: bucketRow("B", "b0000000000000000000000000000000"),
});
const exit = yield* Effect.gen(function* () {
yield* Bucket("A", { name: "b" }).pipe(renamedFrom("B"));
yield* Bucket("B", { name: "b" }).pipe(renamedFrom("A"));
return {};
}).pipe(makePlan, Effect.exit);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
const die = exit.cause.reasons.find(Cause.isDieReason);
expect(String(die?.defect)).toContain("cycle");
}
}),
);
test(
"two resources claiming the same former FQN fail the plan loudly",
Effect.gen(function* () {
const exit = yield* Effect.gen(function* () {
yield* Bucket("A", { name: "a" }).pipe(renamedFrom("Shared"));
yield* Bucket("B", { name: "b" }).pipe(renamedFrom("Shared"));
return {};
}).pipe(makePlan, Effect.exit);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
const die = exit.cause.reasons.find(Cause.isDieReason);
expect(String(die?.defect)).toContain("both claim former FQN 'Shared'");
}
}),
);
});
describe("filtered planning", { tags: ["unit", "local"] }, () => {
for (const keeper of ["new", "unbound", "bound"] as const) {
test.provider(
`requires durable binding evidence from a ${keeper} keeper`,
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const program = (clear: boolean) =>
Effect.gen(function* () {
const a = yield* BindingTarget("A", {});
const middle = yield* BindingTarget("Middle", {});
if (!clear) {
yield* a.bind("Middle", { env: { MIDDLE: middle.name } });
yield* middle.bind("A", { env: { SOURCE: a.string } });
}
if (clear || keeper !== "new") {
const keep = yield* BindingTarget("Keeper", {
string: clear ? "new" : "old",
});
if (clear || keeper === "bound")
yield* keep.bind("constant", { env: { VALUE: "kept" } });
}
yield* TestResource("B", {});
});
yield* stack.deploy(program(false));
const result = yield* stack
.plan(program(true), { include: ["A", "Middle", "Keeper"] })
.pipe(Effect.exit);
if (keeper === "bound") {
if (Exit.isFailure(result))
return yield* Effect.failCause(result.cause);
expect(result.value.resources.Keeper.action).toBe("update");
} else {
expect(Exit.isFailure(result)).toBe(true);
if (Exit.isFailure(result))
expect(Cause.pretty(result.cause)).toContain(
"last historical binding evidence",
);
}
yield* stack.plan(program(true));
yield* stack.plan(program(true), {
include: ["A", "Middle", "Keeper", "B"],
});
yield* stack.destroy();
}),
);
}
for (const incomplete of [
"resource",
"action",
"updating history",
] as const) {
test.provider(
`refuses filtered reconciliation of incomplete ${incomplete} metadata`,
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const Compute = Action("A", (input: { value: string }) =>
Effect.succeed(input.value),
);
const program = (value: string) =>
Effect.gen(function* () {
if (incomplete === "action") yield* Compute({ value });
else yield* TestResource("A", { string: value });
yield* TestResource("B", {});
});
yield* stack.deploy(program("old"));
if (incomplete === "updating history") {
yield* stack.deploy(program("new")).pipe(
Effect.provideService(TestResourceHooks, {
update: () => Effect.die("interrupted update"),
}),
Effect.exit,
);
}
const state = yield* yield* State;
const key = { stack: stack.name, stage: stack.stage, fqn: "A" };
const row = yield* state.get(key);
if (!row) return yield* Effect.die("Expected persisted A");
const legacy = { ...row };
if (incomplete === "updating history") {
if (legacy.kind === "action" || legacy.status !== "updating")
return yield* Effect.die("Expected interrupted A update");
const old = { ...legacy.old };
legacy.old = old;
yield* Effect.sync(() => Reflect.deleteProperty(old, "downstream"));
} else
yield* Effect.sync(() =>
Reflect.deleteProperty(legacy, "downstream"),
);
yield* state.set({ ...key, value: legacy });
const result = yield* stack
.plan(program("new"), { include: ["A"], force: true })
.pipe(Effect.exit);
expect(Exit.isFailure(result)).toBe(true);
if (Exit.isFailure(result))
expect(Cause.pretty(result.cause)).toContain(
"incomplete historical downstream metadata",
);
expect(yield* state.get(key)).toEqual(legacy);
yield* stack.plan(program("new"), { include: ["A", "B"] });
yield* stack.plan(program("new"));
yield* stack.plan(program("new"), { include: ["B"], force: true });
yield* stack.deploy(program("new"), { force: true });
yield* stack.destroy();
}),
);
}
test(
"preserves exact direct and higher-order plan output types",
Effect.sync(() => {
type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
? true
: false;
type Output = { value: string };
const stack: Stack.StackSpec<Output> = {
name: "Inference",
stage: "test",
resources: {},
bindings: {},
actions: {},
output: { value: "value" },
};
const direct = Plan.make(stack);
const piped = Effect.succeed(stack).pipe(Effect.flatMap(Plan.make));
const forced = Plan.make(stack, { force: true });
const explicit = Plan.make<Output>(stack, { force: true });
const filtered = Plan.make(stack, { include: ["Selected"] });
const optional = (options: Plan.FilteredPlanOptions) =>
Plan.make(stack, options);
const optionalArgument = (options?: Plan.FilteredPlanOptions) =>
Plan.make(stack, options);
const assertions: [
Equal<Effect.Success<typeof direct>["output"], Output>,
Equal<Effect.Success<typeof piped>["output"], Output>,
Equal<Effect.Success<typeof forced>["output"], Output>,
Equal<Effect.Success<typeof explicit>["output"], Output>,
Equal<Effect.Success<typeof filtered>["output"], undefined>,
Equal<
Effect.Success<ReturnType<typeof optional>>["output"],
Output | undefined
>,
Equal<
Effect.Success<ReturnType<typeof optionalArgument>>["output"],
Output | undefined
>,
] = [true, true, true, true, true, true, true];
expect(assertions.every(Boolean)).toBe(true);
}),
);
test(
"legacy annotated plan options stay full while optional filters stay optional",
Effect.sync(() => {
type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
? true
: false;
type Output = { value: string };
const stack: Stack.StackSpec<Output> = {
name: "Types",
stage: "test",
resources: {},
bindings: {},
actions: {},
output: { value: "value" },
};
const options: Plan.MakePlanOptions = { force: true };
const legacy = Plan.make(stack, options);
const optionalLegacy = (options?: Plan.MakePlanOptions) =>
Plan.make(stack, options);
const excluded = Plan.make(stack, { exclude: ["Other"] });
const both = Plan.make(stack, { include: ["One"], exclude: ["Other"] });
const absent = Plan.make(stack, {
include: undefined,
exclude: undefined,
});
const optionalInclude = (options: { include?: ReadonlyArray<string> }) =>
Plan.make(stack, options);
const optionalExclude = (options: { exclude?: ReadonlyArray<string> }) =>
Plan.make(stack, options);
const piped = Effect.succeed(stack).pipe(
Effect.flatMap((stack) => Plan.make(stack, options)),
);
const filteredPiped = Effect.succeed(stack).pipe(
Effect.flatMap((stack) => Plan.make(stack, { exclude: ["Other"] })),
);
const assertions: [
Equal<Effect.Success<typeof legacy>["output"], Output>,
Equal<
Effect.Success<ReturnType<typeof optionalLegacy>>["output"],
Output
>,
Equal<Effect.Success<typeof excluded>["output"], undefined>,
Equal<Effect.Success<typeof both>["output"], undefined>,
Equal<Effect.Success<typeof absent>["output"], Output>,
Equal<
Effect.Success<ReturnType<typeof optionalInclude>>["output"],
Output | undefined
>,
Equal<
Effect.Success<ReturnType<typeof optionalExclude>>["output"],
Output | undefined
>,
Equal<Effect.Success<typeof piped>["output"], Output>,
Equal<Effect.Success<typeof filteredPiped>["output"], undefined>,
] = [true, true, true, true, true, true, true, true, true];
expect(assertions.every(Boolean)).toBe(true);
}),
);
test(
"full plan options cannot erase known or optional filters",
Effect.sync(() => {
type Assignable<A, B> = [A] extends [B] ? true : false;
type Base = Omit<Plan.MakePlanOptions, "include" | "exclude">;
const assignable: [
Assignable<
Base & { include: ReadonlyArray<string> },
Plan.MakePlanOptions
>,
Assignable<
Base & { exclude: ReadonlyArray<string> },
Plan.MakePlanOptions
>,
Assignable<
Base & { include?: ReadonlyArray<string> },
Plan.MakePlanOptions
>,
Assignable<
Base & { exclude?: ReadonlyArray<string> },
Plan.MakePlanOptions
>,
Assignable<Plan.FilteredPlanOptions, Plan.MakePlanOptions>,
] = [false, false, false, false, false];
expect(assignable.some(Boolean)).toBe(false);
}),
);
const namespaced = Effect.gen(function* () {
yield* TestResource("Branch", {}).pipe(Namespace.push("One"));
yield* TestResource("Branch", {}).pipe(Namespace.push("Two"));
yield* TestResource("Password", {});
});
for (const include of [[], [""], [" "], ["Missing"], ["Branch"]]) {
test(
`rejects invalid selectors ${JSON.stringify(include)} before provider reads`,
Effect.gen(function* () {
const reads: string[] = [];
const exit = yield* makePlan(namespaced, { include }).pipe(
Effect.provideService(TestResourceHooks, {
read: (id) =>
Effect.sync(() => {
reads.push(id);
return undefined;
}),
}),
Effect.exit,
);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
const message = Cause.pretty(exit.cause);
expect(message).toContain("InvalidResourceSelection");
expect(message).toContain("One/Branch");
expect(message).toContain("Two/Branch");
expect(message).toContain("Password");
}
expect(reads).toEqual([]);
}),
);
}
test(
"accepts exact FQNs and deduplicates selectors",
Effect.gen(function* () {
const plan = yield* makePlan(namespaced, {
include: ["One/Branch", "Password", "One/Branch"],
});
expect(Object.keys(plan.resources).sort()).toEqual([
"One/Branch",
"Password",
]);
expect(plan.output).toBeUndefined();
expect(plan.deletions).toEqual({});
}),
);
test(
"does not resolve unselected missing providers or remote credential demands",
Effect.gen(function* () {
interface Missing extends Resource<
"Missing.Provider",
{},
{ value: string }
> {}
const Missing = Resource<Missing>("Missing.Provider");
const program = Effect.gen(function* () {
yield* Missing("Missing", {});
yield* ModalResource("Remote", { value: "remote" }).pipe(remote());
yield* TestResource("Selected", {});
});
const auth = {
get Test(): never {
throw new Error("unselected credentials demanded");
},
};
const plan = yield* inDev(
makePlan(program, { include: ["Selected"] }),
).pipe(Effect.provideService(AuthProviders, auth));
expect(Object.keys(plan.resources)).toEqual(["Selected"]);
const demanded = yield* inDev(
makePlan(ModalResource("Remote", { value: "remote" }).pipe(remote())),
).pipe(Effect.provideService(AuthProviders, auth), Effect.exit);
expect(Exit.isFailure(demanded)).toBe(true);
if (Exit.isFailure(demanded)) {
expect(Cause.pretty(demanded.cause)).toContain(
"unselected credentials demanded",
);
}
}),
);
test(
"closes resource props, binding, Action input and captured dependencies",
Effect.gen(function* () {
const program = Effect.gen(function* () {
const source = yield* TestResource("Source", {});
const captured = yield* TestResource("Captured", {});
const Compute = Action(
"Compute",
Effect.gen(function* () {
const value = yield* captured.string;
return (input: { source: string }) =>
Effect.map(value, (resolved) => ({
value: `${input.source}:${resolved}`,
}));
}),
);
const result = yield* Compute({ source: source.string });
const host = yield* BindingTarget("Host", {});
yield* host.bind("Result", { env: { RESULT: result.value } });
const consumer = yield* TestResource("Consumer", {
string: host.string,
});
yield* TestResource("Unselected", {});
return consumer.string;
});
const plan = yield* makePlan(program, { include: ["Consumer"] });
expect(Object.keys(plan.resources).sort()).toEqual([
"Captured",
"Consumer",
"Host",
"Source",
]);
expect(Object.keys(plan.actions)).toEqual(["Compute"]);
expect([...plan.selectedFqns!].sort()).toEqual([
"Captured",
"Compute",
"Consumer",
"Host",
"Source",
]);
}),
);
test(
"closes binding cycles without selecting unrelated nodes",
Effect.gen(function* () {
const plan = yield* makePlan(
Effect.gen(function* () {
const a = yield* BindingTarget("A", {});
const b = yield* BindingTarget("B", {});
yield* a.bind("B", { env: { B: b.name } });
yield* b.bind("A", { env: { A: a.name } });
yield* TestResource("Other", {});
}),
{ include: ["A"] },
);
expect(Object.keys(plan.resources).sort()).toEqual(["A", "B"]);
expect([...plan.cycleMembers].sort()).toEqual(["A", "B"]);
}),
);
test(
"does not diff or adopt an unselected resource",
Effect.gen(function* () {
yield* seed({
BadDiff: {
status: "created",
fqn: "BadDiff",
logicalId: "BadDiff",
namespace: undefined,
instanceId,
providerVersion: 0,
resourceType: "Test.BindingTarget",
props: {},
attr: { name: "BadDiff", string: "BadDiff", env: {} },
bindings: [],
downstream: [],
},
Undeclared: {
status: "created",
fqn: "Undeclared",
logicalId: "Undeclared",
namespace: undefined,
instanceId,
providerVersion: 0,
resourceType: "Missing.Provider",
props: {},
attr: {},
bindings: [],
downstream: [],
},
});
const calls: string[] = [];
const program = Effect.gen(function* () {
yield* BindingTarget("BadDiff", {});
yield* TestResource("BadRead", {});
yield* TestResource("Selected", {});
});
yield* makePlan(program, { include: ["Selected"] }).pipe(
Effect.provideService(TestResourceHooks, {
diff: () => Effect.die("unselected diff"),
read: (id) =>
id === "BadRead"
? Effect.die("unselected adoption")
: Effect.sync(() => {
calls.push(id);
return undefined;
}),
}),
);
expect(calls).toEqual(["Selected"]);
}),
);
});
describe("resource selection patterns", { tags: ["unit", "local"] }, () => {
const fqns = [
"Branch",
"One/Branch",
"Two/Branch",
"One/Nested/Leaf",
"One/.Hidden",
".Private/Node",
"Comma,Name",
"Literal[1]",
"Literal1",
"Deep/Unique",
"One/Twin",
"Two/Twin",
];
const program = Effect.gen(function* () {
for (const fqn of fqns) {
const slash = fqn.lastIndexOf("/");
const resource = TestResource(fqn.slice(slash + 1), {});
yield* slash < 0
? resource
: resource.pipe(Namespace.push(fqn.slice(0, slash)));
}
return { full: "output" };
});
const cases: ReadonlyArray<{
name: string;
options?: Plan.FilteredPlanOptions;
expected: ReadonlyArray<string>;
}> = [
{ name: "full default", expected: fqns },
{
name: "undefined filters",
options: { include: undefined, exclude: undefined },
expected: fqns,
},
{
name: "exact FQN before ambiguous logical ID",
options: { include: ["Branch"] },
expected: ["Branch"],
},
{
name: "unique logical ID",
options: { include: ["Unique"] },
expected: ["Deep/Unique"],
},
{
name: "literal commas",
options: { include: ["Comma,Name"] },
expected: ["Comma,Name"],
},
{
name: "literal FQN before glob characters",
options: { include: ["Literal[1]"] },
expected: ["Literal[1]"],
},
{
name: "direct segment star",
options: { include: ["One/*"] },
expected: ["One/Branch", "One/.Hidden", "One/Twin"],
},
{
name: "recursive globstar",
options: { include: ["One/**"] },
expected: fqns.filter((fqn) => fqn.startsWith("One/")),
},
{
name: "all including dot namespaces is partial",
options: { include: ["**"] },
expected: fqns,
},
{
name: "positive braces",
options: { include: ["{One,Two}/Branch"] },
expected: ["One/Branch", "Two/Branch"],
},
{
name: "positive extglob",
options: { include: ["@(One|Two)/Branch"] },
expected: ["One/Branch", "Two/Branch"],
},
{
name: "question mark",
options: { include: ["On?/Branch"] },
expected: ["One/Branch"],
},
{
name: "character class",
options: { include: ["[OT]*/Branch"] },
expected: ["One/Branch", "Two/Branch"],
},
{
name: "duplicate union seeds",
options: { include: ["One/*", "Branch", "One/*", "One/Branch"] },
expected: ["One/Branch", "One/.Hidden", "One/Twin", "Branch"],
},
{
name: "exclusion only",
options: { exclude: ["One/**"] },
expected: fqns.filter((fqn) => !fqn.startsWith("One/")),
},
{
name: "literal exclusion FQN wins",
options: { exclude: ["Branch"] },
expected: fqns.filter((fqn) => fqn !== "Branch"),
},
{
name: "multiple includes and excludes",
options: {
include: ["One/**", "Two/**"],
exclude: ["**/Twin", "One/Nested/**"],
},
expected: ["One/Branch", "One/.Hidden", "Two/Branch"],
},
{
name: "exclusion wins overlap and duplicates",
options: {
include: ["One/*", "One/Branch"],
exclude: ["One/Branch", "One/Branch"],
},
expected: ["One/.Hidden", "One/Twin"],
},
];
for (const { name, options, expected } of cases) {
test(
name,
Effect.gen(function* () {
const plan = yield* makePlan(program, options);
expect(Object.keys(plan.resources).sort()).toEqual(
[...expected].sort(),
);
if (options?.include !== undefined || options?.exclude !== undefined) {
expect([...plan.selectedFqns!].sort()).toEqual([...expected].sort());
expect(plan.output).toBeUndefined();
expect(plan.deletions).toEqual({});
} else {
expect(plan.selectedFqns).toBeUndefined();
expect(plan.output).toEqual({ full: "output" });
}
}),
);
}
for (const literal of ["!Literal", "./!Literal", "././!(Literal)"]) {
test(
`exact FQN priority precedes negation metadata ${literal}`,
Effect.gen(function* () {
const declaration = Effect.gen(function* () {
yield* TestResource(literal, {});
yield* TestResource("Other", {});
});
const included = yield* makePlan(declaration, { include: [literal] });
expect(Object.keys(included.resources)).toEqual([literal]);
const excluded = yield* makePlan(declaration, { exclude: [literal] });
expect(Object.keys(excluded.resources)).toEqual(["Other"]);
}),
);
}
for (const options of [
{ include: [] },
{ exclude: [] },
{ include: ["Branch"], exclude: [] },
{ include: [""] },
{ exclude: [" "] },
{ include: ["Missing"] },
{ include: ["Missing/**"] },
{ include: ["Uni*"] },
{ include: ["Branch,Unique"] },
{ include: ["Twin"] },
{ exclude: ["Twin"] },
{ include: ["!One/**"] },
{ exclude: ["!One/**"] },
{ include: ["!!One/**"] },
{ include: ["!(One)/**"] },
{ include: ["./!One/**"] },
{ exclude: ["./!One/**"] },
{ include: ["./!(One)/**"] },
{ exclude: ["./!(One)/**"] },
{ include: ["././!One/**"] },
{ exclude: ["././!One/**"] },
{ include: ["././!(One)/**"] },
{ exclude: ["././!(One)/**"] },
{ include: ["./././!One/**"] },
{ exclude: ["./././!One/**"] },
{ include: ["./././!(One)/**"] },
{ exclude: ["./././!(One)/**"] },
{ include: ["./!!One/**"] },
{ exclude: ["./!!One/**"] },
{ include: ["./!./!One/**"] },
{ exclude: ["./!./!One/**"] },
{ include: ["One/["] },
{ exclude: ["One/{"] },
{ include: ["Branch"], exclude: ["Branch"] },
{ exclude: ["**"] },
]) {
test(
`rejects ${JSON.stringify(options)} before state or providers`,
Effect.gen(function* () {
let stateAccesses = 0;
const calls: string[] = [];
const exit = yield* makePlan(program, options).pipe(
Effect.provideService(
State,
Effect.suspend(() => {
stateAccesses++;
return InMemoryService();
}),
),
Effect.provideService(TestResourceHooks, {
read: (id) =>
Effect.sync(() => {
calls.push(`read:${id}`);
return undefined;
}),
diff: (id) =>
Effect.sync(() => {
calls.push(`diff:${id}`);
}),
}),
Effect.exit,
);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
const message = Cause.pretty(exit.cause);
expect(message).toContain("InvalidResourceSelection");
if (
options.include?.some((pattern) =>
pattern.replace(/^(?:\.\/)+/, "").startsWith("!"),
) ||
options.exclude?.some((pattern) =>
pattern.replace(/^(?:\.\/)+/, "").startsWith("!"),
)
)
expect(message).toContain("use --exclude");
if (
options.include?.includes("Twin") ||
options.exclude?.includes("Twin")
) {
expect(message).toContain("Ambiguous");
expect(message).toContain("One/Twin");
expect(message).toContain("Two/Twin");
}
}
expect(stateAccesses).toBe(0);
expect(calls).toEqual([]);
}),
);
}
test(
"warns once per unmatched exclusion and remains partial",
Effect.gen(function* () {
const logs: Array<{ level: string; message: unknown }> = [];
const plan = yield* makePlan(program, {
exclude: ["Missing", "Absent/**", "Missing"],
}).pipe(
Effect.provide(
Logger.layer([
Logger.make<unknown, void>((options) => {
logs.push({ level: options.logLevel, message: options.message });
}),
]),
),
);
expect(Object.keys(plan.resources).sort()).toEqual([...fqns].sort());
expect(plan.output).toBeUndefined();
expect(plan.selectedFqns?.size).toBe(fqns.length);
const warnings = logs.filter((entry) => entry.level === "Warn");
expect(warnings).toHaveLength(2);
expect(String(warnings[0].message)).toContain("Missing");
expect(String(warnings[1].message)).toContain("Absent/**");
}),
);
for (const [excluded, chain] of [
["App/Source", "App/Consumer -> App/Host -> App/Compute -> App/Source"],
["App/Captured", "App/Consumer -> App/Host -> App/Compute -> App/Captured"],
["App/Compute", "App/Consumer -> App/Host -> App/Compute"],
["App/Host", "App/Consumer -> App/Host"],
]) {
test(
`blocks transitive excluded ${excluded} with its FQN chain`,
Effect.gen(function* () {
const graph = Effect.gen(function* () {
const source = yield* TestResource("Source", {});
const captured = yield* TestResource("Captured", {});
const Compute = Action(
"Compute",
Effect.gen(function* () {
const value = yield* captured.string;
return (input: { source: string }) =>
Effect.map(value, (capture) => ({
value: `${input.source}:${capture}`,
}));
}),
);
const result = yield* Compute({ source: source.string });
const host = yield* BindingTarget("Host", {});
yield* host.bind("Result", { env: { RESULT: result.value } });
yield* TestResource("Consumer", { string: host.string });
}).pipe(Namespace.push("App"));
const exit = yield* makePlan(graph, {
include: ["App/Consumer"],
exclude: [excluded],
}).pipe(
Effect.provideService(
State,
Effect.die("state accessed before selection"),
),
Effect.exit,
);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
const message = Cause.pretty(exit.cause);
expect(message).toContain(chain);
expect(message).toContain(`exclude pattern '${excluded}'`);
}
}),
);
}
test(
"a binding cycle cannot cross an exclusion",
Effect.gen(function* () {
const graph = Effect.gen(function* () {
const a = yield* BindingTarget("A", {});
const b = yield* BindingTarget("B", {});
yield* a.bind("B", { env: { B: b.name } });
yield* b.bind("A", { env: { A: a.name } });
});
const exit = yield* makePlan(graph, {
include: ["A"],
exclude: ["B"],
}).pipe(
Effect.provideService(
State,
Effect.die("state accessed before selection"),
),
Effect.exit,
);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit))
expect(Cause.pretty(exit.cause)).toContain("A -> B");
}),
);
});