@effect/platform-cloudflare 0.0.0 → 4.1.3

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 (237) hide show
  1. package/AGENTS.md +434 -0
  2. package/CLAUDE.md +434 -0
  3. package/LICENSE +21 -0
  4. package/README.md +506 -0
  5. package/ai-docs/README.md +44 -0
  6. package/ai-docs/package.json +36 -0
  7. package/ai-docs/src/01_effect/01_basics/01_effect-gen.ts +30 -0
  8. package/ai-docs/src/01_effect/01_basics/02_effect-fn.ts +52 -0
  9. package/ai-docs/src/01_effect/01_basics/10_creating-effects.ts +74 -0
  10. package/ai-docs/src/01_effect/01_basics/index.md +7 -0
  11. package/ai-docs/src/01_effect/02_schema/10_schema-basics.ts +43 -0
  12. package/ai-docs/src/01_effect/02_schema/index.md +7 -0
  13. package/ai-docs/src/01_effect/03_services/01_service.ts +45 -0
  14. package/ai-docs/src/01_effect/03_services/10_reference.ts +10 -0
  15. package/ai-docs/src/01_effect/03_services/20_layer-composition.ts +70 -0
  16. package/ai-docs/src/01_effect/03_services/20_layer-unwrap.ts +66 -0
  17. package/ai-docs/src/01_effect/03_services/index.md +5 -0
  18. package/ai-docs/src/01_effect/04_errors/01_error-handling.ts +30 -0
  19. package/ai-docs/src/01_effect/04_errors/10_catch-tags.ts +24 -0
  20. package/ai-docs/src/01_effect/04_errors/20_reason-errors.ts +64 -0
  21. package/ai-docs/src/01_effect/04_errors/index.md +1 -0
  22. package/ai-docs/src/01_effect/05_resources/10_acquire-release.ts +105 -0
  23. package/ai-docs/src/01_effect/05_resources/20_layer-side-effects.ts +31 -0
  24. package/ai-docs/src/01_effect/05_resources/30_layer-map.ts +86 -0
  25. package/ai-docs/src/01_effect/05_resources/index.md +3 -0
  26. package/ai-docs/src/01_effect/06_running/10_run-main.ts +30 -0
  27. package/ai-docs/src/01_effect/06_running/20_layer-launch.ts +27 -0
  28. package/ai-docs/src/01_effect/06_running/index.md +1 -0
  29. package/ai-docs/src/01_effect/07_pubsub/10_pubsub.ts +56 -0
  30. package/ai-docs/src/01_effect/07_pubsub/index.md +3 -0
  31. package/ai-docs/src/03_stream/10_creating-streams.ts +103 -0
  32. package/ai-docs/src/03_stream/20_consuming-streams.ts +137 -0
  33. package/ai-docs/src/03_stream/30_encoding.ts +163 -0
  34. package/ai-docs/src/03_stream/index.md +4 -0
  35. package/ai-docs/src/04_integration/10_managed-runtime.ts +129 -0
  36. package/ai-docs/src/04_integration/index.md +5 -0
  37. package/ai-docs/src/05_batching/10_request-resolver.ts +89 -0
  38. package/ai-docs/src/05_batching/index.md +3 -0
  39. package/ai-docs/src/06_schedule/10_schedules.ts +111 -0
  40. package/ai-docs/src/06_schedule/index.md +3 -0
  41. package/ai-docs/src/07_datetime/10_creating-and-formatting.ts +30 -0
  42. package/ai-docs/src/07_datetime/20_time-zones.ts +44 -0
  43. package/ai-docs/src/07_datetime/index.md +5 -0
  44. package/ai-docs/src/08_observability/10_logging.ts +66 -0
  45. package/ai-docs/src/08_observability/20_otlp-tracing.ts +95 -0
  46. package/ai-docs/src/08_observability/index.md +7 -0
  47. package/ai-docs/src/09_testing/10_effect-tests.ts +55 -0
  48. package/ai-docs/src/09_testing/20_layer-tests.ts +138 -0
  49. package/ai-docs/src/09_testing/index.md +1 -0
  50. package/ai-docs/src/10_predicate/01_basics.ts +14 -0
  51. package/ai-docs/src/10_predicate/index.md +9 -0
  52. package/ai-docs/src/40_sql/10_basics.ts +175 -0
  53. package/ai-docs/src/40_sql/index.md +6 -0
  54. package/ai-docs/src/50_http-client/10_basics.ts +102 -0
  55. package/ai-docs/src/50_http-client/index.md +3 -0
  56. package/ai-docs/src/51_http-server/10_basics.ts +118 -0
  57. package/ai-docs/src/51_http-server/20_testing.ts +100 -0
  58. package/ai-docs/src/51_http-server/fixtures/api/Api.ts +14 -0
  59. package/ai-docs/src/51_http-server/fixtures/api/Authorization.ts +36 -0
  60. package/ai-docs/src/51_http-server/fixtures/api/System.ts +10 -0
  61. package/ai-docs/src/51_http-server/fixtures/api/Users.ts +107 -0
  62. package/ai-docs/src/51_http-server/fixtures/domain/User.ts +21 -0
  63. package/ai-docs/src/51_http-server/fixtures/domain/UserErrors.ts +22 -0
  64. package/ai-docs/src/51_http-server/fixtures/server/Authorization.ts +37 -0
  65. package/ai-docs/src/51_http-server/fixtures/server/Users/http.ts +80 -0
  66. package/ai-docs/src/51_http-server/fixtures/server/Users.ts +183 -0
  67. package/ai-docs/src/51_http-server/index.md +3 -0
  68. package/ai-docs/src/52_graphql/10_basics.ts +117 -0
  69. package/ai-docs/src/52_graphql/20_testing.ts +45 -0
  70. package/ai-docs/src/52_graphql/30_batching.ts +118 -0
  71. package/ai-docs/src/52_graphql/40_aliased-batching.ts +124 -0
  72. package/ai-docs/src/52_graphql/50_errors-and-retries.ts +74 -0
  73. package/ai-docs/src/52_graphql/index.md +6 -0
  74. package/ai-docs/src/60_child-process/10_working-with-child-processes.ts +117 -0
  75. package/ai-docs/src/60_child-process/index.md +3 -0
  76. package/ai-docs/src/70_cli/10_basics.ts +162 -0
  77. package/ai-docs/src/70_cli/index.md +5 -0
  78. package/ai-docs/src/71_ai/10_language-model.ts +156 -0
  79. package/ai-docs/src/71_ai/20_tools.ts +226 -0
  80. package/ai-docs/src/71_ai/30_chat.ts +158 -0
  81. package/ai-docs/src/71_ai/fixtures/domain/LaunchPlan.ts +9 -0
  82. package/ai-docs/src/71_ai/index.md +5 -0
  83. package/ai-docs/src/80_cluster/10_entities.ts +97 -0
  84. package/ai-docs/src/80_cluster/index.md +4 -0
  85. package/ai-docs/src/index.md +10 -0
  86. package/ai-docs/tsconfig.json +28 -0
  87. package/dist/AlchemyCloudflareCluster.d.ts +134 -0
  88. package/dist/AlchemyCloudflareCluster.d.ts.map +1 -0
  89. package/dist/AlchemyCloudflareCluster.js +111 -0
  90. package/dist/AlchemyCloudflareCluster.js.map +1 -0
  91. package/dist/CloudflareCluster.d.ts +196 -0
  92. package/dist/CloudflareCluster.d.ts.map +1 -0
  93. package/dist/CloudflareCluster.js +533 -0
  94. package/dist/CloudflareCluster.js.map +1 -0
  95. package/dist/CloudflareDurableObjectPrograms.d.ts +279 -0
  96. package/dist/CloudflareDurableObjectPrograms.d.ts.map +1 -0
  97. package/dist/CloudflareDurableObjectPrograms.js +295 -0
  98. package/dist/CloudflareDurableObjectPrograms.js.map +1 -0
  99. package/dist/CloudflareDurableObjects.d.ts +149 -0
  100. package/dist/CloudflareDurableObjects.d.ts.map +1 -0
  101. package/dist/CloudflareDurableObjects.js +337 -0
  102. package/dist/CloudflareDurableObjects.js.map +1 -0
  103. package/dist/CloudflarePersistedQueue.d.ts +36 -0
  104. package/dist/CloudflarePersistedQueue.d.ts.map +1 -0
  105. package/dist/CloudflarePersistedQueue.js +122 -0
  106. package/dist/CloudflarePersistedQueue.js.map +1 -0
  107. package/dist/CloudflareWorkflowEngine.d.ts +72 -0
  108. package/dist/CloudflareWorkflowEngine.d.ts.map +1 -0
  109. package/dist/CloudflareWorkflowEngine.js +187 -0
  110. package/dist/CloudflareWorkflowEngine.js.map +1 -0
  111. package/dist/index.d.ts +29 -0
  112. package/dist/index.d.ts.map +1 -0
  113. package/dist/index.js +33 -0
  114. package/dist/index.js.map +1 -0
  115. package/dist/internal/alchemyCluster.d.ts +2 -0
  116. package/dist/internal/alchemyCluster.d.ts.map +1 -0
  117. package/dist/internal/alchemyCluster.js +75 -0
  118. package/dist/internal/alchemyCluster.js.map +1 -0
  119. package/dist/internal/boundedMap.d.ts +3 -0
  120. package/dist/internal/boundedMap.d.ts.map +1 -0
  121. package/dist/internal/boundedMap.js +15 -0
  122. package/dist/internal/boundedMap.js.map +1 -0
  123. package/dist/internal/cluster.d.ts +57 -0
  124. package/dist/internal/cluster.d.ts.map +1 -0
  125. package/dist/internal/cluster.js +2 -0
  126. package/dist/internal/cluster.js.map +1 -0
  127. package/dist/internal/clusterName.d.ts +2 -0
  128. package/dist/internal/clusterName.d.ts.map +1 -0
  129. package/dist/internal/clusterName.js +16 -0
  130. package/dist/internal/clusterName.js.map +1 -0
  131. package/dist/internal/entityKeepAlive.d.ts +2 -0
  132. package/dist/internal/entityKeepAlive.d.ts.map +1 -0
  133. package/dist/internal/entityKeepAlive.js +73 -0
  134. package/dist/internal/entityKeepAlive.js.map +1 -0
  135. package/dist/internal/entityMailbox.d.ts +2 -0
  136. package/dist/internal/entityMailbox.d.ts.map +1 -0
  137. package/dist/internal/entityMailbox.js +160 -0
  138. package/dist/internal/entityMailbox.js.map +1 -0
  139. package/dist/internal/entityRegistry.d.ts +16 -0
  140. package/dist/internal/entityRegistry.d.ts.map +1 -0
  141. package/dist/internal/entityRegistry.js +22 -0
  142. package/dist/internal/entityRegistry.js.map +1 -0
  143. package/dist/internal/entityReply.d.ts +2 -0
  144. package/dist/internal/entityReply.d.ts.map +1 -0
  145. package/dist/internal/entityReply.js +35 -0
  146. package/dist/internal/entityReply.js.map +1 -0
  147. package/dist/internal/entityRuntime.d.ts +2 -0
  148. package/dist/internal/entityRuntime.d.ts.map +1 -0
  149. package/dist/internal/entityRuntime.js +823 -0
  150. package/dist/internal/entityRuntime.js.map +1 -0
  151. package/dist/internal/entitySql.d.ts +2 -0
  152. package/dist/internal/entitySql.d.ts.map +1 -0
  153. package/dist/internal/entitySql.js +29 -0
  154. package/dist/internal/entitySql.js.map +1 -0
  155. package/dist/internal/entityStorage.d.ts +2 -0
  156. package/dist/internal/entityStorage.d.ts.map +1 -0
  157. package/dist/internal/entityStorage.js +98 -0
  158. package/dist/internal/entityStorage.js.map +1 -0
  159. package/dist/internal/entityWire.d.ts +2 -0
  160. package/dist/internal/entityWire.d.ts.map +1 -0
  161. package/dist/internal/entityWire.js +146 -0
  162. package/dist/internal/entityWire.js.map +1 -0
  163. package/dist/internal/queueRuntime.d.ts +2 -0
  164. package/dist/internal/queueRuntime.d.ts.map +1 -0
  165. package/dist/internal/queueRuntime.js +88 -0
  166. package/dist/internal/queueRuntime.js.map +1 -0
  167. package/dist/internal/queueStorage.d.ts +2 -0
  168. package/dist/internal/queueStorage.d.ts.map +1 -0
  169. package/dist/internal/queueStorage.js +129 -0
  170. package/dist/internal/queueStorage.js.map +1 -0
  171. package/dist/internal/registry.d.ts +10 -0
  172. package/dist/internal/registry.d.ts.map +1 -0
  173. package/dist/internal/registry.js +24 -0
  174. package/dist/internal/registry.js.map +1 -0
  175. package/dist/internal/singletonRegistry.d.ts +2 -0
  176. package/dist/internal/singletonRegistry.d.ts.map +1 -0
  177. package/dist/internal/singletonRegistry.js +9 -0
  178. package/dist/internal/singletonRegistry.js.map +1 -0
  179. package/dist/internal/singletonRuntime.d.ts +2 -0
  180. package/dist/internal/singletonRuntime.d.ts.map +1 -0
  181. package/dist/internal/singletonRuntime.js +42 -0
  182. package/dist/internal/singletonRuntime.js.map +1 -0
  183. package/dist/internal/singletonStorage.d.ts +2 -0
  184. package/dist/internal/singletonStorage.d.ts.map +1 -0
  185. package/dist/internal/singletonStorage.js +34 -0
  186. package/dist/internal/singletonStorage.js.map +1 -0
  187. package/dist/internal/transport.d.ts +2 -0
  188. package/dist/internal/transport.d.ts.map +1 -0
  189. package/dist/internal/transport.js +49 -0
  190. package/dist/internal/transport.js.map +1 -0
  191. package/dist/internal/workflowRegistry.d.ts +2 -0
  192. package/dist/internal/workflowRegistry.d.ts.map +1 -0
  193. package/dist/internal/workflowRegistry.js +29 -0
  194. package/dist/internal/workflowRegistry.js.map +1 -0
  195. package/dist/internal/workflowRuntime.d.ts +2 -0
  196. package/dist/internal/workflowRuntime.d.ts.map +1 -0
  197. package/dist/internal/workflowRuntime.js +322 -0
  198. package/dist/internal/workflowRuntime.js.map +1 -0
  199. package/dist/internal/workflowStorage.d.ts +2 -0
  200. package/dist/internal/workflowStorage.d.ts.map +1 -0
  201. package/dist/internal/workflowStorage.js +113 -0
  202. package/dist/internal/workflowStorage.js.map +1 -0
  203. package/dist/internal/workflowWire.d.ts +2 -0
  204. package/dist/internal/workflowWire.d.ts.map +1 -0
  205. package/dist/internal/workflowWire.js +52 -0
  206. package/dist/internal/workflowWire.js.map +1 -0
  207. package/package.json +77 -1
  208. package/src/AlchemyCloudflareCluster.ts +200 -0
  209. package/src/CloudflareCluster.ts +704 -0
  210. package/src/CloudflareDurableObjectPrograms.ts +543 -0
  211. package/src/CloudflareDurableObjects.ts +430 -0
  212. package/src/CloudflarePersistedQueue.ts +189 -0
  213. package/src/CloudflareWorkflowEngine.ts +220 -0
  214. package/src/index.ts +38 -0
  215. package/src/internal/alchemyCluster.ts +120 -0
  216. package/src/internal/boundedMap.ts +15 -0
  217. package/src/internal/cluster.ts +57 -0
  218. package/src/internal/clusterName.ts +19 -0
  219. package/src/internal/entityKeepAlive.ts +121 -0
  220. package/src/internal/entityMailbox.ts +324 -0
  221. package/src/internal/entityRegistry.ts +46 -0
  222. package/src/internal/entityReply.ts +54 -0
  223. package/src/internal/entityRuntime.ts +1318 -0
  224. package/src/internal/entitySql.ts +60 -0
  225. package/src/internal/entityStorage.ts +168 -0
  226. package/src/internal/entityWire.ts +204 -0
  227. package/src/internal/queueRuntime.ts +174 -0
  228. package/src/internal/queueStorage.ts +255 -0
  229. package/src/internal/registry.ts +31 -0
  230. package/src/internal/singletonRegistry.ts +21 -0
  231. package/src/internal/singletonRuntime.ts +88 -0
  232. package/src/internal/singletonStorage.ts +64 -0
  233. package/src/internal/transport.ts +67 -0
  234. package/src/internal/workflowRegistry.ts +78 -0
  235. package/src/internal/workflowRuntime.ts +443 -0
  236. package/src/internal/workflowStorage.ts +234 -0
  237. package/src/internal/workflowWire.ts +123 -0
@@ -0,0 +1,704 @@
1
+ /**
2
+ * Runs Effect Cluster entities on Cloudflare Durable Objects.
3
+ *
4
+ * On this path every entity instance is one Durable Object: the Worker encodes
5
+ * an `(entityType, entityId)` address into a Durable Object name, resolves the
6
+ * object stub with `getByName`, and the object's SQLite storage is the system
7
+ * of record. There is no shard routing, no runner fleet, and no external
8
+ * message storage; `layer` provides the cluster `Sharding` service on top of
9
+ * the Durable Object namespace bindings instead of `Sharding.layer`.
10
+ *
11
+ * @stability experimental
12
+ * @since 4.1.3
13
+ */
14
+ import * as Cause from "effect/Cause"
15
+ import { Clock } from "effect/Clock"
16
+ import { MailboxFull, PersistenceError } from "effect/cluster/ClusterError"
17
+ import { isUninterruptibleForClient, Persisted, Uninterruptible } from "effect/cluster/ClusterSchema"
18
+ import * as DeliverAt from "effect/cluster/DeliverAt"
19
+ import type * as Entity from "effect/cluster/Entity"
20
+ import * as EntityAddress from "effect/cluster/EntityAddress"
21
+ import * as EntityId from "effect/cluster/EntityId"
22
+ import * as Envelope from "effect/cluster/Envelope"
23
+ import * as ShardId from "effect/cluster/ShardId"
24
+ import { Sharding } from "effect/cluster/Sharding"
25
+ import * as Context from "effect/Context"
26
+ import * as Deferred from "effect/Deferred"
27
+ import * as Duration from "effect/Duration"
28
+ import * as Effect from "effect/Effect"
29
+ import * as Exit from "effect/Exit"
30
+ import * as Layer from "effect/Layer"
31
+ import * as Option from "effect/Option"
32
+ import type { PersistedQueueFactory } from "effect/persistence/PersistedQueue"
33
+ import type * as Rpc from "effect/rpc/Rpc"
34
+ import * as RpcClient from "effect/rpc/RpcClient"
35
+ import { type FromClient, RequestId } from "effect/rpc/RpcMessage"
36
+ import * as RpcSchema from "effect/rpc/RpcSchema"
37
+ import * as Schema from "effect/Schema"
38
+ import * as Scope from "effect/Scope"
39
+ import type * as SqlClient from "effect/sql/SqlClient"
40
+ import * as Stream from "effect/Stream"
41
+ import type { WorkflowEngine } from "effect/workflow/WorkflowEngine"
42
+ import * as CloudflarePersistedQueue from "./CloudflarePersistedQueue.ts"
43
+ import * as CloudflareWorkflowEngine from "./CloudflareWorkflowEngine.ts"
44
+ import { setWithEviction } from "./internal/boundedMap.ts"
45
+ import * as Internal from "./internal/clusterName.ts"
46
+ import {
47
+ defaultKeepAliveHeartbeatMillis,
48
+ registerEntity as registerEntityHandler,
49
+ unregisterEntity
50
+ } from "./internal/entityRegistry.ts"
51
+ import { CurrentEntityName, CurrentReplyRegistry } from "./internal/entityReply.ts"
52
+ import { decodeInvokeResult, decodeReplyFor, encodeRequest } from "./internal/entityWire.ts"
53
+ import { registerSingleton as registerSingletonHandler, unregisterSingleton } from "./internal/singletonRegistry.ts"
54
+ import { boundedRetry, callDurableObject } from "./internal/transport.ts"
55
+
56
+ /**
57
+ * A Durable Object name decoded back into its entity address parts.
58
+ *
59
+ * @stability experimental
60
+ * @category models
61
+ * @since 4.1.3
62
+ */
63
+ export interface ClusterName {
64
+ readonly type: string
65
+ readonly id: string
66
+ }
67
+
68
+ /**
69
+ * Encodes an entity address into the Durable Object name used with
70
+ * `getByName`.
71
+ *
72
+ * **Details**
73
+ *
74
+ * The name is the entity type length-prefixed as `` `${type.length}:${type}${id}` ``,
75
+ * which keeps `(type, id)` pairs collision-free without restricting the
76
+ * characters an entity id may contain. Workflow, queue, and singleton names use
77
+ * the same scheme on their own namespaces.
78
+ *
79
+ * @stability experimental
80
+ * @category encoding
81
+ * @since 4.1.3
82
+ */
83
+ export const encodeName: (type: string, id: string) => string = Internal.encodeName
84
+
85
+ /**
86
+ * Decodes a Durable Object name produced by {@link encodeName} back into its
87
+ * entity address parts.
88
+ *
89
+ * **Details**
90
+ *
91
+ * Returns `undefined` for names that were not produced by {@link encodeName},
92
+ * including non-canonical length prefixes. A Durable Object uses this to
93
+ * recover its own address from `ctx.id.name`.
94
+ *
95
+ * @stability experimental
96
+ * @category decoding
97
+ * @since 4.1.3
98
+ */
99
+ export const decodeName: (name: string) => ClusterName | undefined = Internal.decodeName
100
+
101
+ /**
102
+ * Returns the singleton name of the seed singleton that `ClusterCron.make`
103
+ * registers for the cron job with the given name.
104
+ *
105
+ * **When to use**
106
+ *
107
+ * Use to wake a `ClusterCron` from a Worker Cron Trigger, by passing the
108
+ * result to `cluster.wake` with Alchemy or by prefixing it with `Singleton/`
109
+ * for `getByName` on the singleton namespace with Wrangler.
110
+ *
111
+ * **Details**
112
+ *
113
+ * The result is `"ClusterCron/<name>"`, the singleton name without the
114
+ * `Singleton/` Durable Object name prefix.
115
+ *
116
+ * **Gotchas**
117
+ *
118
+ * A singleton runs only when its Durable Object is woken, so a `ClusterCron`
119
+ * never schedules its first run until a Cron Trigger wakes this singleton.
120
+ * Each later run is scheduled through a destination alarm.
121
+ *
122
+ * @stability experimental
123
+ * @category encoding
124
+ * @since 4.1.3
125
+ */
126
+ export const clusterCronSingletonName = (name: string): string => `ClusterCron/${name}`
127
+
128
+ /**
129
+ * The Durable Object namespace bindings and entity definitions the cluster
130
+ * layer is built from.
131
+ *
132
+ * **Details**
133
+ *
134
+ * `entities` is the complete set of entity definitions the Worker serves.
135
+ * Handlers are attached per entity type with `Entity.toLayer`; a client or
136
+ * handler registration for an entity type outside this set fails at the
137
+ * Worker, before any Durable Object is contacted.
138
+ *
139
+ * `keepAliveHeartbeat` (default 30 seconds) is the interval of the alarm an
140
+ * entity Durable Object keeps armed while it holds `Entity.keepAlive` or runs
141
+ * a persisted request. Cloudflare never re-creates an evicted object on its
142
+ * own, so this alarm is what rebuilds a kept-alive entity and replays an
143
+ * interrupted request after a restart or deploy. It bounds recovery latency;
144
+ * it is not a liveness check.
145
+ *
146
+ * @stability experimental
147
+ * @category layers
148
+ * @since 4.1.3
149
+ */
150
+ export interface LayerOptions {
151
+ readonly entities: ReadonlyArray<Entity.Entity<any, any>>
152
+ readonly entityNamespace: DurableObjectNamespace
153
+ readonly workflowNamespace: DurableObjectNamespace
154
+ readonly queueNamespace: DurableObjectNamespace
155
+ readonly singletonNamespace: DurableObjectNamespace
156
+ readonly keepAliveHeartbeat?: Duration.Input | undefined
157
+ }
158
+
159
+ const notImplemented = (method: string) =>
160
+ Effect.die(
161
+ new Error(`CloudflareCluster: ${method} is not implemented yet on the Cloudflare Durable Object path`)
162
+ )
163
+
164
+ const notSupported = (method: string) =>
165
+ Effect.die(new Error(`CloudflareCluster: ${method} is not supported on the Cloudflare Durable Object path`))
166
+
167
+ interface EntityStub {
168
+ readonly invoke: (envelope: string, discard: boolean, delivery?: {
169
+ readonly deliverAt?: number | undefined
170
+ readonly primaryKey?: string | null | undefined
171
+ readonly replyTo?: string | undefined
172
+ }) => Promise<unknown>
173
+ readonly acknowledge: (requestId: string, replyId: string) => Promise<ReadonlyArray<string>>
174
+ readonly interrupt: (storageRequestId: string, clientRequestId?: string) => Promise<void>
175
+ readonly reset: (requestId: string) => Promise<void>
176
+ }
177
+
178
+ interface ClientTargetValue {
179
+ readonly address: EntityAddress.EntityAddress
180
+ // A stub that threw must not be reused, so every call resolves a fresh one.
181
+ readonly resolve: () => EntityStub
182
+ }
183
+
184
+ class ClientTarget extends Context.Service<ClientTarget, ClientTargetValue>()(
185
+ "@effect/platform-cloudflare/CloudflareCluster/ClientTarget"
186
+ ) {}
187
+
188
+ const uuidV7 = (timestamp: number): string => {
189
+ const bytes = crypto.getRandomValues(new Uint8Array(16))
190
+ bytes[0] = Math.floor(timestamp / 2 ** 40)
191
+ bytes[1] = Math.floor(timestamp / 2 ** 32) & 0xff
192
+ bytes[2] = Math.floor(timestamp / 2 ** 24) & 0xff
193
+ bytes[3] = Math.floor(timestamp / 2 ** 16) & 0xff
194
+ bytes[4] = Math.floor(timestamp / 2 ** 8) & 0xff
195
+ bytes[5] = timestamp & 0xff
196
+ bytes[6] = (bytes[6] & 0x0f) | 0x70
197
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
198
+ const hex = (byte: number) => byte.toString(16).padStart(2, "0")
199
+ return [bytes.subarray(0, 4), bytes.subarray(4, 6), bytes.subarray(6, 8), bytes.subarray(8, 10), bytes.subarray(10)]
200
+ .map((part) => Array.from(part, hex).join(""))
201
+ .join("-")
202
+ }
203
+
204
+ const requestTargetCapacity = 4096
205
+
206
+ // ClusterCron owns this entity inside its layer, so callers cannot include it
207
+ // in LayerOptions.entities. Its reserved shape is the only implicit entity.
208
+ const isClusterCronEntity = (entity: Entity.Entity<any, any>): boolean => {
209
+ if (!entity.type.startsWith("ClusterCron/") || entity.protocol.requests.size !== 2) return false
210
+ const run = entity.protocol.requests.get("run")
211
+ const start = entity.protocol.requests.get("start")
212
+ return run !== undefined && start !== undefined &&
213
+ Context.get(run.annotations, Persisted) &&
214
+ Context.get(run.annotations, Uninterruptible) === true &&
215
+ Context.get(start.annotations, Persisted)
216
+ }
217
+
218
+ const make = Effect.fnUntraced(function*(options: LayerOptions) {
219
+ const layerScope = yield* Effect.scope
220
+ const layerContext = yield* Effect.context<never>()
221
+ const entities = new Map<string, Entity.Entity<any, any>>()
222
+ for (const entity of options.entities) {
223
+ entities.set(entity.type, entity)
224
+ }
225
+ const clock = yield* Clock
226
+ const keepAliveHeartbeat = Duration.toMillis(options.keepAliveHeartbeat ?? defaultKeepAliveHeartbeatMillis)
227
+ const requestTargets = new Map<string, { readonly resolve: () => EntityStub; storageRequestId: string }>()
228
+
229
+ const unknownEntity = (entity: Entity.Entity<any, any>) =>
230
+ Effect.die(
231
+ new Error(
232
+ `CloudflareCluster: entity type "${entity.type}" is not part of the entities bound at Worker init`
233
+ )
234
+ )
235
+
236
+ const buildClient = Effect.fnUntraced(function*(entity: Entity.Entity<any, any>) {
237
+ type ClientEntry = {
238
+ readonly rpc: Rpc.AnyWithProps
239
+ readonly context: Context.Context<never>
240
+ readonly clientRequestId: string
241
+ storageRequestId: string
242
+ lastChunkId?: string
243
+ }
244
+ const entries = new Map<string, ClientEntry>()
245
+
246
+ // `handleFromClient` is hoisted and only invoked once requests are made,
247
+ // after `client` is constructed.
248
+ const client = yield* RpcClient.makeNoSerialization<any, MailboxFull | PersistenceError, true>(entity.protocol, {
249
+ spanPrefix: `${entity.type}.client`,
250
+ supportsAck: true,
251
+ generateRequestId: () => RequestId(uuidV7(clock.currentTimeMillisUnsafe())),
252
+ flatten: true,
253
+ onFromClient: (options) => handleFromClient(options)
254
+ })
255
+
256
+ const deliverReplies = (entry: ClientEntry, replyTexts: ReadonlyArray<string>): Effect.Effect<void> =>
257
+ Effect.forEach(
258
+ replyTexts,
259
+ (replyText) =>
260
+ Effect.flatMap(decodeReplyFor(entry.rpc, entry.context, replyText), (reply) => {
261
+ if (reply._tag === "Chunk") {
262
+ entry.lastChunkId = String(reply.id)
263
+ return client.write({
264
+ _tag: "Chunk",
265
+ clientId: 0,
266
+ requestId: RequestId(entry.clientRequestId),
267
+ values: reply.values
268
+ })
269
+ }
270
+ entries.delete(entry.clientRequestId)
271
+ return client.write({
272
+ _tag: "Exit",
273
+ clientId: 0,
274
+ requestId: RequestId(entry.clientRequestId),
275
+ exit: reply.exit
276
+ })
277
+ }),
278
+ { discard: true }
279
+ )
280
+
281
+ function handleFromClient({ context, message }: {
282
+ readonly message: FromClient<any>
283
+ readonly context: Context.Context<never>
284
+ }): Effect.Effect<void, MailboxFull | PersistenceError> {
285
+ const target = Context.getUnsafe(context, ClientTarget)
286
+ switch (message._tag) {
287
+ case "Request": {
288
+ const discard = message.discard === true
289
+ const rpc = entity.protocol.requests.get(message.tag)! as Rpc.AnyWithProps
290
+ const clientRequestId = String(message.id)
291
+ const encode = Schema.encodeUnknownEffect(Schema.toCodecJson(rpc.payloadSchema))(message.payload).pipe(
292
+ Effect.provideContext(context as any),
293
+ Effect.orDie
294
+ ) as unknown as Effect.Effect<unknown>
295
+ return Effect.flatMap(encode, (payload) => {
296
+ const envelope = encodeRequest({
297
+ requestId: clientRequestId,
298
+ address: target.address,
299
+ tag: message.tag,
300
+ payload,
301
+ headers: message.headers,
302
+ ...(message.traceId === undefined ? undefined : {
303
+ traceId: message.traceId,
304
+ spanId: message.spanId,
305
+ sampled: message.sampled
306
+ })
307
+ })
308
+ const entry: ClientEntry = {
309
+ rpc,
310
+ context,
311
+ clientRequestId,
312
+ storageRequestId: clientRequestId
313
+ }
314
+ if (!discard) entries.set(clientRequestId, entry)
315
+ const deliverAt = DeliverAt.toMillis(message.payload)
316
+ const delayed = deliverAt !== null && deliverAt > clock.currentTimeMillisUnsafe()
317
+ const persisted = Context.get(rpc.annotations, Persisted)
318
+ const primaryKey = Envelope.primaryKey({
319
+ ...message,
320
+ requestId: message.id,
321
+ address: target.address,
322
+ headers: message.headers
323
+ } as any)
324
+ const replyTo = discard ? undefined : Option.getOrUndefined(Context.getOption(context, CurrentEntityName))
325
+ const replyRegistry = discard
326
+ ? undefined
327
+ : Option.getOrUndefined(Context.getOption(context, CurrentReplyRegistry))
328
+ if (delayed && (!persisted || (!discard && primaryKey === null))) {
329
+ entries.delete(clientRequestId)
330
+ return Effect.fail(
331
+ new PersistenceError({
332
+ cause: new Error(
333
+ !persisted
334
+ ? "Future DeliverAt requests must be persisted"
335
+ : "Future DeliverAt asks must define a PrimaryKey"
336
+ )
337
+ })
338
+ )
339
+ }
340
+ if (delayed && RpcSchema.isStreamSchema(rpc.successSchema)) {
341
+ entries.delete(clientRequestId)
342
+ return Effect.fail(
343
+ new PersistenceError({
344
+ cause: new Error("Stream asks with a future DeliverAt are not supported")
345
+ })
346
+ )
347
+ }
348
+ const delivery = delayed
349
+ ? { deliverAt: deliverAt!, primaryKey, ...(replyTo === undefined ? undefined : { replyTo }) }
350
+ : replyTo === undefined
351
+ ? undefined
352
+ : { replyTo }
353
+ // A pinned caller receives the scheduled reply pushed over its own
354
+ // Durable Object's `deliverReply` RPC, which completes this waiter.
355
+ // The destination stores this request id with the reply target and
356
+ // pushes under it, so registering before the send closes the race.
357
+ const pushedReply = delivery?.replyTo !== undefined && replyRegistry !== undefined
358
+ ? Deferred.makeUnsafe<string>()
359
+ : undefined
360
+ if (pushedReply !== undefined) replyRegistry!.register(clientRequestId, pushedReply)
361
+ // The mailbox dedupes on the request id, so a persisted request is
362
+ // safe to retry. A non-persisted one may already be running.
363
+ const send = callDurableObject({
364
+ resolve: target.resolve,
365
+ call: (stub) => stub.invoke(envelope, discard, delivery),
366
+ retry: persisted ? boundedRetry : undefined
367
+ }).pipe(
368
+ Effect.flatMap(decodeInvokeResult),
369
+ Effect.flatMap((result): Effect.Effect<void, MailboxFull | PersistenceError> => {
370
+ switch (result._tag) {
371
+ case "MailboxFull":
372
+ return Effect.fail(new MailboxFull({ address: target.address }))
373
+ case "EncodedMessageTooLarge":
374
+ return Effect.fail(
375
+ new PersistenceError({ cause: new Error("Encoded entity message exceeds 2 MB") })
376
+ )
377
+ case "AskDeduplicatedToTell":
378
+ return Effect.fail(
379
+ new PersistenceError({
380
+ cause: new Error("Cannot deduplicate an ask onto a tell with the same PrimaryKey")
381
+ })
382
+ )
383
+ case "Success": {
384
+ entry.storageRequestId = result.requestId
385
+ if (!discard && persisted) {
386
+ setWithEviction(requestTargets, clientRequestId, {
387
+ resolve: target.resolve,
388
+ storageRequestId: result.requestId
389
+ }, requestTargetCapacity)
390
+ }
391
+ // A discarded call is acknowledged once its message is delivered.
392
+ if (discard) {
393
+ return client.write({
394
+ _tag: "Exit",
395
+ clientId: 0,
396
+ requestId: RequestId(clientRequestId),
397
+ exit: Exit.void as any
398
+ })
399
+ }
400
+ if (pushedReply !== undefined && result.replies.length === 0) {
401
+ return Effect.flatMap(
402
+ Deferred.await(pushedReply),
403
+ (replyText) => deliverReplies(entry, [replyText])
404
+ )
405
+ }
406
+ return deliverReplies(entry, result.replies)
407
+ }
408
+ }
409
+ }),
410
+ // A failed send gets no `Exit` reply, so release its entry here.
411
+ // An interrupt releases it in the `Interrupt` case instead, which
412
+ // still needs it to reach the Durable Object.
413
+ Effect.onExit((exit) =>
414
+ Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)
415
+ ? Effect.sync(() => entries.delete(clientRequestId))
416
+ : Effect.void
417
+ )
418
+ )
419
+ return pushedReply === undefined ? send : Effect.ensuring(
420
+ send,
421
+ Effect.sync(() => replyRegistry!.unregister(clientRequestId, pushedReply))
422
+ )
423
+ })
424
+ }
425
+ case "Ack": {
426
+ const entry = entries.get(String(message.requestId))
427
+ if (entry === undefined || entry.lastChunkId === undefined) return Effect.void
428
+ const lastChunkId = entry.lastChunkId
429
+ return callDurableObject({
430
+ resolve: target.resolve,
431
+ call: (stub) => stub.acknowledge(entry.storageRequestId, lastChunkId),
432
+ retry: boundedRetry
433
+ }).pipe(
434
+ Effect.flatMap((replies) => deliverReplies(entry, replies))
435
+ )
436
+ }
437
+ case "Interrupt": {
438
+ const clientRequestId = String(message.requestId)
439
+ const entry = entries.get(clientRequestId)
440
+ entries.delete(clientRequestId)
441
+ requestTargets.delete(clientRequestId)
442
+ if (entry === undefined) return Effect.void
443
+ if (isUninterruptibleForClient(entry.rpc.annotations)) return Effect.void
444
+ return callDurableObject({
445
+ resolve: target.resolve,
446
+ call: (stub) => stub.interrupt(entry.storageRequestId, clientRequestId),
447
+ retry: boundedRetry
448
+ })
449
+ }
450
+ default:
451
+ return Effect.void
452
+ }
453
+ }
454
+
455
+ return (entityId: string) => {
456
+ const id = EntityId.make(entityId)
457
+ const name = Internal.encodeName(entity.type, entityId)
458
+ const target = ClientTarget.context({
459
+ address: EntityAddress.make({
460
+ shardId: ShardId.make(entity.getShardGroup(id), 1),
461
+ entityId: id,
462
+ entityType: entity.type
463
+ }),
464
+ resolve: () => options.entityNamespace.getByName(name) as unknown as EntityStub
465
+ })
466
+ const result: Record<string, unknown> = {}
467
+ for (const tag of entity.protocol.requests.keys()) {
468
+ const rpc = entity.protocol.requests.get(tag)! as Rpc.AnyWithProps
469
+ const isStream = RpcSchema.isStreamSchema(rpc.successSchema)
470
+ const contextFor = (
471
+ ambient: Context.Context<never>,
472
+ methodOptions?: { readonly context?: Context.Context<never> }
473
+ ) => {
474
+ const currentEntityName = Option.getOrUndefined(Context.getOption(ambient, CurrentEntityName))
475
+ const replyRegistry = Option.getOrUndefined(Context.getOption(ambient, CurrentReplyRegistry))
476
+ let requestContext = currentEntityName === undefined
477
+ ? target
478
+ : Context.add(target, CurrentEntityName, currentEntityName)
479
+ if (replyRegistry !== undefined) {
480
+ requestContext = Context.add(requestContext, CurrentReplyRegistry, replyRegistry)
481
+ }
482
+ if (methodOptions?.context !== undefined) {
483
+ requestContext = Context.merge(methodOptions.context, requestContext)
484
+ }
485
+ return requestContext
486
+ }
487
+ result[tag] = (payload: unknown, methodOptions?: {
488
+ readonly context?: Context.Context<never>
489
+ readonly asQueue?: boolean
490
+ }) => {
491
+ // The caller's context is read when the request runs, so it carries
492
+ // the calling entity's reply target. The untyped protocol erases
493
+ // the stream/queue return shape, hence `any`.
494
+ const call = (ambient: Context.Context<never>): any =>
495
+ client.client(tag, payload, { ...methodOptions, context: contextFor(ambient, methodOptions) })
496
+ // With `asQueue`, a stream RPC yields its Dequeue as an Effect.
497
+ return isStream && methodOptions?.asQueue !== true
498
+ ? Stream.unwrap(Effect.map(Effect.context<never>(), call))
499
+ : Effect.contextWith(call)
500
+ }
501
+ }
502
+ return result as any
503
+ }
504
+ })
505
+
506
+ type ClientFactory = (entityId: string) => any
507
+ // Like `Sharding`, each entity's base client is built once from the layer
508
+ // context and scope, so it needs no caller Scope, outlives the scope it was
509
+ // first requested in, and resolves client middleware from the layer rather
510
+ // than from whichever caller asked first.
511
+ const clientContext = Context.add(layerContext, Scope.Scope, layerScope)
512
+ const clients = new Map<Entity.Entity<any, any>, Deferred.Deferred<ClientFactory>>()
513
+ const makeClient = (entity: Entity.Entity<any, any>): Effect.Effect<ClientFactory> =>
514
+ Effect.suspend(() => {
515
+ if (!entities.has(entity.type)) return unknownEntity(entity)
516
+ const existing = clients.get(entity)
517
+ if (existing !== undefined) return Deferred.await(existing)
518
+ const deferred = Deferred.makeUnsafe<ClientFactory>()
519
+ clients.set(entity, deferred)
520
+ return buildClient(entity).pipe(
521
+ Effect.provideContext(clientContext),
522
+ Effect.tap((factory) => Deferred.succeed(deferred, factory)),
523
+ Effect.uninterruptible
524
+ )
525
+ })
526
+
527
+ const registerEntity = Effect.fnUntraced(function*(
528
+ entity: Entity.Entity<any, any>,
529
+ build: Effect.Effect<unknown, never, unknown>,
530
+ buildOptions?: Record<string, unknown>
531
+ ) {
532
+ const declared = entities.has(entity.type)
533
+ if (!declared && !isClusterCronEntity(entity)) {
534
+ return yield* unknownEntity(entity)
535
+ }
536
+ const context = yield* Effect.context<never>()
537
+ const registration = { entity, build: build as any, options: buildOptions, context, keepAliveHeartbeat }
538
+ if (!registerEntityHandler(entity.type, registration)) return
539
+ if (!declared) entities.set(entity.type, entity)
540
+ yield* Effect.addFinalizer(() =>
541
+ Effect.sync(() => {
542
+ unregisterEntity(entity.type, registration)
543
+ if (!declared && entities.get(entity.type) === entity) entities.delete(entity.type)
544
+ })
545
+ )
546
+ })
547
+
548
+ const registerSingleton = Effect.fnUntraced(function*(
549
+ name: string,
550
+ run: Effect.Effect<void, unknown, unknown>
551
+ ) {
552
+ // Fails fast at registration when the singleton namespace binding is
553
+ // missing, before any Cron Trigger fires.
554
+ options.singletonNamespace.getByName(`Singleton/${name}`)
555
+ const context = yield* Effect.context<never>()
556
+ const registration = {
557
+ run: run as Effect.Effect<void, unknown, never>,
558
+ context
559
+ }
560
+ if (!registerSingletonHandler(name, registration)) {
561
+ return yield* Effect.die(`Singleton '${name}' is already registered`)
562
+ }
563
+ yield* Effect.addFinalizer(() =>
564
+ Effect.sync(() => {
565
+ unregisterSingleton(name, registration)
566
+ })
567
+ )
568
+ })
569
+
570
+ return Sharding.of({
571
+ getRegistrationEvents: Stream.never,
572
+ getShardId: (_entityId, group) => ShardId.make(group, 1),
573
+ hasShardId: () => true,
574
+ // A Snowflake needs a machine id unique across the fleet, which isolates
575
+ // do not have.
576
+ getSnowflake: notSupported("Sharding.getSnowflake"),
577
+ isShutdown: Effect.succeed(false),
578
+ makeClient: makeClient as Sharding["Service"]["makeClient"],
579
+ registerEntity: registerEntity as Sharding["Service"]["registerEntity"],
580
+ registerSingleton: registerSingleton as Sharding["Service"]["registerSingleton"],
581
+ send: () => notImplemented("Sharding.send"),
582
+ sendOutgoing: () => notImplemented("Sharding.sendOutgoing"),
583
+ notify: () => notImplemented("Sharding.notify"),
584
+ reset: (requestId) => {
585
+ const target = requestTargets.get(String(requestId))
586
+ if (target === undefined) return Effect.succeed(false)
587
+ return callDurableObject({
588
+ resolve: target.resolve,
589
+ call: (stub) => stub.reset(target.storageRequestId),
590
+ retry: boundedRetry
591
+ }).pipe(Effect.orDie, Effect.as(true))
592
+ },
593
+ pollStorage: notImplemented("Sharding.pollStorage"),
594
+ activeEntityCount: Effect.succeed(0)
595
+ })
596
+ })
597
+
598
+ /**
599
+ * Builds the cluster on Cloudflare Durable Objects.
600
+ *
601
+ * **Details**
602
+ *
603
+ * Provides the cluster `Sharding` service on top of the four same-Worker
604
+ * Durable Object namespace bindings, plus the `WorkflowEngine` backed by the
605
+ * workflow class and the `PersistedQueueFactory` backed by the queue class, so
606
+ * the `DurableQueue` user API works out of the box. `Entity.client` resolves
607
+ * an entity to its Durable Object by
608
+ * encoding `(type, id)` with {@link encodeName} and calling `getByName`; an
609
+ * unknown entity type or a bad encode fails at the Worker before any Durable
610
+ * Object is contacted. Entity handlers registered with `Entity.toLayer` are
611
+ * recorded per `EntityType` at Worker init and built once per Durable Object
612
+ * wake; workflow handlers registered with `Workflow.toLayer` follow the same
613
+ * pattern on the workflow class.
614
+ *
615
+ * @stability experimental
616
+ * @category layers
617
+ * @since 4.1.3
618
+ */
619
+ export const layer = (
620
+ options: LayerOptions
621
+ ): Layer.Layer<Sharding | WorkflowEngine | PersistedQueueFactory> =>
622
+ Layer.mergeAll(
623
+ Layer.effect(Sharding, make(options)),
624
+ CloudflareWorkflowEngine.layer({ workflowNamespace: options.workflowNamespace }),
625
+ CloudflarePersistedQueue.layer({ queueNamespace: options.queueNamespace })
626
+ )
627
+
628
+ /**
629
+ * An `SqlClient` on the current entity Durable Object's own SQLite storage.
630
+ *
631
+ * **When to use**
632
+ *
633
+ * Use to keep rows next to the entity's mailbox from an entity handler build
634
+ * or handler, such as state that must survive eviction and commit atomically
635
+ * with each step. Register the handlers with `Entity.toLayer` and read the
636
+ * client with {@link DurableObjectSqlClient.orDie} in the build.
637
+ *
638
+ * **Details**
639
+ *
640
+ * Each entity Durable Object provides this service, an `@effect/sql-sqlite-do`
641
+ * client, to its handler build and handlers, including handlers registered
642
+ * with `Entity.toLayerQueue`. Yielding the tag directly leaves it as a layer
643
+ * requirement; `orDie` has none. It is a separate tag, so any
644
+ * `SqlClient` from the Worker layer (a D1 database, say) stays reachable inside
645
+ * the handlers. To run code written against `SqlClient.SqlClient`, provide this
646
+ * client as that service locally.
647
+ *
648
+ * `withTransaction` uses the object's storage transaction, so several rows
649
+ * commit or roll back together. Rows live and die with the entity's Durable
650
+ * Object and cannot be queried from outside it.
651
+ *
652
+ * **Gotchas**
653
+ *
654
+ * - Outside an entity Durable Object, `orDie` dies on the first handler build.
655
+ * In tests, provide this service from any SQL layer.
656
+ * - Table names starting with `cluster_` are reserved for the entity mailbox.
657
+ * - A row, like an encoded message, cannot exceed 2 MB.
658
+ * - A storage transaction closes the Durable Object's input gate until it
659
+ * finishes, so timers and I/O awaited inside `withTransaction` never
660
+ * resume. Keep transactions to SQL work.
661
+ * - Mailbox writes wait for an open transaction to finish, so a rollback never
662
+ * takes another handler's stored reply with it.
663
+ *
664
+ * **Example** (Storing rows from entity handlers)
665
+ *
666
+ * ```ts
667
+ * import { CloudflareCluster } from "@effect/platform-cloudflare"
668
+ * import { Effect } from "effect"
669
+ *
670
+ * const JournalLayer = Journal.toLayer(Effect.gen(function*() {
671
+ * const sql = yield* CloudflareCluster.DurableObjectSqlClient.orDie
672
+ * yield* sql`CREATE TABLE IF NOT EXISTS journal (entry TEXT NOT NULL)`.pipe(Effect.orDie)
673
+ * return Journal.of({
674
+ * Append: ({ payload }) => sql`INSERT INTO journal (entry) VALUES (${payload.entry})`.pipe(Effect.orDie)
675
+ * })
676
+ * }))
677
+ * ```
678
+ *
679
+ * @stability experimental
680
+ * @category services
681
+ * @since 4.1.3
682
+ */
683
+ export class DurableObjectSqlClient extends Context.Service<DurableObjectSqlClient, SqlClient.SqlClient>()(
684
+ "@effect/platform-cloudflare/CloudflareCluster/DurableObjectSqlClient"
685
+ ) {
686
+ /**
687
+ * Gets the current entity's SQL client, or dies if no client is provided.
688
+ *
689
+ * @stability experimental
690
+ * @since 4.1.3
691
+ */
692
+ static readonly orDie: Effect.Effect<SqlClient.SqlClient> = Effect.flatMap(
693
+ Effect.serviceOption(DurableObjectSqlClient),
694
+ Option.match({
695
+ onNone: () =>
696
+ Effect.die(
697
+ new Error(
698
+ "DurableObjectSqlClient is not available outside an entity Durable Object; provide it from a SQL layer in tests"
699
+ )
700
+ ),
701
+ onSome: Effect.succeed
702
+ })
703
+ )
704
+ }