effect-cf 0.0.4 → 0.1.0

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.
@@ -0,0 +1,832 @@
1
+ import { Clock, Context, Data, DateTime, Duration, Effect, Exit, Layer, Schema as S } from "effect";
2
+
3
+ import { DurableObjectState } from "./DurableObjectState";
4
+ import type { SqlStorageValue, StorageOperationError } from "./DurableObjectStorage";
5
+
6
+ const INIT_TABLE_SQL = `
7
+ CREATE TABLE IF NOT EXISTS effect_cf_scheduled_alarms (
8
+ storage_id TEXT PRIMARY KEY,
9
+ alarm_id TEXT NOT NULL,
10
+ tag TEXT NOT NULL,
11
+ run_at INTEGER NOT NULL,
12
+ repeat_every_ms INTEGER,
13
+ payload TEXT NOT NULL
14
+ );
15
+ CREATE INDEX IF NOT EXISTS idx_effect_cf_scheduled_alarms_run_at_storage_id
16
+ ON effect_cf_scheduled_alarms (run_at, storage_id);
17
+ `;
18
+
19
+ const DEFAULT_PROCESS_DUE_ALARMS_LIMIT = 100;
20
+ const DEFAULT_PROCESS_DUE_ALARMS_FAILURE_RESCHEDULE_AFTER = "30 seconds" satisfies Duration.Input;
21
+
22
+ const getScheduledEventId = (input: { readonly id: string; readonly tag: string }) =>
23
+ `effect-cf-alarm:${encodeURIComponent(input.tag)}:${encodeURIComponent(input.id)}`;
24
+
25
+ /**
26
+ * JSON-serializable alarm payload stored with each scheduled alarm.
27
+ *
28
+ * Payloads are intentionally opaque to `DurableObjectAlarm`. Consumers should use
29
+ * stable `tag` values to route due alarms and then decode the payload for that
30
+ * tag in their own domain layer.
31
+ */
32
+ export type AlarmPayload = S.Json;
33
+
34
+ interface AlarmRow extends Record<string, SqlStorageValue> {
35
+ readonly alarm_id: string;
36
+ readonly payload: string;
37
+ readonly repeat_every_ms: number | null;
38
+ readonly run_at: number;
39
+ readonly storage_id: string;
40
+ readonly tag: string;
41
+ }
42
+
43
+ interface NextAlarmRow extends Record<string, SqlStorageValue> {
44
+ readonly run_at: number;
45
+ }
46
+
47
+ export class InvalidAlarmRefError extends Data.TaggedError("InvalidAlarmRefError")<{
48
+ readonly cause: unknown;
49
+ }> {}
50
+
51
+ export class InvalidAlarmPayloadError extends Data.TaggedError("InvalidAlarmPayloadError")<{
52
+ readonly cause: unknown;
53
+ }> {}
54
+
55
+ export class InvalidRepeatEveryError extends Data.TaggedError("InvalidRepeatEveryError")<{
56
+ readonly cause: unknown;
57
+ }> {}
58
+
59
+ export class InvalidProcessDueAlarmsOptionsError extends Data.TaggedError(
60
+ "InvalidProcessDueAlarmsOptionsError",
61
+ )<{
62
+ readonly cause: unknown;
63
+ }> {}
64
+
65
+ export class StoredAlarmDecodeError extends Data.TaggedError("StoredAlarmDecodeError")<{
66
+ readonly cause: unknown;
67
+ readonly storageId: string;
68
+ }> {}
69
+
70
+ export type DurableObjectAlarmError =
71
+ | InvalidAlarmPayloadError
72
+ | InvalidAlarmRefError
73
+ | InvalidProcessDueAlarmsOptionsError
74
+ | InvalidRepeatEveryError
75
+ | StorageOperationError
76
+ | StoredAlarmDecodeError;
77
+
78
+ /**
79
+ * Events handled by `processDueAlarms`.
80
+ *
81
+ * `AlarmDue` represents one durable alarm whose scheduled time is now due. The
82
+ * `tag` is the consumer-owned routing key, while `id` is the stable alarm id
83
+ * within that tag.
84
+ *
85
+ * `scheduledAt` is the logical due-after timestamp from durable storage. It is
86
+ * not expected to match the current Durable Object alarm invocation time,
87
+ * especially during retries. Retries re-scan durable rows with `run_at <= now`
88
+ * and identify logical events by `{ tag, id }`.
89
+ *
90
+ * @example Handling events by tag
91
+ * ```ts
92
+ * yield* durableObjectAlarm.processDueAlarms((event) =>
93
+ * event.tag === "heartbeat"
94
+ * ? heartbeatManager.handleAlarmEvent(event).pipe(Effect.asVoid)
95
+ * : Effect.void
96
+ * );
97
+ * ```
98
+ */
99
+ export const DurableObjectAlarmEvent = S.TaggedUnion({
100
+ AlarmDue: {
101
+ id: S.NonEmptyString,
102
+ payload: S.Json,
103
+ scheduledAt: S.DateTimeUtc,
104
+ tag: S.NonEmptyString,
105
+ },
106
+ });
107
+ export type DurableObjectAlarmEvent = typeof DurableObjectAlarmEvent.Type;
108
+
109
+ /**
110
+ * Stable reference for a scheduled alarm.
111
+ *
112
+ * The pair of `tag` and `id` is the durable identity. Scheduling another alarm
113
+ * with the same pair replaces the previous alarm, and cancellation uses the
114
+ * same pair.
115
+ *
116
+ * @example Cancel an alarm
117
+ * ```ts
118
+ * yield* durableObjectAlarm.cancelAlarm({
119
+ * tag: "connection-reconnect-grace",
120
+ * id: "reconnect-grace-check",
121
+ * });
122
+ * ```
123
+ */
124
+ export type AlarmRef<Tag extends string = string> = {
125
+ readonly id: string;
126
+ readonly tag: Tag;
127
+ };
128
+
129
+ const AlarmRefSchema = S.Struct({
130
+ id: S.NonEmptyString,
131
+ tag: S.NonEmptyString,
132
+ });
133
+
134
+ const decodeAlarmRef = (input: AlarmRef) =>
135
+ S.decodeUnknownEffect(AlarmRefSchema)(input).pipe(
136
+ Effect.mapError((cause) => new InvalidAlarmRefError({ cause })),
137
+ );
138
+
139
+ /**
140
+ * Input for scheduling or replacing an alarm.
141
+ *
142
+ * `runAt` is the absolute first fire time. `repeatEvery` can be any Effect
143
+ * `Duration.Input`, including strings like `"5 seconds"`, numbers interpreted
144
+ * as milliseconds, or `Duration` values.
145
+ *
146
+ * @example One-shot alarm
147
+ * ```ts
148
+ * const now = yield* DateTime.now;
149
+ *
150
+ * yield* durableObjectAlarm.scheduleAlarm({
151
+ * tag: "connection-reconnect-grace",
152
+ * id: "reconnect-grace-check",
153
+ * runAt: DateTime.add(now, { seconds: 15 }),
154
+ * payload: { reason: "socket-closed" },
155
+ * });
156
+ * ```
157
+ *
158
+ * @example Repeating alarm
159
+ * ```ts
160
+ * const now = yield* DateTime.now;
161
+ *
162
+ * yield* durableObjectAlarm.scheduleAlarm({
163
+ * tag: "heartbeat",
164
+ * id: "heartbeat-check",
165
+ * runAt: DateTime.add(now, { seconds: 5 }),
166
+ * repeatEvery: "5 seconds",
167
+ * payload: null,
168
+ * });
169
+ * ```
170
+ */
171
+ export type ScheduleAlarmInput<Tag extends string = string> = AlarmRef<Tag> & {
172
+ readonly payload: AlarmPayload;
173
+ readonly repeatEvery?: Duration.Input;
174
+ readonly runAt: DateTime.Utc;
175
+ };
176
+
177
+ export type ProcessDueAlarmsMode = "isolated" | "ordered";
178
+
179
+ export interface ProcessDueAlarmsFailure {
180
+ readonly cause: unknown;
181
+ readonly event?: DurableObjectAlarmEvent;
182
+ readonly id: string;
183
+ readonly storageId: string;
184
+ readonly tag: string;
185
+ }
186
+
187
+ export interface ProcessDueAlarmsResult {
188
+ readonly failed: readonly ProcessDueAlarmsFailure[];
189
+ readonly handled: readonly DurableObjectAlarmEvent[];
190
+ }
191
+
192
+ export type ProcessDueAlarmsFailureAction =
193
+ | "ordered"
194
+ | "retry"
195
+ | "skip-and-advance-repeat"
196
+ | {
197
+ readonly mode: "ordered";
198
+ }
199
+ | {
200
+ readonly mode: "retry";
201
+ readonly retryFailedAfter?: Duration.Input;
202
+ }
203
+ | {
204
+ readonly mode: "skip-and-advance-repeat";
205
+ };
206
+
207
+ export interface ProcessDueAlarmsOptions<OnFailureR = never, OnFailureE = never> {
208
+ /** Maximum due rows to load and process in one invocation. Defaults to 100. */
209
+ readonly limit?: number;
210
+ /**
211
+ * Failure isolation mode. Defaults to `isolated` so one poison logical alarm
212
+ * does not block unrelated maintenance work.
213
+ */
214
+ readonly mode?: ProcessDueAlarmsMode;
215
+ /**
216
+ * Callback for logical handler/decode failures. Return a failure action to
217
+ * override the global failure behavior for this row.
218
+ */
219
+ readonly onFailure?: (
220
+ failure: ProcessDueAlarmsFailure,
221
+ ) => Effect.Effect<ProcessDueAlarmsFailureAction | void, OnFailureE, OnFailureR>;
222
+ /**
223
+ * Delay before retrying a failed logical row in isolated mode. Defaults to 30
224
+ * seconds. Ignored by ordered mode unless supplied explicitly.
225
+ *
226
+ * Retrying updates only the selected occurrence, so replacements scheduled by
227
+ * a long-running handler are not clobbered by failure handling.
228
+ */
229
+ readonly retryFailedAfter?: Duration.Input;
230
+ }
231
+
232
+ /**
233
+ * Handler invoked for each due alarm before that alarm is acknowledged.
234
+ *
235
+ * If the handler fails, `DurableObjectAlarm` leaves the row due and fails the alarm
236
+ * processing effect. Cloudflare can then retry the Durable Object alarm handler,
237
+ * and the scheduler will find the same logical row again because `run_at` is
238
+ * still less than or equal to the retry time.
239
+ */
240
+ export type ProcessDueAlarmsHandler<R = never, E = never> = (
241
+ event: DurableObjectAlarmEvent,
242
+ ) => Effect.Effect<void, E, R>;
243
+
244
+ /**
245
+ * Durable alarm scheduler API.
246
+ *
247
+ * This service only owns scheduling semantics. It does not publish, subscribe,
248
+ * or dispatch events to consumers. Durable Object alarm handlers should pass a
249
+ * domain handler to `processDueAlarms`; rows are acknowledged only after that
250
+ * handler succeeds.
251
+ *
252
+ * This service must be the only code path that owns `storage.setAlarm()` inside
253
+ * a Durable Object instance. Cloudflare exposes one platform alarm timestamp per
254
+ * object, so another scheduler in the same object can clobber this service's
255
+ * reconciled alarm timestamp.
256
+ *
257
+ * @example Durable Object alarm handler
258
+ * ```ts
259
+ * const onAlarm = Effect.gen(function* () {
260
+ * const durableObjectAlarm = yield* DurableObjectAlarm;
261
+ *
262
+ * yield* durableObjectAlarm.processDueAlarms((event) =>
263
+ * Effect.gen(function* () {
264
+ * if (event.tag === "heartbeat") {
265
+ * yield* heartbeatManager.handleAlarmEvent(event);
266
+ * return;
267
+ * }
268
+ *
269
+ * if (event.tag === "connection-reconnect-grace") {
270
+ * yield* connectionManager.expireReconnectGracePeriods();
271
+ * }
272
+ * })
273
+ * );
274
+ * });
275
+ * ```
276
+ */
277
+ export type AlarmScheduler = {
278
+ /**
279
+ * Cancel a scheduled alarm by `{ tag, id }`.
280
+ *
281
+ * This is idempotent. Cancelling a missing alarm is a no-op. The underlying
282
+ * Durable Object alarm is reconciled afterward.
283
+ */
284
+ readonly cancelAlarm: (
285
+ input: AlarmRef,
286
+ ) => Effect.Effect<void, InvalidAlarmRefError | StorageOperationError>;
287
+
288
+ /**
289
+ * Process due alarms according to `Clock.currentTimeMillis`.
290
+ *
291
+ * Each row is acknowledged only after `handle` succeeds. One-shot alarms are
292
+ * then deleted. Repeating alarms are rescheduled to `acknowledgedAt +
293
+ * repeatEvery`, which intentionally behaves as delay-after-success rather
294
+ * than fixed-cadence catch-up. Acknowledgements are conditional on the stored
295
+ * row still matching the selected occurrence, so a handler that reschedules
296
+ * the same `{ tag, id }` will not have its replacement clobbered by the old
297
+ * acknowledgement.
298
+ *
299
+ * Processing is ordered by `runAt` and stable alarm id. By default, logical
300
+ * handler/decode failures are isolated to the failing row: the row is retried
301
+ * after `retryFailedAfter`, later due rows still run, and the result reports
302
+ * both handled and failed rows. Use `mode: "ordered"` when a workflow
303
+ * intentionally requires strict head-of-line blocking.
304
+ *
305
+ * Handlers must still be idempotent: Cloudflare alarms are at-least-once, and
306
+ * if a handler succeeds but the acknowledgement write fails, the logical event
307
+ * can be delivered again.
308
+ */
309
+ readonly processDueAlarms: <R = never, E = never, OnFailureR = never, OnFailureE = never>(
310
+ handle: ProcessDueAlarmsHandler<R, E>,
311
+ options?: ProcessDueAlarmsOptions<OnFailureR, OnFailureE>,
312
+ ) => Effect.Effect<
313
+ ProcessDueAlarmsResult,
314
+ E | OnFailureE | DurableObjectAlarmError,
315
+ R | OnFailureR
316
+ >;
317
+
318
+ /**
319
+ * Schedule or replace an alarm by `{ tag, id }`.
320
+ *
321
+ * The durable row write and underlying platform alarm reconciliation are run
322
+ * in one Durable Object storage transaction, so a failed `setAlarm` rolls back
323
+ * the logical schedule instead of leaving a stored-but-unarmed alarm.
324
+ */
325
+ readonly scheduleAlarm: (
326
+ input: ScheduleAlarmInput,
327
+ ) => Effect.Effect<
328
+ void,
329
+ | InvalidAlarmPayloadError
330
+ | InvalidAlarmRefError
331
+ | InvalidRepeatEveryError
332
+ | StorageOperationError
333
+ >;
334
+ };
335
+
336
+ const StoredPayloadString = S.fromJsonString(S.Json);
337
+
338
+ const decodeStoredPayload = (row: AlarmRow) =>
339
+ S.decodeUnknownEffect(StoredPayloadString)(row.payload).pipe(
340
+ Effect.mapError((cause) => new StoredAlarmDecodeError({ cause, storageId: row.storage_id })),
341
+ );
342
+
343
+ const encodeStoredPayload = (payload: AlarmPayload) =>
344
+ S.encodeEffect(StoredPayloadString)(payload).pipe(
345
+ Effect.mapError((cause) => new InvalidAlarmPayloadError({ cause })),
346
+ );
347
+
348
+ const ensureTable = (state: DurableObjectState["Service"]) =>
349
+ state.storage.sql.exec(INIT_TABLE_SQL).pipe(Effect.asVoid);
350
+
351
+ const toRepeatEveryMillis = (input: Duration.Input | undefined) => {
352
+ if (input === undefined) {
353
+ return Effect.succeed(null);
354
+ }
355
+
356
+ return Effect.try({
357
+ try: () => {
358
+ const millis = Duration.toMillis(Duration.fromInputUnsafe(input));
359
+ if (!Number.isFinite(millis) || millis <= 0) {
360
+ throw new Error("Alarm repeatEvery must be a positive finite duration");
361
+ }
362
+
363
+ return Math.ceil(millis);
364
+ },
365
+ catch: (cause) => new InvalidRepeatEveryError({ cause }),
366
+ });
367
+ };
368
+
369
+ const toAlarmDue = (row: AlarmRow) =>
370
+ Effect.gen(function* () {
371
+ if (!Number.isFinite(row.run_at)) {
372
+ return yield* Effect.fail(
373
+ new StoredAlarmDecodeError({
374
+ cause: new Error("Stored alarm run_at must be a finite number"),
375
+ storageId: row.storage_id,
376
+ }),
377
+ );
378
+ }
379
+
380
+ return DurableObjectAlarmEvent.make({
381
+ _tag: "AlarmDue",
382
+ id: row.alarm_id,
383
+ payload: yield* decodeStoredPayload(row),
384
+ scheduledAt: DateTime.toUtc(DateTime.makeUnsafe(row.run_at)),
385
+ tag: row.tag,
386
+ });
387
+ });
388
+
389
+ const getProcessLimit = (options: ProcessDueAlarmsOptions<unknown, unknown> | undefined) => {
390
+ const limit = options?.limit ?? DEFAULT_PROCESS_DUE_ALARMS_LIMIT;
391
+
392
+ if (!Number.isSafeInteger(limit) || limit <= 0) {
393
+ return Effect.fail(
394
+ new InvalidProcessDueAlarmsOptionsError({
395
+ cause: new Error("processDueAlarms limit must be a positive safe integer"),
396
+ }),
397
+ );
398
+ }
399
+
400
+ return Effect.succeed(limit);
401
+ };
402
+
403
+ const toFailureRescheduleMillis = (input: Duration.Input) =>
404
+ Effect.try({
405
+ try: () => {
406
+ const millis = Duration.toMillis(Duration.fromInputUnsafe(input));
407
+ if (!Number.isFinite(millis) || millis <= 0) {
408
+ throw new Error("Alarm failure rescheduleAfter must be a positive finite duration");
409
+ }
410
+
411
+ return Math.ceil(millis);
412
+ },
413
+ catch: (cause) => new InvalidProcessDueAlarmsOptionsError({ cause }),
414
+ });
415
+
416
+ const getFailureRetryDelay = (options: ProcessDueAlarmsOptions<unknown, unknown> | undefined) =>
417
+ toFailureRescheduleMillis(
418
+ options?.retryFailedAfter ?? DEFAULT_PROCESS_DUE_ALARMS_FAILURE_RESCHEDULE_AFTER,
419
+ );
420
+
421
+ const getFailureActionMode = (action: ProcessDueAlarmsFailureAction) =>
422
+ typeof action === "string" ? action : action.mode;
423
+
424
+ const getFailureActionRetryDelay = (action: ProcessDueAlarmsFailureAction) =>
425
+ typeof action === "string" || action.mode !== "retry" ? undefined : action.retryFailedAfter;
426
+
427
+ export const processDue = <R = never, E = never, OnFailureR = never, OnFailureE = never>(
428
+ handle: ProcessDueAlarmsHandler<R, E>,
429
+ options: ProcessDueAlarmsOptions<OnFailureR, OnFailureE> = {},
430
+ ) =>
431
+ Effect.gen(function* () {
432
+ const durableObjectAlarm = yield* DurableObjectAlarm;
433
+ return yield* durableObjectAlarm.processDueAlarms(handle, options);
434
+ });
435
+
436
+ export const process = processDue;
437
+
438
+ export type AlarmPayloadSchema = S.Codec<any, any, never, never>;
439
+
440
+ export type AlarmFailurePolicy = "ordered" | "retry" | "skip-and-advance-repeat";
441
+
442
+ export interface AlarmRetryPolicy {
443
+ readonly initialDelay?: Duration.Input;
444
+ }
445
+
446
+ export interface AlarmDefinitionConfig<Payload extends AlarmPayloadSchema = AlarmPayloadSchema> {
447
+ readonly failure?: AlarmFailurePolicy;
448
+ readonly payload: Payload;
449
+ readonly retry?: AlarmRetryPolicy;
450
+ }
451
+
452
+ export type AlarmDefinitionEntry = AlarmDefinitionConfig | AlarmPayloadSchema;
453
+
454
+ export type AlarmDefinitions = Readonly<Record<string, AlarmDefinitionEntry>>;
455
+
456
+ type AlarmDefinitionSchema<Definition> =
457
+ Definition extends AlarmDefinitionConfig<infer Payload> ? Payload : Definition;
458
+
459
+ export type AlarmDefinitionPayload<Definition> =
460
+ AlarmDefinitionSchema<Definition> extends S.Codec<infer A, any, never, never> ? A : never;
461
+
462
+ export type DefinedAlarmEvent<Tag extends string, Payload> = Omit<
463
+ DurableObjectAlarmEvent,
464
+ "payload" | "tag"
465
+ > & {
466
+ readonly payload: Payload;
467
+ readonly tag: Tag;
468
+ };
469
+
470
+ export type DefinedAlarmHandlers<Definitions extends AlarmDefinitions, R = never, E = never> = {
471
+ readonly [Tag in keyof Definitions & string]: (
472
+ event: DefinedAlarmEvent<Tag, AlarmDefinitionPayload<Definitions[Tag]>>,
473
+ ) => Effect.Effect<void, E, R>;
474
+ };
475
+
476
+ const isAlarmDefinitionConfig = (
477
+ definition: AlarmDefinitionEntry,
478
+ ): definition is AlarmDefinitionConfig =>
479
+ typeof definition === "object" && definition !== null && "payload" in definition;
480
+
481
+ const getAlarmDefinitionSchema = (definition: AlarmDefinitionEntry) =>
482
+ isAlarmDefinitionConfig(definition) ? definition.payload : definition;
483
+
484
+ const getAlarmDefinitionFailureAction = (
485
+ definition: AlarmDefinitionEntry | undefined,
486
+ ): ProcessDueAlarmsFailureAction | undefined => {
487
+ if (definition === undefined || !isAlarmDefinitionConfig(definition)) {
488
+ return undefined;
489
+ }
490
+
491
+ if (definition.failure === undefined) {
492
+ return undefined;
493
+ }
494
+
495
+ return definition.failure === "retry"
496
+ ? { mode: "retry", retryFailedAfter: definition.retry?.initialDelay }
497
+ : { mode: definition.failure };
498
+ };
499
+
500
+ export const define = <const Definitions extends AlarmDefinitions>(definitions: Definitions) => ({
501
+ handlers: <R = never, E = never>(
502
+ handlers: DefinedAlarmHandlers<Definitions, R, E>,
503
+ options?: ProcessDueAlarmsOptions<R, E>,
504
+ ) =>
505
+ processDue(
506
+ (event) =>
507
+ Effect.gen(function* () {
508
+ const definition = definitions[event.tag];
509
+ if (definition === undefined) {
510
+ return;
511
+ }
512
+
513
+ const schema = getAlarmDefinitionSchema(definition);
514
+ const payload = yield* (
515
+ S.decodeUnknownEffect(schema)(event.payload) as Effect.Effect<
516
+ AlarmDefinitionPayload<Definitions[keyof Definitions & string]>,
517
+ unknown
518
+ >
519
+ ).pipe(
520
+ Effect.mapError(
521
+ (cause) =>
522
+ new StoredAlarmDecodeError({
523
+ cause,
524
+ storageId: getScheduledEventId(event),
525
+ }),
526
+ ),
527
+ );
528
+ const handler = handlers[event.tag];
529
+
530
+ yield* handler({ ...event, payload } as never);
531
+ }),
532
+ {
533
+ ...options,
534
+ onFailure: (failure) =>
535
+ Effect.gen(function* () {
536
+ const action = getAlarmDefinitionFailureAction(definitions[failure.tag]);
537
+ const optionAction =
538
+ options?.onFailure === undefined ? undefined : yield* options.onFailure(failure);
539
+ return action ?? optionAction;
540
+ }),
541
+ },
542
+ ),
543
+ });
544
+
545
+ /**
546
+ * SQLite-backed Durable Object alarm scheduler.
547
+ *
548
+ * `DurableObjectAlarm` stores alarms in the current Durable Object's SQLite storage
549
+ * and keeps the platform alarm set to the earliest pending scheduled alarm. The
550
+ * platform alarm timestamp is only a wake-up hint; logical event identity comes
551
+ * from persisted `{ tag, id }` rows.
552
+ *
553
+ * Retry safety depends on acknowledgement ordering. `processDueAlarms` runs the
554
+ * caller's handler first and only then conditionally deletes or advances the
555
+ * same selected row. By default, handler failures are isolated: the failed row
556
+ * is retried after a delay, later due rows continue, and the platform alarm is
557
+ * reconciled once at the end. Use `mode: "ordered"` for strict workflows where
558
+ * one failed logical alarm should block later due alarms.
559
+ *
560
+ * Provide `DurableObjectAlarm.layer` anywhere `DurableObjectState` is available.
561
+ *
562
+ * @example Providing the service
563
+ * ```ts
564
+ * const program = Effect.gen(function* () {
565
+ * const durableObjectAlarm = yield* DurableObjectAlarm;
566
+ * const now = yield* DateTime.now;
567
+ *
568
+ * yield* durableObjectAlarm.scheduleAlarm({
569
+ * tag: "heartbeat",
570
+ * id: "heartbeat-check",
571
+ * runAt: DateTime.add(now, { seconds: 5 }),
572
+ * repeatEvery: Duration.seconds(5),
573
+ * payload: null,
574
+ * });
575
+ * }).pipe(Effect.provide(DurableObjectAlarm.layer));
576
+ * ```
577
+ *
578
+ * @example Processing due alarms
579
+ * ```ts
580
+ * const handledEvents = yield* durableObjectAlarm.processDueAlarms((event) =>
581
+ * Effect.gen(function* () {
582
+ * if (event.tag === "heartbeat") {
583
+ * yield* heartbeatManager.handleAlarmEvent(event);
584
+ * }
585
+ * })
586
+ * );
587
+ * ```
588
+ */
589
+ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, AlarmScheduler>()(
590
+ "effect-cf/DurableObjectAlarm",
591
+ ) {
592
+ static readonly layer = Layer.effect(
593
+ DurableObjectAlarm,
594
+ Effect.gen(function* () {
595
+ const state = yield* DurableObjectState;
596
+
597
+ const reconcileAlarm = Effect.fn("DurableObjectAlarm.reconcileAlarm")(function* () {
598
+ const cursor = yield* state.storage.sql.exec<NextAlarmRow>(
599
+ `SELECT run_at FROM effect_cf_scheduled_alarms ORDER BY run_at ASC, storage_id ASC LIMIT 1`,
600
+ );
601
+ const next = (yield* cursor.toArray())[0];
602
+
603
+ if (next === undefined) {
604
+ yield* state.storage.deleteAlarm();
605
+ return;
606
+ }
607
+
608
+ yield* state.storage.setAlarm(next.run_at);
609
+ });
610
+
611
+ const rescheduleFailedAlarm = Effect.fn("DurableObjectAlarm.rescheduleFailedAlarm")(
612
+ function* (row: AlarmRow, retryDelayMillis: number) {
613
+ const retryAt = (yield* Clock.currentTimeMillis) + retryDelayMillis;
614
+
615
+ if (row.repeat_every_ms === null) {
616
+ const cursor = yield* state.storage.sql.exec(
617
+ `UPDATE effect_cf_scheduled_alarms
618
+ SET run_at = ?
619
+ WHERE storage_id = ?
620
+ AND run_at = ?
621
+ AND repeat_every_ms IS NULL
622
+ AND payload = ?`,
623
+ retryAt,
624
+ row.storage_id,
625
+ row.run_at,
626
+ row.payload,
627
+ );
628
+ yield* cursor.rowsWritten;
629
+ return;
630
+ }
631
+
632
+ const cursor = yield* state.storage.sql.exec(
633
+ `UPDATE effect_cf_scheduled_alarms
634
+ SET run_at = ?
635
+ WHERE storage_id = ?
636
+ AND run_at = ?
637
+ AND repeat_every_ms = ?
638
+ AND payload = ?`,
639
+ retryAt,
640
+ row.storage_id,
641
+ row.run_at,
642
+ row.repeat_every_ms,
643
+ row.payload,
644
+ );
645
+ yield* cursor.rowsWritten;
646
+ },
647
+ );
648
+
649
+ const acknowledgeAlarm = Effect.fn("DurableObjectAlarm.acknowledgeAlarm")(function* (
650
+ row: AlarmRow,
651
+ ) {
652
+ if (row.repeat_every_ms === null) {
653
+ const cursor = yield* state.storage.sql.exec(
654
+ `DELETE FROM effect_cf_scheduled_alarms
655
+ WHERE storage_id = ?
656
+ AND run_at = ?
657
+ AND repeat_every_ms IS NULL
658
+ AND payload = ?`,
659
+ row.storage_id,
660
+ row.run_at,
661
+ row.payload,
662
+ );
663
+ yield* cursor.rowsWritten;
664
+ return;
665
+ }
666
+
667
+ const acknowledgedAt = yield* Clock.currentTimeMillis;
668
+ const cursor = yield* state.storage.sql.exec(
669
+ `UPDATE effect_cf_scheduled_alarms
670
+ SET run_at = ?
671
+ WHERE storage_id = ?
672
+ AND run_at = ?
673
+ AND repeat_every_ms = ?
674
+ AND payload = ?`,
675
+ acknowledgedAt + row.repeat_every_ms,
676
+ row.storage_id,
677
+ row.run_at,
678
+ row.repeat_every_ms,
679
+ row.payload,
680
+ );
681
+ yield* cursor.rowsWritten;
682
+ });
683
+
684
+ const cancelAlarm = Effect.fn("DurableObjectAlarm.cancelAlarm")(function* (input: AlarmRef) {
685
+ const ref = yield* decodeAlarmRef(input);
686
+ yield* state.storage.transaction(() =>
687
+ Effect.gen(function* () {
688
+ yield* ensureTable(state);
689
+ yield* state.storage.sql
690
+ .exec(
691
+ `DELETE FROM effect_cf_scheduled_alarms WHERE storage_id = ?`,
692
+ getScheduledEventId(ref),
693
+ )
694
+ .pipe(Effect.asVoid);
695
+ yield* reconcileAlarm();
696
+ }),
697
+ );
698
+ });
699
+
700
+ const scheduleAlarm = Effect.fn("DurableObjectAlarm.scheduleAlarm")(function* (
701
+ input: ScheduleAlarmInput,
702
+ ) {
703
+ const ref = yield* decodeAlarmRef(input);
704
+ const repeatEveryMillis = yield* toRepeatEveryMillis(input.repeatEvery);
705
+ const payload = yield* encodeStoredPayload(input.payload);
706
+ const runAt = DateTime.toEpochMillis(input.runAt);
707
+
708
+ yield* state.storage.transaction(() =>
709
+ Effect.gen(function* () {
710
+ yield* ensureTable(state);
711
+ yield* state.storage.sql
712
+ .exec(
713
+ `INSERT OR REPLACE INTO effect_cf_scheduled_alarms
714
+ (storage_id, alarm_id, tag, run_at, repeat_every_ms, payload)
715
+ VALUES (?, ?, ?, ?, ?, ?)`,
716
+ getScheduledEventId(ref),
717
+ ref.id,
718
+ ref.tag,
719
+ runAt,
720
+ repeatEveryMillis,
721
+ payload,
722
+ )
723
+ .pipe(Effect.asVoid);
724
+ yield* reconcileAlarm();
725
+ }),
726
+ );
727
+ });
728
+
729
+ const processDueAlarms = Effect.fn("DurableObjectAlarm.processDueAlarms")(function* <
730
+ R,
731
+ E,
732
+ OnFailureR,
733
+ OnFailureE,
734
+ >(
735
+ handle: ProcessDueAlarmsHandler<R, E>,
736
+ options?: ProcessDueAlarmsOptions<OnFailureR, OnFailureE>,
737
+ ) {
738
+ yield* ensureTable(state);
739
+ const mode = options?.mode ?? "isolated";
740
+ const limit = yield* getProcessLimit(options);
741
+ const now = yield* Clock.currentTimeMillis;
742
+ const cursor = yield* state.storage.sql.exec<AlarmRow>(
743
+ `SELECT storage_id, alarm_id, tag, run_at, repeat_every_ms, payload
744
+ FROM effect_cf_scheduled_alarms
745
+ WHERE run_at <= ?
746
+ ORDER BY run_at ASC, storage_id ASC
747
+ LIMIT ?`,
748
+ now,
749
+ limit,
750
+ );
751
+ const dueRows = yield* cursor.toArray();
752
+ const handled: DurableObjectAlarmEvent[] = [];
753
+ const failed: ProcessDueAlarmsFailure[] = [];
754
+
755
+ const handleFailure = function* (
756
+ row: AlarmRow,
757
+ event: DurableObjectAlarmEvent | undefined,
758
+ cause: unknown,
759
+ ) {
760
+ const failure: ProcessDueAlarmsFailure = {
761
+ cause,
762
+ event,
763
+ id: row.alarm_id,
764
+ storageId: row.storage_id,
765
+ tag: row.tag,
766
+ };
767
+
768
+ failed.push(failure);
769
+ const failureAction =
770
+ options?.onFailure === undefined ? undefined : yield* options.onFailure(failure);
771
+ const actionMode =
772
+ failureAction === undefined ? mode : getFailureActionMode(failureAction);
773
+
774
+ if (actionMode === "retry" || actionMode === "isolated") {
775
+ const actionRetryDelay =
776
+ failureAction === undefined ? undefined : getFailureActionRetryDelay(failureAction);
777
+ const retryDelay =
778
+ actionRetryDelay === undefined
779
+ ? yield* getFailureRetryDelay(options)
780
+ : yield* toFailureRescheduleMillis(actionRetryDelay);
781
+
782
+ yield* rescheduleFailedAlarm(row, retryDelay);
783
+ return "continue" as const;
784
+ }
785
+
786
+ if (actionMode === "skip-and-advance-repeat") {
787
+ yield* acknowledgeAlarm(row);
788
+ return "continue" as const;
789
+ }
790
+
791
+ yield* reconcileAlarm();
792
+ return "stop" as const;
793
+ };
794
+
795
+ for (const row of dueRows) {
796
+ const eventExit = yield* Effect.exit(toAlarmDue(row));
797
+
798
+ if (Exit.isFailure(eventExit)) {
799
+ const action = yield* handleFailure(row, undefined, eventExit.cause);
800
+ if (action === "stop") {
801
+ return yield* Effect.failCause(eventExit.cause);
802
+ }
803
+ continue;
804
+ }
805
+
806
+ const event = eventExit.value;
807
+ const handleExit = yield* Effect.exit(handle(event));
808
+
809
+ if (Exit.isFailure(handleExit)) {
810
+ const action = yield* handleFailure(row, event, handleExit.cause);
811
+ if (action === "stop") {
812
+ return yield* Effect.failCause(handleExit.cause);
813
+ }
814
+ continue;
815
+ }
816
+
817
+ yield* acknowledgeAlarm(row);
818
+ handled.push(event);
819
+ }
820
+
821
+ yield* reconcileAlarm();
822
+ return { failed, handled };
823
+ });
824
+
825
+ return DurableObjectAlarm.of({
826
+ cancelAlarm,
827
+ processDueAlarms,
828
+ scheduleAlarm,
829
+ });
830
+ }).pipe(Effect.withSpan("DurableObjectAlarm.layer")),
831
+ );
832
+ }