Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions packages/alchemy/src/Plan.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you change plan, you gotta add corresponding plan.test.ts cases to document and enforce this

Original file line number Diff line number Diff line change
Expand Up @@ -933,13 +933,21 @@ export const make = <A>(
const newUpstreamDependencies: {
[fqn: string]: string[];
} = Object.fromEntries([
...resources.map(
(resource) =>
[
resource.FQN,
Object.values(Output.upstreamAny(resource.Props)).map((r) => r.FQN),
] as const,
),
...resources.map((resource) => {
// Resource proxies synthesize attribute Outputs for missing keys, so
// only read explicit dependencies when the resource owns the field.
const explicitDependencies = Object.hasOwn(resource, "Dependencies")
? resource.Dependencies
: [];
const dependencies = [
...Object.values(Output.upstreamAny(resource.Props)),
...Object.values(Output.upstreamAny(explicitDependencies ?? [])),
];
return [
resource.FQN,
Array.from(new Set(dependencies.map((resource) => resource.FQN))),
] as const;
}),
...actions.map(
(action) =>
[
Expand Down
1 change: 1 addition & 0 deletions packages/alchemy/src/Platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,7 @@ export const Platform = <
? yield* runtimeContext.exports
: undefined,
};
instance.Dependencies = runtimeContext.dependencies;

return Object.assign(instance, {
RuntimeContext: runtimeContext,
Expand Down
2 changes: 2 additions & 0 deletions packages/alchemy/src/Resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ export interface ResourceLike<
* Properties of the Resource.
*/
Props: Props;
/** Outputs used only for dependency ordering, never provider input. */
Dependencies?: readonly Output.Output[];
/**
* Removal Policy of the Resource.
*/
Expand Down
11 changes: 11 additions & 0 deletions packages/alchemy/src/RuntimeContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export interface BaseRuntimeContext {
Type: string;
id: string;
env: Record<string, any>;
/** Outputs used only to order this runtime after upstream graph nodes. */
dependencies?: Output[];
/**
* Read a value by its (already-canonical) key. The key is used verbatim;
* callers must {@link sanitizeKey} first. See {@link sanitizeKey}.
Expand Down Expand Up @@ -37,6 +39,15 @@ export interface BaseRuntimeContext {
telemetry?: Layer.Layer<never, any, any>;
}

/** Register an ordering-only dependency without exposing it at runtime. */
export const addDependency = (
context: BaseRuntimeContext,
output: Output,
): Effect.Effect<void> =>
Effect.sync(() => {
(context.dependencies ??= []).push(output);
});

/**
* Canonicalize a logical key into a key that is safe to use as the name of an
* environment variable / binding (`[a-zA-Z][a-zA-Z0-9_]*`).
Expand Down
43 changes: 43 additions & 0 deletions packages/alchemy/test/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,27 @@ describe("Plan", () => {
}),
);

test(
"ordering-only dependency keeps task upstream without changing props",
Effect.gen(function* () {
const Migrate = Action("Migrate", (_: {}) =>
Effect.succeed({ migrated: true }),
);

const plan = yield* Effect.gen(function* () {
const migration = yield* Migrate({});
const bucket = yield* Bucket("MyBucket", { name: "static" });
bucket.Dependencies = [migration];
return bucket;
}).pipe(makePlan);

expect(plan.actions.Migrate.downstream).toContain("MyBucket");
expect((plan.resources.MyBucket as Plan.Create).props).toEqual({
name: "static",
});
}),
);

test(
"task depends on resource: resource is upstream of task",
Effect.gen(function* () {
Expand Down Expand Up @@ -295,6 +316,28 @@ describe("Plan", () => {
// ── Apply tests ───────────────────────────────────────────────────────────

describe("Apply", () => {
test.provider(
"ordering-only Action dependency converges to a noop plan",
(stack) =>
Effect.gen(function* () {
const Migrate = Action("Migrate", (_: {}) =>
Effect.succeed({ migrated: true }),
);
const program = Effect.gen(function* () {
const migration = yield* Migrate({});
const bucket = yield* Bucket("MyBucket", { name: "static" });
bucket.Dependencies = [migration];
return bucket;
});

yield* stack.deploy(program);
const settled = yield* stack.plan(program);

expect(settled.actions.Migrate.action).toBe("noop");
expect(settled.resources.MyBucket.action).toBe("noop");
}),
);

test.provider("first run invokes body and persists ran state", (stack) =>
Effect.gen(function* () {
const counter = yield* Ref.make(0);
Expand Down
10 changes: 4 additions & 6 deletions packages/better-auth/src/Migrate.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Action, Stack, type Output } from "alchemy";
import { sha256Object } from "alchemy/Util/sha256";
import { CurrentRuntimeContext, sanitizeKey } from "alchemy/RuntimeContext";
import { addDependency, CurrentRuntimeContext } from "alchemy/RuntimeContext";
import type { BetterAuthOptions } from "better-auth";
import { getSchema } from "better-auth/db";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -105,13 +105,11 @@ export const registerMigration = ({
(effect) => effect as unknown as Effect.Effect<Output<MigrateOutput>>,
);

// Bind the migration result into the host environment (when there is a
// host): the host's env then depends on the Action's output, giving the
// engine a Worker/Function → Migration dependency edge so first-deploy
// traffic cannot race the schema.
// Order the host after the migration without exposing the Action result
// as runtime input.
const rc = yield* CurrentRuntimeContext;
if (rc !== undefined) {
yield* rc.set(sanitizeKey(`${id}Migration`), result as never);
yield* addDependency(rc, result as never);
}
}) as Effect.Effect<void>;

Expand Down
Loading