@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,330 @@
1
+ /**
2
+ * What every pool Layer shares, whatever runs its pool: register the
3
+ * entity whose Construction phase is running with the pool resource, bind
4
+ * what callers need onto the entity, and bundle every class the pool hosts
5
+ * into one Node program. A host only declares the service that runs it.
6
+ *
7
+ * @internal
8
+ */
9
+ import { createHash } from "node:crypto";
10
+ import { fileURLToPath } from "node:url";
11
+ import { dotAlchemyDirectory } from "alchemy/AlchemyContext";
12
+ import * as Bundle from "alchemy/Bundle/Bundle";
13
+ import type { Input } from "alchemy/Input";
14
+ import * as Output from "alchemy/Output";
15
+ import { Random } from "alchemy/Random";
16
+ import { Self } from "alchemy/Self";
17
+ import { Stack } from "alchemy/Stack";
18
+ import * as Data from "effect/Data";
19
+ import * as Effect from "effect/Effect";
20
+ import * as FileSystem from "effect/FileSystem";
21
+ import * as Layer from "effect/Layer";
22
+ import * as Option from "effect/Option";
23
+ import * as Path from "effect/Path";
24
+ import * as Redacted from "effect/Redacted";
25
+ import { Database, type DatabaseShape } from "../Database.ts";
26
+ import { shardGroupKey, type Entity } from "../Entity.ts";
27
+ import { Pool, type PoolEntity } from "../Pool.ts";
28
+ import type { Providers } from "../Providers.ts";
29
+ import { GATEWAY_PORT, RUNNER_PORT } from "./Gateway.ts";
30
+ import type { HostedClass } from "./HostedRuntime.ts";
31
+ import { HOSTED_TYPES, shortLabel, stackDnsLabel } from "./Naming.ts";
32
+ import { PoolHost } from "./PoolHostService.ts";
33
+
34
+ const VIRTUAL = "virtual:cluster-entities";
35
+
36
+ /** Resolve {@link VIRTUAL} to a module that imports every class the pool hosts. */
37
+ const entitiesPlugin = (entities: ReadonlyArray<PoolEntity>) => {
38
+ const source = [
39
+ ...entities.map(
40
+ (entity, i) =>
41
+ `import { ${entity.handler} as e${i} } from ${JSON.stringify(
42
+ entity.module.startsWith("file:") ? fileURLToPath(entity.module) : entity.module,
43
+ )};`,
44
+ ),
45
+ `export default [${entities
46
+ .map((entity, i) => `{ name: ${JSON.stringify(entity.name)}, value: e${i} }`)
47
+ .join(", ")}];`,
48
+ ].join("\n");
49
+ return [
50
+ {
51
+ name: "cluster-entities",
52
+ resolveId: (id: string) => (id === VIRTUAL ? `\0${VIRTUAL}` : null),
53
+ load: (id: string) => (id === `\0${VIRTUAL}` ? source : null),
54
+ },
55
+ ];
56
+ };
57
+
58
+ /** The pool was given a database whose network it cannot join. */
59
+ export class DatabaseNetworkMismatch extends Data.TaggedError("Cluster.DatabaseNetworkMismatch")<{
60
+ readonly message: string;
61
+ }> {}
62
+
63
+ export interface PoolSettings {
64
+ /**
65
+ * The name the pool is known by. It is also the shard group the
66
+ * entities they host are placed in.
67
+ * @default ${stack}-${stage}-${id}
68
+ */
69
+ name?: string;
70
+ /**
71
+ * The shard groups an external program's entities use, which these
72
+ * runners serve. Entities declared with Alchemy always use the pool's
73
+ * own group.
74
+ * @default ["default"]
75
+ */
76
+ shardGroups?: string[];
77
+ }
78
+
79
+ /** What a host needs to declare the service that runs the pool. */
80
+ export interface PoolNodes<Network, Gateway extends PoolGateway = PoolGateway> {
81
+ /** What the host declared for the gateway (e.g. a load balancer to register with). */
82
+ readonly gateway: Gateway;
83
+ readonly database: DatabaseShape;
84
+ readonly network: Network;
85
+ readonly pool: Pool;
86
+ /** The pool's name, a DNS label. */
87
+ readonly name: string;
88
+ /**
89
+ * The bundled program: a directory holding `runner.mjs` with a
90
+ * `Dockerfile`, and a hash of its
91
+ * contents, which changes whenever the program does.
92
+ */
93
+ readonly program: Output.Output<{
94
+ readonly dir: string;
95
+ readonly hash: string;
96
+ }>;
97
+ /** Environment every runner needs, as plain strings. */
98
+ readonly environment: Output.Output<Record<string, string>>;
99
+ }
100
+
101
+ /** What a host binds onto each entity so callers can reach the gateway. */
102
+ export interface PoolGateway {
103
+ readonly url: Input<string>;
104
+ }
105
+
106
+ const output = <T>(value: Input<T>): Output.Output<T> =>
107
+ (Output.isOutput(value) ? value : Output.literal(value)) as Output.Output<T>;
108
+
109
+ /** The Dockerfile every host builds the runner image from. */
110
+ export const RUNNER_DOCKERFILE = `FROM node:26-slim
111
+ WORKDIR /app
112
+ COPY *.mjs /app/
113
+ ENV NODE_ENV=production
114
+ EXPOSE ${RUNNER_PORT} ${GATEWAY_PORT}
115
+ ENTRYPOINT ["node", "/app/runner.mjs"]
116
+ `;
117
+
118
+ /**
119
+ * A pool Layer: `network` narrows the database's network to the one this
120
+ * host can join; `gateway` names where callers reach the pool;
121
+ * `nodes` declares the service that runs them.
122
+ */
123
+ export const makePoolLayer = <Network, Req, Gateway extends PoolGateway = PoolGateway>(options: {
124
+ readonly label: string;
125
+ readonly id: string;
126
+ readonly settings: PoolSettings;
127
+ readonly network: (database: DatabaseShape) => Network | undefined;
128
+ readonly expected: string;
129
+ /** The longest runner name the host accepts. @default 63 */
130
+ readonly nameLength?: number;
131
+ /** The runner image's Dockerfile, when the host needs more than {@link RUNNER_DOCKERFILE}. */
132
+ readonly dockerfile?: string;
133
+ /** Where callers reach the gateway, declaring what it needs; before the pool's service. */
134
+ readonly gateway: (name: string, network: Network) => Effect.Effect<Gateway, never, Req>;
135
+ readonly nodes: (nodes: PoolNodes<Network, Gateway>) => Effect.Effect<void, never, Req>;
136
+ }): Layer.Layer<PoolHost, never, Database | Stack | Providers | Req> =>
137
+ Layer.effect(
138
+ PoolHost,
139
+ Effect.gen(function* () {
140
+ const database = yield* Database;
141
+ // What was provided to the pool, like an entity's RPC middleware,
142
+ // goes with every class they host.
143
+ const services = yield* Effect.context<never>();
144
+ const self = Option.getOrUndefined(yield* Effect.serviceOption(Self));
145
+ const hostsSelf = self !== undefined && HOSTED_TYPES.has(self.Type);
146
+ if (globalThis.__ALCHEMY_RUNTIME__) {
147
+ if (hostsSelf) yield* (self as unknown as HostedClass).provideServices(services);
148
+ return {
149
+ host: (hosted) => hosted.provideServices(services),
150
+ deployExternal: () => Effect.void,
151
+ } satisfies PoolHost["Service"];
152
+ }
153
+ const network = options.network(database);
154
+ if (network === undefined) {
155
+ return yield* Effect.die(
156
+ new DatabaseNetworkMismatch({
157
+ message: `${options.label} runs runners on ${options.expected}, and its database is not on one.`,
158
+ }),
159
+ );
160
+ }
161
+ // Named up front, so an entity knows its pool without depending on
162
+ // the pool resource, whose bindings carry every entity's env.
163
+ const name = options.settings.name ?? (yield* stackDnsLabel(options.id, options.nameLength));
164
+ // A shard group only has to be unique within its database, and must
165
+ // never be one an external program uses (usually `default`). Effect
166
+ // Cluster stores `group:shard` in a 50-character column, so keep it short.
167
+ const group = `alchemy:${yield* shortLabel(options.id, 32)}`;
168
+ const gateway = yield* options.gateway(name, network);
169
+ const context = yield* Effect.context<Stack | Providers | Req>();
170
+
171
+ /** Put `hosted` on the pool, bind the gateway onto `resource`, and declare the pool. */
172
+ const deploy = (hosted: PoolEntity, env: Record<string, unknown>, resource: Entity) =>
173
+ Effect.gen(function* () {
174
+ const pool = yield* Pool(options.id, { name });
175
+ const token = yield* Random(`${options.id}GatewayToken`, {
176
+ bytes: 32,
177
+ }) as unknown as Effect.Effect<Random, never, Providers>;
178
+ // The hosting runners route the entity to its own shard group.
179
+ yield* pool.bind`${hosted.name}`({
180
+ entities: [hosted],
181
+ env:
182
+ hosted.program === undefined ? { ...env, [shardGroupKey(hosted.name)]: group } : env,
183
+ });
184
+ // Alchemy entities live in the pool's own shard group; an
185
+ // external program keeps the groups its entities use.
186
+ const groups =
187
+ hosted.program === undefined ? [group] : (options.settings.shardGroups ?? ["default"]);
188
+ yield* database.shardGroups.bind`${name}`({ groups });
189
+ // What callers need to reach the entity through the gateway.
190
+ yield* resource.bind`Pool`({
191
+ pool: group,
192
+ gateway: gateway.url,
193
+ token: token.text,
194
+ });
195
+ const stack = yield* Stack;
196
+ yield* options.nodes({
197
+ gateway,
198
+ database,
199
+ network,
200
+ pool,
201
+ name,
202
+ program: yield* bundleProgram(
203
+ options.id,
204
+ pool,
205
+ options.dockerfile ?? RUNNER_DOCKERFILE,
206
+ ),
207
+ environment: Output.all(
208
+ pool.env,
209
+ output(database.url),
210
+ token.text,
211
+ database.shardGroups.groups,
212
+ ).pipe(
213
+ Output.map(([values, url, tokenText, allGroups]) => ({
214
+ ...Object.fromEntries(
215
+ Object.entries(values).map(([key, value]) => [key, String(value)]),
216
+ ),
217
+ ALCHEMY_STACK_NAME: stack.name,
218
+ ALCHEMY_STAGE: stack.stage,
219
+ ALCHEMY_PHASE: "runtime",
220
+ CLUSTER_DATABASE_URL: Redacted.value(url),
221
+ CLUSTER_GATEWAY_TOKEN: Redacted.value(tokenText),
222
+ CLUSTER_POOL: name,
223
+ CLUSTER_SHARD_GROUPS: groups.join(","),
224
+ CLUSTER_AVAILABLE_SHARD_GROUPS: allGroups.join(","),
225
+ })),
226
+ ),
227
+ });
228
+ }).pipe(Effect.provide(context));
229
+
230
+ const host = (hosted: HostedClass) =>
231
+ Effect.andThen(
232
+ hosted.provideServices(services),
233
+ hosted.registerPool((env) =>
234
+ deploy(
235
+ {
236
+ name: hosted.LogicalId,
237
+ module: hosted.Props.main,
238
+ handler: hosted.Props.handler ?? "default",
239
+ },
240
+ env,
241
+ hosted as unknown as Entity,
242
+ ),
243
+ ),
244
+ );
245
+
246
+ // The generic `Self` is the class whose phase is running. When it is
247
+ // not hosted on runners, the pool was given an external entity,
248
+ // which may be declared inside any runtime's Construction phase.
249
+ if (hostsSelf) yield* host(self as unknown as HostedClass);
250
+
251
+ return {
252
+ host,
253
+ deployExternal: ({ type, main, handler, program, resource }) =>
254
+ // oxlint-disable-next-line effecttsgo/unsafe-effect-type-assertion -- `bind` is typed `any`; what it needs is the providers in `context`
255
+ deploy(
256
+ { name: type, module: main, handler, program },
257
+ {},
258
+ resource,
259
+ ) as Effect.Effect<void>,
260
+ } satisfies PoolHost["Service"];
261
+ }),
262
+ );
263
+
264
+ const PROGRAM = "virtual:cluster-program";
265
+
266
+ /** Resolve {@link PROGRAM} to the external program's bundled source, as a string. */
267
+ const programPlugin = (source: string | undefined) => [
268
+ {
269
+ name: "cluster-program",
270
+ resolveId: (id: string) => (id === PROGRAM ? `\0${PROGRAM}` : null),
271
+ load: (id: string) =>
272
+ id === `\0${PROGRAM}` ? `export default ${JSON.stringify(source ?? "")};` : null,
273
+ },
274
+ ];
275
+
276
+ /** A runner entry beside this module: `runner` (Alchemy entities) or `external-runner`. */
277
+ const runnerEntry = (name: "runner" | "external-runner") =>
278
+ fileURLToPath(
279
+ new URL(import.meta.url.endsWith(".ts") ? `./${name}.ts` : `./${name}.js`, import.meta.url),
280
+ );
281
+
282
+ /**
283
+ * Bundle the runner program and its Dockerfile into a directory of their
284
+ * own, once the pool knows its classes, and resolve to the directory.
285
+ */
286
+ const bundleProgram = Effect.fn(function* (id: string, pool: Pool, dockerfile: string) {
287
+ const fs = yield* FileSystem.FileSystem;
288
+ const path = yield* Path.Path;
289
+ const dir = path.resolve(yield* dotAlchemyDirectory, "cluster", yield* stackDnsLabel(id));
290
+ return Output.mapEffect((entities: PoolEntity[]) =>
291
+ Effect.gen(function* () {
292
+ yield* fs.remove(dir, { recursive: true, force: true });
293
+ yield* fs.makeDirectory(dir, { recursive: true });
294
+ // An external program is bundled on its own and embedded in the
295
+ // runner, which writes it out and runs it as its own process: it reads
296
+ // its settings from its environment when it loads, so it can't share
297
+ // the runner's modules. One file, on every host.
298
+ const program = entities.find((entity) => entity.program !== undefined)?.program;
299
+ let programSource: string | undefined;
300
+ if (program !== undefined) {
301
+ const programDir = path.join(dir, "program");
302
+ yield* Bundle.build(
303
+ {
304
+ input: path.resolve(program),
305
+ platform: "node",
306
+ resolve: { conditionNames: [...Bundle.NODE_CONDITION_NAMES] },
307
+ },
308
+ { dir: programDir, format: "esm", entryFileNames: "program.mjs", codeSplitting: false },
309
+ );
310
+ programSource = yield* fs.readFileString(path.join(programDir, "program.mjs"));
311
+ yield* fs.remove(programDir, { recursive: true });
312
+ }
313
+ const runner = yield* Bundle.build(
314
+ {
315
+ input: runnerEntry(program === undefined ? "runner" : "external-runner"),
316
+ platform: "node",
317
+ resolve: { conditionNames: [...Bundle.NODE_CONDITION_NAMES] },
318
+ plugins: [...entitiesPlugin(entities), ...programPlugin(programSource)],
319
+ },
320
+ { dir, format: "esm", entryFileNames: "runner.mjs", codeSplitting: false },
321
+ );
322
+ const hashes = [runner.hash, dockerfile];
323
+ yield* fs.writeFileString(path.join(dir, "Dockerfile"), dockerfile);
324
+ const hash = yield* Effect.sync(() =>
325
+ createHash("sha256").update(hashes.join("\n")).digest("hex").slice(0, 16),
326
+ );
327
+ return { dir, hash };
328
+ }).pipe(Effect.orDie),
329
+ )(pool.entities);
330
+ });
@@ -0,0 +1,25 @@
1
+ import * as Context from "effect/Context";
2
+ import type * as Effect from "effect/Effect";
3
+ import type { Entity } from "../Entity.ts";
4
+ import type { HostedClass } from "./HostedRuntime.ts";
5
+
6
+ /**
7
+ * What a pool Layer gives the Effect it is provided to: a way for an
8
+ * external `Cluster.Entity` (an existing Effect Cluster program, referenced
9
+ * by file) to run on the pool.
10
+ * @internal
11
+ */
12
+ export class PoolHost extends Context.Service<
13
+ PoolHost,
14
+ {
15
+ /** Host a class (an entity, a workflow) on the pool: its Construction phase is running. */
16
+ readonly host: (self: HostedClass) => Effect.Effect<void>;
17
+ readonly deployExternal: (entity: {
18
+ readonly type: string;
19
+ readonly main: string;
20
+ readonly handler: string;
21
+ readonly program: string;
22
+ readonly resource: Entity;
23
+ }) => Effect.Effect<void>;
24
+ }
25
+ >()("Cluster.PoolHost") {}
@@ -0,0 +1,83 @@
1
+ /**
2
+ * What crosses between a caller and an entity's object: each call as JSON,
3
+ * its reply as JSON. A host's object RPC hides a thrown error's type, so
4
+ * failures of the object itself come back as data too.
5
+ *
6
+ * @internal
7
+ */
8
+ import * as Effect from "effect/Effect";
9
+ import * as Exit from "effect/Exit";
10
+ import { Rpc, RpcSchema } from "effect/rpc";
11
+ import * as Schema from "effect/Schema";
12
+
13
+ /** One call to an entity, as it crosses to the entity's object. */
14
+ export interface Call {
15
+ /** The caller's id for this call: what an interrupt names. */
16
+ readonly requestId: string;
17
+ readonly tag: string;
18
+ readonly payload: unknown;
19
+ readonly headers: Record<string, string>;
20
+ }
21
+
22
+ /** A failure of the object itself, returned as data. */
23
+ export const DEFECT = "~cluster/defect";
24
+
25
+ /** A cluster error the object answered with instead of running the call. */
26
+ export interface Rejection {
27
+ readonly _tag: "MailboxFull" | "AlreadyProcessingMessage" | "PersistenceError";
28
+ readonly message: string;
29
+ }
30
+
31
+ /**
32
+ * The object's answer to a call: the encoded exit of an RPC, a rejection,
33
+ * or the object's own defect. A stream answers with one of these per frame,
34
+ * plus `{ chunk }` frames before its exit.
35
+ */
36
+ export type Reply =
37
+ | { readonly exit: unknown }
38
+ | { readonly rejected: Rejection }
39
+ | { readonly [DEFECT]: string };
40
+
41
+ export type Frame = Reply | { readonly chunk: ReadonlyArray<unknown> };
42
+
43
+ export const json = <S extends Schema.Top>(schema: S) =>
44
+ Schema.toCodecJson(schema) as unknown as Schema.Codec<unknown, unknown>;
45
+
46
+ export const payloadCodec = (rpc: Rpc.AnyWithProps) => json(rpc.payloadSchema);
47
+ export const exitCodec = (rpc: Rpc.AnyWithProps) => json(Rpc.exitSchema(rpc as Rpc.Any));
48
+ export const chunkCodec = (rpc: Rpc.AnyWithProps) =>
49
+ json(RpcSchema.isStreamSchema(rpc.successSchema) ? rpc.successSchema.success : Schema.Unknown);
50
+
51
+ export const encode = (codec: Schema.Codec<unknown, unknown>, value: unknown) =>
52
+ Schema.encodeUnknownEffect(codec)(value).pipe(Effect.orDie);
53
+ export const decode = (codec: Schema.Codec<unknown, unknown>, value: unknown) =>
54
+ Schema.decodeUnknownEffect(codec)(value).pipe(Effect.orDie);
55
+
56
+ /**
57
+ * An RPC's exit, encoded. A stream's exit carries no value. An exit its
58
+ * schema can't encode (a defect that isn't JSON) is sent as a defect with
59
+ * its message.
60
+ */
61
+ export const encodeExit = (rpc: Rpc.AnyWithProps, exit: Exit.Exit<unknown, unknown>) =>
62
+ Schema.encodeUnknownEffect(exitCodec(rpc))(
63
+ RpcSchema.isStreamSchema(rpc.successSchema) ? Exit.asVoid(exit) : exit,
64
+ ).pipe(
65
+ Effect.catchCause(() =>
66
+ Schema.encodeUnknownEffect(exitCodec(rpc))(
67
+ Exit.die(Exit.isFailure(exit) ? String(exit.cause) : "unencodable exit"),
68
+ ),
69
+ ),
70
+ Effect.orDie,
71
+ );
72
+
73
+ /** `(type, id)` as one object name, length-prefixed like Effect's: `7:Counter` + id. */
74
+ export const entityObjectName = (type: string, id: string) => `${type.length}:${type}${id}`;
75
+
76
+ export const parseEntityObjectName = (name: string) => {
77
+ const colon = name.indexOf(":");
78
+ const length = Number(name.slice(0, colon));
79
+ return {
80
+ type: name.slice(colon + 1, colon + 1 + length),
81
+ id: name.slice(colon + 1 + length),
82
+ };
83
+ };
@@ -0,0 +1,188 @@
1
+ /**
2
+ * How callers outside the cluster reach the workflows a pool hosts: four
3
+ * RPCs on the pool's gateway, served by the pool's own
4
+ * `ClusterWorkflowEngine`, and a `WorkflowEngine` for callers that sends
5
+ * them. Payloads, results and signals cross as JSON, encoded with the
6
+ * workflow's own schemas.
7
+ *
8
+ * @internal
9
+ */
10
+ import {
11
+ decodeExit,
12
+ decodeResult,
13
+ encodeExit,
14
+ encodeResult,
15
+ payloadCodec,
16
+ } from "alchemy/WorkflowEncoding";
17
+ import * as Data from "effect/Data";
18
+ import * as Effect from "effect/Effect";
19
+ import * as FetchHttpClient from "effect/http/FetchHttpClient";
20
+ import * as HttpClient from "effect/http/HttpClient";
21
+ import * as HttpClientRequest from "effect/http/HttpClientRequest";
22
+ import * as Layer from "effect/Layer";
23
+ import * as Option from "effect/Option";
24
+ import { Rpc, RpcClient, type RpcClientError, RpcGroup, RpcSerialization } from "effect/rpc";
25
+ import * as Schema from "effect/Schema";
26
+ import { DurableDeferred, Workflow, WorkflowEngine } from "effect/workflow";
27
+ import { GATEWAY_PATH, type GatewayTarget, gatewayFetch } from "./Gateway.ts";
28
+
29
+ const Run = { workflow: Schema.String, executionId: Schema.String };
30
+
31
+ export class WorkflowRpcs extends RpcGroup.make(
32
+ Rpc.make("Workflow.Start", { payload: { ...Run, payload: Schema.Unknown } }),
33
+ Rpc.make("Workflow.Poll", {
34
+ payload: Run,
35
+ success: Schema.Struct({
36
+ state: Schema.Literals(["none", "suspended", "complete"]),
37
+ result: Schema.optional(Schema.String),
38
+ }),
39
+ }),
40
+ Rpc.make("Workflow.Interrupt", { payload: Run }),
41
+ Rpc.make("Workflow.Signal", { payload: { ...Run, name: Schema.String, exit: Schema.String } }),
42
+ ) {}
43
+
44
+ class UnknownWorkflow extends Data.TaggedError("Cluster.UnknownWorkflow")<{
45
+ readonly workflow: string;
46
+ }> {}
47
+
48
+ /** The pool's side: each RPC on the pool's own engine, for the workflows they host. */
49
+ export const workflowHandlers = (workflows: ReadonlyMap<string, Workflow.Any>) =>
50
+ WorkflowRpcs.toLayer(
51
+ Effect.gen(function* () {
52
+ const engine = yield* WorkflowEngine.WorkflowEngine;
53
+ const find = (name: string) => {
54
+ const workflow = workflows.get(name);
55
+ return workflow === undefined
56
+ ? Effect.die(new UnknownWorkflow({ workflow: name }))
57
+ : Effect.succeed(workflow as Workflow.Workflow<string, any, any, any>);
58
+ };
59
+ return WorkflowRpcs.of({
60
+ "Workflow.Start": ({ workflow: name, executionId, payload }) =>
61
+ Effect.gen(function* () {
62
+ const workflow = yield* find(name);
63
+ const decoded = yield* Schema.decodeUnknownEffect(payloadCodec(workflow))(payload).pipe(
64
+ Effect.orDie,
65
+ );
66
+ yield* engine.execute(workflow, {
67
+ executionId,
68
+ payload: decoded as object,
69
+ discard: true,
70
+ });
71
+ }).pipe(Effect.orDie),
72
+ "Workflow.Poll": ({ workflow: name, executionId }) =>
73
+ Effect.gen(function* () {
74
+ const workflow = yield* find(name);
75
+ const result = yield* engine.poll(workflow, executionId);
76
+ if (Option.isNone(result)) return { state: "none" as const };
77
+ if (result.value._tag === "Suspended") return { state: "suspended" as const };
78
+ return {
79
+ state: "complete" as const,
80
+ result: yield* encodeResult(workflow, result.value.exit),
81
+ };
82
+ }),
83
+ "Workflow.Interrupt": ({ workflow: name, executionId }) =>
84
+ Effect.flatMap(find(name), (workflow) => engine.interrupt(workflow, executionId)),
85
+ // The exit arrives encoded with the deferred's own schemas, so it is
86
+ // passed on through a deferred whose schemas leave it as it is.
87
+ "Workflow.Signal": ({ workflow, executionId, name, exit }) =>
88
+ Effect.flatMap(decodeExit(exit), (decoded) =>
89
+ engine.deferredDone(
90
+ DurableDeferred.make(name, { success: Schema.Unknown, error: Schema.Unknown }),
91
+ { workflowName: workflow, executionId, deferredName: name, exit: decoded },
92
+ ),
93
+ ),
94
+ });
95
+ }),
96
+ );
97
+
98
+ /** A step only runs inside a run, and runs only run on the pool. */
99
+ class RunsOnPool extends Data.TaggedError("Cluster.RunsOnPool")<{
100
+ readonly operation: string;
101
+ }> {}
102
+
103
+ const onPool = (operation: string) => Effect.die(new RunsOnPool({ operation }));
104
+
105
+ /**
106
+ * A caller's engine: starts and reads runs through the gateway of the
107
+ * pool that hosts each workflow. `route` returns that gateway.
108
+ */
109
+ export const makeRemoteEngine = (
110
+ route: (workflowName: string) => Effect.Effect<GatewayTarget>,
111
+ ): WorkflowEngine.WorkflowEngine["Service"] => {
112
+ const call = <A>(
113
+ workflowName: string,
114
+ use: (
115
+ client: RpcClient.FromGroup<typeof WorkflowRpcs, RpcClientError.RpcClientError>,
116
+ ) => Effect.Effect<A, unknown>,
117
+ ) =>
118
+ Effect.gen(function* () {
119
+ const target = yield* route(workflowName);
120
+ const protocol = RpcClient.layerProtocolHttp({
121
+ url: `${target.url.replace(/\/+$/, "")}${GATEWAY_PATH}`,
122
+ transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken(target.token)),
123
+ }).pipe(Layer.provide([FetchHttpClient.layer, RpcSerialization.layerJson]));
124
+ return yield* Effect.flatMap(RpcClient.make(WorkflowRpcs), use).pipe(
125
+ Effect.scoped,
126
+ Effect.provide(protocol),
127
+ Effect.provideService(FetchHttpClient.Fetch, yield* gatewayFetch(target.service)),
128
+ );
129
+ }).pipe(Effect.orDie);
130
+
131
+ const poll = (workflow: Workflow.Any, executionId: string) =>
132
+ Effect.gen(function* () {
133
+ const reply = yield* call(workflow._tag, (client) =>
134
+ client["Workflow.Poll"]({ workflow: workflow._tag, executionId }),
135
+ );
136
+ if (reply.state === "none") return Option.none<Workflow.Result<unknown, unknown>>();
137
+ if (reply.state === "suspended") return Option.some(Workflow.Suspended.make({}));
138
+ return Option.some(
139
+ yield* decodeResult(workflow as Workflow.AnyWithProps, reply.result ?? "null"),
140
+ );
141
+ });
142
+
143
+ return WorkflowEngine.makeUnsafe({
144
+ // Workflows run on their runners; nothing registers here.
145
+ register: () => Effect.void,
146
+ execute: (workflow, options) =>
147
+ Effect.gen(function* () {
148
+ const payload = yield* Schema.encodeEffect(payloadCodec(workflow as Workflow.AnyWithProps))(
149
+ options.payload,
150
+ ).pipe(Effect.orDie);
151
+ yield* call(workflow._tag, (client) =>
152
+ client["Workflow.Start"]({
153
+ workflow: workflow._tag,
154
+ executionId: options.executionId,
155
+ payload,
156
+ }),
157
+ );
158
+ if (options.discard) return undefined;
159
+ return Option.getOrElse(yield* poll(workflow, options.executionId), () =>
160
+ Workflow.Suspended.make({}),
161
+ );
162
+ }) as Effect.Effect<any>,
163
+ poll,
164
+ interrupt: (workflow, executionId) =>
165
+ call(workflow._tag, (client) =>
166
+ client["Workflow.Interrupt"]({ workflow: workflow._tag, executionId }),
167
+ ),
168
+ interruptUnsafe: (workflow, executionId) =>
169
+ call(workflow._tag, (client) =>
170
+ client["Workflow.Interrupt"]({ workflow: workflow._tag, executionId }),
171
+ ),
172
+ resume: () => Effect.void,
173
+ activityExecute: () => onPool("Activity execution"),
174
+ deferredResult: () => onPool("DurableDeferred reads"),
175
+ deferredDone: ({ workflowName, executionId, deferredName, exit }) =>
176
+ Effect.flatMap(encodeExit(exit), (text) =>
177
+ call(workflowName, (client) =>
178
+ client["Workflow.Signal"]({
179
+ workflow: workflowName,
180
+ executionId,
181
+ name: deferredName,
182
+ exit: text,
183
+ }),
184
+ ),
185
+ ),
186
+ scheduleClock: () => onPool("DurableClock scheduling"),
187
+ });
188
+ };
@@ -0,0 +1,10 @@
1
+ // @ts-expect-error resolved by the pool's bundler plugin
2
+ import entities from "virtual:cluster-entities";
3
+ // @ts-expect-error resolved by the pool's bundler plugin
4
+ import program from "virtual:cluster-program";
5
+ import { runExternal } from "./Bootstrap.ts";
6
+
7
+ await runExternal(
8
+ entities as ReadonlyArray<{ readonly name: string; readonly value: unknown }>,
9
+ program as string,
10
+ );
@@ -0,0 +1,5 @@
1
+ // @ts-expect-error resolved by the pool's bundler plugin
2
+ import entities from "virtual:cluster-entities";
3
+ import { bootstrap } from "./Bootstrap.ts";
4
+
5
+ await bootstrap(entities as ReadonlyArray<{ readonly name: string; readonly value: unknown }>);