alchemy 2.0.0-beta.50 → 2.0.0-beta.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/bin/exec.js +117 -113
  2. package/bin/exec.js.map +1 -1
  3. package/lib/AWS/Lambda/BucketEventSource.d.ts.map +1 -1
  4. package/lib/AWS/Lambda/BucketEventSource.js +13 -2
  5. package/lib/AWS/Lambda/BucketEventSource.js.map +1 -1
  6. package/lib/AWS/S3/Bucket.d.ts.map +1 -1
  7. package/lib/AWS/S3/Bucket.js +49 -0
  8. package/lib/AWS/S3/Bucket.js.map +1 -1
  9. package/lib/AWS/S3/BucketNotifications.d.ts +10 -0
  10. package/lib/AWS/S3/BucketNotifications.d.ts.map +1 -1
  11. package/lib/AWS/S3/BucketNotifications.js.map +1 -1
  12. package/lib/Cli/commands/cloudflare.d.ts +1 -1
  13. package/lib/Cloudflare/AiGateway/AiGatewayBinding.d.ts +1 -1
  14. package/lib/Cloudflare/AiGateway/AiGatewayBinding.d.ts.map +1 -1
  15. package/lib/Cloudflare/AiGateway/AiGatewayBinding.js +6 -2
  16. package/lib/Cloudflare/AiGateway/AiGatewayBinding.js.map +1 -1
  17. package/lib/Cloudflare/Providers.d.ts.map +1 -1
  18. package/lib/Cloudflare/Providers.js +3 -1
  19. package/lib/Cloudflare/Providers.js.map +1 -1
  20. package/lib/Cloudflare/Ruleset/Ruleset.d.ts +82 -0
  21. package/lib/Cloudflare/Ruleset/Ruleset.d.ts.map +1 -0
  22. package/lib/Cloudflare/Ruleset/Ruleset.js +126 -0
  23. package/lib/Cloudflare/Ruleset/Ruleset.js.map +1 -0
  24. package/lib/Cloudflare/Ruleset/index.d.ts +2 -0
  25. package/lib/Cloudflare/Ruleset/index.d.ts.map +1 -0
  26. package/lib/Cloudflare/Ruleset/index.js +2 -0
  27. package/lib/Cloudflare/Ruleset/index.js.map +1 -0
  28. package/lib/Cloudflare/SecretsStore/Secret.d.ts +1 -0
  29. package/lib/Cloudflare/SecretsStore/Secret.d.ts.map +1 -1
  30. package/lib/Cloudflare/SecretsStore/Secret.js +2 -0
  31. package/lib/Cloudflare/SecretsStore/Secret.js.map +1 -1
  32. package/lib/Cloudflare/StateStore/State.d.ts +1 -1
  33. package/lib/Cloudflare/StateStore/State.d.ts.map +1 -1
  34. package/lib/Cloudflare/Workers/DurableObjectChatPersistence.d.ts +69 -0
  35. package/lib/Cloudflare/Workers/DurableObjectChatPersistence.d.ts.map +1 -0
  36. package/lib/Cloudflare/Workers/DurableObjectChatPersistence.js +104 -0
  37. package/lib/Cloudflare/Workers/DurableObjectChatPersistence.js.map +1 -0
  38. package/lib/Cloudflare/Workers/Worker.d.ts +3 -2
  39. package/lib/Cloudflare/Workers/Worker.d.ts.map +1 -1
  40. package/lib/Cloudflare/Workers/Worker.js.map +1 -1
  41. package/lib/Cloudflare/Workers/WorkerAsyncBindings.d.ts.map +1 -1
  42. package/lib/Cloudflare/Workers/WorkerAsyncBindings.js +9 -0
  43. package/lib/Cloudflare/Workers/WorkerAsyncBindings.js.map +1 -1
  44. package/lib/Cloudflare/Workers/WorkerBinding.d.ts +2 -1
  45. package/lib/Cloudflare/Workers/WorkerBinding.d.ts.map +1 -1
  46. package/lib/Cloudflare/Workers/WorkerBinding.js.map +1 -1
  47. package/lib/Cloudflare/Workers/index.d.ts +1 -0
  48. package/lib/Cloudflare/Workers/index.d.ts.map +1 -1
  49. package/lib/Cloudflare/Workers/index.js +1 -0
  50. package/lib/Cloudflare/Workers/index.js.map +1 -1
  51. package/lib/Cloudflare/Zaraz/ZarazConfig.d.ts.map +1 -1
  52. package/lib/Cloudflare/index.d.ts +1 -0
  53. package/lib/Cloudflare/index.d.ts.map +1 -1
  54. package/lib/Cloudflare/index.js +1 -0
  55. package/lib/Cloudflare/index.js.map +1 -1
  56. package/lib/Deploy.d.ts +3 -2
  57. package/lib/Deploy.d.ts.map +1 -1
  58. package/lib/Deploy.js.map +1 -1
  59. package/lib/Destroy.d.ts +3 -2
  60. package/lib/Destroy.d.ts.map +1 -1
  61. package/lib/Destroy.js.map +1 -1
  62. package/lib/Drizzle/Schema.d.ts.map +1 -1
  63. package/lib/Drizzle/Schema.js +6 -3
  64. package/lib/Drizzle/Schema.js.map +1 -1
  65. package/lib/Output.d.ts.map +1 -1
  66. package/lib/Output.js +11 -10
  67. package/lib/Output.js.map +1 -1
  68. package/lib/Plan.d.ts.map +1 -1
  69. package/lib/Plan.js +5 -1
  70. package/lib/Plan.js.map +1 -1
  71. package/lib/Planetscale/MySQL/MySQLDatabase.d.ts +0 -2
  72. package/lib/Planetscale/MySQL/MySQLDatabase.d.ts.map +1 -1
  73. package/lib/Planetscale/MySQL/MySQLDatabase.js +1 -3
  74. package/lib/Planetscale/MySQL/MySQLDatabase.js.map +1 -1
  75. package/lib/Planetscale/Postgres/PostgresDatabase.d.ts +0 -2
  76. package/lib/Planetscale/Postgres/PostgresDatabase.d.ts.map +1 -1
  77. package/lib/Planetscale/Postgres/PostgresDatabase.js +1 -3
  78. package/lib/Planetscale/Postgres/PostgresDatabase.js.map +1 -1
  79. package/lib/Platform.d.ts +4 -4
  80. package/lib/Platform.d.ts.map +1 -1
  81. package/lib/Platform.js.map +1 -1
  82. package/lib/Resource.d.ts +6 -0
  83. package/lib/Resource.d.ts.map +1 -1
  84. package/lib/Resource.js +2 -0
  85. package/lib/Resource.js.map +1 -1
  86. package/lib/Stack.d.ts +5 -4
  87. package/lib/Stack.d.ts.map +1 -1
  88. package/lib/Stack.js.map +1 -1
  89. package/lib/Test/Core.d.ts +2 -2
  90. package/lib/Test/Core.d.ts.map +1 -1
  91. package/lib/tsconfig.test.tsbuildinfo +1 -1
  92. package/package.json +1 -1
  93. package/src/AWS/Lambda/BucketEventSource.ts +16 -1
  94. package/src/AWS/S3/Bucket.ts +78 -0
  95. package/src/AWS/S3/BucketNotifications.ts +10 -0
  96. package/src/Cloudflare/AiGateway/AiGatewayBinding.ts +8 -3
  97. package/src/Cloudflare/Providers.ts +3 -0
  98. package/src/Cloudflare/Ruleset/Ruleset.ts +223 -0
  99. package/src/Cloudflare/Ruleset/index.ts +1 -0
  100. package/src/Cloudflare/SecretsStore/Secret.ts +4 -0
  101. package/src/Cloudflare/Workers/DurableObjectChatPersistence.ts +131 -0
  102. package/src/Cloudflare/Workers/Worker.ts +6 -1
  103. package/src/Cloudflare/Workers/WorkerAsyncBindings.ts +8 -0
  104. package/src/Cloudflare/Workers/WorkerBinding.ts +2 -0
  105. package/src/Cloudflare/Workers/index.ts +1 -0
  106. package/src/Cloudflare/index.ts +1 -0
  107. package/src/Deploy.ts +2 -1
  108. package/src/Destroy.ts +2 -1
  109. package/src/Drizzle/Schema.ts +11 -3
  110. package/src/Output.ts +12 -10
  111. package/src/Plan.ts +5 -1
  112. package/src/Planetscale/MySQL/MySQLDatabase.ts +1 -6
  113. package/src/Planetscale/Postgres/PostgresDatabase.ts +1 -5
  114. package/src/Platform.ts +12 -4
  115. package/src/Resource.ts +10 -0
  116. package/src/Stack.ts +11 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alchemy",
3
- "version": "2.0.0-beta.50",
3
+ "version": "2.0.0-beta.51",
4
4
  "homepage": "https://alchemy.run",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Sam Goodwin <sam@alchemy.run>",
@@ -113,13 +113,25 @@ export const BucketEventSourcePolicyLive = BucketEventSourcePolicy.layer.effect(
113
113
  bucket: Bucket,
114
114
  {
115
115
  events: Events = ["s3:ObjectCreated:*"],
116
+ prefix,
117
+ suffix,
116
118
  }: {
117
119
  events?: S3EventType[];
120
+ prefix?: string;
121
+ suffix?: string;
118
122
  } = {},
119
123
  ) {
120
124
  if (Lambda.isFunction(host)) {
125
+ const filterRules = [
126
+ ...(prefix !== undefined
127
+ ? [{ Name: "prefix" as const, Value: prefix }]
128
+ : []),
129
+ ...(suffix !== undefined
130
+ ? [{ Name: "suffix" as const, Value: suffix }]
131
+ : []),
132
+ ];
121
133
  yield* Permission(`AWS.Lambda.InvokeFunction(${bucket.LogicalId})`, {
122
- action: "lambda.InvokeFunction",
134
+ action: "lambda:InvokeFunction",
123
135
  functionName: host.functionName,
124
136
  principal: "s3.amazonaws.com",
125
137
  sourceArn: bucket.bucketArn,
@@ -130,6 +142,9 @@ export const BucketEventSourcePolicyLive = BucketEventSourcePolicy.layer.effect(
130
142
  {
131
143
  LambdaFunctionArn: host.functionArn,
132
144
  Events,
145
+ ...(filterRules.length > 0
146
+ ? { Filter: { Key: { FilterRules: filterRules } } }
147
+ : {}),
133
148
  },
134
149
  ],
135
150
  },
@@ -1,7 +1,9 @@
1
1
  import { Region } from "@distilled.cloud/aws/Region";
2
2
  import type { BucketLocationConstraint } from "@distilled.cloud/aws/s3";
3
3
  import * as s3 from "@distilled.cloud/aws/s3";
4
+ import * as Arr from "effect/Array";
4
5
  import * as Effect from "effect/Effect";
6
+ import * as Order from "effect/Order";
5
7
  import * as Schedule from "effect/Schedule";
6
8
  import type { ScopedPlanStatusSession } from "../../Cli/Cli.ts";
7
9
  import { isResolved } from "../../Diff.ts";
@@ -475,6 +477,75 @@ export const BucketProvider = () =>
475
477
  yield* session.note(`Removed bucket policy: ${bucketName}`);
476
478
  });
477
479
 
480
+ // Apply S3 event notification configuration declared via bindings
481
+ // (e.g. `S3.notifications(bucket).subscribe(...)`). Without this the
482
+ // binding is recorded in state but never reaches the bucket, so no
483
+ // events are ever delivered.
484
+ // Canonical form of the Lambda targets, ignoring S3-assigned `Id`s and
485
+ // event ordering, so drift detection doesn't churn across deploys.
486
+ const canonicalLambda = (
487
+ configs: readonly s3.LambdaFunctionConfiguration[],
488
+ ) =>
489
+ JSON.stringify(
490
+ Arr.map(configs, (c) => ({
491
+ arn: c.LambdaFunctionArn,
492
+ events: Arr.sort(c.Events ?? [], Order.String),
493
+ filter: c.Filter ?? null,
494
+ })),
495
+ );
496
+
497
+ const syncBucketNotifications = Effect.fnUntraced(function* ({
498
+ bucketName,
499
+ bindings,
500
+ session,
501
+ operation,
502
+ }: {
503
+ bucketName: string;
504
+ session: ScopedPlanStatusSession;
505
+ bindings: ResourceBinding<Bucket["Binding"]>[];
506
+ operation: "create" | "update";
507
+ }) {
508
+ const desired = Arr.flatMap(
509
+ bindings,
510
+ (binding) =>
511
+ binding.data.notificationConfiguration
512
+ ?.LambdaFunctionConfigurations ?? [],
513
+ );
514
+
515
+ // Nothing declared — leave any externally-managed config untouched.
516
+ if (Arr.isReadonlyArrayEmpty(desired)) return;
517
+
518
+ const existing = yield* s3.getBucketNotificationConfiguration({
519
+ Bucket: bucketName,
520
+ });
521
+
522
+ if (
523
+ canonicalLambda(existing.LambdaFunctionConfigurations ?? []) ===
524
+ canonicalLambda(desired)
525
+ ) {
526
+ return;
527
+ }
528
+
529
+ yield* Effect.logInfo(
530
+ `S3 Bucket ${operation}: applying notification configuration to ${bucketName}`,
531
+ );
532
+ yield* s3.putBucketNotificationConfiguration({
533
+ Bucket: bucketName,
534
+ // Preserve any Topic/Queue/EventBridge config already on the bucket;
535
+ // only manage the Lambda targets declared through bindings.
536
+ NotificationConfiguration: {
537
+ ...existing,
538
+ LambdaFunctionConfigurations: desired,
539
+ },
540
+ // The Lambda invoke permission is created as a separate resource
541
+ // that may be applied after this bucket reconcile. Skip S3's
542
+ // synchronous test-invoke so the PUT doesn't fail on ordering;
543
+ // the permission exists by the time events are delivered.
544
+ SkipDestinationValidation: true,
545
+ });
546
+ yield* session.note(`Updated bucket notifications: ${bucketName}`);
547
+ });
548
+
478
549
  return {
479
550
  stables: ["bucketName", "bucketArn", "region", "accountId"],
480
551
  // S3 bucket names are globally unique. `headBucket` succeeds only when
@@ -554,6 +625,13 @@ export const BucketProvider = () =>
554
625
  operation,
555
626
  });
556
627
 
628
+ yield* syncBucketNotifications({
629
+ bucketName: resolved.bucketName,
630
+ bindings,
631
+ session,
632
+ operation,
633
+ });
634
+
557
635
  if (operation === "create") {
558
636
  yield* session.note(`Ensured bucket: ${resolved.bucketName}`);
559
637
  }
@@ -24,6 +24,16 @@ export type BucketNotification = {
24
24
  export interface NotificationsProps<Events extends S3EventType[]> {
25
25
  /** S3 event types to subscribe to. Defaults to all event types. */
26
26
  events?: Events;
27
+ /**
28
+ * Only deliver events for object keys beginning with this prefix.
29
+ * Maps to an S3 `prefix` filter rule on the notification configuration.
30
+ */
31
+ prefix?: string;
32
+ /**
33
+ * Only deliver events for object keys ending with this suffix.
34
+ * Maps to an S3 `suffix` filter rule on the notification configuration.
35
+ */
36
+ suffix?: string;
27
37
  }
28
38
 
29
39
  /**
@@ -76,7 +76,7 @@ export interface AiGatewayClient {
76
76
  ): Effect.Effect<Response, AiGatewayError, RuntimeContext>;
77
77
 
78
78
  model(
79
- options: LanguageModelOptions,
79
+ options: Omit<LanguageModelOptions, "client">,
80
80
  ): Layer.Layer<LanguageModel, never, RuntimeContext>;
81
81
  }
82
82
 
@@ -166,7 +166,7 @@ export const AiGatewayBindingLive = Layer.effect(
166
166
  Effect.flatMap((gateway) => tryPromise(() => fn(gateway))),
167
167
  );
168
168
 
169
- return {
169
+ const self: AiGatewayClient = {
170
170
  raw: ai,
171
171
  gateway: runtimeGateway,
172
172
  id: gatewayIdAccessor,
@@ -175,8 +175,13 @@ export const AiGatewayBindingLive = Layer.effect(
175
175
  getLog: (logId) => use((gateway) => gateway.getLog(logId)),
176
176
  getUrl: (provider) => use((gateway) => gateway.getUrl(provider)),
177
177
  run: (data, options) => use((gateway) => gateway.run(data, options)),
178
- model: (options) => makeLanguageModelLayer(options),
178
+ model: (options) =>
179
+ makeLanguageModelLayer({
180
+ ...options,
181
+ client: self,
182
+ }),
179
183
  } satisfies AiGatewayClient;
184
+ return self;
180
185
  });
181
186
  }),
182
187
  );
@@ -31,6 +31,7 @@ import * as KV from "./KV/index.ts";
31
31
  import * as Queue from "./Queue/index.ts";
32
32
  import * as R2 from "./R2/index.ts";
33
33
  import * as RateLimit from "./RateLimit/index.ts";
34
+ import * as Ruleset from "./Ruleset/index.ts";
34
35
  import * as SecretsStore from "./SecretsStore/index.ts";
35
36
  import * as Tunnel from "./Tunnel/index.ts";
36
37
  import * as Vectorize from "./Vectorize/index.ts";
@@ -85,6 +86,7 @@ export const providers = () =>
85
86
  R2.R2Bucket,
86
87
  R2.R2BucketBindingPolicy,
87
88
  RateLimit.RateLimitBindingPolicy,
89
+ Ruleset.Ruleset,
88
90
  SecretsStore.SecretBindingPolicy,
89
91
  SecretsStore.SecretsStore,
90
92
  SecretsStore.Secret,
@@ -138,6 +140,7 @@ export const providers = () =>
138
140
  R2.R2BucketBindingPolicyLive,
139
141
  R2.R2BucketProvider(),
140
142
  RateLimit.RateLimitBindingPolicyLive,
143
+ Ruleset.RulesetProvider(),
141
144
  SecretsStore.SecretBindingPolicyLive,
142
145
  SecretsStore.SecretsStoreProvider(),
143
146
  SecretsStore.StoreSecretProvider(),
@@ -0,0 +1,223 @@
1
+ import * as rulesets from "@distilled.cloud/cloudflare/rulesets";
2
+ import * as Effect from "effect/Effect";
3
+ import { deepEqual, isResolved } from "../../Diff.ts";
4
+ import { createPhysicalName } from "../../PhysicalName.ts";
5
+ import * as Provider from "../../Provider.ts";
6
+ import { Resource } from "../../Resource.ts";
7
+ import type { Providers } from "../Providers.ts";
8
+ import type { Zone, ZoneAttributes } from "../Zone/index.ts";
9
+
10
+ export type RulesetPhase = rulesets.CreateRulesetForZoneRequest["phase"];
11
+ export type RulesetRule = NonNullable<
12
+ rulesets.PutPhasForZoneRequest["rules"]
13
+ >[number];
14
+ export type RulesetOutputRule = Omit<
15
+ NonNullable<rulesets.GetPhasResponse["rules"]>[number],
16
+ "lastUpdated" | "version"
17
+ >;
18
+
19
+ export type RulesetProps = {
20
+ /**
21
+ * Zone to apply the ruleset to. Pass a `Cloudflare.Zone`.
22
+ */
23
+ zone: Zone;
24
+ /**
25
+ * Ruleset phase entrypoint to own.
26
+ */
27
+ phase: RulesetPhase;
28
+ /**
29
+ * Rules to apply to the phase entrypoint.
30
+ */
31
+ rules: RulesetRule[];
32
+ /**
33
+ * Human-readable name for the ruleset.
34
+ * @default ${app}-${stage}-${id}
35
+ */
36
+ name?: string;
37
+ /**
38
+ * Description for the ruleset.
39
+ */
40
+ description?: string;
41
+ };
42
+
43
+ export type RulesetKind =
44
+ | "managed"
45
+ | "custom"
46
+ | "root"
47
+ | "zone"
48
+ | (string & {});
49
+
50
+ export type Ruleset = Resource<
51
+ "Cloudflare.Ruleset",
52
+ RulesetProps,
53
+ {
54
+ /** The unique ID of the ruleset (Cloudflare `id`). */
55
+ rulesetId: string;
56
+ /**
57
+ * Zone the ruleset phase entrypoint belongs to. Alchemy-flattened
58
+ * identifier — not part of Cloudflare's phase-entrypoint response.
59
+ */
60
+ zoneId: string;
61
+ /** The kind of the ruleset. */
62
+ kind: RulesetKind;
63
+ /** The human-readable name of the ruleset. */
64
+ name: string;
65
+ /** The phase of the ruleset. */
66
+ phase: RulesetPhase;
67
+ /** An informative description of the ruleset. */
68
+ description: string | undefined;
69
+ /** The list of rules in the ruleset. */
70
+ rules: RulesetOutputRule[];
71
+ /** The timestamp of when the ruleset was last modified. */
72
+ lastUpdated: string;
73
+ /** The version of the ruleset. */
74
+ version: string;
75
+ },
76
+ never,
77
+ Providers
78
+ >;
79
+
80
+ /**
81
+ * A Cloudflare Ruleset phase entrypoint for a zone.
82
+ *
83
+ * This resource owns the entire ruleset for a phase entrypoint. Rules managed
84
+ * elsewhere in the same phase can be overwritten on deploy.
85
+ *
86
+ * @section WAF Rules
87
+ * @example Block probes in the custom firewall phase
88
+ * ```typescript
89
+ * const zone = yield* Cloudflare.Zone("MyZone", { name: "example.com" });
90
+ * const waf = yield* Cloudflare.Ruleset("WafRules", {
91
+ * zone,
92
+ * phase: "http_request_firewall_custom",
93
+ * rules: [
94
+ * {
95
+ * description: "Block exploit probes",
96
+ * expression: `lower(http.request.uri.path) contains "/.env"`,
97
+ * action: "block",
98
+ * },
99
+ * ],
100
+ * });
101
+ * ```
102
+ */
103
+ export const Ruleset = Resource<Ruleset>("Cloudflare.Ruleset")({});
104
+
105
+ const isNotFoundError = (error: unknown): boolean =>
106
+ typeof error === "object" &&
107
+ error !== null &&
108
+ (("status" in error && (error as { status: unknown }).status === 404) ||
109
+ ("_tag" in error && (error as { _tag: unknown })._tag === "NotFound"));
110
+
111
+ // A `Zone` prop is resolved to its attributes before a lifecycle op runs, so
112
+ // `zone.zoneId` is normally a plain string even though the `Zone` resource
113
+ // type statically exposes it as an `Output`. During `diff` (plan time) the
114
+ // zone can still be unresolved — e.g. when it's being created in the same
115
+ // deploy — in which case `zoneId` is not yet a string. Callers must treat a
116
+ // non-string result as "not resolved yet".
117
+ const zoneIdOf = (zone: Zone): string | undefined => {
118
+ const zoneId = (zone as unknown as Partial<ZoneAttributes>).zoneId;
119
+ return typeof zoneId === "string" ? zoneId : undefined;
120
+ };
121
+
122
+ export const RulesetProvider = () =>
123
+ Provider.effect(
124
+ Ruleset,
125
+ Effect.gen(function* () {
126
+ const getPhas = yield* rulesets.getPhasForZone;
127
+ const putPhas = yield* rulesets.putPhasForZone;
128
+
129
+ const createRulesetName = (id: string, name: string | undefined) =>
130
+ Effect.gen(function* () {
131
+ return name ?? (yield* createPhysicalName({ id }));
132
+ });
133
+
134
+ return {
135
+ stables: ["zoneId", "phase"],
136
+ diff: Effect.fn(function* ({ id, olds, news, output }) {
137
+ if (!isResolved(news)) return undefined;
138
+ const desiredZoneId = zoneIdOf(news.zone);
139
+
140
+ // The desired zone id may still be an unresolved Output (e.g. the
141
+ // zone is being created in the same deploy). Only make a zone-change
142
+ // replacement decision once we have a concrete id.
143
+ if (desiredZoneId !== undefined) {
144
+ if (output?.zoneId && desiredZoneId !== output.zoneId) {
145
+ return { action: "replace" } as const;
146
+ }
147
+ const oldZoneId = olds.zone ? zoneIdOf(olds.zone) : undefined;
148
+ if (oldZoneId !== undefined && oldZoneId !== desiredZoneId) {
149
+ return { action: "replace" } as const;
150
+ }
151
+ }
152
+ if (olds.phase !== news.phase) {
153
+ return { action: "replace" } as const;
154
+ }
155
+
156
+ const oldName =
157
+ output?.name ?? (yield* createRulesetName(id, olds.name));
158
+ const name = yield* createRulesetName(id, news.name);
159
+ if (
160
+ oldName !== name ||
161
+ olds.description !== news.description ||
162
+ !deepEqual(olds.rules, news.rules)
163
+ ) {
164
+ return { action: "update" } as const;
165
+ }
166
+ }),
167
+ reconcile: Effect.fn(function* ({ id, news, output }) {
168
+ const zoneId = output?.zoneId ?? zoneIdOf(news.zone);
169
+ if (zoneId === undefined) {
170
+ return yield* Effect.fail(
171
+ new Error("Cloudflare Ruleset: zone id is not resolved"),
172
+ );
173
+ }
174
+ const name = yield* createRulesetName(id, news.name ?? output?.name);
175
+ const ruleset = yield* putPhas({
176
+ zoneId,
177
+ rulesetPhase: news.phase,
178
+ name,
179
+ description: news.description,
180
+ rules: news.rules,
181
+ });
182
+ return toRulesetAttributes(zoneId, ruleset);
183
+ }),
184
+ delete: Effect.fn(function* ({ olds, output }) {
185
+ yield* putPhas({
186
+ zoneId: output.zoneId,
187
+ rulesetPhase: olds.phase,
188
+ name: output.name,
189
+ description: output.description,
190
+ rules: [],
191
+ }).pipe(Effect.catchIf(isNotFoundError, () => Effect.void));
192
+ }),
193
+ read: Effect.fn(function* ({ olds, output }) {
194
+ const zoneId = output?.zoneId ?? zoneIdOf(olds.zone);
195
+ if (zoneId === undefined) return undefined;
196
+ return yield* getPhas({
197
+ zoneId,
198
+ rulesetPhase: output?.phase ?? olds.phase,
199
+ }).pipe(
200
+ Effect.map((ruleset) => toRulesetAttributes(zoneId, ruleset)),
201
+ Effect.catchIf(isNotFoundError, () => Effect.succeed(undefined)),
202
+ );
203
+ }),
204
+ };
205
+ }),
206
+ );
207
+
208
+ export const toRulesetAttributes = (
209
+ zoneId: string,
210
+ ruleset: rulesets.GetPhasResponse | rulesets.PutPhasResponse,
211
+ ): Ruleset["Attributes"] => ({
212
+ rulesetId: ruleset.id,
213
+ zoneId,
214
+ kind: ruleset.kind,
215
+ name: ruleset.name,
216
+ phase: ruleset.phase,
217
+ description: ruleset.description ?? undefined,
218
+ rules: (ruleset.rules ?? []).map(
219
+ ({ lastUpdated: _lastUpdated, version: _version, ...rule }) => rule,
220
+ ),
221
+ lastUpdated: ruleset.lastUpdated,
222
+ version: ruleset.version,
223
+ });
@@ -0,0 +1 @@
1
+ export * from "./Ruleset.ts";
@@ -54,6 +54,10 @@ export type Secret = Resource<
54
54
  Providers
55
55
  >;
56
56
 
57
+ export const isSecret = (value: unknown): value is Secret =>
58
+ typeof value === "object" &&
59
+ (value as any)?.Type === "Cloudflare.SecretsStore.Secret";
60
+
57
61
  export type SecretStatus = "pending" | "active" | "deleted";
58
62
 
59
63
  // Distilled widened generated string enums to open unions (`string & {}`); the
@@ -0,0 +1,131 @@
1
+ import * as Arr from "effect/Array";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Layer from "effect/Layer";
4
+ import {
5
+ BackingPersistence,
6
+ PersistenceError,
7
+ type BackingPersistenceStore,
8
+ } from "effect/unstable/persistence/Persistence";
9
+ import { DurableObjectState } from "./DurableObjectState.ts";
10
+
11
+ /**
12
+ * A `BackingPersistence` layer (Effect AI persistence module) backed
13
+ * by the surrounding Durable Object's `state.storage`. Drop-in storage
14
+ * for `Persistence.layerResultPersisted({ storeId })` so chat history,
15
+ * cached AI responses, or any other persisted state lives in the DO
16
+ * SQLite store with `${storeId}:` key namespacing.
17
+ *
18
+ * Multiple `storeId`s can coexist within a single Durable Object —
19
+ * keys are namespaced with a `${storeId}:` prefix so they don't
20
+ * collide.
21
+ *
22
+ * :::caution
23
+ * TTL is currently ignored; Durable Object storage has no native TTL.
24
+ * If you need expiry, layer a TTL-aware backing store on top, or run
25
+ * a periodic `clear`.
26
+ * :::
27
+ *
28
+ * @binding
29
+ *
30
+ * @section Wiring it into a chat-backing DO
31
+ * @example Persisted chat history per DO instance
32
+ * `Persistence.layerResultPersisted({ storeId })` is the seam Effect
33
+ * AI exposes for cached/replayable AI calls. Layer
34
+ * `DurableObjectChatPersistence` underneath and every entry is stored
35
+ * in the DO's `state.storage` under the `alchemy.chat:` prefix.
36
+ * ```typescript
37
+ * import * as Cloudflare from "alchemy/Cloudflare";
38
+ * import * as Effect from "effect/Effect";
39
+ * import * as Layer from "effect/Layer";
40
+ * import { Chat, LanguageModel } from "effect/unstable/ai";
41
+ * import { Persistence } from "effect/unstable/persistence";
42
+ *
43
+ * export default class ChatBackend extends Cloudflare.DurableObjectNamespace<ChatBackend>()(
44
+ * "ChatBackend",
45
+ * Effect.gen(function* () {
46
+ * return Effect.gen(function* () {
47
+ * const persistence = yield* Persistence.layerResultPersisted({
48
+ * storeId: "alchemy.chat",
49
+ * }).pipe(Layer.provide(Cloudflare.DurableObjectChatPersistence));
50
+ *
51
+ * return {
52
+ * send: (threadId: string, prompt: string) =>
53
+ * LanguageModel.generateText({ prompt }).pipe(Effect.provide(persistence)),
54
+ * };
55
+ * });
56
+ * }),
57
+ * ) {}
58
+ * ```
59
+ *
60
+ * @section Multiple stores in the same DO
61
+ * @example Separate `storeId`s coexist
62
+ * Different `storeId`s namespace their keys with `${storeId}:`, so
63
+ * one DO can keep, say, chat history *and* an audit log in separate
64
+ * stores without colliding.
65
+ * ```typescript
66
+ * const aiPersistence = yield* Persistence.layerResultPersisted({
67
+ * storeId: "alchemy.chat",
68
+ * }).pipe(Layer.provide(Cloudflare.DurableObjectChatPersistence));
69
+ *
70
+ * const auditPersistence = yield* Persistence.layerResultPersisted({
71
+ * storeId: "alchemy.audit",
72
+ * }).pipe(Layer.provide(Cloudflare.DurableObjectChatPersistence));
73
+ * ```
74
+ */
75
+ export const DurableObjectChatPersistence = Layer.effect(BackingPersistence)(
76
+ Effect.gen(function* () {
77
+ const state = yield* DurableObjectState;
78
+ const storage = state.storage;
79
+
80
+ const wrapErr = (op: string, key?: string) => (cause: unknown) =>
81
+ new PersistenceError({
82
+ message: `Failed to ${op}${key !== undefined ? ` key ${key}` : ""} in DurableObject storage`,
83
+ cause,
84
+ });
85
+
86
+ return BackingPersistence.of({
87
+ make: (storeId) =>
88
+ Effect.sync(() => {
89
+ const prefixed = (k: string) => `${storeId}:${k}`;
90
+ return {
91
+ get: (key) =>
92
+ storage
93
+ .get<object>(prefixed(key))
94
+ .pipe(Effect.mapError(wrapErr("get", key))),
95
+ getMany: (keys) =>
96
+ storage.get<object>(keys.map(prefixed)).pipe(
97
+ Effect.mapError(wrapErr("getMany")),
98
+ Effect.map(
99
+ (map) =>
100
+ keys.map((k) => map.get(prefixed(k))) as Arr.NonEmptyArray<
101
+ object | undefined
102
+ >,
103
+ ),
104
+ ),
105
+ set: (key, value, _ttl) =>
106
+ storage
107
+ .put(prefixed(key), value)
108
+ .pipe(Effect.mapError(wrapErr("set", key))),
109
+ setMany: (entries) =>
110
+ storage
111
+ .put(
112
+ Object.fromEntries(entries.map(([k, v]) => [prefixed(k), v])),
113
+ )
114
+ .pipe(Effect.mapError(wrapErr("setMany"))),
115
+ remove: (key) =>
116
+ storage
117
+ .delete(prefixed(key))
118
+ .pipe(Effect.asVoid, Effect.mapError(wrapErr("remove", key))),
119
+ clear: storage.list({ prefix: `${storeId}:` }).pipe(
120
+ Effect.flatMap((map) => {
121
+ const ks = [...map.keys()];
122
+ if (ks.length === 0) return Effect.void;
123
+ return Effect.asVoid(storage.delete(ks));
124
+ }),
125
+ Effect.mapError(wrapErr("clear")),
126
+ ),
127
+ } satisfies BackingPersistenceStore;
128
+ }),
129
+ });
130
+ }),
131
+ );
@@ -1,6 +1,7 @@
1
1
  import type * as cf from "@cloudflare/workers-types";
2
2
  import * as workers from "@distilled.cloud/cloudflare/workers";
3
3
  import * as zones from "@distilled.cloud/cloudflare/zones";
4
+ import type { ConfigError } from "effect/Config";
4
5
  import * as Config from "effect/Config";
5
6
  import * as Context from "effect/Context";
6
7
  import * as Data from "effect/Data";
@@ -708,7 +709,11 @@ export const Worker: Platform<
708
709
  id: string,
709
710
  props:
710
711
  | InputProps<WorkerProps<Bindings, Assets>>
711
- | Effect.Effect<InputProps<WorkerProps<Bindings, Assets>>, never, Req>,
712
+ | Effect.Effect<
713
+ InputProps<WorkerProps<Bindings, Assets>>,
714
+ ConfigError,
715
+ Req
716
+ >,
712
717
  ): Effect.Effect<
713
718
  Worker<{
714
719
  [binding in keyof NormalizedBindings<
@@ -20,6 +20,7 @@ import { isKVNamespace } from "../KV/KVNamespace.ts";
20
20
  import { isQueue } from "../Queue/Queue.ts";
21
21
  import { isR2Bucket } from "../R2/R2Bucket.ts";
22
22
  import { isRateLimit } from "../RateLimit/RateLimit.ts";
23
+ import { isSecret } from "../SecretsStore/Secret.ts";
23
24
  import { isVectorizeIndex } from "../Vectorize/VectorizeIndex.ts";
24
25
  import { isAssets } from "./Assets.ts";
25
26
  import { isDurableObjectNamespaceLike } from "./DurableObjectNamespace.ts";
@@ -210,6 +211,13 @@ const toBinding = (
210
211
  name: bindingName,
211
212
  indexName: binding.indexName,
212
213
  };
214
+ } else if (isSecret(binding)) {
215
+ return {
216
+ type: "secrets_store_secret",
217
+ name: bindingName,
218
+ secretName: binding.secretName,
219
+ storeId: binding.storeId,
220
+ };
213
221
  } else if (isDynamicWorkerLoader(binding)) {
214
222
  return {
215
223
  type: "worker_loader",
@@ -18,6 +18,7 @@ import type { KVNamespace } from "../KV/KVNamespace.ts";
18
18
  import type { Queue } from "../Queue/Queue.ts";
19
19
  import type { R2Bucket } from "../R2/R2Bucket.ts";
20
20
  import type { RateLimit } from "../RateLimit/RateLimit.ts";
21
+ import type { Secret } from "../SecretsStore/Secret.ts";
21
22
  import type { VectorizeIndex } from "../Vectorize/VectorizeIndex.ts";
22
23
  import type { Assets } from "./Assets.ts";
23
24
  import type { DurableObjectNamespaceLike } from "./DurableObjectNamespace.ts";
@@ -55,6 +56,7 @@ export type WorkerBindingResource =
55
56
  | Images
56
57
  | Hyperdrive
57
58
  | VectorizeIndex
59
+ | Secret
58
60
  | Worker
59
61
  | DynamicWorkerLoader
60
62
  | DurableObjectNamespaceLike<any>;
@@ -2,6 +2,7 @@ export * from "./Assets.ts";
2
2
  export * from "./ConfigProvider.ts";
3
3
  export * from "./CronEventSource.ts";
4
4
  export * from "./DurableObjectBridge.ts";
5
+ export * from "./DurableObjectChatPersistence.ts";
5
6
  export * from "./DurableObjectNamespace.ts";
6
7
  export * from "./DurableObjectState.ts";
7
8
  export * from "./DurableObjectStorage.ts";
@@ -17,6 +17,7 @@ export * from "./Providers.ts";
17
17
  export * from "./Queue/index.ts";
18
18
  export * from "./R2/index.ts";
19
19
  export * from "./RateLimit/index.ts";
20
+ export * from "./Ruleset/index.ts";
20
21
  export * from "./SecretsStore/index.ts";
21
22
  export * from "./StateStore/index.ts";
22
23
  export * from "./Tunnel/index.ts";
package/src/Deploy.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ConfigError } from "effect/Config";
1
2
  import * as Effect from "effect/Effect";
2
3
  import type * as Scope from "effect/Scope";
3
4
  import { AlchemyContext } from "./AlchemyContext.ts";
@@ -14,7 +15,7 @@ export const deploy = <A>({
14
15
  scope,
15
16
  force,
16
17
  }: {
17
- stack: StackEffect<CompiledStack<A>, Stage | AlchemyContext>;
18
+ stack: StackEffect<CompiledStack<A>, ConfigError, Stage | AlchemyContext>;
18
19
  stage: string;
19
20
  dev?: boolean;
20
21
  /** See {@link evalStack} — when set, scoped resources outlive `deploy`. */