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.
@@ -19,15 +19,79 @@ CREATE TABLE IF NOT EXISTS effect_cf_scheduled_alarms (
19
19
  alarm_id TEXT NOT NULL,
20
20
  tag TEXT NOT NULL,
21
21
  run_at INTEGER NOT NULL,
22
+ wake_at INTEGER NOT NULL DEFAULT 0,
22
23
  repeat_every_ms INTEGER,
23
24
  payload TEXT NOT NULL
24
25
  );
25
- CREATE INDEX IF NOT EXISTS idx_effect_cf_scheduled_alarms_run_at_storage_id
26
- ON effect_cf_scheduled_alarms (run_at, storage_id);
26
+ CREATE TABLE IF NOT EXISTS effect_cf_alarm_attempts (
27
+ storage_id TEXT PRIMARY KEY,
28
+ revision TEXT NOT NULL,
29
+ attempts INTEGER NOT NULL,
30
+ parked INTEGER NOT NULL,
31
+ retry_at INTEGER,
32
+ progress INTEGER NOT NULL
33
+ );
27
34
  `;
28
35
 
36
+ const WAKE_INDEX = "idx_effect_cf_scheduled_alarms_wake_at_storage_id";
37
+
29
38
  const DEFAULT_PROCESS_DUE_ALARMS_LIMIT = 100;
30
- const DEFAULT_PROCESS_DUE_ALARMS_FAILURE_RESCHEDULE_AFTER = "30 seconds" satisfies Duration.Input;
39
+
40
+ export const MIN_RETRY_DELAY_MS = 1_000;
41
+ /** Default unchanged-attempt budget. */
42
+ export const UNCHANGED_ATTEMPT_BUDGET = 8;
43
+ /** Minimum and default parked recovery interval. */
44
+ export const PARKED_RETRY_DELAY_MS = 3_600_000;
45
+
46
+ /** Absolute product repeat floor. */
47
+ export const MIN_REPEAT_INTERVAL_MS = 1_000;
48
+ /** Default product schedule floor. Never use repeats to poll state. */
49
+ export const DEFAULT_MIN_REPEAT_INTERVAL_MS = 60_000;
50
+
51
+ export interface ScheduleConfiguration {
52
+ /** Retry floor, at least one second. Defaults to one second. */
53
+ readonly minimumRetryDelay?: Duration.Input;
54
+ /** Positive safe integer. Defaults to eight unchanged attempts. */
55
+ readonly unchangedAttemptBudget?: number;
56
+ /** Recovery interval, at least one hour and the retry floor. Defaults to one hour. */
57
+ readonly parkedRetryDelay?: Duration.Input;
58
+ /** Product repeat floor, at least one second. Defaults to one minute. */
59
+ readonly minimumRepeatInterval?: Duration.Input;
60
+ }
61
+
62
+ /** Optional policy overrides. Provide with Layer.succeed in the Durable Object's application layer. */
63
+ export const ScheduleConfiguration = Context.Reference<ScheduleConfiguration>(
64
+ "effect-cf/DurableObjectAlarm/ScheduleConfiguration",
65
+ { defaultValue: () => ({}) },
66
+ );
67
+
68
+ const safeIntegerAtLeast = (minimum: number) =>
69
+ S.Int.check(S.isGreaterThanOrEqualTo(minimum), S.isLessThanOrEqualTo(Number.MAX_SAFE_INTEGER));
70
+
71
+ const ScheduleConfigurationSchema = S.Struct({
72
+ minimumRetryDelay: safeIntegerAtLeast(MIN_RETRY_DELAY_MS),
73
+ unchangedAttemptBudget: safeIntegerAtLeast(1),
74
+ parkedRetryDelay: safeIntegerAtLeast(PARKED_RETRY_DELAY_MS),
75
+ minimumRepeatInterval: safeIntegerAtLeast(MIN_REPEAT_INTERVAL_MS),
76
+ }).check(
77
+ S.makeFilter((configuration) =>
78
+ configuration.parkedRetryDelay >= configuration.minimumRetryDelay
79
+ ? undefined
80
+ : "parkedRetryDelay must be at least minimumRetryDelay",
81
+ ),
82
+ );
83
+
84
+ type ResolvedScheduleConfiguration = typeof ScheduleConfigurationSchema.Type;
85
+
86
+ const retryDelay = (
87
+ attempts: number,
88
+ configuration: ResolvedScheduleConfiguration,
89
+ parked: boolean,
90
+ initialDelay = configuration.minimumRetryDelay,
91
+ ) =>
92
+ parked
93
+ ? configuration.parkedRetryDelay
94
+ : Math.min(configuration.parkedRetryDelay, initialDelay * 2 ** (attempts - 1));
31
95
 
32
96
  const getScheduledEventId = (input: { readonly id: string; readonly tag: string }) =>
33
97
  `effect-cf-alarm:${encodeURIComponent(input.tag)}:${encodeURIComponent(input.id)}`;
@@ -35,14 +99,47 @@ const getScheduledEventId = (input: { readonly id: string; readonly tag: string
35
99
  export type AlarmPayload = S.Json;
36
100
 
37
101
  interface AlarmRow extends Record<string, SqlStorageValue> {
102
+ readonly attempts: number;
38
103
  readonly alarm_id: string;
39
104
  readonly payload: string;
40
105
  readonly repeat_every_ms: number | null;
41
106
  readonly run_at: number;
107
+ readonly wake_at: number;
42
108
  readonly storage_id: string;
43
109
  readonly tag: string;
110
+ readonly revision: string;
111
+ readonly parked: number;
112
+ readonly retry_at: number | null;
113
+ readonly progress: number;
44
114
  }
45
115
 
116
+ const alarmRowsSql = `
117
+ SELECT a.*, COALESCE(s.revision, '') AS revision,
118
+ COALESCE(s.attempts, 0) AS attempts, COALESCE(s.parked, 0) AS parked,
119
+ s.retry_at, COALESCE(s.progress, -1) AS progress
120
+ FROM effect_cf_scheduled_alarms a
121
+ LEFT JOIN effect_cf_alarm_attempts s USING (storage_id)`;
122
+
123
+ const sameRevisionSql = `storage_id = ? AND COALESCE(
124
+ (SELECT revision FROM effect_cf_alarm_attempts WHERE storage_id = ?), '') = ?`;
125
+
126
+ /** Content-free: no alarm identifiers, payload, error text or consumer state. */
127
+ export const AlarmParked = S.TaggedStruct("AlarmParked", {
128
+ attempts: S.Int,
129
+ retryAt: S.DateTimeUtc,
130
+ });
131
+ export type AlarmParked = typeof AlarmParked.Type;
132
+
133
+ /** Install with Layer.succeed / Effect.provideService. Reporting cannot undo a committed guard. */
134
+ export const AlarmReporter = Context.Reference<(event: AlarmParked) => Effect.Effect<void>>(
135
+ "effect-cf/DurableObjectAlarm/AlarmReporter",
136
+ { defaultValue: () => (event) => Effect.logWarning(event) },
137
+ );
138
+
139
+ const CurrentAlarmPass = Context.Reference<
140
+ { readonly row: AlarmRow; readonly parked: AlarmParked[]; active: boolean } | undefined
141
+ >("effect-cf/DurableObjectAlarm/CurrentAlarmPass", { defaultValue: () => undefined });
142
+
46
143
  interface NextAlarmRow extends Record<string, SqlStorageValue> {
47
144
  readonly run_at: number;
48
145
  }
@@ -81,6 +178,37 @@ export class InvalidProcessDueAlarmsOptionsError extends Data.TaggedError(
81
178
  }
82
179
  }
83
180
 
181
+ export class InvalidScheduleConfigurationError extends Data.TaggedError(
182
+ "InvalidScheduleConfigurationError",
183
+ )<{
184
+ readonly cause: unknown;
185
+ }> {
186
+ override get message(): string {
187
+ return `Invalid Durable Object alarm configuration: ${ErrorMessage.causeMessage(this.cause)}`;
188
+ }
189
+ }
190
+
191
+ const getScheduleConfiguration = Effect.fnUntraced(function* (defaults: ScheduleConfiguration) {
192
+ const input = { ...defaults, ...(yield* ScheduleConfiguration) };
193
+
194
+ return yield* Effect.try({
195
+ try: () =>
196
+ S.decodeUnknownSync(ScheduleConfigurationSchema)({
197
+ minimumRetryDelay: Math.ceil(
198
+ Duration.toMillis(input.minimumRetryDelay ?? MIN_RETRY_DELAY_MS),
199
+ ),
200
+ unchangedAttemptBudget: input.unchangedAttemptBudget ?? UNCHANGED_ATTEMPT_BUDGET,
201
+ parkedRetryDelay: Math.ceil(
202
+ Duration.toMillis(input.parkedRetryDelay ?? PARKED_RETRY_DELAY_MS),
203
+ ),
204
+ minimumRepeatInterval: Math.ceil(
205
+ Duration.toMillis(input.minimumRepeatInterval ?? DEFAULT_MIN_REPEAT_INTERVAL_MS),
206
+ ),
207
+ }),
208
+ catch: (cause) => new InvalidScheduleConfigurationError({ cause }),
209
+ });
210
+ });
211
+
84
212
  export class StoredAlarmDecodeError extends Data.TaggedError("StoredAlarmDecodeError")<{
85
213
  readonly cause: unknown;
86
214
  readonly storageId: string;
@@ -95,6 +223,7 @@ export type DurableObjectAlarmError =
95
223
  | InvalidAlarmRefError
96
224
  | InvalidProcessDueAlarmsOptionsError
97
225
  | InvalidRepeatEveryError
226
+ | InvalidScheduleConfigurationError
98
227
  | StorageOperationError
99
228
  | StoredAlarmDecodeError;
100
229
 
@@ -124,15 +253,18 @@ const decodeAlarmRef = (input: AlarmRef) =>
124
253
  Effect.mapError((cause) => new InvalidAlarmRefError({ cause })),
125
254
  );
126
255
 
127
- /** Reusing `{tag, id}` replaces an alarm. Repeats run after success, never as fixed-cadence catch-up. */
256
+ /**
257
+ * Arm a logical deadline. External enrollment is progress; handler self-rearms consume a budget.
258
+ * Reusing `{tag, id}` replaces an alarm. Product repeats use the configured floor and run after completion.
259
+ */
128
260
  export type ScheduleAlarmInput<Tag extends string = string> = AlarmRef<Tag> & {
129
261
  readonly payload: AlarmPayload;
130
262
  readonly repeatEvery?: Duration.Input;
131
263
  readonly runAt: DateTime.Utc;
264
+ /** Optional monotonic source cursor. Only a strictly increasing cursor resets a live budget. */
265
+ readonly progress?: number;
132
266
  };
133
267
 
134
- export type ProcessDueAlarmsMode = "isolated" | "ordered";
135
-
136
268
  export interface ProcessDueAlarmsFailure {
137
269
  readonly cause: unknown;
138
270
  readonly event?: DurableObjectAlarmEvent;
@@ -144,15 +276,12 @@ export interface ProcessDueAlarmsFailure {
144
276
  export interface ProcessDueAlarmsResult {
145
277
  readonly failed: readonly ProcessDueAlarmsFailure[];
146
278
  readonly handled: readonly DurableObjectAlarmEvent[];
279
+ readonly parked: readonly AlarmParked[];
147
280
  }
148
281
 
149
282
  export type ProcessDueAlarmsFailureAction =
150
- | "ordered"
151
283
  | "retry"
152
284
  | "skip-and-advance-repeat"
153
- | {
154
- readonly mode: "ordered";
155
- }
156
285
  | {
157
286
  readonly mode: "retry";
158
287
  readonly retryFailedAfter?: Duration.Input;
@@ -163,8 +292,6 @@ export type ProcessDueAlarmsFailureAction =
163
292
 
164
293
  export interface ProcessDueAlarmsOptions<OnFailureR = never, OnFailureE = never> {
165
294
  readonly limit?: number;
166
- /** `isolated` retries only the failed row; `ordered` stops before later rows. */
167
- readonly mode?: ProcessDueAlarmsMode;
168
295
  readonly onFailure?: (
169
296
  failure: ProcessDueAlarmsFailure,
170
297
  ) => Effect.Effect<ProcessDueAlarmsFailureAction | void, OnFailureE, OnFailureR>;
@@ -179,7 +306,17 @@ export type ProcessDueAlarmsHandler<R = never, E = never> = (
179
306
  * Alarm mutations owned by one transaction callback. Run them in the callback's
180
307
  * fiber; forked work and use after the callback ends fail with StorageOperationError.
181
308
  */
182
- export type AlarmTransaction = Pick<AlarmScheduler, "scheduleAlarm" | "cancelAlarm">;
309
+ export type AlarmTransaction = Pick<
310
+ AlarmScheduler,
311
+ "scheduleAlarm" | "scheduleAlarmEarlier" | "cancelAlarm"
312
+ >;
313
+
314
+ export interface AlarmStatus {
315
+ readonly attempts: number;
316
+ readonly parked: boolean;
317
+ readonly runAt: DateTime.Utc;
318
+ readonly retryAt: DateTime.Utc | undefined;
319
+ }
183
320
 
184
321
  /** Own `storage.setAlarm()` exclusively: a Durable Object has one platform alarm timestamp. */
185
322
  export type AlarmScheduler = {
@@ -203,7 +340,15 @@ export type AlarmScheduler = {
203
340
  input: AlarmRef,
204
341
  ) => Effect.Effect<void, InvalidAlarmRefError | StorageOperationError>;
205
342
 
206
- /** Acknowledge after handling. Conditional writes preserve handler replacements; alarms are at-least-once. */
343
+ /** Inspect retained work, including hourly recovery deadlines. */
344
+ readonly getAlarmStatus: (
345
+ input: AlarmRef,
346
+ ) => Effect.Effect<AlarmStatus | undefined, InvalidAlarmRefError | StorageOperationError>;
347
+
348
+ /**
349
+ * Dispatch by effective wake deadline, then storage key. Failures retry independently.
350
+ * Conditional acknowledgements preserve handler replacements; alarms are at-least-once.
351
+ */
207
352
  readonly processDueAlarms: <R = never, E = never, OnFailureR = never, OnFailureE = never>(
208
353
  handle: ProcessDueAlarmsHandler<R, E>,
209
354
  options?: ProcessDueAlarmsOptions<OnFailureR, OnFailureE>,
@@ -221,8 +366,11 @@ export type AlarmScheduler = {
221
366
  | InvalidAlarmPayloadError
222
367
  | InvalidAlarmRefError
223
368
  | InvalidRepeatEveryError
369
+ | InvalidScheduleConfigurationError
224
370
  | StorageOperationError
225
371
  >;
372
+ /** Atomically min-merge a logical deadline. Preserves an earlier alarm's payload and repeat. */
373
+ readonly scheduleAlarmEarlier: AlarmScheduler["scheduleAlarm"];
226
374
  };
227
375
 
228
376
  const StoredPayloadString = S.fromJsonString(S.Json);
@@ -237,10 +385,37 @@ const encodeStoredPayload = (payload: AlarmPayload) =>
237
385
  Effect.mapError((cause) => new InvalidAlarmPayloadError({ cause })),
238
386
  );
239
387
 
240
- const ensureTable = (state: DurableObjectState["Service"]) =>
241
- state.storage.sql.exec(INIT_TABLE_SQL).pipe(Effect.asVoid);
388
+ const ensureTable = Effect.fnUntraced(function* (state: DurableObjectState["Service"]) {
389
+ yield* state.storage.sql.exec(INIT_TABLE_SQL);
390
+ const index = yield* state.storage.sql.exec(
391
+ "SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?",
392
+ WAKE_INDEX,
393
+ );
394
+
395
+ if ((yield* index.toArray()).length > 0) return;
396
+ const columns = yield* state.storage.sql.exec<{ name: string }>(
397
+ "SELECT name FROM pragma_table_info('effect_cf_scheduled_alarms')",
398
+ );
399
+ const names = new Set((yield* columns.toArray()).map((column) => column.name));
242
400
 
243
- const toRepeatEveryMillis = (input: Duration.Input | undefined) => {
401
+ if (!names.has("wake_at")) {
402
+ yield* state.storage.sql.exec(
403
+ "ALTER TABLE effect_cf_scheduled_alarms ADD COLUMN wake_at INTEGER NOT NULL DEFAULT 0",
404
+ );
405
+ }
406
+ // Backfill once, including retained schedules from before attempt tracking existed.
407
+ // The wake index is the durable migration marker and is created only after the backfill.
408
+ yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET
409
+ wake_at = MAX(run_at, COALESCE((SELECT retry_at FROM effect_cf_alarm_attempts s
410
+ WHERE s.storage_id = effect_cf_scheduled_alarms.storage_id), run_at))`);
411
+ yield* state.storage.sql.exec(
412
+ "DROP INDEX IF EXISTS idx_effect_cf_scheduled_alarms_run_at_storage_id",
413
+ );
414
+ yield* state.storage.sql.exec(`CREATE INDEX IF NOT EXISTS ${WAKE_INDEX}
415
+ ON effect_cf_scheduled_alarms (wake_at, storage_id)`);
416
+ });
417
+
418
+ const toRepeatEveryMillis = (input: Duration.Input | undefined, minimum: number) => {
244
419
  if (input === undefined) {
245
420
  return Effect.succeed(null);
246
421
  }
@@ -249,8 +424,8 @@ const toRepeatEveryMillis = (input: Duration.Input | undefined) => {
249
424
  try: () => {
250
425
  const millis = Duration.toMillis(input);
251
426
 
252
- if (!Number.isFinite(millis) || millis <= 0) {
253
- throw new Error("Alarm repeatEvery must be a positive finite duration");
427
+ if (!Number.isFinite(millis) || millis < minimum) {
428
+ throw new Error(`Alarm repeatEvery must be at least ${minimum} milliseconds and finite`);
254
429
  }
255
430
 
256
431
  return Math.ceil(millis);
@@ -293,7 +468,7 @@ const getProcessLimit = (options: ProcessDueAlarmsOptions<unknown, unknown> | un
293
468
  return Effect.succeed(limit);
294
469
  };
295
470
 
296
- const toFailureRescheduleMillis = (input: Duration.Input) =>
471
+ const toFailureRescheduleMillis = (input: Duration.Input, minimum: number) =>
297
472
  Effect.try({
298
473
  try: () => {
299
474
  const millis = Duration.toMillis(input);
@@ -302,14 +477,18 @@ const toFailureRescheduleMillis = (input: Duration.Input) =>
302
477
  throw new Error("Alarm failure rescheduleAfter must be a positive finite duration");
303
478
  }
304
479
 
305
- return Math.ceil(millis);
480
+ return Math.max(minimum, Math.ceil(millis));
306
481
  },
307
482
  catch: (cause) => new InvalidProcessDueAlarmsOptionsError({ cause }),
308
483
  });
309
484
 
310
- const getFailureRetryDelay = (options: ProcessDueAlarmsOptions<unknown, unknown> | undefined) =>
485
+ const getFailureRetryDelay = (
486
+ options: ProcessDueAlarmsOptions<unknown, unknown> | undefined,
487
+ configuration: ResolvedScheduleConfiguration,
488
+ ) =>
311
489
  toFailureRescheduleMillis(
312
- options?.retryFailedAfter ?? DEFAULT_PROCESS_DUE_ALARMS_FAILURE_RESCHEDULE_AFTER,
490
+ options?.retryFailedAfter ?? configuration.minimumRetryDelay,
491
+ configuration.minimumRetryDelay,
313
492
  );
314
493
 
315
494
  const getFailureActionMode = (action: ProcessDueAlarmsFailureAction) =>
@@ -330,7 +509,7 @@ export const processDue = <R = never, E = never, OnFailureR = never, OnFailureE
330
509
 
331
510
  export type AlarmPayloadSchema = S.Codec<any, any, never, never>;
332
511
 
333
- export type AlarmFailurePolicy = "ordered" | "retry" | "skip-and-advance-repeat";
512
+ export type AlarmFailurePolicy = "retry" | "skip-and-advance-repeat";
334
513
 
335
514
  export interface AlarmRetryPolicy {
336
515
  readonly initialDelay?: Duration.Input;
@@ -377,6 +556,7 @@ export interface DefinedAlarmTransaction<Definitions extends AlarmDefinitions> {
377
556
  readonly scheduleAlarm: (
378
557
  input: DefinedScheduleAlarmInput<Definitions>,
379
558
  ) => ReturnType<AlarmScheduler["scheduleAlarm"]>;
559
+ readonly scheduleAlarmEarlier: DefinedAlarmTransaction<Definitions>["scheduleAlarm"];
380
560
  readonly cancelAlarm: (
381
561
  input: AlarmRef<keyof Definitions & string>,
382
562
  ) => ReturnType<AlarmScheduler["cancelAlarm"]>;
@@ -397,6 +577,9 @@ export interface DefinedAlarmScheduler<
397
577
  Definitions extends AlarmDefinitions,
398
578
  > extends DefinedAlarmTransaction<Definitions> {
399
579
  readonly [TypedAlarmSchedulerTypeId]: typeof TypedAlarmSchedulerTypeId;
580
+ readonly getAlarmStatus: (
581
+ input: AlarmRef<keyof Definitions & string>,
582
+ ) => ReturnType<AlarmScheduler["getAlarmStatus"]>;
400
583
  readonly transaction: <A, E, R>(
401
584
  closure: (alarms: DefinedAlarmTransaction<Definitions>) => Effect.Effect<A, E, R>,
402
585
  ) => Effect.Effect<A, E | StorageOperationError, R>;
@@ -450,47 +633,54 @@ const makeDefinition = <const Definitions extends AlarmDefinitions>(definitions:
450
633
  const definitionFor = (tag: string) =>
451
634
  Object.hasOwn(definitions, tag) ? definitions[tag] : undefined;
452
635
 
453
- const bind = (mutations: AlarmTransaction): DefinedAlarmTransaction<Definitions> => ({
454
- scheduleAlarm: Effect.fn("DefinedAlarms.scheduleAlarm")(function* (
455
- input: DefinedScheduleAlarmInput<Definitions>,
456
- ) {
457
- const definition = definitionFor(input.tag);
636
+ const bind = (mutations: AlarmTransaction): DefinedAlarmTransaction<Definitions> => {
637
+ const schedule = (method: "scheduleAlarm" | "scheduleAlarmEarlier") =>
638
+ Effect.fn("DefinedAlarms.scheduleAlarm")(function* (
639
+ input: DefinedScheduleAlarmInput<Definitions>,
640
+ ) {
641
+ const definition = definitionFor(input.tag);
458
642
 
459
- if (definition === undefined) {
460
- return yield* Effect.fail(
461
- new InvalidAlarmRefError({
462
- cause: new Error(`Unknown alarm tag "${input.tag}"`),
463
- }),
643
+ if (definition === undefined) {
644
+ return yield* Effect.fail(
645
+ new InvalidAlarmRefError({
646
+ cause: new Error(`Unknown alarm tag "${input.tag}"`),
647
+ }),
648
+ );
649
+ }
650
+ const payload = yield* S.encodeEffect(getAlarmDefinitionSchema(definition))(
651
+ input.payload,
652
+ ).pipe(
653
+ Effect.flatMap(S.decodeUnknownEffect(S.Json)),
654
+ Effect.mapError((cause) => new InvalidAlarmPayloadError({ cause })),
464
655
  );
465
- }
466
- const payload = yield* S.encodeEffect(getAlarmDefinitionSchema(definition))(
467
- input.payload,
468
- ).pipe(
469
- Effect.flatMap(S.decodeUnknownEffect(S.Json)),
470
- Effect.mapError((cause) => new InvalidAlarmPayloadError({ cause })),
471
- );
472
656
 
473
- yield* mutations.scheduleAlarm({ ...input, payload });
474
- }),
475
- cancelAlarm: Effect.fn("DefinedAlarms.cancelAlarm")(function* (
476
- input: AlarmRef<keyof Definitions & string>,
477
- ) {
478
- if (definitionFor(input.tag) === undefined) {
479
- return yield* Effect.fail(
480
- new InvalidAlarmRefError({
481
- cause: new Error(`Unknown alarm tag "${input.tag}"`),
482
- }),
483
- );
484
- }
657
+ yield* mutations[method]({ ...input, payload });
658
+ });
485
659
 
486
- yield* mutations.cancelAlarm(input);
487
- }),
488
- });
660
+ return {
661
+ scheduleAlarm: schedule("scheduleAlarm"),
662
+ scheduleAlarmEarlier: schedule("scheduleAlarmEarlier"),
663
+ cancelAlarm: Effect.fn("DefinedAlarms.cancelAlarm")(function* (
664
+ input: AlarmRef<keyof Definitions & string>,
665
+ ) {
666
+ if (definitionFor(input.tag) === undefined) {
667
+ return yield* Effect.fail(
668
+ new InvalidAlarmRefError({
669
+ cause: new Error(`Unknown alarm tag "${input.tag}"`),
670
+ }),
671
+ );
672
+ }
673
+
674
+ yield* mutations.cancelAlarm(input);
675
+ }),
676
+ };
677
+ };
489
678
 
490
679
  return {
491
680
  make: (alarms: AlarmScheduler): DefinedAlarmScheduler<Definitions> => ({
492
681
  [TypedAlarmSchedulerTypeId]: TypedAlarmSchedulerTypeId,
493
682
  ...bind(alarms),
683
+ getAlarmStatus: (input) => alarms.getAlarmStatus(input),
494
684
  transaction: (closure) => alarms.transaction((tx) => closure(bind(tx))),
495
685
  }),
496
686
  handlers: <R = never, E = never>(
@@ -580,138 +770,189 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
580
770
  DurableObjectAlarm,
581
771
  Effect.gen(function* () {
582
772
  const state = yield* DurableObjectState;
773
+ const configurationDefaults = { ...(yield* ScheduleConfiguration) };
774
+
775
+ const readRow = Effect.fnUntraced(function* (storageId: string) {
776
+ const cursor = yield* state.storage.sql.exec<AlarmRow>(
777
+ `${alarmRowsSql} WHERE a.storage_id = ?`,
778
+ storageId,
779
+ );
780
+
781
+ return (yield* cursor.toArray())[0];
782
+ });
583
783
 
584
784
  const reconcileAlarm = Effect.fn("DurableObjectAlarm.reconcileAlarm")(function* () {
585
785
  const cursor = yield* state.storage.sql.exec<NextAlarmRow>(
586
- `SELECT run_at FROM effect_cf_scheduled_alarms ORDER BY run_at ASC, storage_id ASC LIMIT 1`,
786
+ `SELECT wake_at AS run_at FROM effect_cf_scheduled_alarms
787
+ ORDER BY wake_at, storage_id LIMIT 1`,
587
788
  );
588
789
  const next = (yield* cursor.toArray())[0];
589
790
 
590
- if (next === undefined) {
591
- yield* state.storage.deleteAlarm();
592
-
593
- return;
594
- }
595
-
596
- yield* state.storage.setAlarm(next.run_at);
791
+ // The scheduler is the sole alarm writer. Raw storage is confined to this boundary.
792
+ yield* Effect.tryPromise({
793
+ try: () =>
794
+ next === undefined
795
+ ? state.raw.storage.deleteAlarm()
796
+ : state.raw.storage.setAlarm(next.run_at),
797
+ catch: (cause) =>
798
+ new StorageOperationError({
799
+ operation: next === undefined ? "deleteAlarm" : "setAlarm",
800
+ cause,
801
+ }),
802
+ });
597
803
  });
598
804
 
599
- const rescheduleFailedAlarm = Effect.fn("DurableObjectAlarm.rescheduleFailedAlarm")(
600
- function* (row: AlarmRow, retryDelayMillis: number) {
601
- const retryAt = (yield* Clock.currentTimeMillis) + retryDelayMillis;
602
-
603
- if (row.repeat_every_ms === null) {
604
- const cursor = yield* state.storage.sql.exec(
605
- `UPDATE effect_cf_scheduled_alarms
606
- SET run_at = ?
607
- WHERE storage_id = ?
608
- AND run_at = ?
609
- AND repeat_every_ms IS NULL
610
- AND payload = ?`,
611
- retryAt,
612
- row.storage_id,
613
- row.run_at,
614
- row.payload,
615
- );
616
-
617
- yield* cursor.rowsWritten;
618
-
619
- return;
620
- }
621
-
622
- const cursor = yield* state.storage.sql.exec(
623
- `UPDATE effect_cf_scheduled_alarms
624
- SET run_at = ?
625
- WHERE storage_id = ?
626
- AND run_at = ?
627
- AND repeat_every_ms = ?
628
- AND payload = ?`,
629
- retryAt,
630
- row.storage_id,
631
- row.run_at,
632
- row.repeat_every_ms,
633
- row.payload,
634
- );
635
-
636
- yield* cursor.rowsWritten;
637
- },
638
- );
639
-
640
- const acknowledgeAlarm = Effect.fn("DurableObjectAlarm.acknowledgeAlarm")(function* (
641
- row: AlarmRow,
805
+ const writeAttempts = Effect.fnUntraced(function* (
806
+ storageId: string,
807
+ attempts: number,
808
+ parked: number,
809
+ retryAt: number | null,
810
+ progress: number,
642
811
  ) {
643
- if (row.repeat_every_ms === null) {
644
- const cursor = yield* state.storage.sql.exec(
645
- `DELETE FROM effect_cf_scheduled_alarms
646
- WHERE storage_id = ?
647
- AND run_at = ?
648
- AND repeat_every_ms IS NULL
649
- AND payload = ?`,
650
- row.storage_id,
651
- row.run_at,
652
- row.payload,
653
- );
654
-
655
- yield* cursor.rowsWritten;
656
-
657
- return;
658
- }
659
-
660
- const acknowledgedAt = yield* Clock.currentTimeMillis;
661
- const cursor = yield* state.storage.sql.exec(
812
+ yield* state.storage.sql.exec(
813
+ `INSERT OR REPLACE INTO effect_cf_alarm_attempts
814
+ (storage_id, revision, attempts, parked, retry_at, progress)
815
+ VALUES (?, ?, ?, ?, ?, ?)`,
816
+ storageId,
817
+ crypto.randomUUID(),
818
+ attempts,
819
+ parked,
820
+ retryAt,
821
+ progress,
822
+ );
823
+ // Materialize the effective wake in the same transaction for indexed dispatch/reconciliation.
824
+ yield* state.storage.sql.exec(
662
825
  `UPDATE effect_cf_scheduled_alarms
663
- SET run_at = ?
664
- WHERE storage_id = ?
665
- AND run_at = ?
666
- AND repeat_every_ms = ?
667
- AND payload = ?`,
668
- acknowledgedAt + row.repeat_every_ms,
669
- row.storage_id,
670
- row.run_at,
671
- row.repeat_every_ms,
672
- row.payload,
826
+ SET wake_at = MAX(run_at, COALESCE(?, run_at))
827
+ WHERE storage_id = ?`,
828
+ retryAt,
829
+ storageId,
673
830
  );
674
-
675
- yield* cursor.rowsWritten;
676
831
  });
677
832
 
678
833
  const cancelAlarm = Effect.fn("DurableObjectAlarm.cancelAlarm")(function* (input: AlarmRef) {
679
834
  const ref = yield* decodeAlarmRef(input);
835
+ const storageId = getScheduledEventId(ref);
680
836
 
681
837
  yield* state.storage.sql.exec(
682
838
  `DELETE FROM effect_cf_scheduled_alarms WHERE storage_id = ?`,
683
- getScheduledEventId(ref),
839
+ storageId,
840
+ );
841
+ yield* state.storage.sql.exec(
842
+ `DELETE FROM effect_cf_alarm_attempts WHERE storage_id = ?`,
843
+ storageId,
684
844
  );
685
845
  });
686
846
 
687
847
  const scheduleAlarm = Effect.fn("DurableObjectAlarm.scheduleAlarm")(function* (
688
848
  input: ScheduleAlarmInput,
849
+ earlier: boolean,
850
+ reports: string[],
689
851
  ) {
852
+ const configuration = yield* getScheduleConfiguration(configurationDefaults);
690
853
  const ref = yield* decodeAlarmRef(input);
691
- const repeatEveryMillis = yield* toRepeatEveryMillis(input.repeatEvery);
854
+ const repeatEveryMillis = yield* toRepeatEveryMillis(
855
+ input.repeatEvery,
856
+ configuration.minimumRepeatInterval,
857
+ );
692
858
  const payload = yield* encodeStoredPayload(input.payload);
859
+ const storageId = getScheduledEventId(ref);
860
+ const existing = yield* readRow(storageId);
861
+ const pass = yield* CurrentAlarmPass;
862
+ const source = pass?.row;
863
+ const progress = existing?.progress ?? source?.progress ?? -1;
864
+
865
+ if (pass !== undefined && !pass.active) {
866
+ return yield* Effect.fail(
867
+ new StorageOperationError({
868
+ operation: "alarm.schedule",
869
+ cause: new Error(
870
+ "Alarm handlers cannot schedule detached work after their pass ends",
871
+ ),
872
+ }),
873
+ );
874
+ }
875
+ if (input.progress !== undefined) {
876
+ yield* S.decodeUnknownEffect(S.Natural)(input.progress).pipe(
877
+ Effect.mapError((cause) => new InvalidAlarmRefError({ cause })),
878
+ );
879
+ }
880
+ // Replayed source notices cannot refill a live lane's budget or replace newer state.
881
+ if (source === undefined && input.progress !== undefined && input.progress <= progress) {
882
+ return;
883
+ }
884
+ const progressed = input.progress !== undefined && input.progress > progress;
885
+ const sourceProgressed =
886
+ source !== undefined && storageId === source.storage_id && progress > source.progress;
887
+ const attempts =
888
+ source === undefined || progressed || sourceProgressed
889
+ ? 0
890
+ : source.parked === 1 || existing?.parked === 1
891
+ ? Math.max(source.attempts, existing?.attempts ?? 0)
892
+ : Math.min(
893
+ configuration.unchangedAttemptBudget,
894
+ Math.max(source.attempts + 1, existing?.attempts ?? 0),
895
+ );
896
+ const parked =
897
+ attempts > 0 &&
898
+ (attempts >= configuration.unchangedAttemptBudget ||
899
+ source?.parked === 1 ||
900
+ existing?.parked === 1)
901
+ ? 1
902
+ : 0;
903
+ const now = yield* Clock.currentTimeMillis;
904
+ const retryAt =
905
+ attempts === 0 ? null : now + retryDelay(attempts, configuration, parked === 1);
693
906
  const runAt = DateTime.toEpochMillis(input.runAt);
907
+ const keepEarlier = earlier && existing !== undefined && existing.run_at <= runAt;
908
+ const scheduledAt = keepEarlier ? existing.run_at : runAt;
909
+ const scheduledPayload = keepEarlier ? existing.payload : payload;
910
+ const scheduledRepeat = keepEarlier ? existing.repeat_every_ms : repeatEveryMillis;
911
+ const nextProgress = Math.max(progress, input.progress ?? -1);
694
912
 
695
913
  yield* state.storage.sql.exec(
696
914
  `INSERT OR REPLACE INTO effect_cf_scheduled_alarms
697
- (storage_id, alarm_id, tag, run_at, repeat_every_ms, payload)
698
- VALUES (?, ?, ?, ?, ?, ?)`,
699
- getScheduledEventId(ref),
915
+ (storage_id, alarm_id, tag, run_at, repeat_every_ms, payload)
916
+ VALUES (?, ?, ?, ?, ?, ?)`,
917
+ storageId,
700
918
  ref.id,
701
919
  ref.tag,
702
- runAt,
703
- repeatEveryMillis,
704
- payload,
920
+ scheduledAt,
921
+ scheduledRepeat,
922
+ scheduledPayload,
705
923
  );
924
+ yield* writeAttempts(storageId, attempts, parked, retryAt, nextProgress);
925
+ if (parked === 1 && retryAt !== null && (existing?.parked ?? source?.parked ?? 0) === 0) {
926
+ reports.push(storageId);
927
+ }
928
+ });
929
+
930
+ const reportParked = Effect.fnUntraced(function* (storageIds: readonly string[]) {
931
+ if (storageIds.length === 0) return;
932
+ const pass = yield* CurrentAlarmPass;
933
+ const report = yield* AlarmReporter;
934
+
935
+ for (const storageId of new Set(storageIds)) {
936
+ const row = yield* readRow(storageId);
937
+
938
+ // Cancellation or progress later in the same transaction removes tentative parking.
939
+ if (row === undefined || row.parked === 0 || row.retry_at === null) continue;
940
+ const event = AlarmParked.make({
941
+ attempts: row.attempts,
942
+ retryAt: DateTime.makeUnsafe(Math.max(row.run_at, row.retry_at)),
943
+ });
944
+
945
+ pass?.parked.push(event);
946
+ // Reporting is at-most-once per parked episode, after the native commit.
947
+ yield* Effect.exit(Effect.suspend(() => report(event)));
948
+ }
706
949
  });
707
950
 
708
- const transaction: AlarmScheduler["transaction"] = (closure) =>
709
- state.storage.transaction(() =>
951
+ const transaction: AlarmScheduler["transaction"] = Effect.fnUntraced(function* (closure) {
952
+ const reports: string[] = [];
953
+ const result = yield* state.storage.transaction(() =>
710
954
  Effect.withFiber((owner) => {
711
955
  let active = true;
712
-
713
- // A callback-owned handle cannot escape via a returned Effect or a
714
- // detached fiber and write after reconciliation or rollback.
715
956
  const requireActive = <A, E>(effect: Effect.Effect<A, E>) =>
716
957
  Effect.withFiber<A, E | StorageOperationError>((fiber) =>
717
958
  active && fiber === owner
@@ -731,7 +972,9 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
731
972
  const result = yield* Effect.suspend(() =>
732
973
  closure({
733
974
  cancelAlarm: (input) => requireActive(cancelAlarm(input)),
734
- scheduleAlarm: (input) => requireActive(scheduleAlarm(input)),
975
+ scheduleAlarm: (input) => requireActive(scheduleAlarm(input, false, reports)),
976
+ scheduleAlarmEarlier: (input) =>
977
+ requireActive(scheduleAlarm(input, true, reports)),
735
978
  }),
736
979
  ).pipe(
737
980
  Effect.ensuring(
@@ -748,6 +991,72 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
748
991
  }),
749
992
  );
750
993
 
994
+ yield* reportParked(reports);
995
+
996
+ return result;
997
+ });
998
+
999
+ const rescheduleFailedAlarm = Effect.fnUntraced(function* (
1000
+ row: AlarmRow,
1001
+ initialDelay: number,
1002
+ configuration: ResolvedScheduleConfiguration,
1003
+ reports: string[],
1004
+ ) {
1005
+ const current = yield* readRow(row.storage_id);
1006
+
1007
+ // A self-rearm already charged this pass; a newer schedule must survive a stale failure.
1008
+ if (current === undefined || current.revision !== row.revision) return;
1009
+ const attempts =
1010
+ row.parked === 1
1011
+ ? row.attempts
1012
+ : Math.min(configuration.unchangedAttemptBudget, row.attempts + 1);
1013
+ const parked = row.parked === 1 || attempts >= configuration.unchangedAttemptBudget ? 1 : 0;
1014
+ const retryAt =
1015
+ (yield* Clock.currentTimeMillis) +
1016
+ retryDelay(attempts, configuration, parked === 1, initialDelay);
1017
+
1018
+ yield* writeAttempts(row.storage_id, attempts, parked, retryAt, row.progress);
1019
+ if (parked === 1 && row.parked === 0) {
1020
+ reports.push(row.storage_id);
1021
+ }
1022
+ });
1023
+
1024
+ const acknowledgeAlarm = Effect.fnUntraced(function* (
1025
+ row: AlarmRow,
1026
+ configuration: ResolvedScheduleConfiguration,
1027
+ ) {
1028
+ if (row.repeat_every_ms === null) {
1029
+ const cursor = yield* state.storage.sql.exec(
1030
+ `DELETE FROM effect_cf_scheduled_alarms WHERE ${sameRevisionSql}`,
1031
+ row.storage_id,
1032
+ row.storage_id,
1033
+ row.revision,
1034
+ );
1035
+
1036
+ if ((yield* cursor.rowsWritten) > 0) {
1037
+ yield* state.storage.sql.exec(
1038
+ `DELETE FROM effect_cf_alarm_attempts WHERE storage_id = ?`,
1039
+ row.storage_id,
1040
+ );
1041
+ }
1042
+
1043
+ return;
1044
+ }
1045
+ const now = yield* Clock.currentTimeMillis;
1046
+ const cursor = yield* state.storage.sql.exec(
1047
+ `UPDATE effect_cf_scheduled_alarms SET run_at = ? WHERE ${sameRevisionSql}`,
1048
+ now + Math.max(configuration.minimumRepeatInterval, row.repeat_every_ms),
1049
+ row.storage_id,
1050
+ row.storage_id,
1051
+ row.revision,
1052
+ );
1053
+
1054
+ if ((yield* cursor.rowsWritten) > 0) {
1055
+ // A completed product occurrence is progress. Missed occurrences are never replayed.
1056
+ yield* writeAttempts(row.storage_id, 0, 0, null, row.progress);
1057
+ }
1058
+ });
1059
+
751
1060
  const processDueAlarms = Effect.fn("DurableObjectAlarm.processDueAlarms")(function* <
752
1061
  R,
753
1062
  E,
@@ -757,24 +1066,25 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
757
1066
  handle: ProcessDueAlarmsHandler<R, E>,
758
1067
  options?: ProcessDueAlarmsOptions<OnFailureR, OnFailureE>,
759
1068
  ) {
1069
+ const configuration = yield* getScheduleConfiguration(configurationDefaults);
1070
+
760
1071
  yield* ensureTable(state);
761
- const mode = options?.mode ?? "isolated";
762
1072
  const limit = yield* getProcessLimit(options);
1073
+ const initialDelay = yield* getFailureRetryDelay(options, configuration);
763
1074
  const now = yield* Clock.currentTimeMillis;
1075
+
764
1076
  const cursor = yield* state.storage.sql.exec<AlarmRow>(
765
- `SELECT storage_id, alarm_id, tag, run_at, repeat_every_ms, payload
766
- FROM effect_cf_scheduled_alarms
767
- WHERE run_at <= ?
768
- ORDER BY run_at ASC, storage_id ASC
769
- LIMIT ?`,
1077
+ `${alarmRowsSql}
1078
+ WHERE a.wake_at <= ? ORDER BY a.wake_at ASC, a.storage_id ASC LIMIT ?`,
770
1079
  now,
771
1080
  limit,
772
1081
  );
773
1082
  const dueRows = yield* cursor.toArray();
774
1083
  const handled: DurableObjectAlarmEvent[] = [];
775
1084
  const failed: ProcessDueAlarmsFailure[] = [];
1085
+ const parked: AlarmParked[] = [];
776
1086
 
777
- const handleFailure = function* (
1087
+ const handleFailure = Effect.fnUntraced(function* (
778
1088
  row: AlarmRow,
779
1089
  event: DurableObjectAlarmEvent | undefined,
780
1090
  cause: unknown,
@@ -788,72 +1098,96 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
788
1098
  };
789
1099
 
790
1100
  failed.push(failure);
791
- const failureAction =
792
- options?.onFailure === undefined ? undefined : yield* options.onFailure(failure);
793
- const actionMode =
794
- failureAction === undefined ? mode : getFailureActionMode(failureAction);
795
-
796
- if (actionMode === "retry" || actionMode === "isolated") {
797
- const actionRetryDelay =
798
- failureAction === undefined ? undefined : getFailureActionRetryDelay(failureAction);
799
- const retryDelay =
800
- actionRetryDelay === undefined
801
- ? yield* getFailureRetryDelay(options)
802
- : yield* toFailureRescheduleMillis(actionRetryDelay);
803
-
804
- yield* rescheduleFailedAlarm(row, retryDelay);
805
-
806
- return "continue" as const;
807
- }
808
-
809
- if (actionMode === "skip-and-advance-repeat") {
810
- yield* acknowledgeAlarm(row);
1101
+ const actionExit = yield* Effect.exit(
1102
+ options?.onFailure === undefined
1103
+ ? Effect.void
1104
+ : Effect.suspend(() => options.onFailure!(failure)),
1105
+ );
1106
+ const action = Exit.isSuccess(actionExit) ? actionExit.value : undefined;
1107
+ const actionMode = action === undefined ? "retry" : getFailureActionMode(action);
1108
+ const delayInput = action === undefined ? undefined : getFailureActionRetryDelay(action);
1109
+ const delayExit = yield* Effect.exit(
1110
+ delayInput === undefined
1111
+ ? Effect.succeed(initialDelay)
1112
+ : toFailureRescheduleMillis(delayInput, configuration.minimumRetryDelay),
1113
+ );
1114
+ const delay = Exit.isSuccess(delayExit) ? delayExit.value : initialDelay;
1115
+ const reports: string[] = [];
811
1116
 
812
- return "continue" as const;
1117
+ yield* transaction(() =>
1118
+ actionMode === "skip-and-advance-repeat"
1119
+ ? acknowledgeAlarm(row, configuration)
1120
+ : rescheduleFailedAlarm(row, delay, configuration, reports),
1121
+ );
1122
+ yield* reportParked(reports);
1123
+ if (Exit.isFailure(actionExit)) {
1124
+ return yield* Effect.failCause(actionExit.cause);
813
1125
  }
814
-
815
- yield* reconcileAlarm();
816
-
817
- return "stop" as const;
818
- };
1126
+ if (Exit.isFailure(delayExit)) {
1127
+ return yield* Effect.failCause(delayExit.cause);
1128
+ }
1129
+ });
819
1130
 
820
1131
  for (const row of dueRows) {
1132
+ const current = yield* readRow(row.storage_id);
1133
+
1134
+ if (current === undefined || current.revision !== row.revision) {
1135
+ continue;
1136
+ }
1137
+ const inPass = <A, E, R>(effect: Effect.Effect<A, E, R>) => {
1138
+ const pass = { row, parked, active: true };
1139
+
1140
+ return effect.pipe(
1141
+ Effect.provideService(CurrentAlarmPass, pass),
1142
+ Effect.ensuring(
1143
+ Effect.sync(() => {
1144
+ pass.active = false;
1145
+ }),
1146
+ ),
1147
+ );
1148
+ };
821
1149
  const eventExit = yield* Effect.exit(toAlarmDue(row));
822
1150
 
823
1151
  if (Exit.isFailure(eventExit)) {
824
- const action = yield* handleFailure(row, undefined, eventExit.cause);
825
-
826
- if (action === "stop") {
827
- return yield* Effect.failCause(eventExit.cause);
828
- }
1152
+ yield* inPass(handleFailure(row, undefined, eventExit.cause));
829
1153
  continue;
830
1154
  }
831
-
832
1155
  const event = eventExit.value;
833
- const handleExit = yield* Effect.exit(handle(event));
1156
+ const handleExit = yield* Effect.exit(inPass(Effect.suspend(() => handle(event))));
834
1157
 
835
1158
  if (Exit.isFailure(handleExit)) {
836
- const action = yield* handleFailure(row, event, handleExit.cause);
837
-
838
- if (action === "stop") {
839
- return yield* Effect.failCause(handleExit.cause);
840
- }
1159
+ yield* inPass(handleFailure(row, event, handleExit.cause));
841
1160
  continue;
842
1161
  }
843
-
844
- yield* acknowledgeAlarm(row);
1162
+ yield* transaction(() => acknowledgeAlarm(row, configuration));
845
1163
  handled.push(event);
846
1164
  }
1165
+ yield* transaction(() => Effect.void);
847
1166
 
848
- yield* reconcileAlarm();
849
-
850
- return { failed, handled };
1167
+ return { failed, handled, parked };
851
1168
  });
852
1169
 
853
1170
  return DurableObjectAlarm.of({
854
1171
  cancelAlarm: (input) => transaction((alarms) => alarms.cancelAlarm(input)),
1172
+ getAlarmStatus: Effect.fnUntraced(function* (input) {
1173
+ const ref = yield* decodeAlarmRef(input);
1174
+
1175
+ yield* ensureTable(state);
1176
+ const row = yield* readRow(getScheduledEventId(ref));
1177
+
1178
+ return row === undefined
1179
+ ? undefined
1180
+ : {
1181
+ attempts: row.attempts,
1182
+ parked: row.parked === 1,
1183
+ runAt: DateTime.makeUnsafe(row.run_at),
1184
+ retryAt: row.retry_at === null ? undefined : DateTime.makeUnsafe(row.retry_at),
1185
+ };
1186
+ }),
855
1187
  processDueAlarms,
856
1188
  scheduleAlarm: (input) => transaction((alarms) => alarms.scheduleAlarm(input)),
1189
+ scheduleAlarmEarlier: (input) =>
1190
+ transaction((alarms) => alarms.scheduleAlarmEarlier(input)),
857
1191
  transaction,
858
1192
  });
859
1193
  }),