alchemy 2.0.0-beta.2 → 2.0.0-beta.4

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 (77) hide show
  1. package/bin/{alchemy-effect.js → alchemy.js} +171 -171
  2. package/bin/alchemy.js.map +1 -0
  3. package/bin/{alchemy-effect.sh → alchemy.sh} +2 -2
  4. package/lib/cli/index.d.ts.map +1 -1
  5. package/lib/cli/index.js +167 -167
  6. package/lib/cli/index.js.map +1 -1
  7. package/package.json +54 -45
  8. package/src/AWS/AGENTS.md +10 -10
  9. package/src/AWS/Assets.ts +1 -1
  10. package/src/AWS/AuthProvider.ts +392 -0
  11. package/src/AWS/Credentials.ts +0 -1
  12. package/src/AWS/DynamoDB/Table.ts +63 -10
  13. package/src/AWS/EC2/VpcEndpoint.ts +4 -4
  14. package/src/AWS/EC2/hosted.ts +1 -1
  15. package/src/AWS/ECS/Task.ts +1 -1
  16. package/src/AWS/Kinesis/Stream.ts +44 -8
  17. package/src/AWS/Lambda/Function.ts +218 -10
  18. package/src/AWS/S3/Bucket.ts +26 -19
  19. package/src/AWS/S3/BucketNotifications.ts +1 -1
  20. package/src/AWS/SNS/Topic.ts +47 -10
  21. package/src/AWS/SQS/Queue.ts +66 -0
  22. package/src/Auth/AuthProvider.ts +33 -0
  23. package/src/Auth/Credentials.ts +56 -0
  24. package/src/Auth/Env.ts +45 -0
  25. package/src/Auth/Profile.ts +72 -0
  26. package/src/Auth/index.ts +2 -0
  27. package/src/Cloudflare/Auth/AuthProvider.ts +670 -0
  28. package/src/Cloudflare/Auth/OAuthClient.ts +315 -0
  29. package/src/Cloudflare/Container/Container.ts +122 -0
  30. package/src/Cloudflare/Container/ContainerApplication.ts +6 -3
  31. package/src/Cloudflare/Container/ContainerBinding.ts +2 -4
  32. package/src/Cloudflare/Container/StartContainer.ts +1 -1
  33. package/src/Cloudflare/D1/D1Database.ts +23 -4
  34. package/src/Cloudflare/KV/KVNamespace.ts +25 -0
  35. package/src/Cloudflare/Providers.ts +1 -6
  36. package/src/Cloudflare/R2/R2Bucket.ts +45 -2
  37. package/src/Cloudflare/Website/StaticSite.ts +101 -15
  38. package/src/Cloudflare/Website/Vite.ts +86 -20
  39. package/src/Cloudflare/Workers/Assets.ts +170 -207
  40. package/src/Cloudflare/Workers/DurableObjectNamespace.ts +629 -0
  41. package/src/Cloudflare/Workers/DurableObjectState.ts +63 -0
  42. package/src/Cloudflare/Workers/DurableObjectStorage.ts +256 -0
  43. package/src/Cloudflare/Workers/DynamicWorkerLoader.ts +66 -7
  44. package/src/Cloudflare/Workers/InferEnv.ts +11 -7
  45. package/src/Cloudflare/Workers/Rpc.ts +13 -2
  46. package/src/Cloudflare/Workers/ScheduledEvents.ts +185 -0
  47. package/src/Cloudflare/Workers/WebSocket.ts +1 -1
  48. package/src/Cloudflare/Workers/Worker.ts +572 -67
  49. package/src/Cloudflare/Workers/Workflow.ts +53 -11
  50. package/src/Cloudflare/Workers/index.ts +4 -1
  51. package/src/Construct.ts +2 -2
  52. package/src/GitHub/Comment.ts +224 -0
  53. package/src/GitHub/Secret.ts +257 -0
  54. package/src/GitHub/Variable.ts +166 -0
  55. package/src/GitHub/index.ts +3 -0
  56. package/src/Kubernetes/client.ts +2 -2
  57. package/src/Output.ts +1 -1
  58. package/src/Platform.ts +10 -5
  59. package/src/Provider.ts +1 -1
  60. package/src/Resource.ts +4 -4
  61. package/src/Test/Vitest.ts +4 -3
  62. package/src/Util/Clank.ts +77 -0
  63. package/src/Util/PlatformServices.ts +21 -0
  64. package/src/Util/dedent.ts +59 -0
  65. package/src/Util/index.ts +1 -0
  66. package/bin/alchemy-effect.js.map +0 -1
  67. package/src/Cloudflare/Workers/DurableObject.ts +0 -527
  68. package/src/Daemon/Client.ts +0 -116
  69. package/src/Daemon/Config.ts +0 -28
  70. package/src/Daemon/Errors.ts +0 -48
  71. package/src/Daemon/Lock.ts +0 -162
  72. package/src/Daemon/ProcessRegistry.ts +0 -284
  73. package/src/Daemon/RpcSchema.ts +0 -43
  74. package/src/Daemon/RpcServer.ts +0 -231
  75. package/src/Daemon/index.ts +0 -27
  76. package/src/Spawn.ts +0 -23
  77. /package/bin/{alchemy-effect.ts → alchemy.ts} +0 -0
@@ -0,0 +1,256 @@
1
+ import type * as cf from "@cloudflare/workers-types";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Stream from "effect/Stream";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // SqlStorage — Effect-native wrapper around cf.SqlStorage
7
+ // ---------------------------------------------------------------------------
8
+
9
+ export type SqlStorageValue = cf.SqlStorageValue;
10
+
11
+ export interface SqlCursor<
12
+ T extends Record<string, SqlStorageValue>,
13
+ > extends Stream.Stream<T> {
14
+ next(): Effect.Effect<
15
+ { done?: false; value: T } | { done: true; value?: never }
16
+ >;
17
+ toArray(): Effect.Effect<T[]>;
18
+ one(): Effect.Effect<T>;
19
+ raw<U extends SqlStorageValue[]>(): Stream.Stream<U>;
20
+ readonly columnNames: string[];
21
+ readonly rowsRead: Effect.Effect<number>;
22
+ readonly rowsWritten: Effect.Effect<number>;
23
+ }
24
+
25
+ export interface SqlStorage {
26
+ exec<T extends Record<string, SqlStorageValue>>(
27
+ query: string,
28
+ ...bindings: any[]
29
+ ): Effect.Effect<SqlCursor<T>>;
30
+ readonly databaseSize: number;
31
+ }
32
+
33
+ const fromSqlCursor = <T extends Record<string, SqlStorageValue>>(
34
+ cursor: cf.SqlStorageCursor<T>,
35
+ ): SqlCursor<T> => {
36
+ const stream = Stream.fromIterableEffect(Effect.sync(() => cursor));
37
+ return Object.assign(stream, {
38
+ next: () => Effect.sync(() => cursor.next()),
39
+ toArray: () => Effect.sync(() => cursor.toArray()),
40
+ one: () => Effect.sync(() => cursor.one()),
41
+ raw: <U extends SqlStorageValue[]>() =>
42
+ Stream.fromIterableEffect(Effect.sync(() => cursor.raw<U>())),
43
+ get columnNames() {
44
+ return cursor.columnNames;
45
+ },
46
+ rowsRead: Effect.sync(() => cursor.rowsRead),
47
+ rowsWritten: Effect.sync(() => cursor.rowsWritten),
48
+ }) as SqlCursor<T>;
49
+ };
50
+
51
+ const fromSqlStorage = (sql: cf.SqlStorage): SqlStorage => ({
52
+ exec: <T extends Record<string, SqlStorageValue>>(
53
+ query: string,
54
+ ...bindings: any[]
55
+ ): Effect.Effect<SqlCursor<T>> =>
56
+ Effect.sync(() => fromSqlCursor(sql.exec<T>(query, ...bindings))),
57
+ get databaseSize() {
58
+ return sql.databaseSize;
59
+ },
60
+ });
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // DurableObjectTransaction
64
+ // ---------------------------------------------------------------------------
65
+
66
+ export interface DurableObjectTransaction {
67
+ get<T = unknown>(
68
+ key: string,
69
+ options?: cf.DurableObjectGetOptions,
70
+ ): Effect.Effect<T | undefined>;
71
+ get<T = unknown>(
72
+ keys: string[],
73
+ options?: cf.DurableObjectGetOptions,
74
+ ): Effect.Effect<Map<string, T>>;
75
+ list<T = unknown>(
76
+ options?: cf.DurableObjectListOptions,
77
+ ): Effect.Effect<Map<string, T>>;
78
+ put<T>(
79
+ key: string,
80
+ value: T,
81
+ options?: cf.DurableObjectPutOptions,
82
+ ): Effect.Effect<void>;
83
+ put<T>(
84
+ entries: Record<string, T>,
85
+ options?: cf.DurableObjectPutOptions,
86
+ ): Effect.Effect<void>;
87
+ delete(
88
+ key: string,
89
+ options?: cf.DurableObjectPutOptions,
90
+ ): Effect.Effect<boolean>;
91
+ delete(
92
+ keys: string[],
93
+ options?: cf.DurableObjectPutOptions,
94
+ ): Effect.Effect<number>;
95
+ rollback(): Effect.Effect<void>;
96
+ getAlarm(
97
+ options?: cf.DurableObjectGetAlarmOptions,
98
+ ): Effect.Effect<number | null>;
99
+ setAlarm(
100
+ scheduledTime: number | Date,
101
+ options?: cf.DurableObjectSetAlarmOptions,
102
+ ): Effect.Effect<void>;
103
+ deleteAlarm(options?: cf.DurableObjectSetAlarmOptions): Effect.Effect<void>;
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // DurableObjectStorage
108
+ // ---------------------------------------------------------------------------
109
+
110
+ export interface DurableObjectStorage {
111
+ get<T = unknown>(
112
+ key: string,
113
+ options?: cf.DurableObjectGetOptions,
114
+ ): Effect.Effect<T | undefined>;
115
+ get<T = unknown>(
116
+ keys: string[],
117
+ options?: cf.DurableObjectGetOptions,
118
+ ): Effect.Effect<Map<string, T>>;
119
+ list<T = unknown>(
120
+ options?: cf.DurableObjectListOptions,
121
+ ): Effect.Effect<Map<string, T>>;
122
+ put<T>(
123
+ key: string,
124
+ value: T,
125
+ options?: cf.DurableObjectPutOptions,
126
+ ): Effect.Effect<void>;
127
+ put<T>(
128
+ entries: Record<string, T>,
129
+ options?: cf.DurableObjectPutOptions,
130
+ ): Effect.Effect<void>;
131
+ delete(
132
+ key: string,
133
+ options?: cf.DurableObjectPutOptions,
134
+ ): Effect.Effect<boolean>;
135
+ delete(
136
+ keys: string[],
137
+ options?: cf.DurableObjectPutOptions,
138
+ ): Effect.Effect<number>;
139
+ deleteAll(options?: cf.DurableObjectPutOptions): Effect.Effect<void>;
140
+ transaction<T>(
141
+ closure: (txn: DurableObjectTransaction) => Effect.Effect<T>,
142
+ ): Effect.Effect<T>;
143
+ getAlarm(
144
+ options?: cf.DurableObjectGetAlarmOptions,
145
+ ): Effect.Effect<number | null>;
146
+ setAlarm(
147
+ scheduledTime: number | Date,
148
+ options?: cf.DurableObjectSetAlarmOptions,
149
+ ): Effect.Effect<void>;
150
+ deleteAlarm(options?: cf.DurableObjectSetAlarmOptions): Effect.Effect<void>;
151
+ sync(): Effect.Effect<void>;
152
+ sql: SqlStorage;
153
+ kv: cf.SyncKvStorage;
154
+ transactionSync<T>(closure: () => T): T;
155
+ getCurrentBookmark(): Effect.Effect<string>;
156
+ getBookmarkForTime(timestamp: number | Date): Effect.Effect<string>;
157
+ onNextSessionRestoreBookmark(bookmark: string): Effect.Effect<string>;
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Constructors from raw Cloudflare types
162
+ // ---------------------------------------------------------------------------
163
+
164
+ export const fromDurableObjectTransaction = (
165
+ txn: cf.DurableObjectTransaction,
166
+ ): DurableObjectTransaction => ({
167
+ get: ((keyOrKeys: string | string[], options?: cf.DurableObjectGetOptions) =>
168
+ Effect.tryPromise(() => txn.get(keyOrKeys as any, options))) as any,
169
+ list: (options?: cf.DurableObjectListOptions) =>
170
+ Effect.tryPromise(() => txn.list(options)),
171
+ put: ((
172
+ keyOrEntries: string | Record<string, unknown>,
173
+ valueOrOptions?: unknown,
174
+ maybeOptions?: cf.DurableObjectPutOptions,
175
+ ) =>
176
+ typeof keyOrEntries === "string"
177
+ ? Effect.tryPromise(() =>
178
+ txn.put(keyOrEntries, valueOrOptions, maybeOptions),
179
+ )
180
+ : Effect.tryPromise(() =>
181
+ txn.put(
182
+ keyOrEntries,
183
+ valueOrOptions as cf.DurableObjectPutOptions | undefined,
184
+ ),
185
+ )) as any,
186
+ delete: ((
187
+ keyOrKeys: string | string[],
188
+ options?: cf.DurableObjectPutOptions,
189
+ ) => Effect.tryPromise(() => txn.delete(keyOrKeys as any, options))) as any,
190
+ rollback: () => Effect.sync(() => txn.rollback()),
191
+ getAlarm: (options?: cf.DurableObjectGetAlarmOptions) =>
192
+ Effect.tryPromise(() => txn.getAlarm(options)),
193
+ setAlarm: (
194
+ scheduledTime: number | Date,
195
+ options?: cf.DurableObjectSetAlarmOptions,
196
+ ) => Effect.tryPromise(() => txn.setAlarm(scheduledTime, options)),
197
+ deleteAlarm: (options?: cf.DurableObjectSetAlarmOptions) =>
198
+ Effect.tryPromise(() => txn.deleteAlarm(options)),
199
+ });
200
+
201
+ export const fromDurableObjectStorage = (
202
+ storage: cf.DurableObjectStorage,
203
+ ): DurableObjectStorage => ({
204
+ get: ((keyOrKeys: string | string[], options?: cf.DurableObjectGetOptions) =>
205
+ Effect.tryPromise(() => storage.get(keyOrKeys as any, options))) as any,
206
+ list: (options?: cf.DurableObjectListOptions) =>
207
+ Effect.tryPromise(() => storage.list(options)),
208
+ put: ((
209
+ keyOrEntries: string | Record<string, unknown>,
210
+ valueOrOptions?: unknown,
211
+ maybeOptions?: cf.DurableObjectPutOptions,
212
+ ) =>
213
+ typeof keyOrEntries === "string"
214
+ ? Effect.tryPromise(() =>
215
+ storage.put(keyOrEntries, valueOrOptions, maybeOptions),
216
+ )
217
+ : Effect.tryPromise(() =>
218
+ storage.put(
219
+ keyOrEntries,
220
+ valueOrOptions as cf.DurableObjectPutOptions | undefined,
221
+ ),
222
+ )) as any,
223
+ delete: ((
224
+ keyOrKeys: string | string[],
225
+ options?: cf.DurableObjectPutOptions,
226
+ ) =>
227
+ Effect.tryPromise(() => storage.delete(keyOrKeys as any, options))) as any,
228
+ deleteAll: (options?: cf.DurableObjectPutOptions) =>
229
+ Effect.tryPromise(() => storage.deleteAll(options)),
230
+ transaction: <T>(
231
+ closure: (txn: DurableObjectTransaction) => Effect.Effect<T>,
232
+ ) =>
233
+ Effect.tryPromise(() =>
234
+ storage.transaction((txn) =>
235
+ Effect.runPromise(closure(fromDurableObjectTransaction(txn))),
236
+ ),
237
+ ),
238
+ getAlarm: (options?: cf.DurableObjectGetAlarmOptions) =>
239
+ Effect.tryPromise(() => storage.getAlarm(options)),
240
+ setAlarm: (
241
+ scheduledTime: number | Date,
242
+ options?: cf.DurableObjectSetAlarmOptions,
243
+ ) => Effect.tryPromise(() => storage.setAlarm(scheduledTime, options)),
244
+ deleteAlarm: (options?: cf.DurableObjectSetAlarmOptions) =>
245
+ Effect.tryPromise(() => storage.deleteAlarm(options)),
246
+ sync: () => Effect.tryPromise(() => storage.sync()),
247
+ sql: fromSqlStorage(storage.sql),
248
+ kv: storage.kv,
249
+ transactionSync: <T>(closure: () => T) => storage.transactionSync(closure),
250
+ getCurrentBookmark: () =>
251
+ Effect.tryPromise(() => storage.getCurrentBookmark()),
252
+ getBookmarkForTime: (timestamp: number | Date) =>
253
+ Effect.tryPromise(() => storage.getBookmarkForTime(timestamp)),
254
+ onNextSessionRestoreBookmark: (bookmark: string) =>
255
+ Effect.tryPromise(() => storage.onNextSessionRestoreBookmark(bookmark)),
256
+ });
@@ -70,16 +70,40 @@ export type DynamicWorkerLoader = {
70
70
  };
71
71
 
72
72
  /**
73
- * Declare a Dynamic Worker loader binding inside a Worker program.
73
+ * Load and run ephemeral Workers at runtime from inline JavaScript
74
+ * modules.
74
75
  *
75
- * At deploy time this registers a `worker_loader` binding on the parent
76
- * Worker. At runtime it exposes an Effect-wrapped interface for loading
77
- * and calling into dynamic workers.
76
+ * `DynamicWorkerLoader` registers a `worker_loader` binding on the
77
+ * parent Worker at deploy time. At runtime you call `.load()` with
78
+ * inline module source code and get back a fully typed Worker
79
+ * instance you can `fetch` or call RPC methods on. Each loaded
80
+ * Worker runs in its own isolate with full sandboxing.
78
81
  *
79
- * @example
82
+ * This is useful for evaluating user-provided code, running
83
+ * untrusted plugins, or dynamically generating Workers from
84
+ * templates.
85
+ *
86
+ * @resource
87
+ *
88
+ * @section Creating a Loader
89
+ * Yield `Cloudflare.DynamicWorkerLoader` in your Worker's init
90
+ * phase to register the binding. The string argument becomes the
91
+ * binding name on the deployed Worker.
92
+ *
93
+ * @example Registering a loader
80
94
  * ```typescript
81
- * const loader = yield* DynamicWorker("LOADER");
95
+ * // init
96
+ * const loader = yield* Cloudflare.DynamicWorkerLoader("Loader");
97
+ * ```
98
+ *
99
+ * @section Loading a Worker
100
+ * Call `loader.load()` with a compatibility date, a main module
101
+ * name, and a map of module names to source code strings. The
102
+ * returned instance exposes `.fetch()` for HTTP and RPC methods
103
+ * for named entrypoints.
82
104
  *
105
+ * @example Loading and calling a dynamic Worker
106
+ * ```typescript
83
107
  * const worker = loader.load({
84
108
  * compatibilityDate: "2026-01-28",
85
109
  * mainModule: "worker.js",
@@ -90,10 +114,45 @@ export type DynamicWorkerLoader = {
90
114
  * }
91
115
  * }`,
92
116
  * },
117
+ * });
118
+ *
119
+ * const response = yield* worker.fetch(
120
+ * HttpClientRequest.get("https://worker/"),
121
+ * );
122
+ * ```
123
+ *
124
+ * @section Sandboxing
125
+ * Set `globalOutbound` to `null` to block all outbound network
126
+ * access from the dynamic Worker, or pass an RPC stub to intercept
127
+ * and proxy outbound requests.
128
+ *
129
+ * @example Blocking outbound access
130
+ * ```typescript
131
+ * const worker = loader.load({
132
+ * compatibilityDate: "2026-01-28",
133
+ * mainModule: "worker.js",
134
+ * modules: {
135
+ * "worker.js": `export default {
136
+ * async fetch(req) {
137
+ * // fetch() calls from here will fail
138
+ * return new Response("sandboxed");
139
+ * }
140
+ * }`,
141
+ * },
93
142
  * globalOutbound: null,
94
143
  * });
144
+ * ```
95
145
  *
96
- * const response = yield* worker.fetch(request);
146
+ * @section Named Entrypoints
147
+ * If the dynamic Worker exports named entrypoints, use
148
+ * `.getEntrypoint(name)` to get a typed stub for calling its
149
+ * methods.
150
+ *
151
+ * @example Calling a named entrypoint
152
+ * ```typescript
153
+ * const worker = loader.load({ ... });
154
+ * const api = worker.getEntrypoint<{ greet: (name: string) => Effect.Effect<string> }>("api");
155
+ * const greeting = yield* api.greet("world");
97
156
  * ```
98
157
  */
99
158
  export const DynamicWorkerLoader = Effect.fnUntraced(function* (name: string) {
@@ -14,10 +14,14 @@ export type InferEnv<W> = W extends
14
14
  }
15
15
  : never;
16
16
 
17
- type GetBindingType<T> = T extends Cloudflare.D1Database
18
- ? D1Database
19
- : T extends Cloudflare.R2Bucket
20
- ? R2Bucket
21
- : T extends Cloudflare.KVNamespace
22
- ? KVNamespace
23
- : never;
17
+ type GetBindingType<T> = T extends Cloudflare.Assets
18
+ ? Service
19
+ : T extends Cloudflare.D1Database
20
+ ? D1Database
21
+ : T extends Cloudflare.R2Bucket
22
+ ? R2Bucket
23
+ : T extends Cloudflare.KVNamespace
24
+ ? KVNamespace
25
+ : T extends Cloudflare.DurableObjectNamespaceLike
26
+ ? DurableObjectNamespace<Exclude<T["Shape"], undefined>>
27
+ : never;
@@ -225,7 +225,7 @@ export const makeDurableObjectBridge =
225
225
  ) =>
226
226
  (className: string) =>
227
227
  class DurableObjectBridge extends DurableObject {
228
- readonly object: Promise<any>;
228
+ readonly object: Promise<DurableObjectShape>;
229
229
 
230
230
  async fetch(request: cf.Request): Promise<cf.Response> {
231
231
  const methods = await this.object;
@@ -240,6 +240,13 @@ export const makeDurableObjectBridge =
240
240
  }
241
241
  }
242
242
 
243
+ async alarm(alarmInfo?: cf.AlarmInvocationInfo) {
244
+ const methods = await this.object;
245
+ if (methods.alarm) {
246
+ await Effect.runPromise(methods.alarm(alarmInfo));
247
+ }
248
+ }
249
+
243
250
  async webSocketMessage(ws: cf.WebSocket, message: string | ArrayBuffer) {
244
251
  const methods = await this.object;
245
252
  if (methods.webSocketMessage) {
@@ -288,7 +295,10 @@ export const makeDurableObjectBridge =
288
295
  ? target[prop]
289
296
  : async (...args: unknown[]) => {
290
297
  const methods = await this.object;
291
- const value = methods[prop as string](...args);
298
+ const method = methods[
299
+ prop as keyof DurableObjectShape
300
+ ] as any; // TODO(sam): what should the type be? Is it safe to always call it?
301
+ const value = method(...args);
292
302
  if (Effect.isEffect(value)) {
293
303
  const exit = await Effect.runPromiseExit(
294
304
  value as Effect.Effect<unknown, never>,
@@ -374,6 +384,7 @@ export const makeWorkflowBridge =
374
384
  }
375
385
  };
376
386
 
387
+ import type { DurableObjectShape } from "./DurableObjectNamespace.ts";
377
388
  import {
378
389
  WorkflowEvent as WorkflowEventService,
379
390
  WorkflowStep,
@@ -0,0 +1,185 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Option from "effect/Option";
3
+ import * as Stream from "effect/Stream";
4
+ import { DurableObjectState } from "./DurableObjectState.ts";
5
+ import type { SqlStorageValue } from "./DurableObjectStorage.ts";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Scheduled Events — SQLite-backed cron/timer for Durable Objects
9
+ // ---------------------------------------------------------------------------
10
+
11
+ const INIT_TABLE_SQL = `
12
+ CREATE TABLE IF NOT EXISTS alchemy_scheduled_events (
13
+ id TEXT PRIMARY KEY,
14
+ run_at INTEGER NOT NULL,
15
+ repeat_ms INTEGER,
16
+ payload TEXT NOT NULL
17
+ );
18
+ CREATE INDEX IF NOT EXISTS idx_alchemy_scheduled_events_run_at
19
+ ON alchemy_scheduled_events (run_at);
20
+ `;
21
+
22
+ const ensureTable = Effect.gen(function* () {
23
+ const ctx = yield* DurableObjectState;
24
+ yield* ctx.storage.sql.exec(INIT_TABLE_SQL);
25
+ });
26
+
27
+ export interface ScheduledEvent {
28
+ id: string;
29
+ runAt: Date;
30
+ repeatMs?: number;
31
+ payload: unknown;
32
+ }
33
+
34
+ interface EventRow extends Record<string, SqlStorageValue> {
35
+ id: string;
36
+ run_at: number;
37
+ repeat_ms: number | null;
38
+ payload: string;
39
+ }
40
+
41
+ const toScheduledEvent = (row: EventRow): ScheduledEvent => ({
42
+ id: row.id,
43
+ runAt: new Date(row.run_at),
44
+ repeatMs: row.repeat_ms ?? undefined,
45
+ payload: JSON.parse(row.payload) as unknown,
46
+ });
47
+
48
+ /**
49
+ * Schedule (or reschedule) a named event on the current Durable Object.
50
+ *
51
+ * The event is persisted in a SQLite table and the DO alarm is set to
52
+ * the earliest pending `runAt`. If an event with the same `id` already
53
+ * exists it is replaced (upsert).
54
+ *
55
+ * @param id Stable identifier for the event (used for upsert / cancel).
56
+ * @param runAt When the event should fire.
57
+ * @param payload Arbitrary JSON-serialisable data delivered to the alarm handler.
58
+ * @param repeatMs If set, the event re-schedules itself this many ms after each fire.
59
+ */
60
+ export const scheduleEvent = Effect.fnUntraced(function* (
61
+ id: string,
62
+ runAt: Date,
63
+ payload: unknown,
64
+ repeatMs?: number,
65
+ ) {
66
+ yield* ensureTable;
67
+ const ctx = yield* DurableObjectState;
68
+
69
+ yield* ctx.storage.sql.exec(
70
+ `INSERT OR REPLACE INTO alchemy_scheduled_events (id, run_at, repeat_ms, payload)
71
+ VALUES (?, ?, ?, ?)`,
72
+ id,
73
+ runAt.getTime(),
74
+ repeatMs ?? null,
75
+ JSON.stringify(payload),
76
+ );
77
+
78
+ yield* reconcileAlarm;
79
+ });
80
+
81
+ /**
82
+ * Cancel a previously scheduled event by id. No-op if the event does not exist.
83
+ */
84
+ export const cancelEvent = Effect.fnUntraced(function* (id: string) {
85
+ yield* ensureTable;
86
+ const ctx = yield* DurableObjectState;
87
+
88
+ yield* ctx.storage.sql.exec(
89
+ `DELETE FROM alchemy_scheduled_events WHERE id = ?`,
90
+ id,
91
+ );
92
+
93
+ yield* reconcileAlarm;
94
+ });
95
+
96
+ /**
97
+ * List all currently scheduled events, ordered by `runAt` ascending.
98
+ */
99
+ export const listEvents: Effect.Effect<
100
+ ScheduledEvent[],
101
+ never,
102
+ DurableObjectState
103
+ > = Effect.gen(function* () {
104
+ yield* ensureTable;
105
+ const ctx = yield* DurableObjectState;
106
+
107
+ const cursor = yield* ctx.storage.sql.exec<EventRow>(
108
+ `SELECT id, run_at, repeat_ms, payload FROM alchemy_scheduled_events ORDER BY run_at ASC`,
109
+ );
110
+
111
+ return yield* cursor.pipe(Stream.map(toScheduledEvent), Stream.runCollect);
112
+ });
113
+
114
+ /**
115
+ * Process all events whose `runAt` <= now. Returns the fired events.
116
+ *
117
+ * - One-shot events are deleted after firing.
118
+ * - Repeating events have their `runAt` bumped by `repeatMs`.
119
+ * - The DO alarm is re-set to the next pending event (if any).
120
+ *
121
+ * Call this from your Durable Object's `alarm` handler:
122
+ *
123
+ * ```ts
124
+ * alarm: () => Effect.gen(function* () {
125
+ * const fired = yield* Cloudflare.processScheduledEvents;
126
+ * for (const event of fired) {
127
+ * // handle each event
128
+ * }
129
+ * })
130
+ * ```
131
+ */
132
+ export const processScheduledEvents: Effect.Effect<
133
+ ScheduledEvent[],
134
+ never,
135
+ DurableObjectState
136
+ > = Effect.gen(function* () {
137
+ yield* ensureTable;
138
+ const ctx = yield* DurableObjectState;
139
+ const now = Date.now();
140
+
141
+ const cursor = yield* ctx.storage.sql.exec<EventRow>(
142
+ `SELECT id, run_at, repeat_ms, payload FROM alchemy_scheduled_events WHERE run_at <= ? ORDER BY run_at ASC`,
143
+ now,
144
+ );
145
+
146
+ const fired = yield* cursor.pipe(
147
+ Stream.mapEffect((row) =>
148
+ (row.repeat_ms != null
149
+ ? ctx.storage.sql.exec(
150
+ `UPDATE alchemy_scheduled_events SET run_at = ? WHERE id = ?`,
151
+ now + row.repeat_ms,
152
+ row.id,
153
+ )
154
+ : ctx.storage.sql.exec(
155
+ `DELETE FROM alchemy_scheduled_events WHERE id = ?`,
156
+ row.id,
157
+ )
158
+ ).pipe(Effect.as(toScheduledEvent(row))),
159
+ ),
160
+ Stream.runCollect,
161
+ );
162
+
163
+ yield* reconcileAlarm;
164
+ return fired;
165
+ });
166
+
167
+ /**
168
+ * Set the DO alarm to the earliest pending event, or clear it if none remain.
169
+ */
170
+ const reconcileAlarm: Effect.Effect<void, never, DurableObjectState> =
171
+ Effect.gen(function* () {
172
+ const ctx = yield* DurableObjectState;
173
+
174
+ const next = yield* (yield* ctx.storage.sql.exec<{
175
+ run_at: number;
176
+ }>(
177
+ `SELECT run_at FROM alchemy_scheduled_events ORDER BY run_at ASC LIMIT 1`,
178
+ )).pipe(Stream.take(1), Stream.runHead);
179
+
180
+ if (Option.isSome(next)) {
181
+ yield* ctx.storage.setAlarm(next.value.run_at);
182
+ } else {
183
+ yield* ctx.storage.deleteAlarm();
184
+ }
185
+ });
@@ -2,7 +2,7 @@ import type * as cf from "@cloudflare/workers-types";
2
2
  import * as Effect from "effect/Effect";
3
3
  import * as HttpBody from "effect/unstable/http/HttpBody";
4
4
  import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
5
- import { DurableObjectState } from "./DurableObject.ts";
5
+ import { DurableObjectState } from "./DurableObjectState.ts";
6
6
 
7
7
  export type RawWebSocket = cf.WebSocket;
8
8