@effect/platform-cloudflare 0.0.0-stage → 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 +505 -2
  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 +74 -3
  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,60 @@
1
+ /**
2
+ * The entity Durable Object's SQLite exposed to entity handlers as
3
+ * `DurableObjectSqlClient`.
4
+ *
5
+ * A user transaction holds `storage.transaction()` open across Effect
6
+ * suspensions, and every write on the object's single SQLite connection lands
7
+ * inside it. Another handler resumed from inside the transaction could save
8
+ * its reply there and lose it to the user's rollback, so mailbox writes wait
9
+ * on the same gate as user transactions.
10
+ *
11
+ * @internal
12
+ */
13
+ import type { DurableObjectStorage } from "@cloudflare/workers-types"
14
+ import * as SqliteClient from "@effect/sql-sqlite-do/SqliteClient"
15
+ import * as Context from "effect/Context"
16
+ import * as Effect from "effect/Effect"
17
+ import * as Reactivity from "effect/reactivity/Reactivity"
18
+ import type * as Scope from "effect/Scope"
19
+ import * as Semaphore from "effect/Semaphore"
20
+ import type * as SqlClient from "effect/sql/SqlClient"
21
+ import { DurableObjectSqlClient } from "../CloudflareCluster.ts"
22
+
23
+ /** @internal */
24
+ export interface EntitySql {
25
+ /** Builds the client for one handler build, closed with that build's scope. */
26
+ readonly make: Effect.Effect<Context.Context<DurableObjectSqlClient>, never, Scope.Scope>
27
+ /** Runs a mailbox write once no user transaction is open. */
28
+ readonly guard: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
29
+ }
30
+
31
+ const InUserTransaction = Context.Reference<boolean>(
32
+ "@effect/platform-cloudflare/internal/entitySql/InUserTransaction",
33
+ { defaultValue: () => false }
34
+ )
35
+
36
+ /** @internal */
37
+ export const makeEntitySql = (storage: DurableObjectStorage): EntitySql => {
38
+ const gate = Semaphore.makeUnsafe(1)
39
+
40
+ const make = Effect.map(
41
+ Effect.provideServiceEffect(SqliteClient.make({ storage }), Reactivity.Reactivity, Reactivity.make),
42
+ (client) => {
43
+ // Nested transactions reuse the outer storage transaction, so only the
44
+ // outermost one takes the gate.
45
+ const withTransaction = client.withTransaction
46
+ const gated: SqlClient.SqlClient["withTransaction"] = (effect) =>
47
+ Effect.withFiber((fiber) =>
48
+ fiber.getRef(InUserTransaction)
49
+ ? withTransaction(effect)
50
+ : Semaphore.withPermit(gate, withTransaction(Effect.provideService(effect, InUserTransaction, true)))
51
+ )
52
+ return Context.make(DurableObjectSqlClient, Object.assign(client, { withTransaction: gated }))
53
+ }
54
+ )
55
+
56
+ return {
57
+ make,
58
+ guard: (effect) => Semaphore.withPermit(gate, effect)
59
+ }
60
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Storage glue for the entity Durable Object constructor. The constructor must
3
+ * stay cheap: open SQLite, ensure the mailbox tables, and re-arm the single
4
+ * alarm. No user handlers are built here.
5
+ *
6
+ * @internal
7
+ */
8
+ import type { DurableObjectStorage, SqlStorage } from "@cloudflare/workers-types"
9
+ import * as Effect from "effect/Effect"
10
+ import * as Result from "effect/Result"
11
+
12
+ /** @internal */
13
+ export type EntityAlarm = Pick<DurableObjectStorage, "getAlarm" | "setAlarm">
14
+
15
+ type DeliverAtRow = {
16
+ readonly deliver_at: number | null
17
+ }
18
+
19
+ /**
20
+ * Runs a synchronous storage effect inside `transactionSync`. A defect throws
21
+ * out of the callback and rolls the transaction back; a typed failure happens
22
+ * before any write in the mailbox operations, so it is carried out as a plain
23
+ * failure.
24
+ *
25
+ * @internal
26
+ */
27
+ export const withTransaction = <A, E>(
28
+ storage: Pick<DurableObjectStorage, "transactionSync">,
29
+ effect: Effect.Effect<A, E>
30
+ ): Effect.Effect<A, E> =>
31
+ Effect.suspend(() => {
32
+ const result = storage.transactionSync(() => Effect.runSync(Effect.result(effect)))
33
+ return Result.isSuccess(result) ? Effect.succeed(result.success) : Effect.fail(result.failure)
34
+ })
35
+
36
+ const ddl = [
37
+ `CREATE TABLE IF NOT EXISTS cluster_messages (
38
+ request_id TEXT PRIMARY KEY,
39
+ message_id TEXT UNIQUE,
40
+ envelope TEXT NOT NULL,
41
+ discard INTEGER NOT NULL DEFAULT 0,
42
+ processed INTEGER NOT NULL DEFAULT 0,
43
+ last_reply_id TEXT,
44
+ deliver_at INTEGER,
45
+ reply_to TEXT
46
+ )`,
47
+ `CREATE TABLE IF NOT EXISTS cluster_replies (
48
+ reply_id TEXT PRIMARY KEY,
49
+ request_id TEXT NOT NULL,
50
+ reply TEXT NOT NULL,
51
+ kind TEXT NOT NULL,
52
+ sequence INTEGER,
53
+ acked INTEGER NOT NULL DEFAULT 0,
54
+ UNIQUE (request_id, sequence)
55
+ )`,
56
+ `CREATE INDEX IF NOT EXISTS cluster_messages_deliver_at_idx
57
+ ON cluster_messages (processed, deliver_at)`,
58
+ `CREATE INDEX IF NOT EXISTS cluster_replies_unacked_idx
59
+ ON cluster_replies (request_id) WHERE kind = 'Chunk' AND acked = 0`,
60
+ // One row: whether the entity wants keep-alive across restarts, and the
61
+ // encoded name for wakes that arrive without one.
62
+ `CREATE TABLE IF NOT EXISTS cluster_entity_state (
63
+ id INTEGER PRIMARY KEY CHECK (id = 0),
64
+ keep_alive INTEGER NOT NULL DEFAULT 0,
65
+ name TEXT
66
+ )`
67
+ ]
68
+
69
+ /** @internal */
70
+ export const ensureEntityStorage = (sql: SqlStorage): void => {
71
+ for (const statement of ddl) {
72
+ sql.exec(statement)
73
+ }
74
+ }
75
+
76
+ /** @internal */
77
+ export const earliestDeliverAt = (sql: SqlStorage): number | undefined => {
78
+ const rows = sql.exec<DeliverAtRow>(
79
+ "SELECT min(deliver_at) AS deliver_at FROM cluster_messages WHERE processed = 0 AND deliver_at IS NOT NULL"
80
+ ).toArray()
81
+ return rows[0]?.deliver_at ?? undefined
82
+ }
83
+
84
+ /** @internal */
85
+ export const armAlarm = (alarm: EntityAlarm, deliverAt: number): Effect.Effect<void> =>
86
+ Effect.promise(() => alarm.getAlarm()).pipe(
87
+ Effect.flatMap((current) =>
88
+ current === null || current > deliverAt
89
+ ? Effect.promise(() => alarm.setAlarm(deliverAt))
90
+ : Effect.void
91
+ )
92
+ )
93
+
94
+ type EntityStateRow = {
95
+ readonly keep_alive: number
96
+ readonly name: string | null
97
+ }
98
+
99
+ /** @internal */
100
+ export interface EntityState {
101
+ readonly keepAlive: boolean
102
+ readonly name: string | undefined
103
+ }
104
+
105
+ /** @internal */
106
+ export const loadEntityState = (sql: SqlStorage): EntityState => {
107
+ const row = sql.exec<EntityStateRow>("SELECT keep_alive, name FROM cluster_entity_state WHERE id = 0").toArray()[0]
108
+ return { keepAlive: row?.keep_alive === 1, name: row?.name ?? undefined }
109
+ }
110
+
111
+ /** @internal */
112
+ export const saveKeepAlive = (sql: SqlStorage, enabled: boolean): Effect.Effect<void> =>
113
+ Effect.sync(() => {
114
+ sql.exec(
115
+ `INSERT INTO cluster_entity_state (id, keep_alive) VALUES (0, ?)
116
+ ON CONFLICT (id) DO UPDATE SET keep_alive = excluded.keep_alive`,
117
+ enabled ? 1 : 0
118
+ )
119
+ })
120
+
121
+ /** @internal */
122
+ export const rememberEntityName = (sql: SqlStorage, name: string): void => {
123
+ sql.exec(
124
+ `INSERT INTO cluster_entity_state (id, name) VALUES (0, ?)
125
+ ON CONFLICT (id) DO UPDATE SET name = excluded.name`,
126
+ name
127
+ )
128
+ }
129
+
130
+ type UnprocessedRow = {
131
+ readonly request_id: string
132
+ readonly deliver_at: number | null
133
+ }
134
+
135
+ /**
136
+ * The single alarm time covering every reason to wake this entity: the
137
+ * earliest due row, `now` for an unprocessed row nothing is running, and a
138
+ * heartbeat while a row is in flight or keep-alive is wanted. An in-flight row
139
+ * counts as a heartbeat rather than `now`, so a long handler does not keep
140
+ * re-firing the alarm.
141
+ *
142
+ * @internal
143
+ */
144
+ export const nextAlarmAt = (
145
+ sql: SqlStorage,
146
+ options: {
147
+ readonly now: number
148
+ readonly heartbeatMillis: number
149
+ readonly isRunning: (requestId: string) => boolean
150
+ }
151
+ ): number | undefined => {
152
+ let next = Infinity
153
+ let heartbeat = loadEntityState(sql).keepAlive
154
+ const rows = sql.exec<UnprocessedRow>(
155
+ "SELECT request_id, deliver_at FROM cluster_messages WHERE processed = 0"
156
+ ).toArray()
157
+ for (const row of rows) {
158
+ if (options.isRunning(row.request_id)) {
159
+ heartbeat = true
160
+ continue
161
+ }
162
+ next = Math.min(next, row.deliver_at === null ? options.now : Math.max(row.deliver_at, options.now))
163
+ }
164
+ if (heartbeat) {
165
+ next = Math.min(next, options.now + options.heartbeatMillis)
166
+ }
167
+ return Number.isFinite(next) ? next : undefined
168
+ }
@@ -0,0 +1,204 @@
1
+ /** @internal */
2
+ import * as EntityAddress from "effect/cluster/EntityAddress"
3
+ import * as EntityId from "effect/cluster/EntityId"
4
+ import * as EntityType from "effect/cluster/EntityType"
5
+ import * as Envelope from "effect/cluster/Envelope"
6
+ import * as Reply from "effect/cluster/Reply"
7
+ import * as ShardId from "effect/cluster/ShardId"
8
+ import type * as Context from "effect/Context"
9
+ import * as Effect from "effect/Effect"
10
+ import * as Headers from "effect/http/Headers"
11
+ import * as Rpc from "effect/rpc/Rpc"
12
+ import * as RpcSchema from "effect/rpc/RpcSchema"
13
+ import * as Schema from "effect/Schema"
14
+ import type { EntityInvokeResult } from "../CloudflareDurableObjectPrograms.ts"
15
+ import type { EntityRegistration } from "./entityRegistry.ts"
16
+
17
+ type EncodedRequest = Extract<Envelope.Encoded, { readonly _tag: "Request" }>
18
+
19
+ /**
20
+ * The result of a `ClusterEntity.invoke` RPC. Constructed in the Durable
21
+ * Object as this schema's `Type` (its JSON encoding is the identity) and
22
+ * decoded once on the Worker side.
23
+ *
24
+ * @internal
25
+ */
26
+ export const InvokeResult = Schema.Union([
27
+ Schema.Struct({
28
+ _tag: Schema.Literal("Success"),
29
+ requestId: Schema.String,
30
+ replies: Schema.Array(Schema.String)
31
+ }),
32
+ Schema.Struct({ _tag: Schema.Literal("MailboxFull") }),
33
+ Schema.Struct({ _tag: Schema.Literal("EncodedMessageTooLarge") }),
34
+ Schema.Struct({ _tag: Schema.Literal("AskDeduplicatedToTell") })
35
+ ]) satisfies Schema.Codec<EntityInvokeResult>
36
+
37
+ /** @internal */
38
+ export const decodeInvokeResult = (value: unknown): Effect.Effect<EntityInvokeResult> =>
39
+ Effect.orDie(Schema.decodeUnknownEffect(InvokeResult)(value))
40
+
41
+ const EnvelopePeek = Schema.Struct({ tag: Schema.optional(Schema.String) })
42
+
43
+ /**
44
+ * Reads the RPC tag out of a stored envelope without decoding the payload,
45
+ * for rows whose full decode already failed.
46
+ *
47
+ * @internal
48
+ */
49
+ export const peekEnvelopeTag = (envelopeText: string): Effect.Effect<string | undefined> =>
50
+ Schema.decodeUnknownEffect(EnvelopePeek)(JSON.parse(envelopeText)).pipe(
51
+ Effect.match({ onFailure: () => undefined, onSuccess: ({ tag }) => tag })
52
+ )
53
+
54
+ /** @internal */
55
+ export const runWith = <A>(
56
+ effect: Effect.Effect<A, any, any>,
57
+ context: Context.Context<never>
58
+ ): Effect.Effect<A> => effect.pipe(Effect.provideContext(context as any), Effect.orDie) as Effect.Effect<A>
59
+
60
+ // Cached per rpc so the derived AST stays stable and the memoized parser
61
+ // compiler can hit on repeated chunks.
62
+ const chunkValuesCache = new WeakMap<Rpc.AnyWithProps, Schema.Top | undefined>()
63
+
64
+ const chunkValuesCodec = (rpc: Rpc.AnyWithProps): Schema.Top | undefined => {
65
+ if (chunkValuesCache.has(rpc)) return chunkValuesCache.get(rpc)
66
+ const codec = RpcSchema.isStreamSchema(rpc.successSchema)
67
+ ? Schema.toCodecJson(Schema.NonEmptyArray(rpc.successSchema.success))
68
+ : undefined
69
+ chunkValuesCache.set(rpc, codec)
70
+ return codec
71
+ }
72
+
73
+ /** @internal */
74
+ export const encodeRequest = (options: {
75
+ readonly requestId: string
76
+ readonly address: EntityAddress.EntityAddress
77
+ readonly tag: string
78
+ readonly payload: unknown
79
+ readonly headers: unknown
80
+ readonly traceId?: string | undefined
81
+ readonly spanId?: string | undefined
82
+ readonly sampled?: boolean | undefined
83
+ }): string =>
84
+ JSON.stringify({
85
+ _tag: "Request",
86
+ requestId: options.requestId,
87
+ address: {
88
+ shardId: options.address.shardId,
89
+ entityType: options.address.entityType,
90
+ entityId: options.address.entityId
91
+ },
92
+ tag: options.tag,
93
+ payload: options.payload,
94
+ headers: options.headers,
95
+ ...(options.traceId === undefined ? undefined : {
96
+ traceId: options.traceId,
97
+ spanId: options.spanId,
98
+ sampled: options.sampled
99
+ })
100
+ })
101
+
102
+ /** @internal */
103
+ export const decodeRequest = (
104
+ registration: EntityRegistration,
105
+ envelopeText: string
106
+ ): Effect.Effect<Envelope.Request.Any> =>
107
+ runWith(
108
+ Effect.gen(function*() {
109
+ const encoded = JSON.parse(envelopeText) as EncodedRequest
110
+ if (encoded._tag !== "Request" || typeof encoded.requestId !== "string") {
111
+ return yield* Effect.die("Expected an encoded Request envelope")
112
+ }
113
+ const rpc = registration.entity.protocol.requests.get(encoded.tag) as Rpc.AnyWithProps | undefined
114
+ if (rpc === undefined) return yield* Effect.die(`Unknown entity RPC tag: ${encoded.tag}`)
115
+ const payload = yield* Schema.decodeUnknownEffect(Schema.toCodecJson(rpc.payloadSchema))(encoded.payload)
116
+ return Envelope.makeRequest({
117
+ requestId: encoded.requestId as any,
118
+ address: EntityAddress.make({
119
+ shardId: ShardId.make(encoded.address.shardId.group, encoded.address.shardId.id),
120
+ entityType: EntityType.make(encoded.address.entityType),
121
+ entityId: EntityId.make(encoded.address.entityId)
122
+ }),
123
+ tag: encoded.tag,
124
+ payload,
125
+ headers: Headers.fromInput(encoded.headers),
126
+ ...(encoded.traceId === undefined ? undefined : {
127
+ traceId: encoded.traceId,
128
+ spanId: encoded.spanId!,
129
+ sampled: encoded.sampled!
130
+ })
131
+ }) as Envelope.Request.Any
132
+ }),
133
+ registration.context
134
+ )
135
+
136
+ /** @internal */
137
+ export const encodeReplyFor = (
138
+ registration: EntityRegistration,
139
+ rpc: Rpc.AnyWithProps,
140
+ reply: Reply.Reply<any>
141
+ ): Effect.Effect<string> => {
142
+ if (reply._tag === "WithExit") {
143
+ return runWith(
144
+ Effect.map(
145
+ Schema.encodeUnknownEffect(Schema.toCodecJson(Rpc.exitSchema(rpc)))(reply.exit),
146
+ (exit) => JSON.stringify({ _tag: "WithExit", requestId: String(reply.requestId), id: String(reply.id), exit })
147
+ ),
148
+ registration.context
149
+ )
150
+ }
151
+ const codec = chunkValuesCodec(rpc)
152
+ if (codec === undefined) {
153
+ return Effect.die(`Expected a stream RPC: ${rpc._tag}`)
154
+ }
155
+ return runWith(
156
+ Effect.map(
157
+ Schema.encodeUnknownEffect(codec)(reply.values),
158
+ (values) =>
159
+ JSON.stringify({
160
+ _tag: "Chunk" as const,
161
+ requestId: String(reply.requestId),
162
+ id: String(reply.id),
163
+ sequence: reply.sequence,
164
+ values
165
+ })
166
+ ),
167
+ registration.context
168
+ )
169
+ }
170
+
171
+ /** @internal */
172
+ export const decodeReplyFor = (
173
+ rpc: Rpc.AnyWithProps,
174
+ context: Context.Context<never>,
175
+ replyText: string
176
+ ): Effect.Effect<Reply.Reply<any>> => {
177
+ const encoded = JSON.parse(replyText) as Reply.Encoded
178
+ if (encoded._tag === "WithExit") {
179
+ return runWith(
180
+ Effect.map(
181
+ Schema.decodeUnknownEffect(Schema.toCodecJson(Rpc.exitSchema(rpc)))(encoded.exit),
182
+ (exit) => new Reply.WithExit({ requestId: encoded.requestId as any, id: encoded.id as any, exit: exit as any })
183
+ ),
184
+ context
185
+ )
186
+ }
187
+ const codec = chunkValuesCodec(rpc)
188
+ if (codec === undefined) {
189
+ return Effect.die(`Expected a stream RPC: ${rpc._tag}`)
190
+ }
191
+ return runWith(
192
+ Effect.map(
193
+ Schema.decodeUnknownEffect(codec)(encoded.values),
194
+ (values) =>
195
+ new Reply.Chunk({
196
+ requestId: encoded.requestId as any,
197
+ id: encoded.id as any,
198
+ sequence: encoded.sequence,
199
+ values: values as any
200
+ })
201
+ ),
202
+ context
203
+ )
204
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * The durable queue Durable Object runtime. One object holds one named queue:
3
+ * items live in SQLite, and every lease arms the single alarm at its expiry so
4
+ * an item whose worker died is redelivered once the watchdog fires. Takers
5
+ * with no available item wait in memory until an offer, a retry, or a lease
6
+ * expiry wakes them; a crash or hibernation drops those waiters, whose broken
7
+ * RPCs are retried from the Worker side. An interrupted taker cancels its wait
8
+ * by taker id, releasing an item that was already leased to it.
9
+ *
10
+ * `take` is idempotent per taker id: a retried take replaces the taker's
11
+ * waiter, or returns the item still leased to it. Leases are found by owner in
12
+ * SQLite, so a retry or cancel still finds its item after a restart.
13
+ *
14
+ * @internal
15
+ */
16
+ import type { SqlStorage } from "@cloudflare/workers-types"
17
+ import * as Effect from "effect/Effect"
18
+ import { armAlarm, type EntityAlarm } from "./entityStorage.ts"
19
+ import {
20
+ completeItem,
21
+ earliestLeaseExpiry,
22
+ ensureQueueStorage,
23
+ expireLeases,
24
+ extendLease,
25
+ failItem,
26
+ leasedItem,
27
+ leaseNextItem,
28
+ offerItem,
29
+ type QueueItem,
30
+ releaseItem,
31
+ releaseOwnedItem
32
+ } from "./queueStorage.ts"
33
+
34
+ /** @internal */
35
+ export interface QueueRuntimeOptions {
36
+ readonly sql: SqlStorage
37
+ readonly alarm: EntityAlarm
38
+ readonly now: () => number
39
+ }
40
+
41
+ /** @internal */
42
+ export interface QueueRuntime {
43
+ readonly offer: (
44
+ id: string,
45
+ element: string,
46
+ delayMillis?: number,
47
+ deduplicate?: "untilExecuted" | "untilRemoved"
48
+ ) => Promise<void>
49
+ readonly take: (takerId: string, maxAttempts: number, leaseMillis: number) => Promise<QueueItem>
50
+ readonly cancelTake: (takerId: string) => Promise<void>
51
+ readonly complete: (id: string, takerId: string) => Promise<void>
52
+ readonly fail: (
53
+ id: string,
54
+ lastFailure: string,
55
+ takerId: string,
56
+ terminal?: boolean,
57
+ retryDelayMillis?: number
58
+ ) => Promise<void>
59
+ readonly release: (id: string, takerId: string) => Promise<void>
60
+ readonly extend: (id: string, leaseMillis: number, takerId: string) => Promise<void>
61
+ readonly runAlarm: () => Promise<void>
62
+ }
63
+
64
+ interface Waiter {
65
+ readonly takerId: string
66
+ readonly maxAttempts: number
67
+ readonly leaseMillis: number
68
+ resolve: (item: QueueItem) => void
69
+ }
70
+
71
+ /** @internal */
72
+ export const makeQueueRuntime = (options: QueueRuntimeOptions): QueueRuntime => {
73
+ const sql = options.sql
74
+ ensureQueueStorage(sql)
75
+
76
+ const waiters: Array<Waiter> = []
77
+
78
+ // Waiters differ in maxAttempts, so one waiter finding nothing does not mean
79
+ // a later one will; every waiter gets its own lease attempt. Leasing stays
80
+ // fully synchronous so concurrent wake-ups cannot interleave on the waiter
81
+ // list; only the final alarm arm is asynchronous.
82
+ const wakeWaiters = Effect.suspend(() => {
83
+ const now = options.now()
84
+ let index = 0
85
+ while (index < waiters.length) {
86
+ const waiter = waiters[index]
87
+ const item = leaseNextItem(sql, now, now + waiter.leaseMillis, waiter.maxAttempts, waiter.takerId)
88
+ if (item === undefined) {
89
+ index++
90
+ continue
91
+ }
92
+ waiters.splice(index, 1)
93
+ waiter.resolve(item)
94
+ }
95
+ // Include initial visibility deadlines even when no taker is waiting.
96
+ const earliest = earliestLeaseExpiry(sql)
97
+ return earliest === undefined ? Effect.void : armAlarm(options.alarm, earliest)
98
+ })
99
+
100
+ const mutateAndWake = (mutate: () => void): Promise<void> =>
101
+ Effect.runPromise(Effect.suspend(() => {
102
+ mutate()
103
+ return wakeWaiters
104
+ }))
105
+
106
+ return {
107
+ offer: (id, element, delayMillis = 0, deduplicate) =>
108
+ mutateAndWake(() => {
109
+ offerItem(sql, id, element, delayMillis > 0 ? options.now() + delayMillis : null, deduplicate)
110
+ }),
111
+
112
+ // The taker joins the waiter list and the shared wake pass leases to it,
113
+ // so an immediate take and a woken one follow the same code path.
114
+ take: (takerId, maxAttempts, leaseMillis) => {
115
+ // A broken take RPC is retried with the same taker id. The retry must
116
+ // not leave a second waiter that leases an item nobody handles.
117
+ const leased = leasedItem(sql, takerId, options.now())
118
+ if (leased !== undefined) return Promise.resolve(leased)
119
+ const existing = waiters.find((waiter) => waiter.takerId === takerId)
120
+ const item = new Promise<QueueItem>((resolve) => {
121
+ if (existing === undefined) {
122
+ waiters.push({ takerId, maxAttempts, leaseMillis, resolve })
123
+ } else {
124
+ existing.resolve = resolve
125
+ }
126
+ })
127
+ return Effect.runPromise(wakeWaiters).then(() => item)
128
+ },
129
+
130
+ cancelTake: (takerId) => {
131
+ const index = waiters.findIndex((waiter) => waiter.takerId === takerId)
132
+ if (index >= 0) {
133
+ waiters.splice(index, 1)
134
+ return Promise.resolve()
135
+ }
136
+ // The cancel can race the delivery; release an item already leased to
137
+ // the taker instead of stranding it.
138
+ return mutateAndWake(() => {
139
+ releaseOwnedItem(sql, takerId, options.now())
140
+ })
141
+ },
142
+
143
+ complete: (id, takerId) => Promise.resolve(completeItem(sql, id, takerId)),
144
+
145
+ fail: (id, lastFailure, takerId, terminal = false, retryDelayMillis = 0) =>
146
+ mutateAndWake(() => {
147
+ failItem(
148
+ sql,
149
+ id,
150
+ lastFailure,
151
+ takerId,
152
+ terminal,
153
+ retryDelayMillis > 0 ? options.now() + retryDelayMillis : null
154
+ )
155
+ }),
156
+
157
+ release: (id, takerId) =>
158
+ mutateAndWake(() => {
159
+ releaseItem(sql, id, takerId)
160
+ }),
161
+
162
+ extend: (id, leaseMillis, takerId) => Promise.resolve(extendLease(sql, id, options.now() + leaseMillis, takerId)),
163
+
164
+ runAlarm: () =>
165
+ Effect.runPromise(
166
+ Effect.gen(function*() {
167
+ expireLeases(sql, options.now())
168
+ yield* wakeWaiters
169
+ const next = earliestLeaseExpiry(sql)
170
+ if (next !== undefined) yield* armAlarm(options.alarm, next)
171
+ })
172
+ )
173
+ }
174
+ }