effect-cf 0.55.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,11 @@
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
+
3
9
  ## 0.55.0
4
10
 
5
11
  ### 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. */
@@ -161,6 +162,11 @@ type ProcessDueAlarmsHandler<R = never, E = never> = (event: DurableObjectAlarmE
161
162
  * fiber; forked work and use after the callback ends fail with StorageOperationError.
162
163
  */
163
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
+ }
164
170
  interface AlarmStatus {
165
171
  readonly attempts: number;
166
172
  readonly parked: boolean;
@@ -193,7 +199,7 @@ type AlarmScheduler = {
193
199
  /**
194
200
  * Commits local application storage and logical alarms in one native SQLite
195
201
  * Durable Object transaction, reconciling the native alarm before commit.
196
- * Use the supplied mutations, not standalone alarm methods or nested transactions.
202
+ * Use the supplied mutations inside the callback instead of standalone alarm methods.
197
203
  * SqlClient queries must use this same Durable Object's storage.
198
204
  *
199
205
  * Failure, defects and interruption before commit roll back. A lost reply or
@@ -202,7 +208,7 @@ type AlarmScheduler = {
202
208
  * Cloudflare's native retries are bounded; composition does not remove the
203
209
  * pre-arm requirement or promise infinite retry liveness.
204
210
  */
205
- 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>;
206
212
  readonly cancelAlarm: (input: AlarmRef) => Effect.Effect<void, InvalidAlarmRefError | StorageOperationError>;
207
213
  /** Inspect retained work, including hourly recovery deadlines. */
208
214
  readonly getAlarmStatus: (input: AlarmRef) => Effect.Effect<AlarmStatus | undefined, InvalidAlarmRefError | StorageOperationError>;
@@ -267,7 +273,7 @@ interface DefinedAlarmScheduler<Definitions extends AlarmDefinitions> extends De
267
273
  readonly [TypedAlarmSchedulerTypeId]: typeof TypedAlarmSchedulerTypeId;
268
274
  readonly deferWakes: AlarmScheduler["deferWakes"];
269
275
  readonly getAlarmStatus: (input: AlarmRef<keyof Definitions & string>) => ReturnType<AlarmScheduler["getAlarmStatus"]>;
270
- 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>;
271
277
  }
272
278
  /** Registers a dispatcher and the service authorized to schedule its alarms. */
273
279
  interface AlarmRegistration<Self, R = never, E = never> {
@@ -304,4 +310,4 @@ declare class DurableObjectAlarm extends DurableObjectAlarm_base {
304
310
  static readonly layer: Layer.Layer<DurableObjectAlarm, never, DurableObjectState>;
305
311
  }
306
312
  //#endregion
307
- 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,19 +53,16 @@ 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. */
@@ -87,14 +86,8 @@ const ScheduleConfigurationSchema = S.Struct({
87
86
  }).check(S.makeFilter((configuration) => configuration.parkedRetryDelay >= configuration.minimumRetryDelay ? void 0 : "parkedRetryDelay must be at least minimumRetryDelay"));
88
87
  const retryDelay = (attempts, configuration, parked, initialDelay = configuration.minimumRetryDelay) => parked ? configuration.parkedRetryDelay : Math.min(configuration.parkedRetryDelay, initialDelay * 2 ** (attempts - 1));
89
88
  const getScheduledEventId = (input) => `effect-cf-alarm:${encodeURIComponent(input.tag)}:${encodeURIComponent(input.id)}`;
90
- const alarmRowsSql = `
91
- SELECT a.*, COALESCE(s.revision, '') AS revision,
92
- COALESCE(s.attempts, 0) AS attempts, COALESCE(s.parked, 0) AS parked,
93
- s.retry_at, COALESCE(s.progress, -1) AS progress
94
- FROM effect_cf_scheduled_alarms a
95
- LEFT JOIN effect_cf_alarm_attempts s USING (storage_id)`;
96
- const sameRevisionSql = `storage_id = ? AND COALESCE(
97
- (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 = ?";
98
91
  /** Content-free: no alarm identifiers, payload, error text or consumer state. */
99
92
  const AlarmParked = S.TaggedStruct("AlarmParked", {
100
93
  attempts: S.Int,
@@ -170,6 +163,7 @@ const AlarmRefSchema = S.Struct({
170
163
  tag: S.NonEmptyString
171
164
  });
172
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));
173
167
  /** @internal */
174
168
  const RunAlarm = Symbol("effect-cf/DurableObjectAlarm/RunAlarm");
175
169
  /** @internal */
@@ -184,15 +178,30 @@ const decodeStoredPayload = (row) => S.decodeUnknownEffect(StoredPayloadString)(
184
178
  })));
185
179
  const encodeStoredPayload = (payload) => S.encodeEffect(StoredPayloadString)(payload).pipe(Effect.mapError((cause) => new InvalidAlarmPayloadError({ cause })));
186
180
  const ensureTable = Effect.fnUntraced(function* (state) {
187
- yield* state.storage.sql.exec(INIT_TABLE_SQL);
188
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);
189
183
  const columns = yield* state.storage.sql.exec("SELECT name FROM pragma_table_info('effect_cf_scheduled_alarms')");
190
184
  const names = new Set((yield* columns.toArray()).map((column) => column.name));
191
185
  if (!names.has("lifecycle")) yield* state.storage.sql.exec("ALTER TABLE effect_cf_scheduled_alarms ADD COLUMN lifecycle TEXT NOT NULL DEFAULT 'automatic'");
192
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
+ }
193
202
  yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET
194
- wake_at = MAX(run_at, COALESCE((SELECT retry_at FROM effect_cf_alarm_attempts s
195
- 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");
196
205
  yield* state.storage.sql.exec("DROP INDEX IF EXISTS idx_effect_cf_scheduled_alarms_run_at_storage_id");
197
206
  yield* state.storage.sql.exec(`CREATE INDEX IF NOT EXISTS ${WAKE_INDEX}
198
207
  ON effect_cf_scheduled_alarms (wake_at, storage_id)`);
@@ -346,7 +355,7 @@ const makeDefinition = (definitions) => {
346
355
  ...bind(alarms),
347
356
  deferWakes: alarms.deferWakes,
348
357
  getAlarmStatus: (input) => alarms.getAlarmStatus(input),
349
- transaction: (closure) => alarms.transaction((tx) => closure(bind(tx)))
358
+ transaction: (closure, options) => alarms.transaction((tx) => closure(bind(tx)), options)
350
359
  }),
351
360
  handlers: (handlers, options) => processDue((event) => Effect.gen(function* () {
352
361
  const definition = definitionFor(event.tag);
@@ -443,18 +452,14 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
443
452
  yield* writeNativeAlarm(next?.run_at ?? null);
444
453
  });
445
454
  const writeAttempts = Effect.fnUntraced(function* (storageId, attempts, parked, retryAt, progress) {
446
- yield* state.storage.sql.exec(`INSERT OR REPLACE INTO effect_cf_alarm_attempts
447
- (storage_id, revision, attempts, parked, retry_at, progress)
448
- VALUES (?, ?, ?, ?, ?, ?)`, storageId, crypto.randomUUID(), attempts, parked, retryAt, progress);
449
- yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms
450
- SET wake_at = MAX(run_at, COALESCE(?, run_at))
451
- 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);
452
458
  });
453
459
  const cancelAlarm = Effect.fn("DurableObjectAlarm.cancelAlarm")(function* (input) {
454
460
  const ref = yield* decodeAlarmRef(input);
455
461
  const storageId = getScheduledEventId(ref);
456
462
  yield* state.storage.sql.exec(`DELETE FROM effect_cf_scheduled_alarms WHERE storage_id = ?`, storageId);
457
- yield* state.storage.sql.exec(`DELETE FROM effect_cf_alarm_attempts WHERE storage_id = ?`, storageId);
458
463
  });
459
464
  const scheduleAlarm = Effect.fn("DurableObjectAlarm.scheduleAlarm")(function* (input, earlier, reports) {
460
465
  const configuration = yield* getScheduleConfiguration(configurationDefaults);
@@ -464,6 +469,19 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
464
469
  const repeatEveryMillis = yield* toRepeatEveryMillis(input.repeatEvery, configuration.minimumRepeatInterval);
465
470
  const payload = yield* encodeStoredPayload(input.payload);
466
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
+ }
467
485
  const existing = yield* readRow(storageId);
468
486
  const pass = yield* CurrentAlarmPass;
469
487
  const source = lifecycle === "automatic" && pass?.row.lifecycle === "automatic" ? pass.row : void 0;
@@ -476,16 +494,15 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
476
494
  const parked = attempts > 0 && (attempts >= configuration.unchangedAttemptBudget || source?.parked === 1 || existing?.parked === 1) ? 1 : 0;
477
495
  const now = yield* Clock.currentTimeMillis;
478
496
  const retryAt = attempts === 0 ? null : now + retryDelay(attempts, configuration, parked === 1);
479
- const runAt = DateTime.toEpochMillis(input.runAt);
480
497
  const keepEarlier = earlier && existing !== void 0 && existing.run_at <= runAt;
481
498
  const scheduledAt = keepEarlier ? existing.run_at : runAt;
482
499
  const scheduledPayload = keepEarlier ? existing.payload : payload;
483
- const scheduledRepeat = lifecycle === "manual" ? null : keepEarlier ? existing.repeat_every_ms : repeatEveryMillis;
484
- 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);
485
502
  yield* state.storage.sql.exec(`INSERT OR REPLACE INTO effect_cf_scheduled_alarms
486
- (storage_id, alarm_id, tag, lifecycle, run_at, repeat_every_ms, payload)
487
- VALUES (?, ?, ?, ?, ?, ?, ?)`, storageId, ref.id, ref.tag, lifecycle, scheduledAt, scheduledRepeat, scheduledPayload);
488
- 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);
489
506
  if (parked === 1 && retryAt !== null && (existing?.parked ?? source?.parked ?? 0) === 0) reports.push(storageId);
490
507
  });
491
508
  const reportParked = Effect.fnUntraced(function* (storageIds) {
@@ -503,14 +520,14 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
503
520
  yield* Effect.exit(Effect.suspend(() => report(event)));
504
521
  }
505
522
  });
506
- const transaction = Effect.fnUntraced(function* (closure) {
523
+ const transaction = Effect.fnUntraced(function* (closure, options) {
507
524
  const pass = yield* CurrentAlarmPass;
508
525
  if (pass !== void 0 && !pass.active) return yield* Effect.fail(new StorageOperationError({
509
526
  operation: "alarm.transaction",
510
527
  cause: /* @__PURE__ */ new Error("Alarm handlers cannot mutate detached work after their pass ends")
511
528
  }));
512
529
  const reports = [];
513
- const result = yield* state.storage.transaction(() => Effect.withFiber((owner) => {
530
+ const body = Effect.withFiber((owner) => {
514
531
  let active = true;
515
532
  const requireActive = (effect) => Effect.withFiber((fiber) => active && fiber === owner && (pass === void 0 || pass.active) ? effect : Effect.fail(new StorageOperationError({
516
533
  operation: "alarm.transaction",
@@ -528,7 +545,21 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
528
545
  yield* reconcileAlarm();
529
546
  return result;
530
547
  });
531
- }));
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
+ });
532
563
  yield* reportParked(reports);
533
564
  return result;
534
565
  });
@@ -597,11 +628,11 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
597
628
  });
598
629
  const acknowledgeAlarm = Effect.fnUntraced(function* (row, configuration) {
599
630
  if (row.repeat_every_ms === null) {
600
- 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);
601
632
  return;
602
633
  }
603
634
  const now = yield* Clock.currentTimeMillis;
604
- 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);
605
636
  });
606
637
  const processDueAlarms = Effect.fn("DurableObjectAlarm.processDueAlarms")(function* (handle, options) {
607
638
  const configuration = yield* getScheduleConfiguration(configurationDefaults);
@@ -622,7 +653,7 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
622
653
  if (manualRows.length > 0) yield* transaction(() => Effect.gen(function* () {
623
654
  for (const row of manualRows) {
624
655
  const recoveryAt = now + (options?.[ManualTags]?.has(row.tag) ? configuration.inFlightRecovery : configuration.parkedRetryDelay);
625
- yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET run_at = ?, wake_at = ? WHERE ${sameRevisionSql}`, recoveryAt, recoveryAt, row.storage_id, row.storage_id, row.revision);
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);
626
657
  }
627
658
  }));
628
659
  const handled = [];
@@ -683,7 +714,7 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
683
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 });
684
715
  yield* transaction(() => Effect.gen(function* () {
685
716
  const recoveryAt = (yield* Clock.currentTimeMillis) + configuration.parkedRetryDelay;
686
- 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.storage_id, row.revision);
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);
687
718
  }));
688
719
  yield* Exit.asVoidAll(exits);
689
720
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effect-cf",
3
- "version": "0.55.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
 
@@ -130,15 +130,8 @@ interface AlarmRow extends Record<string, SqlStorageValue> {
130
130
  readonly progress: number;
131
131
  }
132
132
 
133
- const alarmRowsSql = `
134
- SELECT a.*, COALESCE(s.revision, '') AS revision,
135
- COALESCE(s.attempts, 0) AS attempts, COALESCE(s.parked, 0) AS parked,
136
- s.retry_at, COALESCE(s.progress, -1) AS progress
137
- FROM effect_cf_scheduled_alarms a
138
- LEFT JOIN effect_cf_alarm_attempts s USING (storage_id)`;
139
-
140
- const sameRevisionSql = `storage_id = ? AND COALESCE(
141
- (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 = ?";
142
135
 
143
136
  /** Content-free: no alarm identifiers, payload, error text or consumer state. */
144
137
  export const AlarmParked = S.TaggedStruct("AlarmParked", {
@@ -358,6 +351,16 @@ export type AlarmTransaction = Pick<
358
351
  "scheduleAlarm" | "scheduleAlarmEarlier" | "cancelAlarm"
359
352
  >;
360
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
+
361
364
  export interface AlarmStatus {
362
365
  readonly attempts: number;
363
366
  readonly parked: boolean;
@@ -401,7 +404,7 @@ export type AlarmScheduler = {
401
404
  /**
402
405
  * Commits local application storage and logical alarms in one native SQLite
403
406
  * Durable Object transaction, reconciling the native alarm before commit.
404
- * Use the supplied mutations, not standalone alarm methods or nested transactions.
407
+ * Use the supplied mutations inside the callback instead of standalone alarm methods.
405
408
  * SqlClient queries must use this same Durable Object's storage.
406
409
  *
407
410
  * Failure, defects and interruption before commit roll back. A lost reply or
@@ -412,6 +415,7 @@ export type AlarmScheduler = {
412
415
  */
413
416
  readonly transaction: <A, E, R>(
414
417
  closure: (alarms: AlarmTransaction) => Effect.Effect<A, E, R>,
418
+ options?: AlarmTransactionOptions,
415
419
  ) => Effect.Effect<A, E | StorageOperationError, R>;
416
420
 
417
421
  readonly cancelAlarm: (
@@ -465,13 +469,13 @@ const encodeStoredPayload = (payload: AlarmPayload) =>
465
469
  );
466
470
 
467
471
  const ensureTable = Effect.fnUntraced(function* (state: DurableObjectState["Service"]) {
468
- yield* state.storage.sql.exec(INIT_TABLE_SQL);
469
472
  const index = yield* state.storage.sql.exec(
470
473
  "SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?",
471
474
  LIFECYCLE_INDEX,
472
475
  );
473
476
 
474
477
  if ((yield* index.toArray()).length > 0) return;
478
+ yield* state.storage.sql.exec(INIT_TABLE_SQL);
475
479
  const columns = yield* state.storage.sql.exec<{ name: string }>(
476
480
  "SELECT name FROM pragma_table_info('effect_cf_scheduled_alarms')",
477
481
  );
@@ -487,11 +491,36 @@ const ensureTable = Effect.fnUntraced(function* (state: DurableObjectState["Serv
487
491
  "ALTER TABLE effect_cf_scheduled_alarms ADD COLUMN wake_at INTEGER NOT NULL DEFAULT 0",
488
492
  );
489
493
  }
490
- // Backfill once, including retained schedules from before attempt tracking existed.
491
- // 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.
492
519
  yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET
493
- wake_at = MAX(run_at, COALESCE((SELECT retry_at FROM effect_cf_alarm_attempts s
494
- 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
+ );
495
524
  yield* state.storage.sql.exec(
496
525
  "DROP INDEX IF EXISTS idx_effect_cf_scheduled_alarms_run_at_storage_id",
497
526
  );
@@ -683,6 +712,7 @@ export interface DefinedAlarmScheduler<
683
712
  ) => ReturnType<AlarmScheduler["getAlarmStatus"]>;
684
713
  readonly transaction: <A, E, R>(
685
714
  closure: (alarms: DefinedAlarmTransaction<Definitions>) => Effect.Effect<A, E, R>,
715
+ options?: AlarmTransactionOptions,
686
716
  ) => Effect.Effect<A, E | StorageOperationError, R>;
687
717
  }
688
718
 
@@ -950,7 +980,7 @@ const makeDefinition = <const Definitions extends AlarmDefinitions>(definitions:
950
980
  ...bind(alarms),
951
981
  deferWakes: alarms.deferWakes,
952
982
  getAlarmStatus: (input) => alarms.getAlarmStatus(input),
953
- transaction: (closure) => alarms.transaction((tx) => closure(bind(tx))),
983
+ transaction: (closure, options) => alarms.transaction((tx) => closure(bind(tx)), options),
954
984
  }),
955
985
  handlers: <R = never, E = never>(
956
986
  handlers: DefinedAlarmHandlers<Definitions, R, E>,
@@ -1161,21 +1191,14 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1161
1191
  progress: number,
1162
1192
  ) {
1163
1193
  yield* state.storage.sql.exec(
1164
- `INSERT OR REPLACE INTO effect_cf_alarm_attempts
1165
- (storage_id, revision, attempts, parked, retry_at, progress)
1166
- VALUES (?, ?, ?, ?, ?, ?)`,
1167
- 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 = ?`,
1168
1197
  crypto.randomUUID(),
1169
1198
  attempts,
1170
1199
  parked,
1171
1200
  retryAt,
1172
1201
  progress,
1173
- );
1174
- // Materialize the effective wake in the same transaction for indexed dispatch/reconciliation.
1175
- yield* state.storage.sql.exec(
1176
- `UPDATE effect_cf_scheduled_alarms
1177
- SET wake_at = MAX(run_at, COALESCE(?, run_at))
1178
- WHERE storage_id = ?`,
1179
1202
  retryAt,
1180
1203
  storageId,
1181
1204
  );
@@ -1189,10 +1212,6 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1189
1212
  `DELETE FROM effect_cf_scheduled_alarms WHERE storage_id = ?`,
1190
1213
  storageId,
1191
1214
  );
1192
- yield* state.storage.sql.exec(
1193
- `DELETE FROM effect_cf_alarm_attempts WHERE storage_id = ?`,
1194
- storageId,
1195
- );
1196
1215
  });
1197
1216
 
1198
1217
  const scheduleAlarm = Effect.fn("DurableObjectAlarm.scheduleAlarm")(function* (
@@ -1222,6 +1241,34 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1222
1241
  );
1223
1242
  const payload = yield* encodeStoredPayload(input.payload);
1224
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
+ }
1225
1272
  const existing = yield* readRow(storageId);
1226
1273
  const pass = yield* CurrentAlarmPass;
1227
1274
  const source =
@@ -1259,22 +1306,17 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1259
1306
  const now = yield* Clock.currentTimeMillis;
1260
1307
  const retryAt =
1261
1308
  attempts === 0 ? null : now + retryDelay(attempts, configuration, parked === 1);
1262
- const runAt = DateTime.toEpochMillis(input.runAt);
1263
1309
  const keepEarlier = earlier && existing !== undefined && existing.run_at <= runAt;
1264
1310
  const scheduledAt = keepEarlier ? existing.run_at : runAt;
1265
1311
  const scheduledPayload = keepEarlier ? existing.payload : payload;
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);
1312
+ const scheduledRepeat = keepEarlier ? existing.repeat_every_ms : repeatEveryMillis;
1313
+ const nextProgress = Math.max(progress, input.progress ?? -1);
1273
1314
 
1274
1315
  yield* state.storage.sql.exec(
1275
1316
  `INSERT OR REPLACE INTO effect_cf_scheduled_alarms
1276
- (storage_id, alarm_id, tag, lifecycle, run_at, repeat_every_ms, payload)
1277
- 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1278
1320
  storageId,
1279
1321
  ref.id,
1280
1322
  ref.tag,
@@ -1282,8 +1324,13 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1282
1324
  scheduledAt,
1283
1325
  scheduledRepeat,
1284
1326
  scheduledPayload,
1327
+ Math.max(scheduledAt, retryAt ?? scheduledAt),
1328
+ crypto.randomUUID(),
1329
+ attempts,
1330
+ parked,
1331
+ retryAt,
1332
+ nextProgress,
1285
1333
  );
1286
- yield* writeAttempts(storageId, attempts, parked, retryAt, nextProgress);
1287
1334
  if (parked === 1 && retryAt !== null && (existing?.parked ?? source?.parked ?? 0) === 0) {
1288
1335
  reports.push(storageId);
1289
1336
  }
@@ -1310,20 +1357,22 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1310
1357
  }
1311
1358
  });
1312
1359
 
1313
- const transaction: AlarmScheduler["transaction"] = Effect.fnUntraced(function* (closure) {
1314
- const pass = yield* CurrentAlarmPass;
1360
+ const transaction: AlarmScheduler["transaction"] = Effect.fnUntraced(
1361
+ function* (closure, options) {
1362
+ const pass = yield* CurrentAlarmPass;
1315
1363
 
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
- }
1324
- const reports: string[] = [];
1325
- const result = yield* state.storage.transaction(() =>
1326
- 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) => {
1327
1376
  let active = true;
1328
1377
  const requireActive = <A, E>(effect: Effect.Effect<A, E>) =>
1329
1378
  Effect.withFiber<A, E | StorageOperationError>((fiber) =>
@@ -1361,13 +1410,44 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1361
1410
 
1362
1411
  return result;
1363
1412
  });
1364
- }),
1365
- );
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
+ );
1366
1424
 
1367
- 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
+ );
1368
1434
 
1369
- return result;
1370
- });
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
+ );
1371
1451
 
1372
1452
  const deferWakes: AlarmScheduler["deferWakes"] = (body) =>
1373
1453
  Effect.acquireUseRelease(
@@ -1516,20 +1596,12 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1516
1596
  configuration: ResolvedScheduleConfiguration,
1517
1597
  ) {
1518
1598
  if (row.repeat_every_ms === null) {
1519
- const cursor = yield* state.storage.sql.exec(
1599
+ yield* state.storage.sql.exec(
1520
1600
  `DELETE FROM effect_cf_scheduled_alarms WHERE ${sameRevisionSql}`,
1521
1601
  row.storage_id,
1522
- row.storage_id,
1523
1602
  row.revision,
1524
1603
  );
1525
1604
 
1526
- if ((yield* cursor.rowsWritten) > 0) {
1527
- yield* state.storage.sql.exec(
1528
- `DELETE FROM effect_cf_alarm_attempts WHERE storage_id = ?`,
1529
- row.storage_id,
1530
- );
1531
- }
1532
-
1533
1605
  return;
1534
1606
  }
1535
1607
  const now = yield* Clock.currentTimeMillis;
@@ -1537,7 +1609,6 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1537
1609
  `UPDATE effect_cf_scheduled_alarms SET run_at = ? WHERE ${sameRevisionSql}`,
1538
1610
  now + Math.max(configuration.minimumRepeatInterval, row.repeat_every_ms),
1539
1611
  row.storage_id,
1540
- row.storage_id,
1541
1612
  row.revision,
1542
1613
  );
1543
1614
 
@@ -1605,7 +1676,6 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1605
1676
  recoveryAt,
1606
1677
  recoveryAt,
1607
1678
  row.storage_id,
1608
- row.storage_id,
1609
1679
  row.revision,
1610
1680
  );
1611
1681
  }
@@ -1726,7 +1796,6 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1726
1796
  recoveryAt,
1727
1797
  recoveryAt,
1728
1798
  row.storage_id,
1729
- row.storage_id,
1730
1799
  row.revision,
1731
1800
  );
1732
1801
  }