effect-cf 0.53.0 → 0.55.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
 
@@ -42,6 +46,7 @@ export const MIN_RETRY_DELAY_MS = 1_000;
42
46
  export const UNCHANGED_ATTEMPT_BUDGET = 8;
43
47
  /** Minimum and default parked recovery interval. */
44
48
  export const PARKED_RETRY_DELAY_MS = 3_600_000;
49
+ const DEFAULT_IN_FLIGHT_RECOVERY_MS = 1_000;
45
50
 
46
51
  /** Absolute product repeat floor. */
47
52
  export const MIN_REPEAT_INTERVAL_MS = 1_000;
@@ -49,6 +54,10 @@ export const MIN_REPEAT_INTERVAL_MS = 1_000;
49
54
  export const DEFAULT_MIN_REPEAT_INTERVAL_MS = 60_000;
50
55
 
51
56
  export interface ScheduleConfiguration {
57
+ /** Check for due alarms after RPC/fetch responses unwind. Native alarms remain recovery authority. */
58
+ readonly dispatchAfterEvent?: boolean;
59
+ /** Positive recovery deadline while dispatch or deferWakes is active. Defaults to one second. */
60
+ readonly inFlightRecovery?: Duration.Input;
52
61
  /** Retry floor, at least one second. Defaults to one second. */
53
62
  readonly minimumRetryDelay?: Duration.Input;
54
63
  /** Positive safe integer. Defaults to eight unchanged attempts. */
@@ -68,7 +77,14 @@ export const ScheduleConfiguration = Context.Reference<ScheduleConfiguration>(
68
77
  const safeIntegerAtLeast = (minimum: number) =>
69
78
  S.Int.check(S.isGreaterThanOrEqualTo(minimum), S.isLessThanOrEqualTo(Number.MAX_SAFE_INTEGER));
70
79
 
80
+ /** Automatic alarms acknowledge/retry on handler exit; manual alarms retain consumer control. */
81
+ export type AlarmLifecycle = "automatic" | "manual";
82
+
83
+ const AlarmLifecycleSchema = S.Literals(["automatic", "manual"]);
84
+
71
85
  const ScheduleConfigurationSchema = S.Struct({
86
+ dispatchAfterEvent: S.Boolean,
87
+ inFlightRecovery: safeIntegerAtLeast(1),
72
88
  minimumRetryDelay: safeIntegerAtLeast(MIN_RETRY_DELAY_MS),
73
89
  unchangedAttemptBudget: safeIntegerAtLeast(1),
74
90
  parkedRetryDelay: safeIntegerAtLeast(PARKED_RETRY_DELAY_MS),
@@ -99,6 +115,7 @@ const getScheduledEventId = (input: { readonly id: string; readonly tag: string
99
115
  export type AlarmPayload = S.Json;
100
116
 
101
117
  interface AlarmRow extends Record<string, SqlStorageValue> {
118
+ readonly lifecycle: AlarmLifecycle;
102
119
  readonly attempts: number;
103
120
  readonly alarm_id: string;
104
121
  readonly payload: string;
@@ -140,6 +157,18 @@ const CurrentAlarmPass = Context.Reference<
140
157
  { readonly row: AlarmRow; readonly parked: AlarmParked[]; active: boolean } | undefined
141
158
  >("effect-cf/DurableObjectAlarm/CurrentAlarmPass", { defaultValue: () => undefined });
142
159
 
160
+ /** One dispatcher's view of a registration pass: its tags, shared manual permits, raw tracking. */
161
+ const CurrentDispatch = Context.Reference<
162
+ | {
163
+ readonly tags: readonly string[];
164
+ readonly exclude: boolean;
165
+ readonly permits: Semaphore.Semaphore;
166
+ readonly raw?: { processed: boolean };
167
+ }
168
+ | undefined
169
+ >("effect-cf/DurableObjectAlarm/CurrentDispatch", { defaultValue: () => undefined });
170
+ const ManualTags: unique symbol = Symbol("effect-cf/DurableObjectAlarm/ManualTags");
171
+
143
172
  interface NextAlarmRow extends Record<string, SqlStorageValue> {
144
173
  readonly run_at: number;
145
174
  }
@@ -188,12 +217,26 @@ export class InvalidScheduleConfigurationError extends Data.TaggedError(
188
217
  }
189
218
  }
190
219
 
220
+ export class InvalidAlarmRegistrationError extends Data.TaggedError(
221
+ "InvalidAlarmRegistrationError",
222
+ )<{
223
+ readonly cause: unknown;
224
+ }> {
225
+ override get message(): string {
226
+ return `Invalid Durable Object alarm registration: ${ErrorMessage.causeMessage(this.cause)}`;
227
+ }
228
+ }
229
+
191
230
  const getScheduleConfiguration = Effect.fnUntraced(function* (defaults: ScheduleConfiguration) {
192
231
  const input = { ...defaults, ...(yield* ScheduleConfiguration) };
193
232
 
194
233
  return yield* Effect.try({
195
234
  try: () =>
196
235
  S.decodeUnknownSync(ScheduleConfigurationSchema)({
236
+ dispatchAfterEvent: input.dispatchAfterEvent ?? false,
237
+ inFlightRecovery: Math.ceil(
238
+ Duration.toMillis(input.inFlightRecovery ?? DEFAULT_IN_FLIGHT_RECOVERY_MS),
239
+ ),
197
240
  minimumRetryDelay: Math.ceil(
198
241
  Duration.toMillis(input.minimumRetryDelay ?? MIN_RETRY_DELAY_MS),
199
242
  ),
@@ -219,6 +262,7 @@ export class StoredAlarmDecodeError extends Data.TaggedError("StoredAlarmDecodeE
219
262
  }
220
263
 
221
264
  export type DurableObjectAlarmError =
265
+ | InvalidAlarmRegistrationError
222
266
  | InvalidAlarmPayloadError
223
267
  | InvalidAlarmRefError
224
268
  | InvalidProcessDueAlarmsOptionsError
@@ -258,6 +302,8 @@ const decodeAlarmRef = (input: AlarmRef) =>
258
302
  * Reusing `{tag, id}` replaces an alarm. Product repeats use the configured floor and run after completion.
259
303
  */
260
304
  export type ScheduleAlarmInput<Tag extends string = string> = AlarmRef<Tag> & {
305
+ /** Prefer selecting the lifecycle in a typed Tag definition. Defaults to automatic. */
306
+ readonly lifecycle?: AlarmLifecycle;
261
307
  readonly payload: AlarmPayload;
262
308
  readonly repeatEvery?: Duration.Input;
263
309
  readonly runAt: DateTime.Utc;
@@ -291,6 +337,7 @@ export type ProcessDueAlarmsFailureAction =
291
337
  };
292
338
 
293
339
  export interface ProcessDueAlarmsOptions<OnFailureR = never, OnFailureE = never> {
340
+ readonly [ManualTags]?: ReadonlySet<string>;
294
341
  readonly limit?: number;
295
342
  readonly onFailure?: (
296
343
  failure: ProcessDueAlarmsFailure,
@@ -318,8 +365,39 @@ export interface AlarmStatus {
318
365
  readonly retryAt: DateTime.Utc | undefined;
319
366
  }
320
367
 
368
+ /** @internal */
369
+ export const RunAlarm: unique symbol = Symbol("effect-cf/DurableObjectAlarm/RunAlarm");
370
+ /** @internal */
371
+ export const DispatchAfterEvent: unique symbol = Symbol(
372
+ "effect-cf/DurableObjectAlarm/DispatchAfterEvent",
373
+ );
374
+ /** @internal */
375
+ export const HasDueAlarms: unique symbol = Symbol("effect-cf/DurableObjectAlarm/HasDueAlarms");
376
+
321
377
  /** Own `storage.setAlarm()` exclusively: a Durable Object has one platform alarm timestamp. */
322
378
  export type AlarmScheduler = {
379
+ /** @internal DurableObject.make shares this scoped event across native and post-event dispatch. */
380
+ readonly [RunAlarm]: <R>(
381
+ event: (
382
+ beginDispatch: Effect.Effect<void, InvalidScheduleConfigurationError>,
383
+ ) => Effect.Effect<void, unknown, R>,
384
+ native: boolean,
385
+ ) => Effect.Effect<void, unknown, R>;
386
+ /** @internal Read the current event's opt-in without acquiring another event layer. */
387
+ readonly [DispatchAfterEvent]: Effect.Effect<boolean>;
388
+ /** @internal Check inside the alarm event so its clock and configuration apply. */
389
+ readonly [HasDueAlarms]: Effect.Effect<boolean, StorageOperationError>;
390
+ /**
391
+ * Keep a native recovery alarm armed while coalescing checkpoint changes across this
392
+ * object's concurrent and nested scopes. Source/checkpoint transactions still commit;
393
+ * the last scope reconciles their current deadlines on success, failure or interruption.
394
+ * Run outside transactions, around bounded maintenance or inline work. The first scope's
395
+ * inFlightRecovery bounds the shared guard; renew it if it expires and is consumed while
396
+ * scopes remain active. A process loss before exit recovers at that retained alarm.
397
+ */
398
+ readonly deferWakes: <A, E, R>(
399
+ body: Effect.Effect<A, E, R>,
400
+ ) => Effect.Effect<A, E | InvalidScheduleConfigurationError | StorageOperationError, R>;
323
401
  /**
324
402
  * Commits local application storage and logical alarms in one native SQLite
325
403
  * Durable Object transaction, reconciling the native alarm before commit.
@@ -374,6 +452,7 @@ export type AlarmScheduler = {
374
452
  };
375
453
 
376
454
  const StoredPayloadString = S.fromJsonString(S.Json);
455
+ const StoredTags = S.fromJsonString(S.Array(S.String));
377
456
 
378
457
  const decodeStoredPayload = (row: AlarmRow) =>
379
458
  S.decodeUnknownEffect(StoredPayloadString)(row.payload).pipe(
@@ -389,7 +468,7 @@ const ensureTable = Effect.fnUntraced(function* (state: DurableObjectState["Serv
389
468
  yield* state.storage.sql.exec(INIT_TABLE_SQL);
390
469
  const index = yield* state.storage.sql.exec(
391
470
  "SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?",
392
- WAKE_INDEX,
471
+ LIFECYCLE_INDEX,
393
472
  );
394
473
 
395
474
  if ((yield* index.toArray()).length > 0) return;
@@ -398,6 +477,11 @@ const ensureTable = Effect.fnUntraced(function* (state: DurableObjectState["Serv
398
477
  );
399
478
  const names = new Set((yield* columns.toArray()).map((column) => column.name));
400
479
 
480
+ if (!names.has("lifecycle")) {
481
+ yield* state.storage.sql.exec(
482
+ "ALTER TABLE effect_cf_scheduled_alarms ADD COLUMN lifecycle TEXT NOT NULL DEFAULT 'automatic'",
483
+ );
484
+ }
401
485
  if (!names.has("wake_at")) {
402
486
  yield* state.storage.sql.exec(
403
487
  "ALTER TABLE effect_cf_scheduled_alarms ADD COLUMN wake_at INTEGER NOT NULL DEFAULT 0",
@@ -413,6 +497,8 @@ const ensureTable = Effect.fnUntraced(function* (state: DurableObjectState["Serv
413
497
  );
414
498
  yield* state.storage.sql.exec(`CREATE INDEX IF NOT EXISTS ${WAKE_INDEX}
415
499
  ON effect_cf_scheduled_alarms (wake_at, storage_id)`);
500
+ yield* state.storage.sql.exec(`CREATE INDEX IF NOT EXISTS ${LIFECYCLE_INDEX}
501
+ ON effect_cf_scheduled_alarms (lifecycle, wake_at, storage_id)`);
416
502
  });
417
503
 
418
504
  const toRepeatEveryMillis = (input: Duration.Input | undefined, minimum: number) => {
@@ -515,11 +601,20 @@ export interface AlarmRetryPolicy {
515
601
  readonly initialDelay?: Duration.Input;
516
602
  }
517
603
 
518
- export interface AlarmDefinitionConfig<Payload extends AlarmPayloadSchema = AlarmPayloadSchema> {
519
- readonly failure?: AlarmFailurePolicy;
604
+ export type AlarmDefinitionConfig<Payload extends AlarmPayloadSchema = AlarmPayloadSchema> = {
520
605
  readonly payload: Payload;
521
- readonly retry?: AlarmRetryPolicy;
522
- }
606
+ } & (
607
+ | {
608
+ readonly lifecycle?: "automatic";
609
+ readonly failure?: AlarmFailurePolicy;
610
+ readonly retry?: AlarmRetryPolicy;
611
+ }
612
+ | {
613
+ readonly lifecycle: "manual";
614
+ readonly failure?: never;
615
+ readonly retry?: never;
616
+ }
617
+ );
523
618
 
524
619
  export type AlarmDefinitionEntry = AlarmDefinitionConfig | AlarmPayloadSchema;
525
620
 
@@ -547,9 +642,14 @@ export type DefinedAlarmHandlers<Definitions extends AlarmDefinitions, R = never
547
642
 
548
643
  /** A discriminated union keeps each tag paired with its decoded payload type. */
549
644
  export type DefinedScheduleAlarmInput<Definitions extends AlarmDefinitions> = {
550
- readonly [Tag in keyof Definitions & string]: Omit<ScheduleAlarmInput<Tag>, "payload"> & {
645
+ readonly [Tag in keyof Definitions & string]: Omit<
646
+ ScheduleAlarmInput<Tag>,
647
+ "payload" | "lifecycle" | "repeatEvery" | "progress"
648
+ > & {
551
649
  readonly payload: AlarmDefinitionPayload<Definitions[Tag]>;
552
- };
650
+ } & (Definitions[Tag] extends { readonly lifecycle: "manual" }
651
+ ? { readonly repeatEvery?: never; readonly progress?: never }
652
+ : Pick<ScheduleAlarmInput<Tag>, "repeatEvery" | "progress">);
553
653
  }[keyof Definitions & string];
554
654
 
555
655
  export interface DefinedAlarmTransaction<Definitions extends AlarmDefinitions> {
@@ -577,6 +677,7 @@ export interface DefinedAlarmScheduler<
577
677
  Definitions extends AlarmDefinitions,
578
678
  > extends DefinedAlarmTransaction<Definitions> {
579
679
  readonly [TypedAlarmSchedulerTypeId]: typeof TypedAlarmSchedulerTypeId;
680
+ readonly deferWakes: AlarmScheduler["deferWakes"];
580
681
  readonly getAlarmStatus: (
581
682
  input: AlarmRef<keyof Definitions & string>,
582
683
  ) => ReturnType<AlarmScheduler["getAlarmStatus"]>;
@@ -587,12 +688,175 @@ export interface DefinedAlarmScheduler<
587
688
 
588
689
  /** Registers a dispatcher and the service authorized to schedule its alarms. */
589
690
  export interface AlarmRegistration<Self, R = never, E = never> {
590
- readonly layer: Layer.Layer<Self, never, DurableObjectAlarm>;
691
+ readonly layer: Layer.Layer<Self, InvalidAlarmRegistrationError, DurableObjectAlarm>;
591
692
  readonly run: Effect.Effect<
592
693
  ProcessDueAlarmsResult,
593
694
  E | DurableObjectAlarmError,
594
695
  R | DurableObjectAlarm
595
696
  >;
697
+ readonly [RegistrationParts]?: RegistrationParts<R, E>;
698
+ }
699
+
700
+ const RegistrationParts: unique symbol = Symbol("effect-cf/DurableObjectAlarm/RegistrationParts");
701
+
702
+ interface AlarmDispatcher<R, E> {
703
+ readonly tags: readonly string[];
704
+ readonly automatic: boolean;
705
+ readonly run: AlarmRegistration<never, R, E>["run"];
706
+ }
707
+
708
+ interface RegistrationParts<R, E> {
709
+ readonly services: readonly string[];
710
+ readonly dispatchers: readonly AlarmDispatcher<R, E>[];
711
+ readonly invalid?: string;
712
+ }
713
+
714
+ const emptyResult: ProcessDueAlarmsResult = { failed: [], handled: [], parked: [] };
715
+
716
+ const missingAlarm = (tag: string) =>
717
+ new InvalidAlarmRegistrationError({
718
+ cause: new Error(`No manual handler registered for alarm tag "${tag}"`),
719
+ });
720
+
721
+ const validateRegistration = <R, E>(parts: RegistrationParts<R, E>) =>
722
+ Effect.try({
723
+ try: () => {
724
+ if (parts.invalid !== undefined) throw new Error(parts.invalid);
725
+ const tags = parts.dispatchers.flatMap((dispatcher) => dispatcher.tags);
726
+
727
+ S.decodeUnknownSync(S.Array(S.NonEmptyString))([...parts.services, ...tags]);
728
+ if (
729
+ new Set(parts.services).size !== parts.services.length ||
730
+ new Set(tags).size !== tags.length
731
+ ) {
732
+ throw new Error(
733
+ "Alarm service keys and definition tags must be unique within a Durable Object",
734
+ );
735
+ }
736
+ },
737
+ catch: (cause) => new InvalidAlarmRegistrationError({ cause }),
738
+ });
739
+
740
+ const runRegistrations = <R, E>(
741
+ parts: RegistrationParts<R, E>,
742
+ rawAlarm?: Effect.Effect<void, E, R>,
743
+ ): AlarmRegistration<never, R, E>["run"] =>
744
+ Effect.gen(function* () {
745
+ yield* validateRegistration(parts);
746
+ const alarms = yield* DurableObjectAlarm;
747
+ const permits = yield* Semaphore.make(4);
748
+ const parentRaw = (yield* CurrentDispatch)?.raw;
749
+ const recoveryScope = {
750
+ tags: parts.dispatchers.flatMap((dispatcher) => dispatcher.tags),
751
+ exclude: true,
752
+ permits,
753
+ raw: parentRaw,
754
+ };
755
+ const recovery =
756
+ rawAlarm === undefined
757
+ ? makeDefinition({})
758
+ .handlers({})
759
+ .pipe(Effect.provideService(CurrentDispatch, recoveryScope))
760
+ : Effect.suspend(() => {
761
+ const raw = { processed: false };
762
+
763
+ return rawAlarm.pipe(
764
+ Effect.onExit(() =>
765
+ raw.processed ? Effect.void : makeDefinition({}).handlers({}).pipe(Effect.asVoid),
766
+ ),
767
+ Effect.provideService(CurrentDispatch, { ...recoveryScope, raw }),
768
+ Effect.as(emptyResult),
769
+ );
770
+ });
771
+
772
+ return yield* alarms.deferWakes(
773
+ Effect.gen(function* () {
774
+ const run = (dispatchers: RegistrationParts<R, E>["dispatchers"]) =>
775
+ Effect.forEach(
776
+ dispatchers,
777
+ (dispatcher) =>
778
+ dispatcher.run.pipe(
779
+ Effect.catchTag("InvalidProcessDueAlarmsOptionsError", (error) =>
780
+ Effect.fail(error).pipe(
781
+ Effect.onExit(() => makeDefinition({}).handlers({}).pipe(Effect.asVoid)),
782
+ ),
783
+ ),
784
+ Effect.provideService(CurrentDispatch, {
785
+ tags: dispatcher.tags,
786
+ exclude: false,
787
+ permits,
788
+ raw: parentRaw,
789
+ }),
790
+ Effect.exit,
791
+ ),
792
+ { concurrency: "unbounded" },
793
+ );
794
+ // Raw hooks wait only for automatic dispatchers; manual-only work stays independent.
795
+ const automatic = parts.dispatchers.filter(
796
+ (dispatcher) => rawAlarm !== undefined && dispatcher.automatic,
797
+ );
798
+ const independent = parts.dispatchers.filter(
799
+ (dispatcher) => rawAlarm === undefined || !dispatcher.automatic,
800
+ );
801
+ const exits = (yield* Effect.all(
802
+ [
803
+ run(independent),
804
+ run(automatic).pipe(
805
+ Effect.flatMap((exits) =>
806
+ Effect.map(Effect.exit(recovery), (exit) => [...exits, exit]),
807
+ ),
808
+ ),
809
+ ],
810
+ { concurrency: "unbounded" },
811
+ )).flat();
812
+
813
+ yield* Exit.asVoidAll(exits);
814
+
815
+ return {
816
+ failed: exits.flatMap((exit) => (Exit.isSuccess(exit) ? exit.value.failed : [])),
817
+ handled: exits.flatMap((exit) => (Exit.isSuccess(exit) ? exit.value.handled : [])),
818
+ parked: exits.flatMap((exit) => (Exit.isSuccess(exit) ? exit.value.parked : [])),
819
+ };
820
+ }),
821
+ );
822
+ });
823
+
824
+ const makeRegistration = <Self, R, E>(
825
+ layer: AlarmRegistration<Self>["layer"],
826
+ parts: RegistrationParts<R, E>,
827
+ ): AlarmRegistration<Self, R, E> => ({
828
+ [RegistrationParts]: parts,
829
+ layer: layer.pipe(Layer.provide(Layer.effectDiscard(validateRegistration(parts)))),
830
+ run: runRegistrations(parts),
831
+ });
832
+
833
+ /** Compose independent typed alarm registrations under one native alarm owner. */
834
+ export function mergeAll<
835
+ const Registrations extends readonly [
836
+ AlarmRegistration<never, unknown, unknown>,
837
+ ...AlarmRegistration<never, unknown, unknown>[],
838
+ ],
839
+ >(
840
+ ...registrations: Registrations
841
+ ): AlarmRegistration<
842
+ Layer.Success<Registrations[number]["layer"]>,
843
+ Exclude<Effect.Services<Registrations[number]["run"]>, DurableObjectAlarm>,
844
+ Exclude<Effect.Error<Registrations[number]["run"]>, DurableObjectAlarmError>
845
+ >;
846
+ export function mergeAll(
847
+ registration: AlarmRegistration<never, unknown, unknown>,
848
+ ...others: readonly AlarmRegistration<never, unknown, unknown>[]
849
+ ): AlarmRegistration<never, unknown, unknown> {
850
+ const registrations = [registration, ...others];
851
+ const parts = registrations.map((item) => item[RegistrationParts]);
852
+
853
+ return makeRegistration(Layer.mergeAll(registration.layer, ...others.map((item) => item.layer)), {
854
+ services: parts.flatMap((part) => part?.services ?? []),
855
+ dispatchers: parts.flatMap((part) => part?.dispatchers ?? []),
856
+ invalid: parts.some((part) => part === undefined)
857
+ ? "mergeAll requires registrations returned by Tag.handlers"
858
+ : parts.find((part) => part?.invalid !== undefined)?.invalid,
859
+ });
596
860
  }
597
861
 
598
862
  export interface AlarmTagClass<
@@ -654,7 +918,11 @@ const makeDefinition = <const Definitions extends AlarmDefinitions>(definitions:
654
918
  Effect.mapError((cause) => new InvalidAlarmPayloadError({ cause })),
655
919
  );
656
920
 
657
- yield* mutations[method]({ ...input, payload });
921
+ yield* mutations[method]({
922
+ ...input,
923
+ payload,
924
+ lifecycle: isAlarmDefinitionConfig(definition) ? definition.lifecycle : undefined,
925
+ });
658
926
  });
659
927
 
660
928
  return {
@@ -680,6 +948,7 @@ const makeDefinition = <const Definitions extends AlarmDefinitions>(definitions:
680
948
  make: (alarms: AlarmScheduler): DefinedAlarmScheduler<Definitions> => ({
681
949
  [TypedAlarmSchedulerTypeId]: TypedAlarmSchedulerTypeId,
682
950
  ...bind(alarms),
951
+ deferWakes: alarms.deferWakes,
683
952
  getAlarmStatus: (input) => alarms.getAlarmStatus(input),
684
953
  transaction: (closure) => alarms.transaction((tx) => closure(bind(tx))),
685
954
  }),
@@ -724,6 +993,14 @@ const makeDefinition = <const Definitions extends AlarmDefinitions>(definitions:
724
993
  }),
725
994
  {
726
995
  ...options,
996
+ [ManualTags]: new Set(
997
+ Object.entries(definitions)
998
+ .filter(
999
+ ([, definition]) =>
1000
+ isAlarmDefinitionConfig(definition) && definition.lifecycle === "manual",
1001
+ )
1002
+ .map(([tag]) => tag),
1003
+ ),
727
1004
  onFailure: (failure) =>
728
1005
  Effect.gen(function* () {
729
1006
  const action = getAlarmDefinitionFailureAction(definitionFor(failure.tag));
@@ -742,6 +1019,46 @@ export const define = <const Definitions extends AlarmDefinitions>(definitions:
742
1019
  handlers: makeDefinition(definitions).handlers,
743
1020
  });
744
1021
 
1022
+ const hasSchedulerTables = Effect.fnUntraced(function* (state: DurableObjectState["Service"]) {
1023
+ return yield* Effect.gen(function* () {
1024
+ if (state.raw.storage.sql === undefined) return false;
1025
+ // Inspect without creating tables: raw-only SQLite and KV objects retain native ownership.
1026
+ const tables = yield* state.storage.sql.exec(
1027
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1",
1028
+ "effect_cf_scheduled_alarms",
1029
+ );
1030
+
1031
+ return (yield* tables.toArray()).length > 0;
1032
+ }).pipe(
1033
+ Effect.catchIf(
1034
+ (error) =>
1035
+ error.cause instanceof Error &&
1036
+ (error.cause.message.startsWith("SQL is not enabled for this Durable Object class") ||
1037
+ error.cause.message.startsWith("This Durable Object is not backed by SQLite storage")),
1038
+ () => Effect.succeed(false),
1039
+ ),
1040
+ );
1041
+ });
1042
+
1043
+ /** @internal Preserve custom dispatch while recovering retained alarms after deployments. */
1044
+ export const dispatchRawAlarm = <Self, E, R>(
1045
+ rawAlarm: Effect.Effect<void, E, R>,
1046
+ registration?: AlarmRegistration<Self, R, E>,
1047
+ ): Effect.Effect<void, E | DurableObjectAlarmError, R | DurableObjectAlarm | DurableObjectState> =>
1048
+ Effect.gen(function* () {
1049
+ const parts = registration?.[RegistrationParts];
1050
+
1051
+ if (parts !== undefined) return yield* runRegistrations(parts, rawAlarm).pipe(Effect.asVoid);
1052
+ const custom =
1053
+ registration === undefined ? rawAlarm : registration.run.pipe(Effect.andThen(rawAlarm));
1054
+ const state = yield* DurableObjectState;
1055
+ const hasScheduler = yield* hasSchedulerTables(state);
1056
+
1057
+ if (!hasScheduler) return yield* custom;
1058
+
1059
+ return yield* runRegistrations({ services: [], dispatchers: [] }, custom).pipe(Effect.asVoid);
1060
+ });
1061
+
745
1062
  /** The typed scheduler becomes available when its handlers are registered on a Durable Object. */
746
1063
  export const Tag =
747
1064
  <Self>() =>
@@ -756,10 +1073,19 @@ export const Tag =
756
1073
  handlers: <R = never, E = never>(
757
1074
  handlers: DefinedAlarmHandlers<Definitions, R, E>,
758
1075
  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
- }),
1076
+ ): AlarmRegistration<Self, R, E> =>
1077
+ makeRegistration(Layer.effect(tag, Effect.map(DurableObjectAlarm, definition.make)), {
1078
+ services: [id],
1079
+ dispatchers: [
1080
+ {
1081
+ tags: Object.keys(definitions),
1082
+ automatic: Object.values(definitions).some(
1083
+ (entry) => !isAlarmDefinitionConfig(entry) || entry.lifecycle !== "manual",
1084
+ ),
1085
+ run: definition.handlers(handlers, options),
1086
+ },
1087
+ ],
1088
+ }),
763
1089
  });
764
1090
  };
765
1091
 
@@ -771,6 +1097,13 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
771
1097
  Effect.gen(function* () {
772
1098
  const state = yield* DurableObjectState;
773
1099
  const configurationDefaults = { ...(yield* ScheduleConfiguration) };
1100
+ let wakeDeferrals = 0;
1101
+ let wakesReady: Deferred.Deferred<void> | undefined;
1102
+ let pendingAlarm: Deferred.Deferred<void, unknown> | undefined;
1103
+ let dispatchActive = false;
1104
+ let dispatchNeedsReconcile = false;
1105
+ let deferredRecoveryDelay = PARKED_RETRY_DELAY_MS;
1106
+ let deferredRecoveryAt = 0;
774
1107
 
775
1108
  const readRow = Effect.fnUntraced(function* (storageId: string) {
776
1109
  const cursor = yield* state.storage.sql.exec<AlarmRow>(
@@ -781,25 +1114,43 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
781
1114
  return (yield* cursor.toArray())[0];
782
1115
  });
783
1116
 
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({
1117
+ // The scheduler is the sole alarm writer. Raw storage is confined to this boundary.
1118
+ const writeNativeAlarm = (runAt: number | null) =>
1119
+ Effect.tryPromise({
793
1120
  try: () =>
794
- next === undefined
795
- ? state.raw.storage.deleteAlarm()
796
- : state.raw.storage.setAlarm(next.run_at),
1121
+ runAt === null ? state.raw.storage.deleteAlarm() : state.raw.storage.setAlarm(runAt),
797
1122
  catch: (cause) =>
798
1123
  new StorageOperationError({
799
- operation: next === undefined ? "deleteAlarm" : "setAlarm",
1124
+ operation: runAt === null ? "deleteAlarm" : "setAlarm",
800
1125
  cause,
801
1126
  }),
802
1127
  });
1128
+
1129
+ const reconcileAlarm = Effect.fn("DurableObjectAlarm.reconcileAlarm")(function* () {
1130
+ if (wakeDeferrals > 0 || dispatchActive) {
1131
+ if (dispatchActive) dispatchNeedsReconcile = true;
1132
+ const current = yield* state.storage.getAlarm();
1133
+ const now = yield* Clock.currentTimeMillis;
1134
+
1135
+ if (deferredRecoveryAt <= now) {
1136
+ deferredRecoveryAt = now + deferredRecoveryDelay;
1137
+ }
1138
+
1139
+ // Commit the guard before user work. Later transactions keep it even when
1140
+ // logical deadlines move earlier or every checkpoint is cancelled.
1141
+ if (current === null || current <= now || current > deferredRecoveryAt) {
1142
+ yield* writeNativeAlarm(deferredRecoveryAt);
1143
+ }
1144
+
1145
+ return;
1146
+ }
1147
+ const cursor = yield* state.storage.sql.exec<NextAlarmRow>(
1148
+ `SELECT wake_at AS run_at FROM effect_cf_scheduled_alarms
1149
+ ORDER BY wake_at, storage_id LIMIT 1`,
1150
+ );
1151
+ const next = (yield* cursor.toArray())[0];
1152
+
1153
+ yield* writeNativeAlarm(next?.run_at ?? null);
803
1154
  });
804
1155
 
805
1156
  const writeAttempts = Effect.fnUntraced(function* (
@@ -851,6 +1202,20 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
851
1202
  ) {
852
1203
  const configuration = yield* getScheduleConfiguration(configurationDefaults);
853
1204
  const ref = yield* decodeAlarmRef(input);
1205
+ const lifecycle = yield* S.decodeUnknownEffect(AlarmLifecycleSchema)(
1206
+ input.lifecycle ?? "automatic",
1207
+ ).pipe(Effect.mapError((cause) => new InvalidAlarmRefError({ cause })));
1208
+
1209
+ if (
1210
+ lifecycle === "manual" &&
1211
+ (input.repeatEvery !== undefined || input.progress !== undefined)
1212
+ ) {
1213
+ return yield* Effect.fail(
1214
+ new InvalidAlarmRefError({
1215
+ cause: new Error("Manual alarms own their repeats and progress budgets"),
1216
+ }),
1217
+ );
1218
+ }
854
1219
  const repeatEveryMillis = yield* toRepeatEveryMillis(
855
1220
  input.repeatEvery,
856
1221
  configuration.minimumRepeatInterval,
@@ -859,19 +1224,10 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
859
1224
  const storageId = getScheduledEventId(ref);
860
1225
  const existing = yield* readRow(storageId);
861
1226
  const pass = yield* CurrentAlarmPass;
862
- const source = pass?.row;
1227
+ const source =
1228
+ lifecycle === "automatic" && pass?.row.lifecycle === "automatic" ? pass.row : undefined;
863
1229
  const progress = existing?.progress ?? source?.progress ?? -1;
864
1230
 
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
1231
  if (input.progress !== undefined) {
876
1232
  yield* S.decodeUnknownEffect(S.Natural)(input.progress).pipe(
877
1233
  Effect.mapError((cause) => new InvalidAlarmRefError({ cause })),
@@ -907,16 +1263,22 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
907
1263
  const keepEarlier = earlier && existing !== undefined && existing.run_at <= runAt;
908
1264
  const scheduledAt = keepEarlier ? existing.run_at : runAt;
909
1265
  const scheduledPayload = keepEarlier ? existing.payload : payload;
910
- const scheduledRepeat = keepEarlier ? existing.repeat_every_ms : repeatEveryMillis;
911
- const nextProgress = Math.max(progress, input.progress ?? -1);
1266
+ const scheduledRepeat =
1267
+ lifecycle === "manual"
1268
+ ? null
1269
+ : keepEarlier
1270
+ ? existing.repeat_every_ms
1271
+ : repeatEveryMillis;
1272
+ const nextProgress = lifecycle === "manual" ? -1 : Math.max(progress, input.progress ?? -1);
912
1273
 
913
1274
  yield* state.storage.sql.exec(
914
1275
  `INSERT OR REPLACE INTO effect_cf_scheduled_alarms
915
- (storage_id, alarm_id, tag, run_at, repeat_every_ms, payload)
916
- VALUES (?, ?, ?, ?, ?, ?)`,
1276
+ (storage_id, alarm_id, tag, lifecycle, run_at, repeat_every_ms, payload)
1277
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
917
1278
  storageId,
918
1279
  ref.id,
919
1280
  ref.tag,
1281
+ lifecycle,
920
1282
  scheduledAt,
921
1283
  scheduledRepeat,
922
1284
  scheduledPayload,
@@ -949,13 +1311,24 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
949
1311
  });
950
1312
 
951
1313
  const transaction: AlarmScheduler["transaction"] = Effect.fnUntraced(function* (closure) {
1314
+ const pass = yield* CurrentAlarmPass;
1315
+
1316
+ if (pass !== undefined && !pass.active) {
1317
+ return yield* Effect.fail(
1318
+ new StorageOperationError({
1319
+ operation: "alarm.transaction",
1320
+ cause: new Error("Alarm handlers cannot mutate detached work after their pass ends"),
1321
+ }),
1322
+ );
1323
+ }
952
1324
  const reports: string[] = [];
953
1325
  const result = yield* state.storage.transaction(() =>
954
1326
  Effect.withFiber((owner) => {
955
1327
  let active = true;
956
1328
  const requireActive = <A, E>(effect: Effect.Effect<A, E>) =>
957
1329
  Effect.withFiber<A, E | StorageOperationError>((fiber) =>
958
- active && fiber === owner
1330
+ // A queued or suspended transaction can outlive the handler that admitted it.
1331
+ active && fiber === owner && (pass === undefined || pass.active)
959
1332
  ? effect
960
1333
  : Effect.fail(
961
1334
  new StorageOperationError({
@@ -996,6 +1369,123 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
996
1369
  return result;
997
1370
  });
998
1371
 
1372
+ const deferWakes: AlarmScheduler["deferWakes"] = (body) =>
1373
+ Effect.acquireUseRelease(
1374
+ Effect.gen(function* () {
1375
+ const configuration = yield* getScheduleConfiguration(configurationDefaults);
1376
+ const now = yield* Clock.currentTimeMillis;
1377
+
1378
+ if (wakeDeferrals === 0) {
1379
+ deferredRecoveryDelay = configuration.inFlightRecovery;
1380
+ deferredRecoveryAt = now + deferredRecoveryDelay;
1381
+ wakesReady ??= Deferred.makeUnsafe<void>();
1382
+ }
1383
+ wakeDeferrals++;
1384
+ }),
1385
+ () => transaction(() => Effect.void).pipe(Effect.andThen(body)),
1386
+ () =>
1387
+ Effect.gen(function* () {
1388
+ wakeDeferrals--;
1389
+ if (wakeDeferrals === 0) {
1390
+ yield* (dispatchActive ? Effect.void : transaction(() => Effect.void)).pipe(
1391
+ Effect.ensuring(
1392
+ Effect.sync(() => {
1393
+ if (wakeDeferrals === 0 && wakesReady !== undefined) {
1394
+ Deferred.doneUnsafe(wakesReady, Effect.void);
1395
+ wakesReady = undefined;
1396
+ }
1397
+ }),
1398
+ ),
1399
+ );
1400
+ }
1401
+ }),
1402
+ );
1403
+
1404
+ const awaitWakes: Effect.Effect<void> = Effect.suspend(() =>
1405
+ wakesReady === undefined
1406
+ ? Effect.void
1407
+ : Deferred.await(wakesReady).pipe(Effect.andThen(awaitWakes)),
1408
+ );
1409
+ const runAlarm: AlarmScheduler[typeof RunAlarm] = (event, native) =>
1410
+ Effect.flatMap(hasSchedulerTables(state), (managed) =>
1411
+ !managed
1412
+ ? native
1413
+ ? event(Effect.void)
1414
+ : Effect.void
1415
+ : Effect.uninterruptibleMask((restore) =>
1416
+ Effect.suspend(() => {
1417
+ const pending = pendingAlarm;
1418
+
1419
+ if (pending !== undefined) {
1420
+ // A native delivery consumed its timestamp. Restore recovery before joining.
1421
+ return restore(
1422
+ (native ? transaction(() => Effect.void) : Effect.void).pipe(
1423
+ Effect.andThen(Deferred.await(pending)),
1424
+ ),
1425
+ );
1426
+ }
1427
+ const completed = Deferred.makeUnsafe<void, unknown>();
1428
+ let dispatchStarted = false;
1429
+
1430
+ pendingAlarm = completed;
1431
+ dispatchNeedsReconcile = native;
1432
+
1433
+ return restore(
1434
+ Effect.gen(function* () {
1435
+ // Native-first delivery can consume an inline region's guard before
1436
+ // any prompt pass exists to join. Restore it while waiting for exit.
1437
+ if (native && wakesReady !== undefined) yield* transaction(() => Effect.void);
1438
+ yield* awaitWakes;
1439
+ dispatchActive = true;
1440
+ yield* event(
1441
+ Effect.gen(function* () {
1442
+ yield* getScheduleConfiguration(configurationDefaults);
1443
+ dispatchStarted = true;
1444
+ }),
1445
+ );
1446
+ }),
1447
+ ).pipe(
1448
+ Effect.onExit((exit) =>
1449
+ Effect.gen(function* () {
1450
+ // The event includes its scope finalizers. Late enrollments cannot be
1451
+ // swallowed by a native delivery that joined after the final checkpoint.
1452
+ // Acquisition failure never reached a dispatcher: keep a future guard
1453
+ // instead of rearming untouched due rows into an immediate retry loop.
1454
+ const reconcile =
1455
+ dispatchNeedsReconcile || dispatchStarted || Exit.isFailure(exit);
1456
+
1457
+ dispatchActive = Exit.isFailure(exit) && !dispatchStarted;
1458
+ if (dispatchActive) {
1459
+ const recoveryDelay = yield* getScheduleConfiguration(
1460
+ configurationDefaults,
1461
+ ).pipe(
1462
+ Effect.match({
1463
+ onFailure: () => PARKED_RETRY_DELAY_MS,
1464
+ onSuccess: (configuration) => configuration.parkedRetryDelay,
1465
+ }),
1466
+ );
1467
+
1468
+ // A concurrent deferred scope retains ownership of its short guard.
1469
+ if (wakeDeferrals === 0) {
1470
+ deferredRecoveryDelay = recoveryDelay;
1471
+ deferredRecoveryAt = 0;
1472
+ }
1473
+ }
1474
+ const reconciled = yield* (
1475
+ reconcile ? transaction(() => Effect.void) : Effect.void
1476
+ ).pipe(Effect.exit);
1477
+
1478
+ dispatchActive = false;
1479
+ pendingAlarm = undefined;
1480
+ yield* Deferred.done(completed, Exit.asVoidAll([exit, reconciled]));
1481
+ yield* reconciled;
1482
+ }),
1483
+ ),
1484
+ );
1485
+ }),
1486
+ ),
1487
+ );
1488
+
999
1489
  const rescheduleFailedAlarm = Effect.fnUntraced(function* (
1000
1490
  row: AlarmRow,
1001
1491
  initialDelay: number,
@@ -1072,14 +1562,56 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1072
1562
  const limit = yield* getProcessLimit(options);
1073
1563
  const initialDelay = yield* getFailureRetryDelay(options, configuration);
1074
1564
  const now = yield* Clock.currentTimeMillis;
1565
+ const dispatch = yield* CurrentDispatch;
1566
+
1567
+ // A validated dispatcher owns its remaining batch, including newly enrolled work.
1568
+ if (dispatch?.raw !== undefined) dispatch.raw.processed = true;
1569
+
1570
+ const tagFilter =
1571
+ dispatch === undefined
1572
+ ? ""
1573
+ : ` AND a.tag ${dispatch.exclude ? "NOT IN" : "IN"} (SELECT value FROM json_each(?))`;
1574
+ const tagBindings =
1575
+ dispatch === undefined ? [] : [S.encodeSync(StoredTags)([...dispatch.tags])];
1576
+ const select = Effect.fnUntraced(function* (lifecycle: AlarmLifecycle, batchLimit: number) {
1577
+ const cursor = yield* state.storage.sql.exec<AlarmRow>(
1578
+ `${alarmRowsSql} WHERE a.lifecycle = ? AND a.wake_at <= ?${tagFilter}
1579
+ ORDER BY a.wake_at ASC, a.storage_id ASC LIMIT ?`,
1580
+ lifecycle,
1581
+ now,
1582
+ ...tagBindings,
1583
+ batchLimit,
1584
+ );
1075
1585
 
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();
1586
+ return yield* cursor.toArray();
1587
+ });
1588
+ // Manual recovery never consumes the raw/automatic dispatcher's batch allowance.
1589
+ const dueRows = yield* select("automatic", limit);
1590
+ const manualRows = yield* select("manual", DEFAULT_PROCESS_DUE_ALARMS_LIMIT);
1591
+
1592
+ if (manualRows.length > 0) {
1593
+ yield* transaction(() =>
1594
+ Effect.gen(function* () {
1595
+ for (const row of manualRows) {
1596
+ // Keep work eligible after eviction; unknown registrations stay parked.
1597
+ const recoveryAt =
1598
+ now +
1599
+ (options?.[ManualTags]?.has(row.tag)
1600
+ ? configuration.inFlightRecovery
1601
+ : configuration.parkedRetryDelay);
1602
+
1603
+ yield* state.storage.sql.exec(
1604
+ `UPDATE effect_cf_scheduled_alarms SET run_at = ?, wake_at = ? WHERE ${sameRevisionSql}`,
1605
+ recoveryAt,
1606
+ recoveryAt,
1607
+ row.storage_id,
1608
+ row.storage_id,
1609
+ row.revision,
1610
+ );
1611
+ }
1612
+ }),
1613
+ );
1614
+ }
1083
1615
  const handled: DurableObjectAlarmEvent[] = [];
1084
1616
  const failed: ProcessDueAlarmsFailure[] = [];
1085
1617
  const parked: AlarmParked[] = [];
@@ -1128,12 +1660,10 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1128
1660
  }
1129
1661
  });
1130
1662
 
1131
- for (const row of dueRows) {
1663
+ const handleRow = Effect.fnUntraced(function* (row: AlarmRow) {
1132
1664
  const current = yield* readRow(row.storage_id);
1133
1665
 
1134
- if (current === undefined || current.revision !== row.revision) {
1135
- continue;
1136
- }
1666
+ if (current === undefined || current.revision !== row.revision) return;
1137
1667
  const inPass = <A, E, R>(effect: Effect.Effect<A, E, R>) => {
1138
1668
  const pass = { row, parked, active: true };
1139
1669
 
@@ -1146,28 +1676,85 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1146
1676
  ),
1147
1677
  );
1148
1678
  };
1679
+ const manual = row.lifecycle === "manual";
1680
+
1681
+ if (manual && !options?.[ManualTags]?.has(row.tag)) {
1682
+ return yield* Effect.fail(missingAlarm(row.tag));
1683
+ }
1149
1684
  const eventExit = yield* Effect.exit(toAlarmDue(row));
1150
1685
 
1151
1686
  if (Exit.isFailure(eventExit)) {
1152
- yield* inPass(handleFailure(row, undefined, eventExit.cause));
1153
- continue;
1687
+ if (manual) return yield* Effect.failCause(eventExit.cause);
1688
+
1689
+ return yield* inPass(handleFailure(row, undefined, eventExit.cause));
1154
1690
  }
1155
1691
  const event = eventExit.value;
1156
1692
  const handleExit = yield* Effect.exit(inPass(Effect.suspend(() => handle(event))));
1157
1693
 
1158
1694
  if (Exit.isFailure(handleExit)) {
1159
- yield* inPass(handleFailure(row, event, handleExit.cause));
1160
- continue;
1695
+ if (manual) return yield* Effect.failCause(handleExit.cause);
1696
+
1697
+ return yield* inPass(handleFailure(row, event, handleExit.cause));
1161
1698
  }
1162
- yield* transaction(() => acknowledgeAlarm(row, configuration));
1699
+ if (!manual) yield* transaction(() => acknowledgeAlarm(row, configuration));
1163
1700
  handled.push(event);
1164
- }
1165
- yield* transaction(() => Effect.void);
1701
+ });
1702
+ const permits = dispatch?.permits ?? (yield* Semaphore.make(4));
1703
+ const automatic = Effect.forEach(dueRows, handleRow, { discard: true }).pipe(Effect.exit);
1704
+ const exits = yield* manualRows.length === 0
1705
+ ? automatic.pipe(Effect.map((exit) => [exit]))
1706
+ : Effect.all(
1707
+ [
1708
+ automatic,
1709
+ Effect.forEach(
1710
+ manualRows,
1711
+ (row) => permits.withPermits(1)(handleRow(row)).pipe(Effect.exit),
1712
+ { concurrency: 4 },
1713
+ ).pipe(Effect.flatMap(Exit.asVoidAll), Effect.exit),
1714
+ ],
1715
+ { concurrency: 2 },
1716
+ );
1717
+
1718
+ yield* transaction(() =>
1719
+ Effect.gen(function* () {
1720
+ const recoveryAt = (yield* Clock.currentTimeMillis) + configuration.parkedRetryDelay;
1721
+
1722
+ for (const row of manualRows) {
1723
+ // Only a returned handler with no newer checkpoint falls back to parked recovery.
1724
+ yield* state.storage.sql.exec(
1725
+ `UPDATE effect_cf_scheduled_alarms SET run_at = ?, wake_at = ? WHERE ${sameRevisionSql}`,
1726
+ recoveryAt,
1727
+ recoveryAt,
1728
+ row.storage_id,
1729
+ row.storage_id,
1730
+ row.revision,
1731
+ );
1732
+ }
1733
+ }),
1734
+ );
1735
+ yield* Exit.asVoidAll(exits);
1166
1736
 
1167
1737
  return { failed, handled, parked };
1168
1738
  });
1169
1739
 
1170
1740
  return DurableObjectAlarm.of({
1741
+ [RunAlarm]: runAlarm,
1742
+ [DispatchAfterEvent]: Effect.map(
1743
+ ScheduleConfiguration,
1744
+ (configuration) =>
1745
+ (configuration.dispatchAfterEvent ?? configurationDefaults.dispatchAfterEvent) === true,
1746
+ ),
1747
+ [HasDueAlarms]: Effect.gen(function* () {
1748
+ yield* ensureTable(state);
1749
+ const now = yield* Clock.currentTimeMillis;
1750
+ const cursor = yield* state.storage.sql.exec(
1751
+ "SELECT 1 FROM effect_cf_scheduled_alarms WHERE wake_at <= ? LIMIT 1",
1752
+ now,
1753
+ );
1754
+
1755
+ return (yield* cursor.toArray()).length > 0;
1756
+ }),
1757
+ deferWakes,
1171
1758
  cancelAlarm: (input) => transaction((alarms) => alarms.cancelAlarm(input)),
1172
1759
  getAlarmStatus: Effect.fnUntraced(function* (input) {
1173
1760
  const ref = yield* decodeAlarmRef(input);