fix(core): resolve per-field Effect inputs in resource props#596
Conversation
Input<T> admits Effect<T> for any prop, but neither resolveInput
(Plan.ts) nor Output.evaluate (Output.ts) had a branch for raw
Effects: plan-time diffing saw the unresolved Effect object (tripping
providers' isResolved guards into conservative updates) and apply-time
evaluation shredded it via the generic object walk into a plain
`{"~effect/Effect/args": ...}` object that reached provider APIs —
e.g. the Worker provider's domain reconciliation, where a
stage-conditional `domain: Stack.useSync(...)` crashed the Cloudflare
client's schema validation, and a value that should have resolved to
`undefined` was truthy and entered reconciliation anyway.
Resolve raw Effects in both walkers, mirroring how Config values are
resolved (alchemy-run#566), with two carve-outs for Effects that are resource
references rather than per-field values:
- Function-form Effects (resource classes, effectClass constructors,
Binding.Policy tags) stay opaque: they are class references carried
in props (e.g. Worker `exports`).
- Non-class resource references (`const db = Hyperdrive("db", ...)`)
are object-form Effects. The Resource constructor now brands them
(`alchemy/ResourceEffect`) and the walkers leave them opaque:
executing one outside the construction phase re-derives its FQN from
the ambient namespace (none at plan/apply time) and mints a phantom
resource — `MissingSourceError: Source db not found` when a Worker's
env holds `ADMIN_DB: db`. Branded references also count as resolved
in Diff.isResolved so providers' custom diffs keep running, and the
Worker vite env mapping skips them.
Plain Effects that wrap a resource reference (`AI_GATEWAY_ID:
Effect.map(aiGateway, g => g.gatewayId)`) cannot be detected without
executing them, so the Resource constructor additionally memoizes its
result per stack: re-execution during input resolution returns the
resource registered during construction instead of re-deriving the FQN,
and the resulting attribute expression resolves through the normal
Output machinery.
Fixes alchemy-run#588
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
I don't think we should do this, and we should instead change |
|
That's fair — a raw The thing that needs a home before Proposal: have If that matches what you have in mind, I'll rework this PR into that shape — the repro and most of the tests carry over as-is. |
Fixes #588.
Problem
The class-form Worker/StaticSite pattern requires a plain props literal so
InferEnv<typeof Website>can infer the binding map — stage-dependent values must therefore be per-field Effect inputs (domain: Stack.useSync(stack => stack.stage === "prod" ? "example.com" : undefined)), not whole-props branching.Input<T>admitsEffect<T>for any prop, and theisResolveddoc inDiff.tsalready describes props as potentially containing "anEffectthat was not fully evaluated byresolveInputin Plan.ts" — but neitherresolveInputnorOutput.evaluatehad a branch for raw Effects:resolveInput(Plan.ts) passed the Effect through toprovider.diffand the plan node's props, tripping providers'isResolvedguards into conservative fallbacks.Output.evaluate(Output.ts) shredded it via the generic object walk into a plain{"~effect/Effect/args": ...}object that reached provider APIs — for the Worker's domain reconciliation, a crash in the Cloudflare client's schema validation; and because the unresolved object is truthy, adomainthat should have resolved toundefinedentered reconciliation at all.news.url !== falsehad the same hazard.Fix
Resolve raw Effects in both walkers, recursing into the result — mirroring the Config branches added in #566 (same two functions, same recursion shape). Repeated evaluation of the same input Effect across plan and apply matches how
EffectExproutputs already behave (see the existing memoization TODO onOutput.evaluate).Two kinds of Effects are references, not per-field values, and are carved out:
effectClassconstructors,Binding.Policytags) — class references carried in props (e.g. Workerexports); guarded bytypeof !== "function".const db = Hyperdrive("db", …)placed inenv) — bare constructor Effects. The constructor's FQN derives from the ambientNamespaceat execution time, so executing one during input resolution (no namespace in scope) re-derives a different FQN and mints a phantom resource —MissingSourceError: Source db not found. The constructor now brands its Effect (Symbol.for("alchemy/ResourceEffect"), the same duplicate-module-safe pattern asalchemy/Expr), and the walkers leave branded references opaque. They also count as resolved inDiff.isResolved(so provider diffs keep running) and are skipped by the Worker vite env mapping.Plain Effects that wrap a reference (
AI_GATEWAY_ID: Effect.map(aiGateway, g => g.gatewayId)) can't be detected without running them —Effect.mapstrips the brand — and per-field resolution must run them. To make that safe, the constructor additionally memoizes its result per stack (WeakMapkeyed on theStackservice object): re-execution returns the resource registered during construction, and the resulting attribute expression resolves through the normal Output machinery. The// TODO(sam): check if props are different and dieon the existing-FQN registry path is untouched — onmainthe only dedup is the stack registry (stack.resources[fqn]), and the memo adds construction identity alongside it without changing that behavior.Worth saying plainly: for resolved resources (class form, or after
yield*), attribute expressions (backend.url) are the canonical way to reference another resource's output. But for an unyielded non-class reference inside a props literal there is no attribute surface — the reference is the bare constructor Effect, soEffect.map(resource, …)is the only way to project a field from it. The memo is what makes that form safe instead of silently minting a phantom resource. (We initially tried migrating our app toaiGateway.gatewayIdand the type-checker correctly rejected it — the runtime value would beundefined.)Out of scope, noted while auditing
StaticSitestringifies non-stringenvvalues for the build command's environment at construction (JSON.stringify(v)), before any engine resolution — a separate provider-local issue for Effect-valued env there.stringifyResolvedStringin AWSWebsite/StaticSite.ts/Router.tscomposes per-field Effects into inner-resource props; those were latently broken the same way as Worker:domainprop is read without resolving per-field Effect inputs (crashes domain reconciliation) #588 and are healed by this change.Tests
Output.test.ts: evaluate resolves raw Effects (top-level/nested/array/recursive), resolved-undefinedstaysundefined, Redacted results stay wrapped, effect classes and non-class resource references pass through untouched.plan.test.ts: Effect props arrive concrete in plan props; resolved-undefinedlands asundefined; diff observes resolved values (noop on unchanged); a resource reference stays opaque with no phantom plan node.apply.test.ts: end-to-endStack.useSyncprops (including the resolved-undefinedskip-guard case); a namespaced resource reference embedded in another resource's props deploys with the reference arriving un-executed (reproduces theMissingSourceErroron main); a plain Effect wrapping a reference resolves to the real resource's attribute.Diff.test.ts: per-field Effects count as unresolved; branded references count as resolved.All new tests fail on
mainand pass with the fix;bun tsc -b, oxlint, and the engine/Local/Build suites are green.Verified beyond the test suite by deploying a production app through this branch: a Worker with a stage-conditional
domain: Stack.useSync(…)(the #588 repro — crashed every stage before), ~20 bindings including Hyperdrive/R2/KV resource references inenv, Durable Objects, and a wrapped-reference prop. Deploys cleanly; theundefined-resolved domain skips reconciliation; the app serves 200 end-to-end.🤖 Generated with Claude Code