diff --git a/packages/alchemy/src/Plan.ts b/packages/alchemy/src/Plan.ts index 5410fe6920..a2549b8d94 100644 --- a/packages/alchemy/src/Plan.ts +++ b/packages/alchemy/src/Plan.ts @@ -933,13 +933,21 @@ export const make = ( 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) => [ diff --git a/packages/alchemy/src/Platform.ts b/packages/alchemy/src/Platform.ts index e03955521c..47b98390e6 100644 --- a/packages/alchemy/src/Platform.ts +++ b/packages/alchemy/src/Platform.ts @@ -654,6 +654,7 @@ export const Platform = < ? yield* runtimeContext.exports : undefined, }; + instance.Dependencies = runtimeContext.dependencies; return Object.assign(instance, { RuntimeContext: runtimeContext, diff --git a/packages/alchemy/src/Resource.ts b/packages/alchemy/src/Resource.ts index b07a50d070..5b5ef4b343 100644 --- a/packages/alchemy/src/Resource.ts +++ b/packages/alchemy/src/Resource.ts @@ -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. */ diff --git a/packages/alchemy/src/RuntimeContext.ts b/packages/alchemy/src/RuntimeContext.ts index 1ef609a326..ea6e17fbd4 100644 --- a/packages/alchemy/src/RuntimeContext.ts +++ b/packages/alchemy/src/RuntimeContext.ts @@ -10,6 +10,8 @@ export interface BaseRuntimeContext { Type: string; id: string; env: Record; + /** 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}. @@ -37,6 +39,15 @@ export interface BaseRuntimeContext { telemetry?: Layer.Layer; } +/** Register an ordering-only dependency without exposing it at runtime. */ +export const addDependency = ( + context: BaseRuntimeContext, + output: Output, +): Effect.Effect => + 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_]*`). diff --git a/packages/alchemy/test/action.test.ts b/packages/alchemy/test/action.test.ts index 24220f3a43..75826ff11c 100644 --- a/packages/alchemy/test/action.test.ts +++ b/packages/alchemy/test/action.test.ts @@ -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* () { @@ -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); diff --git a/packages/better-auth/src/Migrate.ts b/packages/better-auth/src/Migrate.ts index 6527848b94..aac1776664 100644 --- a/packages/better-auth/src/Migrate.ts +++ b/packages/better-auth/src/Migrate.ts @@ -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"; @@ -105,13 +105,11 @@ export const registerMigration = ({ (effect) => effect as unknown as Effect.Effect>, ); - // 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;