alchemy 2.0.0-beta.3 → 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 (67) hide show
  1. package/bin/{alchemy-effect.js → alchemy.js} +170 -170
  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 +38 -38
  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/ContainerApplication.ts +3 -3
  30. package/src/Cloudflare/Container/ContainerBinding.ts +2 -4
  31. package/src/Cloudflare/Container/StartContainer.ts +1 -1
  32. package/src/Cloudflare/Providers.ts +1 -6
  33. package/src/Cloudflare/R2/R2Bucket.ts +2 -2
  34. package/src/Cloudflare/Website/StaticSite.ts +44 -15
  35. package/src/Cloudflare/Website/Vite.ts +34 -12
  36. package/src/Cloudflare/Workers/Assets.ts +170 -207
  37. package/src/Cloudflare/Workers/DurableObjectNamespace.ts +230 -370
  38. package/src/Cloudflare/Workers/DurableObjectState.ts +63 -0
  39. package/src/Cloudflare/Workers/DurableObjectStorage.ts +256 -0
  40. package/src/Cloudflare/Workers/InferEnv.ts +11 -7
  41. package/src/Cloudflare/Workers/Rpc.ts +13 -2
  42. package/src/Cloudflare/Workers/ScheduledEvents.ts +185 -0
  43. package/src/Cloudflare/Workers/WebSocket.ts +1 -1
  44. package/src/Cloudflare/Workers/Worker.ts +261 -61
  45. package/src/Cloudflare/Workers/index.ts +3 -0
  46. package/src/Construct.ts +2 -2
  47. package/src/GitHub/Secret.ts +52 -7
  48. package/src/GitHub/Variable.ts +45 -2
  49. package/src/Kubernetes/client.ts +2 -2
  50. package/src/Output.ts +1 -1
  51. package/src/Platform.ts +10 -5
  52. package/src/Resource.ts +4 -4
  53. package/src/Test/Vitest.ts +4 -3
  54. package/src/Util/Clank.ts +77 -0
  55. package/src/Util/PlatformServices.ts +21 -0
  56. package/src/Util/dedent.ts +4 -1
  57. package/bin/alchemy-effect.js.map +0 -1
  58. package/src/Daemon/Client.ts +0 -116
  59. package/src/Daemon/Config.ts +0 -28
  60. package/src/Daemon/Errors.ts +0 -48
  61. package/src/Daemon/Lock.ts +0 -162
  62. package/src/Daemon/ProcessRegistry.ts +0 -284
  63. package/src/Daemon/RpcSchema.ts +0 -43
  64. package/src/Daemon/RpcServer.ts +0 -231
  65. package/src/Daemon/index.ts +0 -27
  66. package/src/Spawn.ts +0 -23
  67. /package/bin/{alchemy-effect.ts → alchemy.ts} +0 -0
@@ -0,0 +1,63 @@
1
+ import type * as cf from "@cloudflare/workers-types";
2
+ import * as Context from "effect/Context";
3
+ import * as Effect from "effect/Effect";
4
+ import {
5
+ fromDurableObjectStorage,
6
+ type DurableObjectStorage,
7
+ } from "./DurableObjectStorage.ts";
8
+ import { fromWebSocket, type DurableWebSocket } from "./WebSocket.ts";
9
+
10
+ export class DurableObjectState extends Context.Service<
11
+ DurableObjectState,
12
+ {
13
+ readonly id: cf.DurableObjectId;
14
+ readonly storage: DurableObjectStorage;
15
+ container?: cf.Container;
16
+ blockConcurrencyWhile<T>(
17
+ callback: () => Effect.Effect<T>,
18
+ ): Effect.Effect<T>;
19
+ acceptWebSocket(ws: DurableWebSocket, tags?: string[]): Effect.Effect<void>;
20
+ getWebSockets(tag?: string): Effect.Effect<DurableWebSocket[]>;
21
+ setWebSocketAutoResponse(
22
+ maybeReqResp?: cf.WebSocketRequestResponsePair,
23
+ ): Effect.Effect<void>;
24
+ getWebSocketAutoResponse(): Effect.Effect<cf.WebSocketRequestResponsePair | null>;
25
+ getWebSocketAutoResponseTimestamp(
26
+ ws: cf.WebSocket,
27
+ ): Effect.Effect<Date | null>;
28
+ setHibernatableWebSocketEventTimeout(
29
+ timeoutMs?: number,
30
+ ): Effect.Effect<void>;
31
+ getHibernatableWebSocketEventTimeout(): Effect.Effect<number | null>;
32
+ getTags(ws: cf.WebSocket): Effect.Effect<string[]>;
33
+ abort(reason?: string): Effect.Effect<void>;
34
+ }
35
+ >()("Cloudflare.DurableObjectState") {}
36
+
37
+ export const fromDurableObjectState = (
38
+ state: cf.DurableObjectState,
39
+ ): DurableObjectState["Service"] => ({
40
+ id: state.id,
41
+ container: state.container,
42
+ storage: fromDurableObjectStorage(state.storage),
43
+ blockConcurrencyWhile: <T>(callback: () => Effect.Effect<T>) =>
44
+ Effect.tryPromise(() =>
45
+ state.blockConcurrencyWhile(() => Effect.runPromise(callback())),
46
+ ),
47
+ acceptWebSocket: (ws: DurableWebSocket, tags?: string[]) =>
48
+ Effect.sync(() => state.acceptWebSocket(ws.ws, tags)),
49
+ getWebSockets: (tag?: string) =>
50
+ Effect.sync(() => state.getWebSockets(tag).map(fromWebSocket)),
51
+ setWebSocketAutoResponse: (maybeReqResp?: cf.WebSocketRequestResponsePair) =>
52
+ Effect.sync(() => state.setWebSocketAutoResponse(maybeReqResp)),
53
+ getWebSocketAutoResponse: () =>
54
+ Effect.sync(() => state.getWebSocketAutoResponse()),
55
+ getWebSocketAutoResponseTimestamp: (ws: cf.WebSocket) =>
56
+ Effect.sync(() => state.getWebSocketAutoResponseTimestamp(ws)),
57
+ setHibernatableWebSocketEventTimeout: (timeoutMs?: number) =>
58
+ Effect.sync(() => state.setHibernatableWebSocketEventTimeout(timeoutMs)),
59
+ getHibernatableWebSocketEventTimeout: () =>
60
+ Effect.sync(() => state.getHibernatableWebSocketEventTimeout()),
61
+ getTags: (ws: cf.WebSocket) => Effect.sync(() => state.getTags(ws)),
62
+ abort: (reason?: string) => Effect.sync(() => state.abort(reason)),
63
+ });
@@ -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
+ });
@@ -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 "./DurableObjectNamespace.ts";
5
+ import { DurableObjectState } from "./DurableObjectState.ts";
6
6
 
7
7
  export type RawWebSocket = cf.WebSocket;
8
8