| 1 | --- |
| 2 | title: 2.0.0-beta.61 - Workers Cache, Routes & Sync |
| 3 | date: 2026-07-08T06:00:00Z |
| 4 | category: release |
| 5 | excerpt: v2.0.0-beta.61 adds Workers Cache with an Effect-native ExecutionContext, Wrangler-style zone routes, alchemy sync for repairing state drift, a full Workflows API (retries, rollback, events), resource type aliases, and a long list of reliability fixes. |
| 6 | --- |
| 7 | |
| 8 | beta.61 adds **Workers Cache** with an Effect-native |
| 9 | `ExecutionContext`, Wrangler-style **zone routes**, **`alchemy |
| 10 | sync`** for repairing out-of-band drift, a much fuller |
| 11 | **Workflows** API (retries, rollback, events), **resource type |
| 12 | aliases** that heal the beta.59 renames, and a long list of |
| 13 | reliability fixes. |
| 14 | |
| 15 | :::caution |
| 16 | **Breaking changes** — migration for each is below. |
| 17 | |
| 18 | - **`workflow.create` takes the native options object** — |
| 19 | `create(input)` → `create({ params: input })` |
| 20 | ([#611](https://github.com/alchemy-run/alchemy/pull/611)). |
| 21 | - **`WorkerExecutionContext` is now an Effect wrapper** — it's no |
| 22 | longer the raw `cf.ExecutionContext`; `waitUntil` takes an |
| 23 | `Effect` and is `yield*`-ed |
| 24 | ([#752](https://github.com/alchemy-run/alchemy/pull/752)). |
| 25 | - **effect `>=4.0.0-beta.93` is required** — effect moved |
| 26 | `UrlParams.makeUrl` to `Url.make`; the peer range floor is raised |
| 27 | to match |
| 28 | ([#748](https://github.com/alchemy-run/alchemy/pull/748)). |
| 29 | ::: |
| 30 | |
| 31 | ## Workers Cache |
| 32 | |
| 33 | [Workers Cache](https://blog.cloudflare.com/workers-cache/) — |
| 34 | Cloudflare's regionally tiered cache in front of Worker |
| 35 | entrypoints — lands as both a binding and a prop |
| 36 | ([#752](https://github.com/alchemy-run/alchemy/pull/752)). |
| 37 | Effect-native Workers enable it with `Cloudflare.cache()`, which |
| 38 | also returns the typed purge client: |
| 39 | |
| 40 | ```typescript |
| 41 | Effect.gen(function* () { |
| 42 | // init: enables Workers Cache on this Worker at deploy time |
| 43 | const { purge } = yield* Cloudflare.cache({ crossVersionCache: true }); |
| 44 | |
| 45 | return { |
| 46 | fetch: Effect.gen(function* () { |
| 47 | const request = yield* HttpServerRequest; |
| 48 | if (request.url.startsWith("/invalidate")) { |
| 49 | yield* purge({ tags: ["products"] }); // typed CachePurgeError |
| 50 | return HttpServerResponse.text("purged"); |
| 51 | } |
| 52 | return HttpServerResponse.text("hello", { |
| 53 | headers: { |
| 54 | "Cache-Control": "public, max-age=300, stale-while-revalidate=3600", |
| 55 | "Cache-Tag": "products", |
| 56 | }, |
| 57 | }); |
| 58 | }), |
| 59 | }; |
| 60 | }) |
| 61 | ``` |
| 62 | |
| 63 | Async Workers use the prop: |
| 64 | |
| 65 | ```typescript |
| 66 | const worker = yield* Cloudflare.Worker("Api", { |
| 67 | main: "./src/api.ts", |
| 68 | cache: { enabled: true, crossVersionCache: true }, |
| 69 | }); |
| 70 | ``` |
| 71 | |
| 72 | ## Effect-native `ExecutionContext` |
| 73 | |
| 74 | `WorkerExecutionContext` used to hand you the raw |
| 75 | `cf.ExecutionContext`. It's now an Effect wrapper — and like |
| 76 | `DurableObjectState`, it can be yielded from the Worker's **init |
| 77 | closure** (or any Layer); its methods resolve the live per-event |
| 78 | context: |
| 79 | |
| 80 | ```typescript |
| 81 | Effect.gen(function* () { |
| 82 | const exec = yield* Cloudflare.WorkerExecutionContext; // init |
| 83 | |
| 84 | return { |
| 85 | fetch: Effect.gen(function* () { |
| 86 | // respond now, finish work in the background |
| 87 | yield* exec.waitUntil(journal.record(entry).pipe(Effect.delay("5 seconds"))); |
| 88 | return HttpServerResponse.text("ok"); |
| 89 | }), |
| 90 | }; |
| 91 | }) |
| 92 | ``` |
| 93 | |
| 94 | Migrating is mechanical — `ctx.waitUntil(promise)` becomes |
| 95 | `yield* exec.waitUntil(effect)`. |
| 96 | |
| 97 | ## Zone Routes on Workers |
| 98 | |
| 99 | `Cloudflare.Worker` accepts Wrangler-style zone routes via a new |
| 100 | `routes` prop |
| 101 | ([#438](https://github.com/alchemy-run/alchemy/pull/438)). |
| 102 | Thanks **utopy**! |
| 103 | |
| 104 | ```typescript |
| 105 | yield* Cloudflare.Worker("Api", { |
| 106 | main: import.meta.filename, |
| 107 | routes: [ |
| 108 | { pattern: "api.example.com/*", zoneName: "example.com" }, |
| 109 | { pattern: "example.com/api/*", zoneId: "<YOUR_ZONE_ID>" }, |
| 110 | ], |
| 111 | }); |
| 112 | ``` |
| 113 | |
| 114 | Each entry takes `zoneName` / `zoneId` (the Wrangler equivalents) |
| 115 | or a `zone` reference — and when the zone is omitted, it's inferred |
| 116 | from the pattern hostname. Routes are reconciled on deploy and |
| 117 | cleaned up on destroy. |
| 118 | |
| 119 | ## `alchemy sync` |
| 120 | |
| 121 | Cloud state drifts: someone edits a resource in the dashboard, a |
| 122 | bucket gets deleted, tags get mangled. `alchemy sync` converges it |
| 123 | back to the last-deployed state — without re-running your stack |
| 124 | program |
| 125 | ([#766](https://github.com/alchemy-run/alchemy/pull/766)): |
| 126 | |
| 127 | ```sh |
| 128 | alchemy sync ./alchemy.run.ts --stage prod # detect + repair |
| 129 | alchemy sync ./alchemy.run.ts --stage prod --dry-run # detect only |
| 130 | ``` |
| 131 | |
| 132 | Per resource it runs observe → compare → converge: `read` observes |
| 133 | the live cloud state, a deep-compare against the persisted |
| 134 | attributes decides `unchanged` vs drifted, and drift is repaired by |
| 135 | `reconcile` with the persisted props as the desired state. A |
| 136 | resource deleted out-of-band is recreated under the **same |
| 137 | instance id**, so deterministic physical names regenerate |
| 138 | identically. Resources sync concurrently, and failures are |
| 139 | aggregated after every resource has been attempted. |
| 140 | |
| 141 | ## Workflows: retries, rollback, and events |
| 142 | |
| 143 | The Effect-native Workflow wrapper now covers the full Workers API |
| 144 | surface, 1:1 with the native binding |
| 145 | ([#611](https://github.com/alchemy-run/alchemy/pull/611)). |
| 146 | Thanks **Gerben Mulder**! |
| 147 | |
| 148 | `create` takes the native options object — the breaking change |
| 149 | above — which also unlocks `id` and `retention`: |
| 150 | |
| 151 | ```diff lang="typescript" |
| 152 | - const instance = yield* workflow.create({ orderId: "abc" }); |
| 153 | + const instance = yield* workflow.create({ |
| 154 | + id: "order-abc", |
| 155 | + params: { orderId: "abc" }, |
| 156 | + retention: { successRetention: "1 day", errorRetention: "7 days" }, |
| 157 | + }); |
| 158 | ``` |
| 159 | |
| 160 | `task` gains retries, timeout, and rollback, and |
| 161 | `WorkflowStepContext` exposes the current attempt: |
| 162 | |
| 163 | ```typescript |
| 164 | const result = yield* Cloudflare.Workflows.task( |
| 165 | "call-api", |
| 166 | Effect.gen(function* () { |
| 167 | const context = yield* Cloudflare.Workflows.WorkflowStepContext; |
| 168 | return { attempt: context.attempt }; |
| 169 | }), |
| 170 | { |
| 171 | retries: { limit: 3, delay: "5 seconds", backoff: "linear" }, |
| 172 | rollback: ({ output }) => (output ? cleanup(output.id) : Effect.void), |
| 173 | }, |
| 174 | ); |
| 175 | ``` |
| 176 | |
| 177 | `waitForEvent` parks the instance until a matching `sendEvent`: |
| 178 | |
| 179 | ```typescript |
| 180 | // inside the workflow |
| 181 | const approval = yield* Cloudflare.Workflows.waitForEvent<{ approved: boolean }>( |
| 182 | "approval", |
| 183 | { type: "approval", timeout: "1 day" }, |
| 184 | ); |
| 185 | |
| 186 | // from outside |
| 187 | yield* instance.sendEvent({ type: "approval", payload: { approved: true } }); |
| 188 | ``` |
| 189 | |
| 190 | `createBatch`, `restart`, rollback status, and extended event |
| 191 | metadata round out the surface. Docs: |
| 192 | [Workflows](/cloudflare/compute/workflows). |
| 193 | |
| 194 | ## Resource type aliases |
| 195 | |
| 196 | Renaming a resource's type string used to strand state persisted |
| 197 | under the old name — provider lookup died on the legacy type. |
| 198 | Resources now declare their former names |
| 199 | ([#765](https://github.com/alchemy-run/alchemy/pull/765)): |
| 200 | |
| 201 | ```typescript |
| 202 | export const Queue = Resource<Queue>("Cloudflare.Queues.Queue", { |
| 203 | aliases: ["Cloudflare.Queue"], |
| 204 | }); |
| 205 | ``` |
| 206 | |
| 207 | Plan, apply, destroy, `logs`, and `tail` all resolve providers |
| 208 | through aliases, and a noop deploy migrates state rows to the |
| 209 | canonical name. All **74 resources** renamed in the beta.59 |
| 210 | namespace alignment are annotated with their pre-rename alias — so |
| 211 | state written by beta.58 or earlier now deploys, destroys, and |
| 212 | replaces cleanly instead of erroring on the legacy type. |
| 213 | |
| 214 | ## AI Gateway BYOK: `AI.ProviderKey` |
| 215 | |
| 216 | A bring-your-own-key provider on an AI Gateway needs two coordinated |
| 217 | resources — a Secrets Store `Secret` with an exact |
| 218 | `{gatewayId}_{providerSlug}_{alias}` name, and a `GatewayProvider` |
| 219 | that references it. `Cloudflare.AI.ProviderKey` owns that contract |
| 220 | as one resource |
| 221 | ([#586](https://github.com/alchemy-run/alchemy/pull/586)). |
| 222 | Thanks **Alex**! |
| 223 | |
| 224 | ```typescript |
| 225 | const { secret, gatewayProvider } = yield* Cloudflare.AI.ProviderKey("OpenAiKey", { |
| 226 | store, |
| 227 | gatewayId: gateway.gatewayId, |
| 228 | providerSlug: "openai", |
| 229 | value: yield* Config.redacted("OPENAI_API_KEY"), |
| 230 | }); |
| 231 | ``` |
| 232 | |
| 233 | Docs: [AI Gateway](/cloudflare/ai/ai-gateway). |
| 234 | |
| 235 | ## Lambda async invocation config |
| 236 | |
| 237 | Lambda's asynchronous invocation settings — retries, event age, |
| 238 | success/failure destinations — land as an `eventInvokeConfig` prop |
| 239 | on `Function` and `Alias` |
| 240 | ([#627](https://github.com/alchemy-run/alchemy/pull/627)). |
| 241 | Thanks **José Netto**! |
| 242 | |
| 243 | ```typescript |
| 244 | const fn = yield* AWS.Lambda.Function("AsyncFn", { |
| 245 | main: "./src/handler.ts", |
| 246 | eventInvokeConfig: { |
| 247 | maximumRetryAttempts: 0, |
| 248 | maximumEventAgeInSeconds: 60, |
| 249 | destinationConfig: { |
| 250 | OnFailure: { Destination: queue.queueArn }, |
| 251 | }, |
| 252 | }, |
| 253 | }); |
| 254 | ``` |
| 255 | |
| 256 | ## R2 Bucket `cors` |
| 257 | |
| 258 | The `cors` prop that `Cloudflare.R2.Bucket` had in v1 is restored |
| 259 | ([#771](https://github.com/alchemy-run/alchemy/pull/771)) — |
| 260 | public buckets serving browser range-reads (e.g. PMTiles) declare |
| 261 | CORS in the stack again instead of out-of-band: |
| 262 | |
| 263 | ```typescript |
| 264 | const bucket = yield* Cloudflare.R2.Bucket("Tiles", { |
| 265 | domains: [{ name: "tiles.example.com" }], |
| 266 | cors: [ |
| 267 | { |
| 268 | allowedMethods: ["GET", "HEAD"], |
| 269 | allowedOrigins: ["https://map.example.com"], |
| 270 | allowedHeaders: ["range"], |
| 271 | exposeHeaders: ["etag", "content-range"], |
| 272 | maxAgeSeconds: 3600, |
| 273 | }, |
| 274 | ], |
| 275 | }); |
| 276 | ``` |
| 277 | |
| 278 | Rules use the flat S3-style shape and reconcile like |
| 279 | `lifecycleRules` — observed vs desired, so out-of-band drift and |
| 280 | adoption converge correctly. |
| 281 | |
| 282 | ## Also in this release |
| 283 | |
| 284 | - **Worker metadata-only changes now deploy** |
| 285 | ([#747](https://github.com/alchemy-run/alchemy/pull/747)) — |
| 286 | compatibility flags/date, observability, placement, limits, and |
| 287 | binding changes are folded into the update diff via a metadata |
| 288 | hash; previously they planned as noops and silently never |
| 289 | shipped. The first deploy after upgrading plans a one-time update |
| 290 | per Worker to backfill the hash. Thanks **Alex**! |
| 291 | - **Wedged stacks recover** |
| 292 | ([#767](https://github.com/alchemy-run/alchemy/pull/767), |
| 293 | [#770](https://github.com/alchemy-run/alchemy/pull/770)) — |
| 294 | a deploy interrupted mid-create used to persist a state row whose |
| 295 | Output-valued props can't round-trip, crashing every subsequent |
| 296 | `plan`/`deploy`/`destroy`. Every provider's `read`/`diff` is |
| 297 | audited so the engine re-drives the create and reconcile |
| 298 | converges on the half-created resource. |
| 299 | - **Script uploads retry every binding-target-not-found error** |
| 300 | ([#753](https://github.com/alchemy-run/alchemy/pull/753)) — |
| 301 | KV, R2, D1, Queues, DO classes, Hyperdrive, Vectorize, and more, |
| 302 | covering Cloudflare's deploy-time propagation lag. |
| 303 | - **`Resource.ref` values bind natively in Worker `env`** |
| 304 | ([#756](https://github.com/alchemy-run/alchemy/pull/756)) — |
| 305 | a ref passed as an env binding used to degrade to a plain JSON |
| 306 | env var and break at runtime; refs now classify exactly like |
| 307 | locally-declared resources. |
| 308 | - **Multiple queue consumers on one Worker** |
| 309 | ([#466](https://github.com/alchemy-run/alchemy/pull/466)) — |
| 310 | event dispatch runs every listener for an event type instead of |
| 311 | letting the first queue subscription swallow the rest. Thanks |
| 312 | **Leonardo E. Dominguez**! |
| 313 | - **State-store errors are actionable** |
| 314 | ([#737](https://github.com/alchemy-run/alchemy/pull/737)) — |
| 315 | 30 days of production traces triaged; empty `StateStoreError:` |
| 316 | messages, opaque decode errors, and JSON-parse crashes now |
| 317 | surface real messages (an unauthorized store tells you to run |
| 318 | `alchemy login`). |
| 319 | - **The test suite passes on Windows** |
| 320 | ([#735](https://github.com/alchemy-run/alchemy/pull/735)) — |
| 321 | including two real product fixes: `Drizzle.Schema` passed |
| 322 | OS-native paths to drizzle-kit (backslashes are glob escapes), |
| 323 | and `Bundle.PurePlugin` could be hijacked by a stray |
| 324 | `package.json` above `node_modules`. |
| 325 | - **Drizzle query chains are real Effects** |
| 326 | ([#750](https://github.com/alchemy-run/alchemy/pull/750)) — |
| 327 | `db.select()...` chains now expose the full Effect protocol, so |
| 328 | they compose with `Effect.all` and friends instead of spinning |
| 329 | the run loop. |
| 330 | - **PlanetScale inherited roles compare by membership** |
| 331 | ([#761](https://github.com/alchemy-run/alchemy/pull/761)) — |
| 332 | API-returned ordering no longer forces a `PostgresRole` |
| 333 | replacement. Thanks **Gerben Mulder**! |
| 334 | - **`globalOutbound: null` is preserved in `WorkerLoader`** |
| 335 | ([#746](https://github.com/alchemy-run/alchemy/pull/746)) — |
| 336 | the documented "block all outbound network access" signal no |
| 337 | longer silently coerces to default access. Thanks **Alex**! |
| 338 | - **Binding-hosted DO classes join the precreate stub** |
| 339 | ([#764](https://github.com/alchemy-run/alchemy/pull/764)) — |
| 340 | fixes `Worker did not expose Durable Object namespace` on fresh |
| 341 | deploys of worker↔container cycles. Thanks **Daniel Gangl**! |
| 342 | - **`alchemy dev` no longer prints bun's benign tsconfig fd |
| 343 | warning** |
| 344 | ([#768](https://github.com/alchemy-run/alchemy/pull/768)). |
| 345 | |
| 346 | ## Where to go next |
| 347 | |
| 348 | - [Workflows](/cloudflare/compute/workflows) |
| 349 | - [Workers](/cloudflare/compute/workers) |
| 350 | - [Custom Domains & Routes](/cloudflare/networking/custom-domains) |
| 351 | - [AI Gateway](/cloudflare/ai/ai-gateway) |
| 352 | - [CHANGELOG](https://github.com/alchemy-run/alchemy/blob/main/CHANGELOG.md#v200-beta61) |
| 353 | - [Compare v2.0.0-beta.60 → v2.0.0-beta.61](https://github.com/alchemy-run/alchemy/compare/v2.0.0-beta.60...v2.0.0-beta.61) |