effect-cf 0.53.0 → 0.54.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.
@@ -2,12 +2,14 @@ import * as Clock from "effect/Clock";
2
2
  import * as Context from "effect/Context";
3
3
  import * as Data from "effect/Data";
4
4
  import * as DateTime from "effect/DateTime";
5
+ import * as Deferred from "effect/Deferred";
5
6
  import * as Duration from "effect/Duration";
6
7
  import * as Effect from "effect/Effect";
7
8
  import * as Exit from "effect/Exit";
8
9
  import * as Layer from "effect/Layer";
9
10
  import * as Predicate from "effect/Predicate";
10
11
  import * as S from "effect/Schema";
12
+ import * as Semaphore from "effect/Semaphore";
11
13
 
12
14
  import { DurableObjectState } from "./DurableObjectState";
13
15
  import { type SqlStorageValue, StorageOperationError } from "./DurableObjectStorage";
@@ -18,6 +20,7 @@ CREATE TABLE IF NOT EXISTS effect_cf_scheduled_alarms (
18
20
  storage_id TEXT PRIMARY KEY,
19
21
  alarm_id TEXT NOT NULL,
20
22
  tag TEXT NOT NULL,
23
+ lifecycle TEXT NOT NULL DEFAULT 'automatic',
21
24
  run_at INTEGER NOT NULL,
22
25
  wake_at INTEGER NOT NULL DEFAULT 0,
23
26
  repeat_every_ms INTEGER,
@@ -34,6 +37,7 @@ CREATE TABLE IF NOT EXISTS effect_cf_alarm_attempts (
34
37
  `;
35
38
 
36
39
  const WAKE_INDEX = "idx_effect_cf_scheduled_alarms_wake_at_storage_id";
40
+ const LIFECYCLE_INDEX = "idx_effect_cf_scheduled_alarms_lifecycle_wake_at_storage_id";
37
41
 
38
42
  const DEFAULT_PROCESS_DUE_ALARMS_LIMIT = 100;
39
43
 
@@ -49,6 +53,8 @@ export const MIN_REPEAT_INTERVAL_MS = 1_000;
49
53
  export const DEFAULT_MIN_REPEAT_INTERVAL_MS = 60_000;
50
54
 
51
55
  export interface ScheduleConfiguration {
56
+ /** Check for due alarms after RPC/fetch responses unwind. Native alarms remain recovery authority. */
57
+ readonly dispatchAfterEvent?: boolean;
52
58
  /** Retry floor, at least one second. Defaults to one second. */
53
59
  readonly minimumRetryDelay?: Duration.Input;
54
60
  /** Positive safe integer. Defaults to eight unchanged attempts. */
@@ -68,7 +74,13 @@ export const ScheduleConfiguration = Context.Reference<ScheduleConfiguration>(
68
74
  const safeIntegerAtLeast = (minimum: number) =>
69
75
  S.Int.check(S.isGreaterThanOrEqualTo(minimum), S.isLessThanOrEqualTo(Number.MAX_SAFE_INTEGER));
70
76
 
77
+ /** Automatic alarms acknowledge/retry on handler exit; manual alarms retain consumer control. */
78
+ export type AlarmLifecycle = "automatic" | "manual";
79
+
80
+ const AlarmLifecycleSchema = S.Literals(["automatic", "manual"]);
81
+
71
82
  const ScheduleConfigurationSchema = S.Struct({
83
+ dispatchAfterEvent: S.Boolean,
72
84
  minimumRetryDelay: safeIntegerAtLeast(MIN_RETRY_DELAY_MS),
73
85
  unchangedAttemptBudget: safeIntegerAtLeast(1),
74
86
  parkedRetryDelay: safeIntegerAtLeast(PARKED_RETRY_DELAY_MS),
@@ -99,6 +111,7 @@ const getScheduledEventId = (input: { readonly id: string; readonly tag: string
99
111
  export type AlarmPayload = S.Json;
100
112
 
101
113
  interface AlarmRow extends Record<string, SqlStorageValue> {
114
+ readonly lifecycle: AlarmLifecycle;
102
115
  readonly attempts: number;
103
116
  readonly alarm_id: string;
104
117
  readonly payload: string;
@@ -140,6 +153,18 @@ const CurrentAlarmPass = Context.Reference<
140
153
  { readonly row: AlarmRow; readonly parked: AlarmParked[]; active: boolean } | undefined
141
154
  >("effect-cf/DurableObjectAlarm/CurrentAlarmPass", { defaultValue: () => undefined });
142
155
 
156
+ /** One dispatcher's view of a registration pass: its tags, shared manual permits, raw tracking. */
157
+ const CurrentDispatch = Context.Reference<
158
+ | {
159
+ readonly tags: readonly string[];
160
+ readonly exclude: boolean;
161
+ readonly permits: Semaphore.Semaphore;
162
+ readonly raw?: { processed: boolean };
163
+ }
164
+ | undefined
165
+ >("effect-cf/DurableObjectAlarm/CurrentDispatch", { defaultValue: () => undefined });
166
+ const ManualTags: unique symbol = Symbol("effect-cf/DurableObjectAlarm/ManualTags");
167
+
143
168
  interface NextAlarmRow extends Record<string, SqlStorageValue> {
144
169
  readonly run_at: number;
145
170
  }
@@ -188,12 +213,23 @@ export class InvalidScheduleConfigurationError extends Data.TaggedError(
188
213
  }
189
214
  }
190
215
 
216
+ export class InvalidAlarmRegistrationError extends Data.TaggedError(
217
+ "InvalidAlarmRegistrationError",
218
+ )<{
219
+ readonly cause: unknown;
220
+ }> {
221
+ override get message(): string {
222
+ return `Invalid Durable Object alarm registration: ${ErrorMessage.causeMessage(this.cause)}`;
223
+ }
224
+ }
225
+
191
226
  const getScheduleConfiguration = Effect.fnUntraced(function* (defaults: ScheduleConfiguration) {
192
227
  const input = { ...defaults, ...(yield* ScheduleConfiguration) };
193
228
 
194
229
  return yield* Effect.try({
195
230
  try: () =>
196
231
  S.decodeUnknownSync(ScheduleConfigurationSchema)({
232
+ dispatchAfterEvent: input.dispatchAfterEvent ?? false,
197
233
  minimumRetryDelay: Math.ceil(
198
234
  Duration.toMillis(input.minimumRetryDelay ?? MIN_RETRY_DELAY_MS),
199
235
  ),
@@ -219,6 +255,7 @@ export class StoredAlarmDecodeError extends Data.TaggedError("StoredAlarmDecodeE
219
255
  }
220
256
 
221
257
  export type DurableObjectAlarmError =
258
+ | InvalidAlarmRegistrationError
222
259
  | InvalidAlarmPayloadError
223
260
  | InvalidAlarmRefError
224
261
  | InvalidProcessDueAlarmsOptionsError
@@ -258,6 +295,8 @@ const decodeAlarmRef = (input: AlarmRef) =>
258
295
  * Reusing `{tag, id}` replaces an alarm. Product repeats use the configured floor and run after completion.
259
296
  */
260
297
  export type ScheduleAlarmInput<Tag extends string = string> = AlarmRef<Tag> & {
298
+ /** Prefer selecting the lifecycle in a typed Tag definition. Defaults to automatic. */
299
+ readonly lifecycle?: AlarmLifecycle;
261
300
  readonly payload: AlarmPayload;
262
301
  readonly repeatEvery?: Duration.Input;
263
302
  readonly runAt: DateTime.Utc;
@@ -291,6 +330,7 @@ export type ProcessDueAlarmsFailureAction =
291
330
  };
292
331
 
293
332
  export interface ProcessDueAlarmsOptions<OnFailureR = never, OnFailureE = never> {
333
+ readonly [ManualTags]?: ReadonlySet<string>;
294
334
  readonly limit?: number;
295
335
  readonly onFailure?: (
296
336
  failure: ProcessDueAlarmsFailure,
@@ -318,8 +358,39 @@ export interface AlarmStatus {
318
358
  readonly retryAt: DateTime.Utc | undefined;
319
359
  }
320
360
 
361
+ /** @internal */
362
+ export const RunAlarm: unique symbol = Symbol("effect-cf/DurableObjectAlarm/RunAlarm");
363
+ /** @internal */
364
+ export const DispatchAfterEvent: unique symbol = Symbol(
365
+ "effect-cf/DurableObjectAlarm/DispatchAfterEvent",
366
+ );
367
+ /** @internal */
368
+ export const HasDueAlarms: unique symbol = Symbol("effect-cf/DurableObjectAlarm/HasDueAlarms");
369
+
321
370
  /** Own `storage.setAlarm()` exclusively: a Durable Object has one platform alarm timestamp. */
322
371
  export type AlarmScheduler = {
372
+ /** @internal DurableObject.make shares this scoped event across native and post-event dispatch. */
373
+ readonly [RunAlarm]: <R>(
374
+ event: (
375
+ beginDispatch: Effect.Effect<void, InvalidScheduleConfigurationError>,
376
+ ) => Effect.Effect<void, unknown, R>,
377
+ native: boolean,
378
+ ) => Effect.Effect<void, unknown, R>;
379
+ /** @internal Read the current event's opt-in without acquiring another event layer. */
380
+ readonly [DispatchAfterEvent]: Effect.Effect<boolean>;
381
+ /** @internal Check inside the alarm event so its clock and configuration apply. */
382
+ readonly [HasDueAlarms]: Effect.Effect<boolean, StorageOperationError>;
383
+ /**
384
+ * Keep a native recovery alarm armed while coalescing checkpoint changes across this
385
+ * object's concurrent and nested scopes. Source/checkpoint transactions still commit;
386
+ * the last scope reconciles their current deadlines on success, failure or interruption.
387
+ * Run outside transactions, around bounded maintenance or inline work. The first scope's
388
+ * parkedRetryDelay bounds the shared guard; renew it if it expires and is consumed while
389
+ * scopes remain active. A process loss before exit recovers at that retained alarm.
390
+ */
391
+ readonly deferWakes: <A, E, R>(
392
+ body: Effect.Effect<A, E, R>,
393
+ ) => Effect.Effect<A, E | InvalidScheduleConfigurationError | StorageOperationError, R>;
323
394
  /**
324
395
  * Commits local application storage and logical alarms in one native SQLite
325
396
  * Durable Object transaction, reconciling the native alarm before commit.
@@ -374,6 +445,7 @@ export type AlarmScheduler = {
374
445
  };
375
446
 
376
447
  const StoredPayloadString = S.fromJsonString(S.Json);
448
+ const StoredTags = S.fromJsonString(S.Array(S.String));
377
449
 
378
450
  const decodeStoredPayload = (row: AlarmRow) =>
379
451
  S.decodeUnknownEffect(StoredPayloadString)(row.payload).pipe(
@@ -389,7 +461,7 @@ const ensureTable = Effect.fnUntraced(function* (state: DurableObjectState["Serv
389
461
  yield* state.storage.sql.exec(INIT_TABLE_SQL);
390
462
  const index = yield* state.storage.sql.exec(
391
463
  "SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?",
392
- WAKE_INDEX,
464
+ LIFECYCLE_INDEX,
393
465
  );
394
466
 
395
467
  if ((yield* index.toArray()).length > 0) return;
@@ -398,6 +470,11 @@ const ensureTable = Effect.fnUntraced(function* (state: DurableObjectState["Serv
398
470
  );
399
471
  const names = new Set((yield* columns.toArray()).map((column) => column.name));
400
472
 
473
+ if (!names.has("lifecycle")) {
474
+ yield* state.storage.sql.exec(
475
+ "ALTER TABLE effect_cf_scheduled_alarms ADD COLUMN lifecycle TEXT NOT NULL DEFAULT 'automatic'",
476
+ );
477
+ }
401
478
  if (!names.has("wake_at")) {
402
479
  yield* state.storage.sql.exec(
403
480
  "ALTER TABLE effect_cf_scheduled_alarms ADD COLUMN wake_at INTEGER NOT NULL DEFAULT 0",
@@ -413,6 +490,8 @@ const ensureTable = Effect.fnUntraced(function* (state: DurableObjectState["Serv
413
490
  );
414
491
  yield* state.storage.sql.exec(`CREATE INDEX IF NOT EXISTS ${WAKE_INDEX}
415
492
  ON effect_cf_scheduled_alarms (wake_at, storage_id)`);
493
+ yield* state.storage.sql.exec(`CREATE INDEX IF NOT EXISTS ${LIFECYCLE_INDEX}
494
+ ON effect_cf_scheduled_alarms (lifecycle, wake_at, storage_id)`);
416
495
  });
417
496
 
418
497
  const toRepeatEveryMillis = (input: Duration.Input | undefined, minimum: number) => {
@@ -515,11 +594,20 @@ export interface AlarmRetryPolicy {
515
594
  readonly initialDelay?: Duration.Input;
516
595
  }
517
596
 
518
- export interface AlarmDefinitionConfig<Payload extends AlarmPayloadSchema = AlarmPayloadSchema> {
519
- readonly failure?: AlarmFailurePolicy;
597
+ export type AlarmDefinitionConfig<Payload extends AlarmPayloadSchema = AlarmPayloadSchema> = {
520
598
  readonly payload: Payload;
521
- readonly retry?: AlarmRetryPolicy;
522
- }
599
+ } & (
600
+ | {
601
+ readonly lifecycle?: "automatic";
602
+ readonly failure?: AlarmFailurePolicy;
603
+ readonly retry?: AlarmRetryPolicy;
604
+ }
605
+ | {
606
+ readonly lifecycle: "manual";
607
+ readonly failure?: never;
608
+ readonly retry?: never;
609
+ }
610
+ );
523
611
 
524
612
  export type AlarmDefinitionEntry = AlarmDefinitionConfig | AlarmPayloadSchema;
525
613
 
@@ -547,9 +635,14 @@ export type DefinedAlarmHandlers<Definitions extends AlarmDefinitions, R = never
547
635
 
548
636
  /** A discriminated union keeps each tag paired with its decoded payload type. */
549
637
  export type DefinedScheduleAlarmInput<Definitions extends AlarmDefinitions> = {
550
- readonly [Tag in keyof Definitions & string]: Omit<ScheduleAlarmInput<Tag>, "payload"> & {
638
+ readonly [Tag in keyof Definitions & string]: Omit<
639
+ ScheduleAlarmInput<Tag>,
640
+ "payload" | "lifecycle" | "repeatEvery" | "progress"
641
+ > & {
551
642
  readonly payload: AlarmDefinitionPayload<Definitions[Tag]>;
552
- };
643
+ } & (Definitions[Tag] extends { readonly lifecycle: "manual" }
644
+ ? { readonly repeatEvery?: never; readonly progress?: never }
645
+ : Pick<ScheduleAlarmInput<Tag>, "repeatEvery" | "progress">);
553
646
  }[keyof Definitions & string];
554
647
 
555
648
  export interface DefinedAlarmTransaction<Definitions extends AlarmDefinitions> {
@@ -577,6 +670,7 @@ export interface DefinedAlarmScheduler<
577
670
  Definitions extends AlarmDefinitions,
578
671
  > extends DefinedAlarmTransaction<Definitions> {
579
672
  readonly [TypedAlarmSchedulerTypeId]: typeof TypedAlarmSchedulerTypeId;
673
+ readonly deferWakes: AlarmScheduler["deferWakes"];
580
674
  readonly getAlarmStatus: (
581
675
  input: AlarmRef<keyof Definitions & string>,
582
676
  ) => ReturnType<AlarmScheduler["getAlarmStatus"]>;
@@ -587,12 +681,175 @@ export interface DefinedAlarmScheduler<
587
681
 
588
682
  /** Registers a dispatcher and the service authorized to schedule its alarms. */
589
683
  export interface AlarmRegistration<Self, R = never, E = never> {
590
- readonly layer: Layer.Layer<Self, never, DurableObjectAlarm>;
684
+ readonly layer: Layer.Layer<Self, InvalidAlarmRegistrationError, DurableObjectAlarm>;
591
685
  readonly run: Effect.Effect<
592
686
  ProcessDueAlarmsResult,
593
687
  E | DurableObjectAlarmError,
594
688
  R | DurableObjectAlarm
595
689
  >;
690
+ readonly [RegistrationParts]?: RegistrationParts<R, E>;
691
+ }
692
+
693
+ const RegistrationParts: unique symbol = Symbol("effect-cf/DurableObjectAlarm/RegistrationParts");
694
+
695
+ interface AlarmDispatcher<R, E> {
696
+ readonly tags: readonly string[];
697
+ readonly automatic: boolean;
698
+ readonly run: AlarmRegistration<never, R, E>["run"];
699
+ }
700
+
701
+ interface RegistrationParts<R, E> {
702
+ readonly services: readonly string[];
703
+ readonly dispatchers: readonly AlarmDispatcher<R, E>[];
704
+ readonly invalid?: string;
705
+ }
706
+
707
+ const emptyResult: ProcessDueAlarmsResult = { failed: [], handled: [], parked: [] };
708
+
709
+ const missingAlarm = (tag: string) =>
710
+ new InvalidAlarmRegistrationError({
711
+ cause: new Error(`No manual handler registered for alarm tag "${tag}"`),
712
+ });
713
+
714
+ const validateRegistration = <R, E>(parts: RegistrationParts<R, E>) =>
715
+ Effect.try({
716
+ try: () => {
717
+ if (parts.invalid !== undefined) throw new Error(parts.invalid);
718
+ const tags = parts.dispatchers.flatMap((dispatcher) => dispatcher.tags);
719
+
720
+ S.decodeUnknownSync(S.Array(S.NonEmptyString))([...parts.services, ...tags]);
721
+ if (
722
+ new Set(parts.services).size !== parts.services.length ||
723
+ new Set(tags).size !== tags.length
724
+ ) {
725
+ throw new Error(
726
+ "Alarm service keys and definition tags must be unique within a Durable Object",
727
+ );
728
+ }
729
+ },
730
+ catch: (cause) => new InvalidAlarmRegistrationError({ cause }),
731
+ });
732
+
733
+ const runRegistrations = <R, E>(
734
+ parts: RegistrationParts<R, E>,
735
+ rawAlarm?: Effect.Effect<void, E, R>,
736
+ ): AlarmRegistration<never, R, E>["run"] =>
737
+ Effect.gen(function* () {
738
+ yield* validateRegistration(parts);
739
+ const alarms = yield* DurableObjectAlarm;
740
+ const permits = yield* Semaphore.make(4);
741
+ const parentRaw = (yield* CurrentDispatch)?.raw;
742
+ const recoveryScope = {
743
+ tags: parts.dispatchers.flatMap((dispatcher) => dispatcher.tags),
744
+ exclude: true,
745
+ permits,
746
+ raw: parentRaw,
747
+ };
748
+ const recovery =
749
+ rawAlarm === undefined
750
+ ? makeDefinition({})
751
+ .handlers({})
752
+ .pipe(Effect.provideService(CurrentDispatch, recoveryScope))
753
+ : Effect.suspend(() => {
754
+ const raw = { processed: false };
755
+
756
+ return rawAlarm.pipe(
757
+ Effect.onExit(() =>
758
+ raw.processed ? Effect.void : makeDefinition({}).handlers({}).pipe(Effect.asVoid),
759
+ ),
760
+ Effect.provideService(CurrentDispatch, { ...recoveryScope, raw }),
761
+ Effect.as(emptyResult),
762
+ );
763
+ });
764
+
765
+ return yield* alarms.deferWakes(
766
+ Effect.gen(function* () {
767
+ const run = (dispatchers: RegistrationParts<R, E>["dispatchers"]) =>
768
+ Effect.forEach(
769
+ dispatchers,
770
+ (dispatcher) =>
771
+ dispatcher.run.pipe(
772
+ Effect.catchTag("InvalidProcessDueAlarmsOptionsError", (error) =>
773
+ Effect.fail(error).pipe(
774
+ Effect.onExit(() => makeDefinition({}).handlers({}).pipe(Effect.asVoid)),
775
+ ),
776
+ ),
777
+ Effect.provideService(CurrentDispatch, {
778
+ tags: dispatcher.tags,
779
+ exclude: false,
780
+ permits,
781
+ raw: parentRaw,
782
+ }),
783
+ Effect.exit,
784
+ ),
785
+ { concurrency: "unbounded" },
786
+ );
787
+ // Raw hooks wait only for automatic dispatchers; manual-only work stays independent.
788
+ const automatic = parts.dispatchers.filter(
789
+ (dispatcher) => rawAlarm !== undefined && dispatcher.automatic,
790
+ );
791
+ const independent = parts.dispatchers.filter(
792
+ (dispatcher) => rawAlarm === undefined || !dispatcher.automatic,
793
+ );
794
+ const exits = (yield* Effect.all(
795
+ [
796
+ run(independent),
797
+ run(automatic).pipe(
798
+ Effect.flatMap((exits) =>
799
+ Effect.map(Effect.exit(recovery), (exit) => [...exits, exit]),
800
+ ),
801
+ ),
802
+ ],
803
+ { concurrency: "unbounded" },
804
+ )).flat();
805
+
806
+ yield* Exit.asVoidAll(exits);
807
+
808
+ return {
809
+ failed: exits.flatMap((exit) => (Exit.isSuccess(exit) ? exit.value.failed : [])),
810
+ handled: exits.flatMap((exit) => (Exit.isSuccess(exit) ? exit.value.handled : [])),
811
+ parked: exits.flatMap((exit) => (Exit.isSuccess(exit) ? exit.value.parked : [])),
812
+ };
813
+ }),
814
+ );
815
+ });
816
+
817
+ const makeRegistration = <Self, R, E>(
818
+ layer: AlarmRegistration<Self>["layer"],
819
+ parts: RegistrationParts<R, E>,
820
+ ): AlarmRegistration<Self, R, E> => ({
821
+ [RegistrationParts]: parts,
822
+ layer: layer.pipe(Layer.provide(Layer.effectDiscard(validateRegistration(parts)))),
823
+ run: runRegistrations(parts),
824
+ });
825
+
826
+ /** Compose independent typed alarm registrations under one native alarm owner. */
827
+ export function mergeAll<
828
+ const Registrations extends readonly [
829
+ AlarmRegistration<never, unknown, unknown>,
830
+ ...AlarmRegistration<never, unknown, unknown>[],
831
+ ],
832
+ >(
833
+ ...registrations: Registrations
834
+ ): AlarmRegistration<
835
+ Layer.Success<Registrations[number]["layer"]>,
836
+ Exclude<Effect.Services<Registrations[number]["run"]>, DurableObjectAlarm>,
837
+ Exclude<Effect.Error<Registrations[number]["run"]>, DurableObjectAlarmError>
838
+ >;
839
+ export function mergeAll(
840
+ registration: AlarmRegistration<never, unknown, unknown>,
841
+ ...others: readonly AlarmRegistration<never, unknown, unknown>[]
842
+ ): AlarmRegistration<never, unknown, unknown> {
843
+ const registrations = [registration, ...others];
844
+ const parts = registrations.map((item) => item[RegistrationParts]);
845
+
846
+ return makeRegistration(Layer.mergeAll(registration.layer, ...others.map((item) => item.layer)), {
847
+ services: parts.flatMap((part) => part?.services ?? []),
848
+ dispatchers: parts.flatMap((part) => part?.dispatchers ?? []),
849
+ invalid: parts.some((part) => part === undefined)
850
+ ? "mergeAll requires registrations returned by Tag.handlers"
851
+ : parts.find((part) => part?.invalid !== undefined)?.invalid,
852
+ });
596
853
  }
597
854
 
598
855
  export interface AlarmTagClass<
@@ -654,7 +911,11 @@ const makeDefinition = <const Definitions extends AlarmDefinitions>(definitions:
654
911
  Effect.mapError((cause) => new InvalidAlarmPayloadError({ cause })),
655
912
  );
656
913
 
657
- yield* mutations[method]({ ...input, payload });
914
+ yield* mutations[method]({
915
+ ...input,
916
+ payload,
917
+ lifecycle: isAlarmDefinitionConfig(definition) ? definition.lifecycle : undefined,
918
+ });
658
919
  });
659
920
 
660
921
  return {
@@ -680,6 +941,7 @@ const makeDefinition = <const Definitions extends AlarmDefinitions>(definitions:
680
941
  make: (alarms: AlarmScheduler): DefinedAlarmScheduler<Definitions> => ({
681
942
  [TypedAlarmSchedulerTypeId]: TypedAlarmSchedulerTypeId,
682
943
  ...bind(alarms),
944
+ deferWakes: alarms.deferWakes,
683
945
  getAlarmStatus: (input) => alarms.getAlarmStatus(input),
684
946
  transaction: (closure) => alarms.transaction((tx) => closure(bind(tx))),
685
947
  }),
@@ -724,6 +986,14 @@ const makeDefinition = <const Definitions extends AlarmDefinitions>(definitions:
724
986
  }),
725
987
  {
726
988
  ...options,
989
+ [ManualTags]: new Set(
990
+ Object.entries(definitions)
991
+ .filter(
992
+ ([, definition]) =>
993
+ isAlarmDefinitionConfig(definition) && definition.lifecycle === "manual",
994
+ )
995
+ .map(([tag]) => tag),
996
+ ),
727
997
  onFailure: (failure) =>
728
998
  Effect.gen(function* () {
729
999
  const action = getAlarmDefinitionFailureAction(definitionFor(failure.tag));
@@ -742,6 +1012,46 @@ export const define = <const Definitions extends AlarmDefinitions>(definitions:
742
1012
  handlers: makeDefinition(definitions).handlers,
743
1013
  });
744
1014
 
1015
+ const hasSchedulerTables = Effect.fnUntraced(function* (state: DurableObjectState["Service"]) {
1016
+ return yield* Effect.gen(function* () {
1017
+ if (state.raw.storage.sql === undefined) return false;
1018
+ // Inspect without creating tables: raw-only SQLite and KV objects retain native ownership.
1019
+ const tables = yield* state.storage.sql.exec(
1020
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1",
1021
+ "effect_cf_scheduled_alarms",
1022
+ );
1023
+
1024
+ return (yield* tables.toArray()).length > 0;
1025
+ }).pipe(
1026
+ Effect.catchIf(
1027
+ (error) =>
1028
+ error.cause instanceof Error &&
1029
+ (error.cause.message.startsWith("SQL is not enabled for this Durable Object class") ||
1030
+ error.cause.message.startsWith("This Durable Object is not backed by SQLite storage")),
1031
+ () => Effect.succeed(false),
1032
+ ),
1033
+ );
1034
+ });
1035
+
1036
+ /** @internal Preserve custom dispatch while recovering retained alarms after deployments. */
1037
+ export const dispatchRawAlarm = <Self, E, R>(
1038
+ rawAlarm: Effect.Effect<void, E, R>,
1039
+ registration?: AlarmRegistration<Self, R, E>,
1040
+ ): Effect.Effect<void, E | DurableObjectAlarmError, R | DurableObjectAlarm | DurableObjectState> =>
1041
+ Effect.gen(function* () {
1042
+ const parts = registration?.[RegistrationParts];
1043
+
1044
+ if (parts !== undefined) return yield* runRegistrations(parts, rawAlarm).pipe(Effect.asVoid);
1045
+ const custom =
1046
+ registration === undefined ? rawAlarm : registration.run.pipe(Effect.andThen(rawAlarm));
1047
+ const state = yield* DurableObjectState;
1048
+ const hasScheduler = yield* hasSchedulerTables(state);
1049
+
1050
+ if (!hasScheduler) return yield* custom;
1051
+
1052
+ return yield* runRegistrations({ services: [], dispatchers: [] }, custom).pipe(Effect.asVoid);
1053
+ });
1054
+
745
1055
  /** The typed scheduler becomes available when its handlers are registered on a Durable Object. */
746
1056
  export const Tag =
747
1057
  <Self>() =>
@@ -756,10 +1066,19 @@ export const Tag =
756
1066
  handlers: <R = never, E = never>(
757
1067
  handlers: DefinedAlarmHandlers<Definitions, R, E>,
758
1068
  options?: ProcessDueAlarmsOptions<R, E>,
759
- ): AlarmRegistration<Self, R, E> => ({
760
- layer: Layer.effect(tag, Effect.map(DurableObjectAlarm, definition.make)),
761
- run: definition.handlers(handlers, options),
762
- }),
1069
+ ): AlarmRegistration<Self, R, E> =>
1070
+ makeRegistration(Layer.effect(tag, Effect.map(DurableObjectAlarm, definition.make)), {
1071
+ services: [id],
1072
+ dispatchers: [
1073
+ {
1074
+ tags: Object.keys(definitions),
1075
+ automatic: Object.values(definitions).some(
1076
+ (entry) => !isAlarmDefinitionConfig(entry) || entry.lifecycle !== "manual",
1077
+ ),
1078
+ run: definition.handlers(handlers, options),
1079
+ },
1080
+ ],
1081
+ }),
763
1082
  });
764
1083
  };
765
1084
 
@@ -771,6 +1090,13 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
771
1090
  Effect.gen(function* () {
772
1091
  const state = yield* DurableObjectState;
773
1092
  const configurationDefaults = { ...(yield* ScheduleConfiguration) };
1093
+ let wakeDeferrals = 0;
1094
+ let wakesReady: Deferred.Deferred<void> | undefined;
1095
+ let pendingAlarm: Deferred.Deferred<void, unknown> | undefined;
1096
+ let dispatchActive = false;
1097
+ let dispatchNeedsReconcile = false;
1098
+ let deferredRecoveryDelay = PARKED_RETRY_DELAY_MS;
1099
+ let deferredRecoveryAt = 0;
774
1100
 
775
1101
  const readRow = Effect.fnUntraced(function* (storageId: string) {
776
1102
  const cursor = yield* state.storage.sql.exec<AlarmRow>(
@@ -781,25 +1107,43 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
781
1107
  return (yield* cursor.toArray())[0];
782
1108
  });
783
1109
 
784
- const reconcileAlarm = Effect.fn("DurableObjectAlarm.reconcileAlarm")(function* () {
785
- const cursor = yield* state.storage.sql.exec<NextAlarmRow>(
786
- `SELECT wake_at AS run_at FROM effect_cf_scheduled_alarms
787
- ORDER BY wake_at, storage_id LIMIT 1`,
788
- );
789
- const next = (yield* cursor.toArray())[0];
790
-
791
- // The scheduler is the sole alarm writer. Raw storage is confined to this boundary.
792
- yield* Effect.tryPromise({
1110
+ // The scheduler is the sole alarm writer. Raw storage is confined to this boundary.
1111
+ const writeNativeAlarm = (runAt: number | null) =>
1112
+ Effect.tryPromise({
793
1113
  try: () =>
794
- next === undefined
795
- ? state.raw.storage.deleteAlarm()
796
- : state.raw.storage.setAlarm(next.run_at),
1114
+ runAt === null ? state.raw.storage.deleteAlarm() : state.raw.storage.setAlarm(runAt),
797
1115
  catch: (cause) =>
798
1116
  new StorageOperationError({
799
- operation: next === undefined ? "deleteAlarm" : "setAlarm",
1117
+ operation: runAt === null ? "deleteAlarm" : "setAlarm",
800
1118
  cause,
801
1119
  }),
802
1120
  });
1121
+
1122
+ const reconcileAlarm = Effect.fn("DurableObjectAlarm.reconcileAlarm")(function* () {
1123
+ if (wakeDeferrals > 0 || dispatchActive) {
1124
+ if (dispatchActive) dispatchNeedsReconcile = true;
1125
+ const current = yield* state.storage.getAlarm();
1126
+ const now = yield* Clock.currentTimeMillis;
1127
+
1128
+ if (deferredRecoveryAt <= now) {
1129
+ deferredRecoveryAt = now + deferredRecoveryDelay;
1130
+ }
1131
+
1132
+ // Commit the guard before user work. Later transactions keep it even when
1133
+ // logical deadlines move earlier or every checkpoint is cancelled.
1134
+ if (current === null || current <= now || current > deferredRecoveryAt) {
1135
+ yield* writeNativeAlarm(deferredRecoveryAt);
1136
+ }
1137
+
1138
+ return;
1139
+ }
1140
+ const cursor = yield* state.storage.sql.exec<NextAlarmRow>(
1141
+ `SELECT wake_at AS run_at FROM effect_cf_scheduled_alarms
1142
+ ORDER BY wake_at, storage_id LIMIT 1`,
1143
+ );
1144
+ const next = (yield* cursor.toArray())[0];
1145
+
1146
+ yield* writeNativeAlarm(next?.run_at ?? null);
803
1147
  });
804
1148
 
805
1149
  const writeAttempts = Effect.fnUntraced(function* (
@@ -851,6 +1195,20 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
851
1195
  ) {
852
1196
  const configuration = yield* getScheduleConfiguration(configurationDefaults);
853
1197
  const ref = yield* decodeAlarmRef(input);
1198
+ const lifecycle = yield* S.decodeUnknownEffect(AlarmLifecycleSchema)(
1199
+ input.lifecycle ?? "automatic",
1200
+ ).pipe(Effect.mapError((cause) => new InvalidAlarmRefError({ cause })));
1201
+
1202
+ if (
1203
+ lifecycle === "manual" &&
1204
+ (input.repeatEvery !== undefined || input.progress !== undefined)
1205
+ ) {
1206
+ return yield* Effect.fail(
1207
+ new InvalidAlarmRefError({
1208
+ cause: new Error("Manual alarms own their repeats and progress budgets"),
1209
+ }),
1210
+ );
1211
+ }
854
1212
  const repeatEveryMillis = yield* toRepeatEveryMillis(
855
1213
  input.repeatEvery,
856
1214
  configuration.minimumRepeatInterval,
@@ -859,19 +1217,10 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
859
1217
  const storageId = getScheduledEventId(ref);
860
1218
  const existing = yield* readRow(storageId);
861
1219
  const pass = yield* CurrentAlarmPass;
862
- const source = pass?.row;
1220
+ const source =
1221
+ lifecycle === "automatic" && pass?.row.lifecycle === "automatic" ? pass.row : undefined;
863
1222
  const progress = existing?.progress ?? source?.progress ?? -1;
864
1223
 
865
- if (pass !== undefined && !pass.active) {
866
- return yield* Effect.fail(
867
- new StorageOperationError({
868
- operation: "alarm.schedule",
869
- cause: new Error(
870
- "Alarm handlers cannot schedule detached work after their pass ends",
871
- ),
872
- }),
873
- );
874
- }
875
1224
  if (input.progress !== undefined) {
876
1225
  yield* S.decodeUnknownEffect(S.Natural)(input.progress).pipe(
877
1226
  Effect.mapError((cause) => new InvalidAlarmRefError({ cause })),
@@ -907,16 +1256,22 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
907
1256
  const keepEarlier = earlier && existing !== undefined && existing.run_at <= runAt;
908
1257
  const scheduledAt = keepEarlier ? existing.run_at : runAt;
909
1258
  const scheduledPayload = keepEarlier ? existing.payload : payload;
910
- const scheduledRepeat = keepEarlier ? existing.repeat_every_ms : repeatEveryMillis;
911
- const nextProgress = Math.max(progress, input.progress ?? -1);
1259
+ const scheduledRepeat =
1260
+ lifecycle === "manual"
1261
+ ? null
1262
+ : keepEarlier
1263
+ ? existing.repeat_every_ms
1264
+ : repeatEveryMillis;
1265
+ const nextProgress = lifecycle === "manual" ? -1 : Math.max(progress, input.progress ?? -1);
912
1266
 
913
1267
  yield* state.storage.sql.exec(
914
1268
  `INSERT OR REPLACE INTO effect_cf_scheduled_alarms
915
- (storage_id, alarm_id, tag, run_at, repeat_every_ms, payload)
916
- VALUES (?, ?, ?, ?, ?, ?)`,
1269
+ (storage_id, alarm_id, tag, lifecycle, run_at, repeat_every_ms, payload)
1270
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
917
1271
  storageId,
918
1272
  ref.id,
919
1273
  ref.tag,
1274
+ lifecycle,
920
1275
  scheduledAt,
921
1276
  scheduledRepeat,
922
1277
  scheduledPayload,
@@ -949,13 +1304,24 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
949
1304
  });
950
1305
 
951
1306
  const transaction: AlarmScheduler["transaction"] = Effect.fnUntraced(function* (closure) {
1307
+ const pass = yield* CurrentAlarmPass;
1308
+
1309
+ if (pass !== undefined && !pass.active) {
1310
+ return yield* Effect.fail(
1311
+ new StorageOperationError({
1312
+ operation: "alarm.transaction",
1313
+ cause: new Error("Alarm handlers cannot mutate detached work after their pass ends"),
1314
+ }),
1315
+ );
1316
+ }
952
1317
  const reports: string[] = [];
953
1318
  const result = yield* state.storage.transaction(() =>
954
1319
  Effect.withFiber((owner) => {
955
1320
  let active = true;
956
1321
  const requireActive = <A, E>(effect: Effect.Effect<A, E>) =>
957
1322
  Effect.withFiber<A, E | StorageOperationError>((fiber) =>
958
- active && fiber === owner
1323
+ // A queued or suspended transaction can outlive the handler that admitted it.
1324
+ active && fiber === owner && (pass === undefined || pass.active)
959
1325
  ? effect
960
1326
  : Effect.fail(
961
1327
  new StorageOperationError({
@@ -996,6 +1362,107 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
996
1362
  return result;
997
1363
  });
998
1364
 
1365
+ const deferWakes: AlarmScheduler["deferWakes"] = (body) =>
1366
+ Effect.acquireUseRelease(
1367
+ Effect.gen(function* () {
1368
+ const configuration = yield* getScheduleConfiguration(configurationDefaults);
1369
+ const now = yield* Clock.currentTimeMillis;
1370
+
1371
+ if (wakeDeferrals === 0) {
1372
+ deferredRecoveryDelay = configuration.parkedRetryDelay;
1373
+ deferredRecoveryAt = now + deferredRecoveryDelay;
1374
+ wakesReady ??= Deferred.makeUnsafe<void>();
1375
+ }
1376
+ wakeDeferrals++;
1377
+ }),
1378
+ () => transaction(() => Effect.void).pipe(Effect.andThen(body)),
1379
+ () =>
1380
+ Effect.gen(function* () {
1381
+ wakeDeferrals--;
1382
+ if (wakeDeferrals === 0) {
1383
+ yield* (dispatchActive ? Effect.void : transaction(() => Effect.void)).pipe(
1384
+ Effect.ensuring(
1385
+ Effect.sync(() => {
1386
+ if (wakeDeferrals === 0 && wakesReady !== undefined) {
1387
+ Deferred.doneUnsafe(wakesReady, Effect.void);
1388
+ wakesReady = undefined;
1389
+ }
1390
+ }),
1391
+ ),
1392
+ );
1393
+ }
1394
+ }),
1395
+ );
1396
+
1397
+ const awaitWakes: Effect.Effect<void> = Effect.suspend(() =>
1398
+ wakesReady === undefined
1399
+ ? Effect.void
1400
+ : Deferred.await(wakesReady).pipe(Effect.andThen(awaitWakes)),
1401
+ );
1402
+ const runAlarm: AlarmScheduler[typeof RunAlarm] = (event, native) =>
1403
+ Effect.flatMap(hasSchedulerTables(state), (managed) =>
1404
+ !managed
1405
+ ? native
1406
+ ? event(Effect.void)
1407
+ : Effect.void
1408
+ : Effect.uninterruptibleMask((restore) =>
1409
+ Effect.suspend(() => {
1410
+ const pending = pendingAlarm;
1411
+
1412
+ if (pending !== undefined) {
1413
+ // A native delivery consumed its timestamp. Restore recovery before joining.
1414
+ return restore(
1415
+ (native ? transaction(() => Effect.void) : Effect.void).pipe(
1416
+ Effect.andThen(Deferred.await(pending)),
1417
+ ),
1418
+ );
1419
+ }
1420
+ const completed = Deferred.makeUnsafe<void, unknown>();
1421
+ let dispatchStarted = false;
1422
+
1423
+ pendingAlarm = completed;
1424
+ dispatchNeedsReconcile = native;
1425
+
1426
+ return restore(
1427
+ Effect.gen(function* () {
1428
+ // Native-first delivery can consume an inline region's guard before
1429
+ // any prompt pass exists to join. Restore it while waiting for exit.
1430
+ if (native && wakesReady !== undefined) yield* transaction(() => Effect.void);
1431
+ yield* awaitWakes;
1432
+ dispatchActive = true;
1433
+ yield* event(
1434
+ Effect.gen(function* () {
1435
+ yield* getScheduleConfiguration(configurationDefaults);
1436
+ dispatchStarted = true;
1437
+ }),
1438
+ );
1439
+ }),
1440
+ ).pipe(
1441
+ Effect.onExit((exit) =>
1442
+ Effect.gen(function* () {
1443
+ // The event includes its scope finalizers. Late enrollments cannot be
1444
+ // swallowed by a native delivery that joined after the final checkpoint.
1445
+ // Acquisition failure never reached a dispatcher: keep a future guard
1446
+ // instead of rearming untouched due rows into an immediate retry loop.
1447
+ const reconcile =
1448
+ dispatchNeedsReconcile || dispatchStarted || Exit.isFailure(exit);
1449
+
1450
+ dispatchActive = Exit.isFailure(exit) && !dispatchStarted;
1451
+ const reconciled = yield* (
1452
+ reconcile ? transaction(() => Effect.void) : Effect.void
1453
+ ).pipe(Effect.exit);
1454
+
1455
+ dispatchActive = false;
1456
+ pendingAlarm = undefined;
1457
+ yield* Deferred.done(completed, Exit.asVoidAll([exit, reconciled]));
1458
+ yield* reconciled;
1459
+ }),
1460
+ ),
1461
+ );
1462
+ }),
1463
+ ),
1464
+ );
1465
+
999
1466
  const rescheduleFailedAlarm = Effect.fnUntraced(function* (
1000
1467
  row: AlarmRow,
1001
1468
  initialDelay: number,
@@ -1072,14 +1539,50 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1072
1539
  const limit = yield* getProcessLimit(options);
1073
1540
  const initialDelay = yield* getFailureRetryDelay(options, configuration);
1074
1541
  const now = yield* Clock.currentTimeMillis;
1542
+ const dispatch = yield* CurrentDispatch;
1543
+
1544
+ // A validated dispatcher owns its remaining batch, including newly enrolled work.
1545
+ if (dispatch?.raw !== undefined) dispatch.raw.processed = true;
1546
+
1547
+ const tagFilter =
1548
+ dispatch === undefined
1549
+ ? ""
1550
+ : ` AND a.tag ${dispatch.exclude ? "NOT IN" : "IN"} (SELECT value FROM json_each(?))`;
1551
+ const tagBindings =
1552
+ dispatch === undefined ? [] : [S.encodeSync(StoredTags)([...dispatch.tags])];
1553
+ const select = Effect.fnUntraced(function* (lifecycle: AlarmLifecycle, batchLimit: number) {
1554
+ const cursor = yield* state.storage.sql.exec<AlarmRow>(
1555
+ `${alarmRowsSql} WHERE a.lifecycle = ? AND a.wake_at <= ?${tagFilter}
1556
+ ORDER BY a.wake_at ASC, a.storage_id ASC LIMIT ?`,
1557
+ lifecycle,
1558
+ now,
1559
+ ...tagBindings,
1560
+ batchLimit,
1561
+ );
1075
1562
 
1076
- const cursor = yield* state.storage.sql.exec<AlarmRow>(
1077
- `${alarmRowsSql}
1078
- WHERE a.wake_at <= ? ORDER BY a.wake_at ASC, a.storage_id ASC LIMIT ?`,
1079
- now,
1080
- limit,
1081
- );
1082
- const dueRows = yield* cursor.toArray();
1563
+ return yield* cursor.toArray();
1564
+ });
1565
+ // Manual recovery never consumes the raw/automatic dispatcher's batch allowance.
1566
+ const dueRows = yield* select("automatic", limit);
1567
+ const manualRows = yield* select("manual", DEFAULT_PROCESS_DUE_ALARMS_LIMIT);
1568
+
1569
+ if (manualRows.length > 0) {
1570
+ yield* transaction(() =>
1571
+ Effect.gen(function* () {
1572
+ for (const row of manualRows) {
1573
+ // Keep the selected revision: a racing replacement must survive this guard.
1574
+ yield* state.storage.sql.exec(
1575
+ `UPDATE effect_cf_scheduled_alarms SET run_at = ?, wake_at = ? WHERE ${sameRevisionSql}`,
1576
+ now + configuration.parkedRetryDelay,
1577
+ now + configuration.parkedRetryDelay,
1578
+ row.storage_id,
1579
+ row.storage_id,
1580
+ row.revision,
1581
+ );
1582
+ }
1583
+ }),
1584
+ );
1585
+ }
1083
1586
  const handled: DurableObjectAlarmEvent[] = [];
1084
1587
  const failed: ProcessDueAlarmsFailure[] = [];
1085
1588
  const parked: AlarmParked[] = [];
@@ -1128,12 +1631,10 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1128
1631
  }
1129
1632
  });
1130
1633
 
1131
- for (const row of dueRows) {
1634
+ const handleRow = Effect.fnUntraced(function* (row: AlarmRow) {
1132
1635
  const current = yield* readRow(row.storage_id);
1133
1636
 
1134
- if (current === undefined || current.revision !== row.revision) {
1135
- continue;
1136
- }
1637
+ if (current === undefined || current.revision !== row.revision) return;
1137
1638
  const inPass = <A, E, R>(effect: Effect.Effect<A, E, R>) => {
1138
1639
  const pass = { row, parked, active: true };
1139
1640
 
@@ -1146,28 +1647,69 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1146
1647
  ),
1147
1648
  );
1148
1649
  };
1650
+ const manual = row.lifecycle === "manual";
1651
+
1652
+ if (manual && !options?.[ManualTags]?.has(row.tag)) {
1653
+ return yield* Effect.fail(missingAlarm(row.tag));
1654
+ }
1149
1655
  const eventExit = yield* Effect.exit(toAlarmDue(row));
1150
1656
 
1151
1657
  if (Exit.isFailure(eventExit)) {
1152
- yield* inPass(handleFailure(row, undefined, eventExit.cause));
1153
- continue;
1658
+ if (manual) return yield* Effect.failCause(eventExit.cause);
1659
+
1660
+ return yield* inPass(handleFailure(row, undefined, eventExit.cause));
1154
1661
  }
1155
1662
  const event = eventExit.value;
1156
1663
  const handleExit = yield* Effect.exit(inPass(Effect.suspend(() => handle(event))));
1157
1664
 
1158
1665
  if (Exit.isFailure(handleExit)) {
1159
- yield* inPass(handleFailure(row, event, handleExit.cause));
1160
- continue;
1666
+ if (manual) return yield* Effect.failCause(handleExit.cause);
1667
+
1668
+ return yield* inPass(handleFailure(row, event, handleExit.cause));
1161
1669
  }
1162
- yield* transaction(() => acknowledgeAlarm(row, configuration));
1670
+ if (!manual) yield* transaction(() => acknowledgeAlarm(row, configuration));
1163
1671
  handled.push(event);
1164
- }
1672
+ });
1673
+ const permits = dispatch?.permits ?? (yield* Semaphore.make(4));
1674
+ const automatic = Effect.forEach(dueRows, handleRow, { discard: true }).pipe(Effect.exit);
1675
+ const exits = yield* manualRows.length === 0
1676
+ ? automatic.pipe(Effect.map((exit) => [exit]))
1677
+ : Effect.all(
1678
+ [
1679
+ automatic,
1680
+ Effect.forEach(
1681
+ manualRows,
1682
+ (row) => permits.withPermits(1)(handleRow(row)).pipe(Effect.exit),
1683
+ { concurrency: 4 },
1684
+ ).pipe(Effect.flatMap(Exit.asVoidAll), Effect.exit),
1685
+ ],
1686
+ { concurrency: 2 },
1687
+ );
1688
+
1165
1689
  yield* transaction(() => Effect.void);
1690
+ yield* Exit.asVoidAll(exits);
1166
1691
 
1167
1692
  return { failed, handled, parked };
1168
1693
  });
1169
1694
 
1170
1695
  return DurableObjectAlarm.of({
1696
+ [RunAlarm]: runAlarm,
1697
+ [DispatchAfterEvent]: Effect.map(
1698
+ ScheduleConfiguration,
1699
+ (configuration) =>
1700
+ (configuration.dispatchAfterEvent ?? configurationDefaults.dispatchAfterEvent) === true,
1701
+ ),
1702
+ [HasDueAlarms]: Effect.gen(function* () {
1703
+ yield* ensureTable(state);
1704
+ const now = yield* Clock.currentTimeMillis;
1705
+ const cursor = yield* state.storage.sql.exec(
1706
+ "SELECT 1 FROM effect_cf_scheduled_alarms WHERE wake_at <= ? LIMIT 1",
1707
+ now,
1708
+ );
1709
+
1710
+ return (yield* cursor.toArray()).length > 0;
1711
+ }),
1712
+ deferWakes,
1171
1713
  cancelAlarm: (input) => transaction((alarms) => alarms.cancelAlarm(input)),
1172
1714
  getAlarmStatus: Effect.fnUntraced(function* (input) {
1173
1715
  const ref = yield* decodeAlarmRef(input);