mirror of
https://github.com/VibedByKaKi/t3-code-android-nightly.git
synced 2026-10-17 07:47:44 +02:00
10966 lines
403 KiB
TypeScript
10966 lines
403 KiB
TypeScript
import { Action } from "@/Action";
|
|
import { adopt, AdoptPolicy, OwnedBySomeoneElse, Unowned } from "@/AdoptPolicy";
|
|
import { apply, DestroyError } from "@/Apply";
|
|
import { isResolved } from "@/Diff";
|
|
import * as ProviderLayer from "@/Local/ProviderLayer";
|
|
import { Resource } from "@/Resource";
|
|
import * as Context from "effect/Context";
|
|
import { Cli } from "@/Report.ts";
|
|
import * as Namespace from "@/Namespace.ts";
|
|
import * as Output from "@/Output";
|
|
import * as Provider from "@/Provider";
|
|
import * as RemovalPolicy from "@/RemovalPolicy.ts";
|
|
import { renamedFrom } from "@/Rename.ts";
|
|
import { remote } from "@/ProviderMode.ts";
|
|
import * as Plan from "@/Plan";
|
|
import { Stage } from "@/Stage";
|
|
import { Stack, make as makeStack } from "@/Stack";
|
|
import {
|
|
type ActionState,
|
|
type CreatingResourceState,
|
|
type ReplacedResourceState,
|
|
type ReplacingResourceState,
|
|
type ResourceState,
|
|
State,
|
|
StateStoreError,
|
|
} from "@/State";
|
|
import * as Test from "@/Test/Alchemy";
|
|
import { assert, describe, expect } from "alchemy-test";
|
|
import { Data, Layer } from "effect";
|
|
import * as Cause from "effect/Cause";
|
|
import * as Deferred from "effect/Deferred";
|
|
import * as Duration from "effect/Duration";
|
|
import * as Effect from "effect/Effect";
|
|
import * as Exit from "effect/Exit";
|
|
import * as Fiber from "effect/Fiber";
|
|
import * as Option from "effect/Option";
|
|
import * as Redacted from "effect/Redacted";
|
|
import {
|
|
AliasedWidget,
|
|
aliasedWidgetDeletes,
|
|
aliasedWidgetProvider,
|
|
ArtifactProbe,
|
|
BindingTarget,
|
|
capturedConfigHost,
|
|
CollisionRegistry,
|
|
DeleteFirstResource,
|
|
DeletedBindingRegressionTarget,
|
|
DurationResource,
|
|
FqnProbe,
|
|
Function,
|
|
inDev,
|
|
KindStablesResource,
|
|
ModalResource,
|
|
modalCalls,
|
|
type ModalResourceProps,
|
|
PhasedTarget,
|
|
ProbeBinding,
|
|
StaticStablesResource,
|
|
TestLayers,
|
|
TestResource,
|
|
TestResourceHooks,
|
|
type TestResourceProps,
|
|
} from "./test.resources.ts";
|
|
|
|
const { test } = Test.make({ providers: TestLayers() });
|
|
|
|
const getState = Effect.fn(function* <S = ResourceState>(resourceId: string) {
|
|
const state = yield* yield* State;
|
|
const stk = yield* Stack;
|
|
return (yield* state.get({
|
|
stack: stk.name,
|
|
stage: stk.stage,
|
|
fqn: resourceId,
|
|
})) as S;
|
|
});
|
|
/** The planned action for a logical id, or `undefined` if it isn't planned. */
|
|
const actionOfPlan = (plan: any, logicalId: string) =>
|
|
(Object.values(plan.resources) as any[]).find(
|
|
(node: any) => node.resource.LogicalId === logicalId,
|
|
)?.action;
|
|
|
|
const listState = Effect.fn(function* () {
|
|
const state = yield* yield* State;
|
|
const stk = yield* Stack;
|
|
return yield* state.list({ stack: stk.name, stage: stk.stage });
|
|
});
|
|
|
|
const recordingCli = (events: Array<{ id: string; status: string }>) =>
|
|
Cli.of({
|
|
startPlanningSession: () =>
|
|
Effect.succeed({
|
|
update: () => Effect.void,
|
|
succeed: () => Effect.void,
|
|
fail: () => Effect.void,
|
|
close: Effect.void,
|
|
}),
|
|
approvePlan: () => Effect.succeed(true),
|
|
displayPlan: () => Effect.void,
|
|
startApplySession: () =>
|
|
Effect.succeed({
|
|
done: () => Effect.void,
|
|
emit: (event) =>
|
|
Effect.sync(() => {
|
|
if (event._tag === "apply.resource.status") {
|
|
events.push({ id: event.id, status: event.status });
|
|
}
|
|
}),
|
|
}),
|
|
});
|
|
|
|
const expectConvergedStatus = (status: ResourceState["status"] | undefined) => {
|
|
expect(["created", "updated"]).toContain(status);
|
|
};
|
|
|
|
// Graceful failure handling means downstream resources of a failed upstream
|
|
// may have committed an intermediate "creating"/"replacing" status before
|
|
// their `waitForDeps` discovered the upstream failure - or may have fully
|
|
// converged using a stable previous output of the failed upstream (e.g. a
|
|
// replacement whose old generation is still live). This helper tolerates any
|
|
// of those outcomes; the corresponding recovery deploy validates terminal
|
|
// state.
|
|
const expectNotStarted = (state: ResourceState | undefined) => {
|
|
expect([undefined, "creating", "replacing", "created", "updated"]).toContain(
|
|
state?.status,
|
|
);
|
|
};
|
|
|
|
export class ResourceFailure extends Data.TaggedError("ResourceFailure")<{
|
|
message: string;
|
|
}> {
|
|
constructor() {
|
|
super({ message: `Failed to create` });
|
|
}
|
|
}
|
|
|
|
const hook =
|
|
(hooks?: {
|
|
create?: (id: string, props: TestResourceProps) => Effect.Effect<void, any>;
|
|
update?: (id: string, props: TestResourceProps) => Effect.Effect<void, any>;
|
|
delete?: (id: string) => Effect.Effect<void, any>;
|
|
read?: (id: string) => Effect.Effect<any, any>;
|
|
}) =>
|
|
<A, Err, Req>(test: Effect.Effect<A, Err, Req>) =>
|
|
test.pipe(
|
|
Effect.provide(
|
|
Layer.succeed(
|
|
TestResourceHooks,
|
|
hooks ?? {
|
|
create: () => Effect.fail(new ResourceFailure()),
|
|
update: () => Effect.fail(new ResourceFailure()),
|
|
delete: () => Effect.fail(new ResourceFailure()),
|
|
read: () => Effect.succeed(undefined),
|
|
},
|
|
),
|
|
),
|
|
// Phase-1 (create/update) failures surface as the raw ResourceFailure;
|
|
// Phase-2 (GC/destroy) delete failures are aggregated into DestroyError.
|
|
// @ts-expect-error - catchTag changes the return type
|
|
Effect.catchTag(["ResourceFailure", "DestroyError"], () =>
|
|
Effect.succeed(true),
|
|
),
|
|
) as Effect.Effect<A, Err, Req | State>;
|
|
|
|
// Helper to fail on specific resource IDs
|
|
const failOn = (
|
|
resourceId: string,
|
|
hook: "create" | "update" | "delete",
|
|
): {
|
|
create?: (id: string, props: TestResourceProps) => Effect.Effect<void, any>;
|
|
update?: (id: string, props: TestResourceProps) => Effect.Effect<void, any>;
|
|
delete?: (id: string) => Effect.Effect<void, any>;
|
|
} => ({
|
|
[hook]: (id: string) =>
|
|
id === resourceId
|
|
? Effect.fail(new ResourceFailure())
|
|
: Effect.succeed(undefined),
|
|
});
|
|
|
|
// Helper to fail on multiple resource IDs for different hooks
|
|
const failOnMultiple = (
|
|
failures: Array<{ id: string; hook: "create" | "update" | "delete" }>,
|
|
): {
|
|
create?: (id: string, props: TestResourceProps) => Effect.Effect<void, any>;
|
|
update?: (id: string, props: TestResourceProps) => Effect.Effect<void, any>;
|
|
delete?: (id: string) => Effect.Effect<void, any>;
|
|
} => {
|
|
const createFailures = failures
|
|
.filter((f) => f.hook === "create")
|
|
.map((f) => f.id);
|
|
const updateFailures = failures
|
|
.filter((f) => f.hook === "update")
|
|
.map((f) => f.id);
|
|
const deleteFailures = failures
|
|
.filter((f) => f.hook === "delete")
|
|
.map((f) => f.id);
|
|
|
|
return {
|
|
create: (id: string) =>
|
|
createFailures.includes(id)
|
|
? Effect.fail(new ResourceFailure())
|
|
: Effect.succeed(undefined),
|
|
update: (id: string) =>
|
|
updateFailures.includes(id)
|
|
? Effect.fail(new ResourceFailure())
|
|
: Effect.succeed(undefined),
|
|
delete: (id: string) =>
|
|
deleteFailures.includes(id)
|
|
? Effect.fail(new ResourceFailure())
|
|
: Effect.succeed(undefined),
|
|
};
|
|
};
|
|
|
|
describe("Action output convergence", { tags: ["unit", "local"] }, () => {
|
|
for (const consumer of ["prop", "binding"] as const) {
|
|
test.provider(
|
|
`unchanged Action ${consumer} consumers stop reconciling after the first deploy`,
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
let runs = 0;
|
|
const reconciled: string[] = [];
|
|
const Compute = Action("Compute", (_: {}) =>
|
|
Effect.sync(() => {
|
|
runs++;
|
|
return { value: "v1" };
|
|
}),
|
|
);
|
|
const program = Effect.gen(function* () {
|
|
const result = yield* Compute({});
|
|
if (consumer === "prop") {
|
|
const host = yield* TestResource("Host", {
|
|
string: result.value,
|
|
});
|
|
return host.string;
|
|
}
|
|
const host = yield* BindingTarget("Host", {});
|
|
yield* host.bind("Result", { env: { RESULT: result.value } });
|
|
return host.env.RESULT;
|
|
});
|
|
yield* Effect.gen(function* () {
|
|
for (const _ of [1, 2, 3]) {
|
|
expect(yield* stack.deploy(program)).toBe("v1");
|
|
}
|
|
expect(runs).toBe(1);
|
|
expect(reconciled).toEqual(["create"]);
|
|
const plan = yield* stack.plan(program);
|
|
expect(actionOfPlan(plan, "Host")).toBe("noop");
|
|
}).pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
create: () =>
|
|
Effect.sync(() => {
|
|
reconciled.push("create");
|
|
}),
|
|
update: () =>
|
|
Effect.sync(() => {
|
|
reconciled.push("update");
|
|
}),
|
|
}),
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
|
|
for (const sameOutput of [false, true]) {
|
|
test.provider(
|
|
`changed Action input ${sameOutput ? "with the same output" : "with a fresh output"} converges after the rerun`,
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
let runs = 0;
|
|
const reconciled: (string | undefined)[] = [];
|
|
const Compute = Action("Compute", (input: { revision: string }) =>
|
|
Effect.sync(() => {
|
|
runs++;
|
|
return { value: sameOutput ? "constant" : input.revision };
|
|
}),
|
|
);
|
|
const program = (revision: string) =>
|
|
Effect.gen(function* () {
|
|
const result = yield* Compute({ revision });
|
|
const host = yield* TestResource("Host", {
|
|
string: result.value,
|
|
});
|
|
return host.string;
|
|
});
|
|
const record = (_: string, props: TestResourceProps) =>
|
|
Effect.sync(() => {
|
|
reconciled.push(props.string);
|
|
});
|
|
yield* Effect.gen(function* () {
|
|
expect(yield* stack.deploy(program("v1"))).toBe(
|
|
sameOutput ? "constant" : "v1",
|
|
);
|
|
expect(yield* stack.deploy(program("v2"))).toBe(
|
|
sameOutput ? "constant" : "v2",
|
|
);
|
|
expect(runs).toBe(2);
|
|
if (sameOutput) {
|
|
expect([1, 2]).toContain(reconciled.length);
|
|
expect(reconciled.every((value) => value === "constant")).toBe(
|
|
true,
|
|
);
|
|
} else {
|
|
expect(reconciled).toEqual(["v1", "v2"]);
|
|
}
|
|
const settledCount = reconciled.length;
|
|
expect(actionOfPlan(yield* stack.plan(program("v2")), "Host")).toBe(
|
|
"noop",
|
|
);
|
|
yield* stack.deploy(program("v2"));
|
|
expect(runs).toBe(2);
|
|
expect(reconciled).toHaveLength(settledCount);
|
|
}).pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
create: record,
|
|
update: record,
|
|
}),
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
|
|
test.provider(
|
|
"changed Action binding data reaches the host before subsequent deploys converge",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
let runs = 0;
|
|
let reconciles = 0;
|
|
const Compute = Action("Compute", (input: { value: string }) =>
|
|
Effect.sync(() => {
|
|
runs++;
|
|
return input;
|
|
}),
|
|
);
|
|
const program = (value: string) =>
|
|
Effect.gen(function* () {
|
|
const result = yield* Compute({ value });
|
|
const host = yield* BindingTarget("Host", {});
|
|
yield* host.bind("Result", { env: { RESULT: result.value } });
|
|
return host.env.RESULT;
|
|
});
|
|
const record = () =>
|
|
Effect.sync(() => {
|
|
reconciles++;
|
|
});
|
|
yield* Effect.gen(function* () {
|
|
expect(yield* stack.deploy(program("v1"))).toBe("v1");
|
|
expect(yield* stack.deploy(program("v2"))).toBe("v2");
|
|
expect(runs).toBe(2);
|
|
expect(reconciles).toBe(2);
|
|
expect(actionOfPlan(yield* stack.plan(program("v2")), "Host")).toBe(
|
|
"noop",
|
|
);
|
|
expect(yield* stack.deploy(program("v2"))).toBe("v2");
|
|
expect(runs).toBe(2);
|
|
expect(reconciles).toBe(2);
|
|
}).pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
create: record,
|
|
update: record,
|
|
}),
|
|
);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"Action chains propagate fresh outputs and stop rerunning once unchanged",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const runs: string[] = [];
|
|
const Compute = Action("Compute", (input: { value: string }) =>
|
|
Effect.sync(() => {
|
|
runs.push(input.value);
|
|
return { value: `${input.value}!` };
|
|
}),
|
|
);
|
|
const program = (value: string) =>
|
|
Effect.gen(function* () {
|
|
const first = yield* Compute("First", { value });
|
|
const second = yield* Compute("Second", { value: first.value });
|
|
const host = yield* Function("Host", {
|
|
env: { RESULT: second.value },
|
|
});
|
|
return host.env.RESULT;
|
|
});
|
|
expect(yield* stack.deploy(program("v1"))).toBe("v1!!");
|
|
expect(yield* stack.deploy(program("v2"))).toBe("v2!!");
|
|
expect(yield* stack.deploy(program("v2"))).toBe("v2!!");
|
|
expect(runs).toEqual(["v1", "v1!", "v2", "v2!"]);
|
|
expect(actionOfPlan(yield* stack.plan(program("v2")), "Host")).toBe(
|
|
"noop",
|
|
);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"binding-only changes to an upstream resource invalidate Action inputs and consumer outputs",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const seen: string[] = [];
|
|
const Compute = Action("Compute", (input: { value: string }) =>
|
|
Effect.sync(() => {
|
|
seen.push(input.value);
|
|
return input;
|
|
}),
|
|
);
|
|
const program = (value: string) =>
|
|
Effect.gen(function* () {
|
|
const source = yield* BindingTarget("Source", {});
|
|
yield* source.bind("Value", { env: { RESULT: value } });
|
|
const result = yield* Compute({ value: source.env.RESULT });
|
|
const host = yield* Function("Host", {
|
|
env: { RESULT: result.value },
|
|
});
|
|
return host.env.RESULT;
|
|
});
|
|
expect(yield* stack.deploy(program("v1"))).toBe("v1");
|
|
const changed = yield* stack.plan(program("v2"));
|
|
expect(changed.resources.Source.action).toBe("update");
|
|
expect(changed.actions.Compute.action).toBe("run");
|
|
expect(changed.resources.Host.action).toBe("update");
|
|
expect(yield* stack.deploy(program("v2"))).toBe("v2");
|
|
expect(yield* stack.deploy(program("v2"))).toBe("v2");
|
|
expect(seen).toEqual(["v1", "v2"]);
|
|
expect(actionOfPlan(yield* stack.plan(program("v2")), "Host")).toBe(
|
|
"noop",
|
|
);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"an Action planned to run skips its body when upstream resolves to the same input",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
let firstRuns = 0;
|
|
let secondRuns = 0;
|
|
const First = Action("First", (_: { revision: string }) =>
|
|
Effect.sync(() => {
|
|
firstRuns++;
|
|
return { value: "constant" };
|
|
}),
|
|
);
|
|
const Second = Action("Second", (input: { value: string }) =>
|
|
Effect.sync(() => {
|
|
secondRuns++;
|
|
return { value: `${input.value}!` };
|
|
}),
|
|
);
|
|
const program = (revision: string) =>
|
|
Effect.gen(function* () {
|
|
const first = yield* First({ revision });
|
|
const second = yield* Second({ value: first.value });
|
|
const host = yield* Function("Host", {
|
|
env: { RESULT: second.value },
|
|
});
|
|
return host.env.RESULT;
|
|
});
|
|
expect(yield* stack.deploy(program("v1"))).toBe("constant!");
|
|
const changed = yield* stack.plan(program("v2"));
|
|
expect(changed.actions.First.action).toBe("run");
|
|
expect(changed.actions.Second.action).toBe("run");
|
|
expect(yield* stack.deploy(program("v2"))).toBe("constant!");
|
|
expect(firstRuns).toBe(2);
|
|
expect(secondRuns).toBe(1);
|
|
const forced = yield* program("v2").pipe(
|
|
makeStack({
|
|
name: stack.name,
|
|
providers: TestLayers(),
|
|
state: stack.state,
|
|
}),
|
|
Effect.flatMap((spec) =>
|
|
Plan.make(spec, { force: true }).pipe(
|
|
Effect.flatMap(apply),
|
|
Effect.provide(spec.services),
|
|
),
|
|
),
|
|
Effect.provideService(Stage, stack.stage),
|
|
);
|
|
expect(forced).toBe("constant!");
|
|
expect(firstRuns).toBe(3);
|
|
expect(secondRuns).toBe(2);
|
|
expect(actionOfPlan(yield* stack.plan(program("v2")), "Host")).toBe(
|
|
"noop",
|
|
);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"changed Action output replaces the whole environment without retaining removed keys",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const Compute = Action("Compute", (input: { revision: number }) =>
|
|
Effect.succeed<Record<string, string>>(
|
|
input.revision === 1
|
|
? { KEEP: "old", REMOVE: "old" }
|
|
: { KEEP: "new" },
|
|
),
|
|
);
|
|
const program = (revision: number) =>
|
|
Effect.gen(function* () {
|
|
const env = yield* Compute({ revision });
|
|
const host = yield* Function("Host", { env });
|
|
return host.env;
|
|
});
|
|
expect(yield* stack.deploy(program(1))).toEqual({
|
|
KEEP: "old",
|
|
REMOVE: "old",
|
|
});
|
|
expect(yield* stack.deploy(program(2))).toEqual({ KEEP: "new" });
|
|
expect(actionOfPlan(yield* stack.plan(program(2)), "Host")).toBe(
|
|
"noop",
|
|
);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"a failed Action rerun blocks its value consumer and recovery uses the new output",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
let fail = false;
|
|
const reconciled: (string | undefined)[] = [];
|
|
const Compute = Action("Compute", (input: { revision: string }) =>
|
|
Effect.gen(function* () {
|
|
if (fail) return yield* Effect.fail(new ResourceFailure());
|
|
return { value: input.revision };
|
|
}),
|
|
);
|
|
const program = (revision: string) =>
|
|
Effect.gen(function* () {
|
|
const result = yield* Compute({ revision });
|
|
const host = yield* TestResource("Host", { string: result.value });
|
|
return host.string;
|
|
});
|
|
const record = (_: string, props: TestResourceProps) =>
|
|
Effect.sync(() => {
|
|
reconciled.push(props.string);
|
|
});
|
|
yield* Effect.gen(function* () {
|
|
expect(yield* stack.deploy(program("v1"))).toBe("v1");
|
|
fail = true;
|
|
const failed = yield* Effect.exit(stack.deploy(program("v2")));
|
|
assert(Exit.isFailure(failed));
|
|
expect(
|
|
failed.cause.reasons.find(Cause.isFailReason)?.error,
|
|
).toBeInstanceOf(ResourceFailure);
|
|
expect(reconciled).toEqual(["v1"]);
|
|
fail = false;
|
|
expect(yield* stack.deploy(program("v2"))).toBe("v2");
|
|
expect(reconciled).toEqual(["v1", "v2"]);
|
|
}).pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
create: record,
|
|
update: record,
|
|
}),
|
|
);
|
|
}),
|
|
);
|
|
|
|
for (const value of [undefined, null, false, 0, ""] as const) {
|
|
test.provider(
|
|
`persisted ${String(value)} Action output remains reusable after apply`,
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
let runs = 0;
|
|
const Compute = Action("Compute", (_: {}) =>
|
|
Effect.sync(() => {
|
|
runs++;
|
|
return value;
|
|
}),
|
|
);
|
|
const program = Effect.gen(function* () {
|
|
const result = yield* Compute({});
|
|
const host = yield* Function("Host", {
|
|
env: { RESULT: Output.map(result, String) },
|
|
});
|
|
return host.env.RESULT;
|
|
});
|
|
expect(yield* stack.deploy(program)).toBe(String(value));
|
|
expect(yield* stack.deploy(program)).toBe(String(value));
|
|
expect(runs).toBe(1);
|
|
expect(actionOfPlan(yield* stack.plan(program), "Host")).toBe("noop");
|
|
}),
|
|
);
|
|
}
|
|
|
|
test.provider(
|
|
"a forced Action rerun publishes fresh output instead of its persisted result",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
let runs = 0;
|
|
const Compute = Action("Compute", (_: {}) =>
|
|
Effect.sync(() => ({ value: String(++runs) })),
|
|
);
|
|
const program = Effect.gen(function* () {
|
|
const result = yield* Compute({});
|
|
const host = yield* Function("Host", {
|
|
env: { RESULT: result.value },
|
|
});
|
|
return host.env.RESULT;
|
|
});
|
|
expect(yield* stack.deploy(program)).toBe("1");
|
|
const forced = yield* program.pipe(
|
|
makeStack({
|
|
name: stack.name,
|
|
providers: TestLayers(),
|
|
state: stack.state,
|
|
}),
|
|
Effect.flatMap((spec) =>
|
|
Plan.make(spec, { force: true }).pipe(
|
|
Effect.flatMap(apply),
|
|
Effect.provide(spec.services),
|
|
),
|
|
),
|
|
Effect.provideService(Stage, stack.stage),
|
|
);
|
|
expect(forced).toBe("2");
|
|
expect(yield* stack.deploy(program)).toBe("2");
|
|
expect(runs).toBe(2);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"an Action migration marker in the environment converges without removing its dependency",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
let runs = 0;
|
|
const Compute = Action("Compute", (_: {}) =>
|
|
Effect.sync(() => {
|
|
runs++;
|
|
return { migrated: true };
|
|
}),
|
|
);
|
|
const program = Effect.gen(function* () {
|
|
const result = yield* Compute({});
|
|
const host = yield* Function("Host", {
|
|
name: "host",
|
|
env: { MIGRATION: Output.map(result, JSON.stringify) },
|
|
});
|
|
return host.name;
|
|
});
|
|
expect(yield* stack.deploy(program)).toBe("host");
|
|
expect(yield* stack.deploy(program)).toBe("host");
|
|
expect(runs).toBe(1);
|
|
const plan = yield* stack.plan(program);
|
|
expect(plan.actions.Compute.action).toBe("noop");
|
|
expect(actionOfPlan(plan, "Host")).toBe("noop");
|
|
}),
|
|
);
|
|
|
|
for (const operation of ["create", "update", "replace"] as const) {
|
|
test.provider(
|
|
`Action completion precedes bound host ${operation}`,
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const events: string[] = [];
|
|
const Compute = Action("Compute", (_: { revision: string }) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.yieldNow;
|
|
events.push("action completed");
|
|
return { migrated: true };
|
|
}),
|
|
);
|
|
const program = (revision: string) =>
|
|
Effect.gen(function* () {
|
|
const result = yield* Compute({ revision });
|
|
const host = yield* BindingTarget("Host", {
|
|
string: revision,
|
|
replaceString: operation === "replace" ? revision : "fixed",
|
|
});
|
|
yield* host.bind("Migration", {
|
|
env: { MIGRATION: Output.map(result, JSON.stringify) },
|
|
});
|
|
return host.string;
|
|
});
|
|
const record = () =>
|
|
Effect.sync(() => {
|
|
events.push("host reconciled");
|
|
});
|
|
yield* Effect.gen(function* () {
|
|
if (operation !== "create") yield* stack.deploy(program("v1"));
|
|
events.length = 0;
|
|
expect(actionOfPlan(yield* stack.plan(program("v2")), "Host")).toBe(
|
|
operation,
|
|
);
|
|
expect(yield* stack.deploy(program("v2"))).toBe("v2");
|
|
expect(events).toEqual(["action completed", "host reconciled"]);
|
|
}).pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
create: record,
|
|
update: record,
|
|
}),
|
|
);
|
|
}),
|
|
);
|
|
|
|
test.provider(`Action failure prevents bound host ${operation}`, (stack) =>
|
|
Effect.gen(function* () {
|
|
let fail = false;
|
|
const reconciled: string[] = [];
|
|
const Compute = Action("Compute", (_: { revision: string }) =>
|
|
fail ? Effect.fail(new ResourceFailure()) : Effect.succeed("done"),
|
|
);
|
|
const program = (revision: string) =>
|
|
Effect.gen(function* () {
|
|
const result = yield* Compute({ revision });
|
|
const host = yield* BindingTarget("Host", {
|
|
string: revision,
|
|
replaceString: operation === "replace" ? revision : "fixed",
|
|
});
|
|
yield* host.bind("Migration", {
|
|
env: { MIGRATION: Output.map(result, JSON.stringify) },
|
|
});
|
|
return host.string;
|
|
});
|
|
const record = (id: string) =>
|
|
Effect.sync(() => {
|
|
reconciled.push(id);
|
|
});
|
|
yield* Effect.gen(function* () {
|
|
if (operation !== "create") yield* stack.deploy(program("v1"));
|
|
reconciled.length = 0;
|
|
fail = true;
|
|
expect(actionOfPlan(yield* stack.plan(program("v2")), "Host")).toBe(
|
|
operation,
|
|
);
|
|
const failed = yield* Effect.exit(stack.deploy(program("v2")));
|
|
assert(Exit.isFailure(failed));
|
|
expect(
|
|
failed.cause.reasons.find(Cause.isFailReason)?.error,
|
|
).toBeInstanceOf(ResourceFailure);
|
|
expect(reconciled).toEqual([]);
|
|
}).pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
create: record,
|
|
update: record,
|
|
}),
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
|
|
// #1831: a changed value read by a Platform's Init must reach the provider,
|
|
// even though `CapturedConfigHost`'s diff always returns `noop`.
|
|
describe("Platform Init-captured config", { tags: ["unit", "local"] }, () => {
|
|
test.provider("a changed captured value deploys and converges", (stack) =>
|
|
Effect.gen(function* () {
|
|
expect((yield* stack.deploy(capturedConfigHost("a"))).mode).toBe("a");
|
|
expect(
|
|
actionOfPlan(yield* stack.plan(capturedConfigHost("a")), "Host"),
|
|
).toBe("noop");
|
|
expect((yield* stack.deploy(capturedConfigHost("b"))).mode).toBe("b");
|
|
expect(
|
|
actionOfPlan(yield* stack.plan(capturedConfigHost("b")), "Host"),
|
|
).toBe("noop");
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("basic operations", { tags: ["unit", "local"] }, () => {
|
|
test.provider("should create, update, and delete resources", (stack) =>
|
|
Effect.gen(function* () {
|
|
expect(
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "test-string",
|
|
});
|
|
return A.string;
|
|
}).pipe(stack.deploy),
|
|
).toEqual("test-string");
|
|
|
|
expect(
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "test-string-new",
|
|
});
|
|
return A.string;
|
|
}).pipe(stack.deploy),
|
|
).toEqual("test-string-new");
|
|
|
|
yield* stack.destroy();
|
|
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
expect(yield* listState()).toEqual([]);
|
|
}),
|
|
);
|
|
|
|
test.provider("should resolve output properties", (stack) =>
|
|
Effect.gen(function* () {
|
|
expect(
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "test-string",
|
|
stringArray: ["test-string-array"],
|
|
});
|
|
const B = yield* TestResource("B", {
|
|
string: A.string,
|
|
});
|
|
return B.string;
|
|
}).pipe(stack.deploy),
|
|
).toEqual("test-string");
|
|
|
|
expect(
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "test-string",
|
|
stringArray: ["test-string-array"],
|
|
});
|
|
const B = yield* TestResource("B", {
|
|
string: A.string.pipe(Output.map((string) => string.toUpperCase())),
|
|
});
|
|
return B.string;
|
|
}).pipe(stack.deploy),
|
|
).toEqual("TEST-STRING");
|
|
|
|
expect(
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "test-string",
|
|
stringArray: ["test-string-array"],
|
|
});
|
|
const B = yield* TestResource("B", {
|
|
string: A.string.pipe(
|
|
Output.map((string) => string.toUpperCase() + "-NEW"),
|
|
),
|
|
});
|
|
return B.string;
|
|
}).pipe(stack.deploy),
|
|
).toEqual("TEST-STRING-NEW");
|
|
|
|
expect(
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "test-string",
|
|
stringArray: ["test-string-array"],
|
|
});
|
|
const B = yield* TestResource("B", {
|
|
string: A.string.pipe(
|
|
Output.flatMap((string) =>
|
|
Output.literal(string.toUpperCase() + "-FLAT"),
|
|
),
|
|
),
|
|
});
|
|
return B.string;
|
|
}).pipe(stack.deploy),
|
|
).toEqual("TEST-STRING-FLAT");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"should apply downstream resources when a stable kind shadows an output discriminator",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* KindStablesResource("Database", {
|
|
value: "v1",
|
|
}).pipe(stack.deploy);
|
|
|
|
const output = yield* Effect.gen(function* () {
|
|
const database = yield* KindStablesResource("Database", {
|
|
value: "v2",
|
|
});
|
|
const role = yield* KindStablesResource("Role", {
|
|
value: "role",
|
|
upstream: database,
|
|
});
|
|
return { database, role };
|
|
}).pipe(stack.deploy);
|
|
|
|
expect(output.database.value).toBe("v2");
|
|
expect(output.role.upstreamKind).toBe("postgresql");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"should resolve bindings inside constructs using namespaced resources",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const Site = (id: string, _props: {}) =>
|
|
Effect.gen(function* () {
|
|
const bucket = yield* BindingTarget("Bucket", {
|
|
string: "bucket-value",
|
|
});
|
|
const distribution = yield* BindingTarget("Distribution", {
|
|
string: "distribution-value",
|
|
});
|
|
|
|
yield* bucket.bind("Policy", {
|
|
env: {
|
|
BUCKET: bucket.string,
|
|
DISTRIBUTION: distribution.string,
|
|
},
|
|
});
|
|
|
|
return {
|
|
bucket,
|
|
distribution,
|
|
};
|
|
}).pipe(Namespace.push(id));
|
|
|
|
const output = yield* Site("MarketingSite", {}).pipe(stack.deploy);
|
|
|
|
expect(output.bucket.env).toEqual({
|
|
BUCKET: "bucket-value",
|
|
DISTRIBUTION: "distribution-value",
|
|
});
|
|
expectConvergedStatus(
|
|
(yield* getState("MarketingSite/Bucket"))?.status,
|
|
);
|
|
expect((yield* getState("MarketingSite/Distribution"))?.status).toEqual(
|
|
"created",
|
|
);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"should exclude deleted bindings before provider updates",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const created = yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
const target = yield* DeletedBindingRegressionTarget("A", {
|
|
name: "target",
|
|
});
|
|
yield* target.bind("TestBinding", {
|
|
env: {
|
|
FEATURE_FLAG: "on",
|
|
},
|
|
});
|
|
return target;
|
|
}),
|
|
);
|
|
|
|
expect(created.env).toEqual({
|
|
FEATURE_FLAG: "on",
|
|
});
|
|
|
|
const updated = yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
return yield* DeletedBindingRegressionTarget("A", {
|
|
name: "target",
|
|
});
|
|
}),
|
|
);
|
|
|
|
expect(updated.env).toEqual({});
|
|
expect(yield* getState("A")).toMatchObject({
|
|
bindings: [],
|
|
attr: {
|
|
env: {},
|
|
},
|
|
});
|
|
}),
|
|
);
|
|
|
|
// #874: terminal commits must persist the RESOLVED binding payload the
|
|
// provider reconciled with, not the raw plan-time expressions. Raw
|
|
// `node.bindings` hold unresolved Outputs (silently dropped by JSON state
|
|
// stores), so persisting them makes every later plan's `diffBindings`
|
|
// compare a lossy stored shape against fully-resolved data — a phantom
|
|
// binding update on every plan. Exercises the create, update, and replace
|
|
// commit sites.
|
|
test.provider("terminal commits persist resolved binding payloads", (stack) =>
|
|
Effect.gen(function* () {
|
|
const program = (opts: { source: string; replaceString?: string }) =>
|
|
Effect.gen(function* () {
|
|
const source = yield* BindingTarget("BindSource", {
|
|
string: opts.source,
|
|
});
|
|
const host = yield* BindingTarget("BindHost", {
|
|
name: "host",
|
|
replaceString: opts.replaceString,
|
|
});
|
|
// `source.string` is an unresolved Output at plan time.
|
|
yield* host.bind("FromSource", {
|
|
env: { VALUE: source.string },
|
|
});
|
|
return { source, host };
|
|
});
|
|
|
|
const actionOf = (plan: any, logicalId: string) =>
|
|
(Object.values(plan.resources) as any[]).find(
|
|
(node: any) => node.resource.LogicalId === logicalId,
|
|
)?.action;
|
|
|
|
const expectHostBindings = Effect.fn(function* (value: string) {
|
|
const hostState = yield* getState("BindHost");
|
|
expect(hostState?.bindings).toEqual([
|
|
{ sid: "FromSource", data: { env: { VALUE: value } } },
|
|
]);
|
|
});
|
|
|
|
// ── create commit ──
|
|
yield* stack.deploy(program({ source: "v1" }));
|
|
yield* expectHostBindings("v1");
|
|
const created = yield* stack.plan(program({ source: "v1" }));
|
|
expect(actionOf(created, "BindSource")).toBe("noop");
|
|
expect(actionOf(created, "BindHost")).toBe("noop");
|
|
|
|
// ── update commit ──
|
|
yield* stack.deploy(program({ source: "v2" }));
|
|
yield* expectHostBindings("v2");
|
|
const updated = yield* stack.plan(program({ source: "v2" }));
|
|
expect(actionOf(updated, "BindSource")).toBe("noop");
|
|
expect(actionOf(updated, "BindHost")).toBe("noop");
|
|
|
|
// ── replace commit ──
|
|
yield* stack.deploy(program({ source: "v2", replaceString: "flip" }));
|
|
yield* expectHostBindings("v2");
|
|
const replaced = yield* stack.plan(
|
|
program({ source: "v2", replaceString: "flip" }),
|
|
);
|
|
expect(actionOf(replaced, "BindSource")).toBe("noop");
|
|
expect(actionOf(replaced, "BindHost")).toBe("noop");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"persists resolved binding data so an unchanged redeploy plans binding noops",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// Binding data references an output of a resource created in the
|
|
// SAME deploy — unresolved at plan time, resolved during apply.
|
|
const program = Effect.gen(function* () {
|
|
const upstream = yield* BindingTarget("Upstream", {
|
|
string: "upstream-value",
|
|
});
|
|
const target = yield* BindingTarget("Target", { string: "t" });
|
|
yield* target.bind("Cap", { env: { UPSTREAM: upstream.string } });
|
|
return target;
|
|
});
|
|
|
|
const created = yield* stack.deploy(program);
|
|
expect(created.env).toEqual({ UPSTREAM: "upstream-value" });
|
|
|
|
// The persisted binding data must hold the RESOLVED value (what
|
|
// `reconcile` received) — not the raw plan-time data, whose Output
|
|
// proxies JSON state stores silently drop.
|
|
expect(yield* getState("Target")).toMatchObject({
|
|
status: "created",
|
|
bindings: [
|
|
{ sid: "Cap", data: { env: { UPSTREAM: "upstream-value" } } },
|
|
],
|
|
});
|
|
|
|
// An unchanged redeploy must plan the binding as a noop. Before the
|
|
// fix, the truncated persisted data diffed against the now-resolved
|
|
// value and forced a spurious update on every deploy after a create.
|
|
yield* stack.deploy(program);
|
|
expect((yield* getState("Target"))?.status).toEqual("created");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"should update a surviving consumer before deleting a removed dependency",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const created = yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
const secret = yield* TestResource("Secret", {
|
|
string: "secret-value",
|
|
});
|
|
const worker = yield* Function("Worker", {
|
|
name: "worker",
|
|
env: {
|
|
SECRET: secret.string,
|
|
},
|
|
});
|
|
return { secret, worker };
|
|
}),
|
|
);
|
|
|
|
expect(created.worker.env).toEqual({
|
|
SECRET: "secret-value",
|
|
});
|
|
expect((yield* getState("Secret"))?.status).toEqual("created");
|
|
expect((yield* getState("Worker"))?.status).toEqual("created");
|
|
|
|
const updated = yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
return yield* Function("Worker", {
|
|
name: "worker",
|
|
});
|
|
}),
|
|
);
|
|
|
|
expect(updated.env).toEqual({});
|
|
expect(yield* getState("Secret")).toBeUndefined();
|
|
expect((yield* getState("Worker"))?.status).toEqual("updated");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"should create a resource with a binding that references its own output",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const created = yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
const target = yield* DeletedBindingRegressionTarget("A", {
|
|
name: "target",
|
|
});
|
|
yield* target.bind("SelfBinding", {
|
|
env: {
|
|
SELF_NAME: target.name,
|
|
},
|
|
});
|
|
return target;
|
|
}),
|
|
);
|
|
|
|
expect(created.env).toEqual({
|
|
SELF_NAME: "target",
|
|
});
|
|
}),
|
|
{ timeout: 10_000 },
|
|
);
|
|
});
|
|
|
|
// Regression: a logical ID may legitimately contain the FQN separator ("/").
|
|
// The GitHub event source registers a webhook keyed by `${owner}/${repository}`
|
|
// (e.g. "alchemy-run/alchemy"). During destroy the deletion path used to
|
|
// recompute the state key via `toFqn(namespace, logicalId)`, but `logicalId`
|
|
// came from `parseFqn` which splits on "/" and keeps only the last segment
|
|
// ("alchemy"). The recomputed key missed the real state row, so the
|
|
// resource was deleted from the cloud yet never removed from state — resurfacing
|
|
// as an orphan deletion on every subsequent destroy, forever.
|
|
describe("FQN separator in logical ID", { tags: ["unit", "local"] }, () => {
|
|
test.provider(
|
|
"destroy clears state for a top-level logical ID containing '/'",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const fqn = "alchemy-run/alchemy";
|
|
|
|
yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
return yield* TestResource(fqn, { string: "v1" });
|
|
}),
|
|
);
|
|
|
|
// The row is persisted under the full FQN (separator and all).
|
|
expect((yield* getState(fqn))?.status).toEqual("created");
|
|
|
|
const deleted: string[] = [];
|
|
yield* stack.destroy().pipe(
|
|
hook({
|
|
delete: (id: string) =>
|
|
Effect.sync(() => {
|
|
deleted.push(id);
|
|
}),
|
|
}),
|
|
);
|
|
|
|
// provider.delete ran exactly once, AND the state row was removed
|
|
// (the pre-fix bug deleted the cloud resource but missed the row).
|
|
expect(deleted).toHaveLength(1);
|
|
expect(yield* getState(fqn)).toBeUndefined();
|
|
expect(yield* listState()).toEqual([]);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"destroy clears state for a namespaced logical ID containing '/'",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// Mirrors the GitHub webhook: a host construct (the Worker) whose
|
|
// child resource's logical ID is "owner/repo".
|
|
const fqn = "ReleaseService/alchemy-run/alchemy";
|
|
|
|
yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
return yield* TestResource("alchemy-run/alchemy", {
|
|
string: "v1",
|
|
});
|
|
}).pipe(Namespace.push("ReleaseService")),
|
|
);
|
|
|
|
expect((yield* getState(fqn))?.status).toEqual("created");
|
|
|
|
yield* stack.destroy();
|
|
|
|
expect(yield* getState(fqn)).toBeUndefined();
|
|
expect(yield* listState()).toEqual([]);
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("linear update propagation", { tags: ["unit", "local"] }, () => {
|
|
// Regression: in a linear chain (A -> B with no cycle), an update to A
|
|
// followed by an update to B must let B see A's *post-update* attr, never
|
|
// the stale prior attr. Before the cycle-gating change, A would publish
|
|
// its prior attr early and B's update would race against the live value,
|
|
// sometimes deploying with stale data (e.g. a Worker reading a Build's
|
|
// outdir/hash before the build finished).
|
|
test.provider(
|
|
"downstream update receives upstream's post-update attr",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "v1" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
return { A, B };
|
|
}),
|
|
);
|
|
|
|
const sawByB: string[] = [];
|
|
const captureBHooks = {
|
|
create: () => Effect.succeed(undefined),
|
|
update: (id: string, props: TestResourceProps) =>
|
|
Effect.sync(() => {
|
|
if (id === "B" && typeof props.string === "string") {
|
|
sawByB.push(props.string);
|
|
}
|
|
}),
|
|
delete: () => Effect.succeed(undefined),
|
|
read: () => Effect.succeed(undefined),
|
|
};
|
|
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "v2" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
return { A, B };
|
|
}).pipe(stack.deploy, hook(captureBHooks));
|
|
|
|
expect(output.A.string).toEqual("v2");
|
|
expect(output.B.string).toEqual("v2");
|
|
// B.update must have observed the fresh upstream value, never the
|
|
// stale "v1". A single fresh-only call is the ideal; we accept any
|
|
// sequence as long as no stale value leaked through.
|
|
expect(sawByB.length).toBeGreaterThan(0);
|
|
expect(sawByB.every((v) => v === "v2")).toBe(true);
|
|
}),
|
|
);
|
|
|
|
// Regression: a dependent that repins from upstream A to upstream B updates
|
|
// *itself*, while A and B are both noops. The noop pass must still persist
|
|
// their new `downstream` — delete ordering reads it from the persisted row,
|
|
// so a stale list would delete B concurrently with the dependent that still
|
|
// references it (and needlessly wait on A).
|
|
test.provider(
|
|
"noop upstreams persist a repinned dependent's downstream edge",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const program = (pin: "A" | "B") =>
|
|
Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a" });
|
|
const B = yield* TestResource("B", { string: "b" });
|
|
const C = yield* TestResource("C", {
|
|
string: (pin === "A" ? A : B).string,
|
|
});
|
|
return { A, B, C };
|
|
});
|
|
|
|
yield* stack.deploy(program("A"));
|
|
expect((yield* getState("A"))?.downstream).toEqual(["C"]);
|
|
expect((yield* getState("B"))?.downstream).toEqual([]);
|
|
|
|
yield* stack.deploy(program("B"));
|
|
expect((yield* getState("A"))?.downstream).toEqual([]);
|
|
expect((yield* getState("B"))?.downstream).toEqual(["C"]);
|
|
}),
|
|
);
|
|
});
|
|
|
|
// Regression: `deleteFirst` on a `replace` diff was plumbed from the provider
|
|
// all the way into persisted state but never *read* — every replacement was
|
|
// create-first, with the old generation reclaimed afterwards by Phase-2 GC.
|
|
// That silently broke any resource whose replacement can't coexist with the
|
|
// original (fixed physical name, singleton): the create collided with the
|
|
// not-yet-deleted original. These tests pin both orderings.
|
|
describe("deleteFirst replacements", { tags: ["unit", "local"] }, () => {
|
|
test.provider(
|
|
"deletes the old generation BEFORE creating the replacement",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
return yield* DeleteFirstResource("R", { replaceString: "v1" });
|
|
}),
|
|
);
|
|
|
|
const order: string[] = [];
|
|
const recordHooks = {
|
|
create: () =>
|
|
Effect.sync(() => {
|
|
order.push("create");
|
|
}),
|
|
update: () => Effect.succeed(undefined),
|
|
delete: () =>
|
|
Effect.sync(() => {
|
|
order.push("delete");
|
|
}),
|
|
};
|
|
|
|
yield* Effect.gen(function* () {
|
|
return yield* DeleteFirstResource("R", { replaceString: "v2" });
|
|
}).pipe(stack.deploy, hook(recordHooks));
|
|
|
|
// The whole point: delete-old precedes create-new.
|
|
expect(order).toEqual(["delete", "create"]);
|
|
|
|
// The resource collapses straight to a terminal `created` state with
|
|
// no leftover replacement chain for GC to drain.
|
|
const state = yield* getState("R");
|
|
expect(state?.status).toEqual("created");
|
|
expect((state as { old?: unknown }).old).toBeUndefined();
|
|
expect(yield* listState()).toHaveLength(1);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"default (non-deleteFirst) replacement still creates BEFORE deleting",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// `TestResource` returns a plain `{ action: "replace" }` (deleteFirst
|
|
// defaults to false), so the engine must stay create-first.
|
|
yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
return yield* TestResource("R", { replaceString: "v1" });
|
|
}),
|
|
);
|
|
|
|
const order: string[] = [];
|
|
yield* Effect.gen(function* () {
|
|
return yield* TestResource("R", { replaceString: "v2" });
|
|
}).pipe(
|
|
stack.deploy,
|
|
hook({
|
|
create: () =>
|
|
Effect.sync(() => {
|
|
order.push("create");
|
|
}),
|
|
update: () => Effect.succeed(undefined),
|
|
delete: () =>
|
|
Effect.sync(() => {
|
|
order.push("delete");
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(order).toEqual(["create", "delete"]);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"lets a same-identity replacement succeed where create-first would collide",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// A shared registry of live physical names. The provider's create
|
|
// fails if the (fixed) name is still live — exactly the failure mode
|
|
// of a real fixed-name resource (Docker network "already exists",
|
|
// no-op `volume create`) when create runs before the old is deleted.
|
|
const registry = { live: new Set<string>() };
|
|
const withRegistry = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
effect.pipe(
|
|
Effect.provide(Layer.succeed(CollisionRegistry, registry)),
|
|
);
|
|
|
|
yield* stack
|
|
.deploy(
|
|
Effect.gen(function* () {
|
|
return yield* DeleteFirstResource("R", {
|
|
name: "singleton",
|
|
replaceString: "v1",
|
|
});
|
|
}),
|
|
)
|
|
.pipe(withRegistry);
|
|
expect(registry.live.has("singleton")).toBe(true);
|
|
|
|
// Before the fix this deploy died with a CollisionError because the
|
|
// create of the new "singleton" ran while the old one was still live.
|
|
const result = yield* stack
|
|
.deploy(
|
|
Effect.gen(function* () {
|
|
return yield* DeleteFirstResource("R", {
|
|
name: "singleton",
|
|
replaceString: "v2",
|
|
});
|
|
}),
|
|
)
|
|
.pipe(withRegistry);
|
|
|
|
expect(result.name).toEqual("singleton");
|
|
expect(result.replaceString).toEqual("v2");
|
|
// Exactly one live instance remains (old torn down, new created).
|
|
expect(registry.live.size).toBe(1);
|
|
expect(registry.live.has("singleton")).toBe(true);
|
|
|
|
const state = yield* getState("R");
|
|
expect(state?.status).toEqual("created");
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("circularity via bindings", { tags: ["unit", "local"] }, () => {
|
|
const selfBoundStack = (props: {
|
|
string: string;
|
|
replaceString?: string;
|
|
includeD?: boolean;
|
|
}) =>
|
|
Effect.gen(function* () {
|
|
const A = yield* BindingTarget("A", {
|
|
name: "a",
|
|
string: props.string,
|
|
replaceString: props.replaceString,
|
|
});
|
|
yield* A.bind("SelfBinding", {
|
|
env: {
|
|
SELF: A.string,
|
|
},
|
|
});
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
if (props.includeD) {
|
|
const D = yield* TestResource("D", { string: B.string });
|
|
return { A, B, D };
|
|
}
|
|
return { A, B };
|
|
});
|
|
|
|
const mutualBindingStack = (props: {
|
|
aString: string;
|
|
aReplaceString?: string;
|
|
bString?: string;
|
|
includeD?: boolean;
|
|
}) =>
|
|
Effect.gen(function* () {
|
|
const A = yield* BindingTarget("A", {
|
|
name: "a",
|
|
string: props.aString,
|
|
replaceString: props.aReplaceString,
|
|
});
|
|
const B = yield* BindingTarget("B", {
|
|
name: "b",
|
|
string: props.bString ?? "b-value",
|
|
});
|
|
yield* A.bind("FromB", {
|
|
env: {
|
|
PEER: B.string,
|
|
},
|
|
});
|
|
yield* B.bind("FromA", {
|
|
env: {
|
|
PEER: A.string,
|
|
},
|
|
});
|
|
if (props.includeD) {
|
|
const D = yield* TestResource("D", {
|
|
string: Output.interpolate`${A.string}-${B.string}`,
|
|
});
|
|
return { A, B, D };
|
|
}
|
|
return { A, B };
|
|
});
|
|
|
|
const propAndBindingCycleStack = () =>
|
|
Effect.gen(function* () {
|
|
const A = yield* BindingTarget("A", {
|
|
name: "a",
|
|
string: "a-value",
|
|
});
|
|
const B = yield* TestResource("B", {
|
|
string: A.string,
|
|
});
|
|
yield* A.bind("FromB", {
|
|
env: {
|
|
PEER: B.string,
|
|
},
|
|
});
|
|
return { A, B };
|
|
});
|
|
|
|
test.provider(
|
|
"create succeeds when props use precreate output and bindings use downstream output",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const output = yield* stack.deploy(propAndBindingCycleStack());
|
|
|
|
expect(output.A.env).toEqual({ PEER: "a-value" });
|
|
expect(output.B.string).toEqual("a-value");
|
|
expectConvergedStatus((yield* getState("A"))?.status);
|
|
expectConvergedStatus((yield* getState("B"))?.status);
|
|
}),
|
|
{ timeout: 10_000 },
|
|
);
|
|
|
|
describe("self-referential bindings", () => {
|
|
test.provider("create succeeds with self binding", (stack) =>
|
|
Effect.gen(function* () {
|
|
const output = yield* stack.deploy(
|
|
selfBoundStack({
|
|
string: "a-value",
|
|
replaceString: "original",
|
|
}),
|
|
);
|
|
|
|
expect(output.A.env).toEqual({ SELF: "a-value" });
|
|
expect(output.B.string).toEqual("a-value");
|
|
expectConvergedStatus((yield* getState("A"))?.status);
|
|
expectConvergedStatus((yield* getState("B"))?.status);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"replacing state noop replay recovers and creates downstream resources",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* selfBoundStack({
|
|
string: "a-value",
|
|
replaceString: "original",
|
|
}).pipe(stack.deploy);
|
|
|
|
const program = selfBoundStack({
|
|
string: "a-value-replaced",
|
|
replaceString: "changed",
|
|
includeD: true,
|
|
});
|
|
|
|
yield* program.pipe(stack.deploy, hook(failOn("A", "create")));
|
|
|
|
expect(
|
|
(yield* getState<ReplacingResourceState>("A"))?.status,
|
|
).toEqual("replacing");
|
|
expectConvergedStatus((yield* getState("B"))?.status);
|
|
expectNotStarted(yield* getState("D"));
|
|
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expectConvergedStatus((yield* getState("A"))?.status);
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expectConvergedStatus((yield* getState("D"))?.status);
|
|
expect(output.A.env).toEqual({ SELF: "a-value-replaced" });
|
|
expect(output.D!.string).toEqual("a-value-replaced");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"replacing state update replay updates replacement and creates downstream resources",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* selfBoundStack({
|
|
string: "a-value",
|
|
replaceString: "original",
|
|
}).pipe(stack.deploy);
|
|
|
|
yield* selfBoundStack({
|
|
string: "a-value-replaced",
|
|
replaceString: "changed",
|
|
includeD: true,
|
|
}).pipe(stack.deploy, hook(failOn("A", "create")));
|
|
|
|
expect(
|
|
(yield* getState<ReplacingResourceState>("A"))?.status,
|
|
).toEqual("replacing");
|
|
expectConvergedStatus((yield* getState("B"))?.status);
|
|
expectNotStarted(yield* getState("D"));
|
|
|
|
const output = yield* selfBoundStack({
|
|
string: "a-value-updated-during-recovery",
|
|
replaceString: "changed",
|
|
includeD: true,
|
|
}).pipe(stack.deploy);
|
|
|
|
expectConvergedStatus((yield* getState("A"))?.status);
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expectConvergedStatus((yield* getState("D"))?.status);
|
|
expect(output.A.env).toEqual({
|
|
SELF: "a-value-updated-during-recovery",
|
|
});
|
|
expect(output.D!.string).toEqual("a-value-updated-during-recovery");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"replaced state noop replay finishes cleanup and creates downstream resources",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* selfBoundStack({
|
|
string: "a-value",
|
|
replaceString: "original",
|
|
}).pipe(stack.deploy);
|
|
|
|
const program = selfBoundStack({
|
|
string: "a-value-replaced",
|
|
replaceString: "changed",
|
|
includeD: true,
|
|
});
|
|
|
|
yield* program.pipe(stack.deploy, hook(failOn("B", "update")));
|
|
|
|
expect((yield* getState<ReplacedResourceState>("A"))?.status).toEqual(
|
|
"replaced",
|
|
);
|
|
expect((yield* getState("B"))?.status).toEqual("updating");
|
|
expectNotStarted(yield* getState("D"));
|
|
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expectConvergedStatus((yield* getState("A"))?.status);
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expectConvergedStatus((yield* getState("D"))?.status);
|
|
expect(output.A.env).toEqual({ SELF: "a-value-replaced" });
|
|
expect(output.D!.string).toEqual("a-value-replaced");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"replaced state update replay updates replacement and downstream resources",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* selfBoundStack({
|
|
string: "a-value",
|
|
replaceString: "original",
|
|
}).pipe(stack.deploy);
|
|
|
|
yield* selfBoundStack({
|
|
string: "a-value-replaced",
|
|
replaceString: "changed",
|
|
includeD: true,
|
|
}).pipe(stack.deploy, hook(failOn("B", "update")));
|
|
|
|
expect((yield* getState<ReplacedResourceState>("A"))?.status).toEqual(
|
|
"replaced",
|
|
);
|
|
expect((yield* getState("B"))?.status).toEqual("updating");
|
|
expectNotStarted(yield* getState("D"));
|
|
|
|
const output = yield* selfBoundStack({
|
|
string: "a-value-updated-after-replace",
|
|
replaceString: "changed",
|
|
includeD: true,
|
|
}).pipe(stack.deploy);
|
|
|
|
expectConvergedStatus((yield* getState("A"))?.status);
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expectConvergedStatus((yield* getState("D"))?.status);
|
|
expect(output.A.env).toEqual({
|
|
SELF: "a-value-updated-after-replace",
|
|
});
|
|
expect(output.D!.string).toEqual("a-value-updated-after-replace");
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("mutual A <-> B bindings", () => {
|
|
test.provider("create succeeds with mutual bindings", (stack) =>
|
|
Effect.gen(function* () {
|
|
const output = yield* stack.deploy(
|
|
mutualBindingStack({
|
|
aString: "a-value",
|
|
}),
|
|
);
|
|
|
|
expect(output.A.env).toEqual({ PEER: "b-value" });
|
|
expect(output.B.env).toEqual({ PEER: "a-value" });
|
|
expectConvergedStatus((yield* getState("A"))?.status);
|
|
expectConvergedStatus((yield* getState("B"))?.status);
|
|
}),
|
|
);
|
|
|
|
test.provider("destroy succeeds with mutual bindings", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* mutualBindingStack({
|
|
aString: "a-value",
|
|
}).pipe(stack.deploy);
|
|
|
|
yield* stack.destroy();
|
|
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
expectNotStarted(yield* getState("B"));
|
|
}),
|
|
);
|
|
|
|
describe("from replacing state", () => {
|
|
test.provider(
|
|
"replacing noop recovery creates downstream resources",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* mutualBindingStack({
|
|
aString: "a-value",
|
|
aReplaceString: "original",
|
|
}).pipe(stack.deploy);
|
|
|
|
const program = mutualBindingStack({
|
|
aString: "a-value-replaced",
|
|
aReplaceString: "changed",
|
|
includeD: true,
|
|
});
|
|
|
|
yield* program.pipe(stack.deploy, hook(failOn("A", "create")));
|
|
|
|
expect(
|
|
(yield* getState<ReplacingResourceState>("A"))?.status,
|
|
).toEqual("replacing");
|
|
expectConvergedStatus((yield* getState("B"))?.status);
|
|
expectNotStarted(yield* getState("D"));
|
|
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expectConvergedStatus((yield* getState("A"))?.status);
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expectConvergedStatus((yield* getState("D"))?.status);
|
|
expect(output.A.env).toEqual({ PEER: "b-value" });
|
|
expect(output.B.env).toEqual({ PEER: "a-value-replaced" });
|
|
expect(output.D!.string).toEqual("a-value-replaced-b-value");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"replacing update recovery creates downstream resources",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* mutualBindingStack({
|
|
aString: "a-value",
|
|
aReplaceString: "original",
|
|
}).pipe(stack.deploy);
|
|
|
|
yield* mutualBindingStack({
|
|
aString: "a-value-replaced",
|
|
aReplaceString: "changed",
|
|
includeD: true,
|
|
}).pipe(stack.deploy, hook(failOn("A", "create")));
|
|
|
|
expect(
|
|
(yield* getState<ReplacingResourceState>("A"))?.status,
|
|
).toEqual("replacing");
|
|
expectConvergedStatus((yield* getState("B"))?.status);
|
|
expectNotStarted(yield* getState("D"));
|
|
|
|
const output = yield* mutualBindingStack({
|
|
aString: "a-value-updated-during-recovery",
|
|
aReplaceString: "changed",
|
|
includeD: true,
|
|
}).pipe(stack.deploy);
|
|
|
|
expectConvergedStatus((yield* getState("A"))?.status);
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expectConvergedStatus((yield* getState("D"))?.status);
|
|
expect(output.A.env).toEqual({ PEER: "b-value" });
|
|
expect(output.B.env).toEqual({
|
|
PEER: "a-value-updated-during-recovery",
|
|
});
|
|
expect(output.D!.string).toEqual(
|
|
"a-value-updated-during-recovery-b-value",
|
|
);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"replacing replace recovery nests another replacement",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* mutualBindingStack({
|
|
aString: "a-value",
|
|
aReplaceString: "original",
|
|
}).pipe(stack.deploy);
|
|
|
|
yield* mutualBindingStack({
|
|
aString: "a-value-replaced",
|
|
aReplaceString: "changed",
|
|
includeD: true,
|
|
}).pipe(stack.deploy, hook(failOn("A", "create")));
|
|
|
|
const output = yield* mutualBindingStack({
|
|
aString: "a-value-another-replacement",
|
|
aReplaceString: "another-change",
|
|
includeD: true,
|
|
}).pipe(stack.deploy);
|
|
|
|
expectConvergedStatus((yield* getState("A"))?.status);
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expectConvergedStatus((yield* getState("D"))?.status);
|
|
expect(output.B.env).toEqual({
|
|
PEER: "a-value-another-replacement",
|
|
});
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("from replaced state", () => {
|
|
test.provider(
|
|
"replaced noop recovery updates downstream then creates downstream resources",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* mutualBindingStack({
|
|
aString: "a-value",
|
|
aReplaceString: "original",
|
|
}).pipe(stack.deploy);
|
|
|
|
const program = mutualBindingStack({
|
|
aString: "a-value-replaced",
|
|
aReplaceString: "changed",
|
|
includeD: true,
|
|
});
|
|
|
|
yield* program.pipe(stack.deploy, hook(failOn("B", "update")));
|
|
|
|
expect(
|
|
(yield* getState<ReplacedResourceState>("A"))?.status,
|
|
).toEqual("replaced");
|
|
expect((yield* getState("B"))?.status).toEqual("updating");
|
|
expectNotStarted(yield* getState("D"));
|
|
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expectConvergedStatus((yield* getState("D"))?.status);
|
|
expect(output.A.env).toEqual({ PEER: "b-value" });
|
|
expect(output.B.env).toEqual({ PEER: "a-value-replaced" });
|
|
expect(output.D!.string).toEqual("a-value-replaced-b-value");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"replaced with update recovery updates replacement and downstream resources",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* mutualBindingStack({
|
|
aString: "a-value",
|
|
aReplaceString: "original",
|
|
}).pipe(stack.deploy);
|
|
|
|
yield* mutualBindingStack({
|
|
aString: "a-value-replaced",
|
|
aReplaceString: "changed",
|
|
includeD: true,
|
|
}).pipe(stack.deploy, hook(failOn("B", "update")));
|
|
|
|
expect(
|
|
(yield* getState<ReplacedResourceState>("A"))?.status,
|
|
).toEqual("replaced");
|
|
expect((yield* getState("B"))?.status).toEqual("updating");
|
|
expectNotStarted(yield* getState("D"));
|
|
|
|
const output = yield* mutualBindingStack({
|
|
aString: "a-value-updated-after-replace",
|
|
aReplaceString: "changed",
|
|
includeD: true,
|
|
}).pipe(stack.deploy);
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expectConvergedStatus((yield* getState("D"))?.status);
|
|
expect(output.A.env).toEqual({ PEER: "b-value" });
|
|
expect(output.B.env).toEqual({
|
|
PEER: "a-value-updated-after-replace",
|
|
});
|
|
expect(output.D!.string).toEqual(
|
|
"a-value-updated-after-replace-b-value",
|
|
);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"replaced replace recovery nests another replacement",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* mutualBindingStack({
|
|
aString: "a-value",
|
|
aReplaceString: "original",
|
|
}).pipe(stack.deploy);
|
|
|
|
yield* mutualBindingStack({
|
|
aString: "a-value-replaced",
|
|
aReplaceString: "changed",
|
|
includeD: true,
|
|
}).pipe(stack.deploy, hook(failOn("B", "update")));
|
|
|
|
const output = yield* mutualBindingStack({
|
|
aString: "a-value-another-replacement",
|
|
aReplaceString: "another-change",
|
|
includeD: true,
|
|
}).pipe(stack.deploy);
|
|
|
|
expectConvergedStatus((yield* getState("A"))?.status);
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expectConvergedStatus((yield* getState("D"))?.status);
|
|
expect(output.B.env).toEqual({
|
|
PEER: "a-value-another-replacement",
|
|
});
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("prop-flow convergence", { tags: ["unit", "local"] }, () => {
|
|
test.provider(
|
|
"downstream outside a cycle waits for precreated upstream reconciliation",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
let reconciled = false;
|
|
const observations: boolean[] = [];
|
|
yield* Effect.gen(function* () {
|
|
const worker = yield* PhasedTarget("Worker", {
|
|
desired: "queue-handler",
|
|
replaceKey: "v1",
|
|
});
|
|
const binding = yield* TestResource("Binding", {
|
|
string: worker.stableId,
|
|
});
|
|
yield* worker.bind("Feedback", { env: { B: binding.string } });
|
|
// Like a Queue Consumer: the script name is available from the
|
|
// stub, but attaching requires the real Worker upload to finish.
|
|
return yield* TestResource("Consumer", { string: worker.stableId });
|
|
}).pipe(
|
|
stack.deploy,
|
|
hook({
|
|
create: (id) =>
|
|
Effect.gen(function* () {
|
|
if (id === "Worker") {
|
|
yield* Effect.sleep("50 millis");
|
|
reconciled = true;
|
|
} else if (id === "Consumer") {
|
|
observations.push(reconciled);
|
|
}
|
|
}),
|
|
}),
|
|
);
|
|
expect(observations).toEqual([true]);
|
|
}),
|
|
);
|
|
|
|
const phasedCycleStack = (props: {
|
|
desired: string;
|
|
replaceKey?: string;
|
|
use: "stableId" | "value";
|
|
includeC?: boolean;
|
|
}) =>
|
|
Effect.gen(function* () {
|
|
const A = yield* PhasedTarget("A", {
|
|
desired: props.desired,
|
|
replaceKey: props.replaceKey,
|
|
});
|
|
const selected = props.use === "stableId" ? A.stableId : A.value;
|
|
const B = yield* TestResource("B", {
|
|
string: selected,
|
|
});
|
|
yield* A.bind("FromB", {
|
|
env: {
|
|
B: B.string,
|
|
},
|
|
});
|
|
|
|
if (props.includeC) {
|
|
const C = yield* TestResource("C", {
|
|
string: B.string,
|
|
});
|
|
return { A, B, C };
|
|
}
|
|
|
|
return { A, B };
|
|
});
|
|
|
|
test.provider(
|
|
"fresh circular create may use a stable precreate identifier",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const output = yield* phasedCycleStack({
|
|
desired: "final-a",
|
|
replaceKey: "v1",
|
|
use: "stableId",
|
|
}).pipe(stack.deploy);
|
|
|
|
expect(output.A.value).toEqual("final-a");
|
|
expect(output.B.string).toEqual("stable:v1");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"fresh circular create should converge downstream props to final values",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const output = yield* phasedCycleStack({
|
|
desired: "final-a",
|
|
replaceKey: "v1",
|
|
use: "value",
|
|
}).pipe(stack.deploy);
|
|
|
|
expect(output.A.value).toEqual("final-a");
|
|
expect(output.B.string).toEqual("final-a");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"fresh replacement should converge newly created downstream props to replacement values",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* phasedCycleStack({
|
|
desired: "old-a",
|
|
replaceKey: "v1",
|
|
use: "value",
|
|
}).pipe(stack.deploy);
|
|
|
|
const output = yield* phasedCycleStack({
|
|
desired: "new-a",
|
|
replaceKey: "v2",
|
|
use: "value",
|
|
}).pipe(stack.deploy);
|
|
|
|
expect(output.A.value).toEqual("new-a");
|
|
expect(output.B.string).toEqual("new-a");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"stale precreate values should not propagate transitively",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const output = yield* phasedCycleStack({
|
|
desired: "final-a",
|
|
replaceKey: "v1",
|
|
use: "value",
|
|
includeC: true,
|
|
}).pipe(stack.deploy);
|
|
|
|
expect(output.A.value).toEqual("final-a");
|
|
expect(output.B.string).toEqual("final-a");
|
|
expect(output.C!.string).toEqual("final-a");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"binding feedback converges across an A -> B -> A fixed point",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* PhasedTarget("A", {
|
|
desired: "final-a",
|
|
replaceKey: "v1",
|
|
});
|
|
const B = yield* TestResource("B", {
|
|
string: A.value,
|
|
});
|
|
yield* A.bind("FromB", {
|
|
env: {
|
|
B: B.string,
|
|
},
|
|
});
|
|
return { A, B };
|
|
}).pipe(stack.deploy);
|
|
|
|
expect(output.A.value).toEqual("final-a");
|
|
expect(output.B.string).toEqual("final-a");
|
|
expect(output.A.env).toEqual({
|
|
B: "final-a",
|
|
});
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"terminal created or updated status is delayed until fixed-point convergence finishes",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const events: Array<{ id: string; status: string }> = [];
|
|
const cli = Cli.of({
|
|
startPlanningSession: () =>
|
|
Effect.succeed({
|
|
update: () => Effect.void,
|
|
succeed: () => Effect.void,
|
|
fail: () => Effect.void,
|
|
close: Effect.void,
|
|
}),
|
|
approvePlan: () => Effect.succeed(true),
|
|
displayPlan: () => Effect.void,
|
|
startApplySession: () =>
|
|
Effect.succeed({
|
|
done: () => Effect.void,
|
|
emit: (event) =>
|
|
Effect.sync(() => {
|
|
if (event._tag === "apply.resource.status") {
|
|
events.push({
|
|
id: event.id,
|
|
status: event.status,
|
|
});
|
|
}
|
|
}),
|
|
}),
|
|
});
|
|
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* PhasedTarget("A", {
|
|
desired: "final-a",
|
|
replaceKey: "v1",
|
|
});
|
|
const B = yield* TestResource("B", {
|
|
string: A.value,
|
|
});
|
|
yield* A.bind("FromB", {
|
|
env: {
|
|
B: B.string,
|
|
},
|
|
});
|
|
return { A, B };
|
|
}).pipe(stack.deploy, Effect.provide(Layer.succeed(Cli, cli)));
|
|
|
|
expect(output.A.env).toEqual({
|
|
B: "final-a",
|
|
});
|
|
|
|
const statusesById = events.reduce(
|
|
(acc, event: { id: string; status: string }) => {
|
|
(acc[event.id] ??= []).push(event);
|
|
return acc;
|
|
},
|
|
{} as Record<string, Array<{ id: string; status: string }>>,
|
|
);
|
|
const terminal = (id: string) =>
|
|
(statusesById[id] ?? [])
|
|
.map((event: { id: string; status: string }) => event.status)
|
|
.filter(
|
|
(status: string) => status === "created" || status === "updated",
|
|
);
|
|
|
|
expect(terminal("A")).toEqual(["updated"]);
|
|
expect(terminal("B")).toEqual(["updated"]);
|
|
}),
|
|
);
|
|
|
|
test.provider("apply sessions finalize after a resource failure", (stack) =>
|
|
Effect.gen(function* () {
|
|
let finalized = 0;
|
|
const cli = Cli.of({
|
|
startPlanningSession: () =>
|
|
Effect.succeed({
|
|
update: () => Effect.void,
|
|
succeed: () => Effect.void,
|
|
fail: () => Effect.void,
|
|
close: Effect.void,
|
|
}),
|
|
approvePlan: () => Effect.succeed(true),
|
|
displayPlan: () => Effect.void,
|
|
startApplySession: () =>
|
|
Effect.succeed({
|
|
done: () =>
|
|
Effect.sync(() => {
|
|
finalized += 1;
|
|
}),
|
|
emit: () => Effect.void,
|
|
}),
|
|
});
|
|
|
|
yield* TestResource("A", { string: "value" }).pipe(
|
|
stack.deploy,
|
|
hook(failOn("A", "create")),
|
|
Effect.provide(Layer.succeed(Cli, cli)),
|
|
);
|
|
|
|
expect(finalized).toBe(1);
|
|
}),
|
|
);
|
|
|
|
// Regression: a resource with `precreate` (e.g. Cloudflare Worker) resolves
|
|
// its early `ready` signal before its real `reconcile` runs. A non-cyclic
|
|
// downstream must still wait for the upstream's TERMINAL output, so that an
|
|
// upstream `reconcile` failure interrupts the downstream instead of letting
|
|
// it proceed off the precreate stub. Before the fix the downstream raced
|
|
// ahead on the precreate identifier and fully created itself even though the
|
|
// upstream failed.
|
|
test.provider(
|
|
"precreate upstream reconcile failure interrupts non-cyclic downstream (stable id dep)",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* PhasedTarget("A", {
|
|
desired: "a-value",
|
|
replaceKey: "v1",
|
|
});
|
|
// B depends on A.stableId — a value already available from A's
|
|
// precreate stub — yet must still be gated on A's reconcile.
|
|
const B = yield* TestResource("B", {
|
|
string: A.stableId,
|
|
});
|
|
return { A, B };
|
|
});
|
|
|
|
yield* program.pipe(stack.deploy, hook(failOn("A", "create")));
|
|
|
|
// A's reconcile failed after committing "creating".
|
|
expect((yield* getState("A"))?.status).toEqual("creating");
|
|
// B must NOT have reached its own reconcile. It may have committed an
|
|
// intermediate "creating" while waiting on deps, but it must never be
|
|
// "created" — that would mean the upstream failure was ignored.
|
|
expect((yield* getState("B"))?.status).not.toEqual("created");
|
|
|
|
// Recovery deploy converges both.
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expectConvergedStatus((yield* getState("A"))?.status);
|
|
expectConvergedStatus((yield* getState("B"))?.status);
|
|
expect(output.B.string).toEqual("stable:v1");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"precreate upstream reconcile failure interrupts non-cyclic downstream (value dep)",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* PhasedTarget("A", {
|
|
desired: "a-value",
|
|
replaceKey: "v1",
|
|
});
|
|
const B = yield* TestResource("B", {
|
|
string: A.value,
|
|
});
|
|
const C = yield* TestResource("C", {
|
|
string: B.string,
|
|
});
|
|
return { A, B, C };
|
|
});
|
|
|
|
yield* program.pipe(stack.deploy, hook(failOn("A", "create")));
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("creating");
|
|
expect((yield* getState("B"))?.status).not.toEqual("created");
|
|
// Transitive downstream never starts either.
|
|
expectNotStarted(yield* getState("C"));
|
|
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expectConvergedStatus((yield* getState("A"))?.status);
|
|
expectConvergedStatus((yield* getState("B"))?.status);
|
|
expectConvergedStatus((yield* getState("C"))?.status);
|
|
expect(output.C.string).toEqual("a-value");
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("from created state", { tags: ["unit", "local"] }, () => {
|
|
test.provider("noop when props unchanged", (stack) =>
|
|
Effect.gen(function* () {
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "test-string",
|
|
});
|
|
return A.string;
|
|
});
|
|
|
|
let output = yield* stack.deploy(program);
|
|
expect(output).toEqual("test-string");
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
output = yield* stack.deploy(program);
|
|
|
|
// Re-apply with same props - should be noop
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect(output).toEqual("test-string");
|
|
}),
|
|
);
|
|
|
|
test.provider("replace when props trigger replacement", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
replaceString: "original",
|
|
});
|
|
return A.replaceString;
|
|
}),
|
|
);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
|
|
// Change props that trigger replacement
|
|
|
|
const output = yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
replaceString: "new",
|
|
});
|
|
return A.replaceString;
|
|
}),
|
|
);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect(output).toEqual("new");
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("from updated state", { tags: ["unit", "local"] }, () => {
|
|
test.provider("noop when props unchanged", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
string: "test-string",
|
|
});
|
|
}),
|
|
);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
|
|
// Update to get to updated state
|
|
yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
string: "test-string-changed",
|
|
});
|
|
}),
|
|
);
|
|
expect((yield* getState("A"))?.status).toEqual("updated");
|
|
|
|
// Re-apply with same props - should be noop
|
|
const output = yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "test-string-changed",
|
|
});
|
|
return A.string;
|
|
}),
|
|
);
|
|
expect((yield* getState("A"))?.status).toEqual("updated");
|
|
expect(output).toEqual("test-string-changed");
|
|
}),
|
|
);
|
|
|
|
test.provider("replace when props trigger replacement", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
string: "test-string",
|
|
replaceString: "original",
|
|
});
|
|
}),
|
|
);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
|
|
// Update to get to updated state
|
|
yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
string: "test-string-changed",
|
|
replaceString: "original",
|
|
});
|
|
}),
|
|
);
|
|
expect((yield* getState("A"))?.status).toEqual("updated");
|
|
|
|
// Change props that trigger replacement
|
|
const output = yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "test-string-changed",
|
|
replaceString: "new",
|
|
});
|
|
return A.replaceString;
|
|
}),
|
|
);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect(output).toEqual("new");
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("from creating state", { tags: ["unit", "local"] }, () => {
|
|
test.provider("continue creating when props unchanged", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
string: "test-string",
|
|
});
|
|
}).pipe(stack.deploy, hook());
|
|
expect((yield* getState("A"))?.status).toEqual("creating");
|
|
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "test-string",
|
|
});
|
|
return A.string;
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect(output).toEqual("test-string");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"continue creating when props have updatable changes",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
string: "test-string",
|
|
});
|
|
}).pipe(stack.deploy, hook());
|
|
expect((yield* getState("A"))?.status).toEqual("creating");
|
|
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "test-string-changed",
|
|
});
|
|
return A.string;
|
|
}).pipe(stack.deploy);
|
|
expect(output).toEqual("test-string-changed");
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
}),
|
|
);
|
|
|
|
test.provider("replace when props trigger replacement", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
replaceString: "test-string",
|
|
});
|
|
}).pipe(stack.deploy, hook());
|
|
expect((yield* getState("A"))?.status).toEqual("creating");
|
|
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
replaceString: "test-string-changed",
|
|
});
|
|
return A.replaceString;
|
|
}).pipe(stack.deploy);
|
|
expect(output).toEqual("test-string-changed");
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"destroy should handle creating state with no attributes",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// 1. Create a resource but fail - this leaves state in "creating" with no attr
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
string: "test-string",
|
|
});
|
|
}).pipe(stack.deploy, hook());
|
|
expect((yield* getState("A"))?.status).toEqual("creating");
|
|
expect((yield* getState("A"))?.attr).toBeUndefined();
|
|
|
|
// 2. Call destroy - this triggers collectGarbage which tries to delete
|
|
// the orphaned resource. The bug is that output is undefined in the
|
|
// delete call when the resource never completed creation.
|
|
yield* stack.destroy();
|
|
|
|
// Resource should be cleaned up
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"destroy should handle creating state when attributes can be recovered",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
string: "test-string",
|
|
});
|
|
}).pipe(stack.deploy, hook());
|
|
expect((yield* getState("A"))?.status).toEqual("creating");
|
|
expect((yield* getState("A"))?.attr).toBeUndefined();
|
|
|
|
yield* stack.destroy().pipe(
|
|
hook({
|
|
delete: () => Effect.fail(new ResourceFailure()),
|
|
read: () =>
|
|
Effect.succeed({
|
|
string: "test-string",
|
|
}),
|
|
}),
|
|
);
|
|
|
|
// Resource should be cleaned up
|
|
expect((yield* getState("A"))?.status).toEqual("deleting");
|
|
|
|
// actually delete this time
|
|
yield* stack.destroy().pipe(
|
|
hook({
|
|
read: () =>
|
|
Effect.succeed({
|
|
string: "test-string",
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"destroy should handle replacing state when old resource has no attributes",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// 1. Create a resource but fail - this leaves state in "creating" with no attr
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
replaceString: "original",
|
|
});
|
|
}).pipe(stack.deploy, hook());
|
|
expect((yield* getState("A"))?.status).toEqual("creating");
|
|
expect((yield* getState("A"))?.attr).toBeUndefined();
|
|
|
|
// 2. Trigger replacement but also fail during create - this leaves state in "replacing"
|
|
// with old.attr being undefined
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
replaceString: "new",
|
|
});
|
|
}).pipe(stack.deploy, hook());
|
|
const state = yield* getState<ReplacingResourceState>("A");
|
|
expect(state?.status).toEqual("replacing");
|
|
expect(state?.old?.attr).toBeUndefined();
|
|
|
|
// 3. Call destroy - this triggers collectGarbage which tries to delete
|
|
// the resource. The bug is that old.attr is undefined.
|
|
yield* stack.destroy().pipe(
|
|
hook({
|
|
read: () =>
|
|
Effect.succeed({
|
|
replaceString: "original",
|
|
}),
|
|
}),
|
|
);
|
|
|
|
// Resource should be cleaned up
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
}),
|
|
);
|
|
|
|
// ── attr-less `creating` rows must not orphan the physical resource ──
|
|
//
|
|
// A create can be interrupted AFTER the cloud-side call succeeded but
|
|
// BEFORE reconcile returned Attributes, leaving a `creating` row with
|
|
// `attr === undefined`. Destroy used to skip `provider.delete` entirely
|
|
// for such rows and drop the state, silently orphaning the physical
|
|
// resource. The engine now read-then-deletes: `provider.read` recovers
|
|
// the attributes from the persisted props (deterministic physical name),
|
|
// and only a confirmed-missing or Unowned resource skips the delete.
|
|
|
|
test.provider(
|
|
"destroy deletes the recovered physical resource of an attr-less creating row",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// Interrupted create: cloud-side create "succeeded" but no attrs
|
|
// were ever persisted.
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
string: "test-string",
|
|
});
|
|
}).pipe(stack.deploy, hook());
|
|
expect((yield* getState("A"))?.status).toEqual("creating");
|
|
expect((yield* getState("A"))?.attr).toBeUndefined();
|
|
|
|
const deleted: string[] = [];
|
|
yield* stack.destroy().pipe(
|
|
hook({
|
|
read: () => Effect.succeed({ string: "test-string" }),
|
|
delete: (id) => Effect.sync(() => void deleted.push(id)),
|
|
}),
|
|
);
|
|
|
|
// The physical resource recovered by `read` was actually deleted —
|
|
// not silently orphaned.
|
|
expect(deleted).toEqual(["A"]);
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"destroy never deletes a recovered resource that is Unowned",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
string: "test-string",
|
|
});
|
|
}).pipe(stack.deploy, hook());
|
|
expect((yield* getState("A"))?.status).toEqual("creating");
|
|
|
|
const deleted: string[] = [];
|
|
yield* stack.destroy().pipe(
|
|
hook({
|
|
// The physical name exists but belongs to someone else — our
|
|
// interrupted create actually lost a name race (or died before
|
|
// stamping ownership).
|
|
read: () => Effect.succeed(Unowned({ string: "foreign" })),
|
|
delete: (id) => Effect.sync(() => void deleted.push(id)),
|
|
}),
|
|
);
|
|
|
|
// Foreign resources are left in place; only our state is dropped.
|
|
expect(deleted).toEqual([]);
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"destroy tolerates not-found for an attr-less creating row",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
string: "test-string",
|
|
});
|
|
}).pipe(stack.deploy, hook());
|
|
expect((yield* getState("A"))?.status).toEqual("creating");
|
|
|
|
const deleted: string[] = [];
|
|
yield* stack.destroy().pipe(
|
|
hook({
|
|
read: () => Effect.succeed(undefined),
|
|
delete: (id) => Effect.sync(() => void deleted.push(id)),
|
|
}),
|
|
);
|
|
|
|
// Nothing exists cloud-side — delete is not invoked, state is dropped.
|
|
expect(deleted).toEqual([]);
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"destroy survives a recovery read that crashes on degraded creating props",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// An interrupted create can persist `creating` props whose
|
|
// unresolved Outputs were stripped to holes; a provider read that
|
|
// dereferences one crashes with a defect (e.g. a SchemaError deep
|
|
// in its SDK client, see #995). Destroy must degrade to "nothing
|
|
// recovered" and drop the row instead of bricking the stage.
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
string: "test-string",
|
|
});
|
|
}).pipe(stack.deploy, hook());
|
|
expect((yield* getState("A"))?.status).toEqual("creating");
|
|
|
|
const deleted: string[] = [];
|
|
yield* stack.destroy().pipe(
|
|
hook({
|
|
read: () =>
|
|
Effect.die(
|
|
new Error("SchemaError: Expected string, got undefined"),
|
|
),
|
|
delete: (id) => Effect.sync(() => void deleted.push(id)),
|
|
}),
|
|
);
|
|
|
|
// Recovery failed — delete is not invoked, state is still dropped.
|
|
expect(deleted).toEqual([]);
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"destroy drops an attr-less creating row when the provider has no read",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// Manufacture the interrupted-create row directly: Test.Queue's
|
|
// provider implements no `read`, so recovery is impossible and the
|
|
// engine can only drop the row (surfacing a note, not crashing).
|
|
const state = yield* yield* State;
|
|
const stk = yield* Stack;
|
|
yield* state.set({
|
|
stack: stk.name,
|
|
stage: stk.stage,
|
|
fqn: "Q",
|
|
value: {
|
|
kind: "resource",
|
|
status: "creating",
|
|
resourceType: "Test.Queue",
|
|
namespace: undefined,
|
|
fqn: "Q",
|
|
logicalId: "Q",
|
|
instanceId: "q-instance",
|
|
providerVersion: 0,
|
|
downstream: [],
|
|
bindings: [],
|
|
props: { name: "q" },
|
|
} satisfies CreatingResourceState,
|
|
});
|
|
|
|
yield* stack.destroy();
|
|
expect(yield* getState("Q")).toBeUndefined();
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"replacement drain recovers and deletes an attr-less old generation",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// 1. Interrupted create — `creating` row with no attr.
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
replaceString: "original",
|
|
});
|
|
}).pipe(stack.deploy, hook());
|
|
expect((yield* getState("A"))?.status).toEqual("creating");
|
|
expect((yield* getState("A"))?.attr).toBeUndefined();
|
|
|
|
// 2. Deploy a replacement. The first `read` (plan-time create-resume
|
|
// probe) reports not-found so the engine plans a replacement instead
|
|
// of resuming the create; the drain-time `read` then discovers the
|
|
// physical resource the interrupted create actually made, and the
|
|
// engine must delete it while draining the replaced old generation.
|
|
const deleted: string[] = [];
|
|
let reads = 0;
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
replaceString: "new",
|
|
});
|
|
}).pipe(
|
|
stack.deploy,
|
|
hook({
|
|
read: () =>
|
|
Effect.sync(() => ++reads).pipe(
|
|
Effect.map((n) =>
|
|
n === 1 ? undefined : { replaceString: "original" },
|
|
),
|
|
),
|
|
delete: (id) => Effect.sync(() => void deleted.push(id)),
|
|
}),
|
|
);
|
|
|
|
// The old generation's physical resource was recovered and deleted,
|
|
// and the replacement collapsed to a stable `created` row.
|
|
expect(deleted).toEqual(["A"]);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect(
|
|
((yield* getState("A"))?.attr as TestResourceProps)?.replaceString,
|
|
).toEqual("new");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"resuming an interrupted create fails loudly when the recovered resource is Unowned",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
string: "test-string",
|
|
});
|
|
}).pipe(stack.deploy, hook());
|
|
expect((yield* getState("A"))?.status).toEqual("creating");
|
|
|
|
const exit = yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
string: "test-string",
|
|
});
|
|
}).pipe(
|
|
stack.deploy,
|
|
hook({
|
|
read: () => Effect.succeed(Unowned({ string: "foreign" })),
|
|
}),
|
|
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");
|
|
}
|
|
// The row is untouched — the user can re-run with --adopt.
|
|
expect((yield* getState("A"))?.status).toEqual("creating");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"resuming an interrupted create with adopt(true) takes over an Unowned resource",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
string: "test-string",
|
|
});
|
|
}).pipe(stack.deploy, hook());
|
|
expect((yield* getState("A"))?.status).toEqual("creating");
|
|
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
string: "test-string",
|
|
});
|
|
}).pipe(
|
|
adopt(true),
|
|
stack.deploy,
|
|
hook({
|
|
read: () => Effect.succeed(Unowned({ string: "test-string" })),
|
|
}),
|
|
);
|
|
|
|
const persisted = yield* getState("A");
|
|
expect(persisted?.status).toEqual("created");
|
|
// The Unowned brand never reaches persisted state.
|
|
expect(Unowned.is(persisted?.attr)).toBe(false);
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("from updating state", { tags: ["unit", "local"] }, () => {
|
|
test.provider("continue updating when props unchanged", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
string: "test-string",
|
|
});
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
string: "test-string-changed",
|
|
});
|
|
}).pipe(
|
|
stack.deploy,
|
|
hook({
|
|
update: () => Effect.fail(new ResourceFailure()),
|
|
}),
|
|
);
|
|
expect((yield* getState("A"))?.status).toEqual("updating");
|
|
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "test-string-changed",
|
|
});
|
|
return A.string;
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("updated");
|
|
expect(output).toEqual("test-string-changed");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"continue updating when props have updatable changes",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
string: "test-string",
|
|
});
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
string: "test-string-changed",
|
|
});
|
|
}).pipe(
|
|
stack.deploy,
|
|
hook({
|
|
update: () => Effect.fail(new ResourceFailure()),
|
|
}),
|
|
);
|
|
expect((yield* getState("A"))?.status).toEqual("updating");
|
|
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "test-string-changed-again",
|
|
});
|
|
return A.string;
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("updated");
|
|
expect(output).toEqual("test-string-changed-again");
|
|
}),
|
|
);
|
|
|
|
test.provider("replace when props trigger replacement", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
string: "test-string",
|
|
replaceString: "original",
|
|
});
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
string: "test-string-changed",
|
|
replaceString: "original",
|
|
});
|
|
}).pipe(
|
|
stack.deploy,
|
|
hook({
|
|
update: () => Effect.fail(new ResourceFailure()),
|
|
}),
|
|
);
|
|
expect((yield* getState("A"))?.status).toEqual("updating");
|
|
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "test-string-changed",
|
|
replaceString: "changed",
|
|
});
|
|
return A.replaceString;
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect(output).toEqual("changed");
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("from replacing state", { tags: ["unit", "local"] }, () => {
|
|
test.provider("continue replacement when props unchanged", (stack) =>
|
|
Effect.gen(function* () {
|
|
// 1. Create initial resource
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
replaceString: "original",
|
|
});
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
|
|
// 2. Trigger replacement but fail during create of replacement
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
replaceString: "new",
|
|
});
|
|
}).pipe(
|
|
stack.deploy,
|
|
hook({
|
|
create: () => Effect.fail(new ResourceFailure()),
|
|
}),
|
|
);
|
|
const state = yield* getState<ReplacingResourceState>("A");
|
|
expect(state?.status).toEqual("replacing");
|
|
expect(state?.old?.status).toEqual("created");
|
|
|
|
// 3. Re-apply with same props - should continue replacement
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
replaceString: "new",
|
|
});
|
|
return A.replaceString;
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect(output).toEqual("new");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"continue replacement when props have updatable changes",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// 1. Create initial resource
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
replaceString: "original",
|
|
string: "initial",
|
|
});
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
|
|
// 2. Trigger replacement but fail during create
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
replaceString: "new",
|
|
string: "initial",
|
|
});
|
|
}).pipe(
|
|
stack.deploy,
|
|
hook({
|
|
create: () => Effect.fail(new ResourceFailure()),
|
|
}),
|
|
);
|
|
expect((yield* getState("A"))?.status).toEqual("replacing");
|
|
|
|
// 3. Re-apply with changed props (updatable) - should continue replacement with new props
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
replaceString: "new",
|
|
string: "changed",
|
|
});
|
|
return { replaceString: A.replaceString, string: A.string };
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect(output.replaceString).toEqual("new");
|
|
expect(output.string).toEqual("changed");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"continue replacement when props trigger another replacement",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// 1. Create initial resource
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
replaceString: "original",
|
|
});
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
|
|
// 2. Trigger replacement but fail during create
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
replaceString: "new",
|
|
});
|
|
}).pipe(
|
|
stack.deploy,
|
|
hook({
|
|
create: () => Effect.fail(new ResourceFailure()),
|
|
}),
|
|
);
|
|
expect((yield* getState("A"))?.status).toEqual("replacing");
|
|
|
|
// 3. Replace again with another replacement - should converge
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
replaceString: "another-replacement",
|
|
});
|
|
return A.replaceString;
|
|
}).pipe(stack.deploy);
|
|
expectConvergedStatus((yield* getState("A"))?.status);
|
|
expect(output).toEqual("another-replacement");
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("from replaced state", { tags: ["unit", "local"] }, () => {
|
|
test.provider("continue cleanup when props unchanged", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
replaceString: "test-string",
|
|
});
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
replaceString: "test-string-changed",
|
|
});
|
|
}).pipe(
|
|
stack.deploy,
|
|
hook({
|
|
delete: () => Effect.fail(new ResourceFailure()),
|
|
}),
|
|
);
|
|
const AState = yield* getState<ReplacedResourceState>("A");
|
|
expect(AState?.status).toEqual("replaced");
|
|
expect(AState?.old).toMatchObject({
|
|
status: "created",
|
|
props: {
|
|
replaceString: "test-string",
|
|
},
|
|
});
|
|
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
replaceString: "test-string-changed",
|
|
});
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"update replacement then cleanup when props have updatable changes",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// 1. Create initial resource
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
replaceString: "original",
|
|
string: "initial",
|
|
});
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
|
|
// 2. Trigger replacement and fail during delete of old resource
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
replaceString: "new",
|
|
string: "initial",
|
|
});
|
|
}).pipe(
|
|
stack.deploy,
|
|
hook({
|
|
delete: () => Effect.fail(new ResourceFailure()),
|
|
}),
|
|
);
|
|
const state = yield* getState<ReplacedResourceState>("A");
|
|
expect(state?.status).toEqual("replaced");
|
|
expect(state?.old?.status).toEqual("created");
|
|
|
|
// 3. Change props again (updatable change) - should update the replacement then cleanup
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
replaceString: "new",
|
|
string: "changed",
|
|
});
|
|
return { replaceString: A.replaceString, string: A.string };
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect(output.replaceString).toEqual("new");
|
|
expect(output.string).toEqual("changed");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"continue cleanup when props trigger another replacement",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// 1. Create initial resource
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
replaceString: "original",
|
|
});
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
|
|
// 2. Trigger replacement and fail during delete of old resource
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
replaceString: "new",
|
|
});
|
|
}).pipe(
|
|
stack.deploy,
|
|
hook({
|
|
delete: () => Effect.fail(new ResourceFailure()),
|
|
}),
|
|
);
|
|
expect((yield* getState("A"))?.status).toEqual("replaced");
|
|
|
|
// 3. Replace again and continue cleanup of the older generations
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
replaceString: "another-replacement",
|
|
});
|
|
return A.replaceString;
|
|
}).pipe(stack.deploy);
|
|
expectConvergedStatus((yield* getState("A"))?.status);
|
|
expect(output).toEqual("another-replacement");
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe(
|
|
"retain removal policy on replace",
|
|
{ tags: ["unit", "local"] },
|
|
() => {
|
|
test.provider(
|
|
"replace with retain does not delete the old generation",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const deleted: string[] = [];
|
|
|
|
// 1. Create initial resource with a retain removal policy.
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", { replaceString: "v1" }).pipe(
|
|
RemovalPolicy.retain(true),
|
|
);
|
|
}).pipe(stack.deploy);
|
|
|
|
const before = yield* getState("A");
|
|
expect(before?.status).toEqual("created");
|
|
const oldInstanceId = before?.instanceId;
|
|
|
|
// 2. Trigger a replacement (replaceString change). The old generation
|
|
// must NOT be deleted because the resource is retained.
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { replaceString: "v2" }).pipe(
|
|
RemovalPolicy.retain(true),
|
|
);
|
|
return A.replaceString;
|
|
}).pipe(
|
|
stack.deploy,
|
|
hook({
|
|
delete: (id) =>
|
|
Effect.sync(() => {
|
|
deleted.push(id);
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(output).toEqual("v2");
|
|
// provider.delete must never fire for the retained old generation.
|
|
expect(deleted).not.toContain("A");
|
|
|
|
const after = yield* getState("A");
|
|
// Resource was genuinely replaced (fresh instance id) and the old
|
|
// chain drained back to a terminal `created` state.
|
|
expect(after?.status).toEqual("created");
|
|
expect(after?.instanceId).not.toEqual(oldInstanceId);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"replace without retain deletes the old generation exactly once",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const deleted: string[] = [];
|
|
|
|
// 1. Create initial resource with the default (destroy) policy.
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", { replaceString: "v1" });
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
|
|
// 2. Trigger a replacement. The old generation must be deleted since
|
|
// the resource is not retained — guards the retain patch against
|
|
// disabling normal replacement GC.
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { replaceString: "v2" });
|
|
return A.replaceString;
|
|
}).pipe(
|
|
stack.deploy,
|
|
hook({
|
|
delete: (id) =>
|
|
Effect.sync(() => {
|
|
deleted.push(id);
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(output).toEqual("v2");
|
|
expect(deleted.filter((id) => id === "A")).toHaveLength(1);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"nested replacement chain with retain never deletes old generations",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const deleted: string[] = [];
|
|
|
|
// 1. Create with retain.
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", { replaceString: "v1" }).pipe(
|
|
RemovalPolicy.retain(true),
|
|
);
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
|
|
// 2. Trigger a replacement but fail mid-create so a replacement chain
|
|
// forms (replacing, with old=created still live).
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", { replaceString: "v2" }).pipe(
|
|
RemovalPolicy.retain(true),
|
|
);
|
|
}).pipe(
|
|
stack.deploy,
|
|
hook({ create: () => Effect.fail(new ResourceFailure()) }),
|
|
);
|
|
expect((yield* getState("A"))?.status).toEqual("replacing");
|
|
|
|
// 3. Replace again — converges and drains the entire old chain. Every
|
|
// old generation must be retained (no provider.delete calls).
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { replaceString: "v3" }).pipe(
|
|
RemovalPolicy.retain(true),
|
|
);
|
|
return A.replaceString;
|
|
}).pipe(
|
|
stack.deploy,
|
|
hook({
|
|
delete: (id) =>
|
|
Effect.sync(() => {
|
|
deleted.push(id);
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(output).toEqual("v3");
|
|
expect(deleted).not.toContain("A");
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"orphan delete still honors retain (regression guard)",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const deleted: string[] = [];
|
|
const events: Array<{ id: string; status: string }> = [];
|
|
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", { string: "v1" }).pipe(
|
|
RemovalPolicy.retain(true),
|
|
);
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
|
|
const plan = yield* Effect.void.pipe(stack.plan);
|
|
expect(plan.deletions.A?.action).toBe("orphaned");
|
|
|
|
// Destroy removes the resource from the stack. The explicit orphaned
|
|
// action must skip provider.delete and just drop state.
|
|
yield* stack.destroy().pipe(
|
|
hook({
|
|
delete: (id) =>
|
|
Effect.sync(() => {
|
|
deleted.push(id);
|
|
}),
|
|
}),
|
|
Effect.provide(Layer.succeed(Cli, recordingCli(events))),
|
|
);
|
|
|
|
expect(deleted).not.toContain("A");
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
expect(
|
|
events
|
|
.filter((event) => event.id === "A")
|
|
.map((event) => event.status),
|
|
).toEqual(["orphaning", "orphaned"]);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"retain added to an already-created resource is persisted by the noop deploy",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// 1. Create with the default (destroy) policy.
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", { string: "v1" });
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.removalPolicy).toEqual("destroy");
|
|
|
|
// 2. Add `retain` — props are otherwise identical, so the resource
|
|
// plans as a noop. The policy is a declaration decoration, not a
|
|
// prop, so nothing about it can produce a diff; the noop path is
|
|
// the only pass that ever sees the change.
|
|
const declaration = Effect.gen(function* () {
|
|
yield* TestResource("A", { string: "v1" }).pipe(
|
|
RemovalPolicy.retain(true),
|
|
);
|
|
});
|
|
const plan = yield* declaration.pipe(stack.plan);
|
|
expect(actionOfPlan(plan, "A")).toEqual("noop");
|
|
yield* declaration.pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.removalPolicy).toEqual("retain");
|
|
|
|
// 3. Remove the declaration — the orphan sweep reads the policy from
|
|
// state, so the provider's delete must never fire.
|
|
const deleted: string[] = [];
|
|
yield* stack.destroy().pipe(
|
|
hook({
|
|
delete: (id) =>
|
|
Effect.sync(() => {
|
|
deleted.push(id);
|
|
}),
|
|
}),
|
|
);
|
|
expect(deleted).not.toContain("A");
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"retain removed from an already-created resource is persisted by the noop deploy",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// The inverse direction: a resource that was retained and is now
|
|
// declared `destroy` must actually be deleted by the orphan sweep.
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", { string: "v1" }).pipe(
|
|
RemovalPolicy.retain(true),
|
|
);
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.removalPolicy).toEqual("retain");
|
|
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", { string: "v1" });
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.removalPolicy).toEqual("destroy");
|
|
|
|
const deleted: string[] = [];
|
|
yield* stack.destroy().pipe(
|
|
hook({
|
|
delete: (id) =>
|
|
Effect.sync(() => {
|
|
deleted.push(id);
|
|
}),
|
|
}),
|
|
);
|
|
expect(deleted).toContain("A");
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
describe("from deleting state", { tags: ["unit", "local"] }, () => {
|
|
test.provider(
|
|
"create when props unchanged or have updatable changes",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
string: "test-string",
|
|
});
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
|
|
yield* stack.destroy().pipe(
|
|
hook({
|
|
delete: () => Effect.fail(new ResourceFailure()),
|
|
}),
|
|
);
|
|
expect((yield* getState("A"))?.status).toEqual("deleting");
|
|
|
|
// Now re-apply with the same props - should create the resource again
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "test-string",
|
|
});
|
|
return A.string;
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect(output).toEqual("test-string");
|
|
}),
|
|
);
|
|
|
|
// A destroy interrupted while `provider.delete` is still in flight (the
|
|
// shape of a live delete stuck in a long provisioning wait — e.g.
|
|
// CloudFront's disable→wait→delete — when the test runner's timeout fires
|
|
// and teardown is abandoned) must keep the resource's state row. Deletes
|
|
// are idempotent and resumable: the engine commits a `deleting` row BEFORE
|
|
// calling `provider.delete` and only drops it after success, so the next
|
|
// destroy sees the row and drains it. Losing the row here is an invisible
|
|
// orphan — the next destroy plans "no changes" and the cloud resource
|
|
// leaks forever.
|
|
test.provider(
|
|
"interrupting a destroy mid-delete keeps a resumable deleting row",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", { string: "v1" });
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
|
|
// Destroy with a delete that signals entry and then never resolves,
|
|
// then interrupt the destroy once the delete is in flight —
|
|
// simulating the runner's timeout + teardown abandonment.
|
|
const deleteStarted = yield* Deferred.make<void>();
|
|
const fiber = yield* stack.destroy().pipe(
|
|
hook({
|
|
delete: () =>
|
|
Deferred.succeed(deleteStarted, void 0).pipe(
|
|
Effect.andThen(Effect.never),
|
|
),
|
|
}),
|
|
Effect.forkChild,
|
|
);
|
|
yield* Deferred.await(deleteStarted);
|
|
yield* Fiber.interrupt(fiber);
|
|
|
|
// The row survives the interruption, parked at `deleting`.
|
|
expect((yield* getState("A"))?.status).toEqual("deleting");
|
|
|
|
// The next destroy resumes the delete and drains the row.
|
|
yield* stack.destroy();
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
expect(yield* listState()).toEqual([]);
|
|
}),
|
|
);
|
|
|
|
test.provider("create when props trigger replacement", (stack) =>
|
|
Effect.gen(function* () {
|
|
// 1. Create initial resource
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
replaceString: "original",
|
|
});
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
|
|
// 2. Try to delete but fail
|
|
yield* stack.destroy().pipe(
|
|
hook({
|
|
delete: () => Effect.fail(new ResourceFailure()),
|
|
}),
|
|
);
|
|
expect((yield* getState("A"))?.status).toEqual("deleting");
|
|
|
|
// 3. Re-apply with props that trigger replacement - should recreate
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
replaceString: "new",
|
|
});
|
|
return A.replaceString;
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect(output).toEqual("new");
|
|
}),
|
|
);
|
|
});
|
|
|
|
// =============================================================================
|
|
// DEPENDENT RESOURCES (A -> B where B depends on A.string)
|
|
// =============================================================================
|
|
|
|
describe("dependent resources (A -> B)", { tags: ["unit", "local"] }, () => {
|
|
describe("happy path", () => {
|
|
test.provider("create A then B where B uses A.string", (stack) =>
|
|
Effect.gen(function* () {
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
return { A, B };
|
|
}).pipe(stack.deploy);
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect(output.A.string).toEqual("a-value");
|
|
expect(output.B.string).toEqual("a-value");
|
|
}),
|
|
);
|
|
|
|
test.provider("update A propagates to B", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
yield* TestResource("B", { string: A.string });
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
|
|
// Update A's string - B should update with the new value
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value-updated" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
return { A, B };
|
|
}).pipe(stack.deploy);
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("updated");
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expect(output.A.string).toEqual("a-value-updated");
|
|
expect(output.B.string).toEqual("a-value-updated");
|
|
}),
|
|
);
|
|
|
|
test.provider("replace A, B updates to new A's output", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "a-value",
|
|
replaceString: "original",
|
|
});
|
|
yield* TestResource("B", { string: A.string });
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
|
|
// Replace A - B should update to point to new A's output
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "a-value-new",
|
|
replaceString: "changed",
|
|
});
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
return { A, B };
|
|
}).pipe(stack.deploy);
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expect(output.A.string).toEqual("a-value-new");
|
|
expect(output.B.string).toEqual("a-value-new");
|
|
}),
|
|
);
|
|
|
|
test.provider("delete both resources (B deleted first, then A)", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
yield* TestResource("B", { string: A.string });
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
|
|
yield* stack.destroy();
|
|
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
expectNotStarted(yield* getState("B"));
|
|
expect(yield* listState()).toEqual([]);
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("failures during expandAndPivot", () => {
|
|
test.provider(
|
|
"A create fails, B never starts - recovery creates both",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// A fails to create - B should never start
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
yield* TestResource("B", { string: A.string });
|
|
}).pipe(stack.deploy, hook(failOn("A", "create")));
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("creating");
|
|
expectNotStarted(yield* getState("B"));
|
|
|
|
// Recovery: re-apply should create both
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
return { A, B };
|
|
}).pipe(stack.deploy);
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect(output.A.string).toEqual("a-value");
|
|
expect(output.B.string).toEqual("a-value");
|
|
}),
|
|
);
|
|
|
|
test.provider("A creates, B create fails - recovery creates B", (stack) =>
|
|
Effect.gen(function* () {
|
|
// A succeeds, B fails to create
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
yield* TestResource("B", { string: A.string });
|
|
}).pipe(stack.deploy, hook(failOn("B", "create")));
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("creating");
|
|
|
|
// Recovery: re-apply should noop A and create B
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
return { A, B };
|
|
}).pipe(stack.deploy);
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect(output.B.string).toEqual("a-value");
|
|
}),
|
|
);
|
|
|
|
test.provider("A update fails - recovery updates both", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
yield* TestResource("B", { string: A.string });
|
|
}).pipe(stack.deploy);
|
|
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value-updated" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
return { A, B };
|
|
});
|
|
|
|
// A fails to update - B should not start updating
|
|
yield* program.pipe(stack.deploy, hook(failOn("A", "update")));
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("updating");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
|
|
// Recovery: re-apply should update both
|
|
const output = yield* program.pipe(stack.deploy);
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("updated");
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expect(output.A.string).toEqual("a-value-updated");
|
|
expect(output.B.string).toEqual("a-value-updated");
|
|
}),
|
|
);
|
|
|
|
test.provider("A updates, B update fails - recovery updates B", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
yield* TestResource("B", { string: A.string });
|
|
}).pipe(stack.deploy);
|
|
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value-updated" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
return { A, B };
|
|
});
|
|
|
|
// A succeeds, B fails to update
|
|
yield* program.pipe(stack.deploy, hook(failOn("B", "update")));
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("updated");
|
|
expect((yield* getState("B"))?.status).toEqual("updating");
|
|
|
|
// Recovery: re-apply should noop A and update B
|
|
const output = yield* program.pipe(stack.deploy);
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("updated");
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expect(output.B.string).toEqual("a-value-updated");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"A replacement fails - recovery replaces A and updates B",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "a-value",
|
|
replaceString: "original",
|
|
});
|
|
yield* TestResource("B", { string: A.string });
|
|
}).pipe(stack.deploy);
|
|
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "a-value-new",
|
|
replaceString: "changed",
|
|
});
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
return { A, B };
|
|
});
|
|
|
|
// A replacement fails (during create of new A) - B should not start
|
|
yield* program.pipe(stack.deploy, hook(failOn("A", "create")));
|
|
|
|
expect(
|
|
(yield* getState<ReplacingResourceState>("A"))?.status,
|
|
).toEqual("replacing");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
|
|
// Recovery: re-apply should complete A replacement and update B
|
|
const output = yield* program.pipe(stack.deploy);
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expect(output.A.string).toEqual("a-value-new");
|
|
expect(output.B.string).toEqual("a-value-new");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"A replaced, B update fails - recovery updates B then cleans up",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "a-value",
|
|
replaceString: "original",
|
|
});
|
|
yield* TestResource("B", { string: A.string });
|
|
}).pipe(stack.deploy);
|
|
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "a-value-new",
|
|
replaceString: "changed",
|
|
});
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
return { A, B };
|
|
});
|
|
|
|
// A replacement succeeds, B fails to update
|
|
yield* program.pipe(stack.deploy, hook(failOn("B", "update")));
|
|
|
|
// A should be in replaced state (new A created, old A pending cleanup)
|
|
// B should be in updating state
|
|
const aState = yield* getState<ReplacedResourceState>("A");
|
|
expect(aState?.status).toEqual("replaced");
|
|
expect((yield* getState("B"))?.status).toEqual("updating");
|
|
|
|
// Recovery: re-apply should update B and clean up old A
|
|
const output = yield* program.pipe(stack.deploy);
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expect(output.B.string).toEqual("a-value-new");
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("failures during collectGarbage", () => {
|
|
test.provider(
|
|
"A replaced, B updated, old A delete fails - recovery cleans up",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "a-value",
|
|
replaceString: "original",
|
|
});
|
|
yield* TestResource("B", { string: A.string });
|
|
}).pipe(stack.deploy);
|
|
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "a-value-new",
|
|
replaceString: "changed",
|
|
});
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
return { A, B };
|
|
});
|
|
|
|
// A replacement and B update succeed, but old A delete fails
|
|
yield* program.pipe(stack.deploy, hook(failOn("A", "delete")));
|
|
|
|
// A should be in replaced state (delete of old A failed)
|
|
// B should have been updated successfully
|
|
expect((yield* getState<ReplacedResourceState>("A"))?.status).toEqual(
|
|
"replaced",
|
|
);
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
|
|
// Recovery: re-apply should clean up old A
|
|
const output = yield* program.pipe(stack.deploy);
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expect(output.A.string).toEqual("a-value-new");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"orphan B delete fails - recovery deletes B then A",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
yield* TestResource("B", { string: A.string });
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
|
|
// Orphan deletion: B delete fails
|
|
yield* stack.destroy().pipe(hook(failOn("B", "delete")));
|
|
|
|
// B should be in deleting state, A should still be created (waiting for B)
|
|
expect((yield* getState("B"))?.status).toEqual("deleting");
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
|
|
// Recovery: re-apply destroy should delete B then A
|
|
yield* stack.destroy();
|
|
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
expectNotStarted(yield* getState("B"));
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"orphan A delete fails after B deleted - recovery deletes A",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
yield* TestResource("B", { string: A.string });
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
|
|
// Orphan deletion: B succeeds, A fails
|
|
yield* stack.destroy().pipe(hook(failOn("A", "delete")));
|
|
|
|
// B should be deleted, A should be in deleting state
|
|
expectNotStarted(yield* getState("B"));
|
|
expect((yield* getState("A"))?.status).toEqual("deleting");
|
|
|
|
// Recovery: re-apply destroy should delete A
|
|
yield* stack.destroy();
|
|
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// THREE-LEVEL DEPENDENCY CHAIN (A -> B -> C where C depends on B, B depends on A)
|
|
// =============================================================================
|
|
|
|
describe(
|
|
"three-level dependency chain (A -> B -> C)",
|
|
{ tags: ["unit", "local"] },
|
|
() => {
|
|
describe("happy path", () => {
|
|
test.provider("create A then B then C", (stack) =>
|
|
Effect.gen(function* () {
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: B.string });
|
|
return { A, B, C };
|
|
}).pipe(stack.deploy);
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect((yield* getState("C"))?.status).toEqual("created");
|
|
expect(output.A.string).toEqual("a-value");
|
|
expect(output.B.string).toEqual("a-value");
|
|
expect(output.C.string).toEqual("a-value");
|
|
}),
|
|
);
|
|
|
|
test.provider("update A propagates through B to C", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
yield* TestResource("C", { string: B.string });
|
|
}).pipe(stack.deploy);
|
|
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value-updated" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: B.string });
|
|
return { A, B, C };
|
|
}).pipe(stack.deploy);
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("updated");
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expect((yield* getState("C"))?.status).toEqual("updated");
|
|
expect(output.C.string).toEqual("a-value-updated");
|
|
}),
|
|
);
|
|
|
|
test.provider("replace A propagates through B to C", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "a-value",
|
|
replaceString: "original",
|
|
});
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
yield* TestResource("C", { string: B.string });
|
|
}).pipe(stack.deploy);
|
|
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "a-value-new",
|
|
replaceString: "changed",
|
|
});
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: B.string });
|
|
return { A, B, C };
|
|
}).pipe(stack.deploy);
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expect((yield* getState("C"))?.status).toEqual("updated");
|
|
expect(output.C.string).toEqual("a-value-new");
|
|
}),
|
|
);
|
|
|
|
test.provider("delete all three (C first, then B, then A)", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
yield* TestResource("C", { string: B.string });
|
|
}).pipe(stack.deploy);
|
|
|
|
yield* stack.destroy();
|
|
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
expectNotStarted(yield* getState("B"));
|
|
expectNotStarted(yield* getState("C"));
|
|
expect(yield* listState()).toEqual([]);
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("creation failures", () => {
|
|
test.provider("A create fails - B and C never start", (stack) =>
|
|
Effect.gen(function* () {
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: B.string });
|
|
return { A, B, C };
|
|
});
|
|
|
|
yield* program.pipe(stack.deploy, hook(failOn("A", "create")));
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("creating");
|
|
expectNotStarted(yield* getState("B"));
|
|
expectNotStarted(yield* getState("C"));
|
|
|
|
// Recovery
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect((yield* getState("C"))?.status).toEqual("created");
|
|
expect(output.C.string).toEqual("a-value");
|
|
}),
|
|
);
|
|
|
|
test.provider("A creates, B create fails - C never starts", (stack) =>
|
|
Effect.gen(function* () {
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: B.string });
|
|
return { A, B, C };
|
|
});
|
|
|
|
yield* program.pipe(stack.deploy, hook(failOn("B", "create")));
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("creating");
|
|
expectNotStarted(yield* getState("C"));
|
|
|
|
// Recovery
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect((yield* getState("C"))?.status).toEqual("created");
|
|
expect(output.C.string).toEqual("a-value");
|
|
}),
|
|
);
|
|
|
|
test.provider("A and B create, C create fails", (stack) =>
|
|
Effect.gen(function* () {
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: B.string });
|
|
return { A, B, C };
|
|
});
|
|
|
|
yield* program.pipe(stack.deploy, hook(failOn("C", "create")));
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect((yield* getState("C"))?.status).toEqual("creating");
|
|
|
|
// Recovery
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect((yield* getState("C"))?.status).toEqual("created");
|
|
expect(output.C.string).toEqual("a-value");
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("update failures", () => {
|
|
test.provider("A update fails - B and C remain stable", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
yield* TestResource("C", { string: B.string });
|
|
}).pipe(stack.deploy);
|
|
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value-updated" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: B.string });
|
|
return { A, B, C };
|
|
});
|
|
|
|
yield* program.pipe(stack.deploy, hook(failOn("A", "update")));
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("updating");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect((yield* getState("C"))?.status).toEqual("created");
|
|
|
|
// Recovery
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("updated");
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expect((yield* getState("C"))?.status).toEqual("updated");
|
|
expect(output.C.string).toEqual("a-value-updated");
|
|
}),
|
|
);
|
|
|
|
test.provider("A updates, B update fails - C remains stable", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
yield* TestResource("C", { string: B.string });
|
|
}).pipe(stack.deploy);
|
|
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value-updated" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: B.string });
|
|
return { A, B, C };
|
|
});
|
|
|
|
yield* program.pipe(stack.deploy, hook(failOn("B", "update")));
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("updated");
|
|
expect((yield* getState("B"))?.status).toEqual("updating");
|
|
expect((yield* getState("C"))?.status).toEqual("created");
|
|
|
|
// Recovery
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("updated");
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expect((yield* getState("C"))?.status).toEqual("updated");
|
|
expect(output.C.string).toEqual("a-value-updated");
|
|
}),
|
|
);
|
|
|
|
test.provider("A and B update, C update fails", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
yield* TestResource("C", { string: B.string });
|
|
}).pipe(stack.deploy);
|
|
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value-updated" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: B.string });
|
|
return { A, B, C };
|
|
});
|
|
|
|
yield* program.pipe(stack.deploy, hook(failOn("C", "update")));
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("updated");
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expect((yield* getState("C"))?.status).toEqual("updating");
|
|
|
|
// Recovery
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("updated");
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expect((yield* getState("C"))?.status).toEqual("updated");
|
|
expect(output.C.string).toEqual("a-value-updated");
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("replace cascade failures", () => {
|
|
test.provider("A replace fails - B and C remain stable", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "a-value",
|
|
replaceString: "original",
|
|
});
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
yield* TestResource("C", { string: B.string });
|
|
}).pipe(stack.deploy);
|
|
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "a-value-new",
|
|
replaceString: "changed",
|
|
});
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: B.string });
|
|
return { A, B, C };
|
|
});
|
|
|
|
yield* program.pipe(stack.deploy, hook(failOn("A", "create")));
|
|
|
|
expect(
|
|
(yield* getState<ReplacingResourceState>("A"))?.status,
|
|
).toEqual("replacing");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect((yield* getState("C"))?.status).toEqual("created");
|
|
|
|
// Recovery
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expect((yield* getState("C"))?.status).toEqual("updated");
|
|
expect(output.C.string).toEqual("a-value-new");
|
|
}),
|
|
);
|
|
|
|
test.provider("A replaced, B update fails - C remains stable", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "a-value",
|
|
replaceString: "original",
|
|
});
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
yield* TestResource("C", { string: B.string });
|
|
}).pipe(stack.deploy);
|
|
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "a-value-new",
|
|
replaceString: "changed",
|
|
});
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: B.string });
|
|
return { A, B, C };
|
|
});
|
|
|
|
yield* program.pipe(stack.deploy, hook(failOn("B", "update")));
|
|
|
|
expect((yield* getState<ReplacedResourceState>("A"))?.status).toEqual(
|
|
"replaced",
|
|
);
|
|
expect((yield* getState("B"))?.status).toEqual("updating");
|
|
expect((yield* getState("C"))?.status).toEqual("created");
|
|
|
|
// Recovery
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expect((yield* getState("C"))?.status).toEqual("updated");
|
|
expect(output.C.string).toEqual("a-value-new");
|
|
}),
|
|
);
|
|
|
|
test.provider("A replaced, B updated, C update fails", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "a-value",
|
|
replaceString: "original",
|
|
});
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
yield* TestResource("C", { string: B.string });
|
|
}).pipe(stack.deploy);
|
|
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "a-value-new",
|
|
replaceString: "changed",
|
|
});
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: B.string });
|
|
return { A, B, C };
|
|
});
|
|
|
|
yield* program.pipe(stack.deploy, hook(failOn("C", "update")));
|
|
|
|
expect((yield* getState<ReplacedResourceState>("A"))?.status).toEqual(
|
|
"replaced",
|
|
);
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expect((yield* getState("C"))?.status).toEqual("updating");
|
|
|
|
// Recovery
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expect((yield* getState("C"))?.status).toEqual("updated");
|
|
expect(output.C.string).toEqual("a-value-new");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"A replaced, B and C updated, old A delete fails - recovery cleans up",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "a-value",
|
|
replaceString: "original",
|
|
});
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
yield* TestResource("C", { string: B.string });
|
|
}).pipe(stack.deploy);
|
|
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "a-value-new",
|
|
replaceString: "changed",
|
|
});
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: B.string });
|
|
return { A, B, C };
|
|
});
|
|
|
|
yield* program.pipe(stack.deploy, hook(failOn("A", "delete")));
|
|
|
|
expect(
|
|
(yield* getState<ReplacedResourceState>("A"))?.status,
|
|
).toEqual("replaced");
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expect((yield* getState("C"))?.status).toEqual("updated");
|
|
|
|
// Recovery
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expect((yield* getState("C"))?.status).toEqual("updated");
|
|
expect(output.C.string).toEqual("a-value-new");
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("delete order failures", () => {
|
|
test.provider("C delete fails - A and B waiting", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
yield* TestResource("C", { string: B.string });
|
|
}).pipe(stack.deploy);
|
|
|
|
yield* stack.destroy().pipe(hook(failOn("C", "delete")));
|
|
|
|
expect((yield* getState("C"))?.status).toEqual("deleting");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
|
|
// Recovery
|
|
yield* stack.destroy();
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
expectNotStarted(yield* getState("B"));
|
|
expectNotStarted(yield* getState("C"));
|
|
}),
|
|
);
|
|
|
|
test.provider("C deleted, B delete fails - A waiting", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
yield* TestResource("C", { string: B.string });
|
|
}).pipe(stack.deploy);
|
|
|
|
yield* stack.destroy().pipe(hook(failOn("B", "delete")));
|
|
|
|
expectNotStarted(yield* getState("C"));
|
|
expect((yield* getState("B"))?.status).toEqual("deleting");
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
|
|
// Recovery
|
|
yield* stack.destroy();
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
expectNotStarted(yield* getState("B"));
|
|
}),
|
|
);
|
|
|
|
test.provider("C and B deleted, A delete fails", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
yield* TestResource("C", { string: B.string });
|
|
}).pipe(stack.deploy);
|
|
|
|
yield* stack.destroy().pipe(hook(failOn("A", "delete")));
|
|
|
|
expectNotStarted(yield* getState("C"));
|
|
expectNotStarted(yield* getState("B"));
|
|
expect((yield* getState("A"))?.status).toEqual("deleting");
|
|
|
|
// Recovery
|
|
yield* stack.destroy();
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
}),
|
|
);
|
|
});
|
|
},
|
|
);
|
|
|
|
// =============================================================================
|
|
// DIAMOND DEPENDENCIES (D depends on B and C, both depend on A)
|
|
// A
|
|
// / \
|
|
// B C
|
|
// \ /
|
|
// D
|
|
// =============================================================================
|
|
|
|
describe(
|
|
"diamond dependencies (A -> B,C -> D)",
|
|
{ tags: ["unit", "local"] },
|
|
() => {
|
|
describe("happy path", () => {
|
|
test.provider("create all four resources", (stack) =>
|
|
Effect.gen(function* () {
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: A.string });
|
|
const D = yield* TestResource("D", {
|
|
string: Output.interpolate`${B.string}-${C.string}`,
|
|
});
|
|
return { A, B, C, D };
|
|
}).pipe(stack.deploy);
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect((yield* getState("C"))?.status).toEqual("created");
|
|
expect((yield* getState("D"))?.status).toEqual("created");
|
|
expect(output.D.string).toEqual("a-value-a-value");
|
|
}),
|
|
);
|
|
|
|
test.provider("update A propagates to B, C, and D", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: A.string });
|
|
yield* TestResource("D", {
|
|
string: Output.interpolate`${B.string}-${C.string}`,
|
|
});
|
|
}).pipe(stack.deploy);
|
|
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "updated" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: A.string });
|
|
const D = yield* TestResource("D", {
|
|
string: Output.interpolate`${B.string}-${C.string}`,
|
|
});
|
|
return { A, B, C, D };
|
|
}).pipe(stack.deploy);
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("updated");
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expect((yield* getState("C"))?.status).toEqual("updated");
|
|
expect((yield* getState("D"))?.status).toEqual("updated");
|
|
expect(output.D.string).toEqual("updated-updated");
|
|
}),
|
|
);
|
|
|
|
test.provider("add D while B replaces and C noops", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
yield* TestResource("B", {
|
|
string: Output.interpolate`${A.string}-b`,
|
|
replaceString: "b-original",
|
|
});
|
|
yield* TestResource("C", {
|
|
string: Output.interpolate`${A.string}-c`,
|
|
replaceString: "c-original",
|
|
});
|
|
}).pipe(stack.deploy);
|
|
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", {
|
|
string: Output.interpolate`${A.string}-b-replaced`,
|
|
replaceString: "b-changed",
|
|
});
|
|
const C = yield* TestResource("C", {
|
|
string: Output.interpolate`${A.string}-c`,
|
|
replaceString: "c-original",
|
|
});
|
|
const D = yield* TestResource("D", {
|
|
string: Output.interpolate`${B.string}-${C.string}`,
|
|
});
|
|
return { A, B, C, D };
|
|
}).pipe(stack.deploy);
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect((yield* getState("C"))?.status).toEqual("created");
|
|
expect((yield* getState("D"))?.status).toEqual("created");
|
|
expect(output.B.replaceString).toEqual("b-changed");
|
|
expect(output.C.replaceString).toEqual("c-original");
|
|
expect(output.D.string).toEqual("a-value-b-replaced-a-value-c");
|
|
}),
|
|
);
|
|
|
|
test.provider("add D while C replaces and B noops", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
yield* TestResource("B", {
|
|
string: Output.interpolate`${A.string}-b`,
|
|
replaceString: "b-original",
|
|
});
|
|
yield* TestResource("C", {
|
|
string: Output.interpolate`${A.string}-c`,
|
|
replaceString: "c-original",
|
|
});
|
|
}).pipe(stack.deploy);
|
|
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", {
|
|
string: Output.interpolate`${A.string}-b`,
|
|
replaceString: "b-original",
|
|
});
|
|
const C = yield* TestResource("C", {
|
|
string: Output.interpolate`${A.string}-c-replaced`,
|
|
replaceString: "c-changed",
|
|
});
|
|
const D = yield* TestResource("D", {
|
|
string: Output.interpolate`${B.string}-${C.string}`,
|
|
});
|
|
return { A, B, C, D };
|
|
}).pipe(stack.deploy);
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect((yield* getState("C"))?.status).toEqual("created");
|
|
expect((yield* getState("D"))?.status).toEqual("created");
|
|
expect(output.B.replaceString).toEqual("b-original");
|
|
expect(output.C.replaceString).toEqual("c-changed");
|
|
expect(output.D.string).toEqual("a-value-b-a-value-c-replaced");
|
|
}),
|
|
);
|
|
|
|
test.provider("add D while both B and C replace", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
yield* TestResource("B", {
|
|
string: Output.interpolate`${A.string}-b`,
|
|
replaceString: "b-original",
|
|
});
|
|
yield* TestResource("C", {
|
|
string: Output.interpolate`${A.string}-c`,
|
|
replaceString: "c-original",
|
|
});
|
|
}).pipe(stack.deploy);
|
|
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", {
|
|
string: Output.interpolate`${A.string}-b-replaced`,
|
|
replaceString: "b-changed",
|
|
});
|
|
const C = yield* TestResource("C", {
|
|
string: Output.interpolate`${A.string}-c-replaced`,
|
|
replaceString: "c-changed",
|
|
});
|
|
const D = yield* TestResource("D", {
|
|
string: Output.interpolate`${B.string}-${C.string}`,
|
|
});
|
|
return { A, B, C, D };
|
|
}).pipe(stack.deploy);
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect((yield* getState("C"))?.status).toEqual("created");
|
|
expect((yield* getState("D"))?.status).toEqual("created");
|
|
expect(output.B.replaceString).toEqual("b-changed");
|
|
expect(output.C.replaceString).toEqual("c-changed");
|
|
expect(output.D.string).toEqual(
|
|
"a-value-b-replaced-a-value-c-replaced",
|
|
);
|
|
}),
|
|
);
|
|
|
|
test.provider("delete all (D first, then B and C, then A)", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: A.string });
|
|
yield* TestResource("D", {
|
|
string: Output.interpolate`${B.string}-${C.string}`,
|
|
});
|
|
}).pipe(stack.deploy);
|
|
|
|
yield* stack.destroy();
|
|
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
expectNotStarted(yield* getState("B"));
|
|
expectNotStarted(yield* getState("C"));
|
|
expectNotStarted(yield* getState("D"));
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("creation failures", () => {
|
|
test.provider("A create fails - B, C, D never start", (stack) =>
|
|
Effect.gen(function* () {
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: A.string });
|
|
const D = yield* TestResource("D", {
|
|
string: Output.interpolate`${B.string}-${C.string}`,
|
|
});
|
|
return { A, B, C, D };
|
|
});
|
|
|
|
yield* program.pipe(stack.deploy, hook(failOn("A", "create")));
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("creating");
|
|
expectNotStarted(yield* getState("B"));
|
|
expectNotStarted(yield* getState("C"));
|
|
expectNotStarted(yield* getState("D"));
|
|
|
|
// Recovery
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect((yield* getState("C"))?.status).toEqual("created");
|
|
expect((yield* getState("D"))?.status).toEqual("created");
|
|
expect(output.D.string).toEqual("a-value-a-value");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"A creates, B create fails - C may create, D stuck",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: A.string });
|
|
const D = yield* TestResource("D", {
|
|
string: Output.interpolate`${B.string}-${C.string}`,
|
|
});
|
|
return { A, B, C, D };
|
|
});
|
|
|
|
yield* program.pipe(stack.deploy, hook(failOn("B", "create")));
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("creating");
|
|
// C might have been created since it doesn't depend on B
|
|
const cState = yield* getState("C");
|
|
expect(cState === undefined || cState?.status === "created").toBe(
|
|
true,
|
|
);
|
|
expectNotStarted(yield* getState("D"));
|
|
|
|
// Recovery
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect((yield* getState("C"))?.status).toEqual("created");
|
|
expect((yield* getState("D"))?.status).toEqual("created");
|
|
expect(output.D.string).toEqual("a-value-a-value");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"A creates, C create fails - B may create, D stuck",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: A.string });
|
|
const D = yield* TestResource("D", {
|
|
string: Output.interpolate`${B.string}-${C.string}`,
|
|
});
|
|
return { A, B, C, D };
|
|
});
|
|
|
|
yield* program.pipe(stack.deploy, hook(failOn("C", "create")));
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("C"))?.status).toEqual("creating");
|
|
// B might have been created since it doesn't depend on C
|
|
const bState = yield* getState("B");
|
|
expect(bState === undefined || bState?.status === "created").toBe(
|
|
true,
|
|
);
|
|
expectNotStarted(yield* getState("D"));
|
|
|
|
// Recovery
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect((yield* getState("C"))?.status).toEqual("created");
|
|
expect((yield* getState("D"))?.status).toEqual("created");
|
|
expect(output.D.string).toEqual("a-value-a-value");
|
|
}),
|
|
);
|
|
|
|
test.provider("A, B, C create - D create fails", (stack) =>
|
|
Effect.gen(function* () {
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: A.string });
|
|
const D = yield* TestResource("D", {
|
|
string: Output.interpolate`${B.string}-${C.string}`,
|
|
});
|
|
return { A, B, C, D };
|
|
});
|
|
|
|
yield* program.pipe(stack.deploy, hook(failOn("D", "create")));
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect((yield* getState("C"))?.status).toEqual("created");
|
|
expect((yield* getState("D"))?.status).toEqual("creating");
|
|
|
|
// Recovery
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expect((yield* getState("D"))?.status).toEqual("created");
|
|
expect(output.D.string).toEqual("a-value-a-value");
|
|
}),
|
|
);
|
|
|
|
test.provider("both B and C fail to create - D stuck", (stack) =>
|
|
Effect.gen(function* () {
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: A.string });
|
|
const D = yield* TestResource("D", {
|
|
string: Output.interpolate`${B.string}-${C.string}`,
|
|
});
|
|
return { A, B, C, D };
|
|
});
|
|
|
|
yield* program.pipe(
|
|
stack.deploy,
|
|
hook(
|
|
failOnMultiple([
|
|
{ id: "B", hook: "create" },
|
|
{ id: "C", hook: "create" },
|
|
]),
|
|
),
|
|
);
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
// effect terminates eagerly, so it's possible that B or C to run first and block C from running
|
|
const BState = yield* getState("B");
|
|
const CState = yield* getState("C");
|
|
expect(BState?.status).toBeOneOf(["creating", undefined]);
|
|
expect(CState?.status).toBeOneOf(["creating", undefined]);
|
|
// at leasst one of B or C should have been created
|
|
expect(BState?.status ?? CState?.status).toEqual("creating");
|
|
|
|
expectNotStarted(yield* getState("D"));
|
|
|
|
// Recovery
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect((yield* getState("C"))?.status).toEqual("created");
|
|
expect((yield* getState("D"))?.status).toEqual("created");
|
|
expect(output.D.string).toEqual("a-value-a-value");
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("update failures", () => {
|
|
test.provider("A update fails - B, C, D remain stable", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: A.string });
|
|
yield* TestResource("D", {
|
|
string: Output.interpolate`${B.string}-${C.string}`,
|
|
});
|
|
}).pipe(stack.deploy);
|
|
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "updated" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: A.string });
|
|
const D = yield* TestResource("D", {
|
|
string: Output.interpolate`${B.string}-${C.string}`,
|
|
});
|
|
return { A, B, C, D };
|
|
});
|
|
|
|
yield* program.pipe(stack.deploy, hook(failOn("A", "update")));
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("updating");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect((yield* getState("C"))?.status).toEqual("created");
|
|
expect((yield* getState("D"))?.status).toEqual("created");
|
|
|
|
// Recovery
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("updated");
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expect((yield* getState("C"))?.status).toEqual("updated");
|
|
expect((yield* getState("D"))?.status).toEqual("updated");
|
|
expect(output.D.string).toEqual("updated-updated");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"A updates, B update fails - C may update, D stuck",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: A.string });
|
|
yield* TestResource("D", {
|
|
string: Output.interpolate`${B.string}-${C.string}`,
|
|
});
|
|
}).pipe(stack.deploy);
|
|
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "updated" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: A.string });
|
|
const D = yield* TestResource("D", {
|
|
string: Output.interpolate`${B.string}-${C.string}`,
|
|
});
|
|
return { A, B, C, D };
|
|
});
|
|
|
|
yield* program.pipe(stack.deploy, hook(failOn("B", "update")));
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("updated");
|
|
expect((yield* getState("B"))?.status).toEqual("updating");
|
|
// C might have been updated since it doesn't depend on B
|
|
const cState = yield* getState("C");
|
|
expect(
|
|
cState?.status === "created" || cState?.status === "updated",
|
|
).toBe(true);
|
|
expect((yield* getState("D"))?.status).toEqual("created");
|
|
|
|
// Recovery
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expect((yield* getState("C"))?.status).toEqual("updated");
|
|
expect((yield* getState("D"))?.status).toEqual("updated");
|
|
expect(output.D.string).toEqual("updated-updated");
|
|
}),
|
|
);
|
|
|
|
test.provider("A, B, C update - D update fails", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: A.string });
|
|
yield* TestResource("D", {
|
|
string: Output.interpolate`${B.string}-${C.string}`,
|
|
});
|
|
}).pipe(stack.deploy);
|
|
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "updated" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: A.string });
|
|
const D = yield* TestResource("D", {
|
|
string: Output.interpolate`${B.string}-${C.string}`,
|
|
});
|
|
return { A, B, C, D };
|
|
});
|
|
|
|
yield* program.pipe(stack.deploy, hook(failOn("D", "update")));
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("updated");
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expect((yield* getState("C"))?.status).toEqual("updated");
|
|
expect((yield* getState("D"))?.status).toEqual("updating");
|
|
|
|
// Recovery
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expect((yield* getState("D"))?.status).toEqual("updated");
|
|
expect(output.D.string).toEqual("updated-updated");
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("delete failures", () => {
|
|
test.provider("D delete fails - B, C, A waiting", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: A.string });
|
|
yield* TestResource("D", {
|
|
string: Output.interpolate`${B.string}-${C.string}`,
|
|
});
|
|
}).pipe(stack.deploy);
|
|
|
|
yield* stack.destroy().pipe(hook(failOn("D", "delete")));
|
|
|
|
expect((yield* getState("D"))?.status).toEqual("deleting");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect((yield* getState("C"))?.status).toEqual("created");
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
|
|
// Recovery
|
|
yield* stack.destroy();
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
expectNotStarted(yield* getState("B"));
|
|
expectNotStarted(yield* getState("C"));
|
|
expectNotStarted(yield* getState("D"));
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"D deleted, B delete fails - C may delete, A waiting",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: A.string });
|
|
yield* TestResource("D", {
|
|
string: Output.interpolate`${B.string}-${C.string}`,
|
|
});
|
|
}).pipe(stack.deploy);
|
|
|
|
yield* stack.destroy().pipe(hook(failOn("B", "delete")));
|
|
|
|
expectNotStarted(yield* getState("D"));
|
|
expect((yield* getState("B"))?.status).toEqual("deleting");
|
|
// C may or may not be deleted depending on execution order
|
|
const cState = yield* getState("C");
|
|
expect(cState === undefined || cState?.status === "created").toBe(
|
|
true,
|
|
);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
|
|
// Recovery
|
|
yield* stack.destroy();
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
expectNotStarted(yield* getState("B"));
|
|
expectNotStarted(yield* getState("C"));
|
|
}),
|
|
);
|
|
});
|
|
},
|
|
);
|
|
|
|
// =============================================================================
|
|
// INDEPENDENT RESOURCES (no dependencies between them)
|
|
// =============================================================================
|
|
|
|
describe(
|
|
"independent resources (A, B with no dependencies)",
|
|
{ tags: ["unit", "local"] },
|
|
() => {
|
|
describe("parallel failures", () => {
|
|
test.provider("both A and B fail to create", (stack) =>
|
|
Effect.gen(function* () {
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: "b-value" });
|
|
return { A, B };
|
|
});
|
|
|
|
yield* program.pipe(
|
|
stack.deploy,
|
|
hook(
|
|
failOnMultiple([
|
|
{ id: "A", hook: "create" },
|
|
{ id: "B", hook: "create" },
|
|
]),
|
|
),
|
|
);
|
|
|
|
// effect terminates eagerly, so it's possible that A or B runs first and blocks the other from running
|
|
const AState = yield* getState("A");
|
|
const BState = yield* getState("B");
|
|
expect(AState?.status).toBeOneOf(["creating", undefined]);
|
|
expect(BState?.status).toBeOneOf(["creating", undefined]);
|
|
// at least one of A or B should have been creating
|
|
expect(AState?.status ?? BState?.status).toEqual("creating");
|
|
|
|
// Recovery
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect(output.A.string).toEqual("a-value");
|
|
expect(output.B.string).toEqual("b-value");
|
|
}),
|
|
);
|
|
|
|
test.provider("A creates, B fails - recovery creates B", (stack) =>
|
|
Effect.gen(function* () {
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: "b-value" });
|
|
return { A, B };
|
|
});
|
|
|
|
yield* program.pipe(stack.deploy, hook(failOn("B", "create")));
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("creating");
|
|
|
|
// Recovery
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect(output.B.string).toEqual("b-value");
|
|
}),
|
|
);
|
|
|
|
test.provider("A update fails, B update succeeds", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", { string: "a-value" });
|
|
yield* TestResource("B", { string: "b-value" });
|
|
}).pipe(stack.deploy);
|
|
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-updated" });
|
|
const B = yield* TestResource("B", { string: "b-updated" });
|
|
return { A, B };
|
|
});
|
|
|
|
yield* program.pipe(stack.deploy, hook(failOn("A", "update")));
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("updating");
|
|
// B might have been updated
|
|
const bState = yield* getState("B");
|
|
expect(
|
|
bState?.status === "created" || bState?.status === "updated",
|
|
).toBe(true);
|
|
|
|
// Recovery
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("updated");
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expect(output.A.string).toEqual("a-updated");
|
|
expect(output.B.string).toEqual("b-updated");
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("mixed state recovery", () => {
|
|
test.provider(
|
|
"A in creating, B in updating state - recovery completes both",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// First create B successfully
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("B", { string: "b-value" });
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
|
|
// Now try to create A and update B - A fails
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: "b-updated" });
|
|
return { A, B };
|
|
});
|
|
|
|
yield* program.pipe(
|
|
stack.deploy,
|
|
hook(
|
|
failOnMultiple([
|
|
{ id: "A", hook: "create" },
|
|
{ id: "B", hook: "update" },
|
|
]),
|
|
),
|
|
);
|
|
|
|
// effect terminates eagerly, so it's possible that A or B runs first and blocks the other from running
|
|
const AState = yield* getState("A");
|
|
const BState = yield* getState("B");
|
|
expect(AState?.status).toBeOneOf(["creating", undefined]);
|
|
expect(BState?.status).toBeOneOf(["created", "updating"]);
|
|
// at least one of A or B should have started their failing operation
|
|
expect(
|
|
AState?.status === "creating" || BState?.status === "updating",
|
|
).toBe(true);
|
|
|
|
// Recovery
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expect(output.A.string).toEqual("a-value");
|
|
expect(output.B.string).toEqual("b-updated");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"A in replacing, B in deleting state - complex recovery",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// Create both
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", { replaceString: "original" });
|
|
yield* TestResource("B", { string: "b-value" });
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
|
|
// Try to replace A and delete B (by not including B) - both fail
|
|
const program = Effect.gen(function* () {
|
|
yield* TestResource("A", { replaceString: "changed" });
|
|
});
|
|
|
|
yield* program.pipe(
|
|
stack.deploy,
|
|
hook(
|
|
failOnMultiple([
|
|
{ id: "A", hook: "create" },
|
|
{ id: "B", hook: "delete" },
|
|
]),
|
|
),
|
|
);
|
|
|
|
// effect terminates eagerly, so it's possible that A or B runs first and blocks the other from running
|
|
const AState = yield* getState<ReplacingResourceState>("A");
|
|
const BState = yield* getState("B");
|
|
expect(AState?.status).toBeOneOf(["created", "replacing"]);
|
|
expect(BState?.status).toBeOneOf(["created", "deleting"]);
|
|
// at least one of A or B should have started their failing operation
|
|
expect(
|
|
AState?.status === "replacing" || BState?.status === "deleting",
|
|
).toBe(true);
|
|
|
|
// Recovery - complete the replace and delete
|
|
yield* program.pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expectNotStarted(yield* getState("B"));
|
|
}),
|
|
);
|
|
});
|
|
},
|
|
);
|
|
|
|
// =============================================================================
|
|
// MULTIPLE RESOURCES REPLACING SIMULTANEOUSLY
|
|
// =============================================================================
|
|
|
|
describe("multiple resources replacing", { tags: ["unit", "local"] }, () => {
|
|
test.provider("two independent resources replace successfully", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", { replaceString: "a-original" });
|
|
yield* TestResource("B", { replaceString: "b-original" });
|
|
}).pipe(stack.deploy);
|
|
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { replaceString: "a-new" });
|
|
const B = yield* TestResource("B", { replaceString: "b-new" });
|
|
return { A, B };
|
|
}).pipe(stack.deploy);
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect(output.A.replaceString).toEqual("a-new");
|
|
expect(output.B.replaceString).toEqual("b-new");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"A replace fails, B replace succeeds - recovery completes A",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", { replaceString: "a-original" });
|
|
yield* TestResource("B", { replaceString: "b-original" });
|
|
}).pipe(stack.deploy);
|
|
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { replaceString: "a-new" });
|
|
const B = yield* TestResource("B", { replaceString: "b-new" });
|
|
return { A, B };
|
|
});
|
|
|
|
yield* program.pipe(stack.deploy, hook(failOn("A", "create")));
|
|
|
|
expect((yield* getState<ReplacingResourceState>("A"))?.status).toEqual(
|
|
"replacing",
|
|
);
|
|
// B might have been replaced
|
|
const bState = yield* getState("B");
|
|
expect(
|
|
bState?.status === "created" ||
|
|
bState?.status === "replacing" ||
|
|
bState?.status === "replaced",
|
|
).toBe(true);
|
|
|
|
// Recovery
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect(output.A.replaceString).toEqual("a-new");
|
|
expect(output.B.replaceString).toEqual("b-new");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"both A and B replace fail - recovery completes both",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", { replaceString: "a-original" });
|
|
yield* TestResource("B", { replaceString: "b-original" });
|
|
}).pipe(stack.deploy);
|
|
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { replaceString: "a-new" });
|
|
const B = yield* TestResource("B", { replaceString: "b-new" });
|
|
return { A, B };
|
|
});
|
|
|
|
yield* program.pipe(
|
|
stack.deploy,
|
|
hook(
|
|
failOnMultiple([
|
|
{ id: "A", hook: "create" },
|
|
{ id: "B", hook: "create" },
|
|
]),
|
|
),
|
|
);
|
|
|
|
// effect terminates eagerly, so it's possible that A or B runs first and blocks the other from running
|
|
const AState = yield* getState<ReplacingResourceState>("A");
|
|
const BState = yield* getState<ReplacingResourceState>("B");
|
|
expect(AState?.status).toBeOneOf(["created", "replacing"]);
|
|
expect(BState?.status).toBeOneOf(["created", "replacing"]);
|
|
// at least one of A or B should have started replacing
|
|
expect(
|
|
AState?.status === "replacing" || BState?.status === "replacing",
|
|
).toBe(true);
|
|
|
|
// Recovery
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect(output.A.replaceString).toEqual("a-new");
|
|
expect(output.B.replaceString).toEqual("b-new");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"A replaced, B replacing - old A delete fails, B create fails - recovery completes both",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", { replaceString: "a-original" });
|
|
yield* TestResource("B", { replaceString: "b-original" });
|
|
}).pipe(stack.deploy);
|
|
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { replaceString: "a-new" });
|
|
const B = yield* TestResource("B", { replaceString: "b-new" });
|
|
return { A, B };
|
|
});
|
|
|
|
yield* program.pipe(
|
|
stack.deploy,
|
|
hook(
|
|
failOnMultiple([
|
|
{ id: "A", hook: "delete" },
|
|
{ id: "B", hook: "create" },
|
|
]),
|
|
),
|
|
);
|
|
|
|
// effect terminates eagerly, so it's possible that A or B runs first and blocks the other from running
|
|
// A should be replaced (new created, old pending delete) or still replacing/created if B failed first
|
|
// B should be replacing (new not yet created) or already created if A failed first
|
|
const AState = yield* getState<ReplacedResourceState>("A");
|
|
const BState = yield* getState<ReplacingResourceState>("B");
|
|
expect(AState?.status).toBeOneOf(["created", "replacing", "replaced"]);
|
|
expect(BState?.status).toBeOneOf(["created", "replacing"]);
|
|
// at least one of A or B should have started their failing operation
|
|
expect(
|
|
AState?.status === "replaced" || BState?.status === "replacing",
|
|
).toBe(true);
|
|
|
|
// Recovery
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect(output.A.replaceString).toEqual("a-new");
|
|
expect(output.B.replaceString).toEqual("b-new");
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("repeated replacements", { tags: ["unit", "local"] }, () => {
|
|
test.provider(
|
|
"resource can be replaced again while still in replacing state",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", { replaceString: "a-original" });
|
|
}).pipe(stack.deploy);
|
|
|
|
const firstReplacement = Effect.gen(function* () {
|
|
yield* TestResource("A", { replaceString: "a-first" });
|
|
});
|
|
|
|
yield* firstReplacement.pipe(stack.deploy, hook(failOn("A", "create")));
|
|
|
|
const replacingState = yield* getState<ReplacingResourceState>("A");
|
|
expect(replacingState?.status).toEqual("replacing");
|
|
|
|
const secondReplacement = Effect.gen(function* () {
|
|
yield* TestResource("A", { replaceString: "a-second" });
|
|
});
|
|
|
|
yield* secondReplacement.pipe(stack.deploy);
|
|
|
|
const finalState = yield* getState("A");
|
|
expectConvergedStatus(finalState?.status);
|
|
expect(finalState?.props?.replaceString).toEqual("a-second");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"resource can be replaced again while still in replaced state",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", { replaceString: "a-original" });
|
|
}).pipe(stack.deploy);
|
|
|
|
const firstReplacement = Effect.gen(function* () {
|
|
yield* TestResource("A", { replaceString: "a-first" });
|
|
});
|
|
|
|
yield* firstReplacement.pipe(stack.deploy, hook(failOn("A", "delete")));
|
|
|
|
const replacedState = yield* getState<ReplacedResourceState>("A");
|
|
expect(replacedState?.status).toEqual("replaced");
|
|
|
|
const secondReplacement = Effect.gen(function* () {
|
|
yield* TestResource("A", { replaceString: "a-second" });
|
|
});
|
|
|
|
yield* secondReplacement.pipe(stack.deploy);
|
|
|
|
const finalState = yield* getState("A");
|
|
expectConvergedStatus(finalState?.status);
|
|
expect(finalState?.props?.replaceString).toEqual("a-second");
|
|
}),
|
|
);
|
|
});
|
|
|
|
// =============================================================================
|
|
// ORPHAN CHAIN DELETION
|
|
// =============================================================================
|
|
|
|
describe("orphan chain deletion", { tags: ["unit", "local"] }, () => {
|
|
test.provider("three-level orphan chain deleted in correct order", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
yield* TestResource("C", { string: B.string });
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect((yield* getState("C"))?.status).toEqual("created");
|
|
|
|
// Remove C from graph - should delete C only
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
yield* TestResource("B", { string: A.string });
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expectNotStarted(yield* getState("C"));
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"orphan with intermediate failure recovers correctly",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
yield* TestResource("C", { string: B.string });
|
|
}).pipe(stack.deploy);
|
|
|
|
// Remove all three - C fails to delete
|
|
yield* stack.destroy().pipe(hook(failOn("C", "delete")));
|
|
|
|
expect((yield* getState("C"))?.status).toEqual("deleting");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
|
|
// Recovery
|
|
yield* stack.destroy();
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
expectNotStarted(yield* getState("B"));
|
|
expectNotStarted(yield* getState("C"));
|
|
}),
|
|
);
|
|
|
|
test.provider("partial orphan - remove leaf, add new dependent", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
yield* TestResource("B", { string: A.string });
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
|
|
// Remove B, add C dependent on A
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-value" });
|
|
const C = yield* TestResource("C", { string: A.string });
|
|
return { A, C };
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expectNotStarted(yield* getState("B"));
|
|
expect((yield* getState("C"))?.status).toEqual("created");
|
|
expect(output.C.string).toEqual("a-value");
|
|
}),
|
|
);
|
|
});
|
|
|
|
// =============================================================================
|
|
// COMPLEX MIXED STATE SCENARIOS
|
|
// =============================================================================
|
|
|
|
describe("complex mixed state scenarios", { tags: ["unit", "local"] }, () => {
|
|
test.provider("replace upstream while creating downstream", (stack) =>
|
|
Effect.gen(function* () {
|
|
// Create A
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
string: "a-value",
|
|
replaceString: "original",
|
|
});
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
|
|
// Now add B dependent on A, and also replace A
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "a-value-new",
|
|
replaceString: "changed",
|
|
});
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
return { A, B };
|
|
}).pipe(stack.deploy);
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect(output.A.string).toEqual("a-value-new");
|
|
expect(output.B.string).toEqual("a-value-new");
|
|
}),
|
|
);
|
|
|
|
test.provider("update upstream, create and delete in same apply", (stack) =>
|
|
Effect.gen(function* () {
|
|
// Create A and B
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", { string: "a-value" });
|
|
yield* TestResource("B", { string: "b-value" });
|
|
}).pipe(stack.deploy);
|
|
|
|
// Update A, delete B (by not including), create C
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a-updated" });
|
|
const C = yield* TestResource("C", { string: A.string });
|
|
return { A, C };
|
|
}).pipe(stack.deploy);
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("updated");
|
|
expectNotStarted(yield* getState("B"));
|
|
expect((yield* getState("C"))?.status).toEqual("created");
|
|
expect(output.C.string).toEqual("a-updated");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"chain reaction: A replace triggers B update triggers C update",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "a-value",
|
|
replaceString: "original",
|
|
});
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
yield* TestResource("C", { string: B.string });
|
|
}).pipe(stack.deploy);
|
|
|
|
// Replace A - should cascade updates to B and C
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "a-replaced",
|
|
replaceString: "changed",
|
|
});
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
const C = yield* TestResource("C", { string: B.string });
|
|
return { A, B, C };
|
|
}).pipe(stack.deploy);
|
|
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expect((yield* getState("C"))?.status).toEqual("updated");
|
|
expect(output.C.string).toEqual("a-replaced");
|
|
}),
|
|
);
|
|
|
|
test.provider("multiple failures across all operation types", (stack) =>
|
|
Effect.gen(function* () {
|
|
// Setup: A, B created; C, D will be added
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("A", {
|
|
string: "a-value",
|
|
replaceString: "original",
|
|
});
|
|
yield* TestResource("B", { string: "b-value" });
|
|
}).pipe(stack.deploy);
|
|
|
|
// Complex operation: A replace, B update, C create, D not included (nothing to delete)
|
|
const program = Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "a-replaced",
|
|
replaceString: "changed",
|
|
});
|
|
const B = yield* TestResource("B", { string: "b-updated" });
|
|
const C = yield* TestResource("C", { string: "c-value" });
|
|
return { A, B, C };
|
|
});
|
|
|
|
// Fail on A replace (create phase) and C create
|
|
yield* program.pipe(
|
|
stack.deploy,
|
|
hook(
|
|
failOnMultiple([
|
|
{ id: "A", hook: "create" },
|
|
{ id: "C", hook: "create" },
|
|
]),
|
|
),
|
|
);
|
|
|
|
// effect terminates eagerly, so it's possible that A or C runs first and blocks the other from running
|
|
const AState = yield* getState<ReplacingResourceState>("A");
|
|
// B might have been updated
|
|
const bState = yield* getState("B");
|
|
expect(bState?.status === "created" || bState?.status === "updated").toBe(
|
|
true,
|
|
);
|
|
const CState = yield* getState("C");
|
|
expect(AState?.status).toBeOneOf(["created", "replacing"]);
|
|
expect(CState?.status).toBeOneOf(["creating", undefined]);
|
|
// at least one of A or C should have started their failing operation
|
|
expect(
|
|
AState?.status === "replacing" || CState?.status === "creating",
|
|
).toBe(true);
|
|
|
|
// Recovery
|
|
const output = yield* program.pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
expect((yield* getState("C"))?.status).toEqual("created");
|
|
expect(output.A.replaceString).toEqual("changed");
|
|
expect(output.B.string).toEqual("b-updated");
|
|
expect(output.C.string).toEqual("c-value");
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("artifacts", { tags: ["unit", "local"] }, () => {
|
|
test.provider("shares artifacts from plan diff into apply update", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
yield* ArtifactProbe("A", { value: "v1" });
|
|
}).pipe(stack.deploy);
|
|
|
|
const updated = yield* Effect.gen(function* () {
|
|
const A = yield* ArtifactProbe("A", { value: "v2" });
|
|
return { A };
|
|
}).pipe(stack.deploy);
|
|
|
|
expect(updated.A.value).toEqual("v2");
|
|
expect(updated.A.artifactValue).toEqual("v2");
|
|
expect((yield* getState("A"))?.status).toEqual("updated");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"isolates artifact bags by FQN for namespaced resources with the same leaf logical ID",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const Site = (id: string, props: { value: string }) =>
|
|
Effect.gen(function* () {
|
|
return yield* ArtifactProbe("Shared", { value: props.value });
|
|
}).pipe(Namespace.push(id));
|
|
|
|
yield* Effect.gen(function* () {
|
|
yield* Site("Left", { value: "left-v1" });
|
|
yield* Site("Right", { value: "right-v1" });
|
|
}).pipe(stack.deploy);
|
|
|
|
const updated = yield* Effect.gen(function* () {
|
|
const left = yield* Site("Left", { value: "left-v2" });
|
|
const right = yield* Site("Right", { value: "right-v2" });
|
|
return { left, right };
|
|
}).pipe(stack.deploy);
|
|
|
|
expect(updated.left.artifactValue).toEqual("left-v2");
|
|
expect(updated.right.artifactValue).toEqual("right-v2");
|
|
expect((yield* getState("Left/Shared"))?.status).toEqual("updated");
|
|
expect((yield* getState("Right/Shared"))?.status).toEqual("updated");
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe(
|
|
"resource identity (fqn) threading",
|
|
{ tags: ["unit", "local"] },
|
|
() => {
|
|
test.provider(
|
|
"threads the resource's fully-qualified name into handler inputs, distinct from the logical id",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// Deploy the probe under a namespace so its FQN ("Parent/leaf") is
|
|
// NOT its bare logical id ("leaf"). The provider echoes both the `id`
|
|
// and `fqn` it received back out as attributes.
|
|
const { probe } = yield* Effect.gen(function* () {
|
|
const probe = yield* Effect.gen(function* () {
|
|
return yield* FqnProbe("leaf", {});
|
|
}).pipe(Namespace.push("Parent"));
|
|
return { probe };
|
|
}).pipe(stack.deploy);
|
|
|
|
// The engine passes the leaf logical id as `id` and the full
|
|
// namespace-qualified name as `fqn`.
|
|
expect(probe.id).toEqual("leaf");
|
|
expect(probe.fqn).toEqual("Parent/leaf");
|
|
expect((yield* getState("Parent/leaf"))?.status).toEqual("created");
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
// =============================================================================
|
|
// WHOLE-RESOURCE REFS RE-RESOLVE FRESH ATTRS AT APPLY
|
|
// The plan materializes a whole-resource reference to an *updating* upstream
|
|
// into its stable attributes for the downstream's `diff` — but the node's
|
|
// props keep the evaluable reference, so `reconcile` receives the upstream's
|
|
// fresh post-reconcile attributes, non-stable ones included. Baking the
|
|
// stables-only snapshot into node.props left e.g. a Lambda Alias pointing at
|
|
// the previous Lambda Version forever (#993's alias promotion bug).
|
|
// =============================================================================
|
|
|
|
describe(
|
|
"whole-resource refs re-resolve fresh attrs at apply",
|
|
{ tags: ["unit", "local"] },
|
|
() => {
|
|
test.provider(
|
|
"downstream reconcile sees the upstream's fresh non-stable attributes",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const observed: TestResourceProps[] = [];
|
|
const capture = hook({
|
|
create: () => Effect.void,
|
|
update: (id, props) =>
|
|
Effect.sync(() => {
|
|
if (id === "B") {
|
|
observed.push(props);
|
|
}
|
|
}),
|
|
delete: () => Effect.void,
|
|
});
|
|
|
|
const program = (version: string) =>
|
|
Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: version });
|
|
// B references the WHOLE upstream resource, not a single prop.
|
|
return yield* TestResource("B", { object: A as any });
|
|
});
|
|
|
|
yield* program("v1").pipe(stack.deploy, capture);
|
|
|
|
// A updates in place: the non-stable `string` changes while
|
|
// `stableString` / `stableArray` stay put. B must re-reconcile
|
|
// against A's FRESH attributes — not the stables-only snapshot the
|
|
// plan hands B's diff.
|
|
yield* program("v2").pipe(stack.deploy, capture);
|
|
|
|
expect(observed).toHaveLength(1);
|
|
const object = observed[0]!.object as any;
|
|
expect(object.string).toBe("v2");
|
|
expect(object.stableString).toBe("A");
|
|
|
|
// The persisted props captured the fully-resolved attrs, so the next
|
|
// no-op deploy diffs full-against-full instead of churning.
|
|
const persisted = yield* getState("B");
|
|
expect((persisted?.props as any).object.string).toBe("v2");
|
|
|
|
yield* stack.destroy().pipe(capture);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"host reconcile sees the upstream's fresh non-stable attributes through a binding",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// Captures the DIFF-facing binding rows the host provider observes
|
|
// at plan time (materialized stables-only snapshots).
|
|
const diffObserved: any[] = [];
|
|
const capture = <A, Err, Req>(test: Effect.Effect<A, Err, Req>) =>
|
|
test.pipe(
|
|
Effect.provide(
|
|
Layer.succeed(TestResourceHooks, {
|
|
diff: (id, newBindings) =>
|
|
Effect.sync(() => {
|
|
if (id === "Host") {
|
|
diffObserved.push(newBindings);
|
|
}
|
|
}),
|
|
}),
|
|
),
|
|
);
|
|
|
|
const program = (version: string) =>
|
|
Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: version });
|
|
const host = yield* BindingTarget("Host", { name: "host" });
|
|
// The binding data embeds the WHOLE upstream resource.
|
|
yield* host.bind("FromA", { env: { A } } as any);
|
|
return host;
|
|
});
|
|
|
|
yield* program("v1").pipe(stack.deploy, capture);
|
|
const created = yield* getState("Host");
|
|
expect((created?.bindings as any)[0].data.env.A.string).toBe("v1");
|
|
|
|
// A updates in place: the host's `diff` compares against the
|
|
// materialized stables-only snapshot, but the binding payload the
|
|
// host's `reconcile` receives must re-resolve to A's FRESH
|
|
// post-reconcile attributes at apply.
|
|
yield* program("v2").pipe(stack.deploy, capture);
|
|
|
|
// The plan-time diff saw the stables-only materialization.
|
|
const lastDiff = diffObserved.at(-1);
|
|
expect(lastDiff[0].data.env.A).toEqual({
|
|
stableString: "A",
|
|
stableArray: ["A"],
|
|
});
|
|
|
|
// The reconciled attr merged the fresh payload...
|
|
const updated = yield* getState("Host");
|
|
expect((updated?.attr as any).env.A.string).toBe("v2");
|
|
// ...and the terminal commit persisted the RESOLVED payload the
|
|
// provider reconciled with (#874).
|
|
const bound = (updated?.bindings as any)[0].data.env.A;
|
|
expect(bound.string).toBe("v2");
|
|
expect(bound.stableString).toBe("A");
|
|
|
|
// With full attrs persisted, the next plan diffs full-against-full
|
|
// instead of churning on the stables-only snapshot.
|
|
const rePlan = yield* program("v2").pipe(stack.plan, capture);
|
|
expect((rePlan.resources as any).Host.action).toBe("noop");
|
|
|
|
yield* stack.destroy().pipe(capture);
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
// =============================================================================
|
|
// STATIC STABLE PROPERTIES (provider.stables defined on provider, not in diff)
|
|
// This tests the bug where diff returns undefined but downstream resources
|
|
// depend on stable properties that should be preserved
|
|
// =============================================================================
|
|
|
|
describe(
|
|
"static stable properties (provider.stables)",
|
|
{ tags: ["unit", "local"] },
|
|
() => {
|
|
describe("diff returns undefined with tag-only changes", () => {
|
|
test.provider(
|
|
"upstream has static stables, diff returns undefined, downstream depends on stableId",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// Stage 1: Create A with no tags, B depends on A.stableId
|
|
{
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* StaticStablesResource("A", {
|
|
string: "value",
|
|
});
|
|
const B = yield* TestResource("B", { string: A.stableId });
|
|
return { A, B };
|
|
}).pipe(stack.deploy);
|
|
expect(output.A.stableId).toEqual("stable-A");
|
|
expect(output.B.string).toEqual("stable-A");
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
}
|
|
|
|
// Stage 2: Add tags to A - diff returns undefined, but arePropsChanged is true
|
|
// B depends on A.stableId which should remain stable
|
|
{
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* StaticStablesResource("A", {
|
|
string: "value",
|
|
tags: { Name: "tagged-resource" },
|
|
});
|
|
const B = yield* TestResource("B", { string: A.stableId });
|
|
return { A, B };
|
|
}).pipe(stack.deploy);
|
|
// A should be updated (tags changed)
|
|
expect(output.A.tags).toEqual({ Name: "tagged-resource" });
|
|
// B should NOT be updated because stableId didn't change
|
|
expect(output.B.string).toEqual("stable-A");
|
|
expect((yield* getState("A"))?.status).toEqual("updated");
|
|
// B should remain "created" (noop) since its input (stableId) didn't change
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
}
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"chain: A -> B -> C where B depends on A.stableId and C depends on B.stableString",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// Stage 1: Create chain
|
|
{
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* StaticStablesResource("A", {
|
|
string: "initial",
|
|
});
|
|
const B = yield* TestResource("B", { string: A.stableId });
|
|
const C = yield* TestResource("C", { string: B.stableString });
|
|
return { A, B, C };
|
|
}).pipe(stack.deploy);
|
|
expect(output.A.stableId).toEqual("stable-A");
|
|
expect(output.B.string).toEqual("stable-A");
|
|
expect(output.C.string).toEqual("B");
|
|
}
|
|
|
|
// Stage 2: Change A's tags only - diff returns undefined
|
|
// Neither B nor C should update since their inputs are stable
|
|
{
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* StaticStablesResource("A", {
|
|
string: "initial",
|
|
tags: { Env: "production" },
|
|
});
|
|
const B = yield* TestResource("B", { string: A.stableId });
|
|
const C = yield* TestResource("C", { string: B.stableString });
|
|
return { A, B, C };
|
|
}).pipe(stack.deploy);
|
|
expect(output.A.tags).toEqual({ Env: "production" });
|
|
expect((yield* getState("A"))?.status).toEqual("updated");
|
|
// B and C should not change
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect((yield* getState("C"))?.status).toEqual("created");
|
|
}
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"diamond: A -> B,C -> D where all depend on stable properties",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// Stage 1: Create diamond
|
|
{
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* StaticStablesResource("A", {
|
|
string: "initial",
|
|
});
|
|
const B = yield* TestResource("B", { string: A.stableId });
|
|
const C = yield* TestResource("C", { string: A.stableArn });
|
|
const D = yield* TestResource("D", {
|
|
string: Output.interpolate`${B.stableString}-${C.stableString}`,
|
|
});
|
|
return { A, B, C, D };
|
|
}).pipe(stack.deploy);
|
|
expect(output.A.stableId).toEqual("stable-A");
|
|
expect(output.A.stableArn).toEqual(
|
|
"arn:test:resource:us-east-1:123456789:A",
|
|
);
|
|
expect(output.B.string).toEqual("stable-A");
|
|
expect(output.C.string).toEqual(
|
|
"arn:test:resource:us-east-1:123456789:A",
|
|
);
|
|
expect(output.D.string).toEqual("B-C");
|
|
}
|
|
|
|
// Stage 2: Change A's tags - should not affect B, C, or D
|
|
{
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* StaticStablesResource("A", {
|
|
string: "initial",
|
|
tags: { Team: "platform" },
|
|
});
|
|
const B = yield* TestResource("B", { string: A.stableId });
|
|
const C = yield* TestResource("C", { string: A.stableArn });
|
|
yield* TestResource("D", {
|
|
string: Output.interpolate`${B.stableString}-${C.stableString}`,
|
|
});
|
|
}).pipe(stack.deploy);
|
|
expect((yield* getState("A"))?.status).toEqual("updated");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect((yield* getState("C"))?.status).toEqual("created");
|
|
expect((yield* getState("D"))?.status).toEqual("created");
|
|
}
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("diff returns update action with static stables", () => {
|
|
test.provider(
|
|
"upstream has static stables and diff returns update, downstream depends on stableId",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// Stage 1: Create A and B
|
|
{
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* StaticStablesResource("A", {
|
|
string: "value-1",
|
|
});
|
|
const B = yield* TestResource("B", { string: A.stableId });
|
|
return { A, B };
|
|
}).pipe(stack.deploy);
|
|
expect(output.A.stableId).toEqual("stable-A");
|
|
expect(output.B.string).toEqual("stable-A");
|
|
}
|
|
|
|
// Stage 2: Change A's string - diff returns "update", stableId still stable
|
|
{
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* StaticStablesResource("A", {
|
|
string: "value-2",
|
|
});
|
|
const B = yield* TestResource("B", { string: A.stableId });
|
|
return { A, B };
|
|
}).pipe(stack.deploy);
|
|
expect(output.A.string).toEqual("value-2");
|
|
expect(output.A.stableId).toEqual("stable-A");
|
|
expect((yield* getState("A"))?.status).toEqual("updated");
|
|
// B should not change since stableId is stable
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
}
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"downstream depends on non-stable property, should update",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// Stage 1: Create A and B where B depends on A.string (non-stable)
|
|
{
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* StaticStablesResource("A", {
|
|
string: "value-1",
|
|
});
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
return { A, B };
|
|
}).pipe(stack.deploy);
|
|
expect(output.A.string).toEqual("value-1");
|
|
expect(output.B.string).toEqual("value-1");
|
|
}
|
|
|
|
// Stage 2: Change A's string - B should update
|
|
{
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* StaticStablesResource("A", {
|
|
string: "value-2",
|
|
});
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
return { A, B };
|
|
}).pipe(stack.deploy);
|
|
expect(output.A.string).toEqual("value-2");
|
|
expect(output.B.string).toEqual("value-2");
|
|
expect((yield* getState("A"))?.status).toEqual("updated");
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
}
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("replace action with static stables", () => {
|
|
test.provider(
|
|
"upstream replaces, downstream depends on stableId - should update with new value",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// Stage 1: Create A and B
|
|
{
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* StaticStablesResource("A", {
|
|
string: "value",
|
|
replaceString: "original",
|
|
});
|
|
const B = yield* TestResource("B", { string: A.stableId });
|
|
return { A, B };
|
|
}).pipe(stack.deploy);
|
|
expect(output.A.stableId).toEqual("stable-A");
|
|
expect(output.B.string).toEqual("stable-A");
|
|
}
|
|
|
|
// Stage 2: Replace A - stableId will change (new resource)
|
|
{
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* StaticStablesResource("A", {
|
|
string: "value",
|
|
replaceString: "changed",
|
|
});
|
|
const B = yield* TestResource("B", { string: A.stableId });
|
|
return { A, B };
|
|
}).pipe(stack.deploy);
|
|
// A was replaced, stableId is regenerated
|
|
expect(output.A.stableId).toEqual("stable-A");
|
|
expect(output.B.string).toEqual("stable-A");
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
expect((yield* getState("B"))?.status).toEqual("updated");
|
|
}
|
|
}),
|
|
);
|
|
});
|
|
},
|
|
);
|
|
|
|
describe(
|
|
"Redacted props/outputs survive deploy",
|
|
{ tags: ["unit", "local"] },
|
|
() => {
|
|
test.provider(
|
|
"preserves a Redacted prop end-to-end through create",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const secret = Redacted.make("hunter2");
|
|
const created = yield* Effect.gen(function* () {
|
|
return yield* TestResource("A", {
|
|
string: "x",
|
|
redacted: secret,
|
|
});
|
|
}).pipe(stack.deploy);
|
|
|
|
expect(Redacted.isRedacted(created.redacted)).toBe(true);
|
|
expect(Redacted.value(created.redacted!)).toBe("hunter2");
|
|
|
|
const state = yield* getState("A");
|
|
expect(state).toBeDefined();
|
|
expect(Redacted.isRedacted((state!.props as any).redacted)).toBe(
|
|
true,
|
|
);
|
|
expect(Redacted.value((state!.props as any).redacted)).toBe(
|
|
"hunter2",
|
|
);
|
|
expect(Redacted.isRedacted((state!.attr as any).redacted)).toBe(true);
|
|
expect(Redacted.value((state!.attr as any).redacted)).toBe("hunter2");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"preserves Redacted values nested inside an array end-to-end",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const created = yield* Effect.gen(function* () {
|
|
return yield* TestResource("A", {
|
|
string: "x",
|
|
redactedArray: [Redacted.make("a"), Redacted.make("b")],
|
|
});
|
|
}).pipe(stack.deploy);
|
|
|
|
expect(created.redactedArray).toBeDefined();
|
|
expect(created.redactedArray!.length).toBe(2);
|
|
expect(Redacted.isRedacted(created.redactedArray![0]!)).toBe(true);
|
|
expect(Redacted.value(created.redactedArray![0]!)).toBe("a");
|
|
expect(Redacted.isRedacted(created.redactedArray![1]!)).toBe(true);
|
|
expect(Redacted.value(created.redactedArray![1]!)).toBe("b");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"preserves a Redacted output flowing into a downstream resource prop",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const output = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", {
|
|
string: "x",
|
|
redacted: Redacted.make("hunter2"),
|
|
});
|
|
const B = yield* TestResource("B", {
|
|
string: "y",
|
|
redacted: A.redacted as any,
|
|
});
|
|
return { A, B };
|
|
}).pipe(stack.deploy);
|
|
|
|
expect(Redacted.isRedacted(output.B.redacted)).toBe(true);
|
|
expect(Redacted.value(output.B.redacted!)).toBe("hunter2");
|
|
|
|
const bState = yield* getState("B");
|
|
expect(Redacted.isRedacted((bState!.props as any).redacted)).toBe(
|
|
true,
|
|
);
|
|
expect(Redacted.value((bState!.props as any).redacted)).toBe(
|
|
"hunter2",
|
|
);
|
|
expect(Redacted.isRedacted((bState!.attr as any).redacted)).toBe(
|
|
true,
|
|
);
|
|
expect(Redacted.value((bState!.attr as any).redacted)).toBe(
|
|
"hunter2",
|
|
);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"no-op redeploy when only Redacted prop is present and value unchanged",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const first = yield* Effect.gen(function* () {
|
|
return yield* TestResource("A", {
|
|
string: "x",
|
|
redacted: Redacted.make("hunter2"),
|
|
});
|
|
}).pipe(stack.deploy);
|
|
expect(Redacted.value(first.redacted!)).toBe("hunter2");
|
|
|
|
const before = yield* getState("A");
|
|
|
|
yield* Effect.gen(function* () {
|
|
return yield* TestResource("A", {
|
|
string: "x",
|
|
redacted: Redacted.make("hunter2"),
|
|
});
|
|
}).pipe(stack.deploy);
|
|
|
|
const after = yield* getState("A");
|
|
expect(after?.status).toBe("created");
|
|
expect((before as any).updatedAt ?? null).toEqual(
|
|
(after as any).updatedAt ?? null,
|
|
);
|
|
expect(Redacted.value((after!.attr as any).redacted)).toBe("hunter2");
|
|
}),
|
|
);
|
|
|
|
test.provider("update redeploy when Redacted prop value changes", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
return yield* TestResource("A", {
|
|
string: "x",
|
|
redacted: Redacted.make("old"),
|
|
});
|
|
}).pipe(stack.deploy);
|
|
|
|
const updated = yield* Effect.gen(function* () {
|
|
return yield* TestResource("A", {
|
|
string: "x",
|
|
redacted: Redacted.make("new"),
|
|
});
|
|
}).pipe(stack.deploy);
|
|
|
|
expect(Redacted.isRedacted(updated.redacted)).toBe(true);
|
|
expect(Redacted.value(updated.redacted!)).toBe("new");
|
|
const state = yield* getState("A");
|
|
expect(state?.status).toBe("updated");
|
|
expect(Redacted.value((state!.attr as any).redacted)).toBe("new");
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
describe("stack output persistence", { tags: ["unit", "local"] }, () => {
|
|
const getStackOutput = (stack: string, stage: string) =>
|
|
Effect.gen(function* () {
|
|
const state = yield* yield* State;
|
|
return yield* state.getOutput({ stack, stage });
|
|
});
|
|
|
|
test.provider(
|
|
"apply persists the resolved stack output via state.setOutput",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const result = yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "hello" });
|
|
return { url: A.string };
|
|
}).pipe(stack.deploy);
|
|
expect(result).toEqual({ url: "hello" });
|
|
|
|
const persisted = yield* getStackOutput(stack.name, stack.stage).pipe(
|
|
Effect.provide(stack.state),
|
|
);
|
|
expect(persisted).toEqual({ url: "hello" });
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"redeploys overwrite the persisted stack output with the new value",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "v1" });
|
|
return { url: A.string };
|
|
}).pipe(stack.deploy);
|
|
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "v2" });
|
|
return { url: A.string };
|
|
}).pipe(stack.deploy);
|
|
|
|
const persisted = yield* getStackOutput(stack.name, stack.stage).pipe(
|
|
Effect.provide(stack.state),
|
|
);
|
|
expect(persisted).toEqual({ url: "v2" });
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"another stack can read the persisted output via Output.stackRef",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// First deploy: write the stack output we'll later reference.
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "shared" });
|
|
return { url: A.string };
|
|
}).pipe(stack.deploy);
|
|
|
|
// Second deploy: a downstream resource consumes the previously
|
|
// persisted stack output via Output.stackRef. The deploy
|
|
// succeeds because state.getOutput finds it.
|
|
const result = yield* Effect.gen(function* () {
|
|
const upstream = yield* Output.stackRef<{ url: string }>(stack.name);
|
|
const B = yield* TestResource("B", {
|
|
string: (upstream as any).url,
|
|
});
|
|
return { downstream: B.string };
|
|
}).pipe(stack.deploy);
|
|
|
|
expect(result).toEqual({ downstream: "shared" });
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe(
|
|
"Duration round-trip through state",
|
|
{ tags: ["unit", "local"] },
|
|
() => {
|
|
test.provider(
|
|
"input Duration reaches reconcile as a real Duration and output Duration re-hydrates as a real Duration on the next deploy",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const first = yield* stack.deploy(
|
|
DurationResource("Timer", { timeout: Duration.seconds(15) }),
|
|
);
|
|
|
|
// Reconcile saw a real Duration: arithmetic worked.
|
|
expect(Duration.isDuration(first.observedTimeout)).toBe(true);
|
|
expect(Duration.toMillis(first.observedTimeout)).toBe(15_000);
|
|
expect(Duration.isDuration(first.computedTimeout)).toBe(true);
|
|
expect(Duration.toMillis(first.computedTimeout)).toBe(16_000);
|
|
|
|
// Second deploy: identical props. The engine reads the previous
|
|
// output from state. If the Duration weren't revived, `output`
|
|
// (a plain `{_id,_tag,millis}` shape) would fail `isDuration` and
|
|
// `Duration.toMillis` would throw.
|
|
const second = yield* stack.deploy(
|
|
DurationResource("Timer", { timeout: Duration.seconds(15) }),
|
|
);
|
|
expect(Duration.isDuration(second.observedTimeout)).toBe(true);
|
|
expect(Duration.toMillis(second.observedTimeout)).toBe(15_000);
|
|
expect(Duration.isDuration(second.computedTimeout)).toBe(true);
|
|
expect(Duration.toMillis(second.computedTimeout)).toBe(16_000);
|
|
|
|
// The persisted state itself should round-trip to a real Duration.
|
|
const persisted = yield* getState<{
|
|
attr: DurationResource["Attributes"];
|
|
}>("Timer");
|
|
expect(Duration.isDuration(persisted.attr.computedTimeout)).toBe(
|
|
true,
|
|
);
|
|
expect(Duration.toMillis(persisted.attr.computedTimeout)).toBe(
|
|
16_000,
|
|
);
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
describe("type aliases", { tags: ["unit", "local"] }, () => {
|
|
// Simulate state written before a type rename: rewrite the persisted row's
|
|
// resourceType to the legacy name ("Test.Widget") that the canonical type
|
|
// ("Test.Widgets.Widget") carries as an alias.
|
|
const rewriteTypeToLegacy = Effect.fn(function* (fqn: string) {
|
|
const state = yield* yield* State;
|
|
const stk = yield* Stack;
|
|
const row = (yield* state.get({
|
|
stack: stk.name,
|
|
stage: stk.stage,
|
|
fqn,
|
|
})) as ResourceState;
|
|
expect(row.resourceType).toEqual("Test.Widgets.Widget");
|
|
yield* state.set({
|
|
stack: stk.name,
|
|
stage: stk.stage,
|
|
fqn,
|
|
value: { ...row, resourceType: "Test.Widget" },
|
|
});
|
|
});
|
|
|
|
describe("bare provider layer", () => {
|
|
const { test } = Test.make({
|
|
providers: Layer.mergeAll(TestLayers(), aliasedWidgetProvider()),
|
|
});
|
|
|
|
test.provider(
|
|
"a noop deploy migrates legacy-typed state to the canonical type",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
yield* AliasedWidget("W1", { name: "w1" });
|
|
}).pipe(stack.deploy);
|
|
|
|
yield* rewriteTypeToLegacy("W1");
|
|
|
|
// Unchanged props plan as a noop — Apply must still rewrite the
|
|
// state row to the canonical type name.
|
|
yield* Effect.gen(function* () {
|
|
yield* AliasedWidget("W1", { name: "w1" });
|
|
}).pipe(stack.deploy);
|
|
|
|
const row = yield* getState("W1");
|
|
expect(row.resourceType).toEqual("Test.Widgets.Widget");
|
|
expect(row.status).toEqual("created");
|
|
expect(row.attr).toEqual({ name: "w1" });
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"orphan persisted under a legacy type name is deleted via alias",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
yield* AliasedWidget("W2", { name: "w2" });
|
|
}).pipe(stack.deploy);
|
|
|
|
yield* rewriteTypeToLegacy("W2");
|
|
|
|
// Remove the resource from the stack — the orphan-deletion path
|
|
// resolves the provider from the legacy type via its alias.
|
|
yield* Effect.void.pipe(stack.deploy);
|
|
|
|
expect(aliasedWidgetDeletes).toContain("W2");
|
|
expect(yield* getState("W2")).toBeUndefined();
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"destroy resolves the provider for legacy-typed state via alias",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
yield* AliasedWidget("W3", { name: "w3" });
|
|
}).pipe(stack.deploy);
|
|
|
|
yield* rewriteTypeToLegacy("W3");
|
|
|
|
yield* stack.destroy();
|
|
|
|
expect(aliasedWidgetDeletes).toContain("W3");
|
|
expect(yield* getState("W3")).toBeUndefined();
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("provider collection", () => {
|
|
class AliasApplyProviders extends Provider.ProviderCollection<AliasApplyProviders>()(
|
|
"Test.AliasApplyProviders",
|
|
) {}
|
|
|
|
// The bare provider layer is consumed while building the collection and
|
|
// is NOT exported — lookup can only succeed through the collection.
|
|
const { test } = Test.make({
|
|
providers: Layer.effect(
|
|
AliasApplyProviders,
|
|
Provider.collection([AliasedWidget]),
|
|
).pipe(Layer.provide(aliasedWidgetProvider())),
|
|
});
|
|
|
|
test.provider(
|
|
"orphan persisted under a legacy type name is deleted via alias",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
yield* AliasedWidget("W4", { name: "w4" });
|
|
}).pipe(stack.deploy);
|
|
|
|
yield* rewriteTypeToLegacy("W4");
|
|
|
|
yield* Effect.void.pipe(stack.deploy);
|
|
|
|
expect(aliasedWidgetDeletes).toContain("W4");
|
|
expect(yield* getState("W4")).toBeUndefined();
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
// Regression coverage for
|
|
// https://github.com/alchemy-run/alchemy/issues/793
|
|
//
|
|
// `Plan.make` used to `state.set(...)` the adopted `created` state during plan
|
|
// construction. Because `alchemy plan` / `deploy --dry-run` build a plan the
|
|
// exact same way a real deploy does, a read-only preview silently claimed
|
|
// ownership of an unowned cloud resource — arming a later, unrelated deploy to
|
|
// orphan-delete it. Plan construction must be side-effect-free: reading the
|
|
// cloud resource is fine (needed for an accurate diff), but persisting the
|
|
// adopted state may only happen when the plan node is applied.
|
|
describe(
|
|
"engine-level adoption persists at apply, not plan (issue #793)",
|
|
{ tags: ["unit", "local"] },
|
|
() => {
|
|
// A pre-existing, foreign-owned cloud resource that `read` always discovers
|
|
// — the exact shape that triggers an `--adopt` takeover.
|
|
const ownedAttrs: TestResource["Attributes"] = {
|
|
string: "hello",
|
|
stringArray: [],
|
|
stableString: "Adopted",
|
|
stableArray: ["Adopted"],
|
|
replaceString: undefined,
|
|
redacted: undefined,
|
|
redactedArray: undefined,
|
|
};
|
|
|
|
test.provider(
|
|
"a dry-run plan writes nothing to the state store; applying persists",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const events: Array<{ id: string; status: string }> = [];
|
|
let creates = 0;
|
|
let updates = 0;
|
|
const hooks = Layer.succeed(TestResourceHooks, {
|
|
read: () => Effect.succeed(Unowned(ownedAttrs)),
|
|
create: () => Effect.sync(() => creates++),
|
|
update: () => Effect.sync(() => updates++),
|
|
});
|
|
// ── dry-run: build a plan that adopts the unowned cloud resource ──
|
|
const plan = yield* TestResource("Adopted", { string: "hello" }).pipe(
|
|
adopt(true),
|
|
stack.plan,
|
|
Effect.provide(hooks),
|
|
);
|
|
|
|
// The adopted state rides on an explicit plan node (which still
|
|
// provider re-syncs ownership tags / config) — it is not persisted.
|
|
expect(plan.resources.Adopted!.action).toBe("adopted");
|
|
expect(plan.resources.Adopted!.state?.status).toBe("created");
|
|
|
|
// The critical invariant of #793: planning persisted nothing, so a
|
|
// read-only `alchemy plan` / `--dry-run` cannot arm a later deploy to
|
|
// orphan-delete the live resource.
|
|
expect(yield* getState("Adopted")).toBeUndefined();
|
|
expect(yield* listState()).toEqual([]);
|
|
|
|
// ── apply: the same config now deploys. Because plan didn't persist,
|
|
// the resource is still adoptable here. ──
|
|
yield* TestResource("Adopted", { string: "hello" }).pipe(
|
|
adopt(true),
|
|
stack.deploy,
|
|
Effect.provide(hooks),
|
|
Effect.provide(Layer.succeed(Cli, recordingCli(events))),
|
|
);
|
|
|
|
// Applying DOES persist the adopted state.
|
|
const persisted = yield* getState("Adopted");
|
|
expect(["created", "updated"]).toContain(persisted?.status);
|
|
expect(yield* listState()).toEqual(["Adopted"]);
|
|
// Adoption is the reconciler's `output defined, olds undefined` path.
|
|
expect(creates).toBe(1);
|
|
expect(updates).toBe(0);
|
|
const statuses = events
|
|
.filter((event) => event.id === "Adopted")
|
|
.map((event) => event.status);
|
|
expect(statuses).toContain("adopting");
|
|
expect(statuses).toContain("adopted");
|
|
expect(statuses.indexOf("adopting")).toBeLessThan(
|
|
statuses.indexOf("adopted"),
|
|
);
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
describe("deferred adoption", { tags: ["unit", "local"] }, () => {
|
|
interface Singleton extends Resource<
|
|
"Test.DeferredSingleton",
|
|
{ parent?: string; value?: string },
|
|
{ identity: string; value: string }
|
|
> {}
|
|
const Singleton = Resource<Singleton>("Test.DeferredSingleton");
|
|
class Probe extends Context.Service<
|
|
Probe,
|
|
{
|
|
ready: boolean;
|
|
foreign: boolean;
|
|
absent: boolean;
|
|
fail: boolean;
|
|
reads: string[];
|
|
reconciles: Array<{
|
|
id: string;
|
|
olds: Singleton["Props"] | undefined;
|
|
output: Singleton["Attributes"] | undefined;
|
|
}>;
|
|
deletes: string[];
|
|
}
|
|
>()("DeferredAdoptionProbe") {}
|
|
const providers = Provider.succeed(Singleton, {
|
|
read: Effect.fn(function* ({ id, olds, output }) {
|
|
if (id === "Parent") return output;
|
|
const probe = yield* Probe;
|
|
if (!olds.parent) return undefined;
|
|
expect(probe.ready).toBe(true);
|
|
expect(olds.parent).toBe("child-branch");
|
|
probe.reads.push(id);
|
|
if (output) return output;
|
|
if (probe.absent) return undefined;
|
|
const attrs = { identity: olds.parent, value: "inherited" };
|
|
return probe.foreign ? Unowned(attrs) : attrs;
|
|
}),
|
|
reconcile: Effect.fn(function* ({ id, news, olds, output }) {
|
|
const probe = yield* Probe;
|
|
if (id === "Parent") {
|
|
probe.ready = true;
|
|
return { identity: "child-branch", value: "parent" };
|
|
}
|
|
probe.reconciles.push({ id, olds, output });
|
|
if (!output && !probe.absent)
|
|
return yield* new OwnedBySomeoneElse({
|
|
message: "Singleton requires engine adoption",
|
|
});
|
|
expect(Unowned.is(output)).toBe(false);
|
|
if (probe.fail) return yield* new ResourceFailure();
|
|
return { identity: news.parent!, value: news.value ?? "desired" };
|
|
}),
|
|
delete: Effect.fn(function* ({ id }) {
|
|
(yield* Probe).deletes.push(id);
|
|
}),
|
|
});
|
|
interface Stub extends Resource<
|
|
"Test.DeferredStub",
|
|
Singleton["Props"],
|
|
Singleton["Attributes"]
|
|
> {}
|
|
const Stub = Resource<Stub>("Test.DeferredStub");
|
|
const stubProvider = Provider.succeed(Stub, {
|
|
read: Effect.fn(function* ({ id }) {
|
|
(yield* Probe).reads.push(id);
|
|
return Unowned({ identity: "stub", value: "stub" });
|
|
}),
|
|
precreate: () => Effect.succeed({ identity: "stub", value: "stub" }),
|
|
reconcile: Effect.fn(function* ({ id, news, olds, output }) {
|
|
const probe = yield* Probe;
|
|
probe.reconciles.push({ id, olds, output });
|
|
expect(output).toEqual({ identity: "stub", value: "stub" });
|
|
if (probe.fail) return yield* new ResourceFailure();
|
|
return { identity: news.parent!, value: "ready" };
|
|
}),
|
|
delete: Effect.fn(function* ({ id }) {
|
|
(yield* Probe).deletes.push(id);
|
|
}),
|
|
});
|
|
const makeProbe = (): Probe["Service"] => ({
|
|
ready: false,
|
|
foreign: true,
|
|
absent: false,
|
|
fail: false,
|
|
reads: [],
|
|
reconciles: [],
|
|
deletes: [],
|
|
});
|
|
const { test } = Test.make({
|
|
providers: Layer.mergeAll(providers, stubProvider).pipe(
|
|
Layer.provideMerge(
|
|
Layer.effect(
|
|
Probe,
|
|
// Reuse the test's probe across successive stack layer builds.
|
|
Effect.serviceOption(Probe).pipe(
|
|
Effect.map(Option.getOrElse(makeProbe)),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
});
|
|
const program = (enabled?: boolean, sibling = false) =>
|
|
Effect.gen(function* () {
|
|
const parent = yield* Singleton("Parent", {});
|
|
const child = Singleton("Child", { parent: parent.identity });
|
|
const result = yield* enabled === undefined
|
|
? child
|
|
: child.pipe(adopt(enabled));
|
|
if (sibling) yield* Singleton("Sibling", { parent: parent.identity });
|
|
return result;
|
|
});
|
|
|
|
for (const kind of ["scoped", "default", "owned", "absent"] as const) {
|
|
test.provider(
|
|
`accepts ${kind} after resolving a new upstream without plan writes`,
|
|
(stack) => {
|
|
return Effect.gen(function* () {
|
|
const probe = yield* Probe;
|
|
probe.foreign = kind !== "owned";
|
|
probe.absent = kind === "absent";
|
|
yield* stack.destroy();
|
|
const app = program(kind === "scoped" ? true : undefined);
|
|
const plan = yield* stack.plan(app);
|
|
expect(plan.resources.Child?.action).toBe("create");
|
|
expect(probe.reads).toEqual([]);
|
|
expect(yield* getState("Child")).toBeUndefined();
|
|
const result = yield* stack.deploy(app);
|
|
expect(result.identity).toBe("child-branch");
|
|
expect(probe.reads).toEqual(["Child"]);
|
|
expect(probe.reconciles[0]?.olds).toBeUndefined();
|
|
expect(probe.reconciles[0]?.output).toEqual(
|
|
kind === "absent"
|
|
? undefined
|
|
: { identity: "child-branch", value: "inherited" },
|
|
);
|
|
expect(Unowned.is((yield* getState("Child")).attr)).toBe(false);
|
|
yield* stack.destroy();
|
|
}).pipe(Effect.provideService(AdoptPolicy, kind === "default"));
|
|
},
|
|
);
|
|
}
|
|
|
|
for (const enabled of [undefined, false]) {
|
|
test.provider(
|
|
`refuses unowned resources before reconcile with scoped policy ${enabled}`,
|
|
(stack) => {
|
|
return Effect.gen(function* () {
|
|
const probe = yield* Probe;
|
|
yield* stack.destroy();
|
|
const refused = yield* stack.deploy(program(enabled)).pipe(
|
|
Effect.as(false),
|
|
Effect.catchTag("OwnedBySomeoneElse", () => Effect.succeed(true)),
|
|
);
|
|
expect(refused).toBe(true);
|
|
expect(probe.reads).toEqual(["Child"]);
|
|
expect(probe.reconciles).toEqual([]);
|
|
expect((yield* getState("Child")).attr).toBeUndefined();
|
|
yield* stack.destroy();
|
|
expect(probe.deletes).not.toContain("Child");
|
|
}).pipe(Effect.provideService(AdoptPolicy, enabled === false));
|
|
},
|
|
);
|
|
}
|
|
|
|
test.provider(
|
|
"never probes a precreated stub, including interrupted reconciliation",
|
|
(stack) => {
|
|
const app = Effect.gen(function* () {
|
|
const parent = yield* Singleton("Parent", {});
|
|
return yield* Stub("Stub", { parent: parent.identity }).pipe(
|
|
adopt(false),
|
|
);
|
|
});
|
|
return Effect.gen(function* () {
|
|
const probe = yield* Probe;
|
|
probe.fail = true;
|
|
yield* stack.destroy();
|
|
yield* stack
|
|
.deploy(app)
|
|
.pipe(Effect.catchTag("ResourceFailure", () => Effect.void));
|
|
expect((yield* getState("Stub")).attr).toEqual({
|
|
identity: "stub",
|
|
value: "stub",
|
|
});
|
|
probe.fail = false;
|
|
expect((yield* stack.deploy(app)).identity).toBe("child-branch");
|
|
expect(probe.reads).toEqual([]);
|
|
expect(probe.reconciles).toHaveLength(2);
|
|
expect(probe.reconciles.every(({ olds }) => olds === undefined)).toBe(
|
|
true,
|
|
);
|
|
yield* stack.destroy();
|
|
});
|
|
},
|
|
);
|
|
|
|
test.provider("resource adoption never authorizes a sibling", (stack) => {
|
|
return Effect.gen(function* () {
|
|
const probe = yield* Probe;
|
|
yield* stack.destroy();
|
|
const refused = yield* stack.deploy(program(true, true)).pipe(
|
|
Effect.as(false),
|
|
Effect.catchTag("OwnedBySomeoneElse", () => Effect.succeed(true)),
|
|
);
|
|
expect(refused).toBe(true);
|
|
expect(probe.reads).toContain("Sibling");
|
|
expect(probe.reconciles.some(({ id }) => id === "Sibling")).toBe(false);
|
|
expect((yield* getState("Sibling")).attr).toBeUndefined();
|
|
yield* stack.destroy();
|
|
expect(probe.deletes).not.toContain("Sibling");
|
|
});
|
|
});
|
|
|
|
test.provider(
|
|
"checkpoints accepted attributes and retries reconciliation with olds undefined",
|
|
(stack) => {
|
|
return Effect.gen(function* () {
|
|
const probe = yield* Probe;
|
|
probe.fail = true;
|
|
yield* stack.destroy();
|
|
const failed = yield* stack.deploy(program(true)).pipe(
|
|
Effect.as(false),
|
|
Effect.catchTag("ResourceFailure", () => Effect.succeed(true)),
|
|
);
|
|
expect(failed).toBe(true);
|
|
const checkpoint = yield* getState<CreatingResourceState>("Child");
|
|
expect(checkpoint.status).toBe("creating");
|
|
expect(checkpoint.props).toEqual({ parent: "child-branch" });
|
|
expect(checkpoint.attr).toEqual({
|
|
identity: "child-branch",
|
|
value: "inherited",
|
|
});
|
|
expect(Unowned.is(checkpoint.attr)).toBe(false);
|
|
probe.fail = false;
|
|
yield* stack.deploy(program(false));
|
|
expect(probe.reconciles).toHaveLength(2);
|
|
expect(probe.reconciles.every(({ olds }) => olds === undefined)).toBe(
|
|
true,
|
|
);
|
|
expect(probe.reads).toEqual(["Child"]);
|
|
yield* stack.destroy();
|
|
});
|
|
},
|
|
);
|
|
|
|
test.provider(
|
|
"retries an attr-less refusal using resolved desired identity",
|
|
(stack) => {
|
|
return Effect.gen(function* () {
|
|
const probe = yield* Probe;
|
|
yield* stack.destroy();
|
|
yield* stack
|
|
.deploy(program(false))
|
|
.pipe(Effect.catchTag("OwnedBySomeoneElse", () => Effect.void));
|
|
expect((yield* getState("Child")).attr).toBeUndefined();
|
|
yield* stack.deploy(program(true));
|
|
expect(probe.reconciles).toHaveLength(1);
|
|
expect(probe.reconciles[0]?.olds).toBeUndefined();
|
|
yield* stack.destroy();
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
describe(
|
|
"interrupted create persists no unresolved Output exprs",
|
|
{ tags: ["unit", "local"] },
|
|
() => {
|
|
test.provider(
|
|
"creating-state props are plain data and destroy converges",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const program = Effect.gen(function* () {
|
|
const a = yield* TestResource("A", { string: "a-value" });
|
|
const b = yield* TestResource("B", { string: a.string });
|
|
return { a, b };
|
|
});
|
|
|
|
// B fails after dependency resolution and the deferred-read checkpoint.
|
|
yield* program.pipe(stack.deploy, hook(failOn("B", "create")));
|
|
|
|
const b = yield* getState("B");
|
|
expect(b?.status).toEqual("creating");
|
|
// Recovery receives the resolved identity, never a live Output proxy.
|
|
expect((b?.props as TestResourceProps).string).toBe("a-value");
|
|
|
|
yield* stack.destroy();
|
|
expect(yield* getState("B")).toBeUndefined();
|
|
expect(yield* listState()).toEqual([]);
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
describe(
|
|
"interrupted replacement destruction",
|
|
{ tags: ["unit", "local"] },
|
|
() => {
|
|
type Attributes = {
|
|
physicalId: string;
|
|
revision: string;
|
|
dependency?: string;
|
|
};
|
|
interface Generation extends Resource<
|
|
"Test.DestructionGeneration",
|
|
{ revision: string; dependency?: string },
|
|
Attributes
|
|
> {}
|
|
const Generation = Resource<Generation>("Test.DestructionGeneration");
|
|
class Registry extends Context.Service<
|
|
Registry,
|
|
{
|
|
physical: Map<string, Attributes>;
|
|
calls: Array<{
|
|
op: "read" | "delete";
|
|
physicalId: string;
|
|
mode: string;
|
|
}>;
|
|
reconcile?: (
|
|
attrs: Attributes,
|
|
create: Effect.Effect<void>,
|
|
) => Effect.Effect<void>;
|
|
remove?: (attrs: Attributes) => Effect.Effect<void, ResourceFailure>;
|
|
unowned?: boolean;
|
|
}
|
|
>()("DestructionGeneration.Registry") {}
|
|
const variant = (mode: "live" | "local", precreate = false) =>
|
|
Provider.succeed(Generation, {
|
|
...(precreate
|
|
? {
|
|
precreate: Effect.fn(function* ({
|
|
instanceId,
|
|
news,
|
|
}: {
|
|
instanceId: string;
|
|
news: Generation["Props"];
|
|
}) {
|
|
const registry = yield* Registry;
|
|
const attrs = {
|
|
physicalId: instanceId,
|
|
revision: "stub",
|
|
dependency: news.dependency,
|
|
};
|
|
registry.physical.set(instanceId, attrs);
|
|
return attrs;
|
|
}),
|
|
}
|
|
: {}),
|
|
diff: Effect.fn(function* ({ news, olds }) {
|
|
if (
|
|
"revision" in news &&
|
|
isResolved(news.revision) &&
|
|
news.revision !== olds?.revision
|
|
) {
|
|
return { action: "replace" };
|
|
}
|
|
}),
|
|
read: Effect.fn(function* ({ instanceId }) {
|
|
const registry = yield* Registry;
|
|
registry.calls.push({ op: "read", physicalId: instanceId, mode });
|
|
const attrs = registry.physical.get(instanceId);
|
|
return attrs && registry.unowned ? Unowned(attrs) : attrs;
|
|
}),
|
|
reconcile: Effect.fn(function* ({ instanceId, news }) {
|
|
const registry = yield* Registry;
|
|
const attrs = { physicalId: instanceId, ...news };
|
|
const create = Effect.sync(() => {
|
|
registry.physical.set(instanceId, attrs);
|
|
});
|
|
yield* registry.reconcile
|
|
? registry.reconcile(attrs, create)
|
|
: create;
|
|
return attrs;
|
|
}),
|
|
delete: Effect.fn(function* ({ instanceId, output }) {
|
|
const registry = yield* Registry;
|
|
expect(output.physicalId).toBe(instanceId);
|
|
registry.calls.push({ op: "delete", physicalId: instanceId, mode });
|
|
if (registry.remove) yield* registry.remove(output);
|
|
expect(
|
|
[...registry.physical.values()].some(
|
|
(value) => value.dependency === instanceId,
|
|
),
|
|
).toBe(false);
|
|
registry.physical.delete(instanceId);
|
|
}),
|
|
});
|
|
const makeRegistry = (): Registry["Service"] => ({
|
|
physical: new Map(),
|
|
calls: [],
|
|
});
|
|
const { test: generationTest } = Test.make({
|
|
providers: ProviderLayer.dual(Generation, {
|
|
live: () => variant("live"),
|
|
local: () => variant("local"),
|
|
}).pipe(
|
|
Layer.provideMerge(
|
|
Layer.effect(
|
|
Registry,
|
|
Effect.serviceOption(Registry).pipe(
|
|
Effect.map(Option.getOrElse(makeRegistry)),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
});
|
|
|
|
generationTest.provider(
|
|
"GC preserves dependency direction across pending generations",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const registry = yield* Registry;
|
|
yield* stack.destroy();
|
|
const first = yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
const A = yield* Generation("A", { revision: "one" });
|
|
const B = yield* Generation("B", {
|
|
revision: "one",
|
|
dependency: A.physicalId,
|
|
});
|
|
return { A, B };
|
|
}),
|
|
);
|
|
registry.remove = (attrs) =>
|
|
attrs.physicalId === first.B.physicalId
|
|
? Effect.fail(new ResourceFailure())
|
|
: Effect.void;
|
|
const replacement = yield* stack
|
|
.deploy(
|
|
Effect.gen(function* () {
|
|
const B = yield* Generation("B", { revision: "two" });
|
|
return yield* Generation("A", {
|
|
revision: "two",
|
|
dependency: B.physicalId,
|
|
});
|
|
}),
|
|
)
|
|
.pipe(Effect.exit);
|
|
expect(Exit.isFailure(replacement)).toBe(true);
|
|
const A = yield* getState("A");
|
|
const B = yield* getState("B");
|
|
assert(A.status === "replaced" && B.status === "replaced");
|
|
registry.remove = undefined;
|
|
registry.calls.length = 0;
|
|
const current = yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
const A = yield* Generation("A", { revision: "three" });
|
|
const B = yield* Generation("B", { revision: "three" });
|
|
return { A, B };
|
|
}),
|
|
);
|
|
expect(
|
|
registry.calls
|
|
.filter((call) => call.op === "delete")
|
|
.map((call) => call.physicalId),
|
|
).toEqual([
|
|
A.instanceId,
|
|
B.instanceId,
|
|
first.B.physicalId,
|
|
first.A.physicalId,
|
|
]);
|
|
expect([...registry.physical.keys()].sort()).toEqual(
|
|
[current.A.physicalId, current.B.physicalId].sort(),
|
|
);
|
|
yield* stack.destroy();
|
|
expect([...registry.physical.values()]).toEqual([]);
|
|
}),
|
|
{ timeout: 10_000 },
|
|
);
|
|
|
|
generationTest.provider(
|
|
"GC waits for a deferred physical deletion before deleting its dependency",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const registry = yield* Registry;
|
|
yield* stack.destroy();
|
|
const first = yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
const A = yield* Generation("A", { revision: "one" });
|
|
const B = yield* Generation("B", {
|
|
revision: "one",
|
|
dependency: A.physicalId,
|
|
});
|
|
const C = yield* Generation("C", { revision: "one" });
|
|
return { A, B, C };
|
|
}),
|
|
);
|
|
registry.remove = (attrs) =>
|
|
attrs.physicalId === first.C.physicalId
|
|
? Effect.fail(new ResourceFailure())
|
|
: Effect.void;
|
|
const replacement = yield* stack
|
|
.deploy(
|
|
Effect.gen(function* () {
|
|
const A = yield* Generation("A", { revision: "one" });
|
|
const B = yield* Generation("B", {
|
|
revision: "one",
|
|
dependency: A.physicalId,
|
|
});
|
|
return yield* Generation("C", {
|
|
revision: "two",
|
|
dependency: B.physicalId,
|
|
});
|
|
}),
|
|
)
|
|
.pipe(Effect.exit);
|
|
assert(Exit.isFailure(replacement));
|
|
const C = yield* getState("C");
|
|
assert(C.status === "replaced");
|
|
expect(C.old.instanceId).toBe(first.C.physicalId);
|
|
registry.remove = undefined;
|
|
registry.calls.length = 0;
|
|
const current = yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
const A = yield* Generation("A", { revision: "two" });
|
|
const C = yield* Generation("C", { revision: "three" });
|
|
return { A, C };
|
|
}),
|
|
);
|
|
const deleted = registry.calls
|
|
.filter((call) => call.op === "delete")
|
|
.map((call) => call.physicalId);
|
|
expect(deleted).toEqual([
|
|
C.instanceId,
|
|
first.C.physicalId,
|
|
first.B.physicalId,
|
|
first.A.physicalId,
|
|
]);
|
|
expect(yield* getState("B")).toBeUndefined();
|
|
expect([...registry.physical.keys()].sort()).toEqual(
|
|
[current.A.physicalId, current.C.physicalId].sort(),
|
|
);
|
|
yield* stack.destroy();
|
|
expect([...registry.physical.values()]).toEqual([]);
|
|
expect(yield* listState()).toEqual([]);
|
|
}),
|
|
{ timeout: 10_000 },
|
|
);
|
|
|
|
const { test: stubTest } = Test.make({
|
|
providers: variant("live", true).pipe(
|
|
Layer.provideMerge(
|
|
Layer.effect(
|
|
Registry,
|
|
Effect.serviceOption(Registry).pipe(
|
|
Effect.map(Option.getOrElse(makeRegistry)),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
});
|
|
stubTest.provider(
|
|
"unfinished precreate remains resumable after its deleting checkpoint fails",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const registry = yield* Registry;
|
|
yield* stack.destroy();
|
|
const first = yield* stack.deploy(
|
|
Generation("R", { revision: "one" }),
|
|
);
|
|
const reached = yield* Deferred.make<void>();
|
|
registry.reconcile = () =>
|
|
Deferred.succeed(reached, undefined).pipe(
|
|
Effect.andThen(Effect.never),
|
|
);
|
|
const fiber = yield* stack
|
|
.deploy(Generation("R", { revision: "two" }))
|
|
.pipe(Effect.forkChild);
|
|
yield* Deferred.await(reached).pipe(Effect.timeout("2 seconds"));
|
|
yield* Fiber.interrupt(fiber);
|
|
const pending = yield* getState("R");
|
|
assert(pending.status === "replacing");
|
|
expect(pending.attr?.revision).toBe("stub");
|
|
const plan = yield* stack.plan(Effect.void);
|
|
const state = yield* yield* State;
|
|
const exit = yield* apply(plan).pipe(
|
|
Effect.provideService(
|
|
State,
|
|
Effect.succeed({
|
|
...state,
|
|
set: (request) =>
|
|
request.fqn === "R" && request.value.status === "deleting"
|
|
? Effect.fail(
|
|
new StateStoreError({
|
|
message: "Deleting checkpoint failed",
|
|
}),
|
|
)
|
|
: state.set(request),
|
|
}),
|
|
),
|
|
Effect.exit,
|
|
);
|
|
assert(Exit.isFailure(exit));
|
|
expect(registry.physical.has(first.physicalId)).toBe(false);
|
|
const checkpoint = yield* getState("R");
|
|
let reconciles = 0;
|
|
registry.reconcile = (_, create) =>
|
|
Effect.sync(() => {
|
|
reconciles++;
|
|
}).pipe(Effect.andThen(create));
|
|
const output = yield* stack.deploy(
|
|
Generation("R", { revision: "two" }),
|
|
);
|
|
expect(reconciles).toBe(1);
|
|
expect(checkpoint.status).toBe("creating");
|
|
expect(checkpoint.attr).toEqual(pending.attr);
|
|
expect(output.revision).toBe("two");
|
|
yield* stack.destroy();
|
|
expect([...registry.physical.values()]).toEqual([]);
|
|
}),
|
|
{ timeout: 10_000 },
|
|
);
|
|
|
|
for (const phase of [
|
|
"before-create",
|
|
"after-create",
|
|
"after-reconcile",
|
|
] as const) {
|
|
generationTest.provider(
|
|
`destroy drains generations interrupted ${phase}`,
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const registry = yield* Registry;
|
|
yield* stack.destroy();
|
|
const first = yield* inDev(
|
|
stack.deploy(Generation("R", { revision: "one" })),
|
|
);
|
|
const reached = yield* Deferred.make<void>();
|
|
registry.reconcile = (_, create) =>
|
|
Effect.gen(function* () {
|
|
if (phase !== "before-create") yield* create;
|
|
if (phase !== "after-reconcile") {
|
|
yield* Deferred.succeed(reached, undefined);
|
|
yield* Effect.never;
|
|
}
|
|
});
|
|
registry.remove = () =>
|
|
Deferred.succeed(reached, undefined).pipe(
|
|
Effect.andThen(Effect.never),
|
|
);
|
|
const fiber = yield* stack
|
|
.deploy(Generation("R", { revision: "two" }))
|
|
.pipe(Effect.forkChild);
|
|
yield* Deferred.await(reached).pipe(Effect.timeout("2 seconds"));
|
|
yield* Fiber.interrupt(fiber);
|
|
const pending = yield* getState("R");
|
|
assert(
|
|
pending?.status === "replacing" || pending?.status === "replaced",
|
|
);
|
|
expect(pending.instanceId).not.toBe(first.physicalId);
|
|
expect(pending.old.instanceId).toBe(first.physicalId);
|
|
registry.reconcile = undefined;
|
|
registry.remove = undefined;
|
|
registry.calls.length = 0;
|
|
yield* stack.destroy();
|
|
expect(yield* listState()).toEqual([]);
|
|
expect([...registry.physical.values()]).toEqual([]);
|
|
expect(registry.calls).toContainEqual({
|
|
op: "delete",
|
|
physicalId: first.physicalId,
|
|
mode: "local",
|
|
});
|
|
expect(registry.calls).toContainEqual({
|
|
op: phase === "before-create" ? "read" : "delete",
|
|
physicalId: pending.instanceId,
|
|
mode: "live",
|
|
});
|
|
yield* stack.destroy();
|
|
}),
|
|
{ timeout: 10_000 },
|
|
);
|
|
}
|
|
|
|
generationTest.provider(
|
|
"replacement GC blocks a planned dependency deletion until its entire old chain drains",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const registry = yield* Registry;
|
|
yield* stack.destroy();
|
|
const program = (revision: string) =>
|
|
Effect.gen(function* () {
|
|
const dependency = yield* Generation("Dependency", {
|
|
revision: "dependency",
|
|
});
|
|
return yield* Generation("R", {
|
|
revision,
|
|
dependency: dependency.physicalId,
|
|
});
|
|
});
|
|
const first = yield* stack.deploy(program("one"));
|
|
const reached = yield* Deferred.make<void>();
|
|
registry.reconcile = (attrs, create) =>
|
|
create.pipe(
|
|
Effect.andThen(
|
|
attrs.revision === "two"
|
|
? Deferred.succeed(reached, undefined).pipe(
|
|
Effect.andThen(Effect.never),
|
|
)
|
|
: Effect.void,
|
|
),
|
|
);
|
|
const fiber = yield* stack
|
|
.deploy(program("two"))
|
|
.pipe(Effect.forkChild);
|
|
yield* Deferred.await(reached).pipe(Effect.timeout("2 seconds"));
|
|
yield* Fiber.interrupt(fiber);
|
|
registry.reconcile = undefined;
|
|
registry.remove = () => Effect.fail(new ResourceFailure());
|
|
expect(
|
|
Exit.isFailure(
|
|
yield* stack.deploy(program("three")).pipe(Effect.exit),
|
|
),
|
|
).toBe(true);
|
|
const pending = yield* getState("R");
|
|
assert(pending?.status === "replaced");
|
|
assert(pending.old.status === "replacing");
|
|
registry.remove = (attrs) =>
|
|
attrs.physicalId === first.physicalId
|
|
? Effect.fail(new ResourceFailure())
|
|
: Effect.void;
|
|
const survivor = Generation("R", { revision: "three" });
|
|
const exit = yield* stack.deploy(survivor).pipe(Effect.exit);
|
|
assert(Exit.isFailure(exit));
|
|
const error = exit.cause.reasons.find(Cause.isFailReason)?.error;
|
|
assert(error instanceof DestroyError);
|
|
expect(error.failures.map((entry) => entry.fqn)).toEqual(["R"]);
|
|
expect(error.blocked.map((entry) => entry.fqn)).toEqual([
|
|
"Dependency",
|
|
]);
|
|
expect(registry.physical.has(pending.old.instanceId)).toBe(false);
|
|
expect(registry.physical.has(first.physicalId)).toBe(true);
|
|
expect(yield* getState("Dependency")).toBeDefined();
|
|
registry.remove = undefined;
|
|
yield* stack.deploy(survivor);
|
|
expect([...registry.physical.keys()]).toEqual([pending.instanceId]);
|
|
expect(yield* getState("Dependency")).toBeUndefined();
|
|
yield* stack.destroy();
|
|
expect([...registry.physical.values()]).toEqual([]);
|
|
}),
|
|
{ timeout: 10_000 },
|
|
);
|
|
|
|
generationTest.provider(
|
|
"retries an old-generation delete when its cleanup checkpoint fails",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const registry = yield* Registry;
|
|
yield* stack.destroy();
|
|
const first = yield* stack.deploy(
|
|
Generation("R", { revision: "one" }),
|
|
);
|
|
const reached = yield* Deferred.make<void>();
|
|
registry.reconcile = (_, create) =>
|
|
create.pipe(
|
|
Effect.andThen(Deferred.succeed(reached, undefined)),
|
|
Effect.andThen(Effect.never),
|
|
);
|
|
const fiber = yield* stack
|
|
.deploy(Generation("R", { revision: "two" }))
|
|
.pipe(Effect.forkChild);
|
|
yield* Deferred.await(reached).pipe(Effect.timeout("2 seconds"));
|
|
yield* Fiber.interrupt(fiber);
|
|
const pending = yield* getState("R");
|
|
assert(pending?.status === "replacing");
|
|
const plan = yield* stack.plan(Effect.void);
|
|
const state = yield* yield* State;
|
|
const exit = yield* apply(plan).pipe(
|
|
Effect.provideService(
|
|
State,
|
|
Effect.succeed({
|
|
...state,
|
|
set: (request) =>
|
|
request.fqn === "R" && request.value.status === "creating"
|
|
? Effect.fail(
|
|
new StateStoreError({ message: "Checkpoint failed" }),
|
|
)
|
|
: state.set(request),
|
|
}),
|
|
),
|
|
Effect.exit,
|
|
);
|
|
assert(Exit.isFailure(exit));
|
|
const error = exit.cause.reasons.find(Cause.isFailReason)?.error;
|
|
assert(error instanceof DestroyError);
|
|
expect(error.failures.map((entry) => entry.fqn)).toEqual(["R"]);
|
|
expect(registry.physical.has(first.physicalId)).toBe(false);
|
|
expect(registry.physical.has(pending.instanceId)).toBe(true);
|
|
expect(yield* getState("R")).toEqual(pending);
|
|
yield* stack.destroy();
|
|
expect(
|
|
registry.calls.filter(
|
|
(call) =>
|
|
call.op === "delete" && call.physicalId === first.physicalId,
|
|
),
|
|
).toHaveLength(2);
|
|
expect([...registry.physical.values()]).toEqual([]);
|
|
expect(yield* listState()).toEqual([]);
|
|
}),
|
|
{ timeout: 10_000 },
|
|
);
|
|
|
|
for (const policy of ["unowned", "retain"] as const) {
|
|
generationTest.provider(
|
|
`preserves ${policy} physical resources during interrupted replacement cleanup`,
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const registry = yield* Registry;
|
|
yield* stack.destroy();
|
|
const program = (revision: string) =>
|
|
Generation("R", { revision }).pipe(
|
|
RemovalPolicy.retain(policy === "retain"),
|
|
);
|
|
const first = yield* stack.deploy(program("one"));
|
|
const reached = yield* Deferred.make<void>();
|
|
registry.reconcile = (_, create) =>
|
|
create.pipe(
|
|
Effect.andThen(Deferred.succeed(reached, undefined)),
|
|
Effect.andThen(Effect.never),
|
|
);
|
|
const fiber = yield* stack
|
|
.deploy(program("two"))
|
|
.pipe(Effect.forkChild);
|
|
yield* Deferred.await(reached).pipe(Effect.timeout("2 seconds"));
|
|
yield* Fiber.interrupt(fiber);
|
|
const pending = yield* getState("R");
|
|
assert(pending?.status === "replacing");
|
|
registry.unowned = policy === "unowned";
|
|
registry.calls.length = 0;
|
|
yield* stack.destroy();
|
|
expect(yield* listState()).toEqual([]);
|
|
expect(registry.physical.has(pending.instanceId)).toBe(true);
|
|
expect(registry.physical.has(first.physicalId)).toBe(
|
|
policy === "retain",
|
|
);
|
|
expect(
|
|
registry.calls
|
|
.filter((call) => call.op === "delete")
|
|
.map((call) => call.physicalId),
|
|
).toEqual(policy === "retain" ? [] : [first.physicalId]);
|
|
}),
|
|
{ timeout: 10_000 },
|
|
);
|
|
}
|
|
|
|
for (const failure of ["fail", "interrupt"] as const) {
|
|
for (const target of ["newest", "oldest"] as const) {
|
|
generationTest.provider(
|
|
`${failure} deleting ${target} preserves the chain and blocks dependencies`,
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const registry = yield* Registry;
|
|
yield* stack.destroy();
|
|
const program = (revision: string) =>
|
|
Effect.gen(function* () {
|
|
const dependency = yield* Generation("Dependency", {
|
|
revision: "dependency",
|
|
});
|
|
yield* Generation("Sibling", { revision: "sibling" });
|
|
const resource = Generation("R", {
|
|
revision,
|
|
dependency: dependency.physicalId,
|
|
});
|
|
return yield* revision === "two"
|
|
? resource.pipe(remote())
|
|
: resource;
|
|
});
|
|
const first = yield* inDev(stack.deploy(program("one")));
|
|
for (const revision of ["two", "three"]) {
|
|
const reached = yield* Deferred.make<void>();
|
|
registry.reconcile = (attrs, create) =>
|
|
create.pipe(
|
|
Effect.andThen(
|
|
attrs.revision === revision
|
|
? Deferred.succeed(reached, undefined).pipe(
|
|
Effect.andThen(Effect.never),
|
|
)
|
|
: Effect.void,
|
|
),
|
|
);
|
|
const fiber = yield* inDev(
|
|
stack.deploy(program(revision)),
|
|
).pipe(Effect.forkChild);
|
|
yield* Deferred.await(reached).pipe(
|
|
Effect.timeout("2 seconds"),
|
|
);
|
|
yield* Fiber.interrupt(fiber);
|
|
}
|
|
const pending = yield* getState("R");
|
|
assert(pending?.status === "replacing");
|
|
assert(pending.old.status === "replacing");
|
|
expect(
|
|
new Set([
|
|
first.physicalId,
|
|
pending.old.instanceId,
|
|
pending.instanceId,
|
|
]).size,
|
|
).toBe(3);
|
|
const blocked = yield* Deferred.make<void>();
|
|
registry.remove = (attrs) =>
|
|
attrs.physicalId ===
|
|
(target === "newest" ? pending.instanceId : first.physicalId)
|
|
? failure === "fail"
|
|
? Effect.fail(new ResourceFailure())
|
|
: Deferred.succeed(blocked, undefined).pipe(
|
|
Effect.andThen(Effect.never),
|
|
)
|
|
: Effect.void;
|
|
if (failure === "fail") {
|
|
const exit = yield* stack.destroy().pipe(Effect.exit);
|
|
assert(Exit.isFailure(exit));
|
|
const error = exit.cause.reasons.find(
|
|
Cause.isFailReason,
|
|
)?.error;
|
|
assert(error instanceof DestroyError);
|
|
expect(error.failures.map((entry) => entry.fqn)).toEqual(["R"]);
|
|
expect(
|
|
error.blocked.map((entry) => ({
|
|
fqn: entry.fqn,
|
|
blockedBy: entry.blockedBy,
|
|
})),
|
|
).toEqual([{ fqn: "Dependency", blockedBy: ["R"] }]);
|
|
} else {
|
|
const fiber = yield* stack.destroy().pipe(Effect.forkChild);
|
|
yield* Deferred.await(blocked).pipe(
|
|
Effect.timeout("2 seconds"),
|
|
);
|
|
yield* Fiber.interrupt(fiber);
|
|
}
|
|
expect(yield* getState("Dependency")).toBeDefined();
|
|
const checkpoint = yield* getState("R");
|
|
assert(checkpoint !== undefined);
|
|
if (target === "oldest") {
|
|
assert(checkpoint.status === "replacing");
|
|
expect(checkpoint.old.instanceId).toBe(first.physicalId);
|
|
expect(registry.physical.has(first.physicalId)).toBe(true);
|
|
} else {
|
|
expect(checkpoint.status).toBe("deleting");
|
|
expect(checkpoint.instanceId).toBe(pending.instanceId);
|
|
expect(registry.physical.has(first.physicalId)).toBe(false);
|
|
}
|
|
if (failure === "fail")
|
|
expect(yield* getState("Sibling")).toBeUndefined();
|
|
registry.remove = undefined;
|
|
yield* stack.destroy();
|
|
expect([...registry.physical.values()]).toEqual([]);
|
|
expect(yield* listState()).toEqual([]);
|
|
for (const [physicalId, mode] of [
|
|
[first.physicalId, "local"],
|
|
[pending.old.instanceId, "live"],
|
|
[pending.instanceId, "local"],
|
|
]) {
|
|
expect(registry.calls).toContainEqual({
|
|
op: "delete",
|
|
physicalId,
|
|
mode,
|
|
});
|
|
}
|
|
}),
|
|
{ timeout: 10_000 },
|
|
);
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
// A single failed provider.delete used to abort the whole destroy, stranding
|
|
// every not-yet-deleted resource — even ones whose deletes would have
|
|
// succeeded. The engine now attempts every delete in dependency order,
|
|
// collects the failures, skips only resources whose DEPENDENT failed to
|
|
// delete (they may be legitimately undeletable — "blocked", not a second
|
|
// error), and raises everything at the end as one typed DestroyError.
|
|
describe("error-aggregating destroy", { tags: ["unit", "local"] }, () => {
|
|
const expectDestroyError = (exit: Exit.Exit<unknown, unknown>) => {
|
|
expect(Exit.isFailure(exit)).toBe(true);
|
|
assert(Exit.isFailure(exit));
|
|
const reason = exit.cause.reasons.find(Cause.isFailReason);
|
|
const error = reason?.error as DestroyError;
|
|
expect(error._tag).toBe("DestroyError");
|
|
return error;
|
|
};
|
|
|
|
test.provider(
|
|
"a failed delete does not abort sibling deletes and aggregates into DestroyError",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
yield* TestResource("A", { string: "a" });
|
|
yield* TestResource("B", { string: "b" });
|
|
yield* TestResource("C", { string: "c" });
|
|
}),
|
|
);
|
|
|
|
const deleted: string[] = [];
|
|
const exit = yield* stack.destroy().pipe(
|
|
Effect.provide(
|
|
Layer.succeed(TestResourceHooks, {
|
|
delete: (id: string) =>
|
|
id === "B"
|
|
? Effect.fail(new ResourceFailure())
|
|
: Effect.sync(() => void deleted.push(id)),
|
|
}),
|
|
),
|
|
Effect.exit,
|
|
);
|
|
|
|
// The independent siblings were still deleted...
|
|
expect(deleted.sort()).toEqual(["A", "C"]);
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
expect(yield* getState("C")).toBeUndefined();
|
|
// ...the failed resource stays behind for the next destroy...
|
|
expect((yield* getState("B"))?.status).toEqual("deleting");
|
|
|
|
// ...and the destroy as a whole still fails, with a typed aggregate.
|
|
const error = expectDestroyError(exit);
|
|
expect(error.failures.map((f) => f.fqn)).toEqual(["B"]);
|
|
expect(error.blocked).toEqual([]);
|
|
|
|
// A subsequent destroy (failure gone) converges.
|
|
yield* stack.destroy();
|
|
expect(yield* listState()).toEqual([]);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"a resource whose dependent failed to delete is skipped as blocked, not attempted",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// A <- B <- C is a dependency chain (deletes run C, B, A); D is
|
|
// independent.
|
|
yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a" });
|
|
const B = yield* TestResource("B", { string: A.string });
|
|
yield* TestResource("C", { string: B.string });
|
|
yield* TestResource("D", { string: "d" });
|
|
}),
|
|
);
|
|
|
|
const deleted: string[] = [];
|
|
const exit = yield* stack.destroy().pipe(
|
|
Effect.provide(
|
|
Layer.succeed(TestResourceHooks, {
|
|
delete: (id: string) =>
|
|
id === "C"
|
|
? Effect.fail(new ResourceFailure())
|
|
: Effect.sync(() => void deleted.push(id)),
|
|
}),
|
|
),
|
|
Effect.exit,
|
|
);
|
|
|
|
// Only the independent sibling was attempted and deleted. A and B
|
|
// sit upstream of the failed C, so their deletes were never even
|
|
// attempted — they may be legitimately undeletable while C exists.
|
|
expect(deleted).toEqual(["D"]);
|
|
expect(yield* getState("D")).toBeUndefined();
|
|
expect((yield* getState("C"))?.status).toEqual("deleting");
|
|
expect((yield* getState("B"))?.status).toEqual("created");
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
|
|
// The aggregate reports exactly one FAILURE (C); B and A are
|
|
// "blocked by" notes, not spurious errors.
|
|
const error = expectDestroyError(exit);
|
|
expect(error.failures.map((f) => f.fqn)).toEqual(["C"]);
|
|
expect(
|
|
error.blocked
|
|
.map((b) => ({ fqn: b.fqn, blockedBy: b.blockedBy }))
|
|
.sort((x, y) => x.fqn.localeCompare(y.fqn)),
|
|
).toEqual([
|
|
{ fqn: "A", blockedBy: ["B"] },
|
|
{ fqn: "B", blockedBy: ["C"] },
|
|
]);
|
|
|
|
// Once the blocker can be deleted, everything drains.
|
|
yield* stack.destroy();
|
|
expect(yield* listState()).toEqual([]);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"independent subtrees are unaffected by a failure in another subtree",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// Two disjoint chains: A <- B (B's delete fails) and X <- Y.
|
|
yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "a" });
|
|
yield* TestResource("B", { string: A.string });
|
|
const X = yield* TestResource("X", { string: "x" });
|
|
yield* TestResource("Y", { string: X.string });
|
|
}),
|
|
);
|
|
|
|
const deleted: string[] = [];
|
|
const exit = yield* stack.destroy().pipe(
|
|
Effect.provide(
|
|
Layer.succeed(TestResourceHooks, {
|
|
delete: (id: string) =>
|
|
id === "B"
|
|
? Effect.fail(new ResourceFailure())
|
|
: Effect.sync(() => void deleted.push(id)),
|
|
}),
|
|
),
|
|
Effect.exit,
|
|
);
|
|
|
|
// The X <- Y chain drained fully, in dependency order.
|
|
expect(deleted).toEqual(["Y", "X"]);
|
|
expect(yield* getState("X")).toBeUndefined();
|
|
expect(yield* getState("Y")).toBeUndefined();
|
|
// B failed; A is blocked behind it.
|
|
expect((yield* getState("B"))?.status).toEqual("deleting");
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
|
|
const error = expectDestroyError(exit);
|
|
expect(error.failures.map((f) => f.fqn)).toEqual(["B"]);
|
|
expect(error.blocked.map((b) => b.fqn)).toEqual(["A"]);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"a failed replaced-old-generation delete fails the deploy without spinning the drain loop",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
yield* TestResource("A", { replaceString: "v1" });
|
|
}),
|
|
);
|
|
|
|
// Replacement create succeeds; GC then fails to delete the old
|
|
// generation. The drain loop must terminate (the still-`replaced`
|
|
// row is excluded from retry) and surface the typed aggregate.
|
|
const exit = yield* stack
|
|
.deploy(
|
|
Effect.gen(function* () {
|
|
yield* TestResource("A", { replaceString: "v2" });
|
|
}),
|
|
)
|
|
.pipe(
|
|
Effect.provide(
|
|
Layer.succeed(TestResourceHooks, {
|
|
delete: () => Effect.fail(new ResourceFailure()),
|
|
}),
|
|
),
|
|
Effect.exit,
|
|
);
|
|
|
|
const error = expectDestroyError(exit);
|
|
expect(error.failures.map((f) => f.fqn)).toEqual(["A"]);
|
|
|
|
// The replacement chain is preserved for a later deploy to drain.
|
|
const state = yield* getState<ReplacedResourceState>("A");
|
|
expect(state?.status).toEqual("replaced");
|
|
expect(state?.old?.status).toEqual("created");
|
|
|
|
// Next deploy (delete healthy again) drains the old chain.
|
|
yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
yield* TestResource("A", { replaceString: "v2" });
|
|
}),
|
|
);
|
|
expect((yield* getState("A"))?.status).toEqual("created");
|
|
}),
|
|
{ timeout: 15_000 },
|
|
);
|
|
});
|
|
|
|
describe("provider modes (local ⇄ live)", { tags: ["unit", "local"] }, () => {
|
|
// ModalResource registers via `ProviderLayer.dual` with distinct live and
|
|
// local implementations that record lifecycle calls per variant into
|
|
// `modalCalls` (tagged with the scratch stack name so concurrent tests can
|
|
// filter to their own activity). These tests cover the APPLY-level
|
|
// semantics: `providerMode` stamping across every lifecycle commit,
|
|
// mode-correct deletes of old generations and orphans, replaced-chain
|
|
// draining, destroy, and legacy-row re-stamping.
|
|
|
|
const callsFor = (stackName: string) =>
|
|
modalCalls.filter((c) => c.stack === stackName);
|
|
|
|
const setState = Effect.fn(function* (fqn: string, value: ResourceState) {
|
|
const state = yield* yield* State;
|
|
const stk = yield* Stack;
|
|
yield* state.set({ stack: stk.name, stage: stk.stage, fqn, value });
|
|
});
|
|
|
|
const modal = (id: string, props: ModalResourceProps) =>
|
|
Effect.gen(function* () {
|
|
const a = yield* ModalResource(id, props);
|
|
return { runtime: a.runtime };
|
|
});
|
|
|
|
test.provider(
|
|
"providerMode is stamped through create → update → mode-switch replace → same-mode replace",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// ── create (dev run → local) ──
|
|
const created = yield* inDev(
|
|
modal("A", { value: "v1" }).pipe(stack.deploy),
|
|
);
|
|
expect(created.runtime).toEqual("local");
|
|
const afterCreate = yield* getState("A");
|
|
expect(afterCreate?.status).toEqual("created");
|
|
expect(afterCreate?.providerMode).toEqual("local");
|
|
const localInstanceId = afterCreate?.instanceId;
|
|
|
|
// ── update (still a dev run → local) ──
|
|
yield* inDev(modal("A", { value: "v2" }).pipe(stack.deploy));
|
|
const afterUpdate = yield* getState("A");
|
|
expect(afterUpdate?.status).toEqual("updated");
|
|
expect(afterUpdate?.providerMode).toEqual("local");
|
|
expect(afterUpdate?.instanceId).toEqual(localInstanceId);
|
|
|
|
// ── mode switch (local → live): replacement; the LOCAL provider
|
|
// deletes the old generation, the LIVE provider creates the new ──
|
|
const before = callsFor(stack.name).length;
|
|
const switched = yield* modal("A", { value: "v2" }).pipe(stack.deploy);
|
|
expect(switched.runtime).toEqual("live");
|
|
const afterSwitch = yield* getState("A");
|
|
expect(afterSwitch?.status).toEqual("created");
|
|
expect(afterSwitch?.providerMode).toEqual("live");
|
|
expect(afterSwitch?.instanceId).not.toEqual(localInstanceId);
|
|
const switchCalls = callsFor(stack.name).slice(before);
|
|
expect(switchCalls).toContainEqual({
|
|
stack: stack.name,
|
|
mode: "live",
|
|
op: "reconcile",
|
|
id: "A",
|
|
});
|
|
expect(switchCalls).toContainEqual({
|
|
stack: stack.name,
|
|
mode: "local",
|
|
op: "delete",
|
|
id: "A",
|
|
});
|
|
|
|
// ── ordinary (same-mode) replacement via the provider diff:
|
|
// providerMode survives, and the old generation is deleted with
|
|
// its own (live) mode ──
|
|
const beforeReplace = callsFor(stack.name).length;
|
|
yield* modal("A", { value: "v2", replaceValue: "r2" }).pipe(
|
|
stack.deploy,
|
|
);
|
|
const afterReplace = yield* getState("A");
|
|
expect(afterReplace?.status).toEqual("created");
|
|
expect(afterReplace?.providerMode).toEqual("live");
|
|
expect(afterReplace?.instanceId).not.toEqual(afterSwitch?.instanceId);
|
|
expect(callsFor(stack.name).slice(beforeReplace)).toContainEqual({
|
|
stack: stack.name,
|
|
mode: "live",
|
|
op: "delete",
|
|
id: "A",
|
|
});
|
|
|
|
yield* stack.destroy();
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"a replaced chain drains each old generation with ITS stamped mode",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// Simulate an interrupted mode-switch deploy: the live replacement
|
|
// was created and committed as `replaced`, but the apply died before
|
|
// GC drained the old (local) generation. The recovery deploy's GC
|
|
// must delete that generation with the LOCAL provider.
|
|
const oldInstanceId = "11111111111111111111111111111111";
|
|
const newInstanceId = "22222222222222222222222222222222";
|
|
yield* setState("A", {
|
|
status: "replaced",
|
|
fqn: "A",
|
|
logicalId: "A",
|
|
namespace: undefined,
|
|
instanceId: newInstanceId,
|
|
resourceType: "Test.ModalResource",
|
|
providerVersion: 0,
|
|
props: { value: "v1" },
|
|
attr: { value: "v1", runtime: "live" },
|
|
bindings: [],
|
|
downstream: [],
|
|
deleteFirst: false,
|
|
providerMode: "live",
|
|
old: {
|
|
status: "created",
|
|
fqn: "A",
|
|
logicalId: "A",
|
|
namespace: undefined,
|
|
instanceId: oldInstanceId,
|
|
resourceType: "Test.ModalResource",
|
|
providerVersion: 0,
|
|
props: { value: "v1" },
|
|
attr: { value: "v1", runtime: "local" },
|
|
bindings: [],
|
|
downstream: [],
|
|
providerMode: "local",
|
|
},
|
|
} as ResourceState);
|
|
|
|
const before = callsFor(stack.name).length;
|
|
// Identical props, live-default run: the top generation noops and
|
|
// GC drains the pending old chain.
|
|
yield* modal("A", { value: "v1" }).pipe(stack.deploy);
|
|
|
|
expect(callsFor(stack.name).slice(before)).toContainEqual({
|
|
stack: stack.name,
|
|
mode: "local",
|
|
op: "delete",
|
|
id: "A",
|
|
});
|
|
const settled = yield* getState("A");
|
|
expect(settled?.status).toEqual("created");
|
|
expect(settled?.providerMode).toEqual("live");
|
|
expect(settled?.instanceId).toEqual(newInstanceId);
|
|
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"stack.destroy tears down a local row with the local provider during a live-default run",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* inDev(modal("A", { value: "v1" }).pipe(stack.deploy));
|
|
expect((yield* getState("A"))?.providerMode).toEqual("local");
|
|
|
|
// `stack.destroy()` runs without any mode policy — the run default
|
|
// is live — but the orphaned row must still be deleted by the
|
|
// provider that created it.
|
|
const before = callsFor(stack.name).length;
|
|
yield* stack.destroy();
|
|
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
expect(yield* listState()).toEqual([]);
|
|
expect(callsFor(stack.name).slice(before)).toContainEqual({
|
|
stack: stack.name,
|
|
mode: "local",
|
|
op: "delete",
|
|
id: "A",
|
|
});
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"legacy rows (no persisted mode) are re-stamped on their next write",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* modal("A", { value: "v1" }).pipe(stack.deploy);
|
|
const row = yield* getState("A");
|
|
expect(row?.providerMode).toEqual("live");
|
|
|
|
// Simulate a row written before providerMode existed.
|
|
yield* setState("A", { ...row!, providerMode: undefined });
|
|
|
|
// Same-mode update: no replacement churn (assumed current mode) and
|
|
// the row comes out stamped.
|
|
yield* modal("A", { value: "v2" }).pipe(stack.deploy);
|
|
const restamped = yield* getState("A");
|
|
expect(restamped?.status).toEqual("updated");
|
|
expect(restamped?.providerMode).toEqual("live");
|
|
// Same instance — the legacy row was updated, not replaced.
|
|
expect(restamped?.instanceId).toEqual(row?.instanceId);
|
|
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"a deleteFirst replacement tears down a mixed-mode old chain, each generation with its own provider",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// An interrupted local → live switch left a `replaced` row: live top
|
|
// generation, undrained local old generation. A deleteFirst
|
|
// replacement (deleteFirstValue change) now restarts a new outer
|
|
// generation and must tear the WHOLE chain down before creating —
|
|
// the live generation with the LIVE provider, the local generation
|
|
// with the LOCAL provider (`deleteOldGenerations`).
|
|
const oldInstanceId = "11111111111111111111111111111111";
|
|
const newInstanceId = "22222222222222222222222222222222";
|
|
yield* setState("A", {
|
|
status: "replaced",
|
|
fqn: "A",
|
|
logicalId: "A",
|
|
namespace: undefined,
|
|
instanceId: newInstanceId,
|
|
resourceType: "Test.ModalResource",
|
|
providerVersion: 0,
|
|
props: { value: "v1" },
|
|
attr: { value: "v1", runtime: "live" },
|
|
bindings: [],
|
|
downstream: [],
|
|
deleteFirst: false,
|
|
providerMode: "live",
|
|
old: {
|
|
status: "created",
|
|
fqn: "A",
|
|
logicalId: "A",
|
|
namespace: undefined,
|
|
instanceId: oldInstanceId,
|
|
resourceType: "Test.ModalResource",
|
|
providerVersion: 0,
|
|
props: { value: "v1" },
|
|
attr: { value: "v1", runtime: "local" },
|
|
bindings: [],
|
|
downstream: [],
|
|
providerMode: "local",
|
|
},
|
|
} as ResourceState);
|
|
|
|
const before = callsFor(stack.name).length;
|
|
yield* modal("A", { value: "v1", deleteFirstValue: "df" }).pipe(
|
|
stack.deploy,
|
|
);
|
|
|
|
const calls = callsFor(stack.name).slice(before);
|
|
const liveDelete = calls.findIndex(
|
|
(c) => c.op === "delete" && c.mode === "live",
|
|
);
|
|
const localDelete = calls.findIndex(
|
|
(c) => c.op === "delete" && c.mode === "local",
|
|
);
|
|
const create = calls.findIndex(
|
|
(c) => c.op === "reconcile" && c.mode === "live",
|
|
);
|
|
expect(liveDelete).toBeGreaterThanOrEqual(0);
|
|
expect(localDelete).toBeGreaterThanOrEqual(0);
|
|
// deleteFirst: the whole old chain is reclaimed BEFORE the new
|
|
// generation is created.
|
|
expect(create).toBeGreaterThan(liveDelete);
|
|
expect(create).toBeGreaterThan(localDelete);
|
|
|
|
const settled = yield* getState("A");
|
|
expect(settled?.status).toEqual("created");
|
|
expect(settled?.providerMode).toEqual("live");
|
|
expect(settled?.instanceId).not.toEqual(newInstanceId);
|
|
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"a failed mode-switch create leaves the old runtime's instance intact, then converges on retry",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* inDev(modal("A", { value: "v1" }).pipe(stack.deploy));
|
|
const localRow = yield* getState("A");
|
|
expect(localRow?.providerMode).toEqual("local");
|
|
|
|
// The live replacement's create fails. Replacements are
|
|
// create-first, so the local generation must survive (it is only
|
|
// reclaimed by GC after a successful create — which never runs on a
|
|
// failed apply).
|
|
const before = callsFor(stack.name).length;
|
|
const failCreate = Layer.succeed(TestResourceHooks, {
|
|
create: () => Effect.fail(new ResourceFailure()),
|
|
});
|
|
const exit = yield* modal("A", { value: "v1" }).pipe(
|
|
stack.deploy,
|
|
Effect.provide(failCreate),
|
|
Effect.exit,
|
|
);
|
|
expect(Exit.isFailure(exit)).toBe(true);
|
|
|
|
const failedCalls = callsFor(stack.name).slice(before);
|
|
expect(
|
|
failedCalls.some((c) => c.op === "delete" && c.mode === "local"),
|
|
).toBe(false);
|
|
const interrupted = yield* getState("A");
|
|
expect(interrupted?.status).toEqual("replacing");
|
|
expect(interrupted?.providerMode).toEqual("live");
|
|
expect(
|
|
(interrupted as ReplacingResourceState).old.providerMode,
|
|
).toEqual("local");
|
|
|
|
// Retry (same live mode): the interrupted replacement resumes, the
|
|
// live create succeeds, and GC finally reclaims the local instance
|
|
// with the LOCAL provider.
|
|
const beforeRetry = callsFor(stack.name).length;
|
|
const retried = yield* modal("A", { value: "v1" }).pipe(stack.deploy);
|
|
expect(retried.runtime).toEqual("live");
|
|
expect(callsFor(stack.name).slice(beforeRetry)).toContainEqual({
|
|
stack: stack.name,
|
|
mode: "local",
|
|
op: "delete",
|
|
id: "A",
|
|
});
|
|
const settled = yield* getState("A");
|
|
expect(settled?.status).toEqual("created");
|
|
expect(settled?.providerMode).toEqual("live");
|
|
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"GC drains a multi-generation chain with per-generation modes",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// Two undrained generations with DIFFERENT modes: the outer old is a
|
|
// replaced local generation whose own old is a live generation
|
|
// (live → local → live churn interrupted twice). GC pops one
|
|
// generation per pass — each must be deleted by its own provider.
|
|
const inner = "00000000000000000000000000000000";
|
|
const middle = "11111111111111111111111111111111";
|
|
const top = "22222222222222222222222222222222";
|
|
yield* setState("A", {
|
|
status: "replaced",
|
|
fqn: "A",
|
|
logicalId: "A",
|
|
namespace: undefined,
|
|
instanceId: top,
|
|
resourceType: "Test.ModalResource",
|
|
providerVersion: 0,
|
|
props: { value: "v1" },
|
|
attr: { value: "v1", runtime: "live" },
|
|
bindings: [],
|
|
downstream: [],
|
|
deleteFirst: false,
|
|
providerMode: "live",
|
|
old: {
|
|
status: "replaced",
|
|
fqn: "A",
|
|
logicalId: "A",
|
|
namespace: undefined,
|
|
instanceId: middle,
|
|
resourceType: "Test.ModalResource",
|
|
providerVersion: 0,
|
|
props: { value: "v1" },
|
|
attr: { value: "v1", runtime: "local" },
|
|
bindings: [],
|
|
downstream: [],
|
|
deleteFirst: false,
|
|
providerMode: "local",
|
|
old: {
|
|
status: "created",
|
|
fqn: "A",
|
|
logicalId: "A",
|
|
namespace: undefined,
|
|
instanceId: inner,
|
|
resourceType: "Test.ModalResource",
|
|
providerVersion: 0,
|
|
props: { value: "v1" },
|
|
attr: { value: "v1", runtime: "live" },
|
|
bindings: [],
|
|
downstream: [],
|
|
providerMode: "live",
|
|
},
|
|
},
|
|
} as ResourceState);
|
|
|
|
const before = callsFor(stack.name).length;
|
|
yield* modal("A", { value: "v1" }).pipe(stack.deploy);
|
|
|
|
const deletes = callsFor(stack.name)
|
|
.slice(before)
|
|
.filter((c) => c.op === "delete");
|
|
// Outer-in: the middle (local) generation pops first, then the
|
|
// inner (live) one — each with its own stamped mode.
|
|
expect(deletes.map((c) => c.mode)).toEqual(["local", "live"]);
|
|
|
|
const settled = yield* getState("A");
|
|
expect(settled?.status).toEqual("created");
|
|
expect(settled?.providerMode).toEqual("live");
|
|
expect(settled?.instanceId).toEqual(top);
|
|
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"a mode switch flows through downstream dependents end-to-end",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const program = Effect.gen(function* () {
|
|
const a = yield* ModalResource("A", { value: "v1" });
|
|
const b = yield* TestResource("B", { string: a.value });
|
|
return { runtime: a.runtime, string: b.string };
|
|
});
|
|
|
|
const dev = yield* inDev(program.pipe(stack.deploy));
|
|
expect(dev.runtime).toEqual("local");
|
|
expect(dev.string).toEqual("v1");
|
|
|
|
// Switching A to live replaces it; B (mode-agnostic) re-reconciles
|
|
// against the replacement's fresh attrs instead of nooping on stale
|
|
// ones, and both settle in a terminal state.
|
|
const promoted = yield* program.pipe(stack.deploy);
|
|
expect(promoted.runtime).toEqual("live");
|
|
expect(promoted.string).toEqual("v1");
|
|
|
|
const a = yield* getState("A");
|
|
expect(a?.status).toEqual("created");
|
|
expect(a?.providerMode).toEqual("live");
|
|
const b = yield* getState("B");
|
|
expect(["created", "updated"]).toContain(b?.status);
|
|
expect(b?.providerMode).toBeUndefined();
|
|
|
|
yield* stack.destroy();
|
|
expect(yield* listState()).toEqual([]);
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe(
|
|
"binding client data-plane routing (apply)",
|
|
{ tags: ["unit", "local"] },
|
|
() => {
|
|
// Action bodies invoke Binding.Service clients at apply time. In a
|
|
// `dev` run the wrap must route local resources to the emulator plane
|
|
// and `Alchemy.remote()` resources to the live plane — the inverse of
|
|
// each other, and never ambient.
|
|
|
|
test.provider(
|
|
"dev Action on a local resource hits the emulator plane",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const out = yield* inDev(
|
|
Effect.gen(function* () {
|
|
const resource = yield* ModalResource("A", { value: "v1" });
|
|
const Probe = Action(
|
|
"ProbeLocal",
|
|
Effect.gen(function* () {
|
|
const read = yield* ProbeBinding(resource);
|
|
return () => read();
|
|
}),
|
|
);
|
|
return yield* Probe({});
|
|
}).pipe(stack.deploy),
|
|
);
|
|
expect(out).toBe("local");
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"dev Action on a remote() resource hits the live plane",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const out = yield* inDev(
|
|
Effect.gen(function* () {
|
|
const resource = yield* ModalResource("A", { value: "v1" }).pipe(
|
|
remote(),
|
|
);
|
|
const Probe = Action(
|
|
"ProbeRemote",
|
|
Effect.gen(function* () {
|
|
const read = yield* ProbeBinding(resource);
|
|
return () => read();
|
|
}),
|
|
);
|
|
return yield* Probe({});
|
|
}).pipe(stack.deploy),
|
|
);
|
|
expect(out).toBe("live");
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
// Apply must honor dependency ORDER and resolve values for references at ANY
|
|
// nesting depth of plain data — objects in arrays, arrays in objects, arrays
|
|
// of arrays, whole-resource refs (#1082 hardened the walkers with a
|
|
// plain-data gate + cycle guards; these pin end-to-end that no nesting shape
|
|
// lost its edge or its resolution).
|
|
describe(
|
|
"deeply nested dependencies (order + resolution)",
|
|
{ tags: ["unit", "local"] },
|
|
() => {
|
|
test.provider(
|
|
"creates upstream first and resolves refs at every nesting depth",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const createOrder: string[] = [];
|
|
let bCreateProps: any;
|
|
const createHooks = {
|
|
create: (id: string, props: any) =>
|
|
Effect.sync(() => {
|
|
createOrder.push(id);
|
|
if (id === "B") bCreateProps = props;
|
|
}),
|
|
update: () => Effect.succeed(undefined),
|
|
delete: () => Effect.succeed(undefined),
|
|
read: () => Effect.succeed(undefined),
|
|
};
|
|
|
|
const nestedProps = (a: any) =>
|
|
({
|
|
layers: [{ config: { hosts: [{ url: a.string }] } }],
|
|
matrix: [[a.string]],
|
|
whole: { list: [a] },
|
|
mixed: [1, "x", { deep: [a.string] }, null],
|
|
}) as any;
|
|
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "deep-a" });
|
|
const B = yield* TestResource("B", nestedProps(A));
|
|
return { A, B };
|
|
}).pipe(stack.deploy, hook(createHooks));
|
|
|
|
// Order: the ONLY references to A are deeply nested — A must still
|
|
// be created before B.
|
|
expect(createOrder).toEqual(["A", "B"]);
|
|
|
|
// Resolution: every nested position received the concrete value.
|
|
expect(bCreateProps.layers[0].config.hosts[0].url).toBe("deep-a");
|
|
expect(bCreateProps.matrix[0][0]).toBe("deep-a");
|
|
expect(bCreateProps.mixed[2].deep[0]).toBe("deep-a");
|
|
// A whole-resource reference resolves to the upstream's attributes.
|
|
expect(bCreateProps.whole.list[0].string).toBe("deep-a");
|
|
|
|
// Second deploy: the upstream value changes; the change must
|
|
// propagate through every nested position, again upstream-first.
|
|
const updateOrder: string[] = [];
|
|
let bUpdateProps: any;
|
|
const updateHooks = {
|
|
create: () => Effect.succeed(undefined),
|
|
update: (id: string, props: any) =>
|
|
Effect.sync(() => {
|
|
updateOrder.push(id);
|
|
if (id === "B") bUpdateProps = props;
|
|
}),
|
|
delete: () => Effect.succeed(undefined),
|
|
read: () => Effect.succeed(undefined),
|
|
};
|
|
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "deep-a2" });
|
|
const B = yield* TestResource("B", nestedProps(A));
|
|
return { A, B };
|
|
}).pipe(stack.deploy, hook(updateHooks));
|
|
|
|
expect(updateOrder).toEqual(["A", "B"]);
|
|
expect(bUpdateProps.layers[0].config.hosts[0].url).toBe("deep-a2");
|
|
expect(bUpdateProps.matrix[0][0]).toBe("deep-a2");
|
|
expect(bUpdateProps.mixed[2].deep[0]).toBe("deep-a2");
|
|
expect(bUpdateProps.whole.list[0].string).toBe("deep-a2");
|
|
|
|
yield* stack.destroy();
|
|
expect(yield* listState()).toEqual([]);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"a fan-in of deeply nested deps creates ALL upstreams before the dependent",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const order: string[] = [];
|
|
let cProps: any;
|
|
const hooks = {
|
|
create: (id: string, props: any) =>
|
|
Effect.sync(() => {
|
|
order.push(id);
|
|
if (id === "C") cProps = props;
|
|
}),
|
|
update: () => Effect.succeed(undefined),
|
|
delete: () => Effect.succeed(undefined),
|
|
read: () => Effect.succeed(undefined),
|
|
};
|
|
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "fan-a" });
|
|
const B = yield* TestResource("B", { string: "fan-b" });
|
|
const C = yield* TestResource("C", {
|
|
fromA: { arr: [{ v: A.string }] },
|
|
fromB: [[{ v: B.string }]],
|
|
} as any);
|
|
return { A, B, C };
|
|
}).pipe(stack.deploy, hook(hooks));
|
|
|
|
// A and B may create in either order (they're independent), but C
|
|
// must come last.
|
|
expect(order).toHaveLength(3);
|
|
expect(order[2]).toBe("C");
|
|
expect(order.slice(0, 2).sort()).toEqual(["A", "B"]);
|
|
expect(cProps.fromA.arr[0].v).toBe("fan-a");
|
|
expect(cProps.fromB[0][0].v).toBe("fan-b");
|
|
|
|
yield* stack.destroy();
|
|
expect(yield* listState()).toEqual([]);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"a dependency chain through nested containers applies in topological order",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const order: string[] = [];
|
|
const hooks = {
|
|
create: (id: string) =>
|
|
Effect.sync(() => {
|
|
order.push(id);
|
|
}),
|
|
update: () => Effect.succeed(undefined),
|
|
delete: () => Effect.succeed(undefined),
|
|
read: () => Effect.succeed(undefined),
|
|
};
|
|
|
|
yield* Effect.gen(function* () {
|
|
const A = yield* TestResource("A", { string: "chain-a" });
|
|
const B = yield* TestResource("B", {
|
|
nested: [{ from: A.string }],
|
|
} as any);
|
|
const C = yield* TestResource("C", {
|
|
nested: { deep: [[B.string]] },
|
|
} as any);
|
|
return { A, B, C };
|
|
}).pipe(stack.deploy, hook(hooks));
|
|
|
|
expect(order).toEqual(["A", "B", "C"]);
|
|
|
|
yield* stack.destroy();
|
|
expect(yield* listState()).toEqual([]);
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
// End-to-end pins for the #1082 leaf rules: class instances in props reach
|
|
// `reconcile` by identity (prototype intact), are stripped from persisted
|
|
// state, and never churn a diff; cyclic plain data deploys and re-deploys
|
|
// without hanging or phantom updates.
|
|
describe(
|
|
"non-plain and cyclic props through deploy",
|
|
{ tags: ["unit", "local"] },
|
|
() => {
|
|
test.provider(
|
|
"a Date prop reaches reconcile intact and re-deploys without churn",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const seen: Date[] = [];
|
|
const updates: string[] = [];
|
|
const hooks = {
|
|
create: (_id: string, props: any) =>
|
|
Effect.sync(() => {
|
|
seen.push(props.expires);
|
|
}),
|
|
update: (id: string, props: any) =>
|
|
Effect.sync(() => {
|
|
updates.push(id);
|
|
seen.push(props.expires);
|
|
}),
|
|
delete: () => Effect.succeed(undefined),
|
|
read: () => Effect.succeed(undefined),
|
|
};
|
|
|
|
const program = (iso: string) =>
|
|
Effect.gen(function* () {
|
|
return yield* TestResource("A", {
|
|
string: "date-holder",
|
|
expires: new Date(iso),
|
|
} as any);
|
|
});
|
|
|
|
yield* program("2027-01-01").pipe(stack.deploy, hook(hooks));
|
|
// The Date arrives in reconcile as a real Date, not `{}`.
|
|
expect(seen[0]).toBeInstanceOf(Date);
|
|
expect(seen[0]!.toISOString()).toBe("2027-01-01T00:00:00.000Z");
|
|
|
|
// And it ROUND-TRIPS: read back out of the (durable, on-disk)
|
|
// store, the persisted prop is a real Date again — the DATE_MARKER
|
|
// envelope in StateEncoding, not a bare ISO string. This is what
|
|
// provider diff/delete/read receive as `olds` on a later run.
|
|
const persisted = (yield* getState("A"))?.props as {
|
|
expires: Date;
|
|
};
|
|
expect(persisted.expires).toBeInstanceOf(Date);
|
|
expect(persisted.expires.toISOString()).toBe(
|
|
"2027-01-01T00:00:00.000Z",
|
|
);
|
|
|
|
// Same date again — no phantom update from Date handling.
|
|
yield* program("2027-01-01").pipe(stack.deploy, hook(hooks));
|
|
expect(updates).toEqual([]);
|
|
|
|
// Changed date — must be detected and delivered.
|
|
yield* program("2028-06-15").pipe(stack.deploy, hook(hooks));
|
|
expect(updates).toEqual(["A"]);
|
|
const last = seen[seen.length - 1]!;
|
|
expect(last).toBeInstanceOf(Date);
|
|
expect(last.toISOString()).toBe("2028-06-15T00:00:00.000Z");
|
|
|
|
yield* stack.destroy();
|
|
expect(yield* listState()).toEqual([]);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"a class-instance prop reaches reconcile by identity and never churns",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
class SdkConfig {
|
|
constructor(readonly region: string) {}
|
|
}
|
|
const received: any[] = [];
|
|
const updates: string[] = [];
|
|
const hooks = {
|
|
create: (_id: string, props: any) =>
|
|
Effect.sync(() => {
|
|
received.push(props.config);
|
|
}),
|
|
update: (id: string) =>
|
|
Effect.sync(() => {
|
|
updates.push(id);
|
|
}),
|
|
delete: () => Effect.succeed(undefined),
|
|
read: () => Effect.succeed(undefined),
|
|
};
|
|
|
|
const program = () =>
|
|
Effect.gen(function* () {
|
|
// A fresh instance every deploy — identity differs run to run.
|
|
return yield* TestResource("A", {
|
|
string: "sdk-holder",
|
|
config: new SdkConfig("us-east-1"),
|
|
} as any);
|
|
});
|
|
|
|
yield* program().pipe(stack.deploy, hook(hooks));
|
|
// Prototype intact all the way into reconcile.
|
|
expect(received[0]).toBeInstanceOf(SdkConfig);
|
|
expect(received[0].region).toBe("us-east-1");
|
|
|
|
// Persisted state holds plain data only — the instance is stripped.
|
|
const persisted = yield* getState("A");
|
|
expect((persisted?.props as any).config).toBeUndefined();
|
|
expect(() => JSON.stringify(persisted?.props)).not.toThrow();
|
|
|
|
// A fresh (different-identity) instance must not cause an update:
|
|
// runtime-only wiring is invisible to the diff.
|
|
yield* program().pipe(stack.deploy, hook(hooks));
|
|
expect(updates).toEqual([]);
|
|
|
|
yield* stack.destroy();
|
|
expect(yield* listState()).toEqual([]);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"cyclic plain props deploy, persist truncated, and re-deploy as noop",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
const updates: string[] = [];
|
|
const hooks = {
|
|
create: () => Effect.succeed(undefined),
|
|
update: (id: string) =>
|
|
Effect.sync(() => {
|
|
updates.push(id);
|
|
}),
|
|
delete: () => Effect.succeed(undefined),
|
|
read: () => Effect.succeed(undefined),
|
|
};
|
|
|
|
const program = () =>
|
|
Effect.gen(function* () {
|
|
const cyclic: any = { name: "cfg" };
|
|
cyclic.self = cyclic;
|
|
const A = yield* TestResource("A", { string: "up" });
|
|
const B = yield* TestResource("B", {
|
|
config: cyclic,
|
|
url: A.string,
|
|
} as any);
|
|
return { A, B };
|
|
});
|
|
|
|
yield* program().pipe(stack.deploy, hook(hooks));
|
|
|
|
// Persisted with the cycle cut — still JSON-serializable.
|
|
const persisted = yield* getState("B");
|
|
expect((persisted?.props as any).config.name).toBe("cfg");
|
|
expect((persisted?.props as any).config.self).toBeUndefined();
|
|
expect(() => JSON.stringify(persisted?.props)).not.toThrow();
|
|
|
|
// Identical (still-cyclic) props — a clean noop.
|
|
yield* program().pipe(stack.deploy, hook(hooks));
|
|
expect(updates).toEqual([]);
|
|
|
|
yield* stack.destroy();
|
|
expect(yield* listState()).toEqual([]);
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
describe("renamed resources (renamedFrom)", { tags: ["unit", "local"] }, () => {
|
|
const setState = Effect.fn(function* (fqn: string, value: ResourceState) {
|
|
const state = yield* yield* State;
|
|
const stk = yield* Stack;
|
|
yield* state.set({ stack: stk.name, stage: stk.stage, fqn, value });
|
|
});
|
|
|
|
test.provider(
|
|
"migrates the state row from a former FQN without recreating the resource",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
yield* TestResource("Old", { string: "v1" });
|
|
}),
|
|
);
|
|
const before = yield* getState("Old");
|
|
expect(before?.status).toEqual("created");
|
|
|
|
// Redeploy under the new id: no create, no delete — the state row
|
|
// moves and exactly ONE update reconcile runs to re-brand the
|
|
// physical resource (its tags still carry the old logical id).
|
|
const touched: string[] = [];
|
|
const track = (op: string) => (id: string) =>
|
|
Effect.sync(() => void touched.push(`${op}:${id}`));
|
|
yield* stack
|
|
.deploy(
|
|
Effect.gen(function* () {
|
|
yield* TestResource("New", { string: "v1" }).pipe(
|
|
renamedFrom("Old"),
|
|
);
|
|
}),
|
|
)
|
|
.pipe(
|
|
hook({
|
|
create: track("create"),
|
|
update: track("update"),
|
|
delete: track("delete"),
|
|
}),
|
|
);
|
|
expect(touched).toEqual(["update:New"]);
|
|
|
|
const after = yield* getState("New");
|
|
expect(after?.instanceId).toEqual(before?.instanceId);
|
|
expect(after?.logicalId).toEqual("New");
|
|
expect(yield* getState("Old")).toBeUndefined();
|
|
|
|
// A second deploy is a clean noop — the migration is done.
|
|
const touchedAgain: string[] = [];
|
|
const trackAgain = (op: string) => (id: string) =>
|
|
Effect.sync(() => void touchedAgain.push(`${op}:${id}`));
|
|
yield* stack
|
|
.deploy(
|
|
Effect.gen(function* () {
|
|
yield* TestResource("New", { string: "v1" }).pipe(
|
|
renamedFrom("Old"),
|
|
);
|
|
}),
|
|
)
|
|
.pipe(
|
|
hook({
|
|
create: trackAgain("create"),
|
|
update: trackAgain("update"),
|
|
delete: trackAgain("delete"),
|
|
}),
|
|
);
|
|
expect(touchedAgain).toEqual([]);
|
|
|
|
yield* stack.destroy();
|
|
expect(yield* listState()).toEqual([]);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"the old id can be reused by a new resource without stealing the migrated physical",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
yield* TestResource("Old", { string: "v1" });
|
|
}),
|
|
);
|
|
const before = yield* getState("Old");
|
|
|
|
// One deploy renames Old → New AND declares a brand-new resource
|
|
// reusing the id `Old`. The read hook simulates a tag-based
|
|
// adoption probe that would FIND the migrated physical resource
|
|
// (its cloud tags still say `Old`) — the engine must not consult
|
|
// it for the reuser.
|
|
const touched: string[] = [];
|
|
const track = (op: string) => (id: string) =>
|
|
Effect.sync(() => void touched.push(`${op}:${id}`));
|
|
yield* stack
|
|
.deploy(
|
|
Effect.gen(function* () {
|
|
yield* TestResource("New", { string: "v1" }).pipe(
|
|
renamedFrom("Old"),
|
|
);
|
|
yield* TestResource("Old", { string: "fresh" });
|
|
}),
|
|
)
|
|
.pipe(
|
|
hook({
|
|
create: track("create"),
|
|
update: track("update"),
|
|
delete: track("delete"),
|
|
read: () =>
|
|
Effect.succeed({ string: "v1", urn: "stolen-physical" }),
|
|
}),
|
|
);
|
|
|
|
// The renamed resource kept its identity...
|
|
const renamed = yield* getState("New");
|
|
expect(renamed?.instanceId).toEqual(before?.instanceId);
|
|
// ...and the reuser was created FRESH: new instanceId, a real
|
|
// create call, and no adoption of the migrated physical.
|
|
const reuser = yield* getState("Old");
|
|
expect(reuser?.instanceId).not.toEqual(before?.instanceId);
|
|
expect(touched).toContain("create:Old");
|
|
expect(touched).toContain("update:New");
|
|
expect(touched.filter((t) => t.startsWith("delete"))).toEqual([]);
|
|
|
|
yield* stack.destroy();
|
|
expect(yield* listState()).toEqual([]);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"a persisted renamer excludes its former FQN before the fresh create has a checkpoint",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
const predecessor = yield* stack.deploy(
|
|
TestResource("Old", { string: "original" }),
|
|
);
|
|
const renamed = TestResource("New", { string: "original" }).pipe(
|
|
renamedFrom("Old"),
|
|
);
|
|
yield* stack.deploy(renamed).pipe(hook(failOn("New", "update")));
|
|
expect(yield* getState("Old")).toBeUndefined();
|
|
expect((yield* getState("New")).status).toBe("updating");
|
|
let reads = 0;
|
|
const freshCreated = yield* Deferred.make<void>();
|
|
yield* stack
|
|
.deploy(
|
|
Effect.gen(function* () {
|
|
yield* renamed;
|
|
const upstream = yield* Function("Upstream", { name: "fresh" });
|
|
return yield* TestResource("Old", { string: upstream.name }).pipe(
|
|
adopt(true),
|
|
);
|
|
}),
|
|
)
|
|
.pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
read: (id) =>
|
|
Effect.sync(() => {
|
|
if (id !== "Old") return undefined;
|
|
reads++;
|
|
return predecessor;
|
|
}),
|
|
update: (id) =>
|
|
id === "New" ? Deferred.await(freshCreated) : Effect.void,
|
|
create: (id) =>
|
|
id === "Old"
|
|
? Deferred.succeed(freshCreated, undefined).pipe(
|
|
Effect.asVoid,
|
|
)
|
|
: Effect.void,
|
|
}),
|
|
);
|
|
expect(reads).toBe(0);
|
|
yield* stack.destroy();
|
|
}),
|
|
{ timeout: 10_000 },
|
|
);
|
|
|
|
for (const finish of ["destroy", "gc"] as const) {
|
|
test.provider(
|
|
`blocked create replacement retains migration exclusion through interruption and ${finish}`,
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
const predecessor = yield* stack.deploy(
|
|
TestResource("Old", { string: "original" }),
|
|
);
|
|
const app = (replaceString: string) =>
|
|
Effect.gen(function* () {
|
|
yield* TestResource("New", { string: "original" }).pipe(
|
|
renamedFrom("Old"),
|
|
RemovalPolicy.retain(),
|
|
);
|
|
const upstream = yield* TestResource("Upstream", {
|
|
string: "fresh",
|
|
});
|
|
return yield* TestResource("Old", {
|
|
string: replaceString === "first" ? upstream.string : "fresh",
|
|
replaceString,
|
|
}).pipe(adopt(true));
|
|
});
|
|
const interruptAtCheckpoint = (version: string) =>
|
|
Effect.gen(function* () {
|
|
const newStarted = yield* Deferred.make<void>();
|
|
const upstreamStarted = yield* Deferred.make<void>();
|
|
const oldCheckpointed = yield* Deferred.make<void>();
|
|
const fiber = yield* stack.deploy(app(version)).pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
update: (id) =>
|
|
id === "New"
|
|
? Deferred.succeed(newStarted, undefined).pipe(
|
|
Effect.andThen(Effect.never),
|
|
)
|
|
: Effect.void,
|
|
create: (id) =>
|
|
id === "Upstream"
|
|
? Deferred.succeed(upstreamStarted, undefined).pipe(
|
|
Effect.andThen(Effect.never),
|
|
)
|
|
: Effect.void,
|
|
}),
|
|
Effect.provideService(Cli, {
|
|
...recordingCli([]),
|
|
startApplySession: () =>
|
|
Effect.succeed({
|
|
done: () => Effect.void,
|
|
emit: (event) =>
|
|
event._tag === "apply.resource.status" &&
|
|
event.id === "Old" &&
|
|
event.status === "pending"
|
|
? Deferred.succeed(oldCheckpointed, undefined).pipe(
|
|
Effect.andThen(Effect.never),
|
|
)
|
|
: Effect.void,
|
|
}),
|
|
}),
|
|
Effect.forkChild,
|
|
);
|
|
yield* Deferred.await(newStarted);
|
|
yield* Deferred.await(upstreamStarted);
|
|
yield* Deferred.await(oldCheckpointed);
|
|
yield* Fiber.interrupt(fiber);
|
|
});
|
|
yield* interruptAtCheckpoint("first");
|
|
const creating = yield* getState("Old");
|
|
expect(creating.status).toBe("creating");
|
|
expect(creating.adoptionBlocked).toBe("migrated-fqn");
|
|
yield* interruptAtCheckpoint("second");
|
|
const replacing = yield* getState("Old");
|
|
expect(replacing.status).toBe("replacing");
|
|
expect(replacing.attr).toBeUndefined();
|
|
expect(replacing.instanceId).not.toBe(creating.instanceId);
|
|
const reads: string[] = [];
|
|
const deleted: string[] = [];
|
|
const hooks = TestResourceHooks.of({
|
|
read: (id) =>
|
|
Effect.sync(() => {
|
|
if (id !== "Old") return undefined;
|
|
reads.push(id);
|
|
return predecessor;
|
|
}),
|
|
delete: (id) =>
|
|
Effect.sync(() => {
|
|
deleted.push(id);
|
|
}),
|
|
});
|
|
if (finish === "gc") {
|
|
yield* stack
|
|
.deploy(app("second"))
|
|
.pipe(Effect.provideService(TestResourceHooks, hooks));
|
|
const completed = yield* getState("Old");
|
|
expect(completed.status).toBe("created");
|
|
expect(completed.adoptionBlocked).toBe("migrated-fqn");
|
|
expect(reads).toEqual([]);
|
|
expect(deleted).toEqual([]);
|
|
const next = yield* stack.plan(app("third"));
|
|
expect(next.resources.Old?.action).toBe("replace");
|
|
expect(next.resources.Old?.adoptionBlocked).toBe("migrated-fqn");
|
|
}
|
|
yield* stack
|
|
.destroy()
|
|
.pipe(Effect.provideService(TestResourceHooks, hooks));
|
|
expect(reads).toEqual([]);
|
|
expect(deleted.sort()).toEqual(
|
|
finish === "gc" ? ["Old", "Upstream"] : [],
|
|
);
|
|
expect(replacing.adoptionBlocked).toBe("migrated-fqn");
|
|
expect(yield* listState()).toEqual([]);
|
|
}),
|
|
{ timeout: 10_000 },
|
|
);
|
|
}
|
|
|
|
for (const recovery of ["plan", "deferred", "destroy"] as const) {
|
|
test.provider(
|
|
`interrupted migrated FQN reuse suppresses ${recovery} recovery of the predecessor`,
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
const predecessorAttrs = yield* stack.deploy(
|
|
TestResource("Old", { string: "original" }),
|
|
);
|
|
const predecessor = yield* getState("Old");
|
|
const newStarted = yield* Deferred.make<void>();
|
|
const upstreamStarted = yield* Deferred.make<void>();
|
|
const oldCheckpointed = yield* Deferred.make<void>();
|
|
let renaming = true;
|
|
const app = Effect.gen(function* () {
|
|
const target = TestResource("New", { string: "original" });
|
|
yield* renaming ? target.pipe(renamedFrom("Old")) : target;
|
|
const upstream = yield* TestResource("Upstream", {
|
|
string: "fresh",
|
|
});
|
|
return yield* TestResource("Old", { string: upstream.string }).pipe(
|
|
adopt(true),
|
|
);
|
|
});
|
|
const fiber = yield* stack.deploy(app).pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
update: (id) =>
|
|
id === "New"
|
|
? Deferred.succeed(newStarted, undefined).pipe(
|
|
Effect.andThen(Effect.never),
|
|
)
|
|
: Effect.void,
|
|
create: (id) =>
|
|
id === "Upstream"
|
|
? Deferred.succeed(upstreamStarted, undefined).pipe(
|
|
Effect.andThen(Effect.never),
|
|
)
|
|
: Effect.void,
|
|
}),
|
|
Effect.provideService(Cli, {
|
|
...recordingCli([]),
|
|
startApplySession: () =>
|
|
Effect.succeed({
|
|
done: () => Effect.void,
|
|
emit: (event) =>
|
|
event._tag === "apply.resource.status" &&
|
|
event.id === "Old" &&
|
|
event.status === "pending"
|
|
? Deferred.succeed(oldCheckpointed, undefined).pipe(
|
|
Effect.asVoid,
|
|
)
|
|
: Effect.void,
|
|
}),
|
|
}),
|
|
Effect.forkChild,
|
|
);
|
|
yield* Deferred.await(newStarted);
|
|
yield* Deferred.await(upstreamStarted);
|
|
yield* Deferred.await(oldCheckpointed);
|
|
yield* Fiber.interrupt(fiber);
|
|
expect((yield* getState("New")).instanceId).toBe(
|
|
predecessor.instanceId,
|
|
);
|
|
const fresh = yield* getState("Old");
|
|
expect(fresh.status).toBe("creating");
|
|
expect(fresh.attr).toBeUndefined();
|
|
expect(fresh.props?.string).toBeUndefined();
|
|
expect(fresh.instanceId).not.toBe(predecessor.instanceId);
|
|
expect(fresh.adoptionBlocked).toBe("migrated-fqn");
|
|
renaming = false;
|
|
let reads = 0;
|
|
if (recovery === "destroy") {
|
|
const deleted: string[] = [];
|
|
yield* stack.destroy().pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
read: (id) =>
|
|
Effect.sync(() => {
|
|
if (id !== "Old") return undefined;
|
|
reads++;
|
|
return predecessorAttrs;
|
|
}),
|
|
delete: (id) =>
|
|
Effect.sync(() => {
|
|
deleted.push(id);
|
|
}),
|
|
}),
|
|
);
|
|
expect(reads).toBe(0);
|
|
expect(deleted).toEqual(["New"]);
|
|
return;
|
|
}
|
|
const freshCreated = yield* Deferred.make<void>();
|
|
const createAttrs: Array<unknown> = [];
|
|
const store = yield* yield* State;
|
|
yield* stack.deploy(app).pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
read: (id) =>
|
|
Effect.sync(() => {
|
|
if (id !== "Old") return undefined;
|
|
reads++;
|
|
return recovery === "plan" || reads > 1
|
|
? predecessorAttrs
|
|
: undefined;
|
|
}),
|
|
create: (id) =>
|
|
id === "Old"
|
|
? Effect.gen(function* () {
|
|
const current = yield* store.get({
|
|
stack: stack.name,
|
|
stage: stack.stage,
|
|
fqn: "Old",
|
|
});
|
|
assert(
|
|
current !== undefined && current.kind !== "action",
|
|
);
|
|
createAttrs.push(current.attr);
|
|
yield* Deferred.succeed(freshCreated, undefined);
|
|
})
|
|
: Effect.void,
|
|
update: (id) =>
|
|
id === "New" ? Deferred.await(freshCreated) : Effect.void,
|
|
}),
|
|
);
|
|
expect(reads).toBe(0);
|
|
expect(createAttrs).toEqual([undefined]);
|
|
expect((yield* getState("New")).instanceId).toBe(
|
|
predecessor.instanceId,
|
|
);
|
|
expect((yield* getState("Old")).instanceId).toBe(fresh.instanceId);
|
|
yield* stack.destroy();
|
|
}),
|
|
{ timeout: 10_000 },
|
|
);
|
|
}
|
|
|
|
test.provider(
|
|
"a reused migrated FQN skips deferred adoption even with unresolved upstream props",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
yield* stack.deploy(TestResource("Old", { string: "original" }));
|
|
const previous = yield* getState("Old");
|
|
const reads: string[] = [];
|
|
yield* stack
|
|
.deploy(
|
|
Effect.gen(function* () {
|
|
yield* TestResource("New", { string: "original" }).pipe(
|
|
renamedFrom("Old"),
|
|
);
|
|
const upstream = yield* Function("Upstream", { name: "fresh" });
|
|
return yield* TestResource("Old", { string: upstream.name }).pipe(
|
|
adopt(true),
|
|
);
|
|
}),
|
|
)
|
|
.pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
read: (id) =>
|
|
Effect.sync(() => {
|
|
reads.push(id);
|
|
return undefined;
|
|
}),
|
|
}),
|
|
);
|
|
expect(reads).toEqual([]);
|
|
expect((yield* getState("New")).instanceId).toBe(previous.instanceId);
|
|
expect((yield* getState("Old")).instanceId).not.toBe(
|
|
previous.instanceId,
|
|
);
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"migrates a namespaced row (StaticSite's <id>/Worker → <id> shape)",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// The pre-rename shape: `Worker` declared under the `App/Site`
|
|
// namespace chain (fqn `App/Site/Worker`).
|
|
yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
yield* Effect.gen(function* () {
|
|
yield* TestResource("Worker", { string: "v1" });
|
|
}).pipe(Namespace.push("Site"), Namespace.push("App"));
|
|
}),
|
|
);
|
|
const before = yield* getState("App/Site/Worker");
|
|
expect(before?.status).toEqual("created");
|
|
|
|
// The post-rename shape: the resource is `Site` itself, still under
|
|
// `App`, claiming its former namespace-RELATIVE id — exactly what
|
|
// StaticSite does with `renamedFrom(`${id}/Worker`)`.
|
|
const touched: string[] = [];
|
|
const track = (op: string) => (id: string) =>
|
|
Effect.sync(() => void touched.push(`${op}:${id}`));
|
|
yield* stack
|
|
.deploy(
|
|
Effect.gen(function* () {
|
|
yield* TestResource("Site", { string: "v1" }).pipe(
|
|
renamedFrom("Site/Worker"),
|
|
Namespace.push("App"),
|
|
);
|
|
}),
|
|
)
|
|
.pipe(
|
|
hook({
|
|
create: track("create"),
|
|
update: track("update"),
|
|
delete: track("delete"),
|
|
}),
|
|
);
|
|
expect(touched).toEqual(["update:Site"]);
|
|
|
|
const after = yield* getState("App/Site");
|
|
expect(after?.instanceId).toEqual(before?.instanceId);
|
|
expect(after?.logicalId).toEqual("Site");
|
|
expect(yield* getState("App/Site/Worker")).toBeUndefined();
|
|
|
|
yield* stack.destroy();
|
|
expect(yield* listState()).toEqual([]);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"finishes an interrupted migration state-only (rows at both FQNs, same instanceId)",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
yield* TestResource("New", { string: "v1" }).pipe(
|
|
renamedFrom("Old"),
|
|
);
|
|
}),
|
|
);
|
|
const row = yield* getState("New");
|
|
|
|
// Simulate a crash between apply's `state.set` (new FQN) and
|
|
// `state.delete` (former FQN): a stale copy remains at `Old`.
|
|
yield* setState("Old", { ...row!, fqn: "Old", logicalId: "Old" });
|
|
|
|
const deleted: string[] = [];
|
|
yield* stack
|
|
.deploy(
|
|
Effect.gen(function* () {
|
|
yield* TestResource("New", { string: "v1" }).pipe(
|
|
renamedFrom("Old"),
|
|
);
|
|
}),
|
|
)
|
|
.pipe(
|
|
hook({
|
|
delete: (id: string) => Effect.sync(() => void deleted.push(id)),
|
|
}),
|
|
);
|
|
|
|
// The leftover row was dropped WITHOUT a provider.delete.
|
|
expect(deleted).toEqual([]);
|
|
expect(yield* getState("Old")).toBeUndefined();
|
|
expect((yield* getState("New"))?.instanceId).toEqual(row?.instanceId);
|
|
|
|
yield* stack.destroy();
|
|
expect(yield* listState()).toEqual([]);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"a rename chain with repeated partial failures converges in one deploy",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// A → B rename, then B → C, with the A→B migration's delete having
|
|
// failed (a leftover copy of the row remains at A).
|
|
yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
yield* TestResource("A", { string: "v1" });
|
|
}),
|
|
);
|
|
yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
yield* TestResource("B", { string: "v1" }).pipe(renamedFrom("A"));
|
|
}),
|
|
);
|
|
const row = yield* getState("B");
|
|
yield* setState("A", { ...row!, fqn: "A", logicalId: "A" });
|
|
|
|
const touched: string[] = [];
|
|
const track = (op: string) => (id: string) =>
|
|
Effect.sync(() => void touched.push(`${op}:${id}`));
|
|
yield* stack
|
|
.deploy(
|
|
Effect.gen(function* () {
|
|
// Most recent former id first.
|
|
yield* TestResource("C", { string: "v1" }).pipe(
|
|
renamedFrom("B", "A"),
|
|
);
|
|
}),
|
|
)
|
|
.pipe(
|
|
hook({
|
|
create: track("create"),
|
|
update: track("update"),
|
|
delete: track("delete"),
|
|
}),
|
|
);
|
|
|
|
// One deploy: migrated from B AND dropped the stale copy at A —
|
|
// one re-branding update, no create, no delete.
|
|
expect(touched).toEqual(["update:C"]);
|
|
expect((yield* getState("C"))?.instanceId).toEqual(row?.instanceId);
|
|
expect(yield* getState("B")).toBeUndefined();
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
|
|
yield* stack.destroy();
|
|
expect(yield* listState()).toEqual([]);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"a same-deploy rename shift (A→B while B→C) moves both rows end-to-end",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
yield* TestResource("A", { string: "a" });
|
|
yield* TestResource("B", { string: "b" });
|
|
}),
|
|
);
|
|
const oldA = yield* getState("A");
|
|
const oldB = yield* getState("B");
|
|
|
|
// One deploy shifts both names: A→B and B→C. B's migration write
|
|
// and C's former-row cleanup target the same FQN — the apply-side
|
|
// discipline must never let C's delete destroy B's migrated row.
|
|
const touched: string[] = [];
|
|
const track = (op: string) => (id: string) =>
|
|
Effect.sync(() => void touched.push(`${op}:${id}`));
|
|
yield* stack
|
|
.deploy(
|
|
Effect.gen(function* () {
|
|
yield* TestResource("B", { string: "a" }).pipe(renamedFrom("A"));
|
|
yield* TestResource("C", { string: "b" }).pipe(renamedFrom("B"));
|
|
}),
|
|
)
|
|
.pipe(
|
|
hook({
|
|
create: track("create"),
|
|
delete: track("delete"),
|
|
}),
|
|
);
|
|
|
|
// No physical resource was created or deleted — both rows moved.
|
|
expect(touched).toEqual([]);
|
|
expect((yield* getState("B"))?.instanceId).toEqual(oldA?.instanceId);
|
|
expect((yield* getState("C"))?.instanceId).toEqual(oldB?.instanceId);
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
|
|
yield* stack.destroy();
|
|
expect(yield* listState()).toEqual([]);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"a pending replacement backlog drains after a rename",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
yield* TestResource("A", { string: "x", replaceString: "1" });
|
|
}),
|
|
);
|
|
|
|
// A replacement whose old-generation delete FAILS: the new
|
|
// generation is live but the old one stays queued in the row's
|
|
// `old` chain (status `replaced`). The failed cleanup is soft at
|
|
// deploy time — the deploy may still report success.
|
|
yield* stack
|
|
.deploy(
|
|
Effect.gen(function* () {
|
|
yield* TestResource("A", { string: "x", replaceString: "2" });
|
|
}),
|
|
)
|
|
.pipe(
|
|
hook({
|
|
delete: () => Effect.fail(new ResourceFailure()),
|
|
}),
|
|
Effect.exit,
|
|
);
|
|
const mid = yield* getState("A");
|
|
expect(mid?.status).toEqual("replaced");
|
|
expect((mid as any).old).toBeDefined();
|
|
|
|
// Rename while the backlog is pending: the chain must ride the
|
|
// migration and STILL drain — the old generation's physical
|
|
// resource is deleted during the rename deploy.
|
|
const deleted: string[] = [];
|
|
yield* stack
|
|
.deploy(
|
|
Effect.gen(function* () {
|
|
yield* TestResource("B", {
|
|
string: "x",
|
|
replaceString: "2",
|
|
}).pipe(renamedFrom("A"));
|
|
}),
|
|
)
|
|
.pipe(
|
|
hook({
|
|
delete: (id: string) => Effect.sync(() => void deleted.push(id)),
|
|
}),
|
|
);
|
|
|
|
// Exactly one physical delete: the queued old generation.
|
|
expect(deleted).toHaveLength(1);
|
|
const after = yield* getState("B");
|
|
// The new generation survived under the new identity, chain drained.
|
|
expect(after?.instanceId).toEqual(mid?.instanceId);
|
|
expect(["created", "updated"]).toContain(after?.status);
|
|
expect((after as any).old).toBeUndefined();
|
|
expect(yield* getState("A")).toBeUndefined();
|
|
|
|
yield* stack.destroy();
|
|
expect(yield* listState()).toEqual([]);
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"ignores the alias when the new FQN row exists with a different instanceId",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
// Both resources exist independently.
|
|
yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
yield* TestResource("Old", { string: "old" });
|
|
yield* TestResource("New", { string: "new" });
|
|
}),
|
|
);
|
|
const oldRow = yield* getState("Old");
|
|
const newRow = yield* getState("New");
|
|
expect(oldRow?.instanceId).not.toEqual(newRow?.instanceId);
|
|
|
|
// `New` claims `Old` as a former FQN, but already has its own row —
|
|
// the alias is ignored and `Old` is a normal orphan delete (the
|
|
// physical resource IS deleted).
|
|
const deleted: string[] = [];
|
|
yield* stack
|
|
.deploy(
|
|
Effect.gen(function* () {
|
|
yield* TestResource("New", { string: "new" }).pipe(
|
|
renamedFrom("Old"),
|
|
);
|
|
}),
|
|
)
|
|
.pipe(
|
|
hook({
|
|
delete: (id: string) => Effect.sync(() => void deleted.push(id)),
|
|
}),
|
|
);
|
|
|
|
expect(deleted).toEqual(["Old"]);
|
|
expect(yield* getState("Old")).toBeUndefined();
|
|
expect((yield* getState("New"))?.instanceId).toEqual(
|
|
newRow?.instanceId,
|
|
);
|
|
|
|
yield* stack.destroy();
|
|
expect(yield* listState()).toEqual([]);
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("filtered reconciliation", { tags: ["unit", "local"] }, () => {
|
|
for (const selection of [
|
|
{ include: ["Branch"] },
|
|
{ exclude: ["Worker", "NewUnselected", "SkippedAction", "Undeclared"] },
|
|
{
|
|
include: ["**"],
|
|
exclude: ["Worker", "NewUnselected", "SkippedAction", "Undeclared"],
|
|
},
|
|
]) {
|
|
test.provider(
|
|
`preserves unselected declared and undeclared rows and full-stack outputs ${JSON.stringify(selection)}`,
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
const Compute = Action("SkippedAction", (_: { value: string }) =>
|
|
Effect.succeed({ value: "old" }),
|
|
);
|
|
yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
yield* TestResource("Branch", { string: "v1" });
|
|
yield* TestResource("Worker", { string: "old" });
|
|
yield* TestResource("Undeclared", {});
|
|
yield* Compute({ value: "old" });
|
|
return { full: "original" };
|
|
}),
|
|
);
|
|
const worker = yield* getState("Worker");
|
|
const undeclared = yield* getState("Undeclared");
|
|
const skipped = yield* getState("SkippedAction");
|
|
const calls: string[] = [];
|
|
const result = yield* stack
|
|
.deploy(
|
|
Effect.gen(function* () {
|
|
const branch = yield* TestResource("Branch", { string: "v2" });
|
|
yield* TestResource("Worker", { string: "changed" });
|
|
yield* TestResource("NewUnselected", {});
|
|
const Fail = Action("SkippedAction", (_: { value: string }) =>
|
|
Effect.die("unselected action ran"),
|
|
);
|
|
yield* Fail({ value: "changed" });
|
|
return Output.map(branch.string, () => {
|
|
throw new Error("filtered output evaluated");
|
|
});
|
|
}),
|
|
selection,
|
|
)
|
|
.pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
update: (id) =>
|
|
Effect.sync(() => {
|
|
calls.push(id);
|
|
}),
|
|
create: () => Effect.die("unselected create"),
|
|
delete: () => Effect.die("unselected delete"),
|
|
}),
|
|
);
|
|
expect(result).toBeUndefined();
|
|
expect(calls).toEqual(["Branch"]);
|
|
expect(yield* getState("Worker")).toEqual(worker);
|
|
expect(yield* getState("Undeclared")).toEqual(undeclared);
|
|
expect(yield* getState("SkippedAction")).toEqual(skipped);
|
|
expect(yield* getState("NewUnselected")).toBeUndefined();
|
|
const state = yield* yield* State;
|
|
expect(
|
|
yield* state.getOutput({ stack: stack.name, stage: stack.stage }),
|
|
).toEqual({ full: "original" });
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
}
|
|
|
|
test.provider(
|
|
"applies transitive bindings and captured Actions while retaining pure reuse",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
let runs = 0;
|
|
const program = Effect.gen(function* () {
|
|
const source = yield* TestResource("Source", { string: "input" });
|
|
const captured = yield* TestResource("Captured", {
|
|
string: "capture",
|
|
});
|
|
const Compute = Action(
|
|
"Compute",
|
|
Effect.gen(function* () {
|
|
const value = yield* captured.string;
|
|
return (input: { value: string }) =>
|
|
Effect.gen(function* () {
|
|
runs++;
|
|
return { value: `${input.value}:${yield* value}` };
|
|
});
|
|
}),
|
|
);
|
|
const result = yield* Compute({ value: source.string });
|
|
const host = yield* BindingTarget("Host", {});
|
|
yield* host.bind("Compute", { env: { RESULT: result.value } });
|
|
yield* TestResource("Other", {});
|
|
return host.env.RESULT;
|
|
});
|
|
yield* stack.deploy(program, { include: ["Host"] });
|
|
const host = yield* getState("Host");
|
|
assert(host.attr !== undefined);
|
|
expect(host.attr.env).toEqual({
|
|
RESULT: "input:capture",
|
|
});
|
|
expect(yield* getState("Other")).toBeUndefined();
|
|
yield* stack.deploy(program, { include: ["Host"] });
|
|
expect(runs).toBe(1);
|
|
expect(yield* stack.deploy(program)).toBe("input:capture");
|
|
expect(runs).toBe(1);
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"preserves prior downstream edges owned by unselected resources and Actions",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
const Compute = Action("Compute", (input: { value: string }) =>
|
|
Effect.succeed({ value: input.value }),
|
|
);
|
|
yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
const a = yield* TestResource("A", {});
|
|
const result = yield* Compute({ value: a.string });
|
|
yield* TestResource("B", { string: result.value });
|
|
}),
|
|
);
|
|
const b = yield* getState("B");
|
|
const plan = yield* stack.plan(
|
|
Effect.gen(function* () {
|
|
const a = yield* TestResource("A", {});
|
|
yield* Compute({ value: a.string });
|
|
}),
|
|
{ include: ["Compute"] },
|
|
);
|
|
expect(plan.resources.A.downstream).toEqual(["Compute"]);
|
|
expect(plan.actions.Compute.downstream).toEqual(["B"]);
|
|
yield* apply(plan);
|
|
expect((yield* getState("A")).downstream).toEqual(["Compute"]);
|
|
expect((yield* getState("Compute")).downstream).toEqual(["B"]);
|
|
expect(yield* getState("B")).toEqual(b);
|
|
yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
const a = yield* TestResource("A", {});
|
|
yield* TestResource("B", { string: a.string });
|
|
}),
|
|
);
|
|
expect((yield* getState("A")).downstream).toEqual(["B"]);
|
|
yield* stack.deploy(TestResource("A", {}), { include: ["A"] });
|
|
expect((yield* getState("A")).downstream).toEqual(["B"]);
|
|
const deleted: string[] = [];
|
|
yield* stack.destroy().pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
delete: (id) =>
|
|
Effect.sync(() => {
|
|
deleted.push(id);
|
|
}),
|
|
}),
|
|
);
|
|
expect(deleted).toEqual(["B", "A"]);
|
|
}),
|
|
);
|
|
|
|
test.provider("does not switch unselected provider modes", (stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
const program = Effect.gen(function* () {
|
|
yield* ModalResource("Worker", { value: "same" });
|
|
yield* TestResource("Branch", {});
|
|
});
|
|
yield* stack.deploy(program);
|
|
const worker = yield* getState("Worker");
|
|
const before = modalCalls.filter(
|
|
(call) => call.stack === stack.name,
|
|
).length;
|
|
yield* inDev(stack.deploy(program, { include: ["Branch"] }));
|
|
expect(yield* getState("Worker")).toEqual(worker);
|
|
expect(
|
|
modalCalls.filter((call) => call.stack === stack.name).length,
|
|
).toBe(before);
|
|
yield* inDev(stack.deploy(program));
|
|
expect((yield* getState("Worker")).providerMode).toBe("local");
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"rejects selected renames without moving persisted identities",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
yield* stack.deploy(TestResource("Old", {}));
|
|
const old = yield* getState("Old");
|
|
const exit = yield* stack
|
|
.deploy(TestResource("New", {}).pipe(renamedFrom("Old")), {
|
|
include: ["New"],
|
|
})
|
|
.pipe(Effect.exit);
|
|
expect(Exit.isFailure(exit)).toBe(true);
|
|
if (Exit.isFailure(exit))
|
|
expect(Cause.pretty(exit.cause)).toContain("UnsafeSelectionBoundary");
|
|
expect(yield* getState("Old")).toEqual(old);
|
|
expect(yield* getState("New")).toBeUndefined();
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"rejects replacement across an unselected dependent boundary, then permits a closed replacement",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
const program = (revision: string) =>
|
|
Effect.gen(function* () {
|
|
const a = yield* TestResource("A", { replaceString: revision });
|
|
yield* TestResource("B", { string: a.string });
|
|
});
|
|
yield* stack.deploy(program("1"));
|
|
const a = yield* getState("A");
|
|
const b = yield* getState("B");
|
|
const exit = yield* stack
|
|
.deploy(program("2"), { include: ["A"] })
|
|
.pipe(Effect.exit);
|
|
expect(Exit.isFailure(exit)).toBe(true);
|
|
if (Exit.isFailure(exit))
|
|
expect(Cause.pretty(exit.cause)).toContain("UnsafeSelectionBoundary");
|
|
expect(yield* getState("A")).toEqual(a);
|
|
expect(yield* getState("B")).toEqual(b);
|
|
yield* stack.deploy(program("2"), { include: ["B"] });
|
|
expect((yield* getState("A")).instanceId).not.toBe(a.instanceId);
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"rejects replacement when removed historical binding-cycle edges cross the selection",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
const a = yield* BindingTarget("A", { replaceString: "1" });
|
|
const b = yield* BindingTarget("B", {});
|
|
yield* a.bind("B", { env: { B: b.name } });
|
|
yield* b.bind("A", { env: { A: a.name } });
|
|
}),
|
|
);
|
|
const a = yield* getState("A");
|
|
const b = yield* getState("B");
|
|
expect(a.downstream).toEqual([]);
|
|
expect(b.downstream).toEqual([]);
|
|
const exit = yield* stack
|
|
.deploy(
|
|
Effect.gen(function* () {
|
|
yield* BindingTarget("A", { replaceString: "2" });
|
|
yield* BindingTarget("B", {});
|
|
}),
|
|
{ include: ["A"] },
|
|
)
|
|
.pipe(Effect.exit);
|
|
expect(Exit.isFailure(exit)).toBe(true);
|
|
if (Exit.isFailure(exit))
|
|
expect(Cause.pretty(exit.cause)).toContain(
|
|
"Historical binding-cycle",
|
|
);
|
|
expect(yield* getState("A")).toEqual(a);
|
|
expect(yield* getState("B")).toEqual(b);
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"rejects resuming selected GC when an old generation still has unselected dependents",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
const program = (revision: string) =>
|
|
Effect.gen(function* () {
|
|
const a = yield* TestResource("A", { replaceString: revision });
|
|
yield* TestResource("B", { string: a.string });
|
|
});
|
|
yield* stack.deploy(program("1"));
|
|
yield* stack.deploy(program("2")).pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
delete: () => Effect.fail(new ResourceFailure()),
|
|
}),
|
|
Effect.exit,
|
|
);
|
|
const before = yield* getState("A");
|
|
expect(before.status).toBe("replaced");
|
|
const exit = yield* stack
|
|
.deploy(TestResource("A", { replaceString: "2" }), { include: ["A"] })
|
|
.pipe(Effect.exit);
|
|
expect(Exit.isFailure(exit)).toBe(true);
|
|
if (Exit.isFailure(exit))
|
|
expect(Cause.pretty(exit.cause)).toContain("UnsafeSelectionBoundary");
|
|
expect(yield* getState("A")).toEqual(before);
|
|
yield* stack.deploy(program("2"));
|
|
expectConvergedStatus((yield* getState("A")).status);
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"does not let an unselected rename claim a selected identity",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
yield* stack.deploy(TestResource("Old", { string: "original" }));
|
|
const before = yield* getState("Old");
|
|
const exit = yield* stack
|
|
.deploy(
|
|
Effect.gen(function* () {
|
|
yield* TestResource("Old", { string: "reused" });
|
|
yield* TestResource("New", {}).pipe(renamedFrom("Old"));
|
|
}),
|
|
{ include: ["Old"] },
|
|
)
|
|
.pipe(Effect.exit);
|
|
expect(Exit.isFailure(exit)).toBe(true);
|
|
expect(yield* getState("Old")).toEqual(before);
|
|
expect(yield* getState("New")).toBeUndefined();
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"does not collect unselected interrupted replacement generations",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
const program = (revision: string) =>
|
|
Effect.gen(function* () {
|
|
yield* TestResource("Old", { replaceString: revision });
|
|
yield* TestResource("Branch", {});
|
|
});
|
|
yield* stack.deploy(program("1"));
|
|
const interrupted = yield* stack.deploy(program("2")).pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
delete: () => Effect.fail(new ResourceFailure()),
|
|
}),
|
|
Effect.exit,
|
|
);
|
|
expect(Exit.isFailure(interrupted)).toBe(true);
|
|
const before = yield* getState("Old");
|
|
expect(before.status).toBe("replaced");
|
|
yield* stack
|
|
.deploy(TestResource("Branch", {}), { include: ["Branch"] })
|
|
.pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
delete: () => Effect.die("unselected GC"),
|
|
}),
|
|
);
|
|
expect(yield* getState("Old")).toEqual(before);
|
|
yield* stack.deploy(program("2"));
|
|
expectConvergedStatus((yield* getState("Old")).status);
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("filtered audit safeguards", { tags: ["unit", "local"] }, () => {
|
|
test.provider(
|
|
"rejects an unpersisted keeper before its first checkpoint can fail",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
const program = (clear: boolean, revision = "1") =>
|
|
Effect.gen(function* () {
|
|
const a = yield* BindingTarget("A", {
|
|
string: `generation-${revision}`,
|
|
replaceString: revision,
|
|
});
|
|
const middle = yield* BindingTarget("Middle", {});
|
|
if (!clear) {
|
|
yield* a.bind("Middle", { env: { MIDDLE: middle.name } });
|
|
yield* middle.bind("A", { env: { SOURCE: a.string } });
|
|
} else {
|
|
const keeper = yield* BindingTarget("Keeper", {});
|
|
yield* keeper.bind("constant", {
|
|
env: { VALUE: "new evidence" },
|
|
});
|
|
}
|
|
yield* TestResource("B", {
|
|
string: clear
|
|
? "detached"
|
|
: middle.env.pipe(Output.map((env) => env.SOURCE)),
|
|
});
|
|
});
|
|
yield* stack.deploy(program(false));
|
|
const rows = Effect.all([
|
|
getState("A"),
|
|
getState("Middle"),
|
|
getState("B"),
|
|
]);
|
|
const before = yield* rows;
|
|
const bytes = yield* Effect.sync(() => JSON.stringify(before));
|
|
const state = yield* yield* State;
|
|
const writes: string[] = [];
|
|
const calls: string[] = [];
|
|
const track = (id: string) =>
|
|
Effect.sync(() => {
|
|
calls.push(id);
|
|
});
|
|
const result = yield* Effect.gen(function* () {
|
|
const plan = yield* stack.plan(program(true), {
|
|
include: ["A", "Middle", "Keeper"],
|
|
});
|
|
yield* apply(plan);
|
|
}).pipe(
|
|
Effect.provideService(
|
|
State,
|
|
Effect.succeed({
|
|
...state,
|
|
set: (request) =>
|
|
Effect.sync(() => {
|
|
writes.push(request.fqn);
|
|
}).pipe(
|
|
Effect.andThen(() =>
|
|
request.fqn === "Keeper"
|
|
? Effect.fail(
|
|
new StateStoreError({
|
|
message: "First Keeper checkpoint failed",
|
|
}),
|
|
)
|
|
: state.set(request),
|
|
),
|
|
),
|
|
}),
|
|
),
|
|
Effect.provideService(TestResourceHooks, {
|
|
create: track,
|
|
update: track,
|
|
delete: track,
|
|
}),
|
|
Effect.exit,
|
|
);
|
|
assert(Exit.isFailure(result));
|
|
expect(Cause.pretty(result.cause)).toContain(
|
|
"last historical binding evidence",
|
|
);
|
|
expect(writes).toEqual([]);
|
|
expect(calls).toEqual([]);
|
|
const after = yield* rows;
|
|
expect(after).toEqual(before);
|
|
expect(yield* Effect.sync(() => JSON.stringify(after))).toBe(bytes);
|
|
expect(yield* getState("Keeper")).toBeUndefined();
|
|
const replacement = yield* stack
|
|
.deploy(program(true, "2"), { include: ["A"] })
|
|
.pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
create: track,
|
|
update: track,
|
|
delete: track,
|
|
}),
|
|
Effect.exit,
|
|
);
|
|
assert(Exit.isFailure(replacement));
|
|
expect(calls).toEqual([]);
|
|
expect(yield* rows).toEqual(before);
|
|
yield* stack.deploy(program(true), {
|
|
include: ["A", "Middle", "Keeper", "B"],
|
|
});
|
|
expect((yield* getState("B")).attr?.string).toBe("detached");
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
|
|
for (const operation of ["force", "update", "action noop"] as const) {
|
|
test.provider(
|
|
`preserves incomplete metadata before partial ${operation} and replacement`,
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
let runs = 0;
|
|
const Compute = Action("Middle", (input: { value: string }) =>
|
|
Effect.sync(() => {
|
|
runs++;
|
|
return { value: input.value };
|
|
}),
|
|
);
|
|
const program = (
|
|
detached: boolean,
|
|
revision = "1",
|
|
value = "generation-1",
|
|
) =>
|
|
Effect.gen(function* () {
|
|
const a = yield* TestResource("A", {
|
|
string: value,
|
|
replaceString: revision,
|
|
});
|
|
if (operation === "action noop") {
|
|
const middle = yield* Compute({
|
|
value: detached ? "generation-1" : a.string,
|
|
});
|
|
if (detached)
|
|
yield* TestResource("C", { string: middle.value });
|
|
yield* TestResource("B", {
|
|
string: detached ? "detached" : middle.value,
|
|
});
|
|
} else
|
|
yield* TestResource("B", {
|
|
string: detached ? "detached" : a.string,
|
|
});
|
|
});
|
|
yield* stack.deploy(program(false));
|
|
const state = yield* yield* State;
|
|
const incomplete = operation === "action noop" ? "Middle" : "A";
|
|
const key = {
|
|
stack: stack.name,
|
|
stage: stack.stage,
|
|
fqn: incomplete,
|
|
};
|
|
const row = yield* state.get(key);
|
|
assert(row !== undefined);
|
|
const legacy = { ...row };
|
|
yield* Effect.sync(() =>
|
|
Reflect.deleteProperty(legacy, "downstream"),
|
|
);
|
|
yield* state.set({ ...key, value: legacy });
|
|
const ids =
|
|
operation === "action noop" ? ["A", "Middle", "B"] : ["A", "B"];
|
|
const rows = Effect.all(
|
|
ids.map((id) => state.get({ ...key, fqn: id })),
|
|
);
|
|
const before = yield* rows;
|
|
const bytes = yield* Effect.sync(() => JSON.stringify(before));
|
|
const runsBefore = runs;
|
|
const calls: string[] = [];
|
|
const track = (id: string) =>
|
|
Effect.sync(() => {
|
|
calls.push(id);
|
|
});
|
|
const desired = program(
|
|
true,
|
|
"1",
|
|
operation === "update" ? "updated" : "generation-1",
|
|
);
|
|
const include =
|
|
operation === "action noop" ? ["A", "Middle", "C"] : ["A"];
|
|
const result = yield* stack
|
|
.deploy(desired, { include, force: operation === "force" })
|
|
.pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
create: track,
|
|
update: track,
|
|
delete: track,
|
|
}),
|
|
Effect.exit,
|
|
);
|
|
assert(Exit.isFailure(result));
|
|
expect(Cause.pretty(result.cause)).toContain(
|
|
"incomplete historical downstream metadata",
|
|
);
|
|
expect(calls).toEqual([]);
|
|
expect(runs).toBe(runsBefore);
|
|
const after = yield* rows;
|
|
expect(after).toEqual(before);
|
|
expect(yield* Effect.sync(() => JSON.stringify(after))).toBe(bytes);
|
|
expect(yield* getState("C")).toBeUndefined();
|
|
const replacement = yield* stack
|
|
.deploy(program(true, "2", "generation-2"), { include: ["A"] })
|
|
.pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
create: track,
|
|
update: track,
|
|
delete: track,
|
|
}),
|
|
Effect.exit,
|
|
);
|
|
assert(Exit.isFailure(replacement));
|
|
expect(calls).toEqual([]);
|
|
expect(yield* rows).toEqual(before);
|
|
yield* stack.deploy(desired, { force: true });
|
|
expect((yield* getState("B")).attr?.string).toBe("detached");
|
|
yield* stack.deploy(program(true, "2", "generation-2"), {
|
|
include: ["A"],
|
|
});
|
|
expect((yield* getState("A")).attr?.string).toBe("generation-2");
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
}
|
|
|
|
test.provider(
|
|
"retains last binding evidence in interrupted cleanup snapshots",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
const program = (bound: boolean) =>
|
|
Effect.gen(function* () {
|
|
const a = yield* BindingTarget("A", { string: "generation-1" });
|
|
const middle = yield* BindingTarget("Middle", {});
|
|
if (bound) {
|
|
yield* a.bind("Middle", { env: { Middle: middle.name } });
|
|
yield* middle.bind("A", { env: { A: a.string } });
|
|
}
|
|
yield* TestResource("B", {
|
|
string: bound
|
|
? middle.env.pipe(Output.map((env) => env.A))
|
|
: "detached",
|
|
});
|
|
});
|
|
yield* stack.deploy(program(true));
|
|
const interrupted = yield* stack.deploy(program(false)).pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
update: () => Effect.fail(new ResourceFailure()),
|
|
}),
|
|
Effect.exit,
|
|
);
|
|
assert(Exit.isFailure(interrupted));
|
|
const rows = Effect.all([
|
|
getState("A"),
|
|
getState("Middle"),
|
|
getState("B"),
|
|
]);
|
|
const before = yield* rows;
|
|
for (const id of ["A", "Middle"]) {
|
|
const row = yield* getState(id);
|
|
assert(row.status === "updating");
|
|
expect(row.bindings).toEqual([]);
|
|
expect(row.old.bindings.length).toBeGreaterThan(0);
|
|
}
|
|
const calls: string[] = [];
|
|
const track = (id: string) =>
|
|
Effect.sync(() => {
|
|
calls.push(id);
|
|
});
|
|
const recovery = yield* stack
|
|
.deploy(program(false), { include: ["A", "Middle"] })
|
|
.pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
create: track,
|
|
update: track,
|
|
delete: track,
|
|
}),
|
|
Effect.exit,
|
|
);
|
|
assert(Exit.isFailure(recovery));
|
|
expect(Cause.pretty(recovery.cause)).toContain(
|
|
"last historical binding evidence",
|
|
);
|
|
expect(calls).toEqual([]);
|
|
expect(yield* rows).toEqual(before);
|
|
yield* stack.deploy(program(false));
|
|
for (const row of yield* rows) {
|
|
expect(row.bindings).toEqual([]);
|
|
expect(row).not.toHaveProperty("old");
|
|
}
|
|
expect((yield* getState("B")).attr?.string).toBe("detached");
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"allows partial binding cleanup when a selected noop retains evidence",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
const program = (bound: boolean) =>
|
|
Effect.gen(function* () {
|
|
const a = yield* BindingTarget("A", {});
|
|
const keeper = yield* BindingTarget("Keeper", {});
|
|
if (bound) yield* a.bind("value", { env: { VALUE: "a" } });
|
|
yield* keeper.bind("value", { env: { VALUE: "keeper" } });
|
|
yield* TestResource("B", {});
|
|
});
|
|
yield* stack.deploy(program(true));
|
|
const b = yield* getState("B");
|
|
const keeper = yield* getState("Keeper");
|
|
const plan = yield* stack.plan(program(false), {
|
|
include: ["A", "Keeper"],
|
|
});
|
|
expect(plan.resources.Keeper.action).toBe("noop");
|
|
yield* apply(plan);
|
|
expect((yield* getState("A")).bindings).toEqual([]);
|
|
expect(yield* getState("Keeper")).toEqual(keeper);
|
|
expect(yield* getState("B")).toEqual(b);
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
|
|
for (const initialCycle of [false, true]) {
|
|
for (const consumer of ["Resource", "Action"] as const) {
|
|
for (const completion of [
|
|
"selected closure",
|
|
"full deployment",
|
|
] as const) {
|
|
test.provider(
|
|
`preserves last binding evidence for ${consumer} through ${completion} (initialCycle=${initialCycle})`,
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
let runs = 0;
|
|
const Consume = Action("B", (input: { value: string }) =>
|
|
Effect.sync(() => {
|
|
runs++;
|
|
return input.value;
|
|
}),
|
|
);
|
|
const program = (
|
|
mode: "acyclic" | "cycle" | "clear",
|
|
revision = "1",
|
|
) =>
|
|
Effect.gen(function* () {
|
|
const a = yield* BindingTarget("A", {
|
|
string: `generation-${revision}`,
|
|
replaceString: revision,
|
|
});
|
|
const middle = yield* BindingTarget("Middle", {});
|
|
if (mode !== "clear")
|
|
yield* middle.bind("A", { env: { A: a.string } });
|
|
if (mode === "cycle")
|
|
yield* a.bind("Middle", { env: { Middle: middle.name } });
|
|
const value =
|
|
mode === "clear"
|
|
? "detached"
|
|
: middle.env.pipe(Output.map((env) => env.A));
|
|
if (consumer === "Action") yield* Consume({ value });
|
|
else yield* TestResource("B", { string: value });
|
|
});
|
|
const consumerValue = Effect.gen(function* () {
|
|
const row = yield* getState<ResourceState | ActionState>("B");
|
|
if (row.kind === "action") {
|
|
assert(row.status === "ran");
|
|
return row.output;
|
|
}
|
|
return row.attr?.string;
|
|
});
|
|
yield* stack.deploy(program(initialCycle ? "cycle" : "acyclic"));
|
|
expect(yield* consumerValue).toBe("generation-1");
|
|
const b = yield* getState<ResourceState | ActionState>("B");
|
|
if (!initialCycle) {
|
|
expect((yield* getState("A")).downstream).toEqual(["Middle"]);
|
|
yield* stack.deploy(program("cycle"), {
|
|
include: ["A", "Middle"],
|
|
});
|
|
}
|
|
expect((yield* getState("A")).downstream).toEqual([]);
|
|
expect(yield* getState<ResourceState | ActionState>("B")).toEqual(
|
|
b,
|
|
);
|
|
const rows = Effect.all([
|
|
getState("A"),
|
|
getState("Middle"),
|
|
getState<ResourceState | ActionState>("B"),
|
|
]);
|
|
const before = yield* rows;
|
|
const bytes = yield* Effect.sync(() => JSON.stringify(before));
|
|
expect((yield* getState("A")).bindings.length).toBeGreaterThan(0);
|
|
expect(
|
|
(yield* getState("Middle")).bindings.length,
|
|
).toBeGreaterThan(0);
|
|
const runsBefore = runs;
|
|
const calls: string[] = [];
|
|
const track = (id: string) =>
|
|
Effect.sync(() => {
|
|
calls.push(id);
|
|
});
|
|
const exit = yield* stack
|
|
.deploy(program("clear"), { include: ["A", "Middle"] })
|
|
.pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
create: track,
|
|
update: track,
|
|
delete: track,
|
|
}),
|
|
Effect.exit,
|
|
);
|
|
assert(Exit.isFailure(exit));
|
|
expect(Cause.pretty(exit.cause)).toContain(
|
|
"last historical binding evidence",
|
|
);
|
|
expect(Cause.pretty(exit.cause)).toContain("unselected: B");
|
|
expect(Cause.pretty(exit.cause)).toContain(
|
|
"Select them or run a full deployment",
|
|
);
|
|
expect(calls).toEqual([]);
|
|
expect(runs).toBe(runsBefore);
|
|
const after = yield* rows;
|
|
expect(after).toEqual(before);
|
|
expect(yield* Effect.sync(() => JSON.stringify(after))).toBe(
|
|
bytes,
|
|
);
|
|
const replacement = yield* stack
|
|
.deploy(program("clear", "2"), { include: ["A"] })
|
|
.pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
create: track,
|
|
update: track,
|
|
delete: track,
|
|
}),
|
|
Effect.exit,
|
|
);
|
|
assert(Exit.isFailure(replacement));
|
|
expect(Cause.pretty(replacement.cause)).toContain(
|
|
"Historical binding-cycle",
|
|
);
|
|
expect(calls).toEqual([]);
|
|
expect(runs).toBe(runsBefore);
|
|
expect(yield* rows).toEqual(before);
|
|
if (consumer === "Action") {
|
|
const actionBoundary = yield* stack
|
|
.deploy(program("clear", "2"), {
|
|
include: ["A", "Middle"],
|
|
})
|
|
.pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
create: track,
|
|
update: track,
|
|
delete: track,
|
|
}),
|
|
Effect.exit,
|
|
);
|
|
assert(Exit.isFailure(actionBoundary));
|
|
expect(Cause.pretty(actionBoundary.cause)).toContain(
|
|
"'B' is unselected",
|
|
);
|
|
expect(Cause.pretty(actionBoundary.cause)).toContain(
|
|
"Historical binding-cycle",
|
|
);
|
|
expect(calls).toEqual([]);
|
|
expect(runs).toBe(runsBefore);
|
|
expect(yield* rows).toEqual(before);
|
|
}
|
|
if (completion === "selected closure")
|
|
yield* stack.deploy(program("clear"), {
|
|
include: ["A", "Middle", "B"],
|
|
});
|
|
else yield* stack.deploy(program("clear"));
|
|
expect((yield* getState("A")).bindings).toEqual([]);
|
|
expect((yield* getState("Middle")).bindings).toEqual([]);
|
|
expect(yield* consumerValue).toBe("detached");
|
|
const clearedB = yield* getState<ResourceState | ActionState>(
|
|
"B",
|
|
);
|
|
const a = yield* getState("A");
|
|
yield* stack.deploy(program("clear", "2"), { include: ["A"] });
|
|
expect((yield* getState("A")).instanceId).not.toBe(a.instanceId);
|
|
expect(yield* getState<ResourceState | ActionState>("B")).toEqual(
|
|
clearedB,
|
|
);
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const intermediate of ["Action", "Resource"] as const) {
|
|
for (const completion of ["selected closure", "full deployment"] as const) {
|
|
for (const historical of [false, true]) {
|
|
test.provider(
|
|
`refuses selected ${intermediate} detachment before ${completion} (historical=${historical})`,
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
let runs = 0;
|
|
const Compute = Action("Middle", (input: { value: string }) =>
|
|
Effect.sync(() => {
|
|
runs++;
|
|
return input;
|
|
}),
|
|
);
|
|
const program = (
|
|
revision: string,
|
|
detached: boolean,
|
|
updating = false,
|
|
) =>
|
|
Effect.gen(function* () {
|
|
const a = yield* TestResource("A", {
|
|
string: updating ? "updated" : `generation-${revision}`,
|
|
replaceString: revision,
|
|
});
|
|
const input = detached ? "detached" : a.string;
|
|
const middle =
|
|
intermediate === "Action"
|
|
? (yield* Compute({ value: input })).value
|
|
: (yield* TestResource("Middle", { string: input }))
|
|
.string;
|
|
const value = historical
|
|
? (yield* TestResource("Bridge", {
|
|
string:
|
|
updating && intermediate === "Resource"
|
|
? "detached"
|
|
: middle,
|
|
})).string
|
|
: middle;
|
|
yield* TestResource("B", {
|
|
string: historical && detached ? "detached" : value,
|
|
});
|
|
});
|
|
yield* stack.deploy(program("1", false));
|
|
if (historical) {
|
|
const failed = yield* stack
|
|
.deploy(program("1", true, true))
|
|
.pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
update: () => Effect.fail(new ResourceFailure()),
|
|
}),
|
|
Effect.exit,
|
|
);
|
|
assert(Exit.isFailure(failed));
|
|
const a = yield* getState("A");
|
|
const bridge = yield* getState("Bridge");
|
|
assert(a.status === "updating");
|
|
assert(bridge.status === "updating");
|
|
expect(a.downstream).toEqual([]);
|
|
expect(a.old).toMatchObject({ downstream: ["Middle"] });
|
|
expect(bridge.downstream).toEqual([]);
|
|
expect(bridge.old).toMatchObject({ downstream: ["B"] });
|
|
}
|
|
const ids = historical
|
|
? ["A", "Middle", "Bridge", "B"]
|
|
: ["A", "Middle", "B"];
|
|
const rows = Effect.all(ids.map((id) => getState(id)));
|
|
const before = yield* rows;
|
|
const beforeBytes = yield* Effect.sync(() =>
|
|
JSON.stringify(before),
|
|
);
|
|
const runsBefore = runs;
|
|
const calls: string[] = [];
|
|
const track = (id: string) =>
|
|
Effect.sync(() => {
|
|
calls.push(id);
|
|
});
|
|
const include = ids.filter((id) => id !== "B");
|
|
const exit = yield* stack
|
|
.deploy(program("1", true, historical), { include })
|
|
.pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
create: track,
|
|
update: track,
|
|
delete: track,
|
|
}),
|
|
Effect.exit,
|
|
);
|
|
assert(Exit.isFailure(exit));
|
|
expect(Cause.pretty(exit.cause)).toContain(
|
|
"Cannot detach 'A' from 'Middle'",
|
|
);
|
|
expect(Cause.pretty(exit.cause)).toContain("unselected: B");
|
|
expect(Cause.pretty(exit.cause)).toContain(
|
|
"Select them or run a full deployment",
|
|
);
|
|
expect(calls).toEqual([]);
|
|
expect(runs).toBe(runsBefore);
|
|
const after = yield* rows;
|
|
expect(after).toEqual(before);
|
|
expect(yield* Effect.sync(() => JSON.stringify(after))).toBe(
|
|
beforeBytes,
|
|
);
|
|
const complete = program("1", true, historical);
|
|
if (completion === "selected closure")
|
|
yield* stack.deploy(complete, { include: ids });
|
|
else yield* stack.deploy(complete);
|
|
expect((yield* getState("A")).downstream).toEqual([]);
|
|
expect((yield* getState("B")).attr?.string).toBe("detached");
|
|
const b = yield* getState("B");
|
|
const a = yield* getState("A");
|
|
yield* stack.deploy(program("2", true, historical), {
|
|
include: ["A"],
|
|
});
|
|
expect((yield* getState("A")).instanceId).not.toBe(a.instanceId);
|
|
expect(yield* getState("B")).toEqual(b);
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
test.provider(
|
|
"does not confuse binding SCC filtering with selected detachment",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
const program = (cycle: boolean) =>
|
|
Effect.gen(function* () {
|
|
const a = yield* BindingTarget("A", {});
|
|
const middle = yield* BindingTarget("Middle", {});
|
|
yield* middle.bind("A", { env: { A: a.name } });
|
|
if (cycle)
|
|
yield* a.bind("Middle", { env: { Middle: middle.name } });
|
|
yield* TestResource("B", { string: middle.name });
|
|
});
|
|
yield* stack.deploy(program(false));
|
|
expect((yield* getState("A")).downstream).toEqual(["Middle"]);
|
|
const b = yield* getState("B");
|
|
yield* stack.deploy(program(true), { include: ["A", "Middle"] });
|
|
expect((yield* getState("A")).downstream).toEqual([]);
|
|
expect(yield* getState("B")).toEqual(b);
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"preserves historical excluded edges through partial cycle convergence",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
const program = (revision: string, attached: boolean) =>
|
|
Effect.gen(function* () {
|
|
const a = yield* BindingTarget("A", { string: `${revision}-a` });
|
|
const c = yield* BindingTarget("C", { string: `${revision}-c` });
|
|
yield* a.bind("C", { env: { C: c.string } });
|
|
yield* c.bind("A", { env: { A: a.string } });
|
|
yield* TestResource("B", {
|
|
string: attached ? a.string : "detached",
|
|
});
|
|
});
|
|
yield* stack.deploy(program("1", true));
|
|
const failed = yield* stack.deploy(program("2", false)).pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
update: () => Effect.fail(new ResourceFailure()),
|
|
}),
|
|
Effect.exit,
|
|
);
|
|
assert(Exit.isFailure(failed));
|
|
const interruptedA = yield* getState("A");
|
|
const b = yield* getState("B");
|
|
assert(interruptedA.status === "updating");
|
|
expect(interruptedA.downstream).toEqual([]);
|
|
expect(interruptedA.old).toMatchObject({ downstream: ["B"] });
|
|
const started = new Set<string>();
|
|
const updates: string[] = [];
|
|
const ready = yield* Deferred.make<void>();
|
|
yield* stack.deploy(program("2", false), { include: ["A"] }).pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
update: (id) =>
|
|
Effect.gen(function* () {
|
|
const count = yield* Effect.sync(() => {
|
|
updates.push(id);
|
|
started.add(id);
|
|
return started.size;
|
|
});
|
|
// Both cycle members reconcile stale bindings before convergence.
|
|
if (count === 2) yield* Deferred.succeed(ready, undefined);
|
|
else yield* Deferred.await(ready);
|
|
}),
|
|
}),
|
|
);
|
|
expect(updates.filter((id) => id === "A").length).toBeGreaterThan(1);
|
|
const a = yield* getState("A");
|
|
expectConvergedStatus(a.status);
|
|
expect(a).not.toHaveProperty("old");
|
|
expect(a.downstream).toEqual(["B"]);
|
|
expect(a.attr?.env).toEqual({ C: "2-c" });
|
|
expect(yield* getState("B")).toEqual(b);
|
|
yield* stack.destroy();
|
|
}),
|
|
{ timeout: 10_000 },
|
|
);
|
|
|
|
for (const declaredConsumer of [true, false]) {
|
|
test.provider(
|
|
`preserves historical excluded edges through partial update recovery (declared=${declaredConsumer})`,
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
const program = (
|
|
value: string,
|
|
replacement: string,
|
|
attached: boolean,
|
|
consumer = true,
|
|
) =>
|
|
Effect.gen(function* () {
|
|
const a = yield* TestResource("A", {
|
|
string: value,
|
|
replaceString: replacement,
|
|
});
|
|
if (consumer)
|
|
yield* TestResource("B", {
|
|
string: attached ? a.string : "detached",
|
|
});
|
|
});
|
|
yield* stack.deploy(program("original", "1", true));
|
|
const failed = yield* stack
|
|
.deploy(program("recovered", "1", false))
|
|
.pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
update: () => Effect.fail(new ResourceFailure()),
|
|
}),
|
|
Effect.exit,
|
|
);
|
|
assert(Exit.isFailure(failed));
|
|
const interruptedA = yield* getState("A");
|
|
const b = yield* getState("B");
|
|
assert(interruptedA.status === "updating");
|
|
assert(b.status === "updating");
|
|
expect(interruptedA.downstream).toEqual([]);
|
|
expect(interruptedA.old).toMatchObject({ downstream: ["B"] });
|
|
expect(b.attr?.string).toBe("original");
|
|
const recovery = program("recovered", "1", false, declaredConsumer);
|
|
const plan = yield* stack.plan(recovery, { include: ["A"] });
|
|
expect(plan.resources.A.action).toBe("update");
|
|
yield* apply(plan);
|
|
const recovered = yield* getState("A");
|
|
expectConvergedStatus(recovered.status);
|
|
expect(recovered).not.toHaveProperty("old");
|
|
expect(recovered.downstream).toEqual(["B"]);
|
|
expect(yield* getState("B")).toEqual(b);
|
|
const noop = yield* stack.plan(recovery, { include: ["A"] });
|
|
expect(noop.resources.A.action).toBe("noop");
|
|
yield* apply(noop);
|
|
expect((yield* getState("A")).downstream).toEqual(["B"]);
|
|
expect(yield* getState("B")).toEqual(b);
|
|
yield* stack.deploy(
|
|
program("updated again", "1", false, declaredConsumer),
|
|
{ include: ["A"] },
|
|
);
|
|
const before = yield* getState("A");
|
|
expect(before.downstream).toEqual(["B"]);
|
|
expect(yield* getState("B")).toEqual(b);
|
|
const calls: string[] = [];
|
|
const track = (id: string) =>
|
|
Effect.sync(() => {
|
|
calls.push(id);
|
|
});
|
|
const exit = yield* stack
|
|
.deploy(program("updated again", "2", false, declaredConsumer), {
|
|
include: ["A"],
|
|
})
|
|
.pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
create: track,
|
|
update: track,
|
|
delete: track,
|
|
}),
|
|
Effect.exit,
|
|
);
|
|
assert(Exit.isFailure(exit));
|
|
expect(Cause.pretty(exit.cause)).toContain(
|
|
"dependents are unselected: B",
|
|
);
|
|
expect(calls).toEqual([]);
|
|
expect(yield* getState("A")).toEqual(before);
|
|
expect(yield* getState("B")).toEqual(b);
|
|
yield* stack.deploy(program("updated again", "1", false));
|
|
expect((yield* getState("A")).downstream).toEqual([]);
|
|
expectConvergedStatus((yield* getState("B")).status);
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
}
|
|
|
|
for (const status of ["updating", "replacing", "replaced"] as const) {
|
|
test.provider(
|
|
`traverses intermediate ${status}.old downstream edges`,
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
const program = (aRevision: string, changed: boolean) =>
|
|
Effect.gen(function* () {
|
|
const a = yield* TestResource("A", { replaceString: aRevision });
|
|
const middle = yield* TestResource("Middle", {
|
|
string: a.string,
|
|
stringArray: [changed ? "2" : "1"],
|
|
replaceString: changed && status !== "updating" ? "2" : "1",
|
|
});
|
|
yield* TestResource("B", {
|
|
string: changed ? "detached" : middle.string,
|
|
});
|
|
});
|
|
yield* stack.deploy(program("1", false));
|
|
const failed = yield* stack
|
|
.deploy(program("1", true))
|
|
.pipe(
|
|
Effect.provideService(
|
|
TestResourceHooks,
|
|
failOn(
|
|
"Middle",
|
|
status === "updating"
|
|
? "update"
|
|
: status === "replacing"
|
|
? "create"
|
|
: "delete",
|
|
),
|
|
),
|
|
Effect.exit,
|
|
);
|
|
assert(Exit.isFailure(failed));
|
|
const before = yield* Effect.all([
|
|
getState("A"),
|
|
getState("Middle"),
|
|
getState("B"),
|
|
]);
|
|
const middle = before[1];
|
|
assert(
|
|
middle.status === "updating" ||
|
|
middle.status === "replacing" ||
|
|
middle.status === "replaced",
|
|
);
|
|
expect(middle.status).toBe(status);
|
|
expect(middle.downstream).toEqual([]);
|
|
expect(middle.old).toMatchObject({ downstream: ["B"] });
|
|
const calls: string[] = [];
|
|
const track = (id: string) =>
|
|
Effect.sync(() => {
|
|
calls.push(id);
|
|
});
|
|
const exit = yield* stack
|
|
.deploy(program("2", true), { include: ["Middle"] })
|
|
.pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
create: track,
|
|
update: track,
|
|
delete: track,
|
|
}),
|
|
Effect.exit,
|
|
);
|
|
assert(Exit.isFailure(exit));
|
|
expect(Cause.pretty(exit.cause)).toContain(
|
|
"dependents are unselected: B",
|
|
);
|
|
expect(calls).toEqual([]);
|
|
expect(
|
|
yield* Effect.all([
|
|
getState("A"),
|
|
getState("Middle"),
|
|
getState("B"),
|
|
]),
|
|
).toEqual(before);
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
}
|
|
|
|
test.provider(
|
|
"refuses ambiguous mixed binding-cycle GC resumption",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
const program = (revision: string, bound: boolean) =>
|
|
Effect.gen(function* () {
|
|
const b = yield* BindingTarget("B", {});
|
|
const a = yield* BindingTarget("A", {
|
|
replaceString: revision,
|
|
string: bound ? b.name : undefined,
|
|
});
|
|
if (bound) yield* b.bind("A", { env: { A: a.name } });
|
|
});
|
|
yield* stack.deploy(program("1", true));
|
|
const failed = yield* stack.deploy(program("2", true)).pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
delete: () => Effect.fail(new ResourceFailure()),
|
|
}),
|
|
Effect.exit,
|
|
);
|
|
assert(Exit.isFailure(failed));
|
|
const before = yield* Effect.all([getState("A"), getState("B")]);
|
|
const a = before[0];
|
|
assert(a.status === "replaced");
|
|
expect(a.bindings).toEqual([]);
|
|
expect(a.old.bindings).toEqual([]);
|
|
expect(a.downstream).toEqual([]);
|
|
expect(a.old.downstream).toEqual([]);
|
|
const calls: string[] = [];
|
|
const track = (id: string) =>
|
|
Effect.sync(() => {
|
|
calls.push(id);
|
|
});
|
|
const exit = yield* stack
|
|
.deploy(program("2", false), { include: ["A"] })
|
|
.pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
create: track,
|
|
update: track,
|
|
delete: track,
|
|
}),
|
|
Effect.exit,
|
|
);
|
|
assert(Exit.isFailure(exit));
|
|
expect(Cause.pretty(exit.cause)).toContain("Historical binding-cycle");
|
|
expect(calls).toEqual([]);
|
|
expect(yield* Effect.all([getState("A"), getState("B")])).toEqual(
|
|
before,
|
|
);
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
|
|
for (const intermediate of ["Action", "Resource"] as const) {
|
|
for (const declaredConsumer of [true, false]) {
|
|
for (const resume of [false, true]) {
|
|
test.provider(
|
|
`refuses transitive ${intermediate} boundary (declared=${declaredConsumer}, resume=${resume})`,
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
let runs = 0;
|
|
const Compute = Action("Middle", (input: { value: string }) =>
|
|
Effect.sync(() => {
|
|
runs++;
|
|
return input;
|
|
}),
|
|
);
|
|
const program = (revision: string, consumer = true) =>
|
|
Effect.gen(function* () {
|
|
const a = yield* TestResource("A", {
|
|
string: revision,
|
|
replaceString: revision,
|
|
});
|
|
const value =
|
|
intermediate === "Action"
|
|
? (yield* Compute({ value: a.string })).value
|
|
: (yield* TestResource("Middle", { string: a.string }))
|
|
.string;
|
|
if (consumer) yield* TestResource("B", { string: value });
|
|
});
|
|
yield* stack.deploy(program("1"));
|
|
if (resume) {
|
|
const interrupted = yield* stack.deploy(program("2")).pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
delete: () => Effect.fail(new ResourceFailure()),
|
|
}),
|
|
Effect.exit,
|
|
);
|
|
assert(Exit.isFailure(interrupted));
|
|
expect((yield* getState("A")).status).toBe("replaced");
|
|
}
|
|
const before = yield* Effect.all([
|
|
getState("A"),
|
|
getState("Middle"),
|
|
getState("B"),
|
|
]);
|
|
const runsBefore = runs;
|
|
const calls: string[] = [];
|
|
const track = (id: string) =>
|
|
Effect.sync(() => {
|
|
calls.push(id);
|
|
});
|
|
const exit = yield* stack
|
|
.deploy(program("2", declaredConsumer), {
|
|
include: ["Middle"],
|
|
})
|
|
.pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
create: track,
|
|
update: track,
|
|
delete: track,
|
|
}),
|
|
Effect.exit,
|
|
);
|
|
assert(Exit.isFailure(exit));
|
|
expect(Cause.pretty(exit.cause)).toContain(
|
|
"UnsafeSelectionBoundary",
|
|
);
|
|
expect(Cause.pretty(exit.cause)).toContain("B");
|
|
expect(calls).toEqual([]);
|
|
expect(runs).toBe(runsBefore);
|
|
expect(
|
|
yield* Effect.all([
|
|
getState("A"),
|
|
getState("Middle"),
|
|
getState("B"),
|
|
]),
|
|
).toEqual(before);
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const history of [
|
|
"partial removal",
|
|
"selected updating",
|
|
"unselected updating",
|
|
"unselected replacing",
|
|
"unselected replaced",
|
|
"mixed cycle",
|
|
] as const) {
|
|
test.provider(
|
|
`refuses ambiguous historical bindings after ${history}`,
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
const program = (bound: boolean, aRevision = "1", bRevision = "1") =>
|
|
Effect.gen(function* () {
|
|
const b = yield* BindingTarget("B", { replaceString: bRevision });
|
|
const a = yield* BindingTarget("A", {
|
|
replaceString: aRevision,
|
|
string: bound && history === "mixed cycle" ? b.name : undefined,
|
|
});
|
|
if (bound) {
|
|
if (history !== "mixed cycle")
|
|
yield* a.bind("B", { env: { B: b.name } });
|
|
yield* b.bind("A", { env: { A: a.name } });
|
|
}
|
|
});
|
|
yield* stack.deploy(program(true));
|
|
if (history === "selected updating") {
|
|
yield* stack.deploy(program(false), { include: ["B"] });
|
|
const failed = yield* stack.deploy(program(false)).pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
update: () => Effect.fail(new ResourceFailure()),
|
|
}),
|
|
Effect.exit,
|
|
);
|
|
assert(Exit.isFailure(failed));
|
|
const a = yield* getState("A");
|
|
assert(a.status === "updating");
|
|
expect(a.bindings).toEqual([]);
|
|
expect(a.old.bindings.length).toBeGreaterThan(0);
|
|
expect((yield* getState("B")).bindings).toEqual([]);
|
|
} else if (history !== "mixed cycle") {
|
|
yield* stack.deploy(program(false), { include: ["A"] });
|
|
expect((yield* getState("A")).bindings).toEqual([]);
|
|
if (history === "unselected updating") {
|
|
const failed = yield* stack.deploy(program(false)).pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
update: () => Effect.fail(new ResourceFailure()),
|
|
}),
|
|
Effect.exit,
|
|
);
|
|
assert(Exit.isFailure(failed));
|
|
const b = yield* getState("B");
|
|
assert(b.status === "updating");
|
|
expect(b.bindings).toEqual([]);
|
|
expect(b.old.bindings.length).toBeGreaterThan(0);
|
|
} else if (
|
|
history === "unselected replacing" ||
|
|
history === "unselected replaced"
|
|
) {
|
|
const failed = yield* stack
|
|
.deploy(program(false, "1", "2"))
|
|
.pipe(
|
|
Effect.provideService(
|
|
TestResourceHooks,
|
|
history === "unselected replacing"
|
|
? { create: () => Effect.fail(new ResourceFailure()) }
|
|
: { delete: () => Effect.fail(new ResourceFailure()) },
|
|
),
|
|
Effect.exit,
|
|
);
|
|
assert(Exit.isFailure(failed));
|
|
const b = yield* getState("B");
|
|
assert(b.status === "replacing" || b.status === "replaced");
|
|
expect(b.status).toBe(
|
|
history === "unselected replacing" ? "replacing" : "replaced",
|
|
);
|
|
expect(b.bindings).toEqual([]);
|
|
expect(b.old.bindings.length).toBeGreaterThan(0);
|
|
}
|
|
}
|
|
const before = yield* Effect.all([getState("A"), getState("B")]);
|
|
expect(before[0].bindings).toEqual([]);
|
|
const calls: string[] = [];
|
|
const track = (id: string) =>
|
|
Effect.sync(() => {
|
|
calls.push(id);
|
|
});
|
|
const exit = yield* stack
|
|
.deploy(program(false, "2"), { include: ["A"] })
|
|
.pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
create: track,
|
|
update: track,
|
|
delete: track,
|
|
}),
|
|
Effect.exit,
|
|
);
|
|
assert(Exit.isFailure(exit));
|
|
expect(Cause.pretty(exit.cause)).toContain(
|
|
"Historical binding-cycle",
|
|
);
|
|
expect(calls).toEqual([]);
|
|
expect(yield* Effect.all([getState("A"), getState("B")])).toEqual(
|
|
before,
|
|
);
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
}
|
|
|
|
for (const replacement of [false, true]) {
|
|
test.provider(
|
|
`refuses Action collision with persisted resource (replacement=${replacement})`,
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
yield* stack.deploy(TestResource("X", { replaceString: "1" }));
|
|
if (replacement) {
|
|
const failed = yield* stack
|
|
.deploy(TestResource("X", { replaceString: "2" }))
|
|
.pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
delete: () => Effect.fail(new ResourceFailure()),
|
|
}),
|
|
Effect.exit,
|
|
);
|
|
assert(Exit.isFailure(failed));
|
|
expect((yield* getState("X")).status).toBe("replaced");
|
|
}
|
|
const before = yield* getState("X");
|
|
let runs = 0;
|
|
const Compute = Action("X", (_: {}) =>
|
|
Effect.sync(() => {
|
|
runs++;
|
|
}),
|
|
);
|
|
const calls: string[] = [];
|
|
const track = (id: string) =>
|
|
Effect.sync(() => {
|
|
calls.push(id);
|
|
});
|
|
const exit = yield* stack
|
|
.deploy(Compute({}), { include: ["X"] })
|
|
.pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
create: track,
|
|
update: track,
|
|
delete: track,
|
|
}),
|
|
Effect.exit,
|
|
);
|
|
assert(Exit.isFailure(exit));
|
|
expect(Cause.pretty(exit.cause)).toContain("UnsafeSelectionBoundary");
|
|
expect(Cause.pretty(exit.cause)).toContain("persisted resource");
|
|
expect(runs).toBe(0);
|
|
expect(calls).toEqual([]);
|
|
expect(yield* getState("X")).toEqual(before);
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
}
|
|
|
|
test.provider(
|
|
"refuses filtered mode switch after interrupted live destruction",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
const program = ModalResource("Worker", { value: "live" });
|
|
yield* stack.deploy(program);
|
|
const failed = yield* stack.destroy().pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
delete: () => Effect.fail(new ResourceFailure()),
|
|
}),
|
|
Effect.exit,
|
|
);
|
|
assert(Exit.isFailure(failed));
|
|
const before = yield* getState("Worker");
|
|
expect(before.status).toBe("deleting");
|
|
expect(before.providerMode).toBe("live");
|
|
const callsBefore = modalCalls.filter(
|
|
(call) => call.stack === stack.name,
|
|
);
|
|
const exit = yield* inDev(
|
|
stack.deploy(program, { include: ["Worker"] }),
|
|
).pipe(Effect.exit);
|
|
assert(Exit.isFailure(exit));
|
|
expect(Cause.pretty(exit.cause)).toContain("UnsafeSelectionBoundary");
|
|
expect(Cause.pretty(exit.cause)).toContain("recorded 'live' mode");
|
|
expect(yield* getState("Worker")).toEqual(before);
|
|
expect(modalCalls.filter((call) => call.stack === stack.name)).toEqual(
|
|
callsBefore,
|
|
);
|
|
yield* inDev(stack.destroy());
|
|
expect(
|
|
modalCalls
|
|
.filter((call) => call.stack === stack.name)
|
|
.slice(callsBefore.length),
|
|
).toEqual([
|
|
{ stack: stack.name, mode: "live", op: "delete", id: "Worker" },
|
|
]);
|
|
expect(yield* getState("Worker")).toBeUndefined();
|
|
yield* inDev(stack.deploy(program, { include: ["Worker"] }));
|
|
expect((yield* getState("Worker")).providerMode).toBe("local");
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("noop stable readiness", { tags: ["unit", "local"] }, () => {
|
|
test.provider(
|
|
"publishes noop resource output before waking captured Action consumers",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
let runs = 0;
|
|
const program = (revision: number) =>
|
|
Effect.gen(function* () {
|
|
const source = yield* TestResource("Source", {
|
|
string: "ready",
|
|
}).pipe(RemovalPolicy.retain(revision > 1));
|
|
const Compute = Action(
|
|
"Compute",
|
|
Effect.gen(function* () {
|
|
const value = yield* source.string;
|
|
return (_: { revision: number }) =>
|
|
Effect.gen(function* () {
|
|
const resolved = yield* value;
|
|
runs++;
|
|
return { value: resolved };
|
|
});
|
|
}),
|
|
);
|
|
const result = yield* Compute({ revision });
|
|
return result.value;
|
|
});
|
|
expect(yield* stack.deploy(program(1))).toBe("ready");
|
|
const plan = yield* stack.plan(program(2));
|
|
expect(plan.resources.Source.action).toBe("noop");
|
|
expect(plan.actions.Compute.action).toBe("run");
|
|
// The policy note holds the noop until the Action reports pending.
|
|
// One cooperative yield lets it await readyStable before publication;
|
|
// Deferred completion then resumes the waiting consumer synchronously.
|
|
const pending = yield* Deferred.make<void>();
|
|
expect(
|
|
yield* apply(plan, {
|
|
session: {
|
|
done: () => Effect.void,
|
|
emit: (event) => {
|
|
if (
|
|
event._tag === "apply.resource.status" &&
|
|
event.id === "Compute" &&
|
|
event.status === "pending"
|
|
) {
|
|
return Deferred.succeed(pending, undefined).pipe(
|
|
Effect.asVoid,
|
|
);
|
|
}
|
|
if (
|
|
event._tag === "apply.resource.note" &&
|
|
event.id === "Source"
|
|
) {
|
|
return Deferred.await(pending).pipe(
|
|
Effect.andThen(Effect.yieldNow),
|
|
);
|
|
}
|
|
return Effect.void;
|
|
},
|
|
},
|
|
}),
|
|
).toBe("ready");
|
|
expect(runs).toBe(2);
|
|
expect(yield* stack.deploy(program(2))).toBe("ready");
|
|
expect(runs).toBe(2);
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe(
|
|
"resource selection apply barriers",
|
|
{ tags: ["unit", "local"] },
|
|
() => {
|
|
for (const revision of ["old", "new"]) {
|
|
test.provider(
|
|
`excluded ${revision === "old" ? "noop" : "changed"} upstream rejects without reads diffs mutations or row changes`,
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
const program = (value: string, fresh = false) =>
|
|
Effect.gen(function* () {
|
|
const source = yield* BindingTarget("Source", {
|
|
string: value,
|
|
});
|
|
const middle = yield* BindingTarget("Middle", {
|
|
string: source.string,
|
|
});
|
|
const consumer = yield* TestResource("Consumer", {
|
|
string: middle.string,
|
|
});
|
|
if (fresh) yield* TestResource("Fresh", {});
|
|
return { consumer: consumer.string };
|
|
}).pipe(Namespace.push("App"));
|
|
yield* stack.deploy(program("old"));
|
|
const state = yield* yield* State;
|
|
const key = { stack: stack.name, stage: stack.stage };
|
|
const snapshot = Effect.gen(function* () {
|
|
const ids = [...(yield* state.list(key))].sort();
|
|
const rows = yield* Effect.forEach(ids, (fqn) =>
|
|
state.get({ ...key, fqn }),
|
|
);
|
|
const output = yield* state.getOutput(key);
|
|
return yield* Effect.sync(() =>
|
|
JSON.stringify({ ids, rows, output }),
|
|
);
|
|
});
|
|
const before = yield* snapshot;
|
|
const calls: string[] = [];
|
|
const record = (op: string) => (id: string) =>
|
|
Effect.sync(() => {
|
|
calls.push(`${op}:${id}`);
|
|
});
|
|
const rejected = yield* stack
|
|
.deploy(program(revision, true), {
|
|
include: ["App/Fresh", "App/Consumer"],
|
|
exclude: ["App/S*"],
|
|
})
|
|
.pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
read: (id) => record("read")(id).pipe(Effect.as(undefined)),
|
|
diff: record("diff"),
|
|
create: record("create"),
|
|
update: record("update"),
|
|
delete: record("delete"),
|
|
}),
|
|
Effect.exit,
|
|
);
|
|
expect(Exit.isFailure(rejected)).toBe(true);
|
|
if (Exit.isFailure(rejected)) {
|
|
const message = Cause.pretty(rejected.cause);
|
|
expect(message).toContain(
|
|
"App/Consumer -> App/Middle -> App/Source",
|
|
);
|
|
expect(message).toContain("exclude pattern 'App/S*'");
|
|
}
|
|
expect(calls).toEqual([]);
|
|
expect(yield* snapshot).toBe(before);
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
}
|
|
|
|
test.provider(
|
|
"includes and updates implicit upstreams outside the include pattern",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
const program = (value: string) =>
|
|
Effect.gen(function* () {
|
|
const source = yield* TestResource("Source", { string: value });
|
|
const middle = yield* TestResource("Middle", {
|
|
string: source.string,
|
|
});
|
|
const consumer = yield* TestResource("Consumer", {
|
|
string: middle.string,
|
|
});
|
|
yield* TestResource("Excluded", {});
|
|
return { value: consumer.string };
|
|
});
|
|
yield* stack.deploy(program("old"));
|
|
const excluded = yield* getState("Excluded");
|
|
const changed: string[] = [];
|
|
const output = yield* stack
|
|
.deploy(program("new"), {
|
|
include: ["Cons*"],
|
|
exclude: ["Excluded"],
|
|
})
|
|
.pipe(
|
|
Effect.provideService(TestResourceHooks, {
|
|
update: (id) =>
|
|
Effect.sync(() => {
|
|
changed.push(id);
|
|
}),
|
|
}),
|
|
);
|
|
expect(output).toBeUndefined();
|
|
expect(changed).toEqual(["Source", "Middle", "Consumer"]);
|
|
expect((yield* getState("Source")).attr?.string).toBe("new");
|
|
expect((yield* getState("Middle")).attr?.string).toBe("new");
|
|
expect((yield* getState("Consumer")).attr?.string).toBe("new");
|
|
expect(yield* getState("Excluded")).toEqual(excluded);
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
|
|
for (const selection of [
|
|
{ include: ["**"] },
|
|
{ exclude: ["Missing/**"] },
|
|
]) {
|
|
test.provider(
|
|
`selecting everything still preserves stack outputs ${JSON.stringify(selection)}`,
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
yield* stack.deploy(
|
|
TestResource("Only", { string: "old" }).pipe(
|
|
Effect.as({ full: "old" }),
|
|
),
|
|
);
|
|
const output = yield* stack.deploy(
|
|
TestResource("Only", { string: "new" }).pipe(
|
|
Effect.map((resource) =>
|
|
Output.map(resource.string, () => {
|
|
throw new Error("partial output evaluated");
|
|
}),
|
|
),
|
|
),
|
|
selection,
|
|
);
|
|
expect(output).toBeUndefined();
|
|
expect((yield* getState("Only")).attr?.string).toBe("new");
|
|
const state = yield* yield* State;
|
|
expect(
|
|
yield* state.getOutput({ stack: stack.name, stage: stack.stage }),
|
|
).toEqual({ full: "old" });
|
|
yield* stack.destroy();
|
|
}),
|
|
);
|
|
}
|
|
},
|
|
);
|