effect-cf 0.54.0 → 0.56.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # effect-cf
2
2
 
3
+ ## 0.56.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#213](https://github.com/danieljvdm/effect-cf/pull/213) [`b0e1201`](https://github.com/danieljvdm/effect-cf/commit/b0e1201663c3769fa2e63daad6f46a8a8b0d9b05) Thanks [@danieljvdm](https://github.com/danieljvdm)! - Allow alarm transactions to share a Durable Object SQL client's connection and commit boundary. Reduce manual alarm checkpoint writes while preserving recovery guards and stale-dispatch fencing.
8
+
9
+ ## 0.55.0
10
+
11
+ ### Minor Changes
12
+
13
+ - [#211](https://github.com/danieljvdm/effect-cf/pull/211) [`2764143`](https://github.com/danieljvdm/effect-cf/commit/276414374c3fe71f619c26ef1c447ab0f52f9ab4) Thanks [@danieljvdm](https://github.com/danieljvdm)! - Keep in-flight Durable Object work recoverable through a configurable short alarm deadline while preserving hourly parked recovery. Coalesce native deliveries with active processing without starting another pass.
14
+
3
15
  ## 0.54.0
4
16
 
5
17
  ### Minor Changes
@@ -6,8 +6,9 @@ import * as S from "effect/Schema";
6
6
  import * as Layer from "effect/Layer";
7
7
  import * as Duration from "effect/Duration";
8
8
  import * as DateTime from "effect/DateTime";
9
+ import { SqlClient } from "effect/sql/SqlClient";
9
10
  declare namespace DurableObjectAlarm_d_exports {
10
- export { AlarmDefinitionConfig, AlarmDefinitionEntry, AlarmDefinitionPayload, AlarmDefinitions, AlarmFailurePolicy, AlarmLifecycle, AlarmParked, AlarmPayload, AlarmPayloadSchema, AlarmRef, AlarmRegistration, AlarmReporter, AlarmRetryPolicy, AlarmScheduler, AlarmService, AlarmStatus, AlarmTagClass, AlarmTransaction, DEFAULT_MIN_REPEAT_INTERVAL_MS, DefinedAlarmEvent, DefinedAlarmHandlers, DefinedAlarmScheduler, DefinedAlarmTransaction, DefinedScheduleAlarmInput, DispatchAfterEvent, DurableObjectAlarm, DurableObjectAlarmError, DurableObjectAlarmEvent, HasDueAlarms, InvalidAlarmPayloadError, InvalidAlarmRefError, InvalidAlarmRegistrationError, InvalidProcessDueAlarmsOptionsError, InvalidRepeatEveryError, InvalidScheduleConfigurationError, MIN_REPEAT_INTERVAL_MS, MIN_RETRY_DELAY_MS, PARKED_RETRY_DELAY_MS, ProcessDueAlarmsFailure, ProcessDueAlarmsFailureAction, ProcessDueAlarmsHandler, ProcessDueAlarmsOptions, ProcessDueAlarmsResult, RunAlarm, ScheduleAlarmInput, ScheduleConfiguration, StoredAlarmDecodeError, Tag, UNCHANGED_ATTEMPT_BUDGET, define, dispatchRawAlarm, mergeAll, processDue };
11
+ export { AlarmDefinitionConfig, AlarmDefinitionEntry, AlarmDefinitionPayload, AlarmDefinitions, AlarmFailurePolicy, AlarmLifecycle, AlarmParked, AlarmPayload, AlarmPayloadSchema, AlarmRef, AlarmRegistration, AlarmReporter, AlarmRetryPolicy, AlarmScheduler, AlarmService, AlarmStatus, AlarmTagClass, AlarmTransaction, AlarmTransactionOptions, DEFAULT_MIN_REPEAT_INTERVAL_MS, DefinedAlarmEvent, DefinedAlarmHandlers, DefinedAlarmScheduler, DefinedAlarmTransaction, DefinedScheduleAlarmInput, DispatchAfterEvent, DurableObjectAlarm, DurableObjectAlarmError, DurableObjectAlarmEvent, HasDueAlarms, InvalidAlarmPayloadError, InvalidAlarmRefError, InvalidAlarmRegistrationError, InvalidProcessDueAlarmsOptionsError, InvalidRepeatEveryError, InvalidScheduleConfigurationError, MIN_REPEAT_INTERVAL_MS, MIN_RETRY_DELAY_MS, PARKED_RETRY_DELAY_MS, ProcessDueAlarmsFailure, ProcessDueAlarmsFailureAction, ProcessDueAlarmsHandler, ProcessDueAlarmsOptions, ProcessDueAlarmsResult, RunAlarm, ScheduleAlarmInput, ScheduleConfiguration, StoredAlarmDecodeError, Tag, UNCHANGED_ATTEMPT_BUDGET, define, dispatchRawAlarm, mergeAll, processDue };
11
12
  }
12
13
  declare const MIN_RETRY_DELAY_MS = 1000;
13
14
  /** Default unchanged-attempt budget. */
@@ -21,6 +22,8 @@ declare const DEFAULT_MIN_REPEAT_INTERVAL_MS = 60000;
21
22
  interface ScheduleConfiguration {
22
23
  /** Check for due alarms after RPC/fetch responses unwind. Native alarms remain recovery authority. */
23
24
  readonly dispatchAfterEvent?: boolean;
25
+ /** Positive recovery deadline while dispatch or deferWakes is active. Defaults to one second. */
26
+ readonly inFlightRecovery?: Duration.Input;
24
27
  /** Retry floor, at least one second. Defaults to one second. */
25
28
  readonly minimumRetryDelay?: Duration.Input;
26
29
  /** Positive safe integer. Defaults to eight unchanged attempts. */
@@ -159,6 +162,11 @@ type ProcessDueAlarmsHandler<R = never, E = never> = (event: DurableObjectAlarmE
159
162
  * fiber; forked work and use after the callback ends fail with StorageOperationError.
160
163
  */
161
164
  type AlarmTransaction = Pick<AlarmScheduler, "scheduleAlarm" | "scheduleAlarmEarlier" | "cancelAlarm">;
165
+ /** Share the same native commit and connection permit as the caller's SQL writes. */
166
+ interface AlarmTransactionOptions {
167
+ /** Must be a storage-backed @effect/sql-sqlite-do client for this Durable Object. */
168
+ readonly sqlClient: SqlClient;
169
+ }
162
170
  interface AlarmStatus {
163
171
  readonly attempts: number;
164
172
  readonly parked: boolean;
@@ -184,14 +192,14 @@ type AlarmScheduler = {
184
192
  * object's concurrent and nested scopes. Source/checkpoint transactions still commit;
185
193
  * the last scope reconciles their current deadlines on success, failure or interruption.
186
194
  * Run outside transactions, around bounded maintenance or inline work. The first scope's
187
- * parkedRetryDelay bounds the shared guard; renew it if it expires and is consumed while
195
+ * inFlightRecovery bounds the shared guard; renew it if it expires and is consumed while
188
196
  * scopes remain active. A process loss before exit recovers at that retained alarm.
189
197
  */
190
198
  readonly deferWakes: <A, E, R>(body: Effect.Effect<A, E, R>) => Effect.Effect<A, E | InvalidScheduleConfigurationError | StorageOperationError, R>;
191
199
  /**
192
200
  * Commits local application storage and logical alarms in one native SQLite
193
201
  * Durable Object transaction, reconciling the native alarm before commit.
194
- * Use the supplied mutations, not standalone alarm methods or nested transactions.
202
+ * Use the supplied mutations inside the callback instead of standalone alarm methods.
195
203
  * SqlClient queries must use this same Durable Object's storage.
196
204
  *
197
205
  * Failure, defects and interruption before commit roll back. A lost reply or
@@ -200,7 +208,7 @@ type AlarmScheduler = {
200
208
  * Cloudflare's native retries are bounded; composition does not remove the
201
209
  * pre-arm requirement or promise infinite retry liveness.
202
210
  */
203
- readonly transaction: <A, E, R>(closure: (alarms: AlarmTransaction) => Effect.Effect<A, E, R>) => Effect.Effect<A, E | StorageOperationError, R>;
211
+ readonly transaction: <A, E, R>(closure: (alarms: AlarmTransaction) => Effect.Effect<A, E, R>, options?: AlarmTransactionOptions) => Effect.Effect<A, E | StorageOperationError, R>;
204
212
  readonly cancelAlarm: (input: AlarmRef) => Effect.Effect<void, InvalidAlarmRefError | StorageOperationError>;
205
213
  /** Inspect retained work, including hourly recovery deadlines. */
206
214
  readonly getAlarmStatus: (input: AlarmRef) => Effect.Effect<AlarmStatus | undefined, InvalidAlarmRefError | StorageOperationError>;
@@ -265,7 +273,7 @@ interface DefinedAlarmScheduler<Definitions extends AlarmDefinitions> extends De
265
273
  readonly [TypedAlarmSchedulerTypeId]: typeof TypedAlarmSchedulerTypeId;
266
274
  readonly deferWakes: AlarmScheduler["deferWakes"];
267
275
  readonly getAlarmStatus: (input: AlarmRef<keyof Definitions & string>) => ReturnType<AlarmScheduler["getAlarmStatus"]>;
268
- readonly transaction: <A, E, R>(closure: (alarms: DefinedAlarmTransaction<Definitions>) => Effect.Effect<A, E, R>) => Effect.Effect<A, E | StorageOperationError, R>;
276
+ readonly transaction: <A, E, R>(closure: (alarms: DefinedAlarmTransaction<Definitions>) => Effect.Effect<A, E, R>, options?: AlarmTransactionOptions) => Effect.Effect<A, E | StorageOperationError, R>;
269
277
  }
270
278
  /** Registers a dispatcher and the service authorized to schedule its alarms. */
271
279
  interface AlarmRegistration<Self, R = never, E = never> {
@@ -302,4 +310,4 @@ declare class DurableObjectAlarm extends DurableObjectAlarm_base {
302
310
  static readonly layer: Layer.Layer<DurableObjectAlarm, never, DurableObjectState>;
303
311
  }
304
312
  //#endregion
305
- export { AlarmDefinitionConfig, AlarmDefinitionEntry, AlarmDefinitionPayload, AlarmDefinitions, AlarmFailurePolicy, AlarmLifecycle, AlarmParked, AlarmPayload, AlarmPayloadSchema, AlarmRef, AlarmRegistration, AlarmReporter, AlarmRetryPolicy, AlarmScheduler, AlarmService, AlarmStatus, AlarmTagClass, AlarmTransaction, DEFAULT_MIN_REPEAT_INTERVAL_MS, DefinedAlarmEvent, DefinedAlarmHandlers, DefinedAlarmScheduler, DefinedAlarmTransaction, DefinedScheduleAlarmInput, DispatchAfterEvent, DurableObjectAlarm, DurableObjectAlarmError, DurableObjectAlarmEvent, DurableObjectAlarm_d_exports, HasDueAlarms, InvalidAlarmPayloadError, InvalidAlarmRefError, InvalidAlarmRegistrationError, InvalidProcessDueAlarmsOptionsError, InvalidRepeatEveryError, InvalidScheduleConfigurationError, MIN_REPEAT_INTERVAL_MS, MIN_RETRY_DELAY_MS, PARKED_RETRY_DELAY_MS, ProcessDueAlarmsFailure, ProcessDueAlarmsFailureAction, ProcessDueAlarmsHandler, ProcessDueAlarmsOptions, ProcessDueAlarmsResult, RunAlarm, ScheduleAlarmInput, ScheduleConfiguration, StoredAlarmDecodeError, Tag, UNCHANGED_ATTEMPT_BUDGET, define, dispatchRawAlarm, mergeAll, processDue };
313
+ export { AlarmDefinitionConfig, AlarmDefinitionEntry, AlarmDefinitionPayload, AlarmDefinitions, AlarmFailurePolicy, AlarmLifecycle, AlarmParked, AlarmPayload, AlarmPayloadSchema, AlarmRef, AlarmRegistration, AlarmReporter, AlarmRetryPolicy, AlarmScheduler, AlarmService, AlarmStatus, AlarmTagClass, AlarmTransaction, AlarmTransactionOptions, DEFAULT_MIN_REPEAT_INTERVAL_MS, DefinedAlarmEvent, DefinedAlarmHandlers, DefinedAlarmScheduler, DefinedAlarmTransaction, DefinedScheduleAlarmInput, DispatchAfterEvent, DurableObjectAlarm, DurableObjectAlarmError, DurableObjectAlarmEvent, DurableObjectAlarm_d_exports, HasDueAlarms, InvalidAlarmPayloadError, InvalidAlarmRefError, InvalidAlarmRegistrationError, InvalidProcessDueAlarmsOptionsError, InvalidRepeatEveryError, InvalidScheduleConfigurationError, MIN_REPEAT_INTERVAL_MS, MIN_RETRY_DELAY_MS, PARKED_RETRY_DELAY_MS, ProcessDueAlarmsFailure, ProcessDueAlarmsFailureAction, ProcessDueAlarmsHandler, ProcessDueAlarmsOptions, ProcessDueAlarmsResult, RunAlarm, ScheduleAlarmInput, ScheduleConfiguration, StoredAlarmDecodeError, Tag, UNCHANGED_ATTEMPT_BUDGET, define, dispatchRawAlarm, mergeAll, processDue };
@@ -11,9 +11,11 @@ import * as Predicate from "effect/Predicate";
11
11
  import * as Exit from "effect/Exit";
12
12
  import * as Duration from "effect/Duration";
13
13
  import * as Clock from "effect/Clock";
14
+ import { SqliteClient } from "@effect/sql-sqlite-do";
14
15
  import * as DateTime from "effect/DateTime";
15
16
  import * as Deferred from "effect/Deferred";
16
17
  import * as Semaphore from "effect/Semaphore";
18
+ import { SqlError } from "effect/sql/SqlError";
17
19
  //#region src/DurableObjectAlarm.ts
18
20
  var DurableObjectAlarm_exports = /* @__PURE__ */ __exportAll({
19
21
  AlarmParked: () => AlarmParked,
@@ -51,23 +53,21 @@ CREATE TABLE IF NOT EXISTS effect_cf_scheduled_alarms (
51
53
  run_at INTEGER NOT NULL,
52
54
  wake_at INTEGER NOT NULL DEFAULT 0,
53
55
  repeat_every_ms INTEGER,
54
- payload TEXT NOT NULL
55
- );
56
- CREATE TABLE IF NOT EXISTS effect_cf_alarm_attempts (
57
- storage_id TEXT PRIMARY KEY,
58
- revision TEXT NOT NULL,
59
- attempts INTEGER NOT NULL,
60
- parked INTEGER NOT NULL,
56
+ payload TEXT NOT NULL,
57
+ revision TEXT NOT NULL DEFAULT '',
58
+ attempts INTEGER NOT NULL DEFAULT 0,
59
+ parked INTEGER NOT NULL DEFAULT 0,
61
60
  retry_at INTEGER,
62
- progress INTEGER NOT NULL
61
+ progress INTEGER NOT NULL DEFAULT -1
63
62
  );
64
63
  `;
65
64
  const WAKE_INDEX = "idx_effect_cf_scheduled_alarms_wake_at_storage_id";
66
- const LIFECYCLE_INDEX = "idx_effect_cf_scheduled_alarms_lifecycle_wake_at_storage_id";
65
+ const LIFECYCLE_INDEX = "idx_effect_cf_scheduled_alarms_lifecycle_wake_at_storage_id_v2";
67
66
  const DEFAULT_PROCESS_DUE_ALARMS_LIMIT = 100;
68
67
  const MIN_RETRY_DELAY_MS = 1e3;
69
68
  /** Minimum and default parked recovery interval. */
70
69
  const PARKED_RETRY_DELAY_MS = 36e5;
70
+ const DEFAULT_IN_FLIGHT_RECOVERY_MS = 1e3;
71
71
  /** Absolute product repeat floor. */
72
72
  const MIN_REPEAT_INTERVAL_MS = 1e3;
73
73
  /** Default product schedule floor. Never use repeats to poll state. */
@@ -78,6 +78,7 @@ const safeIntegerAtLeast = (minimum) => S.Int.check(S.isGreaterThanOrEqualTo(min
78
78
  const AlarmLifecycleSchema = S.Literals(["automatic", "manual"]);
79
79
  const ScheduleConfigurationSchema = S.Struct({
80
80
  dispatchAfterEvent: S.Boolean,
81
+ inFlightRecovery: safeIntegerAtLeast(1),
81
82
  minimumRetryDelay: safeIntegerAtLeast(MIN_RETRY_DELAY_MS),
82
83
  unchangedAttemptBudget: safeIntegerAtLeast(1),
83
84
  parkedRetryDelay: safeIntegerAtLeast(PARKED_RETRY_DELAY_MS),
@@ -85,14 +86,8 @@ const ScheduleConfigurationSchema = S.Struct({
85
86
  }).check(S.makeFilter((configuration) => configuration.parkedRetryDelay >= configuration.minimumRetryDelay ? void 0 : "parkedRetryDelay must be at least minimumRetryDelay"));
86
87
  const retryDelay = (attempts, configuration, parked, initialDelay = configuration.minimumRetryDelay) => parked ? configuration.parkedRetryDelay : Math.min(configuration.parkedRetryDelay, initialDelay * 2 ** (attempts - 1));
87
88
  const getScheduledEventId = (input) => `effect-cf-alarm:${encodeURIComponent(input.tag)}:${encodeURIComponent(input.id)}`;
88
- const alarmRowsSql = `
89
- SELECT a.*, COALESCE(s.revision, '') AS revision,
90
- COALESCE(s.attempts, 0) AS attempts, COALESCE(s.parked, 0) AS parked,
91
- s.retry_at, COALESCE(s.progress, -1) AS progress
92
- FROM effect_cf_scheduled_alarms a
93
- LEFT JOIN effect_cf_alarm_attempts s USING (storage_id)`;
94
- const sameRevisionSql = `storage_id = ? AND COALESCE(
95
- (SELECT revision FROM effect_cf_alarm_attempts WHERE storage_id = ?), '') = ?`;
89
+ const alarmRowsSql = "SELECT a.* FROM effect_cf_scheduled_alarms a";
90
+ const sameRevisionSql = "storage_id = ? AND revision = ?";
96
91
  /** Content-free: no alarm identifiers, payload, error text or consumer state. */
97
92
  const AlarmParked = S.TaggedStruct("AlarmParked", {
98
93
  attempts: S.Int,
@@ -142,6 +137,7 @@ const getScheduleConfiguration = Effect.fnUntraced(function* (defaults) {
142
137
  return yield* Effect.try({
143
138
  try: () => S.decodeUnknownSync(ScheduleConfigurationSchema)({
144
139
  dispatchAfterEvent: input.dispatchAfterEvent ?? false,
140
+ inFlightRecovery: Math.ceil(Duration.toMillis(input.inFlightRecovery ?? DEFAULT_IN_FLIGHT_RECOVERY_MS)),
145
141
  minimumRetryDelay: Math.ceil(Duration.toMillis(input.minimumRetryDelay ?? 1e3)),
146
142
  unchangedAttemptBudget: input.unchangedAttemptBudget ?? 8,
147
143
  parkedRetryDelay: Math.ceil(Duration.toMillis(input.parkedRetryDelay ?? 36e5)),
@@ -167,6 +163,7 @@ const AlarmRefSchema = S.Struct({
167
163
  tag: S.NonEmptyString
168
164
  });
169
165
  const decodeAlarmRef = (input) => S.decodeUnknownEffect(AlarmRefSchema)(input).pipe(Effect.mapError((cause) => new InvalidAlarmRefError({ cause })));
166
+ const StorageSqlClient = S.declare((value) => Predicate.hasProperty(value, SqliteClient.TypeId));
170
167
  /** @internal */
171
168
  const RunAlarm = Symbol("effect-cf/DurableObjectAlarm/RunAlarm");
172
169
  /** @internal */
@@ -181,15 +178,30 @@ const decodeStoredPayload = (row) => S.decodeUnknownEffect(StoredPayloadString)(
181
178
  })));
182
179
  const encodeStoredPayload = (payload) => S.encodeEffect(StoredPayloadString)(payload).pipe(Effect.mapError((cause) => new InvalidAlarmPayloadError({ cause })));
183
180
  const ensureTable = Effect.fnUntraced(function* (state) {
184
- yield* state.storage.sql.exec(INIT_TABLE_SQL);
185
181
  if ((yield* (yield* state.storage.sql.exec("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?", LIFECYCLE_INDEX)).toArray()).length > 0) return;
182
+ yield* state.storage.sql.exec(INIT_TABLE_SQL);
186
183
  const columns = yield* state.storage.sql.exec("SELECT name FROM pragma_table_info('effect_cf_scheduled_alarms')");
187
184
  const names = new Set((yield* columns.toArray()).map((column) => column.name));
188
185
  if (!names.has("lifecycle")) yield* state.storage.sql.exec("ALTER TABLE effect_cf_scheduled_alarms ADD COLUMN lifecycle TEXT NOT NULL DEFAULT 'automatic'");
189
186
  if (!names.has("wake_at")) yield* state.storage.sql.exec("ALTER TABLE effect_cf_scheduled_alarms ADD COLUMN wake_at INTEGER NOT NULL DEFAULT 0");
187
+ for (const [column, definition] of Object.entries({
188
+ revision: "TEXT NOT NULL DEFAULT ''",
189
+ attempts: "INTEGER NOT NULL DEFAULT 0",
190
+ parked: "INTEGER NOT NULL DEFAULT 0",
191
+ retry_at: "INTEGER",
192
+ progress: "INTEGER NOT NULL DEFAULT -1"
193
+ })) if (!names.has(column)) yield* state.storage.sql.exec(`ALTER TABLE effect_cf_scheduled_alarms ADD COLUMN ${column} ${definition}`);
194
+ if ((yield* (yield* state.storage.sql.exec("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'effect_cf_alarm_attempts'")).toArray()).length > 0) {
195
+ yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET
196
+ (revision, attempts, parked, retry_at, progress) =
197
+ (SELECT revision, attempts, parked, retry_at, progress FROM effect_cf_alarm_attempts s
198
+ WHERE s.storage_id = effect_cf_scheduled_alarms.storage_id)
199
+ WHERE storage_id IN (SELECT storage_id FROM effect_cf_alarm_attempts)`);
200
+ yield* state.storage.sql.exec("DROP TABLE effect_cf_alarm_attempts");
201
+ }
190
202
  yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET
191
- wake_at = MAX(run_at, COALESCE((SELECT retry_at FROM effect_cf_alarm_attempts s
192
- WHERE s.storage_id = effect_cf_scheduled_alarms.storage_id), run_at))`);
203
+ wake_at = MAX(run_at, COALESCE(retry_at, run_at))`);
204
+ yield* state.storage.sql.exec("DROP INDEX IF EXISTS idx_effect_cf_scheduled_alarms_lifecycle_wake_at_storage_id");
193
205
  yield* state.storage.sql.exec("DROP INDEX IF EXISTS idx_effect_cf_scheduled_alarms_run_at_storage_id");
194
206
  yield* state.storage.sql.exec(`CREATE INDEX IF NOT EXISTS ${WAKE_INDEX}
195
207
  ON effect_cf_scheduled_alarms (wake_at, storage_id)`);
@@ -343,7 +355,7 @@ const makeDefinition = (definitions) => {
343
355
  ...bind(alarms),
344
356
  deferWakes: alarms.deferWakes,
345
357
  getAlarmStatus: (input) => alarms.getAlarmStatus(input),
346
- transaction: (closure) => alarms.transaction((tx) => closure(bind(tx)))
358
+ transaction: (closure, options) => alarms.transaction((tx) => closure(bind(tx)), options)
347
359
  }),
348
360
  handlers: (handlers, options) => processDue((event) => Effect.gen(function* () {
349
361
  const definition = definitionFor(event.tag);
@@ -440,18 +452,14 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
440
452
  yield* writeNativeAlarm(next?.run_at ?? null);
441
453
  });
442
454
  const writeAttempts = Effect.fnUntraced(function* (storageId, attempts, parked, retryAt, progress) {
443
- yield* state.storage.sql.exec(`INSERT OR REPLACE INTO effect_cf_alarm_attempts
444
- (storage_id, revision, attempts, parked, retry_at, progress)
445
- VALUES (?, ?, ?, ?, ?, ?)`, storageId, crypto.randomUUID(), attempts, parked, retryAt, progress);
446
- yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms
447
- SET wake_at = MAX(run_at, COALESCE(?, run_at))
448
- WHERE storage_id = ?`, retryAt, storageId);
455
+ yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET revision = ?, attempts = ?, parked = ?,
456
+ retry_at = ?, progress = ?, wake_at = MAX(run_at, COALESCE(?, run_at))
457
+ WHERE storage_id = ?`, crypto.randomUUID(), attempts, parked, retryAt, progress, retryAt, storageId);
449
458
  });
450
459
  const cancelAlarm = Effect.fn("DurableObjectAlarm.cancelAlarm")(function* (input) {
451
460
  const ref = yield* decodeAlarmRef(input);
452
461
  const storageId = getScheduledEventId(ref);
453
462
  yield* state.storage.sql.exec(`DELETE FROM effect_cf_scheduled_alarms WHERE storage_id = ?`, storageId);
454
- yield* state.storage.sql.exec(`DELETE FROM effect_cf_alarm_attempts WHERE storage_id = ?`, storageId);
455
463
  });
456
464
  const scheduleAlarm = Effect.fn("DurableObjectAlarm.scheduleAlarm")(function* (input, earlier, reports) {
457
465
  const configuration = yield* getScheduleConfiguration(configurationDefaults);
@@ -461,6 +469,19 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
461
469
  const repeatEveryMillis = yield* toRepeatEveryMillis(input.repeatEvery, configuration.minimumRepeatInterval);
462
470
  const payload = yield* encodeStoredPayload(input.payload);
463
471
  const storageId = getScheduledEventId(ref);
472
+ const runAt = DateTime.toEpochMillis(input.runAt);
473
+ if (lifecycle === "manual") {
474
+ const deadline = earlier ? "MIN(run_at, excluded.run_at)" : "excluded.run_at";
475
+ const nextPayload = earlier ? "CASE WHEN run_at <= excluded.run_at THEN payload ELSE excluded.payload END" : "excluded.payload";
476
+ yield* state.storage.sql.exec(`INSERT INTO effect_cf_scheduled_alarms
477
+ (storage_id, alarm_id, tag, lifecycle, run_at, wake_at, payload, revision)
478
+ VALUES (?, ?, ?, 'manual', ?, ?, ?, ?)
479
+ ON CONFLICT(storage_id) DO UPDATE SET
480
+ lifecycle = 'manual', run_at = ${deadline}, wake_at = ${deadline},
481
+ payload = ${nextPayload}, revision = excluded.revision,
482
+ repeat_every_ms = NULL, attempts = 0, parked = 0, retry_at = NULL, progress = -1`, storageId, ref.id, ref.tag, runAt, runAt, payload, crypto.randomUUID());
483
+ return;
484
+ }
464
485
  const existing = yield* readRow(storageId);
465
486
  const pass = yield* CurrentAlarmPass;
466
487
  const source = lifecycle === "automatic" && pass?.row.lifecycle === "automatic" ? pass.row : void 0;
@@ -473,16 +494,15 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
473
494
  const parked = attempts > 0 && (attempts >= configuration.unchangedAttemptBudget || source?.parked === 1 || existing?.parked === 1) ? 1 : 0;
474
495
  const now = yield* Clock.currentTimeMillis;
475
496
  const retryAt = attempts === 0 ? null : now + retryDelay(attempts, configuration, parked === 1);
476
- const runAt = DateTime.toEpochMillis(input.runAt);
477
497
  const keepEarlier = earlier && existing !== void 0 && existing.run_at <= runAt;
478
498
  const scheduledAt = keepEarlier ? existing.run_at : runAt;
479
499
  const scheduledPayload = keepEarlier ? existing.payload : payload;
480
- const scheduledRepeat = lifecycle === "manual" ? null : keepEarlier ? existing.repeat_every_ms : repeatEveryMillis;
481
- const nextProgress = lifecycle === "manual" ? -1 : Math.max(progress, input.progress ?? -1);
500
+ const scheduledRepeat = keepEarlier ? existing.repeat_every_ms : repeatEveryMillis;
501
+ const nextProgress = Math.max(progress, input.progress ?? -1);
482
502
  yield* state.storage.sql.exec(`INSERT OR REPLACE INTO effect_cf_scheduled_alarms
483
- (storage_id, alarm_id, tag, lifecycle, run_at, repeat_every_ms, payload)
484
- VALUES (?, ?, ?, ?, ?, ?, ?)`, storageId, ref.id, ref.tag, lifecycle, scheduledAt, scheduledRepeat, scheduledPayload);
485
- yield* writeAttempts(storageId, attempts, parked, retryAt, nextProgress);
503
+ (storage_id, alarm_id, tag, lifecycle, run_at, repeat_every_ms, payload,
504
+ wake_at, revision, attempts, parked, retry_at, progress)
505
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, storageId, ref.id, ref.tag, lifecycle, scheduledAt, scheduledRepeat, scheduledPayload, Math.max(scheduledAt, retryAt ?? scheduledAt), crypto.randomUUID(), attempts, parked, retryAt, nextProgress);
486
506
  if (parked === 1 && retryAt !== null && (existing?.parked ?? source?.parked ?? 0) === 0) reports.push(storageId);
487
507
  });
488
508
  const reportParked = Effect.fnUntraced(function* (storageIds) {
@@ -500,14 +520,14 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
500
520
  yield* Effect.exit(Effect.suspend(() => report(event)));
501
521
  }
502
522
  });
503
- const transaction = Effect.fnUntraced(function* (closure) {
523
+ const transaction = Effect.fnUntraced(function* (closure, options) {
504
524
  const pass = yield* CurrentAlarmPass;
505
525
  if (pass !== void 0 && !pass.active) return yield* Effect.fail(new StorageOperationError({
506
526
  operation: "alarm.transaction",
507
527
  cause: /* @__PURE__ */ new Error("Alarm handlers cannot mutate detached work after their pass ends")
508
528
  }));
509
529
  const reports = [];
510
- const result = yield* state.storage.transaction(() => Effect.withFiber((owner) => {
530
+ const body = Effect.withFiber((owner) => {
511
531
  let active = true;
512
532
  const requireActive = (effect) => Effect.withFiber((fiber) => active && fiber === owner && (pass === void 0 || pass.active) ? effect : Effect.fail(new StorageOperationError({
513
533
  operation: "alarm.transaction",
@@ -525,7 +545,21 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
525
545
  yield* reconcileAlarm();
526
546
  return result;
527
547
  });
528
- }));
548
+ });
549
+ const result = yield* options === void 0 ? state.storage.transaction(() => body) : Effect.gen(function* () {
550
+ const client = yield* S.decodeUnknownEffect(StorageSqlClient)(options.sqlClient).pipe(Effect.mapError((cause) => new StorageOperationError({
551
+ operation: "alarm.transaction",
552
+ cause
553
+ })));
554
+ if (client.config.storage !== state.raw.storage) return yield* Effect.fail(new StorageOperationError({
555
+ operation: "alarm.transaction",
556
+ cause: /* @__PURE__ */ new Error("Alarm transactions require this Durable Object's storage-backed SQL client")
557
+ }));
558
+ return yield* client.withTransaction(body).pipe(Effect.mapError((cause) => cause instanceof SqlError ? new StorageOperationError({
559
+ operation: "alarm.transaction",
560
+ cause
561
+ }) : cause));
562
+ });
529
563
  yield* reportParked(reports);
530
564
  return result;
531
565
  });
@@ -533,7 +567,7 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
533
567
  const configuration = yield* getScheduleConfiguration(configurationDefaults);
534
568
  const now = yield* Clock.currentTimeMillis;
535
569
  if (wakeDeferrals === 0) {
536
- deferredRecoveryDelay = configuration.parkedRetryDelay;
570
+ deferredRecoveryDelay = configuration.inFlightRecovery;
537
571
  deferredRecoveryAt = now + deferredRecoveryDelay;
538
572
  wakesReady ??= Deferred.makeUnsafe();
539
573
  }
@@ -566,6 +600,16 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
566
600
  })).pipe(Effect.onExit((exit) => Effect.gen(function* () {
567
601
  const reconcile = dispatchNeedsReconcile || dispatchStarted || Exit.isFailure(exit);
568
602
  dispatchActive = Exit.isFailure(exit) && !dispatchStarted;
603
+ if (dispatchActive) {
604
+ const recoveryDelay = yield* getScheduleConfiguration(configurationDefaults).pipe(Effect.match({
605
+ onFailure: () => PARKED_RETRY_DELAY_MS,
606
+ onSuccess: (configuration) => configuration.parkedRetryDelay
607
+ }));
608
+ if (wakeDeferrals === 0) {
609
+ deferredRecoveryDelay = recoveryDelay;
610
+ deferredRecoveryAt = 0;
611
+ }
612
+ }
569
613
  const reconciled = yield* (reconcile ? transaction(() => Effect.void) : Effect.void).pipe(Effect.exit);
570
614
  dispatchActive = false;
571
615
  pendingAlarm = void 0;
@@ -584,11 +628,11 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
584
628
  });
585
629
  const acknowledgeAlarm = Effect.fnUntraced(function* (row, configuration) {
586
630
  if (row.repeat_every_ms === null) {
587
- if ((yield* (yield* state.storage.sql.exec(`DELETE FROM effect_cf_scheduled_alarms WHERE ${sameRevisionSql}`, row.storage_id, row.storage_id, row.revision)).rowsWritten) > 0) yield* state.storage.sql.exec(`DELETE FROM effect_cf_alarm_attempts WHERE storage_id = ?`, row.storage_id);
631
+ yield* state.storage.sql.exec(`DELETE FROM effect_cf_scheduled_alarms WHERE ${sameRevisionSql}`, row.storage_id, row.revision);
588
632
  return;
589
633
  }
590
634
  const now = yield* Clock.currentTimeMillis;
591
- if ((yield* (yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET run_at = ? WHERE ${sameRevisionSql}`, now + Math.max(configuration.minimumRepeatInterval, row.repeat_every_ms), row.storage_id, row.storage_id, row.revision)).rowsWritten) > 0) yield* writeAttempts(row.storage_id, 0, 0, null, row.progress);
635
+ if ((yield* (yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET run_at = ? WHERE ${sameRevisionSql}`, now + Math.max(configuration.minimumRepeatInterval, row.repeat_every_ms), row.storage_id, row.revision)).rowsWritten) > 0) yield* writeAttempts(row.storage_id, 0, 0, null, row.progress);
592
636
  });
593
637
  const processDueAlarms = Effect.fn("DurableObjectAlarm.processDueAlarms")(function* (handle, options) {
594
638
  const configuration = yield* getScheduleConfiguration(configurationDefaults);
@@ -607,7 +651,10 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
607
651
  const dueRows = yield* select("automatic", limit);
608
652
  const manualRows = yield* select("manual", DEFAULT_PROCESS_DUE_ALARMS_LIMIT);
609
653
  if (manualRows.length > 0) yield* transaction(() => Effect.gen(function* () {
610
- for (const row of manualRows) yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET run_at = ?, wake_at = ? WHERE ${sameRevisionSql}`, now + configuration.parkedRetryDelay, now + configuration.parkedRetryDelay, row.storage_id, row.storage_id, row.revision);
654
+ for (const row of manualRows) {
655
+ const recoveryAt = now + (options?.[ManualTags]?.has(row.tag) ? configuration.inFlightRecovery : configuration.parkedRetryDelay);
656
+ yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET run_at = ?, wake_at = ? WHERE ${sameRevisionSql}`, recoveryAt, recoveryAt, row.storage_id, row.revision);
657
+ }
611
658
  }));
612
659
  const handled = [];
613
660
  const failed = [];
@@ -665,7 +712,10 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
665
712
  const permits = dispatch?.permits ?? (yield* Semaphore.make(4));
666
713
  const automatic = Effect.forEach(dueRows, handleRow, { discard: true }).pipe(Effect.exit);
667
714
  const exits = yield* manualRows.length === 0 ? automatic.pipe(Effect.map((exit) => [exit])) : Effect.all([automatic, Effect.forEach(manualRows, (row) => permits.withPermits(1)(handleRow(row)).pipe(Effect.exit), { concurrency: 4 }).pipe(Effect.flatMap(Exit.asVoidAll), Effect.exit)], { concurrency: 2 });
668
- yield* transaction(() => Effect.void);
715
+ yield* transaction(() => Effect.gen(function* () {
716
+ const recoveryAt = (yield* Clock.currentTimeMillis) + configuration.parkedRetryDelay;
717
+ for (const row of manualRows) yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET run_at = ?, wake_at = ? WHERE ${sameRevisionSql}`, recoveryAt, recoveryAt, row.storage_id, row.revision);
718
+ }));
669
719
  yield* Exit.asVoidAll(exits);
670
720
  return {
671
721
  failed,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effect-cf",
3
- "version": "0.54.0",
3
+ "version": "0.56.0",
4
4
  "description": "Effect-native primitives for Cloudflare Workers and bindings.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,3 +1,4 @@
1
+ import { SqliteClient } from "@effect/sql-sqlite-do";
1
2
  import * as Clock from "effect/Clock";
2
3
  import * as Context from "effect/Context";
3
4
  import * as Data from "effect/Data";
@@ -10,6 +11,8 @@ import * as Layer from "effect/Layer";
10
11
  import * as Predicate from "effect/Predicate";
11
12
  import * as S from "effect/Schema";
12
13
  import * as Semaphore from "effect/Semaphore";
14
+ import type { SqlClient } from "effect/sql/SqlClient";
15
+ import { SqlError } from "effect/sql/SqlError";
13
16
 
14
17
  import { DurableObjectState } from "./DurableObjectState";
15
18
  import { type SqlStorageValue, StorageOperationError } from "./DurableObjectStorage";
@@ -24,20 +27,17 @@ CREATE TABLE IF NOT EXISTS effect_cf_scheduled_alarms (
24
27
  run_at INTEGER NOT NULL,
25
28
  wake_at INTEGER NOT NULL DEFAULT 0,
26
29
  repeat_every_ms INTEGER,
27
- payload TEXT NOT NULL
28
- );
29
- CREATE TABLE IF NOT EXISTS effect_cf_alarm_attempts (
30
- storage_id TEXT PRIMARY KEY,
31
- revision TEXT NOT NULL,
32
- attempts INTEGER NOT NULL,
33
- parked INTEGER NOT NULL,
30
+ payload TEXT NOT NULL,
31
+ revision TEXT NOT NULL DEFAULT '',
32
+ attempts INTEGER NOT NULL DEFAULT 0,
33
+ parked INTEGER NOT NULL DEFAULT 0,
34
34
  retry_at INTEGER,
35
- progress INTEGER NOT NULL
35
+ progress INTEGER NOT NULL DEFAULT -1
36
36
  );
37
37
  `;
38
38
 
39
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";
40
+ const LIFECYCLE_INDEX = "idx_effect_cf_scheduled_alarms_lifecycle_wake_at_storage_id_v2";
41
41
 
42
42
  const DEFAULT_PROCESS_DUE_ALARMS_LIMIT = 100;
43
43
 
@@ -46,6 +46,7 @@ export const MIN_RETRY_DELAY_MS = 1_000;
46
46
  export const UNCHANGED_ATTEMPT_BUDGET = 8;
47
47
  /** Minimum and default parked recovery interval. */
48
48
  export const PARKED_RETRY_DELAY_MS = 3_600_000;
49
+ const DEFAULT_IN_FLIGHT_RECOVERY_MS = 1_000;
49
50
 
50
51
  /** Absolute product repeat floor. */
51
52
  export const MIN_REPEAT_INTERVAL_MS = 1_000;
@@ -55,6 +56,8 @@ export const DEFAULT_MIN_REPEAT_INTERVAL_MS = 60_000;
55
56
  export interface ScheduleConfiguration {
56
57
  /** Check for due alarms after RPC/fetch responses unwind. Native alarms remain recovery authority. */
57
58
  readonly dispatchAfterEvent?: boolean;
59
+ /** Positive recovery deadline while dispatch or deferWakes is active. Defaults to one second. */
60
+ readonly inFlightRecovery?: Duration.Input;
58
61
  /** Retry floor, at least one second. Defaults to one second. */
59
62
  readonly minimumRetryDelay?: Duration.Input;
60
63
  /** Positive safe integer. Defaults to eight unchanged attempts. */
@@ -81,6 +84,7 @@ const AlarmLifecycleSchema = S.Literals(["automatic", "manual"]);
81
84
 
82
85
  const ScheduleConfigurationSchema = S.Struct({
83
86
  dispatchAfterEvent: S.Boolean,
87
+ inFlightRecovery: safeIntegerAtLeast(1),
84
88
  minimumRetryDelay: safeIntegerAtLeast(MIN_RETRY_DELAY_MS),
85
89
  unchangedAttemptBudget: safeIntegerAtLeast(1),
86
90
  parkedRetryDelay: safeIntegerAtLeast(PARKED_RETRY_DELAY_MS),
@@ -126,15 +130,8 @@ interface AlarmRow extends Record<string, SqlStorageValue> {
126
130
  readonly progress: number;
127
131
  }
128
132
 
129
- const alarmRowsSql = `
130
- SELECT a.*, COALESCE(s.revision, '') AS revision,
131
- COALESCE(s.attempts, 0) AS attempts, COALESCE(s.parked, 0) AS parked,
132
- s.retry_at, COALESCE(s.progress, -1) AS progress
133
- FROM effect_cf_scheduled_alarms a
134
- LEFT JOIN effect_cf_alarm_attempts s USING (storage_id)`;
135
-
136
- const sameRevisionSql = `storage_id = ? AND COALESCE(
137
- (SELECT revision FROM effect_cf_alarm_attempts WHERE storage_id = ?), '') = ?`;
133
+ const alarmRowsSql = "SELECT a.* FROM effect_cf_scheduled_alarms a";
134
+ const sameRevisionSql = "storage_id = ? AND revision = ?";
138
135
 
139
136
  /** Content-free: no alarm identifiers, payload, error text or consumer state. */
140
137
  export const AlarmParked = S.TaggedStruct("AlarmParked", {
@@ -230,6 +227,9 @@ const getScheduleConfiguration = Effect.fnUntraced(function* (defaults: Schedule
230
227
  try: () =>
231
228
  S.decodeUnknownSync(ScheduleConfigurationSchema)({
232
229
  dispatchAfterEvent: input.dispatchAfterEvent ?? false,
230
+ inFlightRecovery: Math.ceil(
231
+ Duration.toMillis(input.inFlightRecovery ?? DEFAULT_IN_FLIGHT_RECOVERY_MS),
232
+ ),
233
233
  minimumRetryDelay: Math.ceil(
234
234
  Duration.toMillis(input.minimumRetryDelay ?? MIN_RETRY_DELAY_MS),
235
235
  ),
@@ -351,6 +351,16 @@ export type AlarmTransaction = Pick<
351
351
  "scheduleAlarm" | "scheduleAlarmEarlier" | "cancelAlarm"
352
352
  >;
353
353
 
354
+ /** Share the same native commit and connection permit as the caller's SQL writes. */
355
+ export interface AlarmTransactionOptions {
356
+ /** Must be a storage-backed @effect/sql-sqlite-do client for this Durable Object. */
357
+ readonly sqlClient: SqlClient;
358
+ }
359
+
360
+ const StorageSqlClient = S.declare<SqliteClient.SqliteClient>(
361
+ (value): value is SqliteClient.SqliteClient => Predicate.hasProperty(value, SqliteClient.TypeId),
362
+ );
363
+
354
364
  export interface AlarmStatus {
355
365
  readonly attempts: number;
356
366
  readonly parked: boolean;
@@ -385,7 +395,7 @@ export type AlarmScheduler = {
385
395
  * object's concurrent and nested scopes. Source/checkpoint transactions still commit;
386
396
  * the last scope reconciles their current deadlines on success, failure or interruption.
387
397
  * 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
398
+ * inFlightRecovery bounds the shared guard; renew it if it expires and is consumed while
389
399
  * scopes remain active. A process loss before exit recovers at that retained alarm.
390
400
  */
391
401
  readonly deferWakes: <A, E, R>(
@@ -394,7 +404,7 @@ export type AlarmScheduler = {
394
404
  /**
395
405
  * Commits local application storage and logical alarms in one native SQLite
396
406
  * Durable Object transaction, reconciling the native alarm before commit.
397
- * Use the supplied mutations, not standalone alarm methods or nested transactions.
407
+ * Use the supplied mutations inside the callback instead of standalone alarm methods.
398
408
  * SqlClient queries must use this same Durable Object's storage.
399
409
  *
400
410
  * Failure, defects and interruption before commit roll back. A lost reply or
@@ -405,6 +415,7 @@ export type AlarmScheduler = {
405
415
  */
406
416
  readonly transaction: <A, E, R>(
407
417
  closure: (alarms: AlarmTransaction) => Effect.Effect<A, E, R>,
418
+ options?: AlarmTransactionOptions,
408
419
  ) => Effect.Effect<A, E | StorageOperationError, R>;
409
420
 
410
421
  readonly cancelAlarm: (
@@ -458,13 +469,13 @@ const encodeStoredPayload = (payload: AlarmPayload) =>
458
469
  );
459
470
 
460
471
  const ensureTable = Effect.fnUntraced(function* (state: DurableObjectState["Service"]) {
461
- yield* state.storage.sql.exec(INIT_TABLE_SQL);
462
472
  const index = yield* state.storage.sql.exec(
463
473
  "SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?",
464
474
  LIFECYCLE_INDEX,
465
475
  );
466
476
 
467
477
  if ((yield* index.toArray()).length > 0) return;
478
+ yield* state.storage.sql.exec(INIT_TABLE_SQL);
468
479
  const columns = yield* state.storage.sql.exec<{ name: string }>(
469
480
  "SELECT name FROM pragma_table_info('effect_cf_scheduled_alarms')",
470
481
  );
@@ -480,11 +491,36 @@ const ensureTable = Effect.fnUntraced(function* (state: DurableObjectState["Serv
480
491
  "ALTER TABLE effect_cf_scheduled_alarms ADD COLUMN wake_at INTEGER NOT NULL DEFAULT 0",
481
492
  );
482
493
  }
483
- // Backfill once, including retained schedules from before attempt tracking existed.
484
- // The wake index is the durable migration marker and is created only after the backfill.
494
+ for (const [column, definition] of Object.entries({
495
+ revision: "TEXT NOT NULL DEFAULT ''",
496
+ attempts: "INTEGER NOT NULL DEFAULT 0",
497
+ parked: "INTEGER NOT NULL DEFAULT 0",
498
+ retry_at: "INTEGER",
499
+ progress: "INTEGER NOT NULL DEFAULT -1",
500
+ })) {
501
+ if (!names.has(column))
502
+ yield* state.storage.sql.exec(
503
+ `ALTER TABLE effect_cf_scheduled_alarms ADD COLUMN ${column} ${definition}`,
504
+ );
505
+ }
506
+ const attempts = yield* state.storage.sql.exec(
507
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'effect_cf_alarm_attempts'",
508
+ );
509
+
510
+ if ((yield* attempts.toArray()).length > 0) {
511
+ yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET
512
+ (revision, attempts, parked, retry_at, progress) =
513
+ (SELECT revision, attempts, parked, retry_at, progress FROM effect_cf_alarm_attempts s
514
+ WHERE s.storage_id = effect_cf_scheduled_alarms.storage_id)
515
+ WHERE storage_id IN (SELECT storage_id FROM effect_cf_alarm_attempts)`);
516
+ yield* state.storage.sql.exec("DROP TABLE effect_cf_alarm_attempts");
517
+ }
518
+ // The replacement lifecycle index marks the committed single-row migration.
485
519
  yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET
486
- wake_at = MAX(run_at, COALESCE((SELECT retry_at FROM effect_cf_alarm_attempts s
487
- WHERE s.storage_id = effect_cf_scheduled_alarms.storage_id), run_at))`);
520
+ wake_at = MAX(run_at, COALESCE(retry_at, run_at))`);
521
+ yield* state.storage.sql.exec(
522
+ "DROP INDEX IF EXISTS idx_effect_cf_scheduled_alarms_lifecycle_wake_at_storage_id",
523
+ );
488
524
  yield* state.storage.sql.exec(
489
525
  "DROP INDEX IF EXISTS idx_effect_cf_scheduled_alarms_run_at_storage_id",
490
526
  );
@@ -676,6 +712,7 @@ export interface DefinedAlarmScheduler<
676
712
  ) => ReturnType<AlarmScheduler["getAlarmStatus"]>;
677
713
  readonly transaction: <A, E, R>(
678
714
  closure: (alarms: DefinedAlarmTransaction<Definitions>) => Effect.Effect<A, E, R>,
715
+ options?: AlarmTransactionOptions,
679
716
  ) => Effect.Effect<A, E | StorageOperationError, R>;
680
717
  }
681
718
 
@@ -943,7 +980,7 @@ const makeDefinition = <const Definitions extends AlarmDefinitions>(definitions:
943
980
  ...bind(alarms),
944
981
  deferWakes: alarms.deferWakes,
945
982
  getAlarmStatus: (input) => alarms.getAlarmStatus(input),
946
- transaction: (closure) => alarms.transaction((tx) => closure(bind(tx))),
983
+ transaction: (closure, options) => alarms.transaction((tx) => closure(bind(tx)), options),
947
984
  }),
948
985
  handlers: <R = never, E = never>(
949
986
  handlers: DefinedAlarmHandlers<Definitions, R, E>,
@@ -1154,21 +1191,14 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1154
1191
  progress: number,
1155
1192
  ) {
1156
1193
  yield* state.storage.sql.exec(
1157
- `INSERT OR REPLACE INTO effect_cf_alarm_attempts
1158
- (storage_id, revision, attempts, parked, retry_at, progress)
1159
- VALUES (?, ?, ?, ?, ?, ?)`,
1160
- storageId,
1194
+ `UPDATE effect_cf_scheduled_alarms SET revision = ?, attempts = ?, parked = ?,
1195
+ retry_at = ?, progress = ?, wake_at = MAX(run_at, COALESCE(?, run_at))
1196
+ WHERE storage_id = ?`,
1161
1197
  crypto.randomUUID(),
1162
1198
  attempts,
1163
1199
  parked,
1164
1200
  retryAt,
1165
1201
  progress,
1166
- );
1167
- // Materialize the effective wake in the same transaction for indexed dispatch/reconciliation.
1168
- yield* state.storage.sql.exec(
1169
- `UPDATE effect_cf_scheduled_alarms
1170
- SET wake_at = MAX(run_at, COALESCE(?, run_at))
1171
- WHERE storage_id = ?`,
1172
1202
  retryAt,
1173
1203
  storageId,
1174
1204
  );
@@ -1182,10 +1212,6 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1182
1212
  `DELETE FROM effect_cf_scheduled_alarms WHERE storage_id = ?`,
1183
1213
  storageId,
1184
1214
  );
1185
- yield* state.storage.sql.exec(
1186
- `DELETE FROM effect_cf_alarm_attempts WHERE storage_id = ?`,
1187
- storageId,
1188
- );
1189
1215
  });
1190
1216
 
1191
1217
  const scheduleAlarm = Effect.fn("DurableObjectAlarm.scheduleAlarm")(function* (
@@ -1215,6 +1241,34 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1215
1241
  );
1216
1242
  const payload = yield* encodeStoredPayload(input.payload);
1217
1243
  const storageId = getScheduledEventId(ref);
1244
+ const runAt = DateTime.toEpochMillis(input.runAt);
1245
+
1246
+ if (lifecycle === "manual") {
1247
+ // The queue owns attempts. One row carries the aggregate deadline and dispatch fence.
1248
+ const deadline = earlier ? "MIN(run_at, excluded.run_at)" : "excluded.run_at";
1249
+ const nextPayload = earlier
1250
+ ? "CASE WHEN run_at <= excluded.run_at THEN payload ELSE excluded.payload END"
1251
+ : "excluded.payload";
1252
+
1253
+ yield* state.storage.sql.exec(
1254
+ `INSERT INTO effect_cf_scheduled_alarms
1255
+ (storage_id, alarm_id, tag, lifecycle, run_at, wake_at, payload, revision)
1256
+ VALUES (?, ?, ?, 'manual', ?, ?, ?, ?)
1257
+ ON CONFLICT(storage_id) DO UPDATE SET
1258
+ lifecycle = 'manual', run_at = ${deadline}, wake_at = ${deadline},
1259
+ payload = ${nextPayload}, revision = excluded.revision,
1260
+ repeat_every_ms = NULL, attempts = 0, parked = 0, retry_at = NULL, progress = -1`,
1261
+ storageId,
1262
+ ref.id,
1263
+ ref.tag,
1264
+ runAt,
1265
+ runAt,
1266
+ payload,
1267
+ crypto.randomUUID(),
1268
+ );
1269
+
1270
+ return;
1271
+ }
1218
1272
  const existing = yield* readRow(storageId);
1219
1273
  const pass = yield* CurrentAlarmPass;
1220
1274
  const source =
@@ -1252,22 +1306,17 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1252
1306
  const now = yield* Clock.currentTimeMillis;
1253
1307
  const retryAt =
1254
1308
  attempts === 0 ? null : now + retryDelay(attempts, configuration, parked === 1);
1255
- const runAt = DateTime.toEpochMillis(input.runAt);
1256
1309
  const keepEarlier = earlier && existing !== undefined && existing.run_at <= runAt;
1257
1310
  const scheduledAt = keepEarlier ? existing.run_at : runAt;
1258
1311
  const scheduledPayload = keepEarlier ? existing.payload : payload;
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);
1312
+ const scheduledRepeat = keepEarlier ? existing.repeat_every_ms : repeatEveryMillis;
1313
+ const nextProgress = Math.max(progress, input.progress ?? -1);
1266
1314
 
1267
1315
  yield* state.storage.sql.exec(
1268
1316
  `INSERT OR REPLACE INTO effect_cf_scheduled_alarms
1269
- (storage_id, alarm_id, tag, lifecycle, run_at, repeat_every_ms, payload)
1270
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
1317
+ (storage_id, alarm_id, tag, lifecycle, run_at, repeat_every_ms, payload,
1318
+ wake_at, revision, attempts, parked, retry_at, progress)
1319
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1271
1320
  storageId,
1272
1321
  ref.id,
1273
1322
  ref.tag,
@@ -1275,8 +1324,13 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1275
1324
  scheduledAt,
1276
1325
  scheduledRepeat,
1277
1326
  scheduledPayload,
1327
+ Math.max(scheduledAt, retryAt ?? scheduledAt),
1328
+ crypto.randomUUID(),
1329
+ attempts,
1330
+ parked,
1331
+ retryAt,
1332
+ nextProgress,
1278
1333
  );
1279
- yield* writeAttempts(storageId, attempts, parked, retryAt, nextProgress);
1280
1334
  if (parked === 1 && retryAt !== null && (existing?.parked ?? source?.parked ?? 0) === 0) {
1281
1335
  reports.push(storageId);
1282
1336
  }
@@ -1303,20 +1357,22 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1303
1357
  }
1304
1358
  });
1305
1359
 
1306
- const transaction: AlarmScheduler["transaction"] = Effect.fnUntraced(function* (closure) {
1307
- const pass = yield* CurrentAlarmPass;
1360
+ const transaction: AlarmScheduler["transaction"] = Effect.fnUntraced(
1361
+ function* (closure, options) {
1362
+ const pass = yield* CurrentAlarmPass;
1308
1363
 
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
- }
1317
- const reports: string[] = [];
1318
- const result = yield* state.storage.transaction(() =>
1319
- Effect.withFiber((owner) => {
1364
+ if (pass !== undefined && !pass.active) {
1365
+ return yield* Effect.fail(
1366
+ new StorageOperationError({
1367
+ operation: "alarm.transaction",
1368
+ cause: new Error(
1369
+ "Alarm handlers cannot mutate detached work after their pass ends",
1370
+ ),
1371
+ }),
1372
+ );
1373
+ }
1374
+ const reports: string[] = [];
1375
+ const body = Effect.withFiber((owner) => {
1320
1376
  let active = true;
1321
1377
  const requireActive = <A, E>(effect: Effect.Effect<A, E>) =>
1322
1378
  Effect.withFiber<A, E | StorageOperationError>((fiber) =>
@@ -1354,13 +1410,44 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1354
1410
 
1355
1411
  return result;
1356
1412
  });
1357
- }),
1358
- );
1413
+ });
1414
+ const result = yield* options === undefined
1415
+ ? state.storage.transaction(() => body)
1416
+ : Effect.gen(function* () {
1417
+ const client = yield* S.decodeUnknownEffect(StorageSqlClient)(
1418
+ options.sqlClient,
1419
+ ).pipe(
1420
+ Effect.mapError(
1421
+ (cause) => new StorageOperationError({ operation: "alarm.transaction", cause }),
1422
+ ),
1423
+ );
1359
1424
 
1360
- yield* reportParked(reports);
1425
+ if (client.config.storage !== state.raw.storage)
1426
+ return yield* Effect.fail(
1427
+ new StorageOperationError({
1428
+ operation: "alarm.transaction",
1429
+ cause: new Error(
1430
+ "Alarm transactions require this Durable Object's storage-backed SQL client",
1431
+ ),
1432
+ }),
1433
+ );
1361
1434
 
1362
- return result;
1363
- });
1435
+ return yield* client
1436
+ .withTransaction(body)
1437
+ .pipe(
1438
+ Effect.mapError((cause) =>
1439
+ cause instanceof SqlError
1440
+ ? new StorageOperationError({ operation: "alarm.transaction", cause })
1441
+ : cause,
1442
+ ),
1443
+ );
1444
+ });
1445
+
1446
+ yield* reportParked(reports);
1447
+
1448
+ return result;
1449
+ },
1450
+ );
1364
1451
 
1365
1452
  const deferWakes: AlarmScheduler["deferWakes"] = (body) =>
1366
1453
  Effect.acquireUseRelease(
@@ -1369,7 +1456,7 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1369
1456
  const now = yield* Clock.currentTimeMillis;
1370
1457
 
1371
1458
  if (wakeDeferrals === 0) {
1372
- deferredRecoveryDelay = configuration.parkedRetryDelay;
1459
+ deferredRecoveryDelay = configuration.inFlightRecovery;
1373
1460
  deferredRecoveryAt = now + deferredRecoveryDelay;
1374
1461
  wakesReady ??= Deferred.makeUnsafe<void>();
1375
1462
  }
@@ -1448,6 +1535,22 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1448
1535
  dispatchNeedsReconcile || dispatchStarted || Exit.isFailure(exit);
1449
1536
 
1450
1537
  dispatchActive = Exit.isFailure(exit) && !dispatchStarted;
1538
+ if (dispatchActive) {
1539
+ const recoveryDelay = yield* getScheduleConfiguration(
1540
+ configurationDefaults,
1541
+ ).pipe(
1542
+ Effect.match({
1543
+ onFailure: () => PARKED_RETRY_DELAY_MS,
1544
+ onSuccess: (configuration) => configuration.parkedRetryDelay,
1545
+ }),
1546
+ );
1547
+
1548
+ // A concurrent deferred scope retains ownership of its short guard.
1549
+ if (wakeDeferrals === 0) {
1550
+ deferredRecoveryDelay = recoveryDelay;
1551
+ deferredRecoveryAt = 0;
1552
+ }
1553
+ }
1451
1554
  const reconciled = yield* (
1452
1555
  reconcile ? transaction(() => Effect.void) : Effect.void
1453
1556
  ).pipe(Effect.exit);
@@ -1493,20 +1596,12 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1493
1596
  configuration: ResolvedScheduleConfiguration,
1494
1597
  ) {
1495
1598
  if (row.repeat_every_ms === null) {
1496
- const cursor = yield* state.storage.sql.exec(
1599
+ yield* state.storage.sql.exec(
1497
1600
  `DELETE FROM effect_cf_scheduled_alarms WHERE ${sameRevisionSql}`,
1498
1601
  row.storage_id,
1499
- row.storage_id,
1500
1602
  row.revision,
1501
1603
  );
1502
1604
 
1503
- if ((yield* cursor.rowsWritten) > 0) {
1504
- yield* state.storage.sql.exec(
1505
- `DELETE FROM effect_cf_alarm_attempts WHERE storage_id = ?`,
1506
- row.storage_id,
1507
- );
1508
- }
1509
-
1510
1605
  return;
1511
1606
  }
1512
1607
  const now = yield* Clock.currentTimeMillis;
@@ -1514,7 +1609,6 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1514
1609
  `UPDATE effect_cf_scheduled_alarms SET run_at = ? WHERE ${sameRevisionSql}`,
1515
1610
  now + Math.max(configuration.minimumRepeatInterval, row.repeat_every_ms),
1516
1611
  row.storage_id,
1517
- row.storage_id,
1518
1612
  row.revision,
1519
1613
  );
1520
1614
 
@@ -1570,12 +1664,17 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1570
1664
  yield* transaction(() =>
1571
1665
  Effect.gen(function* () {
1572
1666
  for (const row of manualRows) {
1573
- // Keep the selected revision: a racing replacement must survive this guard.
1667
+ // Keep work eligible after eviction; unknown registrations stay parked.
1668
+ const recoveryAt =
1669
+ now +
1670
+ (options?.[ManualTags]?.has(row.tag)
1671
+ ? configuration.inFlightRecovery
1672
+ : configuration.parkedRetryDelay);
1673
+
1574
1674
  yield* state.storage.sql.exec(
1575
1675
  `UPDATE effect_cf_scheduled_alarms SET run_at = ?, wake_at = ? WHERE ${sameRevisionSql}`,
1576
- now + configuration.parkedRetryDelay,
1577
- now + configuration.parkedRetryDelay,
1578
- row.storage_id,
1676
+ recoveryAt,
1677
+ recoveryAt,
1579
1678
  row.storage_id,
1580
1679
  row.revision,
1581
1680
  );
@@ -1686,7 +1785,22 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1686
1785
  { concurrency: 2 },
1687
1786
  );
1688
1787
 
1689
- yield* transaction(() => Effect.void);
1788
+ yield* transaction(() =>
1789
+ Effect.gen(function* () {
1790
+ const recoveryAt = (yield* Clock.currentTimeMillis) + configuration.parkedRetryDelay;
1791
+
1792
+ for (const row of manualRows) {
1793
+ // Only a returned handler with no newer checkpoint falls back to parked recovery.
1794
+ yield* state.storage.sql.exec(
1795
+ `UPDATE effect_cf_scheduled_alarms SET run_at = ?, wake_at = ? WHERE ${sameRevisionSql}`,
1796
+ recoveryAt,
1797
+ recoveryAt,
1798
+ row.storage_id,
1799
+ row.revision,
1800
+ );
1801
+ }
1802
+ }),
1803
+ );
1690
1804
  yield* Exit.asVoidAll(exits);
1691
1805
 
1692
1806
  return { failed, handled, parked };