1---
2title: 2.0.0-beta.37 - Cross-Stack References & Bindings
3date: 2026-05-12
4category: release
5excerpt: Cross-stack and cross-stage references, fully-typed Worker-to-Worker bindings, Workflows with typed I/O, `Alchemy.Secret` / `Alchemy.Variable`, cron triggers, an Analytics Engine binding, and R2 buckets that finally empty themselves on destroy.
6---
7
8`v2.0.0-beta.37` is the biggest beta in a while. The
9headline addition is **cross-stack and cross-stage
10references** — the missing piece that lets PR-preview
11stages share a database with `staging` instead of
12re-provisioning a whole Postgres cluster every time someone
13opens a draft PR. On top of that: fully-typed
14Worker-to-Worker bindings, typed Workflow I/O, the new
15`Alchemy.Secret` / `Alchemy.Variable` one-liners, cron
16triggers, and an Analytics Engine binding.
17
18A bunch of this came from outside the core team — props
19to the contributors throughout, and full credits at the
20[bottom of the post](#contributors).
21
22## Cross-stack and cross-stage references
23
24Lazy, typed references to resources deployed by a
25*different* stack or stage. The use case it was built for:
26ephemeral PR-preview stages that need a Neon project,
27shouldn't pay for their own, and should instead share one
28that's owned by `staging`.
29
30### Pointing a PR stage at `staging`'s database
31
32Same `alchemy.run.ts`, conditional on the stage. If the
33stage looks like a PR preview (`pr-147`, `pr-148`, …),
34`Neon.Project.ref` reaches into the `staging` stage's state
35file and pulls out the already-deployed project. Otherwise
36the stage creates its own.
37
38```typescript
39// src/Db.ts
40import * as Alchemy from "alchemy";
41import * as Drizzle from "alchemy/Drizzle";
42import * as Neon from "alchemy/Neon";
43import * as Effect from "effect/Effect";
44
45export const NeonDb = Effect.gen(function* () {
46 const { stage } = yield* Alchemy.Stack;
47
48 const schema = yield* Drizzle.Schema("app-schema", {
49 schema: "./src/schema.ts",
50 out: "./migrations",
51 });
52
53 // PR previews share the long-lived staging project.
54 // Every other stage gets its own.
55 const project = stage.startsWith("pr-")
56 ? yield* Neon.Project.ref("app-db", { stage: "staging" })
57 : yield* Neon.Project("app-db", { region: "aws-us-east-1" });
58
59 // Branches are cheap and per-stage either way.
60 const branch = yield* Neon.Branch("app-branch", {
61 project,
62 migrationsDir: schema.out,
63 });
64
65 return { project, branch, schema };
66});
67```
68
69Three things to note:
70
71- **Same logical id, same type.** `"app-db"` matches the
72 id `staging` uses to create the project; `project` is
73 `Neon.Project` either way, so downstream
74 (`Neon.Branch({ project })`) doesn't know or care
75 whether it's real or referenced.
76- **Resolved at plan time.** Alchemy reads the project's
77 attributes (id, host, etc.) out of `staging`'s persisted
78 [state store](/state-store). If `staging`
79 hasn't been deployed yet, plan fails loudly with
80 `InvalidReferenceError`.
81- **PR teardown stays scoped.** `alchemy destroy --stage
82 pr-147` deletes the per-PR `Neon.Branch` but doesn't
83 touch the shared project — this stage doesn't own it.
84
85Deploy `staging` once, then PR stages can point at it:
86
87```sh
88alchemy deploy --stage staging # creates the project once
89alchemy deploy --stage pr-147 # references it, creates only the branch
90```
91
92The full file lives in
93[`examples/cloudflare-neon-drizzle/src/Db.ts`](https://github.com/alchemy-run/alchemy/tree/main/examples/cloudflare-neon-drizzle/src/Db.ts);
94the guide is at
95[Guides › Shared database across stages](/cloudflare/data/shared-database).
96
97### Referencing an entire stack's outputs
98
99The example above pulls one **resource** across stages.
100The other shape — pulling a **whole stack's outputs** —
101is what you reach for in a monorepo where the frontend
102package wants to read the backend stack's deployed URL.
103
104Declare a typed stack handle once:
105
106```typescript
107// backend/src/Stack.ts
108import * as Alchemy from "alchemy";
109
110export class Backend extends Alchemy.Stack<
111 Backend,
112 { url: string }
113>()("Backend") {}
114```
115
116Deploy the backend with `Backend.make(...)` (the typed
117shorthand for `Alchemy.Stack`), then `yield* Backend` from
118the frontend's stack to get its outputs back, type-checked:
119
120```typescript
121// frontend/alchemy.run.ts
122import * as Alchemy from "alchemy";
123import * as Cloudflare from "alchemy/Cloudflare";
124import { Backend } from "backend";
125import * as Effect from "effect/Effect";
126
127export default Alchemy.Stack(
128 "Frontend",
129 { providers: Cloudflare.providers(), state: Cloudflare.state() },
130 Effect.gen(function* () {
131 // Resolves Backend's outputs from the same stage of the same
132 // stack name. `pr-42` frontend reads `pr-42` backend.
133 const backend = yield* Backend;
134 // ^? { url: string }
135
136 return yield* Cloudflare.Website.Vite("Website", {
137 env: { VITE_API_URL: backend.url },
138 });
139 }),
140);
141```
142
143`yield* Backend` defaults to "same stage as the consumer".
144When you need to pin — say, the prod frontend always reads
145the prod backend regardless of which branch deploys it —
146use `Backend.stage.<name>`:
147
148```typescript
149const backend = yield* Backend.stage.prod; // always pin to prod
150const backend = yield* Backend.stage["pr-42"]; // arbitrary stage name
151```
152
153Under the hood, both shapes are
154[`Output.stackRef`](/infrastructure-as-code/outputs#ref) /
155[`Resource.ref`](/infrastructure-as-code/resource#ref) reading the
156state store. The new APIs just make them ergonomic.
157
158[Concepts › References](/infrastructure-as-code/references) ·
159[Guides › Shared database](/cloudflare/data/shared-database) ·
160[Guides › Monorepos](/project-structure/monorepo) ·
161[Tutorial › Branch from a shared database](/cloudflare/data/branch-from-shared-database)
162
163## `Alchemy.Secret` and `Alchemy.Variable`
164
165The boring part of a stack — wiring an env var into a
166deploy target — used to leak across three files. One yield
167now collapses it into a line that's also a typed runtime
168accessor.
169
170```typescript
171// alchemy.run.ts — declare once on the Worker
172export default Cloudflare.Worker("Api",
173 { main: import.meta.filename },
174 Effect.gen(function* () {
175 const apiKey = yield* Alchemy.Secret("OPENAI_API_KEY");
176 // ^? Output<Redacted<string>>
177
178 return {
179 fetch: Effect.gen(function* () {
180 // …and read the bound value inside the handler.
181 const key = yield* apiKey; // Redacted<string>
182 return HttpServerResponse.text(
183 `key has ${Redacted.value(key).length} chars`,
184 );
185 }),
186 };
187 }),
188);
189```
190
191`Alchemy.Variable` is the same shape without `Redacted`:
192
193```typescript
194const port = yield* Alchemy.Variable("PORT", 3000);
195const flags = yield* Alchemy.Variable("FLAGS", { beta: true });
196
197// inside fetch
198const p = yield* port; // number — 3000
199const f = yield* flags; // { beta: true }
200```
201
202Both accept a literal, an `Effect`, a `Config`, or default
203to reading the value from the active `ConfigProvider` under
204the same name. The same call routes to the platform's
205native secret/variable binding — Cloudflare `secret_text`
206for `Secret`, Lambda encrypted env vars on AWS — and the
207runtime accessor decodes back to the original type.
208
209The docs are at [Concepts › Secrets](/environments/secrets) and [Secrets & env](/cloudflare/security/secrets-env).
210
211## Worker-to-Worker bindings, fully typed
212
213A Worker is now bindable as a binding on another Worker,
214and the caller gets full RPC types on the other side — no
215codegen, no manual interfaces, no `as` casts on `env`.
216Three call shapes are supported, all on the same
217deployment.
218
219The example: a `Backend` Worker exposing both an RPC method
220and an HTTP route, plus a TanStack Start frontend calling
221it three different ways. (This is the
222[`examples/cloudflare-tanstack`](https://github.com/alchemy-run/alchemy/tree/main/examples/cloudflare-website-tanstack-start)
223project — cut down to the relevant pieces.)
224
225The backend Worker:
226
227```typescript
228// src/backend.ts
229import * as Cloudflare from "alchemy/Cloudflare";
230import * as Effect from "effect/Effect";
231import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";
232import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
233
234export const Bucket = Cloudflare.R2.Bucket("Bucket");
235
236export default class Backend extends Cloudflare.Worker<Backend>()(
237 "Backend",
238 { main: import.meta.filename },
239 Effect.gen(function* () {
240 const bucket = yield* Cloudflare.R2.ReadWriteBucket(Bucket);
241
242 return {
243 // RPC method — callable via `backend.hello(key)` on the other side.
244 hello: Effect.fn("Backend.hello")(function* (key: string) {
245 const object = yield* bucket.get(key);
246 return object === null ? null : yield* object.text();
247 }),
248
249 // HTTP handler — callable via `env.BACKEND.fetch(...)`.
250 fetch: Effect.gen(function* () {
251 const request = yield* HttpServerRequest;
252 const key = new URL(request.url, "http://backend").searchParams.get("key");
253 if (!key) return HttpServerResponse.text("missing key", { status: 400 });
254
255 if (request.method === "GET") {
256 const object = yield* bucket.get(key);
257 return object === null
258 ? HttpServerResponse.text("not found", { status: 404 })
259 : HttpServerResponse.stream(object.body);
260 }
261 return HttpServerResponse.text("method not allowed", { status: 405 });
262 }),
263 };
264 }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)),
265) {}
266```
267
268Wire it into another Worker as a binding:
269
270```typescript
271// alchemy.run.ts
272import Backend, { Bucket } from "./src/backend.ts";
273
274export const Website = Cloudflare.Website.Vite("Website", {
275 bindings: {
276 BUCKET: Bucket, // R2 binding
277 BACKEND: Backend, // Worker-to-Worker binding
278 },
279});
280```
281
282Now the caller has three ways to talk to the backend. All
283three are real, all three are typed, all three work in the
284same handler — pick whichever fits the call site.
285
286```typescript
287// frontend route handler
288import * as Cloudflare from "alchemy/Cloudflare";
289import type Backend from "../backend.ts";
290import { env } from "../env.ts";
291
292// Option 1 — async binding (just call the platform API directly).
293const object = await env.BUCKET.get(key);
294
295// Option 2 — Worker-to-Worker fetch over the service binding.
296const res = await env.BACKEND.fetch(`https://backend/?key=${encodeURIComponent(key)}`);
297
298// Option 3 — typed RPC. `toPromiseApi<Backend>` wraps the wire-shape
299// binding into a Promise<T> view that throws on `Effect.fail` and
300// unwraps stream envelopes — full method signatures from `Backend`.
301const backend = Cloudflare.toPromiseApi<Backend>(env.BACKEND);
302const value = await backend.hello(key);
303// ^? string | null (typed end-to-end)
304```
305
306Effect-native callers also get a fourth path — `yield*
307Backend.bind(env.BACKEND)` returns the same RPC surface
308without the Promise envelope. The HTTP option (#2) is the
309right one when you need request/response semantics with
310streaming bodies; the RPC option (#3) is the right one
311when you want typed method calls.
312
313[Tutorial › Vite SPA + Worker bridge](/cloudflare/frontend/vite-spa) ·
314[Example › `cloudflare-tanstack`](https://github.com/alchemy-run/alchemy/tree/main/examples/cloudflare-website-tanstack-start)
315
316## Workflows with typed input and output
317
318`Cloudflare.Workflow` is now generic over input *and*
319output types. The body is an `Effect.fn` that takes the
320typed input directly; `workflow.create(input)` is
321type-checked end to end; the returned value flows through
322to `instance.status().output`.
323
324A realistic example — a notifier workflow that touches KV,
325reads an `Alchemy.Secret`, broadcasts through a Durable
326Object, sleeps, and finalizes — with each side effect
327wrapped in a `task` so a crash + replay returns the
328persisted result instead of re-running:
329
330```typescript
331// src/NotifyWorkflow.ts
332import * as Alchemy from "alchemy";
333import * as Cloudflare from "alchemy/Cloudflare";
334import * as Effect from "effect/Effect";
335import * as Redacted from "effect/Redacted";
336import { KV } from "./KV.ts";
337import Room from "./Room.ts";
338
339export default class NotifyWorkflow extends Cloudflare.Workflow<NotifyWorkflow>()(
340 "Notifier",
341 Effect.gen(function* () {
342 // Outer init phase: resolve shared dependencies once.
343 const rooms = yield* Room;
344 const kv = yield* Cloudflare.KV.ReadWriteNamespace(KV);
345 const secret = yield* Alchemy.Secret("WORKFLOW_SECRET");
346
347 return Effect.fn(function* (input: { roomId: string; message: string }) {
348 const { roomId, message } = input;
349
350 // Each `task` is a checkpoint — replay-safe.
351 const stored = yield* Cloudflare.Workflows.task("kv-roundtrip",
352 Effect.gen(function* () {
353 const key = `notify:${roomId}`;
354 yield* kv.put(key, message);
355 return (yield* kv.get(key)) ?? message;
356 }).pipe(Effect.orDie),
357 );
358
359 const value = Redacted.value(yield* secret);
360 const processed = yield* Cloudflare.Workflows.task("process",
361 Effect.succeed({ text: `Processed: ${stored}`, secret: value }),
362 );
363
364 yield* Cloudflare.Workflows.task("broadcast",
365 rooms.getByName(roomId).broadcast(`[workflow] ${processed.text}`),
366 );
367
368 yield* Cloudflare.Workflows.sleep("cooldown", "2 seconds");
369 yield* Cloudflare.Workflows.task("finalize",
370 rooms.getByName(roomId).broadcast(`[workflow] complete for ${roomId}`),
371 );
372
373 return processed;
374 });
375 }),
376) {}
377```
378
379Start it from a Worker — `create` is typed against the
380input shape, `instance.status()` reports the typed output:
381
382```typescript
383// inside a Worker's fetch handler
384const notifier = yield* NotifyWorkflow;
385
386const instance = yield* notifier.create({
387 roomId: "room-42",
388 message: "hello",
389});
390// instance.id: string
391
392const status = yield* (yield* notifier.get(instance.id)).status();
393// status.output: { text: string; secret: string } | undefined
394```
395
396[Tutorial › Workflows](/cloudflare/compute/add-a-workflow)
397
398## Cron triggers via `Cloudflare.Workers.cron(...)`
399
400Subscribe to a Cloudflare Cron Trigger with an Effect
401handler. The deploy-time half attaches the cron expression
402to the host Worker; the runtime half registers a
403`scheduled` listener. Use it alongside any other binding
404you've already wired up — the handler runs inside the same
405Worker context.
406
407```typescript
408export default Cloudflare.Worker("Reporter",
409 { main: import.meta.filename },
410 Effect.gen(function* () {
411 const kv = yield* Cloudflare.KV.ReadWriteNamespace(Counters);
412
413 // Fires once at the top of every hour.
414 yield* Cloudflare.Workers.cron("0 * * * *").subscribe((controller) =>
415 Effect.gen(function* () {
416 yield* kv.put(`tick:${controller.scheduledTime}`, "ok");
417 yield* Effect.log(`tick at ${new Date(controller.scheduledTime).toISOString()}`);
418 }),
419 );
420
421 return { fetch: Effect.succeed(HttpServerResponse.text("ok")) };
422 }),
423);
424```
425
426Multiple `cron("…")` calls register multiple schedules on
427the same Worker; the cheapest cron granularity Cloudflare
428offers is one minute (`* * * * *`).
429
430— *Thanks to **Dawson** ([#288](https://github.com/alchemy-run/alchemy/pull/288))
431for the contribution.*
432
433## Analytics Engine binding
434
435Cloudflare Workers Analytics Engine, exposed as a
436zero-provisioning Worker binding. Declare a dataset
437resource, bind it on a Worker, and call `writeDataPoint`
438from the handler — same Effect-error channel as every
439other Alchemy binding.
440
441```typescript
442// alchemy.run.ts
443export const Events = Cloudflare.AnalyticsEngine.Dataset("Events", {
444 dataset: "app-events",
445});
446
447// inside the Worker
448export default Cloudflare.Worker("Api",
449 { main: import.meta.filename },
450 Effect.gen(function* () {
451 const analytics = yield* Cloudflare.AnalyticsEngineDataset.bind(Events);
452
453 return {
454 fetch: Effect.gen(function* () {
455 yield* analytics.writeDataPoint({
456 indexes: ["account-1"], // queryable, low-cardinality
457 blobs: ["signup"], // arbitrary string columns
458 doubles: [1], // numeric metrics
459 });
460 return HttpServerResponse.text("recorded");
461 }),
462 };
463 }).pipe(Effect.provide(Cloudflare.AnalyticsEngineDatasetBindingLive)),
464);
465```
466
467— *Thanks to **Dawson** ([#286](https://github.com/alchemy-run/alchemy/pull/286))
468for the contribution.*
469
470## R2 buckets empty themselves on destroy
471
472`destroy` on an `Bucket` now drains the contents before
473deleting the bucket. No more `BucketNotEmpty` failures
474during teardown — ephemeral PR previews and integration
475tests tear down cleanly with a single `alchemy destroy`.
476
477```typescript
478const Photos = Cloudflare.R2.Bucket("Photos");
479// `alchemy destroy` empties Photos and deletes it in one go
480```
481
482If you want the old behavior (fail if non-empty) for
483production guards, opt out per-bucket:
484
485```typescript
486const Photos = Cloudflare.R2.Bucket("Photos", { emptyOnDestroy: false });
487```
488
489— *Thanks to **Michael K** ([#276](https://github.com/alchemy-run/alchemy/pull/276))
490for the contribution.*
491
492## Fixes worth knowing about
493
494- **D1 `prepare()` / `bind()` are synchronous now.** Matches
495 the upstream Cloudflare Workers API — no more `yield*`
496 on trivial statement construction.
497- **WASM modules in the local sidecar bundle.** `bun alchemy
498 dev` now correctly bundles `.wasm` modules into the local
499 sidecar, fixing a class of "module not found" errors for
500 Workers that depend on WASM. *Thanks to **Baptiste
501 Arnaud**
502 ([#305](https://github.com/alchemy-run/alchemy/pull/305)).*
503- **Unresolved `Output` JS-coercion throws.** Accidentally
504 using an unresolved `Output<string>` in a template
505 literal (e.g. `` `${bucket.bucketName}` `` outside an
506 Effect) previously coerced to `"[object Output]"` and
507 shipped garbage to the cloud. It now throws. *Thanks to
508 **Zé Yuri**
509 ([#306](https://github.com/alchemy-run/alchemy/pull/306)).*
510- **Deprecated libsodium wrapper types removed.** No public
511 API impact; if you were importing internal types they're
512 gone. *Thanks to **齐天大圣**
513 ([#311](https://github.com/alchemy-run/alchemy/pull/311)).*
514
515## Contributors
516
517Big thank-you to everyone who shipped code in this beta:
518
519- **Michael K** — R2 empty-on-destroy ([#276](https://github.com/alchemy-run/alchemy/pull/276))
520- **Dawson** — Worker cron triggers ([#288](https://github.com/alchemy-run/alchemy/pull/288))
521- **Dawson** — Analytics Engine binding ([#286](https://github.com/alchemy-run/alchemy/pull/286))
522- **Baptiste Arnaud** — WASM in local sidecar bundle ([#305](https://github.com/alchemy-run/alchemy/pull/305))
523- **Zé Yuri** — throw on unresolved `Output` JS-coercion ([#306](https://github.com/alchemy-run/alchemy/pull/306))
524- **齐天大圣** — remove deprecated libsodium wrapper types ([#311](https://github.com/alchemy-run/alchemy/pull/311))
525
526## Where to go next
527
528- [CHANGELOG](https://github.com/alchemy-run/alchemy/blob/main/CHANGELOG.md#v200-beta37)
529- [Compare v2.0.0-beta.36 → v2.0.0-beta.37](https://github.com/alchemy-run/alchemy/compare/v2.0.0-beta.36...v2.0.0-beta.37)