effect-cf 0.50.0 → 0.52.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.
@@ -9,19 +9,28 @@ import * as S from "effect/Schema";
9
9
  import * as Layer from "effect/Layer";
10
10
  import * as Predicate from "effect/Predicate";
11
11
  import * as Exit from "effect/Exit";
12
- import * as Clock from "effect/Clock";
13
12
  import * as Duration from "effect/Duration";
13
+ import * as Clock from "effect/Clock";
14
14
  import * as DateTime from "effect/DateTime";
15
15
  //#region src/DurableObjectAlarm.ts
16
16
  var DurableObjectAlarm_exports = /* @__PURE__ */ __exportAll({
17
+ AlarmParked: () => AlarmParked,
18
+ AlarmReporter: () => AlarmReporter,
19
+ DEFAULT_MIN_REPEAT_INTERVAL_MS: () => DEFAULT_MIN_REPEAT_INTERVAL_MS,
17
20
  DurableObjectAlarm: () => DurableObjectAlarm,
18
21
  DurableObjectAlarmEvent: () => DurableObjectAlarmEvent,
19
22
  InvalidAlarmPayloadError: () => InvalidAlarmPayloadError,
20
23
  InvalidAlarmRefError: () => InvalidAlarmRefError,
21
24
  InvalidProcessDueAlarmsOptionsError: () => InvalidProcessDueAlarmsOptionsError,
22
25
  InvalidRepeatEveryError: () => InvalidRepeatEveryError,
26
+ InvalidScheduleConfigurationError: () => InvalidScheduleConfigurationError,
27
+ MIN_REPEAT_INTERVAL_MS: () => MIN_REPEAT_INTERVAL_MS,
28
+ MIN_RETRY_DELAY_MS: () => MIN_RETRY_DELAY_MS,
29
+ PARKED_RETRY_DELAY_MS: () => PARKED_RETRY_DELAY_MS,
30
+ ScheduleConfiguration: () => ScheduleConfiguration,
23
31
  StoredAlarmDecodeError: () => StoredAlarmDecodeError,
24
32
  Tag: () => Tag,
33
+ UNCHANGED_ATTEMPT_BUDGET: () => 8,
25
34
  define: () => define,
26
35
  processDue: () => processDue
27
36
  });
@@ -31,15 +40,55 @@ CREATE TABLE IF NOT EXISTS effect_cf_scheduled_alarms (
31
40
  alarm_id TEXT NOT NULL,
32
41
  tag TEXT NOT NULL,
33
42
  run_at INTEGER NOT NULL,
43
+ wake_at INTEGER NOT NULL DEFAULT 0,
34
44
  repeat_every_ms INTEGER,
35
45
  payload TEXT NOT NULL
36
46
  );
37
- CREATE INDEX IF NOT EXISTS idx_effect_cf_scheduled_alarms_run_at_storage_id
38
- ON effect_cf_scheduled_alarms (run_at, storage_id);
47
+ CREATE TABLE IF NOT EXISTS effect_cf_alarm_attempts (
48
+ storage_id TEXT PRIMARY KEY,
49
+ revision TEXT NOT NULL,
50
+ attempts INTEGER NOT NULL,
51
+ parked INTEGER NOT NULL,
52
+ retry_at INTEGER,
53
+ progress INTEGER NOT NULL
54
+ );
39
55
  `;
56
+ const WAKE_INDEX = "idx_effect_cf_scheduled_alarms_wake_at_storage_id";
40
57
  const DEFAULT_PROCESS_DUE_ALARMS_LIMIT = 100;
41
- const DEFAULT_PROCESS_DUE_ALARMS_FAILURE_RESCHEDULE_AFTER = "30 seconds";
58
+ const MIN_RETRY_DELAY_MS = 1e3;
59
+ /** Minimum and default parked recovery interval. */
60
+ const PARKED_RETRY_DELAY_MS = 36e5;
61
+ /** Absolute product repeat floor. */
62
+ const MIN_REPEAT_INTERVAL_MS = 1e3;
63
+ /** Default product schedule floor. Never use repeats to poll state. */
64
+ const DEFAULT_MIN_REPEAT_INTERVAL_MS = 6e4;
65
+ /** Optional policy overrides. Provide with Layer.succeed in the Durable Object's application layer. */
66
+ const ScheduleConfiguration = Context.Reference("effect-cf/DurableObjectAlarm/ScheduleConfiguration", { defaultValue: () => ({}) });
67
+ const safeIntegerAtLeast = (minimum) => S.Int.check(S.isGreaterThanOrEqualTo(minimum), S.isLessThanOrEqualTo(Number.MAX_SAFE_INTEGER));
68
+ const ScheduleConfigurationSchema = S.Struct({
69
+ minimumRetryDelay: safeIntegerAtLeast(MIN_RETRY_DELAY_MS),
70
+ unchangedAttemptBudget: safeIntegerAtLeast(1),
71
+ parkedRetryDelay: safeIntegerAtLeast(PARKED_RETRY_DELAY_MS),
72
+ minimumRepeatInterval: safeIntegerAtLeast(MIN_REPEAT_INTERVAL_MS)
73
+ }).check(S.makeFilter((configuration) => configuration.parkedRetryDelay >= configuration.minimumRetryDelay ? void 0 : "parkedRetryDelay must be at least minimumRetryDelay"));
74
+ const retryDelay = (attempts, configuration, parked, initialDelay = configuration.minimumRetryDelay) => parked ? configuration.parkedRetryDelay : Math.min(configuration.parkedRetryDelay, initialDelay * 2 ** (attempts - 1));
42
75
  const getScheduledEventId = (input) => `effect-cf-alarm:${encodeURIComponent(input.tag)}:${encodeURIComponent(input.id)}`;
76
+ const alarmRowsSql = `
77
+ SELECT a.*, COALESCE(s.revision, '') AS revision,
78
+ COALESCE(s.attempts, 0) AS attempts, COALESCE(s.parked, 0) AS parked,
79
+ s.retry_at, COALESCE(s.progress, -1) AS progress
80
+ FROM effect_cf_scheduled_alarms a
81
+ LEFT JOIN effect_cf_alarm_attempts s USING (storage_id)`;
82
+ const sameRevisionSql = `storage_id = ? AND COALESCE(
83
+ (SELECT revision FROM effect_cf_alarm_attempts WHERE storage_id = ?), '') = ?`;
84
+ /** Content-free: no alarm identifiers, payload, error text or consumer state. */
85
+ const AlarmParked = S.TaggedStruct("AlarmParked", {
86
+ attempts: S.Int,
87
+ retryAt: S.DateTimeUtc
88
+ });
89
+ /** Install with Layer.succeed / Effect.provideService. Reporting cannot undo a committed guard. */
90
+ const AlarmReporter = Context.Reference("effect-cf/DurableObjectAlarm/AlarmReporter", { defaultValue: () => (event) => Effect.logWarning(event) });
91
+ const CurrentAlarmPass = Context.Reference("effect-cf/DurableObjectAlarm/CurrentAlarmPass", { defaultValue: () => void 0 });
43
92
  var InvalidAlarmRefError = class extends Data.TaggedError("InvalidAlarmRefError") {
44
93
  get message() {
45
94
  return `Invalid Durable Object alarm ref: ${causeMessage(this.cause)}`;
@@ -60,6 +109,26 @@ var InvalidProcessDueAlarmsOptionsError = class extends Data.TaggedError("Invali
60
109
  return `Invalid processDueAlarms options: ${causeMessage(this.cause)}`;
61
110
  }
62
111
  };
112
+ var InvalidScheduleConfigurationError = class extends Data.TaggedError("InvalidScheduleConfigurationError") {
113
+ get message() {
114
+ return `Invalid Durable Object alarm configuration: ${causeMessage(this.cause)}`;
115
+ }
116
+ };
117
+ const getScheduleConfiguration = Effect.fnUntraced(function* (defaults) {
118
+ const input = {
119
+ ...defaults,
120
+ ...yield* ScheduleConfiguration
121
+ };
122
+ return yield* Effect.try({
123
+ try: () => S.decodeUnknownSync(ScheduleConfigurationSchema)({
124
+ minimumRetryDelay: Math.ceil(Duration.toMillis(input.minimumRetryDelay ?? 1e3)),
125
+ unchangedAttemptBudget: input.unchangedAttemptBudget ?? 8,
126
+ parkedRetryDelay: Math.ceil(Duration.toMillis(input.parkedRetryDelay ?? 36e5)),
127
+ minimumRepeatInterval: Math.ceil(Duration.toMillis(input.minimumRepeatInterval ?? 6e4))
128
+ }),
129
+ catch: (cause) => new InvalidScheduleConfigurationError({ cause })
130
+ });
131
+ });
63
132
  var StoredAlarmDecodeError = class extends Data.TaggedError("StoredAlarmDecodeError") {
64
133
  get message() {
65
134
  return `Failed to decode stored alarm "${this.storageId}": ${causeMessage(this.cause)}`;
@@ -83,13 +152,24 @@ const decodeStoredPayload = (row) => S.decodeUnknownEffect(StoredPayloadString)(
83
152
  storageId: row.storage_id
84
153
  })));
85
154
  const encodeStoredPayload = (payload) => S.encodeEffect(StoredPayloadString)(payload).pipe(Effect.mapError((cause) => new InvalidAlarmPayloadError({ cause })));
86
- const ensureTable = (state) => state.storage.sql.exec(INIT_TABLE_SQL).pipe(Effect.asVoid);
87
- const toRepeatEveryMillis = (input) => {
155
+ const ensureTable = Effect.fnUntraced(function* (state) {
156
+ yield* state.storage.sql.exec(INIT_TABLE_SQL);
157
+ if ((yield* (yield* state.storage.sql.exec("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?", WAKE_INDEX)).toArray()).length > 0) return;
158
+ const columns = yield* state.storage.sql.exec("SELECT name FROM pragma_table_info('effect_cf_scheduled_alarms')");
159
+ if (!new Set((yield* columns.toArray()).map((column) => column.name)).has("wake_at")) yield* state.storage.sql.exec("ALTER TABLE effect_cf_scheduled_alarms ADD COLUMN wake_at INTEGER NOT NULL DEFAULT 0");
160
+ yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET
161
+ wake_at = MAX(run_at, COALESCE((SELECT retry_at FROM effect_cf_alarm_attempts s
162
+ WHERE s.storage_id = effect_cf_scheduled_alarms.storage_id), run_at))`);
163
+ yield* state.storage.sql.exec("DROP INDEX IF EXISTS idx_effect_cf_scheduled_alarms_run_at_storage_id");
164
+ yield* state.storage.sql.exec(`CREATE INDEX IF NOT EXISTS ${WAKE_INDEX}
165
+ ON effect_cf_scheduled_alarms (wake_at, storage_id)`);
166
+ });
167
+ const toRepeatEveryMillis = (input, minimum) => {
88
168
  if (input === void 0) return Effect.succeed(null);
89
169
  return Effect.try({
90
170
  try: () => {
91
171
  const millis = Duration.toMillis(input);
92
- if (!Number.isFinite(millis) || millis <= 0) throw new Error("Alarm repeatEvery must be a positive finite duration");
172
+ if (!Number.isFinite(millis) || millis < minimum) throw new Error(`Alarm repeatEvery must be at least ${minimum} milliseconds and finite`);
93
173
  return Math.ceil(millis);
94
174
  },
95
175
  catch: (cause) => new InvalidRepeatEveryError({ cause })
@@ -113,15 +193,15 @@ const getProcessLimit = (options) => {
113
193
  if (!Number.isSafeInteger(limit) || limit <= 0) return Effect.fail(new InvalidProcessDueAlarmsOptionsError({ cause: /* @__PURE__ */ new Error("processDueAlarms limit must be a positive safe integer") }));
114
194
  return Effect.succeed(limit);
115
195
  };
116
- const toFailureRescheduleMillis = (input) => Effect.try({
196
+ const toFailureRescheduleMillis = (input, minimum) => Effect.try({
117
197
  try: () => {
118
198
  const millis = Duration.toMillis(input);
119
199
  if (!Number.isFinite(millis) || millis <= 0) throw new Error("Alarm failure rescheduleAfter must be a positive finite duration");
120
- return Math.ceil(millis);
200
+ return Math.max(minimum, Math.ceil(millis));
121
201
  },
122
202
  catch: (cause) => new InvalidProcessDueAlarmsOptionsError({ cause })
123
203
  });
124
- const getFailureRetryDelay = (options) => toFailureRescheduleMillis(options?.retryFailedAfter ?? DEFAULT_PROCESS_DUE_ALARMS_FAILURE_RESCHEDULE_AFTER);
204
+ const getFailureRetryDelay = (options, configuration) => toFailureRescheduleMillis(options?.retryFailedAfter ?? configuration.minimumRetryDelay, configuration.minimumRetryDelay);
125
205
  const getFailureActionMode = (action) => Predicate.isString(action) ? action : action.mode;
126
206
  const getFailureActionRetryDelay = (action) => Predicate.isString(action) || action.mode !== "retry" ? void 0 : action.retryFailedAfter;
127
207
  const processDue = (handle, options = {}) => Effect.gen(function* () {
@@ -140,25 +220,30 @@ const getAlarmDefinitionFailureAction = (definition) => {
140
220
  };
141
221
  const makeDefinition = (definitions) => {
142
222
  const definitionFor = (tag) => Object.hasOwn(definitions, tag) ? definitions[tag] : void 0;
143
- const bind = (mutations) => ({
144
- scheduleAlarm: Effect.fn("DefinedAlarms.scheduleAlarm")(function* (input) {
223
+ const bind = (mutations) => {
224
+ const schedule = (method) => Effect.fn("DefinedAlarms.scheduleAlarm")(function* (input) {
145
225
  const definition = definitionFor(input.tag);
146
226
  if (definition === void 0) return yield* Effect.fail(new InvalidAlarmRefError({ cause: /* @__PURE__ */ new Error(`Unknown alarm tag "${input.tag}"`) }));
147
227
  const payload = yield* S.encodeEffect(getAlarmDefinitionSchema(definition))(input.payload).pipe(Effect.flatMap(S.decodeUnknownEffect(S.Json)), Effect.mapError((cause) => new InvalidAlarmPayloadError({ cause })));
148
- yield* mutations.scheduleAlarm({
228
+ yield* mutations[method]({
149
229
  ...input,
150
230
  payload
151
231
  });
152
- }),
153
- cancelAlarm: Effect.fn("DefinedAlarms.cancelAlarm")(function* (input) {
154
- if (definitionFor(input.tag) === void 0) return yield* Effect.fail(new InvalidAlarmRefError({ cause: /* @__PURE__ */ new Error(`Unknown alarm tag "${input.tag}"`) }));
155
- yield* mutations.cancelAlarm(input);
156
- })
157
- });
232
+ });
233
+ return {
234
+ scheduleAlarm: schedule("scheduleAlarm"),
235
+ scheduleAlarmEarlier: schedule("scheduleAlarmEarlier"),
236
+ cancelAlarm: Effect.fn("DefinedAlarms.cancelAlarm")(function* (input) {
237
+ if (definitionFor(input.tag) === void 0) return yield* Effect.fail(new InvalidAlarmRefError({ cause: /* @__PURE__ */ new Error(`Unknown alarm tag "${input.tag}"`) }));
238
+ yield* mutations.cancelAlarm(input);
239
+ })
240
+ };
241
+ };
158
242
  return {
159
243
  make: (alarms) => ({
160
244
  [TypedAlarmSchedulerTypeId]: TypedAlarmSchedulerTypeId,
161
245
  ...bind(alarms),
246
+ getAlarmStatus: (input) => alarms.getAlarmStatus(input),
162
247
  transaction: (closure) => alarms.transaction((tx) => closure(bind(tx)))
163
248
  }),
164
249
  handlers: (handlers, options) => processDue((event) => Effect.gen(function* () {
@@ -201,93 +286,137 @@ const Tag = () => (id, definitions) => {
201
286
  var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("effect-cf/DurableObjectAlarm") {
202
287
  static layer = Layer.effect(DurableObjectAlarm, Effect.gen(function* () {
203
288
  const state = yield* DurableObjectState;
204
- const reconcileAlarm = Effect.fn("DurableObjectAlarm.reconcileAlarm")(function* () {
205
- const next = (yield* (yield* state.storage.sql.exec(`SELECT run_at FROM effect_cf_scheduled_alarms ORDER BY run_at ASC, storage_id ASC LIMIT 1`)).toArray())[0];
206
- if (next === void 0) {
207
- yield* state.storage.deleteAlarm();
208
- return;
209
- }
210
- yield* state.storage.setAlarm(next.run_at);
289
+ const configurationDefaults = { ...yield* ScheduleConfiguration };
290
+ const readRow = Effect.fnUntraced(function* (storageId) {
291
+ return (yield* (yield* state.storage.sql.exec(`${alarmRowsSql} WHERE a.storage_id = ?`, storageId)).toArray())[0];
211
292
  });
212
- const rescheduleFailedAlarm = Effect.fn("DurableObjectAlarm.rescheduleFailedAlarm")(function* (row, retryDelayMillis) {
213
- const retryAt = (yield* Clock.currentTimeMillis) + retryDelayMillis;
214
- if (row.repeat_every_ms === null) {
215
- yield* (yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms
216
- SET run_at = ?
217
- WHERE storage_id = ?
218
- AND run_at = ?
219
- AND repeat_every_ms IS NULL
220
- AND payload = ?`, retryAt, row.storage_id, row.run_at, row.payload)).rowsWritten;
221
- return;
222
- }
223
- yield* (yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms
224
- SET run_at = ?
225
- WHERE storage_id = ?
226
- AND run_at = ?
227
- AND repeat_every_ms = ?
228
- AND payload = ?`, retryAt, row.storage_id, row.run_at, row.repeat_every_ms, row.payload)).rowsWritten;
293
+ const reconcileAlarm = Effect.fn("DurableObjectAlarm.reconcileAlarm")(function* () {
294
+ const next = (yield* (yield* state.storage.sql.exec(`SELECT wake_at AS run_at FROM effect_cf_scheduled_alarms
295
+ ORDER BY wake_at, storage_id LIMIT 1`)).toArray())[0];
296
+ yield* Effect.tryPromise({
297
+ try: () => next === void 0 ? state.raw.storage.deleteAlarm() : state.raw.storage.setAlarm(next.run_at),
298
+ catch: (cause) => new StorageOperationError({
299
+ operation: next === void 0 ? "deleteAlarm" : "setAlarm",
300
+ cause
301
+ })
302
+ });
229
303
  });
230
- const acknowledgeAlarm = Effect.fn("DurableObjectAlarm.acknowledgeAlarm")(function* (row) {
231
- if (row.repeat_every_ms === null) {
232
- yield* (yield* state.storage.sql.exec(`DELETE FROM effect_cf_scheduled_alarms
233
- WHERE storage_id = ?
234
- AND run_at = ?
235
- AND repeat_every_ms IS NULL
236
- AND payload = ?`, row.storage_id, row.run_at, row.payload)).rowsWritten;
237
- return;
238
- }
239
- const acknowledgedAt = yield* Clock.currentTimeMillis;
240
- yield* (yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms
241
- SET run_at = ?
242
- WHERE storage_id = ?
243
- AND run_at = ?
244
- AND repeat_every_ms = ?
245
- AND payload = ?`, acknowledgedAt + row.repeat_every_ms, row.storage_id, row.run_at, row.repeat_every_ms, row.payload)).rowsWritten;
304
+ const writeAttempts = Effect.fnUntraced(function* (storageId, attempts, parked, retryAt, progress) {
305
+ yield* state.storage.sql.exec(`INSERT OR REPLACE INTO effect_cf_alarm_attempts
306
+ (storage_id, revision, attempts, parked, retry_at, progress)
307
+ VALUES (?, ?, ?, ?, ?, ?)`, storageId, crypto.randomUUID(), attempts, parked, retryAt, progress);
308
+ yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms
309
+ SET wake_at = MAX(run_at, COALESCE(?, run_at))
310
+ WHERE storage_id = ?`, retryAt, storageId);
246
311
  });
247
312
  const cancelAlarm = Effect.fn("DurableObjectAlarm.cancelAlarm")(function* (input) {
248
313
  const ref = yield* decodeAlarmRef(input);
249
- yield* state.storage.sql.exec(`DELETE FROM effect_cf_scheduled_alarms WHERE storage_id = ?`, getScheduledEventId(ref));
314
+ const storageId = getScheduledEventId(ref);
315
+ yield* state.storage.sql.exec(`DELETE FROM effect_cf_scheduled_alarms WHERE storage_id = ?`, storageId);
316
+ yield* state.storage.sql.exec(`DELETE FROM effect_cf_alarm_attempts WHERE storage_id = ?`, storageId);
250
317
  });
251
- const scheduleAlarm = Effect.fn("DurableObjectAlarm.scheduleAlarm")(function* (input) {
318
+ const scheduleAlarm = Effect.fn("DurableObjectAlarm.scheduleAlarm")(function* (input, earlier, reports) {
319
+ const configuration = yield* getScheduleConfiguration(configurationDefaults);
252
320
  const ref = yield* decodeAlarmRef(input);
253
- const repeatEveryMillis = yield* toRepeatEveryMillis(input.repeatEvery);
321
+ const repeatEveryMillis = yield* toRepeatEveryMillis(input.repeatEvery, configuration.minimumRepeatInterval);
254
322
  const payload = yield* encodeStoredPayload(input.payload);
323
+ const storageId = getScheduledEventId(ref);
324
+ const existing = yield* readRow(storageId);
325
+ const pass = yield* CurrentAlarmPass;
326
+ const source = pass?.row;
327
+ const progress = existing?.progress ?? source?.progress ?? -1;
328
+ if (pass !== void 0 && !pass.active) return yield* Effect.fail(new StorageOperationError({
329
+ operation: "alarm.schedule",
330
+ cause: /* @__PURE__ */ new Error("Alarm handlers cannot schedule detached work after their pass ends")
331
+ }));
332
+ if (input.progress !== void 0) yield* S.decodeUnknownEffect(S.Natural)(input.progress).pipe(Effect.mapError((cause) => new InvalidAlarmRefError({ cause })));
333
+ if (source === void 0 && input.progress !== void 0 && input.progress <= progress) return;
334
+ const progressed = input.progress !== void 0 && input.progress > progress;
335
+ const sourceProgressed = source !== void 0 && storageId === source.storage_id && progress > source.progress;
336
+ const attempts = source === void 0 || progressed || sourceProgressed ? 0 : source.parked === 1 || existing?.parked === 1 ? Math.max(source.attempts, existing?.attempts ?? 0) : Math.min(configuration.unchangedAttemptBudget, Math.max(source.attempts + 1, existing?.attempts ?? 0));
337
+ const parked = attempts > 0 && (attempts >= configuration.unchangedAttemptBudget || source?.parked === 1 || existing?.parked === 1) ? 1 : 0;
338
+ const now = yield* Clock.currentTimeMillis;
339
+ const retryAt = attempts === 0 ? null : now + retryDelay(attempts, configuration, parked === 1);
255
340
  const runAt = DateTime.toEpochMillis(input.runAt);
341
+ const keepEarlier = earlier && existing !== void 0 && existing.run_at <= runAt;
342
+ const scheduledAt = keepEarlier ? existing.run_at : runAt;
343
+ const scheduledPayload = keepEarlier ? existing.payload : payload;
344
+ const scheduledRepeat = keepEarlier ? existing.repeat_every_ms : repeatEveryMillis;
345
+ const nextProgress = Math.max(progress, input.progress ?? -1);
256
346
  yield* state.storage.sql.exec(`INSERT OR REPLACE INTO effect_cf_scheduled_alarms
257
- (storage_id, alarm_id, tag, run_at, repeat_every_ms, payload)
258
- VALUES (?, ?, ?, ?, ?, ?)`, getScheduledEventId(ref), ref.id, ref.tag, runAt, repeatEveryMillis, payload);
347
+ (storage_id, alarm_id, tag, run_at, repeat_every_ms, payload)
348
+ VALUES (?, ?, ?, ?, ?, ?)`, storageId, ref.id, ref.tag, scheduledAt, scheduledRepeat, scheduledPayload);
349
+ yield* writeAttempts(storageId, attempts, parked, retryAt, nextProgress);
350
+ if (parked === 1 && retryAt !== null && (existing?.parked ?? source?.parked ?? 0) === 0) reports.push(storageId);
259
351
  });
260
- const transaction = (closure) => state.storage.transaction(() => Effect.withFiber((owner) => {
261
- let active = true;
262
- const requireActive = (effect) => Effect.withFiber((fiber) => active && fiber === owner ? effect : Effect.fail(new StorageOperationError({
263
- operation: "alarm.transaction",
264
- cause: /* @__PURE__ */ new Error("Alarm mutations require their active transaction callback")
265
- })));
266
- return Effect.gen(function* () {
267
- yield* ensureTable(state);
268
- const result = yield* Effect.suspend(() => closure({
269
- cancelAlarm: (input) => requireActive(cancelAlarm(input)),
270
- scheduleAlarm: (input) => requireActive(scheduleAlarm(input))
271
- })).pipe(Effect.ensuring(Effect.sync(() => {
272
- active = false;
352
+ const reportParked = Effect.fnUntraced(function* (storageIds) {
353
+ if (storageIds.length === 0) return;
354
+ const pass = yield* CurrentAlarmPass;
355
+ const report = yield* AlarmReporter;
356
+ for (const storageId of new Set(storageIds)) {
357
+ const row = yield* readRow(storageId);
358
+ if (row === void 0 || row.parked === 0 || row.retry_at === null) continue;
359
+ const event = AlarmParked.make({
360
+ attempts: row.attempts,
361
+ retryAt: DateTime.makeUnsafe(Math.max(row.run_at, row.retry_at))
362
+ });
363
+ pass?.parked.push(event);
364
+ yield* Effect.exit(Effect.suspend(() => report(event)));
365
+ }
366
+ });
367
+ const transaction = Effect.fnUntraced(function* (closure) {
368
+ const reports = [];
369
+ const result = yield* state.storage.transaction(() => Effect.withFiber((owner) => {
370
+ let active = true;
371
+ const requireActive = (effect) => Effect.withFiber((fiber) => active && fiber === owner ? effect : Effect.fail(new StorageOperationError({
372
+ operation: "alarm.transaction",
373
+ cause: /* @__PURE__ */ new Error("Alarm mutations require their active transaction callback")
273
374
  })));
274
- yield* reconcileAlarm();
275
- return result;
276
- });
277
- }));
375
+ return Effect.gen(function* () {
376
+ yield* ensureTable(state);
377
+ const result = yield* Effect.suspend(() => closure({
378
+ cancelAlarm: (input) => requireActive(cancelAlarm(input)),
379
+ scheduleAlarm: (input) => requireActive(scheduleAlarm(input, false, reports)),
380
+ scheduleAlarmEarlier: (input) => requireActive(scheduleAlarm(input, true, reports))
381
+ })).pipe(Effect.ensuring(Effect.sync(() => {
382
+ active = false;
383
+ })));
384
+ yield* reconcileAlarm();
385
+ return result;
386
+ });
387
+ }));
388
+ yield* reportParked(reports);
389
+ return result;
390
+ });
391
+ const rescheduleFailedAlarm = Effect.fnUntraced(function* (row, initialDelay, configuration, reports) {
392
+ const current = yield* readRow(row.storage_id);
393
+ if (current === void 0 || current.revision !== row.revision) return;
394
+ const attempts = row.parked === 1 ? row.attempts : Math.min(configuration.unchangedAttemptBudget, row.attempts + 1);
395
+ const parked = row.parked === 1 || attempts >= configuration.unchangedAttemptBudget ? 1 : 0;
396
+ const retryAt = (yield* Clock.currentTimeMillis) + retryDelay(attempts, configuration, parked === 1, initialDelay);
397
+ yield* writeAttempts(row.storage_id, attempts, parked, retryAt, row.progress);
398
+ if (parked === 1 && row.parked === 0) reports.push(row.storage_id);
399
+ });
400
+ const acknowledgeAlarm = Effect.fnUntraced(function* (row, configuration) {
401
+ if (row.repeat_every_ms === null) {
402
+ 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);
403
+ return;
404
+ }
405
+ const now = yield* Clock.currentTimeMillis;
406
+ 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);
407
+ });
278
408
  const processDueAlarms = Effect.fn("DurableObjectAlarm.processDueAlarms")(function* (handle, options) {
409
+ const configuration = yield* getScheduleConfiguration(configurationDefaults);
279
410
  yield* ensureTable(state);
280
- const mode = options?.mode ?? "isolated";
281
411
  const limit = yield* getProcessLimit(options);
412
+ const initialDelay = yield* getFailureRetryDelay(options, configuration);
282
413
  const now = yield* Clock.currentTimeMillis;
283
- const dueRows = yield* (yield* state.storage.sql.exec(`SELECT storage_id, alarm_id, tag, run_at, repeat_every_ms, payload
284
- FROM effect_cf_scheduled_alarms
285
- WHERE run_at <= ?
286
- ORDER BY run_at ASC, storage_id ASC
287
- LIMIT ?`, now, limit)).toArray();
414
+ const dueRows = yield* (yield* state.storage.sql.exec(`${alarmRowsSql}
415
+ WHERE a.wake_at <= ? ORDER BY a.wake_at ASC, a.storage_id ASC LIMIT ?`, now, limit)).toArray();
288
416
  const handled = [];
289
417
  const failed = [];
290
- const handleFailure = function* (row, event, cause) {
418
+ const parked = [];
419
+ const handleFailure = Effect.fnUntraced(function* (row, event, cause) {
291
420
  const failure = {
292
421
  cause,
293
422
  event,
@@ -296,49 +425,71 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
296
425
  tag: row.tag
297
426
  };
298
427
  failed.push(failure);
299
- const failureAction = options?.onFailure === void 0 ? void 0 : yield* options.onFailure(failure);
300
- const actionMode = failureAction === void 0 ? mode : getFailureActionMode(failureAction);
301
- if (actionMode === "retry" || actionMode === "isolated") {
302
- const actionRetryDelay = failureAction === void 0 ? void 0 : getFailureActionRetryDelay(failureAction);
303
- const retryDelay = actionRetryDelay === void 0 ? yield* getFailureRetryDelay(options) : yield* toFailureRescheduleMillis(actionRetryDelay);
304
- yield* rescheduleFailedAlarm(row, retryDelay);
305
- return "continue";
306
- }
307
- if (actionMode === "skip-and-advance-repeat") {
308
- yield* acknowledgeAlarm(row);
309
- return "continue";
310
- }
311
- yield* reconcileAlarm();
312
- return "stop";
313
- };
428
+ const actionExit = yield* Effect.exit(options?.onFailure === void 0 ? Effect.void : Effect.suspend(() => options.onFailure(failure)));
429
+ const action = Exit.isSuccess(actionExit) ? actionExit.value : void 0;
430
+ const actionMode = action === void 0 ? "retry" : getFailureActionMode(action);
431
+ const delayInput = action === void 0 ? void 0 : getFailureActionRetryDelay(action);
432
+ const delayExit = yield* Effect.exit(delayInput === void 0 ? Effect.succeed(initialDelay) : toFailureRescheduleMillis(delayInput, configuration.minimumRetryDelay));
433
+ const delay = Exit.isSuccess(delayExit) ? delayExit.value : initialDelay;
434
+ const reports = [];
435
+ yield* transaction(() => actionMode === "skip-and-advance-repeat" ? acknowledgeAlarm(row, configuration) : rescheduleFailedAlarm(row, delay, configuration, reports));
436
+ yield* reportParked(reports);
437
+ if (Exit.isFailure(actionExit)) return yield* Effect.failCause(actionExit.cause);
438
+ if (Exit.isFailure(delayExit)) return yield* Effect.failCause(delayExit.cause);
439
+ });
314
440
  for (const row of dueRows) {
441
+ const current = yield* readRow(row.storage_id);
442
+ if (current === void 0 || current.revision !== row.revision) continue;
443
+ const inPass = (effect) => {
444
+ const pass = {
445
+ row,
446
+ parked,
447
+ active: true
448
+ };
449
+ return effect.pipe(Effect.provideService(CurrentAlarmPass, pass), Effect.ensuring(Effect.sync(() => {
450
+ pass.active = false;
451
+ })));
452
+ };
315
453
  const eventExit = yield* Effect.exit(toAlarmDue(row));
316
454
  if (Exit.isFailure(eventExit)) {
317
- if ((yield* handleFailure(row, void 0, eventExit.cause)) === "stop") return yield* Effect.failCause(eventExit.cause);
455
+ yield* inPass(handleFailure(row, void 0, eventExit.cause));
318
456
  continue;
319
457
  }
320
458
  const event = eventExit.value;
321
- const handleExit = yield* Effect.exit(handle(event));
459
+ const handleExit = yield* Effect.exit(inPass(Effect.suspend(() => handle(event))));
322
460
  if (Exit.isFailure(handleExit)) {
323
- if ((yield* handleFailure(row, event, handleExit.cause)) === "stop") return yield* Effect.failCause(handleExit.cause);
461
+ yield* inPass(handleFailure(row, event, handleExit.cause));
324
462
  continue;
325
463
  }
326
- yield* acknowledgeAlarm(row);
464
+ yield* transaction(() => acknowledgeAlarm(row, configuration));
327
465
  handled.push(event);
328
466
  }
329
- yield* reconcileAlarm();
467
+ yield* transaction(() => Effect.void);
330
468
  return {
331
469
  failed,
332
- handled
470
+ handled,
471
+ parked
333
472
  };
334
473
  });
335
474
  return DurableObjectAlarm.of({
336
475
  cancelAlarm: (input) => transaction((alarms) => alarms.cancelAlarm(input)),
476
+ getAlarmStatus: Effect.fnUntraced(function* (input) {
477
+ const ref = yield* decodeAlarmRef(input);
478
+ yield* ensureTable(state);
479
+ const row = yield* readRow(getScheduledEventId(ref));
480
+ return row === void 0 ? void 0 : {
481
+ attempts: row.attempts,
482
+ parked: row.parked === 1,
483
+ runAt: DateTime.makeUnsafe(row.run_at),
484
+ retryAt: row.retry_at === null ? void 0 : DateTime.makeUnsafe(row.retry_at)
485
+ };
486
+ }),
337
487
  processDueAlarms,
338
488
  scheduleAlarm: (input) => transaction((alarms) => alarms.scheduleAlarm(input)),
489
+ scheduleAlarmEarlier: (input) => transaction((alarms) => alarms.scheduleAlarmEarlier(input)),
339
490
  transaction
340
491
  });
341
492
  }));
342
493
  };
343
494
  //#endregion
344
- export { DurableObjectAlarm, DurableObjectAlarmEvent, DurableObjectAlarm_exports, InvalidAlarmPayloadError, InvalidAlarmRefError, InvalidProcessDueAlarmsOptionsError, InvalidRepeatEveryError, StoredAlarmDecodeError, Tag, define, processDue };
495
+ export { AlarmParked, AlarmReporter, DEFAULT_MIN_REPEAT_INTERVAL_MS, DurableObjectAlarm, DurableObjectAlarmEvent, DurableObjectAlarm_exports, InvalidAlarmPayloadError, InvalidAlarmRefError, InvalidProcessDueAlarmsOptionsError, InvalidRepeatEveryError, InvalidScheduleConfigurationError, MIN_REPEAT_INTERVAL_MS, MIN_RETRY_DELAY_MS, PARKED_RETRY_DELAY_MS, ScheduleConfiguration, StoredAlarmDecodeError, Tag, define, processDue };
@@ -3,8 +3,8 @@ import * as Context$1 from "effect/Context";
3
3
  import * as Effect from "effect/Effect";
4
4
  import * as Layer$1 from "effect/Layer";
5
5
  import * as Option from "effect/Option";
6
- import * as Duration from "effect/Duration";
7
6
  import * as Stream from "effect/Stream";
7
+ import * as Duration from "effect/Duration";
8
8
  import { Scope } from "effect";
9
9
  declare namespace DurableObjectContainer_d_exports {
10
10
  export { ContainerError, ContainerExecOptions, ContainerExecOutput, ContainerExecOutputText, ContainerHttpReadinessOptions, ContainerInfo, ContainerNotConfiguredError, ContainerProcess, ContainerProcessLog, ContainerReadinessTimeoutError, ContainerSnapshot, ContainerSnapshotOptions, ContainerStartOptions, ContainerTcpPort, DurableObjectContainer, DurableObjectContainerService, fromContainer, layer };
@@ -92,6 +92,8 @@ interface ContainerTcpPort {
92
92
  readonly proxy: (request: Request) => Effect.Effect<Response, ContainerError>;
93
93
  /** The caller owns the returned socket and must close it. */
94
94
  readonly connect: (...args: Parameters<globalThis.Fetcher["connect"]>) => Effect.Effect<ReturnType<globalThis.Fetcher["connect"]>, ContainerError>;
95
+ /** Closes the socket when the scope ends, awaiting cleanup and preserving close failures as defects. */
96
+ readonly connectScoped: (...args: Parameters<globalThis.Fetcher["connect"]>) => Effect.Effect<ReturnType<globalThis.Fetcher["connect"]>, ContainerError, Scope.Scope>;
95
97
  }
96
98
  /** Direct container control inside a Durable Object; lifecycle policy belongs to the application. */
97
99
  interface DurableObjectContainerService {
@@ -6,8 +6,8 @@ import * as Data from "effect/Data";
6
6
  import * as Effect from "effect/Effect";
7
7
  import * as Layer from "effect/Layer";
8
8
  import * as Option from "effect/Option";
9
- import * as Duration from "effect/Duration";
10
9
  import * as Stream from "effect/Stream";
10
+ import * as Duration from "effect/Duration";
11
11
  //#region src/DurableObjectContainer.ts
12
12
  var DurableObjectContainer_exports = /* @__PURE__ */ __exportAll({
13
13
  ContainerError: () => ContainerError,
@@ -99,6 +99,7 @@ const fromTcpPort = (port) => {
99
99
  signal: callerSignal === null || callerSignal === void 0 ? signal : AbortSignal.any([callerSignal, signal])
100
100
  });
101
101
  });
102
+ const connect = (...args) => attempt("getTcpPort.connect", () => port.connect(...args));
102
103
  return {
103
104
  raw: port,
104
105
  fetch,
@@ -107,7 +108,8 @@ const fromTcpPort = (port) => {
107
108
  url.protocol = "http:";
108
109
  return new Request(url, request);
109
110
  }).pipe(Effect.flatMap((forwarded) => fetch(forwarded))),
110
- connect: (...args) => attempt("getTcpPort.connect", () => port.connect(...args))
111
+ connect,
112
+ connectScoped: (...args) => Effect.acquireRelease(connect(...args), (socket) => attemptPromise("getTcpPort.close", () => socket.close()).pipe(Effect.orDie))
111
113
  };
112
114
  };
113
115
  /** Wraps `ctx.container` without starting it or taking ownership of its lifecycle. */
@@ -64,8 +64,8 @@ interface LayerOptions {
64
64
  readonly attachmentKey?: string | undefined;
65
65
  /**
66
66
  * Heartbeat behavior. `"auto-response"` (the default) installs Cloudflare's
67
- * hibernation-safe text Ping/Pong response while an RPC socket exists and no
68
- * non-resumable RPC operation is active, then removes it while one is pending.
67
+ * hibernation-safe text Ping/Pong response for the lifetime of RPC sockets,
68
+ * including pending operations. Keepalives never dispatch application code.
69
69
  * `"passthrough"` leaves the Durable Object's existing auto-response
70
70
  * configuration unchanged and makes the application responsible for waking a
71
71
  * lost pending operation.
@@ -70,7 +70,6 @@ const layer = (options = {}) => Layer.effectContext(Effect.gen(function* () {
70
70
  const declarationsByTag = /* @__PURE__ */ new Map();
71
71
  const replayRequests = [];
72
72
  let nextClientId = 0;
73
- let activeNonResumableRequestCount = 0;
74
73
  let writeRequest;
75
74
  for (const declaration of declarations) {
76
75
  if (declaration.id.length === 0 || declaration.rpcTag.length === 0) return yield* Effect.die(/* @__PURE__ */ new Error("Resumable RPC stream declaration ids and tags must be non-empty"));
@@ -137,7 +136,7 @@ const layer = (options = {}) => Layer.effectContext(Effect.gen(function* () {
137
136
  return connection;
138
137
  };
139
138
  const heartbeat = yield* makeHeartbeatController(durableObjectState, serialization, heartbeatPolicy);
140
- const syncHeartbeat = () => heartbeat.setEnabled(connectionsBySocket.size > 0 && activeNonResumableRequestCount === 0);
139
+ const syncHeartbeat = () => heartbeat.setEnabled(connectionsBySocket.size > 0);
141
140
  const persistConnection = Effect.fn("DurableObjectRpcWebSocket.persistConnection")(function* (connection) {
142
141
  return yield* Effect.try({
143
142
  try: () => {
@@ -171,7 +170,6 @@ const layer = (options = {}) => Layer.effectContext(Effect.gen(function* () {
171
170
  if (connection === void 0) return;
172
171
  connectionsBySocket.delete(socket.raw);
173
172
  connectionsById.delete(connection.id);
174
- activeNonResumableRequestCount -= connection.nonResumableRequestIds.size;
175
173
  connection.requestIds.clear();
176
174
  connection.nonResumableRequestIds.clear();
177
175
  connection.subscriptionsByRequestId.clear();
@@ -210,7 +208,6 @@ const layer = (options = {}) => Layer.effectContext(Effect.gen(function* () {
210
208
  } else {
211
209
  connection.requestIds.add(request.id);
212
210
  connection.nonResumableRequestIds.add(request.id);
213
- activeNonResumableRequestCount++;
214
211
  }
215
212
  if (!(yield* persistOrReset(connection))) return false;
216
213
  yield* syncHeartbeat();
@@ -218,7 +215,7 @@ const layer = (options = {}) => Layer.effectContext(Effect.gen(function* () {
218
215
  });
219
216
  const completeRequest = Effect.fn("DurableObjectRpcWebSocket.completeRequest")(function* (connection, requestId, preserveAckFilter = false) {
220
217
  if (connection.requestIds.delete(requestId)) {
221
- if (connection.nonResumableRequestIds.delete(requestId)) activeNonResumableRequestCount--;
218
+ connection.nonResumableRequestIds.delete(requestId);
222
219
  const subscription = removeSubscription(connection, requestId);
223
220
  connection.currentPendingRequestIds.delete(requestId);
224
221
  if (preserveAckFilter && subscription !== void 0) connection.suppressedAckRequestIds.add(requestId);
@@ -229,7 +226,6 @@ const layer = (options = {}) => Layer.effectContext(Effect.gen(function* () {
229
226
  });
230
227
  const completeAllRequests = Effect.fn("DurableObjectRpcWebSocket.completeAllRequests")(function* (connection) {
231
228
  const hadRequests = connection.requestIds.size > 0;
232
- if (connection.requestIds.size > 0) activeNonResumableRequestCount -= connection.nonResumableRequestIds.size;
233
229
  connection.requestIds.clear();
234
230
  connection.nonResumableRequestIds.clear();
235
231
  connection.subscriptionsByRequestId.clear();