Skip to content

fix(core): resolve per-field Effect inputs in resource props#596

Open
agcty wants to merge 1 commit into
alchemy-run:mainfrom
agcty:fix/worker-domain-input-resolution
Open

fix(core): resolve per-field Effect inputs in resource props#596
agcty wants to merge 1 commit into
alchemy-run:mainfrom
agcty:fix/worker-domain-input-resolution

Conversation

@agcty

@agcty agcty commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

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> admits Effect<T> for any prop, and the isResolved doc in Diff.ts already describes props as potentially containing "an Effect that was not fully evaluated by resolveInput in Plan.ts" — but neither resolveInput nor Output.evaluate had a branch for raw Effects:

  • resolveInput (Plan.ts) passed the Effect through to provider.diff and the plan node's props, tripping providers' isResolved guards 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, a domain that should have resolved to undefined entered reconciliation at all. news.url !== false had 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 EffectExpr outputs already behave (see the existing memoization TODO on Output.evaluate).

Two kinds of Effects are references, not per-field values, and are carved out:

  • Function-form Effects (resource classes, effectClass constructors, Binding.Policy tags) — class references carried in props (e.g. Worker exports); guarded by typeof !== "function".
  • Non-class resource references (const db = Hyperdrive("db", …) placed in env) — bare constructor Effects. The constructor's FQN derives from the ambient Namespace at 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 as alchemy/Expr), and the walkers leave branded references opaque. They also count as resolved in Diff.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.map strips the brand — and per-field resolution must run them. To make that safe, the constructor additionally memoizes its result per stack (WeakMap keyed on the Stack service 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 die on the existing-FQN registry path is untouched — on main the 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, so Effect.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 to aiGateway.gatewayId and the type-checker correctly rejected it — the runtime value would be undefined.)

Out of scope, noted while auditing

Tests

  • Output.test.ts: evaluate resolves raw Effects (top-level/nested/array/recursive), resolved-undefined stays undefined, 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-undefined lands as undefined; diff observes resolved values (noop on unchanged); a resource reference stays opaque with no phantom plan node.
  • apply.test.ts: end-to-end Stack.useSync props (including the resolved-undefined skip-guard case); a namespaced resource reference embedded in another resource's props deploys with the reference arriving un-executed (reproduces the MissingSourceError on 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 main and 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 in env, Durable Objects, and a wrapped-reference prop. Deploys cleanly; the undefined-resolved domain skips reconciliation; the app serves 200 end-to-end.

🤖 Generated with Claude Code

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>
@sam-goodwin sam-goodwin self-assigned this Jun 11, 2026
@sam-goodwin

Copy link
Copy Markdown
Contributor

I don't think we should do this, and we should instead change Input<T> to now allow an Effect because we can't capture the requirements and error channels of the effect, which breaks the type safety of Effect

@agcty

agcty commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

That's fair — a raw Effect<T, E, R> in props erases both the error and requirements channels, and resolving it in the walkers codifies that erasure rather than fixing it.

The thing that needs a home before Input<T> drops Effects is stage-conditional per-field values. The class-form Worker pattern needs a plain props literal for InferEnv, so whole-props branching is out — and today the only per-field form is domain: Stack.useSync(stack => stack.stage === "prod" ? "example.com" : undefined), which is exactly the raw-Effect shape you want gone.

Proposal: have Stack.useSync (or a sibling) return a branded, channel-free expression — the same expression surface the Output machinery already resolves — instead of a raw Effect. Then Input<T> can stop admitting Effect at the type level, Config stays the secrets path, and field projection off resources stays attribute expressions on yielded resources. The walkers need no new Effect branch at all; the half-support gets deleted instead of completed.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Worker: domain prop is read without resolving per-field Effect inputs (crashes domain reconciliation)

2 participants