1---
2title: 2.0.0-beta.51 - Rulesets & Chat Persistence
3date: 2026-06-02T20:00:00Z
4category: release
5excerpt: v2.0.0-beta.51 adds a `Cloudflare.Ruleset` resource for WAF custom rules and redirects, a Durable Object-backed persistence layer for Effect AI chat history, working S3 bucket notifications to Lambda with prefix/suffix filters, and `effect/Config` reads in stack bodies without `Effect.orDie`.
6---
7
8`v2.0.0-beta.51` brings a new `Cloudflare.Ruleset` resource, a
9Durable Object-backed persistence layer for Effect AI chats, an
10S3 → Lambda notification fix, and `ConfigError` in the
11error channel of `Alchemy.Stack` and Worker effects.
12
13## `Cloudflare.Ruleset`
14
15`Cloudflare.Ruleset` owns a zone's ruleset phase entrypoint — WAF
16custom rules, redirects, or any of Cloudflare's ruleset phases.
17Pass a `Zone`, a `phase`, and the rules; the resource reconciles
18the entire phase entrypoint. Thanks
19**[Alex](https://github.com/agcty)**
20([#240](https://github.com/alchemy-run/alchemy/pull/240)).
21
22```typescript
23const zone = yield* Cloudflare.Zone("MyZone", { name: "example.com" });
24
25const waf = yield* Cloudflare.Ruleset("WafRules", {
26 zone,
27 phase: "http_request_firewall_custom",
28 rules: [
29 {
30 description: "Block exploit probes",
31 expression: `lower(http.request.uri.path) contains "/.env"`,
32 action: "block",
33 },
34 ],
35});
36```
37
38Outputs include `rulesetId`, `kind`, `version`, and the resolved
39`rules`. Changing the `zone` or `phase` replaces the ruleset; rule
40edits are in-place updates. Because the resource owns the whole
41phase entrypoint, rules managed elsewhere in the same phase are
42overwritten on deploy.
43
44## Durable Object chat persistence
45
46`Cloudflare.DurableObjectChatPersistence` is a `BackingPersistence`
47layer (Effect AI's persistence module) backed by the surrounding
48Durable Object's `state.storage`. Drop it under
49`Chat.makePersisted({ storeId })` and chat history lives in the
50DO's SQLite store — surviving hibernation and eviction — with
51`${storeId}:` key namespacing so multiple stores coexist in one DO
52([#390](https://github.com/alchemy-run/alchemy/pull/390)).
53
54One DO instance == one persisted conversation thread:
55
56```typescript
57import { Chat } from "effect/unstable/ai";
58
59export default class ChatBackend extends Cloudflare.DurableObjectNamespace<ChatBackend>()(
60 "ChatBackend",
61 Effect.gen(function* () {
62 // Init: bind the AI Gateway and build the LanguageModel layer.
63 const aiGateway = yield* Cloudflare.AiGateway.bind(Gateway);
64 const languageModel = aiGateway.model({
65 model: "@cf/meta/llama-3.1-8b-instruct",
66 });
67
68 return Effect.gen(function* () {
69 // Per-instance: chat history backed by this DO's storage.
70 const persistence = yield* Chat.makePersisted({
71 storeId: "alchemy.chat",
72 }).pipe(Effect.provide(Cloudflare.DurableObjectChatPersistence));
73
74 return {
75 send: (threadId: string, prompt: string) =>
76 Effect.gen(function* () {
77 const chat = yield* persistence.getOrCreate(threadId);
78 const response = yield* chat.generateText({ prompt });
79 return response.text;
80 }).pipe(Effect.provide(languageModel), Effect.orDie),
81 };
82 });
83 }).pipe(Effect.provide(Cloudflare.AiGatewayBindingLive)),
84) {}
85```
86
87Paired with [AI Gateway](/cloudflare/ai/ai-gateway), that's a
88fully persisted per-thread chat backend. One caveat: TTL is
89currently ignored — DO storage has no native TTL.
90
91## S3 event notifications actually reach Lambda
92
93S3 bucket notifications subscribed via
94`S3.notifications(bucket).subscribe(...)` on a Lambda event source
95were never delivered: the notification configuration was recorded
96in state but never applied to the bucket, and the auto-created
97Lambda permission used the invalid action string
98`lambda.InvokeFunction` instead of `lambda:InvokeFunction`. Both
99are fixed, and the notification props gained `prefix` / `suffix`
100key filters (mapped to S3 `FilterRules`). Thanks
101**[Andrey Konopkov](https://github.com/konopkov)**
102([#470](https://github.com/alchemy-run/alchemy/pull/470)).
103
104```typescript
105yield* S3.notifications(bucket, {
106 events: ["s3:ObjectCreated:*"],
107 prefix: "incoming/",
108}).subscribe((stream) =>
109 stream.pipe(
110 Stream.runForEach((event) =>
111 putObject({
112 Key: `processed/${event.key.slice("incoming/".length)}`,
113 Body: JSON.stringify({ key: event.key, size: event.size }),
114 ContentType: "application/json",
115 }).pipe(Effect.orDie),
116 ),
117 ),
118);
119```
120
121The `prefix` filter matters when the handler writes back into the
122same bucket — without it, the `processed/` write would itself
123trigger the subscription, which writes another object, and so on.
124
125## `effect/Config` in stack bodies
126
127`Alchemy.Stack`, `Cloudflare.Worker`, and the other Platform
128effects now permit `ConfigError` in their error channel instead of
129requiring `never`. Reading configuration with `Config.string` in a
130stack body previously forced an `Effect.orDie` wrapper; now it
131type-checks directly and the `ConfigError` surfaces on the
132resulting effect
133([#487](https://github.com/alchemy-run/alchemy/pull/487)).
134
135```diff lang="typescript"
136 const stack = Alchemy.Stack(
137 "my-app",
138 { providers, state },
139 Effect.gen(function* () {
140 const value = yield* Config.string("SOME_CONFIG");
141 return { value };
142- }).pipe(Effect.orDie),
143+ }),
144 );
145```
146
147## Also in this release
148
149- **Resource-scoped `adopt(...)` is honored by the planner** —
150 `.pipe(adopt(true))` on a resource overrides the stack/CLI-wide
151 adopt default, in both directions
152 ([#521](https://github.com/alchemy-run/alchemy/pull/521)).
153- **`Cloudflare.SecretsStore.Secret` works in a Worker's `env`** —
154 the provider maps it to a native `secrets_store_secret` binding,
155 so the runtime sees a real `SecretsStoreSecret` with `.get()`
156 ([#475](https://github.com/alchemy-run/alchemy/pull/475)).
157- **Drizzle `Schema` preserves snapshot lineage** — the previous
158 snapshot id is passed to `generateDrizzleJson` when generating
159 migrations. Thanks
160 **[Julius Marminge](https://github.com/juliusmarminge)**
161 ([#519](https://github.com/alchemy-run/alchemy/pull/519)).
162- **Outputs are identified by symbol, not a duck-typed `kind`
163 property** — user data containing a `kind` field is no longer
164 misclassified as an Output expression. Thanks
165 **[Julius Marminge](https://github.com/juliusmarminge)**
166 ([#517](https://github.com/alchemy-run/alchemy/pull/517)).
167- **PlanetScale MySQL/Postgres Database outputs drop the `kind`
168 attribute** that collided with the output proxy discriminator
169 ([#518](https://github.com/alchemy-run/alchemy/pull/518)).
170- **Website: the theme toggle no longer collides with the mobile
171 hamburger menu**
172 ([#499](https://github.com/alchemy-run/alchemy/pull/499)).
173
174## Where to go next
175
176- [Effect AI guide](/cloudflare/ai/effect-ai)
177- [AI Gateway tutorial](/cloudflare/ai/ai-gateway)
178- [S3 Events tutorial](/aws/messaging/s3-events)
179- [Concepts › Stack](/infrastructure-as-code/stack)
180- [CHANGELOG](https://github.com/alchemy-run/alchemy/blob/main/CHANGELOG.md#v200-beta51)
181- [Compare v2.0.0-beta.50 → v2.0.0-beta.51](https://github.com/alchemy-run/alchemy/compare/v2.0.0-beta.50...v2.0.0-beta.51)