@gjermundgaraba/effect-actions-cloudflare 0.22.0 → 0.23.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.
Files changed (54) hide show
  1. package/dist/ActionDurableObject.d.mts +6 -0
  2. package/dist/ActionDurableObject.mjs +7 -7
  3. package/dist/alarm.mjs +5 -27
  4. package/dist/batches.mjs +0 -1
  5. package/dist/calls.mjs +0 -11
  6. package/dist/events.mjs +0 -15
  7. package/dist/frames.mjs +6 -9
  8. package/dist/idempotency.mjs +2 -8
  9. package/dist/placed.mjs +0 -4
  10. package/dist/sockets.d.mts +0 -4
  11. package/dist/sockets.mjs +7 -14
  12. package/dist/stub.d.mts +4 -4
  13. package/dist/stub.mjs +9 -8
  14. package/dist/subscriptions.mjs +10 -22
  15. package/dist/tasks.mjs +3 -8
  16. package/dist/webhooks.mjs +5 -21
  17. package/docs/ActionDurableObject-events.md +0 -20
  18. package/docs/ActionDurableObject-failures.md +1 -1
  19. package/docs/ActionDurableObject-placement.md +1 -4
  20. package/docs/ActionDurableObject-stub.md +3 -31
  21. package/docs/ActionDurableObject-testing.md +0 -5
  22. package/docs/ActionDurableObject.md +3 -49
  23. package/docs/README.md +1 -1
  24. package/examples/chat-rooms/README.md +2 -2
  25. package/examples/chat-rooms/src/Api.ts +0 -3
  26. package/examples/chat-rooms/src/Rpc.ts +0 -7
  27. package/examples/chat-rooms/src/browser.ts +2 -34
  28. package/examples/chat-rooms/src/chat/Chat.ts +3 -23
  29. package/examples/chat-rooms/src/chat/activity.ts +0 -5
  30. package/examples/chat-rooms/src/chat/authorize.ts +0 -3
  31. package/examples/chat-rooms/src/chat/events.ts +0 -12
  32. package/examples/chat-rooms/src/chat/handlers.ts +0 -6
  33. package/examples/chat-rooms/src/chat/room.ts +2 -7
  34. package/examples/chat-rooms/src/chat/store.ts +0 -6
  35. package/examples/chat-rooms/src/identity/Identity.ts +0 -6
  36. package/examples/chat-rooms/src/identity/members.ts +1 -3
  37. package/examples/chat-rooms/src/identity/verifier.ts +6 -9
  38. package/examples/chat-rooms/src/lobby/Calls.ts +0 -5
  39. package/examples/chat-rooms/src/lobby/Lobby.ts +2 -7
  40. package/examples/chat-rooms/src/lobby/events.ts +0 -5
  41. package/examples/chat-rooms/src/lobby/handlers.ts +0 -3
  42. package/examples/chat-rooms/src/lobby/host.ts +0 -1
  43. package/examples/chat-rooms/src/lobby/store.ts +2 -4
  44. package/examples/chat-rooms/src/platform/kv-previews.ts +6 -5
  45. package/examples/chat-rooms/src/platform/lobby-stubs.ts +0 -15
  46. package/examples/chat-rooms/src/platform/sql-store.ts +0 -11
  47. package/examples/chat-rooms/src/rooms-entrypoint.ts +2 -10
  48. package/examples/chat-rooms/src/routes.ts +0 -25
  49. package/examples/chat-rooms/src/worker.ts +3 -24
  50. package/examples/chat-rooms/test/platform/sql-store.test.ts +0 -5
  51. package/examples/chat-rooms/test/support/socket.ts +0 -9
  52. package/examples/chat-rooms/test/worker.test.ts +4 -18
  53. package/examples/chat-rooms/vite.config.ts +0 -5
  54. package/package.json +3 -3
@@ -194,6 +194,12 @@ export interface Handlers<S = never> {
194
194
  * Guide: `docs/ActionDurableObject-own-class.md` in this package.
195
195
  */
196
196
  export declare const handlers: <S = never, SE = never, SR extends State | Alarm | Tasks | Event.Events | Action.Idempotency = never, A = never, E = never, R extends Allowed<A, S> = never, AE = never, AR extends S | State | Alarm | Tasks | Event.Events | Action.Idempotency | Scope.Scope = never, CA = never, CE = never, CR extends RpcServer.Protocol | S | State | Alarm | Tasks | Event.Events | Action.Idempotency = never>(ctx: DurableObjectState, options: Options<S, SE, SR, A, E, R, AE, AR, CA, CE, CR>) => Handlers<S>;
197
+ /**
198
+ * An instance of a class `make` returns: a Durable Object with the handlers it defines, each
199
+ * taking the runtime's own arguments, so a class extending it overrides them as `Handlers`
200
+ * declares them, returning a promise, and reaches `make`'s through `super`, its `env` the one its
201
+ * `services` function takes.
202
+ */
197
203
  /**
198
204
  * An instance of a class `make` returns: a Durable Object with the handlers it defines, each
199
205
  * taking the runtime's own arguments, so a class extending it overrides them as `Handlers`
@@ -24,23 +24,23 @@ const handlersOf = (ctx, options) => {
24
24
  const { waits, wakeDue } = makeWaits(schedule);
25
25
  const tasks = makeTasks(schedule, options.tasks ?? {});
26
26
  const build = Effect.gen(function* () {
27
- const memoMap = yield* Layer.makeMemoMap;
27
+ const sharedMemoMap = yield* Layer.makeMemoMap;
28
28
  const scope = yield* Scope.make();
29
29
  return yield* Effect.gen(function* () {
30
- const context = yield* Layer.buildWithMemoMap(services.pipe(Layer.provideMerge(Layer.mergeAll(Layer.succeed(Alarm)(alarmOf(schedule)), Layer.succeed(Waits)(waits), Layer.succeed(Tasks)(tasks.tasks), layerEvents, layerIdempotency(ctx))), Layer.provideMerge(state), Layer.provideMerge(Layer.merge(Layer.succeed(Scheduler.Scheduler)(scheduler), options.placed === void 0 ? Layer.empty : layerInputCheck(options.placed, ctx.id.name)))), memoMap, scope);
30
+ const context = yield* Layer.buildWithMemoMap(services.pipe(Layer.provideMerge(Layer.mergeAll(Layer.succeed(Alarm)(alarmOf(schedule)), Layer.succeed(Waits)(waits), Layer.succeed(Tasks)(tasks.tasks), layerEvents, layerIdempotency(ctx))), Layer.provideMerge(state), Layer.provideMerge(Layer.merge(Layer.succeed(Scheduler.Scheduler)(scheduler), options.placed === void 0 ? Layer.empty : layerInputCheck(options.placed, ctx.id.name)))), sharedMemoMap, scope);
31
31
  const given = Layer.succeedContext(context);
32
32
  yield* schedule.restore;
33
33
  const call = calls === void 0 ? async () => {
34
34
  throw new Error("This Durable Object serves no rpcs over its stub: give handlers or make a calls Layer");
35
35
  } : yield* Effect.gen(function* () {
36
36
  const stub = yield* Effect.provideContext(makeStubProtocol, context);
37
- yield* Layer.buildWithMemoMap(calls.pipe(Layer.provide(Layer.succeed(RpcServer.Protocol)(stub.protocol)), Layer.provide(given)), memoMap, scope);
37
+ yield* Layer.buildWithMemoMap(calls.pipe(Layer.provide(Layer.succeed(RpcServer.Protocol)(stub.protocol)), Layer.provide(given)), sharedMemoMap, scope);
38
38
  yield* stub.served;
39
39
  return async (request) => await Effect.runPromise(stub.serve(request));
40
40
  });
41
- const sockets = yield* Layer.buildWithMemoMap(Sockets.layer.pipe(Layer.provide(given)), memoMap, scope);
41
+ const sockets = yield* Layer.buildWithMemoMap(Sockets.layer.pipe(Layer.provide(given)), sharedMemoMap, scope);
42
42
  const serve = routes === void 0 ? async () => new Response(null, { status: 404 }) : yield* Effect.gen(function* () {
43
- const routed = yield* Layer.buildWithMemoMap(routes.pipe(Layer.provideMerge(Layer.succeedContext(Context.merge(context, sockets))), Layer.provide(HttpServer.layerServices), Layer.provideMerge(HttpRouter.layer)), memoMap, scope);
43
+ const routed = yield* Layer.buildWithMemoMap(routes.pipe(Layer.provideMerge(Layer.succeedContext(Context.merge(context, sockets))), Layer.provide(HttpServer.layerServices), Layer.provideMerge(HttpRouter.layer)), sharedMemoMap, scope);
44
44
  return yield* Effect.sync(() => HttpEffect.toWebHandlerWith(routed)(Context.get(routed, HttpRouter.HttpRouter).asHttpEffect(), options.disableLogger === true ? void 0 : HttpMiddleware.logger));
45
45
  });
46
46
  return {
@@ -89,8 +89,8 @@ const handlersOf = (ctx, options) => {
89
89
  if (!building) start();
90
90
  return await promise;
91
91
  };
92
- const provided = ({ context }, effect) => Effect.scopedWith((scope) => Effect.provideContext(effect, context.pipe(Context.add(Scope.Scope, scope), Context.add(Layer.CurrentMemoMap, Layer.makeMemoMapUnsafe()))));
93
- const run = async (effect) => await withBuilt(async (built) => await Effect.runPromise(provided(built, effect)));
92
+ const providedPerCall = ({ context }, effect) => Effect.scopedWith((scope) => Effect.provideContext(effect, context.pipe(Context.add(Scope.Scope, scope), Context.add(Layer.CurrentMemoMap, Layer.makeMemoMapUnsafe()))));
93
+ const run = async (effect) => await withBuilt(async (built) => await Effect.runPromise(providedPerCall(built, effect)));
94
94
  const dispatch = async (event) => {
95
95
  await withBuilt(async ({ sockets }) => {
96
96
  await Effect.runPromise(Effect.asVoid(event(sockets)));
package/dist/alarm.mjs CHANGED
@@ -1,7 +1,6 @@
1
1
  import { State } from "./state.mjs";
2
2
  import { Clock, Context, DateTime, Effect, Exit, Layer, Option, Schema, Semaphore } from "effect";
3
3
  // src/durable/alarm.ts
4
- /** Where the object's alarm times are kept, one key per owner. */
5
4
  const alarmPrefix = "effect-actions/alarm/";
6
5
  const keyOf = (owner) => `${alarmPrefix}${owner}`;
7
6
  const Kept = Schema.Struct({
@@ -12,15 +11,10 @@ const keptOf = Schema.decodeUnknownOption(Kept);
12
11
  const backoffRetries = 6;
13
12
  const hour = 36e5;
14
13
  const retryDelay = (failures) => failures > backoffRetries ? hour : 2e3 * 2 ** (failures - 1);
15
- /**
16
- * The object's alarm times, one per owner, kept in its storage: the runtime's one alarm is set
17
- * for the earliest, and `runDue` runs each owner whose time has come. `handlers` and `make` make
18
- * one per instance, which its `Alarm` and its `alarm` handler share.
19
- */
20
14
  const makeSchedule = (state) => {
21
15
  const { storage } = state;
22
16
  const lock = Semaphore.makeUnsafe(1);
23
- const running = /* @__PURE__ */ new Set();
17
+ const runningInThisInstance = /* @__PURE__ */ new Set();
24
18
  const read = (owner) => Effect.map(Effect.promise(async () => await storage.get(keyOf(owner))), keptOf);
25
19
  const times = Effect.promise(async () => await storage.list({ prefix: alarmPrefix })).pipe(Effect.map((listed) => [...listed].flatMap(([key, value]) => Option.match(keptOf(value), {
26
20
  onNone: () => [],
@@ -41,29 +35,15 @@ const makeSchedule = (state) => {
41
35
  return {
42
36
  rearm,
43
37
  restore: lock.withPermits(1)(Effect.flatMap(Effect.promise(async () => await storage.getAlarm()), (alarm) => alarm === null ? Effect.flatMap(times, (kept) => kept.length === 0 ? Effect.void : rearm) : Effect.void)),
44
- /**
45
- * Keeps `at` as the owner's time, in place of any: set as the owner's run fails, it gives way
46
- * to that run's retry where the retry is earlier.
47
- */
48
38
  set: (owner, at) => lock.withPermits(1)(Effect.flatMap(read(owner), (kept) => Option.exists(kept, (held) => held.at === at && held.failures === 0) ? Effect.void : Effect.andThen(keep(owner, {
49
39
  at,
50
40
  failures: 0
51
41
  }), rearm))),
52
- /**
53
- * Keeps `at` as the owner's time unless an earlier one is kept, a retry due included: the
54
- * mark of the owner's run under way is no time, so a time set from the run is kept.
55
- */
56
- setBy: (owner, at) => lock.withPermits(1)(Effect.flatMap(read(owner), (kept) => Option.exists(kept, (held) => held.at <= at && (held.failures === 0 || !running.has(owner))) ? Effect.void : Effect.andThen(keep(owner, {
42
+ setBy: (owner, at) => lock.withPermits(1)(Effect.flatMap(read(owner), (kept) => Option.exists(kept, (held) => held.at <= at && (held.failures === 0 || !runningInThisInstance.has(owner))) ? Effect.void : Effect.andThen(keep(owner, {
57
43
  at,
58
44
  failures: 0
59
45
  }), rearm))),
60
46
  clear: (owner) => lock.withPermits(1)(Effect.andThen(Effect.promise(async () => await storage.delete(keyOf(owner))), rearm)),
61
- /**
62
- * Runs each owner whose time has come as its turn comes, removing its time once its run
63
- * succeeds unless one was set or cleared as it ran: one that fails is logged and retried, 2
64
- * seconds later, doubling, six times, then hourly, unless cleared as it ran or a time set as
65
- * it ran is earlier.
66
- */
67
47
  runDue: (owners) => Effect.gen(function* () {
68
48
  const now = yield* Clock.currentTimeMillis;
69
49
  const kept = yield* times;
@@ -82,7 +62,7 @@ const makeSchedule = (state) => {
82
62
  yield* keep(owner, mark).pipe(Effect.andThen(rearm), Effect.andThen(Effect.promise(async () => {
83
63
  await storage.sync();
84
64
  })));
85
- running.add(owner);
65
+ runningInThisInstance.add(owner);
86
66
  return {
87
67
  ...current,
88
68
  mark,
@@ -100,7 +80,7 @@ const makeSchedule = (state) => {
100
80
  if (Exit.isFailure(exit)) yield* Effect.logError(`The alarm of ${owner} failed: retried in ${retryDelay(attempt)} ms`, exit.cause);
101
81
  const endedAt = yield* Clock.currentTimeMillis;
102
82
  yield* lock.withPermits(1)(Effect.gen(function* () {
103
- running.delete(owner);
83
+ runningInThisInstance.delete(owner);
104
84
  const current = yield* stored;
105
85
  const isMark = current?.at === mark.at && current.failures === mark.failures;
106
86
  if (Exit.isSuccess(exit)) {
@@ -115,15 +95,13 @@ const makeSchedule = (state) => {
115
95
  if (isMark || retry.at < current.at) yield* keep(owner, retry);
116
96
  }));
117
97
  }).pipe(Effect.ensuring(Effect.sync(() => {
118
- running.delete(owner);
98
+ runningInThisInstance.delete(owner);
119
99
  }))), { discard: true });
120
100
  yield* lock.withPermits(1)(rearm);
121
101
  })
122
102
  };
123
103
  };
124
- /** The owner the application's `alarm` option runs as. */
125
104
  const applicationOwner = "application";
126
- /** The application's alarm over `schedule`, the instance's. */
127
105
  const alarmOf = (schedule) => Alarm.of({
128
106
  set: (at) => schedule.setBy(applicationOwner, DateTime.toEpochMillis(at)),
129
107
  clear: schedule.clear(applicationOwner)
package/dist/batches.mjs CHANGED
@@ -5,6 +5,5 @@ import { Array, Effect } from "effect";
5
5
  * most 128 keys each, as Cloudflare's storage API documents, though local workerd takes more.
6
6
  */
7
7
  const batches = (keys) => Array.chunksOf(keys, 128);
8
- /** Deletes `keys` from `storage`, one batch per call. */
9
8
  const deleted = (storage, keys) => Effect.forEach(batches(keys), (batch) => Effect.promise(async () => await storage.delete([...batch])), { discard: true });
10
9
  export { batches, deleted };
package/dist/calls.mjs CHANGED
@@ -2,19 +2,8 @@ import { Effect, Predicate, Queue, Schema } from "effect";
2
2
  import { RpcMessage, RpcServer } from "effect/rpc";
3
3
  import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
4
4
  // src/durable/calls.ts
5
- /**
6
- * The codec of the stub's values: each schema's type to its plain form, `Schema.toCodecIso`, which
7
- * Workers RPC's structured clone carries as it is, a `Date`, bytes, `undefined` and a `Map` among
8
- * them, where JSON would encode them again. An `Exit`, a `Cause` and an `Option` encode to their
9
- * tagged objects, a class instance to its fields.
10
- */
11
5
  const codecFor = Schema.toCodecIso;
12
6
  const encodeDefect = Schema.encodeSync(codecFor(Schema.Defect()));
13
- /**
14
- * A unary `RpcServer.Protocol` over the object's stub, passing values, modeled on Effect's HTTP
15
- * protocol: one call is one client, its requests and their responses, then its end. `serve`
16
- * answers one call of `effectActionsRpc`, and `served` dies unless a server runs on `protocol`.
17
- */
18
7
  const makeStubProtocol = Effect.gen(function* () {
19
8
  const context = yield* Effect.context();
20
9
  let running = false;
package/dist/events.mjs CHANGED
@@ -18,10 +18,6 @@ const positionOf = Schema.decodeUnknownOption(Position);
18
18
  const storedOf = Schema.decodeUnknownOption(Stored);
19
19
  const encodeStored = Schema.encodeSync(Stored);
20
20
  const seqWidth = 16;
21
- /**
22
- * A log's storage in the object's, under `effect-actions/log/<event>/`: its position, and each
23
- * entry by its sequence number, its data encoded as the event's payload JSON.
24
- */
25
21
  const storageLog = (storage, event) => {
26
22
  const prefix = `effect-actions/log/${event.name}/`;
27
23
  const positionKey = `${prefix}meta`;
@@ -85,10 +81,6 @@ const epochKey = `${revisionPrefix}epoch`;
85
81
  const countPrefix = `${revisionPrefix}count/`;
86
82
  const epochOf = Schema.decodeUnknownOption(Schema.String);
87
83
  const countOf = Schema.decodeUnknownOption(Schema.Int);
88
- /**
89
- * Current sources' revisions in the object's storage, under `effect-actions/revisions/`: an
90
- * epoch made as the object first builds, and a counter by name.
91
- */
92
84
  const storageRevisions = (storage) => {
93
85
  const get = (name) => Effect.map(Effect.promise(async () => await storage.get(`${countPrefix}${name}`)), (found) => Option.getOrElse(countOf(found), () => 0));
94
86
  return Effect.gen(function* () {
@@ -107,13 +99,6 @@ const storageRevisions = (storage) => {
107
99
  };
108
100
  });
109
101
  };
110
- /**
111
- * The object's `Event.Events`, which `handlers` and `make` provide to the services, calls and
112
- * routes: each log source's log kept in the object's storage, under `effect-actions/log/`, each
113
- * current source's revisions beside it, so a cursor outlives an eviction, and a check or current
114
- * source's subscribers read again when notified, not on its `nextPoll`, a timer that would keep the
115
- * object from hibernating. Its rules are on `handlers`.
116
- */
117
102
  const layerEvents = Layer.effect(Event.Events)(Effect.flatMap(State, ({ storage }) => Effect.flatMap(storageRevisions(storage), (revisions) => {
118
103
  const named = /* @__PURE__ */ new Map();
119
104
  return Event.makeEvents({
package/dist/frames.mjs CHANGED
@@ -2,32 +2,29 @@ import { Effect, Schema } from "effect";
2
2
  import { RpcSerialization } from "effect/rpc";
3
3
  // src/durable/frames.ts
4
4
  const socketPrefix = "effect-actions/socket#";
5
- /** The tag a socket carries for its id, unique among the object's sockets. */
6
5
  const socketTag = (id) => `${socketPrefix}${id}`;
7
- /** The id a socket's tags give it, or 0, which no socket is given, for one no protocol accepted. */
8
6
  const socketIdOf = (tags) => {
9
7
  const tag = tags.find((found) => found.startsWith(socketPrefix));
10
8
  return tag === void 0 ? 0 : Number(tag.slice(22));
11
9
  };
12
- /** The tag a socket carries for the `layerWebSocket` path that accepted it. */
13
10
  const groupTag = (path) => `effect-actions/rpc:${path}`;
14
- /** A request's id on the wire, a finite number or a string, which a client tells apart. */
15
11
  const RequestId = Schema.Union([Schema.String, Schema.Finite]);
16
- /** The object's JSON parser, which keeps no state between frames. */
17
12
  const parser = RpcSerialization.json.makeUnsafe();
18
- /** Closes `socket` with `code`, or with none, unless it is already closing. */
19
13
  const closeQuietly = (socket, code, reason) => {
20
14
  try {
21
15
  if (code === void 0) socket.close();
22
16
  else socket.close(code, reason);
23
- } catch {}
17
+ } catch {
18
+ return;
19
+ }
24
20
  };
25
- /** Sends `frame` to the socket `id`, if one holds it and takes it: one the object closed does not. */
26
21
  const sendFrame = (state, id, frame) => Effect.sync(() => {
27
22
  const encoded = parser.encode(frame);
28
23
  if (encoded === void 0) return;
29
24
  try {
30
25
  for (const socket of state.getWebSockets(socketTag(id))) socket.send(encoded);
31
- } catch {}
26
+ } catch {
27
+ return;
28
+ }
32
29
  });
33
30
  export { RequestId, closeQuietly, groupTag, parser, sendFrame, socketIdOf, socketTag };
@@ -2,19 +2,13 @@ import { deleted } from "./batches.mjs";
2
2
  import { Clock, Effect, Layer, Option, Schema } from "effect";
3
3
  import * as Action from "@gjermundgaraba/effect-actions/Action";
4
4
  // src/durable/idempotency.ts
5
- /** Where the object's kept answers live, one key per action, caller's principal and idempotency key. */
6
5
  const keptPrefix = "effect-actions/idempotency/";
7
6
  const hour = 36e5;
8
7
  const page = 128;
9
8
  const keptOf = Schema.decodeUnknownOption(Action.Idempotency.Kept);
10
- /**
11
- * `Action.Idempotency` over the object's storage: each answer kept 24 hours, through
12
- * hibernation, eviction and deployments, and deleted once its day is past as a later one is kept,
13
- * at most once an hour, so an object given no further key keeps its last day's answers.
14
- */
15
9
  const layerIdempotency = (state) => Layer.sync(Action.Idempotency)(() => {
16
10
  let prunedAt = -Infinity;
17
- const prune = Effect.gen(function* () {
11
+ const pruneHourlyOnWrite = Effect.gen(function* () {
18
12
  const now = yield* Clock.currentTimeMillis;
19
13
  if (now - prunedAt < hour) return;
20
14
  prunedAt = now;
@@ -34,7 +28,7 @@ const layerIdempotency = (state) => Layer.sync(Action.Idempotency)(() => {
34
28
  get: (id) => Effect.map(Effect.promise(async () => await state.storage.get(`${keptPrefix}${id}`)), (stored) => Option.getOrUndefined(keptOf(stored))),
35
29
  set: (id, kept) => Effect.andThen(Effect.promise(async () => {
36
30
  await state.storage.put(`${keptPrefix}${id}`, kept);
37
- }), prune)
31
+ }), pruneHourlyOnWrite)
38
32
  });
39
33
  });
40
34
  export { layerIdempotency };
package/dist/placed.mjs CHANGED
@@ -37,10 +37,6 @@ const reaching = (placement, namespace) => {
37
37
  return HttpClient.transform(fetching, (sent) => Effect.provideService(sent, FetchHttpClient.Fetch, async (url, init) => await stub.fetch(url, init)));
38
38
  });
39
39
  };
40
- /**
41
- * The input check of an object named `name`, given `placement`: an input of one of its contracts
42
- * whose key names another object is refused.
43
- */
44
40
  const layerInputCheck = (placement, name) => {
45
41
  const contracts = /* @__PURE__ */ new Set([...placement.binding.actions, ...placement.binding.events]);
46
42
  return Layer.succeed(Action.InputCheck)((contract, input) => {
@@ -2,11 +2,8 @@ import { State } from "./state.mjs";
2
2
  import { Context, Effect, Layer } from "effect";
3
3
  import { HttpRouter, HttpServerResponse } from "effect/http";
4
4
  import { RpcServer } from "effect/rpc";
5
- /** What a protocol does with the events of the sockets carrying its tag. */
6
5
  interface Receiver {
7
- /** Hands a message to the protocol. */
8
6
  readonly message: (socket: WebSocket, data: string | ArrayBuffer) => Effect.Effect<void>;
9
- /** Tells the protocol the socket closed or failed. */
10
7
  readonly close: (socket: WebSocket) => Effect.Effect<void>;
11
8
  }
12
9
  declare const Sockets_base: Context.ServiceClass<Sockets, "effect-actions-cloudflare/ActionDurableObject/Sockets", {
@@ -41,7 +38,6 @@ declare const Sockets_base: Context.ServiceClass<Sockets, "effect-actions-cloudf
41
38
  * - A socket accepted by an earlier version of the routes at another `path` carries a tag no protocol holds now, and is answered `false`: close it, or serve that `path` again.
42
39
  */
43
40
  export declare class Sockets extends Sockets_base {
44
- /** One dispatcher, with no protocol registered, for the instance whose `State` it is given. */
45
41
  static readonly layer: Layer.Layer<Sockets, never, State>;
46
42
  }
47
43
  /**
package/dist/sockets.mjs CHANGED
@@ -6,6 +6,7 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
6
6
  import { RpcMessage, RpcSerialization, RpcServer } from "effect/rpc";
7
7
  import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
8
8
  // src/durable/sockets.ts
9
+ const restartCloseCode = 1012;
9
10
  const replyToClose = (socket, code, reason) => Effect.sync(() => {
10
11
  if (code >= 1e3 && code !== 1005 && code !== 1006 && code !== 1015) closeQuietly(socket, code, reason);
11
12
  else closeQuietly(socket);
@@ -23,7 +24,6 @@ const replyToClose = (socket, code, reason) => Effect.sync(() => {
23
24
  * - A socket accepted by an earlier version of the routes at another `path` carries a tag no protocol holds now, and is answered `false`: close it, or serve that `path` again.
24
25
  */
25
26
  var Sockets = class Sockets extends Context.Service()("effect-actions-cloudflare/ActionDurableObject/Sockets") {
26
- /** One dispatcher, with no protocol registered, for the instance whose `State` it is given. */
27
27
  static layer = Layer.effect(Sockets)(Effect.gen(function* () {
28
28
  const state = yield* State;
29
29
  const receivers = /* @__PURE__ */ new Map();
@@ -54,15 +54,13 @@ const Attachment = Schema.Struct({
54
54
  pending: Schema.optional(Schema.Array(RequestId))
55
55
  });
56
56
  const decodeAttachment = Schema.decodeUnknownExit(Schema.NullishOr(Attachment));
57
- /** The socket's attachment, failing when it does not decode, or `undefined` with none. */
58
57
  const attachmentOf = (socket) => Exit.map(decodeAttachment(socket.deserializeAttachment()), (held) => held ?? void 0);
59
- /** The socket's attachment, or `undefined` with none or one that does not decode. */
60
58
  const heldOf = (socket) => Exit.match(attachmentOf(socket), {
61
59
  onFailure: Function.constUndefined,
62
60
  onSuccess: (held) => held
63
61
  });
64
62
  const pendingOf = (socket) => heldOf(socket)?.pending ?? [];
65
- const tracked = (socket, update) => {
63
+ const heldInAttachment = (socket, update) => {
66
64
  const held = heldOf(socket);
67
65
  if (held === void 0) return true;
68
66
  const pending = update(held.pending ?? []);
@@ -87,7 +85,7 @@ const upgraded = (client) => HttpServerResponse.raw(new Response(null, {
87
85
  const closeReasonBytes = 123;
88
86
  const bytesOf = (text) => new TextEncoder().encode(text).byteLength;
89
87
  const acceptedCode = (code) => Number.isInteger(code) && code >= 4e3 && code <= 4999;
90
- const refusalOf = (protocol) => Effect.gen(function* () {
88
+ const validatedRefusal = (protocol) => Effect.gen(function* () {
91
89
  if (protocol === void 0) return;
92
90
  const { version, code, reason = `This server speaks protocol ${version}: reload the page` } = protocol;
93
91
  if (!acceptedCode(code)) return yield* Effect.die(/* @__PURE__ */ new Error(`ActionDurableObject.layerWebSocket's protocol.code ${code} is not one it accepts, an integer from 4000 to 4999, the codes a watch ends at: give one of those`));
@@ -119,16 +117,11 @@ const refuseUpgrade = (code, reason) => Effect.suspend(() => {
119
117
  server.close(code, reason);
120
118
  return Effect.succeed(upgraded(client));
121
119
  });
122
- /**
123
- * A hibernating `RpcServer.Protocol` over the object's WebSockets, speaking JSON, and its
124
- * upgrade route at `path`. Each socket carries two tags, its group's and its own id's, and keeps
125
- * its upgrade request's headers in its attachment, so both survive hibernation.
126
- */
127
120
  const makeProtocol = (state, sockets, { path, protocol: version }) => Effect.gen(function* () {
128
121
  const router = yield* HttpRouter.HttpRouter;
129
122
  const context = yield* Effect.context();
130
123
  const group = groupTag(path);
131
- const refusal = yield* refusalOf(version);
124
+ const refusal = yield* validatedRefusal(version);
132
125
  const idOf = (socket) => socketIdOf(state.getTags(socket));
133
126
  state.setWebSocketAutoResponse(new WebSocketRequestResponsePair(String(parser.encode(RpcMessage.constPing)), String(parser.encode(RpcMessage.constPong))));
134
127
  const disconnects = yield* Queue.make();
@@ -136,7 +129,7 @@ const makeProtocol = (state, sockets, { path, protocol: version }) => Effect.gen
136
129
  const send = (clientId, response) => sendFrame(state, clientId, response).pipe(Effect.andThen(Effect.sync(() => {
137
130
  const settled = settledBy(response);
138
131
  if (settled === void 0) return;
139
- for (const socket of state.getWebSockets(socketTag(clientId))) tracked(socket, settled);
132
+ for (const socket of state.getWebSockets(socketTag(clientId))) heldInAttachment(socket, settled);
140
133
  })), Effect.andThen(subscriptions.sent(clientId, response)));
141
134
  const protocol = yield* RpcServer.Protocol.make((write) => {
142
135
  if (!sockets.register(group, {
@@ -146,7 +139,7 @@ const makeProtocol = (state, sockets, { path, protocol: version }) => Effect.gen
146
139
  const headers = (yield* Effect.orDie(attachmentOf(socket)))?.headers ?? [];
147
140
  const messages = parser.decode(Predicate.isString(data) ? data : new Uint8Array(data));
148
141
  const sent = messages.flatMap((message) => Predicate.isTagged(message, "Request") ? [message.id] : []);
149
- const kept = sent.length === 0 || tracked(socket, (pending) => [...pending, ...sent]);
142
+ const kept = sent.length === 0 || heldInAttachment(socket, (pending) => [...pending, ...sent]);
150
143
  return yield* Effect.forEach(messages, (message) => {
151
144
  if (!Predicate.isTagged(message, "Request")) return write(clientId, message);
152
145
  return kept ? write(clientId, {
@@ -166,7 +159,7 @@ const makeProtocol = (state, sockets, { path, protocol: version }) => Effect.gen
166
159
  })) return Effect.die(/* @__PURE__ */ new Error(`Two ActionDurableObject.layerWebSocket protocols of one object serve the path ${path}: a router prefix does not tell them apart: give each its own path, such as /a/rpc and /b/rpc`));
167
160
  const resumed = subscriptions.resumed(write, (socket, kept) => {
168
161
  if (pendingOf(socket).every(kept)) return true;
169
- closeQuietly(socket, 1012, lostCalls);
162
+ closeQuietly(socket, restartCloseCode, lostCalls);
170
163
  return false;
171
164
  });
172
165
  return Effect.as(resumed, {
package/dist/stub.d.mts CHANGED
@@ -12,15 +12,15 @@ export interface Stub {
12
12
  }
13
13
  /**
14
14
  * The `RpcClient.Protocol` `RpcClient.make` and `ActionRpc.client` call an object's rpcs with,
15
- * over `stub`, or over the stub a function returns as each call is sent: each call is one call
15
+ * over the stub `stubOf` returns as each call is sent: each call is one call
16
16
  * of the object's `effectActionsRpc` method, its messages and their answer passed as values,
17
17
  * each schema's plain form, which the object serves through `make`'s or `handlers`' `calls`. The stub's own failure is an `RpcClientError`, as an HTTP transport's
18
18
  * is, in every call's errors.
19
19
  *
20
20
  * **Gotchas**
21
21
  *
22
- * - A stub given is held, and many exceptions leave a stub broken, failing every later call through it with the same error: give a function, `() => env.ROOM.getByName(name)`, called as each call is sent, for a new stub each time; one that throws is a defect, not an `RpcClientError`, as it is no failure of the stub.
23
- * - An `RpcClientError`'s `RpcClientDefect` holds the runtime's error as its `cause`: `.retryable` true allows repeating the call, as Cloudflare's guide says, and `.durableObjectReset` true needs a new stub, which a function gives.
22
+ * - `stubOf` is called as each call is sent, `() => env.ROOM.getByName(name)`, for a new stub each time, since many exceptions leave a stub broken, failing every later call through it with the same error; one that throws is a defect, not an `RpcClientError`, as it is no failure of the stub.
23
+ * - An `RpcClientError`'s `RpcClientDefect` holds the runtime's error as its `cause`: `.retryable` true allows repeating the call, as Cloudflare's guide says, and `.durableObjectReset` true needs a new stub, which the next call's `stubOf` gives.
24
24
  * - Workers RPC limits a call's arguments and its answer to 32 MiB, serialized: past it, the call fails with the runtime's `limited to 32MiB` error.
25
25
  * - An object serving no `calls`, once built, or a class without the method, fails each call, with the object's or the runtime's message as the error's cause; an object whose build fails fails it with that failure first.
26
26
  * - A call's interruption does not reach the object, as a stub call cannot be cancelled: its answer is dropped.
@@ -29,7 +29,7 @@ export interface Stub {
29
29
  *
30
30
  * Guide: `docs/ActionDurableObject-stub.md` in this package.
31
31
  */
32
- export declare const layerStubClient: (stub: Stub | (() => Stub)) => Layer.Layer<RpcClient.Protocol>;
32
+ export declare const layerStubClient: (stubOf: () => Stub) => Layer.Layer<RpcClient.Protocol>;
33
33
  type Unary<Rpcs extends Rpc.Any> = Rpcs extends Rpc.Any ? [Rpc.SuccessSchema<Rpcs>] extends [RpcSchema.Stream<infer _A, infer _E>] ? never : Rpcs : never;
34
34
  /**
35
35
  * A group's rpcs, as `client.<rpc>(payload)`: Effect's native client, each call failing with
package/dist/stub.mjs CHANGED
@@ -6,6 +6,7 @@ import { RpcClient, RpcClientError, RpcSchema } from "effect/rpc";
6
6
  const clientError = (reason) => new RpcClientError.RpcClientError({ reason });
7
7
  const isAnswer = Schema.is(Schema.Array(Schema.Struct({ _tag: Schema.String })));
8
8
  const completionOf = Schema.decodeUnknownOption(Schema.Union([Schema.TaggedStruct("Exit", { requestId: RequestId }), Schema.Struct({ _tag: Schema.Literals(["Defect", "ClientProtocolError"]) })]));
9
+ const completesCall = (answer, request) => answer.some((response) => Option.exists(completionOf(response), (completion) => !Predicate.isTagged(completion, "Exit") || completion.requestId === request.id));
9
10
  const makeProtocol = (target) => RpcClient.Protocol.make((write) => Effect.succeed({
10
11
  send: (clientId, request) => Predicate.isTagged(request, "Request") ? Effect.gen(function* () {
11
12
  const stub = yield* target;
@@ -16,7 +17,7 @@ const makeProtocol = (target) => RpcClient.Protocol.make((write) => Effect.succe
16
17
  cause
17
18
  }))
18
19
  });
19
- if (!(isAnswer(answer) && answer.some((response) => Option.exists(completionOf(response), (completion) => !Predicate.isTagged(completion, "Exit") || completion.requestId === request.id)))) return yield* clientError(new RpcClientError.RpcClientDefect({
20
+ if (!(isAnswer(answer) && completesCall(answer, request))) return yield* clientError(new RpcClientError.RpcClientDefect({
20
21
  message: "The object's answer ended before the call completed: delegate effectActionsRpc to handlers' own, returning its answer",
21
22
  cause: request
22
23
  }));
@@ -28,15 +29,15 @@ const makeProtocol = (target) => RpcClient.Protocol.make((write) => Effect.succe
28
29
  }));
29
30
  /**
30
31
  * The `RpcClient.Protocol` `RpcClient.make` and `ActionRpc.client` call an object's rpcs with,
31
- * over `stub`, or over the stub a function returns as each call is sent: each call is one call
32
+ * over the stub `stubOf` returns as each call is sent: each call is one call
32
33
  * of the object's `effectActionsRpc` method, its messages and their answer passed as values,
33
34
  * each schema's plain form, which the object serves through `make`'s or `handlers`' `calls`. The stub's own failure is an `RpcClientError`, as an HTTP transport's
34
35
  * is, in every call's errors.
35
36
  *
36
37
  * **Gotchas**
37
38
  *
38
- * - A stub given is held, and many exceptions leave a stub broken, failing every later call through it with the same error: give a function, `() => env.ROOM.getByName(name)`, called as each call is sent, for a new stub each time; one that throws is a defect, not an `RpcClientError`, as it is no failure of the stub.
39
- * - An `RpcClientError`'s `RpcClientDefect` holds the runtime's error as its `cause`: `.retryable` true allows repeating the call, as Cloudflare's guide says, and `.durableObjectReset` true needs a new stub, which a function gives.
39
+ * - `stubOf` is called as each call is sent, `() => env.ROOM.getByName(name)`, for a new stub each time, since many exceptions leave a stub broken, failing every later call through it with the same error; one that throws is a defect, not an `RpcClientError`, as it is no failure of the stub.
40
+ * - An `RpcClientError`'s `RpcClientDefect` holds the runtime's error as its `cause`: `.retryable` true allows repeating the call, as Cloudflare's guide says, and `.durableObjectReset` true needs a new stub, which the next call's `stubOf` gives.
40
41
  * - Workers RPC limits a call's arguments and its answer to 32 MiB, serialized: past it, the call fails with the runtime's `limited to 32MiB` error.
41
42
  * - An object serving no `calls`, once built, or a class without the method, fails each call, with the object's or the runtime's message as the error's cause; an object whose build fails fails it with that failure first.
42
43
  * - A call's interruption does not reach the object, as a stub call cannot be cancelled: its answer is dropped.
@@ -45,9 +46,9 @@ const makeProtocol = (target) => RpcClient.Protocol.make((write) => Effect.succe
45
46
  *
46
47
  * Guide: `docs/ActionDurableObject-stub.md` in this package.
47
48
  */
48
- const layerStubClient = (stub) => Layer.effect(RpcClient.Protocol)(makeProtocol(Predicate.isFunction(stub) ? Effect.sync(stub) : Effect.succeed(stub)));
49
- const CurrentStub = Context.Reference("effect-actions-cloudflare/ActionDurableObject/CurrentStub", { defaultValue: Function.constUndefined });
50
- const currentStub = Effect.flatMap(Effect.service(CurrentStub), (stubOf) => stubOf === void 0 ? Effect.die(/* @__PURE__ */ new Error("No stub for the call")) : Effect.sync(stubOf));
49
+ const layerStubClient = (stubOf) => Layer.effect(RpcClient.Protocol)(makeProtocol(Effect.sync(stubOf)));
50
+ const StubOfCurrentCall = Context.Reference("effect-actions-cloudflare/ActionDurableObject/CurrentStub", { defaultValue: Function.constUndefined });
51
+ const currentStub = Effect.flatMap(Effect.service(StubOfCurrentCall), (stubOf) => stubOf === void 0 ? Effect.die(/* @__PURE__ */ new Error("No stub for the call")) : Effect.sync(stubOf));
51
52
  /**
52
53
  * The rpcs of `group`, as `(stubOf) => client`, for a Worker or an object calling objects of
53
54
  * one class: `room(() => env.CHAT_ROOM.getByName(name)).latestAt()`. One native client, built
@@ -70,6 +71,6 @@ const client = (group) => Effect.gen(function* () {
70
71
  const native = yield* RpcClient.make(group).pipe(Effect.provideServiceEffect(RpcClient.Protocol, makeProtocol(currentStub)), Effect.map((made) => made));
71
72
  const rpcs = group.requests.values();
72
73
  const unary = new Set([...rpcs].flatMap((rpc) => RpcSchema.isStreamSchema(rpc.successSchema) ? [] : [rpc._tag]));
73
- return (stubOf) => Object.fromEntries(Object.entries(native).flatMap(([tag, method]) => unary.has(tag) ? [[tag, (...args) => method(...args).pipe(Effect.provideService(CurrentStub, stubOf))]] : []));
74
+ return (stubOf) => Object.fromEntries(Object.entries(native).flatMap(([tag, method]) => unary.has(tag) ? [[tag, (...args) => method(...args).pipe(Effect.provideService(StubOfCurrentCall, stubOf))]] : []));
74
75
  });
75
76
  export { client, layerStubClient };
@@ -2,7 +2,6 @@ import { deleted } from "./batches.mjs";
2
2
  import { RequestId, closeQuietly, groupTag, socketIdOf } from "./frames.mjs";
3
3
  import { Effect, Function, Match, Option, Predicate, Schema } from "effect";
4
4
  // src/durable/subscriptions.ts
5
- /** Where an object keeps its sockets' event subscriptions. */
6
5
  const keptPrefix = "effect-actions/subscription/";
7
6
  const Request = Schema.TaggedStruct("Request", {
8
7
  id: RequestId,
@@ -11,33 +10,27 @@ const Request = Schema.TaggedStruct("Request", {
11
10
  });
12
11
  const requestOf = Schema.decodeUnknownOption(Request);
13
12
  const payloadOf = Schema.decodeUnknownOption(Schema.Json);
13
+ const resumeFailedCode = 1e3;
14
14
  const unresumable = "This server could not resume this socket's subscriptions: connect again";
15
15
  const suffixOf = (socket, id) => `${socket}#${Predicate.isNumber(id) ? "n" : "s"}${id}`;
16
16
  const socketOf = (suffix) => Number(suffix.slice(0, suffix.indexOf("#")));
17
- /**
18
- * The event subscriptions of the sockets one `layerWebSocket` path accepted, kept in storage:
19
- * `keeper` stores each request as `ActionRpc.layer` admits it, `sent` stores the payload that
20
- * resumes it with each chunk sent and forgets it at its exit, `closed` forgets a socket's, and
21
- * `resumed` writes each kept one whose socket of this path is still open to the server again,
22
- * from there.
23
- */
24
17
  const makeSubscriptions = (state, path) => {
25
18
  const { storage } = state;
26
19
  const group = groupTag(path);
27
20
  const requests = `${keptPrefix}request/${path}#`;
28
21
  const payloads = `${keptPrefix}payload/${path}#`;
29
22
  const keysOf = (suffix) => [`${requests}${suffix}`, `${payloads}${suffix}`];
30
- const live = /* @__PURE__ */ new Map();
23
+ const liveSubscriptionsMirror = /* @__PURE__ */ new Map();
31
24
  const listed = (prefix) => Effect.map(Effect.promise(async () => await storage.list({ prefix })), (found) => [...found].map(([key, value]) => [key.slice(prefix.length), value]));
32
25
  const closed = (socket) => Effect.suspend(() => {
33
- const owned = [...live.keys()].filter((suffix) => socketOf(suffix) === socket);
34
- for (const suffix of owned) live.delete(suffix);
26
+ const owned = [...liveSubscriptionsMirror.keys()].filter((suffix) => socketOf(suffix) === socket);
27
+ for (const suffix of owned) liveSubscriptionsMirror.delete(suffix);
35
28
  return deleted(storage, owned.flatMap(keysOf));
36
29
  });
37
30
  const keeper = { keep: ({ clientId, requestId, tag, payload, headers, resumeFrom }) => Effect.suspend(() => {
38
31
  const suffix = suffixOf(clientId, requestId);
39
- const resuming = live.has(suffix);
40
- live.set(suffix, resumeFrom);
32
+ const resuming = liveSubscriptionsMirror.has(suffix);
33
+ liveSubscriptionsMirror.set(suffix, resumeFrom);
41
34
  if (resuming) return Effect.void;
42
35
  return Effect.promise(async () => {
43
36
  await storage.put({
@@ -51,23 +44,18 @@ const makeSubscriptions = (state, path) => {
51
44
  });
52
45
  }) };
53
46
  const moved = (suffix, values) => Effect.suspend(() => {
54
- const payload = live.get(suffix)?.(values);
47
+ const payload = liveSubscriptionsMirror.get(suffix)?.(values);
55
48
  return payload === void 0 ? Effect.void : Effect.promise(async () => {
56
49
  await storage.put(`${payloads}${suffix}`, payload);
57
50
  });
58
51
  });
59
52
  const sent = (socket, response) => Match.value(response).pipe(Match.tag("Defect", () => closed(socket)), Match.tag("Chunk", ({ requestId, values }) => moved(suffixOf(socket, requestId), values)), Match.tag("Exit", ({ requestId }) => Effect.suspend(() => {
60
53
  const suffix = suffixOf(socket, requestId);
61
- return live.delete(suffix) ? Effect.promise(async () => await storage.delete(keysOf(suffix))) : Effect.void;
54
+ return liveSubscriptionsMirror.delete(suffix) ? Effect.promise(async () => await storage.delete(keysOf(suffix))) : Effect.void;
62
55
  })), Match.orElse(() => Effect.void));
63
56
  const unresumed = (socket) => Effect.andThen(Effect.logWarning(`layerWebSocket at ${path} could not resume a socket's kept event subscriptions, and closes it`), Effect.sync(() => {
64
- closeQuietly(socket, 1e3, unresumable);
57
+ closeQuietly(socket, resumeFailedCode, unresumable);
65
58
  }));
66
- /**
67
- * Writes each kept subscription of a socket of this path still open to the server, from where
68
- * it reached, and forgets the rest: `held` answers whether a socket stays open, given whether
69
- * one of its requests is a subscription kept for it, and closes one that does not.
70
- */
71
59
  const resumed = (write, held) => Effect.gen(function* () {
72
60
  const kept = yield* listed(requests);
73
61
  const resumeFrom = new Map(yield* listed(payloads));
@@ -88,7 +76,7 @@ const makeSubscriptions = (state, path) => {
88
76
  const unread = new Set(entries.flatMap(({ socket, request }) => open.has(socket) && Option.isNone(request) ? [socket] : []));
89
77
  const gone = [...resumeFrom.keys()].flatMap((suffix) => keptIds.has(suffix) ? [] : [`${payloads}${suffix}`]);
90
78
  for (const { suffix, socket, request } of entries) if (open.has(socket) && !unread.has(socket) && Option.isSome(request)) {
91
- live.set(suffix, Function.constUndefined);
79
+ liveSubscriptionsMirror.set(suffix, Function.constUndefined);
92
80
  yield* write(socket, request.value);
93
81
  } else gone.push(...keysOf(suffix));
94
82
  yield* deleted(storage, gone);
package/dist/tasks.mjs CHANGED
@@ -1,6 +1,5 @@
1
1
  import { Clock, Context, Effect } from "effect";
2
2
  // src/durable/tasks.ts
3
- /** The alarm owner a task's time is kept under, one per task name. */
4
3
  const taskOwner = (name) => `task:${name}`;
5
4
  /**
6
5
  * Runs the object's tasks, `make`'s or `handlers`' `tasks`, by name, after a change, on the
@@ -15,16 +14,12 @@ const taskOwner = (name) => `task:${name}`;
15
14
  * - A run that fails is logged and retried as the alarm retries an owner: 2 seconds later, doubling, six times, then hourly.
16
15
  */
17
16
  var Tasks = class extends Context.Service()("effect-actions-cloudflare/ActionDurableObject/Tasks") {};
18
- /**
19
- * The instance's `Tasks` over its `schedule`, and the alarm owners that run each of `tasks`, by
20
- * the name `runAfter` takes.
21
- */
22
17
  const makeTasks = (schedule, tasks) => ({
23
18
  tasks: Tasks.of({ runAfter: (name, change) => Effect.gen(function* () {
24
19
  if (!Object.hasOwn(tasks, name)) return yield* Effect.die(/* @__PURE__ */ new Error(`ActionDurableObject.Tasks: this object has no task ${name} (give make or handlers a tasks entry named ${name})`));
25
- const marked = schedule.set(taskOwner(name), yield* Clock.currentTimeMillis);
26
- yield* marked;
27
- return yield* Effect.ensuring(change, marked);
20
+ const markDueNow = schedule.set(taskOwner(name), yield* Clock.currentTimeMillis);
21
+ yield* markDueNow;
22
+ return yield* Effect.ensuring(change, markDueNow);
28
23
  }) }),
29
24
  owners: Object.fromEntries(Object.entries(tasks).map(([name, task]) => [taskOwner(name), () => task]))
30
25
  });