@alchemy.run/effect-cluster 0.0.0-stage → 2.0.0-beta.86

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 (233) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +105 -2
  3. package/lib/AWS/Database.d.ts +54 -0
  4. package/lib/AWS/Database.d.ts.map +1 -0
  5. package/lib/AWS/Database.js +96 -0
  6. package/lib/AWS/Database.js.map +1 -0
  7. package/lib/AWS/EcsPool.d.ts +56 -0
  8. package/lib/AWS/EcsPool.d.ts.map +1 -0
  9. package/lib/AWS/EcsPool.js +87 -0
  10. package/lib/AWS/EcsPool.js.map +1 -0
  11. package/lib/AWS/index.d.ts +7 -0
  12. package/lib/AWS/index.d.ts.map +1 -0
  13. package/lib/AWS/index.js +7 -0
  14. package/lib/AWS/index.js.map +1 -0
  15. package/lib/AWS/internal/Gateway.d.ts +23 -0
  16. package/lib/AWS/internal/Gateway.d.ts.map +1 -0
  17. package/lib/AWS/internal/Gateway.js +70 -0
  18. package/lib/AWS/internal/Gateway.js.map +1 -0
  19. package/lib/Cloudflare/DurableObjectPool.d.ts +42 -0
  20. package/lib/Cloudflare/DurableObjectPool.d.ts.map +1 -0
  21. package/lib/Cloudflare/DurableObjectPool.js +136 -0
  22. package/lib/Cloudflare/DurableObjectPool.js.map +1 -0
  23. package/lib/Cloudflare/PoolWorker.d.ts +186 -0
  24. package/lib/Cloudflare/PoolWorker.d.ts.map +1 -0
  25. package/lib/Cloudflare/PoolWorker.js +75 -0
  26. package/lib/Cloudflare/PoolWorker.js.map +1 -0
  27. package/lib/Cloudflare/index.d.ts +7 -0
  28. package/lib/Cloudflare/index.d.ts.map +1 -0
  29. package/lib/Cloudflare/index.js +7 -0
  30. package/lib/Cloudflare/index.js.map +1 -0
  31. package/lib/Cloudflare/internal/Objects.d.ts +38 -0
  32. package/lib/Cloudflare/internal/Objects.d.ts.map +1 -0
  33. package/lib/Cloudflare/internal/Objects.js +102 -0
  34. package/lib/Cloudflare/internal/Objects.js.map +1 -0
  35. package/lib/Cron.d.ts +143 -0
  36. package/lib/Cron.d.ts.map +1 -0
  37. package/lib/Cron.js +83 -0
  38. package/lib/Cron.js.map +1 -0
  39. package/lib/Database.d.ts +95 -0
  40. package/lib/Database.d.ts.map +1 -0
  41. package/lib/Database.js +38 -0
  42. package/lib/Database.js.map +1 -0
  43. package/lib/Docker/Database.d.ts +42 -0
  44. package/lib/Docker/Database.d.ts.map +1 -0
  45. package/lib/Docker/Database.js +53 -0
  46. package/lib/Docker/Database.js.map +1 -0
  47. package/lib/Docker/SwarmPool.d.ts +44 -0
  48. package/lib/Docker/SwarmPool.d.ts.map +1 -0
  49. package/lib/Docker/SwarmPool.js +56 -0
  50. package/lib/Docker/SwarmPool.js.map +1 -0
  51. package/lib/Docker/index.d.ts +7 -0
  52. package/lib/Docker/index.d.ts.map +1 -0
  53. package/lib/Docker/index.js +7 -0
  54. package/lib/Docker/index.js.map +1 -0
  55. package/lib/Entity.d.ts +348 -0
  56. package/lib/Entity.d.ts.map +1 -0
  57. package/lib/Entity.js +308 -0
  58. package/lib/Entity.js.map +1 -0
  59. package/lib/Fly/Database.d.ts +50 -0
  60. package/lib/Fly/Database.d.ts.map +1 -0
  61. package/lib/Fly/Database.js +56 -0
  62. package/lib/Fly/Database.js.map +1 -0
  63. package/lib/Fly/MachinePool.d.ts +39 -0
  64. package/lib/Fly/MachinePool.d.ts.map +1 -0
  65. package/lib/Fly/MachinePool.js +51 -0
  66. package/lib/Fly/MachinePool.js.map +1 -0
  67. package/lib/Fly/index.d.ts +7 -0
  68. package/lib/Fly/index.d.ts.map +1 -0
  69. package/lib/Fly/index.js +7 -0
  70. package/lib/Fly/index.js.map +1 -0
  71. package/lib/GCP/ComputeEnginePool.d.ts +41 -0
  72. package/lib/GCP/ComputeEnginePool.d.ts.map +1 -0
  73. package/lib/GCP/ComputeEnginePool.js +172 -0
  74. package/lib/GCP/ComputeEnginePool.js.map +1 -0
  75. package/lib/GCP/Database.d.ts +48 -0
  76. package/lib/GCP/Database.d.ts.map +1 -0
  77. package/lib/GCP/Database.js +115 -0
  78. package/lib/GCP/Database.js.map +1 -0
  79. package/lib/GCP/index.d.ts +7 -0
  80. package/lib/GCP/index.d.ts.map +1 -0
  81. package/lib/GCP/index.js +7 -0
  82. package/lib/GCP/index.js.map +1 -0
  83. package/lib/Hetzner/Database.d.ts +48 -0
  84. package/lib/Hetzner/Database.d.ts.map +1 -0
  85. package/lib/Hetzner/Database.js +91 -0
  86. package/lib/Hetzner/Database.js.map +1 -0
  87. package/lib/Hetzner/ServerPool.d.ts +35 -0
  88. package/lib/Hetzner/ServerPool.d.ts.map +1 -0
  89. package/lib/Hetzner/ServerPool.js +98 -0
  90. package/lib/Hetzner/ServerPool.js.map +1 -0
  91. package/lib/Hetzner/index.d.ts +7 -0
  92. package/lib/Hetzner/index.d.ts.map +1 -0
  93. package/lib/Hetzner/index.js +7 -0
  94. package/lib/Hetzner/index.js.map +1 -0
  95. package/lib/Host.d.ts +60 -0
  96. package/lib/Host.d.ts.map +1 -0
  97. package/lib/Host.js +31 -0
  98. package/lib/Host.js.map +1 -0
  99. package/lib/Kubernetes/Database.d.ts +40 -0
  100. package/lib/Kubernetes/Database.d.ts.map +1 -0
  101. package/lib/Kubernetes/Database.js +56 -0
  102. package/lib/Kubernetes/Database.js.map +1 -0
  103. package/lib/Kubernetes/DeploymentPool.d.ts +54 -0
  104. package/lib/Kubernetes/DeploymentPool.d.ts.map +1 -0
  105. package/lib/Kubernetes/DeploymentPool.js +49 -0
  106. package/lib/Kubernetes/DeploymentPool.js.map +1 -0
  107. package/lib/Kubernetes/index.d.ts +7 -0
  108. package/lib/Kubernetes/index.d.ts.map +1 -0
  109. package/lib/Kubernetes/index.js +7 -0
  110. package/lib/Kubernetes/index.js.map +1 -0
  111. package/lib/Pool.d.ts +66 -0
  112. package/lib/Pool.d.ts.map +1 -0
  113. package/lib/Pool.js +47 -0
  114. package/lib/Pool.js.map +1 -0
  115. package/lib/Providers.d.ts +19 -0
  116. package/lib/Providers.d.ts.map +1 -0
  117. package/lib/Providers.js +31 -0
  118. package/lib/Providers.js.map +1 -0
  119. package/lib/ShardGroups.d.ts +24 -0
  120. package/lib/ShardGroups.d.ts.map +1 -0
  121. package/lib/ShardGroups.js +19 -0
  122. package/lib/ShardGroups.js.map +1 -0
  123. package/lib/Singleton.d.ts +110 -0
  124. package/lib/Singleton.d.ts.map +1 -0
  125. package/lib/Singleton.js +60 -0
  126. package/lib/Singleton.js.map +1 -0
  127. package/lib/Workflow.d.ts +251 -0
  128. package/lib/Workflow.d.ts.map +1 -0
  129. package/lib/Workflow.js +288 -0
  130. package/lib/Workflow.js.map +1 -0
  131. package/lib/index.d.ts +12 -0
  132. package/lib/index.d.ts.map +1 -0
  133. package/lib/index.js +12 -0
  134. package/lib/index.js.map +1 -0
  135. package/lib/internal/Bootstrap.d.ts +28 -0
  136. package/lib/internal/Bootstrap.d.ts.map +1 -0
  137. package/lib/internal/Bootstrap.js +257 -0
  138. package/lib/internal/Bootstrap.js.map +1 -0
  139. package/lib/internal/EntityRuntime.d.ts +40 -0
  140. package/lib/internal/EntityRuntime.d.ts.map +1 -0
  141. package/lib/internal/EntityRuntime.js +698 -0
  142. package/lib/internal/EntityRuntime.js.map +1 -0
  143. package/lib/internal/Gateway.d.ts +82 -0
  144. package/lib/internal/Gateway.d.ts.map +1 -0
  145. package/lib/internal/Gateway.js +218 -0
  146. package/lib/internal/Gateway.js.map +1 -0
  147. package/lib/internal/HostedPlatform.d.ts +36 -0
  148. package/lib/internal/HostedPlatform.d.ts.map +1 -0
  149. package/lib/internal/HostedPlatform.js +36 -0
  150. package/lib/internal/HostedPlatform.js.map +1 -0
  151. package/lib/internal/HostedRuntime.d.ts +58 -0
  152. package/lib/internal/HostedRuntime.d.ts.map +1 -0
  153. package/lib/internal/HostedRuntime.js +72 -0
  154. package/lib/internal/HostedRuntime.js.map +1 -0
  155. package/lib/internal/Naming.d.ts +12 -0
  156. package/lib/internal/Naming.d.ts.map +1 -0
  157. package/lib/internal/Naming.js +40 -0
  158. package/lib/internal/Naming.js.map +1 -0
  159. package/lib/internal/ObjectSharding.d.ts +141 -0
  160. package/lib/internal/ObjectSharding.d.ts.map +1 -0
  161. package/lib/internal/ObjectSharding.js +337 -0
  162. package/lib/internal/ObjectSharding.js.map +1 -0
  163. package/lib/internal/PoolHost.d.ts +80 -0
  164. package/lib/internal/PoolHost.d.ts.map +1 -0
  165. package/lib/internal/PoolHost.js +203 -0
  166. package/lib/internal/PoolHost.js.map +1 -0
  167. package/lib/internal/PoolHostService.d.ts +25 -0
  168. package/lib/internal/PoolHostService.d.ts.map +1 -0
  169. package/lib/internal/PoolHostService.js +10 -0
  170. package/lib/internal/PoolHostService.js.map +1 -0
  171. package/lib/internal/Wire.d.ts +60 -0
  172. package/lib/internal/Wire.d.ts.map +1 -0
  173. package/lib/internal/Wire.js +36 -0
  174. package/lib/internal/Wire.js.map +1 -0
  175. package/lib/internal/WorkflowGateway.d.ts +36 -0
  176. package/lib/internal/WorkflowGateway.d.ts.map +1 -0
  177. package/lib/internal/WorkflowGateway.js +124 -0
  178. package/lib/internal/WorkflowGateway.js.map +1 -0
  179. package/lib/internal/external-runner.d.ts +2 -0
  180. package/lib/internal/external-runner.d.ts.map +1 -0
  181. package/lib/internal/external-runner.js +7 -0
  182. package/lib/internal/external-runner.js.map +1 -0
  183. package/lib/internal/runner.d.ts +2 -0
  184. package/lib/internal/runner.d.ts.map +1 -0
  185. package/lib/internal/runner.js +5 -0
  186. package/lib/internal/runner.js.map +1 -0
  187. package/package.json +143 -3
  188. package/src/AWS/Database.ts +139 -0
  189. package/src/AWS/EcsPool.ts +118 -0
  190. package/src/AWS/index.ts +6 -0
  191. package/src/AWS/internal/Gateway.ts +82 -0
  192. package/src/Cloudflare/DurableObjectPool.ts +198 -0
  193. package/src/Cloudflare/PoolWorker.ts +124 -0
  194. package/src/Cloudflare/index.ts +6 -0
  195. package/src/Cloudflare/internal/Objects.ts +160 -0
  196. package/src/Cron.ts +202 -0
  197. package/src/Database.ts +116 -0
  198. package/src/Docker/Database.ts +94 -0
  199. package/src/Docker/SwarmPool.ts +83 -0
  200. package/src/Docker/index.ts +6 -0
  201. package/src/Entity.ts +621 -0
  202. package/src/Fly/Database.ts +102 -0
  203. package/src/Fly/MachinePool.ts +72 -0
  204. package/src/Fly/index.ts +6 -0
  205. package/src/GCP/ComputeEnginePool.ts +203 -0
  206. package/src/GCP/Database.ts +168 -0
  207. package/src/GCP/index.ts +6 -0
  208. package/src/Hetzner/Database.ts +142 -0
  209. package/src/Hetzner/ServerPool.ts +116 -0
  210. package/src/Hetzner/index.ts +6 -0
  211. package/src/Host.ts +68 -0
  212. package/src/Kubernetes/Database.ts +89 -0
  213. package/src/Kubernetes/DeploymentPool.ts +77 -0
  214. package/src/Kubernetes/index.ts +6 -0
  215. package/src/Pool.ts +104 -0
  216. package/src/Providers.ts +49 -0
  217. package/src/ShardGroups.ts +41 -0
  218. package/src/Singleton.ts +153 -0
  219. package/src/Workflow.ts +556 -0
  220. package/src/index.ts +11 -0
  221. package/src/internal/Bootstrap.ts +368 -0
  222. package/src/internal/EntityRuntime.ts +996 -0
  223. package/src/internal/Gateway.ts +366 -0
  224. package/src/internal/HostedPlatform.ts +83 -0
  225. package/src/internal/HostedRuntime.ts +118 -0
  226. package/src/internal/Naming.ts +44 -0
  227. package/src/internal/ObjectSharding.ts +578 -0
  228. package/src/internal/PoolHost.ts +330 -0
  229. package/src/internal/PoolHostService.ts +25 -0
  230. package/src/internal/Wire.ts +83 -0
  231. package/src/internal/WorkflowGateway.ts +188 -0
  232. package/src/internal/external-runner.ts +10 -0
  233. package/src/internal/runner.ts +5 -0
@@ -0,0 +1,198 @@
1
+ import type * as Cloudflare from "@alchemy.run/cloudflare";
2
+ import { Worker as CloudflareWorker } from "@alchemy.run/cloudflare/Workers";
3
+ import { dotAlchemyDirectory } from "alchemy/AlchemyContext";
4
+ import * as Output from "alchemy/Output";
5
+ import { Random } from "alchemy/Random";
6
+ import { Self } from "alchemy/Self";
7
+ import type { Stack } from "alchemy/Stack";
8
+ import type * as Context from "effect/Context";
9
+ import * as Data from "effect/Data";
10
+ import * as Effect from "effect/Effect";
11
+ import * as FileSystem from "effect/FileSystem";
12
+ import * as Layer from "effect/Layer";
13
+ import * as Option from "effect/Option";
14
+ import * as Path from "effect/Path";
15
+ import * as Redacted from "effect/Redacted";
16
+ import type { Entity } from "../Entity.ts";
17
+ import { registerRouteBinder, routeType } from "../internal/Gateway.ts";
18
+ import type { HostedClass } from "../internal/HostedRuntime.ts";
19
+ import { HOSTED_TYPES, stackDnsLabel } from "../internal/Naming.ts";
20
+ import { PoolHost } from "../internal/PoolHostService.ts";
21
+ import { Pool as PoolResource, type PoolEntity } from "../Pool.ts";
22
+ import type { Providers } from "../Providers.ts";
23
+ import { poolWorker } from "./PoolWorker.ts";
24
+
25
+ // A Worker that calls a class this pool hosts gets a service binding to
26
+ // the pool's Worker: Workers can't fetch another Worker's workers.dev URL.
27
+ registerRouteBinder((key, worker) =>
28
+ Effect.gen(function* () {
29
+ if (globalThis.__ALCHEMY_RUNTIME__) return;
30
+ const caller = Option.getOrUndefined(
31
+ yield* Effect.serviceOption(
32
+ (CloudflareWorker as unknown as { Self: Context.Key<CloudflareWorker, CloudflareWorker> })
33
+ .Self,
34
+ ),
35
+ );
36
+ if (caller === undefined) return;
37
+ yield* caller.bind`${key}`({
38
+ bindings: [{ type: "service", name: key, service: worker as string }],
39
+ });
40
+ }),
41
+ );
42
+
43
+ export interface DurableObjectPoolOptions {
44
+ /**
45
+ * The name the pool is known by, used for its Worker's entry.
46
+ * @default ${stack}-${stage}-${id}
47
+ */
48
+ name?: string;
49
+ }
50
+
51
+ /** An external program needs runners that run Node, which Workers don't. */
52
+ export class ExternalProgramOnWorkers extends Data.TaggedError("Cluster.ExternalProgramOnWorkers")<{
53
+ readonly message: string;
54
+ }> {}
55
+
56
+ /**
57
+ * Write the Worker's entry module: it imports every class the pool hosts
58
+ * and default-exports the pool Worker with them, so the Worker's own
59
+ * bundler bundles them all.
60
+ */
61
+ const writeEntry = Effect.fn(function* (id: string, workerId: string, pool: PoolResource) {
62
+ const fs = yield* FileSystem.FileSystem;
63
+ const path = yield* Path.Path;
64
+ const dir = path.resolve(yield* dotAlchemyDirectory, "cluster", yield* stackDnsLabel(id));
65
+ return Output.mapEffect((entities: PoolEntity[]) =>
66
+ Effect.gen(function* () {
67
+ yield* fs.makeDirectory(dir, { recursive: true });
68
+ const file = path.join(dir, "worker.ts");
69
+ const source = [
70
+ `import { poolWorker } from "@alchemy.run/effect-cluster/Cloudflare/PoolWorker";`,
71
+ ...entities.map(
72
+ (entity, i) =>
73
+ `import { ${entity.handler} as e${i} } from ${JSON.stringify(
74
+ entity.module.startsWith("file:") ? new URL(entity.module).pathname : entity.module,
75
+ )};`,
76
+ ),
77
+ `export default poolWorker(${JSON.stringify(workerId)}, undefined, [${entities
78
+ .map((entity, i) => `{ name: ${JSON.stringify(entity.name)}, value: e${i} }`)
79
+ .join(", ")}]);`,
80
+ "",
81
+ ].join("\n");
82
+ yield* fs.writeFileString(file, source);
83
+ return file;
84
+ }).pipe(Effect.orDie),
85
+ )(pool.entities);
86
+ });
87
+
88
+ /**
89
+ * Run an Effect Cluster on Cloudflare: one Worker that hosts every class
90
+ * given this pool on Durable Objects. Each entity instance is a Durable
91
+ * Object named by its type and id, so there are no shards, no runners to
92
+ * place it on, and no database: the object's own SQLite holds its persisted
93
+ * messages. Each workflow execution is a Durable Object too, and each
94
+ * singleton and cron one that its alarm keeps awake.
95
+ *
96
+ * Callers anywhere reach the entities through the Worker's gateway; inside
97
+ * the Worker, one entity calls another's object directly.
98
+ *
99
+ * ### Running entities on Cloudflare
100
+ * **Example:** Runners on Durable Objects
101
+ * ```typescript
102
+ * export const Default = ClusterCloudflare.DurableObjectPool("Default");
103
+ * ```
104
+ *
105
+ * @primitive
106
+ */
107
+ export const DurableObjectPool = (
108
+ id: string,
109
+ options: DurableObjectPoolOptions = {},
110
+ ): Layer.Layer<PoolHost, never, Stack | Providers | Cloudflare.Providers> =>
111
+ Layer.effect(
112
+ PoolHost,
113
+ Effect.gen(function* () {
114
+ // What was provided to the pool, like an entity's RPC middleware,
115
+ // goes with every class they host.
116
+ const services = yield* Effect.context<never>();
117
+ const self = Option.getOrUndefined(yield* Effect.serviceOption(Self));
118
+ const hostsSelf = self !== undefined && HOSTED_TYPES.has(self.Type);
119
+ if (globalThis.__ALCHEMY_RUNTIME__) {
120
+ if (hostsSelf) yield* (self as unknown as HostedClass).provideServices(services);
121
+ return {
122
+ host: (hosted) => hosted.provideServices(services),
123
+ deployExternal: () => Effect.void,
124
+ } satisfies PoolHost["Service"];
125
+ }
126
+ const name = options.name ?? (yield* stackDnsLabel(id));
127
+ const workerId = `${id}Pool`;
128
+ const context = yield* Effect.context<Stack | Providers | Cloudflare.Providers>();
129
+
130
+ /** Put `hosted` on the pool, and bind the gateway onto `resource`. */
131
+ const deploy = Effect.fn(function* (
132
+ hosted: PoolEntity,
133
+ env: Record<string, unknown>,
134
+ resource: Entity,
135
+ ) {
136
+ const pool = yield* PoolResource(id, { name });
137
+ const token = yield* Random(`${id}GatewayToken`, {
138
+ bytes: 32,
139
+ }) as unknown as Effect.Effect<Random, never, Providers>;
140
+ yield* pool.bind`${hosted.name}`({ entities: [hosted], env });
141
+ const worker = yield* poolWorker(
142
+ workerId,
143
+ {
144
+ main: (yield* writeEntry(id, workerId, pool)) as unknown as string,
145
+ compatibility: { flags: ["nodejs_compat"] },
146
+ env: {
147
+ // The classes' own environment, which only the deploy knows
148
+ // the keys of: one secret, unpacked when the Worker starts.
149
+ CLUSTER_ENV: pool.env.pipe(
150
+ Output.map((values) => Redacted.make(JSON.stringify(values))),
151
+ ),
152
+ CLUSTER_POOL: name,
153
+ CLUSTER_GATEWAY_TOKEN: token.text,
154
+ } as unknown as Record<string, string>,
155
+ },
156
+ [],
157
+ );
158
+ // Callers that are Workers reach the gateway through a service binding.
159
+ routeType(hosted.name, worker.workerName);
160
+ // What callers need to reach the entity through the gateway.
161
+ yield* resource.bind`Pool`({
162
+ pool: name,
163
+ gateway: worker.url as unknown as string,
164
+ token: token.text,
165
+ });
166
+ }, Effect.provide(context));
167
+
168
+ const host = (hosted: HostedClass) =>
169
+ Effect.andThen(
170
+ hosted.provideServices(services),
171
+ hosted.registerPool((env) =>
172
+ deploy(
173
+ {
174
+ name: hosted.LogicalId,
175
+ module: hosted.Props.main,
176
+ handler: hosted.Props.handler ?? "default",
177
+ },
178
+ env,
179
+ hosted as unknown as Entity,
180
+ ),
181
+ ),
182
+ );
183
+
184
+ if (hostsSelf) yield* host(self as unknown as HostedClass);
185
+
186
+ return {
187
+ host,
188
+ deployExternal: ({ type }) =>
189
+ Effect.die(
190
+ new ExternalProgramOnWorkers({
191
+ message:
192
+ `Cluster.Entity '${type}' runs an existing Effect Cluster program, which needs Node: ` +
193
+ `give it a pool on another host, like ClusterDocker.SwarmPool or ClusterAWS.EcsPool.`,
194
+ }),
195
+ ),
196
+ } satisfies PoolHost["Service"];
197
+ }),
198
+ );
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The Worker a `ClusterCloudflare.DurableObjectPool` Layer deploys. While deploying it
3
+ * declares the Durable Object classes every cluster uses; inside the
4
+ * deployed Worker it also builds each class the pool hosts, registers
5
+ * them for those objects, and serves the gateway callers reach them through.
6
+ *
7
+ * The pool Layer declares it, and the entry module it generates for the
8
+ * Worker's bundle default-exports it with the hosted classes imported.
9
+ *
10
+ * @internal
11
+ */
12
+ import * as Cloudflare from "@alchemy.run/cloudflare/Workers";
13
+ import type { InputProps } from "alchemy/Input";
14
+ import * as Config from "effect/Config";
15
+ import * as Effect from "effect/Effect";
16
+ import * as HttpServerResponse from "effect/http/HttpServerResponse";
17
+ import * as Layer from "effect/Layer";
18
+ import * as Redacted from "effect/Redacted";
19
+ import { RpcSerialization, RpcServer } from "effect/rpc";
20
+ import * as Scope from "effect/Scope";
21
+ import { authorized, gatewayServer } from "../internal/Gateway.ts";
22
+ import {
23
+ type HostedDefinition,
24
+ hostClasses,
25
+ makeObjectSharding,
26
+ type Namespace,
27
+ type ScheduledObjectStub,
28
+ wakeScheduled,
29
+ } from "../internal/ObjectSharding.ts";
30
+ import {
31
+ EntityObject,
32
+ host,
33
+ ScheduledObject,
34
+ WorkflowObject,
35
+ workflowEngine,
36
+ } from "./internal/Objects.ts";
37
+
38
+ export type { HostedDefinition } from "../internal/ObjectSharding.ts";
39
+
40
+ /**
41
+ * The pool's Worker `id`. `definitions` are the classes it hosts, which
42
+ * only the deployed Worker builds; `props` are its props, which only the
43
+ * deploy reads.
44
+ */
45
+ export const poolWorker = (
46
+ id: string,
47
+ props: InputProps<Cloudflare.WorkerProps> | undefined,
48
+ definitions: ReadonlyArray<HostedDefinition>,
49
+ ) =>
50
+ Cloudflare.Worker(
51
+ id,
52
+ props ?? {},
53
+ Effect.gen(function* () {
54
+ const entities = yield* EntityObject;
55
+ const scheduled = yield* ScheduledObject;
56
+ const workflows = yield* WorkflowObject;
57
+ // A Cron Trigger wakes every singleton and cron each minute, in case
58
+ // their alarms stopped.
59
+ yield* Cloudflare.cron("* * * * *", () => wakeScheduled(scheduled));
60
+ if (!globalThis.__ALCHEMY_RUNTIME__) {
61
+ return { fetch: Effect.succeed(HttpServerResponse.empty({ status: 404 })) };
62
+ }
63
+ // A class that fails to build fails every call, with its cause in the
64
+ // Worker's logs rather than in a response anyone can read.
65
+ return yield* serve(definitions, entities, scheduled, workflows).pipe(
66
+ Effect.tapCause((cause) => Effect.logError("Effect Cluster pool failed to start", cause)),
67
+ Effect.catchCause(() =>
68
+ Effect.succeed({
69
+ fetch: Effect.succeed(
70
+ HttpServerResponse.text(
71
+ "Effect Cluster pool failed to start; see the Worker's logs.",
72
+ {
73
+ status: 503,
74
+ },
75
+ ),
76
+ ),
77
+ }),
78
+ ),
79
+ );
80
+ }).pipe(Effect.provide(Cloudflare.CronEventSourceLive)),
81
+ );
82
+
83
+ const serve = Effect.fn(function* (
84
+ definitions: ReadonlyArray<HostedDefinition>,
85
+ entities: unknown,
86
+ scheduled: Namespace<ScheduledObjectStub>,
87
+ workflows: unknown,
88
+ ) {
89
+ // The classes' own environment, which the deploy packs into one secret.
90
+ const packed = yield* Config.String("CLUSTER_ENV").pipe(Config.withDefault(""));
91
+ if (packed) yield* Effect.sync(() => Object.assign(globalThis.process.env, JSON.parse(packed)));
92
+ // Built in the isolate's own scope: once, and never closed (workerd has
93
+ // no teardown hook). Workflows run on the Durable Object engine.
94
+ const hosted = yield* hostClasses({
95
+ definitions,
96
+ scope: Scope.makeUnsafe(),
97
+ sharding: makeObjectSharding(() => entities as never, host),
98
+ engine: workflowEngine(() => workflows as never),
99
+ });
100
+
101
+ const token = Redacted.value(yield* Config.Redacted("CLUSTER_GATEWAY_TOKEN"));
102
+ const { group, handlers } = gatewayServer(
103
+ hosted.entities.map((entry) => entry.entity),
104
+ new Map(hosted.workflows.map((entry) => [entry.workflow._tag, entry.workflow])),
105
+ );
106
+ let woken = false;
107
+ return {
108
+ fetch: authorized(token)(
109
+ Effect.gen(function* () {
110
+ // The first call this isolate serves starts the singletons and
111
+ // crons, so they run without waiting for the Cron Trigger.
112
+ if (!woken) {
113
+ woken = true;
114
+ yield* wakeScheduled(scheduled);
115
+ }
116
+ const app = yield* RpcServer.toHttpEffect(group).pipe(
117
+ Effect.provide(Layer.mergeAll(handlers, RpcSerialization.layerJson)),
118
+ Effect.provideContext(hosted.services),
119
+ );
120
+ return yield* app;
121
+ }),
122
+ ),
123
+ };
124
+ });
@@ -0,0 +1,6 @@
1
+ /**
2
+ * @alchemy.run/effect-cluster/Cloudflare — run an Effect Cluster's entities,
3
+ * workflows, singletons and crons on Cloudflare Durable Objects. Requires
4
+ * `@alchemy.run/cloudflare`.
5
+ */
6
+ export * from "./DurableObjectPool.ts";
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The Durable Object classes a `ClusterCloudflare.DurableObjectPool` Worker
3
+ * exports. Every entity type shares one class, `ClusterEntity`, whose objects
4
+ * are named by `(type, id)`; every workflow shares `ClusterWorkflow`, one
5
+ * object per execution; every singleton and cron shares `ClusterScheduled`,
6
+ * one object per name. What each object runs is host-neutral (see
7
+ * `internal/ObjectSharding.ts`); this module gives it workerd's storage.
8
+ *
9
+ * @internal
10
+ */
11
+ import * as Cloudflare from "@alchemy.run/cloudflare/Workers";
12
+ import type { RuntimeContext } from "alchemy/RuntimeContext";
13
+ import { makeDurableObjectEngine, type ObjectImpl } from "alchemy/Workers/DurableObjectEngine";
14
+ import * as Context from "effect/Context";
15
+ import * as Effect from "effect/Effect";
16
+ import * as Predicate from "effect/Predicate";
17
+ import { InstanceStorage } from "../../Host.ts";
18
+ import type { EntityStorage } from "../../internal/EntityRuntime.ts";
19
+ import {
20
+ type EntityObjectStub,
21
+ makeEntityObject,
22
+ makeScheduledObject,
23
+ type Namespace,
24
+ NotHosted,
25
+ type ObjectHost,
26
+ registry,
27
+ type ScheduledObjectStub,
28
+ workflowClassOf,
29
+ workflowObjectName,
30
+ } from "../../internal/ObjectSharding.ts";
31
+
32
+ /** Cloudflare, as a host of the cluster's objects. */
33
+ export const host: ObjectHost = {
34
+ group: "cloudflare",
35
+ // workerd marks a transient failure `retryable`, and an `overloaded`
36
+ // object wants less traffic, not more.
37
+ isRetryable: (error) =>
38
+ Predicate.hasProperty(error, "cause") &&
39
+ Predicate.hasProperty(error.cause, "retryable") &&
40
+ error.cause.retryable === true &&
41
+ !(Predicate.hasProperty(error.cause, "overloaded") && error.cause.overloaded === true),
42
+ };
43
+
44
+ /** The most bytes workerd's SQLite stores in one value. */
45
+ const MAX_ENCODED_BYTES = 2 * 1024 * 1024;
46
+
47
+ /** workerd's own storage: its SQL is synchronous. */
48
+ interface NativeStorage {
49
+ readonly sql: {
50
+ readonly exec: (
51
+ query: string,
52
+ ...bindings: ReadonlyArray<unknown>
53
+ ) => { readonly toArray: () => Array<unknown> };
54
+ };
55
+ readonly transactionSync: <A>(run: () => A) => A;
56
+ readonly getAlarm: () => Promise<number | null>;
57
+ readonly setAlarm: (at: number) => Promise<void>;
58
+ }
59
+
60
+ const objectName = (state: Cloudflare.DurableObjectState["Service"]) => state.id.name ?? "";
61
+
62
+ /**
63
+ * The object's storage, called directly: a statement is one synchronous
64
+ * call, so the mailbox costs no event-loop turns.
65
+ */
66
+ const storageOf = (state: Cloudflare.DurableObjectState["Service"]): EntityStorage => {
67
+ const storage = state.raw.storage as unknown as NativeStorage;
68
+ return {
69
+ exec: <T>(query: string, ...bindings: ReadonlyArray<unknown>) =>
70
+ Effect.sync(() => storage.sql.exec(query, ...bindings).toArray() as Array<T>),
71
+ // Every statement is synchronous, so the transaction is too.
72
+ transaction: (effect) =>
73
+ Effect.sync(() => storage.transactionSync(() => Effect.runSync(effect))),
74
+ getAlarm: Effect.promise(() => storage.getAlarm()),
75
+ setAlarm: (at) => Effect.promise(() => storage.setAlarm(at)),
76
+ };
77
+ };
78
+
79
+ /** Every entity instance: one object per `(type, id)`. */
80
+ export const EntityObject = Cloudflare.DurableObject<EntityObjectStub>()(
81
+ "ClusterEntity",
82
+ Effect.gen(function* () {
83
+ const state = yield* Cloudflare.DurableObjectState;
84
+ return Effect.gen(function* () {
85
+ const runtime = yield* Effect.context<RuntimeContext>();
86
+ return yield* makeEntityObject({
87
+ name: objectName(state),
88
+ host,
89
+ storage: storageOf(state),
90
+ // The object's own key-value storage, for platforms built on
91
+ // entities (a Domain model's) to keep each instance's data in.
92
+ runtime: Context.add(
93
+ runtime,
94
+ InstanceStorage,
95
+ state.storage as unknown as InstanceStorage["Service"],
96
+ ) as Context.Context<RuntimeContext>,
97
+ maxEncodedBytes: MAX_ENCODED_BYTES,
98
+ waitUntil: (effect) => state.waitUntil(effect).pipe(Effect.provideContext(runtime)),
99
+ });
100
+ });
101
+ }),
102
+ );
103
+
104
+ /** Every singleton and cron: one object per name. */
105
+ export const ScheduledObject = Cloudflare.DurableObject<ScheduledObjectStub>()(
106
+ "ClusterScheduled",
107
+ Effect.gen(function* () {
108
+ const state = yield* Cloudflare.DurableObjectState;
109
+ return Effect.gen(function* () {
110
+ const runtime = yield* Effect.context<RuntimeContext>();
111
+ return yield* makeScheduledObject({
112
+ name: objectName(state),
113
+ storage: storageOf(state),
114
+ runtime,
115
+ });
116
+ });
117
+ }),
118
+ );
119
+
120
+ /**
121
+ * Every workflow execution: one object per `(workflow, execution)`, running
122
+ * Alchemy's Durable Object workflow engine. The engine declares a class per
123
+ * workflow; here each of those is a view onto this one class, so the Worker
124
+ * exports the same classes whatever workflows it hosts.
125
+ */
126
+ export const WorkflowObject = Cloudflare.DurableObject<Record<string, never>>()(
127
+ "ClusterWorkflow",
128
+ Effect.gen(function* () {
129
+ const state = yield* Cloudflare.DurableObjectState;
130
+ return Effect.gen(function* () {
131
+ const className = workflowClassOf(objectName(state));
132
+ const impl = registry.workflows.get(className) as
133
+ | ObjectImpl<Cloudflare.DurableObjectState>
134
+ | undefined;
135
+ if (impl === undefined) {
136
+ return yield* Effect.die(
137
+ new NotHosted({ message: `This Worker hosts no Cluster.Workflow '${className}'.` }),
138
+ );
139
+ }
140
+ const object = yield* impl.pipe(Effect.provideService(Cloudflare.DurableObjectState, state));
141
+ return (yield* object.pipe(
142
+ Effect.provideService(Cloudflare.DurableObjectState, state),
143
+ )) as Record<string, never>;
144
+ });
145
+ }),
146
+ );
147
+
148
+ /** The workflow engine, its executions on {@link WorkflowObject}. */
149
+ export const workflowEngine = (objects: () => Namespace<unknown>) =>
150
+ makeDurableObjectEngine<never, Cloudflare.DurableObjectState>({
151
+ declare: (className, impl) =>
152
+ Effect.sync(() => {
153
+ registry.workflows.set(className, impl);
154
+ return {
155
+ getByName: (executionId: string) =>
156
+ objects().getByName(workflowObjectName(className, executionId)) as never,
157
+ };
158
+ }),
159
+ state: Cloudflare.DurableObjectState,
160
+ });
package/src/Cron.ts ADDED
@@ -0,0 +1,202 @@
1
+ import type { InputProps } from "alchemy/Input";
2
+ import type { Named } from "alchemy/Named";
3
+ import type { PlatformIdentity, PlatformServices } from "alchemy/Platform";
4
+ import type { RuntimeContext } from "alchemy/RuntimeContext";
5
+ import type { Self } from "alchemy/Self";
6
+ import type { ClusterCron } from "effect/cluster";
7
+ import type * as ConfigError from "effect/Config";
8
+ import type * as Context from "effect/Context";
9
+ import * as Cron_ from "effect/Cron";
10
+ import * as Data from "effect/Data";
11
+ import type * as Duration from "effect/Duration";
12
+ import * as Effect from "effect/Effect";
13
+ import type * as Layer from "effect/Layer";
14
+ import * as Result from "effect/Result";
15
+ import type { Scope } from "effect/Scope";
16
+ import {
17
+ type HostedProps,
18
+ type HostedResource,
19
+ makeHostedPlatform,
20
+ } from "./internal/HostedPlatform.ts";
21
+ import type { Providers } from "./Providers.ts";
22
+
23
+ export const CronTypeId = "Cluster.Cron";
24
+ export type CronTypeId = typeof CronTypeId;
25
+
26
+ /** When a cron runs: its Layer's props, like Effect's `ClusterCron.make` options. */
27
+ export interface CronProps extends HostedProps {
28
+ /**
29
+ * The schedule, as a cron expression with an optional leading seconds
30
+ * field: `"0 3 * * *"`, `"*\/10 * * * * *"`.
31
+ */
32
+ cron: string;
33
+ /** The time zone the expression is read in. @default UTC */
34
+ timeZone?: string;
35
+ /**
36
+ * Compute each run from the previous run's scheduled time rather than
37
+ * from when it finished.
38
+ * @default false
39
+ */
40
+ calculateNextRunFromPrevious?: boolean;
41
+ /** Skip a run that is due longer ago than this, as after an outage. */
42
+ skipIfOlderThan?: Duration.Input;
43
+ }
44
+
45
+ export interface ClusterCronResource extends HostedResource<CronTypeId, CronProps> {}
46
+
47
+ /**
48
+ * A cron as a runner runs it: its schedule, the job its Construction phase
49
+ * returned, and the services that phase ran with.
50
+ * @internal
51
+ */
52
+ export interface HostedCron {
53
+ readonly kind: "cron";
54
+ readonly name: string;
55
+ readonly cron: Cron_.Cron;
56
+ readonly options: Pick<CronProps, "calculateNextRunFromPrevious" | "skipIfOlderThan">;
57
+ readonly run: Effect.Effect<void, unknown, Scope | ClusterCron.CurrentScheduledTime>;
58
+ readonly context: Context.Context<never>;
59
+ }
60
+
61
+ /** A cron expression that doesn't parse. */
62
+ export class InvalidCron extends Data.TaggedError("Cluster.InvalidCron")<{
63
+ readonly message: string;
64
+ }> {}
65
+
66
+ type CronServices =
67
+ | ClusterCronResource
68
+ | Providers
69
+ | RuntimeContext
70
+ | Self
71
+ | Self<ClusterCronResource>;
72
+
73
+ const { platform, provider, declare } = makeHostedPlatform<CronTypeId, HostedCron, CronProps>(
74
+ CronTypeId,
75
+ (id, run, context, props) => ({
76
+ kind: "cron",
77
+ name: id,
78
+ // Checked while deploying, so it parses.
79
+ cron: (Cron_.parse(props.cron, props.timeZone) as Result.Success<Cron_.Cron, never>).success,
80
+ options: {
81
+ calculateNextRunFromPrevious: props.calculateNextRunFromPrevious,
82
+ skipIfOlderThan: props.skipIfOlderThan,
83
+ },
84
+ run: run as HostedCron["run"],
85
+ context,
86
+ }),
87
+ // A schedule that doesn't parse fails the deploy, not the runner.
88
+ (id, _context, props) => {
89
+ const cron = Cron_.parse(props.cron, props.timeZone);
90
+ return Result.isSuccess(cron)
91
+ ? Effect.void
92
+ : Effect.die(new InvalidCron({ message: `Cluster.Cron '${id}': ${cron.failure.message}` }));
93
+ },
94
+ );
95
+
96
+ /** The resource type behind every cron, which the providers register. @internal */
97
+ export const CronResource = platform;
98
+ export const CronProvider = provider;
99
+
100
+ /** A declared cron, implemented by `.make(props, impl)`. */
101
+ export type CronTag<Self, Name extends string> = Named<Name> &
102
+ PlatformIdentity<Name> & {
103
+ /**
104
+ * The cron's implementation, as a Layer: its schedule, and an Effect
105
+ * that resolves bindings once per runner and returns the job to run.
106
+ * Provide a pool Layer to `impl` to deploy it; provide the returned
107
+ * Layer to the Stack.
108
+ */
109
+ make<InitReq = never>(
110
+ props: InputProps<CronProps>,
111
+ impl: Effect.Effect<
112
+ Effect.Effect<void, unknown, Scope | ClusterCron.CurrentScheduledTime>,
113
+ ConfigError.ConfigError,
114
+ InitReq
115
+ >,
116
+ ): Layer.Layer<Self, never, Providers | Exclude<InitReq, CronServices | PlatformServices>>;
117
+ new (_: never): {};
118
+ };
119
+
120
+ /** A cron declared with its implementation: the class is what deploys it. */
121
+ export type CronInline<Name extends string, Req> = Effect.Effect<ClusterCronResource, never, Req> &
122
+ Named<Name> &
123
+ PlatformIdentity<Name> & {
124
+ new (_: never): {};
125
+ };
126
+
127
+ /** The class surface of {@link Cron}. */
128
+ export interface CronClass {
129
+ // `Self` names the class (`extends Cluster.Cron<Nightly>()`).
130
+ <Self>(): {
131
+ /**
132
+ * Declared with its implementation: an Effect that resolves bindings
133
+ * once per runner and returns the job. Provide a pool Layer to
134
+ * `impl` to deploy it; `yield*` the class in the Stack.
135
+ */
136
+ <const Name extends string, InitReq = never>(
137
+ name: Name,
138
+ props: InputProps<CronProps>,
139
+ impl: Effect.Effect<
140
+ Effect.Effect<void, unknown, Scope | ClusterCron.CurrentScheduledTime>,
141
+ ConfigError.ConfigError,
142
+ InitReq
143
+ >,
144
+ ): CronInline<Name, Providers | Exclude<InitReq, CronServices | PlatformServices>>;
145
+ /** A bare declaration, implemented by `.make(props, impl)`. */
146
+ <const Name extends string>(name: Name): CronTag<Self, Name>;
147
+ };
148
+ }
149
+
150
+ /**
151
+ * A **Cron** on an Effect Cluster: a job that runs on a schedule, once per
152
+ * scheduled time across the cluster, on whichever runner owns it. Each run
153
+ * is a persisted message, so a run due while its runner restarts still
154
+ * happens.
155
+ *
156
+ * Declare it by name, with its schedule, as Effect's `ClusterCron.make`
157
+ * takes it, and its implementation: an Effect that resolves bindings once
158
+ * per runner and returns the job. The job can read the time it was
159
+ * scheduled for with `ClusterCron.CurrentScheduledTime`. Providing a pool
160
+ * Layer to the implementation is what deploys it. Or declare it bare and
161
+ * implement it with `.make(props, impl)`, as a Layer.
162
+ *
163
+ * ### Declaring a cron
164
+ * **Example:** A nightly job
165
+ * ```typescript
166
+ * // src/Nightly.ts
167
+ * export default class Nightly extends Cluster.Cron<Nightly>()(
168
+ * "Nightly",
169
+ * { main: import.meta.url, cron: "0 3 * * *", skipIfOlderThan: "1 hour" },
170
+ * Effect.gen(function* () {
171
+ * const counters = yield* Counter;
172
+ * return Effect.gen(function* () {
173
+ * const counter = yield* counters.getByName("nightly");
174
+ * yield* counter.Increment({ amount: 1 });
175
+ * }).pipe(Effect.orDie);
176
+ * }).pipe(Effect.provide(Default)),
177
+ * ) {}
178
+ * ```
179
+ *
180
+ * **Example:** Declared bare, and implemented as a Layer
181
+ * ```typescript
182
+ * // src/Nightly.ts
183
+ * export class Nightly extends Cluster.Cron<Nightly>()("Nightly") {}
184
+ *
185
+ * // src/NightlyLive.ts
186
+ * export default Nightly.make(
187
+ * { main: import.meta.url, cron: "0 3 * * *", skipIfOlderThan: "1 hour" },
188
+ * Effect.gen(function* () {
189
+ * const counters = yield* Counter;
190
+ * return Effect.gen(function* () {
191
+ * const counter = yield* counters.getByName("nightly");
192
+ * yield* counter.Increment({ amount: 1 });
193
+ * }).pipe(Effect.orDie);
194
+ * }).pipe(Effect.provide(Default)),
195
+ * );
196
+ * ```
197
+ *
198
+ * @resource
199
+ */
200
+ export const Cron: CronClass = (() =>
201
+ (name: string, props?: InputProps<CronProps>, impl?: unknown) =>
202
+ declare(name, {}, props, impl)) as unknown as CronClass;