effect-cf 0.36.0 → 0.37.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,13 @@
1
1
  # effect-cf
2
2
 
3
+ ## 0.37.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#135](https://github.com/danieljvdm/effect-cf/pull/135) [`9dea62e`](https://github.com/danieljvdm/effect-cf/commit/9dea62e74818b7fc9da919e8bdbfcc49991aef36) Thanks [@danieljvdm](https://github.com/danieljvdm)! - Add `DurableObjectAlarm.transaction` to commit application storage and logical alarm schedule, replacement, or cancellation together in one native SQLite Durable Object transaction. Application queries can use the existing storage wrapper or a `SqlClient` from `@effect/sql-sqlite-do` backed by the same object. The callback receives transaction-only alarm mutations and preserves the caller's success value, typed errors, and service requirements.
8
+
9
+ Failures before commit roll back application rows, logical alarms, and native alarm reconciliation together. Interruption or a lost reply after commit does not undo committed state. Keep external effects outside the transaction and durably pre-arm a later wake before fallible external work; Cloudflare's native alarm retries remain bounded.
10
+
3
11
  ## 0.36.0
4
12
 
5
13
  ### Minor Changes
package/README.md CHANGED
@@ -47,6 +47,8 @@ Declare `SETTINGS` in `wrangler.jsonc` and pass `SettingsLive` to `Worker.make`.
47
47
 
48
48
  The [counter example](https://github.com/danieljvdm/effect-cf/tree/main/examples/counter) shows `DurableObject.Tag`, storage, a typed RPC call, and the complete Wrangler configuration.
49
49
 
50
+ For atomic application writes and alarm changes, see the [alarm transaction example](tests/fixtures/alarm-transaction-consumer.ts) and [API contract](src/DurableObjectAlarm.ts).
51
+
50
52
  ## Native RPC tracing
51
53
 
52
54
  `call()`, `scopedCall()`, and definition methods create one CLIENT span named `binding/method`, covering argument encoding, the native RPC wait, and success decoding. Raw `rpc()` retains Cloudflare's pipelined result without creating a span. Wrap its complete lifetime with `RpcTracing.withRpcClientSpan` when tracing raw calls.
@@ -1,4 +1,4 @@
1
- import { t as DurableObjectState } from "./DurableObjectState-DCh4PZMN.mjs";
1
+ import { t as DurableObjectState } from "./DurableObjectState-DLL0YgBd.mjs";
2
2
  import { fromClient } from "./ComputerArtifacts.mjs";
3
3
  import { t as lookup } from "./ComputerWorkspaceHostRegistry-CY5Fv4Ng.mjs";
4
4
  import { Context, Effect, Layer, Predicate, Schema, Stream } from "effect";
@@ -288,4 +288,4 @@ const runExitPreservingTypedFailures = async (run, effect) => {
288
288
  return exit;
289
289
  };
290
290
  //#endregion
291
- export { DurableWebSocketAttachmentError as a, DurableObjectWebSocket_exports as i, DurableObjectState_exports as n, fromWebSocket as o, fromDurableObjectState as r, DurableObjectStorage_exports as s, DurableObjectState as t };
291
+ export { DurableWebSocketAttachmentError as a, StorageOperationError as c, DurableObjectWebSocket_exports as i, DurableObjectState_exports as n, fromWebSocket as o, fromDurableObjectState as r, DurableObjectStorage_exports as s, DurableObjectState as t };
package/dist/Vitest.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { a as WorkerEnvironment, i as WorkerConfig } from "./ErrorMessage-Byx2OgNd.mjs";
2
2
  import { r as runNativeCallback } from "./WorkerContext-Yobnfw-K.mjs";
3
- import { r as fromDurableObjectState, t as DurableObjectState } from "./DurableObjectState-DCh4PZMN.mjs";
3
+ import { r as fromDurableObjectState, t as DurableObjectState } from "./DurableObjectState-DLL0YgBd.mjs";
4
4
  import { ConfigProvider, Effect, Exit, Layer } from "effect";
5
5
  import { env } from "cloudflare:workers";
6
6
  import { abortAllDurableObjects as abortAllDurableObjects$1, adminSecretsStore as adminSecretsStore$1, applyD1Migrations as applyD1Migrations$1, createExecutionContext, createMessageBatch, createPagesEventContext, createScheduledController, evictAllDurableObjects as evictAllDurableObjects$1, evictDurableObject as evictDurableObject$1, getQueueResult, introspectWorkflow as introspectWorkflow$1, introspectWorkflowInstance as introspectWorkflowInstance$1, listDurableObjectIds as listDurableObjectIds$1, reset as reset$1, runDurableObjectAlarm as runDurableObjectAlarm$1, runInDurableObject as runInDurableObject$1, waitOnExecutionContext } from "cloudflare:test";
package/dist/index.d.mts CHANGED
@@ -853,7 +853,7 @@ type DurableObjectClass<Rpc extends DurableObjectRpc<ROut>, ROut> = new (state:
853
853
  */
854
854
  declare const make$6: <ROut, LayerError, REvent = never, EventLayerError = never, const Rpc extends DurableObjectRpc<ROut | REvent> = Record<never, never>>(layer: Layer.Layer<ROut, LayerError, DurableObjectState | WorkerEnvironment>, options?: DurableObjectOptions<ROut, REvent, EventLayerError, Rpc>) => DurableObjectClass<Rpc, ROut | REvent>;
855
855
  declare namespace DurableObjectAlarm_d_exports {
856
- export { AlarmDefinitionConfig, AlarmDefinitionEntry, AlarmDefinitionPayload, AlarmDefinitions, AlarmFailurePolicy, AlarmPayload, AlarmPayloadSchema, AlarmRef, AlarmRetryPolicy, AlarmScheduler, DefinedAlarmEvent, DefinedAlarmHandlers, DurableObjectAlarm, DurableObjectAlarmError, DurableObjectAlarmEvent, InvalidAlarmPayloadError, InvalidAlarmRefError, InvalidProcessDueAlarmsOptionsError, InvalidRepeatEveryError, ProcessDueAlarmsFailure, ProcessDueAlarmsFailureAction, ProcessDueAlarmsHandler, ProcessDueAlarmsMode, ProcessDueAlarmsOptions, ProcessDueAlarmsResult, ScheduleAlarmInput, StoredAlarmDecodeError, define, processDue };
856
+ export { AlarmDefinitionConfig, AlarmDefinitionEntry, AlarmDefinitionPayload, AlarmDefinitions, AlarmFailurePolicy, AlarmPayload, AlarmPayloadSchema, AlarmRef, AlarmRetryPolicy, AlarmScheduler, AlarmTransaction, DefinedAlarmEvent, DefinedAlarmHandlers, DurableObjectAlarm, DurableObjectAlarmError, DurableObjectAlarmEvent, InvalidAlarmPayloadError, InvalidAlarmRefError, InvalidProcessDueAlarmsOptionsError, InvalidRepeatEveryError, ProcessDueAlarmsFailure, ProcessDueAlarmsFailureAction, ProcessDueAlarmsHandler, ProcessDueAlarmsMode, ProcessDueAlarmsOptions, ProcessDueAlarmsResult, ScheduleAlarmInput, StoredAlarmDecodeError, define, processDue };
857
857
  }
858
858
  type AlarmPayload = Schema.Json;
859
859
  declare const InvalidAlarmRefError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
@@ -946,8 +946,26 @@ interface ProcessDueAlarmsOptions<OnFailureR = never, OnFailureE = never> {
946
946
  readonly retryFailedAfter?: Duration.Input;
947
947
  }
948
948
  type ProcessDueAlarmsHandler<R = never, E = never> = (event: DurableObjectAlarmEvent) => Effect.Effect<void, E, R>;
949
+ /**
950
+ * Alarm mutations owned by one transaction callback. Run them in the callback's
951
+ * fiber; forked work and use after the callback ends fail with StorageOperationError.
952
+ */
953
+ type AlarmTransaction = Pick<AlarmScheduler, "scheduleAlarm" | "cancelAlarm">;
949
954
  /** Own `storage.setAlarm()` exclusively: a Durable Object has one platform alarm timestamp. */
950
955
  type AlarmScheduler = {
956
+ /**
957
+ * Commits local application storage and logical alarms in one native SQLite
958
+ * Durable Object transaction, reconciling the native alarm before commit.
959
+ * Use the supplied mutations, not standalone alarm methods or nested transactions.
960
+ * SqlClient queries must use this same Durable Object's storage.
961
+ *
962
+ * Failure, defects and interruption before commit roll back. A lost reply or
963
+ * interruption after commit does not undo committed state. Keep RPC and other
964
+ * external effects outside; atomically pre-arm a later wake before fallible work.
965
+ * Cloudflare's native retries are bounded; composition does not remove the
966
+ * pre-arm requirement or promise infinite retry liveness.
967
+ */
968
+ readonly transaction: <A, E, R>(closure: (alarms: AlarmTransaction) => Effect.Effect<A, E, R>) => Effect.Effect<A, E | StorageOperationError, R>;
951
969
  readonly cancelAlarm: (input: AlarmRef) => Effect.Effect<void, InvalidAlarmRefError | StorageOperationError>;
952
970
  /** Acknowledge after handling. Conditional writes preserve handler replacements; alarms are at-least-once. */
953
971
  readonly processDueAlarms: <R = never, E = never, OnFailureR = never, OnFailureE = never>(handle: ProcessDueAlarmsHandler<R, E>, options?: ProcessDueAlarmsOptions<OnFailureR, OnFailureE>) => Effect.Effect<ProcessDueAlarmsResult, E | OnFailureE | DurableObjectAlarmError, R | OnFailureR>;
package/dist/index.mjs CHANGED
@@ -3,7 +3,7 @@ import { a as WorkerEnvironment, i as WorkerConfig, n as violationsMessage, r as
3
3
  import { n as Service$1, r as layer$17, t as Binding_exports } from "./Binding-C82jRhdr.mjs";
4
4
  import { n as Artifacts_exports } from "./Artifacts-CBxWGeZB.mjs";
5
5
  import { t as fromExecutionContext } from "./WorkerContext-Yobnfw-K.mjs";
6
- import { a as DurableWebSocketAttachmentError, i as DurableObjectWebSocket_exports, n as DurableObjectState_exports, o as fromWebSocket, r as fromDurableObjectState, s as DurableObjectStorage_exports, t as DurableObjectState } from "./DurableObjectState-DCh4PZMN.mjs";
6
+ import { a as DurableWebSocketAttachmentError, c as StorageOperationError, i as DurableObjectWebSocket_exports, n as DurableObjectState_exports, o as fromWebSocket, r as fromDurableObjectState, s as DurableObjectStorage_exports, t as DurableObjectState } from "./DurableObjectState-DLL0YgBd.mjs";
7
7
  import { A as QueueDefinition_exports, C as decodeSuccess, D as method$1, E as make$15, O as reservedMethodNames$2, S as decodeArgs, T as encodeSuccess, _ as RpcTracing_exports, a as WorkerDefinition_exports, b as RpcDefinition_exports, c as resolve, d as makeEntrypointRuntime, f as runEventPromise, g as recordDecodedArgs, h as invokeRpcMethod, i as Worker_exports, j as QueueBinding_exports, k as Queue_exports, l as scoped, m as defineEntrypointRpcMethods, n as NativeRequest, o as ServiceBinding_exports, p as assumeEntrypointClass, r as WorkerContext, s as Rpc_exports, t as ExecutionContext, u as scheduleTelemetryFlush$1, v as withRpcClientSpan, w as encodeArgs, x as assertNoReservedMethods, y as withRpcTraceContext } from "./Worker-XsGJsCJX.mjs";
8
8
  import { Cause, Clock, Config, ConfigProvider, Context, Data, DateTime, Duration, Effect, Exit, FiberSet, Function as Function$1, Layer, Option, Predicate, Queue, Result, Schema, Tracer } from "effect";
9
9
  import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
@@ -1430,25 +1430,35 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
1430
1430
  });
1431
1431
  const cancelAlarm = Effect.fn("DurableObjectAlarm.cancelAlarm")(function* (input) {
1432
1432
  const ref = yield* decodeAlarmRef(input);
1433
- yield* state.storage.transaction(() => Effect.gen(function* () {
1434
- yield* ensureTable(state);
1435
- yield* state.storage.sql.exec(`DELETE FROM effect_cf_scheduled_alarms WHERE storage_id = ?`, getScheduledEventId(ref)).pipe(Effect.asVoid);
1436
- yield* reconcileAlarm();
1437
- }));
1433
+ yield* state.storage.sql.exec(`DELETE FROM effect_cf_scheduled_alarms WHERE storage_id = ?`, getScheduledEventId(ref));
1438
1434
  });
1439
1435
  const scheduleAlarm = Effect.fn("DurableObjectAlarm.scheduleAlarm")(function* (input) {
1440
1436
  const ref = yield* decodeAlarmRef(input);
1441
1437
  const repeatEveryMillis = yield* toRepeatEveryMillis(input.repeatEvery);
1442
1438
  const payload = yield* encodeStoredPayload(input.payload);
1443
1439
  const runAt = DateTime.toEpochMillis(input.runAt);
1444
- yield* state.storage.transaction(() => Effect.gen(function* () {
1445
- yield* ensureTable(state);
1446
- yield* state.storage.sql.exec(`INSERT OR REPLACE INTO effect_cf_scheduled_alarms
1440
+ yield* state.storage.sql.exec(`INSERT OR REPLACE INTO effect_cf_scheduled_alarms
1447
1441
  (storage_id, alarm_id, tag, run_at, repeat_every_ms, payload)
1448
- VALUES (?, ?, ?, ?, ?, ?)`, getScheduledEventId(ref), ref.id, ref.tag, runAt, repeatEveryMillis, payload).pipe(Effect.asVoid);
1449
- yield* reconcileAlarm();
1450
- }));
1442
+ VALUES (?, ?, ?, ?, ?, ?)`, getScheduledEventId(ref), ref.id, ref.tag, runAt, repeatEveryMillis, payload);
1451
1443
  });
1444
+ const transaction = (closure) => state.storage.transaction(() => Effect.withFiber((owner) => {
1445
+ let active = true;
1446
+ const requireActive = (effect) => Effect.withFiber((fiber) => active && fiber === owner ? effect : Effect.fail(new StorageOperationError({
1447
+ operation: "alarm.transaction",
1448
+ cause: /* @__PURE__ */ new Error("Alarm mutations require their active transaction callback")
1449
+ })));
1450
+ return Effect.gen(function* () {
1451
+ yield* ensureTable(state);
1452
+ const result = yield* Effect.suspend(() => closure({
1453
+ cancelAlarm: (input) => requireActive(cancelAlarm(input)),
1454
+ scheduleAlarm: (input) => requireActive(scheduleAlarm(input))
1455
+ })).pipe(Effect.ensuring(Effect.sync(() => {
1456
+ active = false;
1457
+ })));
1458
+ yield* reconcileAlarm();
1459
+ return result;
1460
+ });
1461
+ }));
1452
1462
  const processDueAlarms = Effect.fn("DurableObjectAlarm.processDueAlarms")(function* (handle, options) {
1453
1463
  yield* ensureTable(state);
1454
1464
  const mode = options?.mode ?? "isolated";
@@ -1507,9 +1517,10 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
1507
1517
  };
1508
1518
  });
1509
1519
  return DurableObjectAlarm.of({
1510
- cancelAlarm,
1520
+ cancelAlarm: (input) => transaction((alarms) => alarms.cancelAlarm(input)),
1511
1521
  processDueAlarms,
1512
- scheduleAlarm
1522
+ scheduleAlarm: (input) => transaction((alarms) => alarms.scheduleAlarm(input)),
1523
+ transaction
1513
1524
  });
1514
1525
  }).pipe(Effect.withSpan("DurableObjectAlarm.layer")));
1515
1526
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effect-cf",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "description": "Effect-native primitives for Cloudflare Workers and bindings.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -12,7 +12,7 @@ import {
12
12
  } from "effect";
13
13
 
14
14
  import { DurableObjectState } from "./DurableObjectState";
15
- import type { SqlStorageValue, StorageOperationError } from "./DurableObjectStorage";
15
+ import { type SqlStorageValue, StorageOperationError } from "./DurableObjectStorage";
16
16
  import * as ErrorMessage from "./internal/ErrorMessage";
17
17
 
18
18
  const INIT_TABLE_SQL = `
@@ -177,8 +177,30 @@ export type ProcessDueAlarmsHandler<R = never, E = never> = (
177
177
  event: DurableObjectAlarmEvent,
178
178
  ) => Effect.Effect<void, E, R>;
179
179
 
180
+ /**
181
+ * Alarm mutations owned by one transaction callback. Run them in the callback's
182
+ * fiber; forked work and use after the callback ends fail with StorageOperationError.
183
+ */
184
+ export type AlarmTransaction = Pick<AlarmScheduler, "scheduleAlarm" | "cancelAlarm">;
185
+
180
186
  /** Own `storage.setAlarm()` exclusively: a Durable Object has one platform alarm timestamp. */
181
187
  export type AlarmScheduler = {
188
+ /**
189
+ * Commits local application storage and logical alarms in one native SQLite
190
+ * Durable Object transaction, reconciling the native alarm before commit.
191
+ * Use the supplied mutations, not standalone alarm methods or nested transactions.
192
+ * SqlClient queries must use this same Durable Object's storage.
193
+ *
194
+ * Failure, defects and interruption before commit roll back. A lost reply or
195
+ * interruption after commit does not undo committed state. Keep RPC and other
196
+ * external effects outside; atomically pre-arm a later wake before fallible work.
197
+ * Cloudflare's native retries are bounded; composition does not remove the
198
+ * pre-arm requirement or promise infinite retry liveness.
199
+ */
200
+ readonly transaction: <A, E, R>(
201
+ closure: (alarms: AlarmTransaction) => Effect.Effect<A, E, R>,
202
+ ) => Effect.Effect<A, E | StorageOperationError, R>;
203
+
182
204
  readonly cancelAlarm: (
183
205
  input: AlarmRef,
184
206
  ) => Effect.Effect<void, InvalidAlarmRefError | StorageOperationError>;
@@ -523,17 +545,9 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
523
545
  const cancelAlarm = Effect.fn("DurableObjectAlarm.cancelAlarm")(function* (input: AlarmRef) {
524
546
  const ref = yield* decodeAlarmRef(input);
525
547
 
526
- yield* state.storage.transaction(() =>
527
- Effect.gen(function* () {
528
- yield* ensureTable(state);
529
- yield* state.storage.sql
530
- .exec(
531
- `DELETE FROM effect_cf_scheduled_alarms WHERE storage_id = ?`,
532
- getScheduledEventId(ref),
533
- )
534
- .pipe(Effect.asVoid);
535
- yield* reconcileAlarm();
536
- }),
548
+ yield* state.storage.sql.exec(
549
+ `DELETE FROM effect_cf_scheduled_alarms WHERE storage_id = ?`,
550
+ getScheduledEventId(ref),
537
551
  );
538
552
  });
539
553
 
@@ -545,27 +559,62 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
545
559
  const payload = yield* encodeStoredPayload(input.payload);
546
560
  const runAt = DateTime.toEpochMillis(input.runAt);
547
561
 
548
- yield* state.storage.transaction(() =>
549
- Effect.gen(function* () {
550
- yield* ensureTable(state);
551
- yield* state.storage.sql
552
- .exec(
553
- `INSERT OR REPLACE INTO effect_cf_scheduled_alarms
562
+ yield* state.storage.sql.exec(
563
+ `INSERT OR REPLACE INTO effect_cf_scheduled_alarms
554
564
  (storage_id, alarm_id, tag, run_at, repeat_every_ms, payload)
555
565
  VALUES (?, ?, ?, ?, ?, ?)`,
556
- getScheduledEventId(ref),
557
- ref.id,
558
- ref.tag,
559
- runAt,
560
- repeatEveryMillis,
561
- payload,
562
- )
563
- .pipe(Effect.asVoid);
564
- yield* reconcileAlarm();
565
- }),
566
+ getScheduledEventId(ref),
567
+ ref.id,
568
+ ref.tag,
569
+ runAt,
570
+ repeatEveryMillis,
571
+ payload,
566
572
  );
567
573
  });
568
574
 
575
+ const transaction: AlarmScheduler["transaction"] = (closure) =>
576
+ state.storage.transaction(() =>
577
+ Effect.withFiber((owner) => {
578
+ let active = true;
579
+
580
+ // A callback-owned handle cannot escape via a returned Effect or a
581
+ // detached fiber and write after reconciliation or rollback.
582
+ const requireActive = <A, E>(effect: Effect.Effect<A, E>) =>
583
+ Effect.withFiber<A, E | StorageOperationError>((fiber) =>
584
+ active && fiber === owner
585
+ ? effect
586
+ : Effect.fail(
587
+ new StorageOperationError({
588
+ operation: "alarm.transaction",
589
+ cause: new Error(
590
+ "Alarm mutations require their active transaction callback",
591
+ ),
592
+ }),
593
+ ),
594
+ );
595
+
596
+ return Effect.gen(function* () {
597
+ yield* ensureTable(state);
598
+ const result = yield* Effect.suspend(() =>
599
+ closure({
600
+ cancelAlarm: (input) => requireActive(cancelAlarm(input)),
601
+ scheduleAlarm: (input) => requireActive(scheduleAlarm(input)),
602
+ }),
603
+ ).pipe(
604
+ Effect.ensuring(
605
+ Effect.sync(() => {
606
+ active = false;
607
+ }),
608
+ ),
609
+ );
610
+
611
+ yield* reconcileAlarm();
612
+
613
+ return result;
614
+ });
615
+ }),
616
+ );
617
+
569
618
  const processDueAlarms = Effect.fn("DurableObjectAlarm.processDueAlarms")(function* <
570
619
  R,
571
620
  E,
@@ -669,9 +718,10 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
669
718
  });
670
719
 
671
720
  return DurableObjectAlarm.of({
672
- cancelAlarm,
721
+ cancelAlarm: (input) => transaction((alarms) => alarms.cancelAlarm(input)),
673
722
  processDueAlarms,
674
- scheduleAlarm,
723
+ scheduleAlarm: (input) => transaction((alarms) => alarms.scheduleAlarm(input)),
724
+ transaction,
675
725
  });
676
726
  }).pipe(Effect.withSpan("DurableObjectAlarm.layer")),
677
727
  );