@alchemy.run/effect-cluster 0.0.0 → 2.0.0-beta.87
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.
- package/LICENSE +201 -0
- package/README.md +101 -4
- package/lib/AWS/Database.d.ts +54 -0
- package/lib/AWS/Database.d.ts.map +1 -0
- package/lib/AWS/Database.js +96 -0
- package/lib/AWS/Database.js.map +1 -0
- package/lib/AWS/EcsPool.d.ts +56 -0
- package/lib/AWS/EcsPool.d.ts.map +1 -0
- package/lib/AWS/EcsPool.js +87 -0
- package/lib/AWS/EcsPool.js.map +1 -0
- package/lib/AWS/index.d.ts +7 -0
- package/lib/AWS/index.d.ts.map +1 -0
- package/lib/AWS/index.js +7 -0
- package/lib/AWS/index.js.map +1 -0
- package/lib/AWS/internal/Gateway.d.ts +23 -0
- package/lib/AWS/internal/Gateway.d.ts.map +1 -0
- package/lib/AWS/internal/Gateway.js +70 -0
- package/lib/AWS/internal/Gateway.js.map +1 -0
- package/lib/Cloudflare/DurableObjectPool.d.ts +42 -0
- package/lib/Cloudflare/DurableObjectPool.d.ts.map +1 -0
- package/lib/Cloudflare/DurableObjectPool.js +136 -0
- package/lib/Cloudflare/DurableObjectPool.js.map +1 -0
- package/lib/Cloudflare/PoolWorker.d.ts +186 -0
- package/lib/Cloudflare/PoolWorker.d.ts.map +1 -0
- package/lib/Cloudflare/PoolWorker.js +75 -0
- package/lib/Cloudflare/PoolWorker.js.map +1 -0
- package/lib/Cloudflare/index.d.ts +7 -0
- package/lib/Cloudflare/index.d.ts.map +1 -0
- package/lib/Cloudflare/index.js +7 -0
- package/lib/Cloudflare/index.js.map +1 -0
- package/lib/Cloudflare/internal/Objects.d.ts +38 -0
- package/lib/Cloudflare/internal/Objects.d.ts.map +1 -0
- package/lib/Cloudflare/internal/Objects.js +102 -0
- package/lib/Cloudflare/internal/Objects.js.map +1 -0
- package/lib/Cron.d.ts +143 -0
- package/lib/Cron.d.ts.map +1 -0
- package/lib/Cron.js +83 -0
- package/lib/Cron.js.map +1 -0
- package/lib/Database.d.ts +95 -0
- package/lib/Database.d.ts.map +1 -0
- package/lib/Database.js +38 -0
- package/lib/Database.js.map +1 -0
- package/lib/Docker/Database.d.ts +42 -0
- package/lib/Docker/Database.d.ts.map +1 -0
- package/lib/Docker/Database.js +53 -0
- package/lib/Docker/Database.js.map +1 -0
- package/lib/Docker/SwarmPool.d.ts +44 -0
- package/lib/Docker/SwarmPool.d.ts.map +1 -0
- package/lib/Docker/SwarmPool.js +56 -0
- package/lib/Docker/SwarmPool.js.map +1 -0
- package/lib/Docker/index.d.ts +7 -0
- package/lib/Docker/index.d.ts.map +1 -0
- package/lib/Docker/index.js +7 -0
- package/lib/Docker/index.js.map +1 -0
- package/lib/Entity.d.ts +348 -0
- package/lib/Entity.d.ts.map +1 -0
- package/lib/Entity.js +308 -0
- package/lib/Entity.js.map +1 -0
- package/lib/Fly/Database.d.ts +50 -0
- package/lib/Fly/Database.d.ts.map +1 -0
- package/lib/Fly/Database.js +56 -0
- package/lib/Fly/Database.js.map +1 -0
- package/lib/Fly/MachinePool.d.ts +39 -0
- package/lib/Fly/MachinePool.d.ts.map +1 -0
- package/lib/Fly/MachinePool.js +51 -0
- package/lib/Fly/MachinePool.js.map +1 -0
- package/lib/Fly/index.d.ts +7 -0
- package/lib/Fly/index.d.ts.map +1 -0
- package/lib/Fly/index.js +7 -0
- package/lib/Fly/index.js.map +1 -0
- package/lib/GCP/ComputeEnginePool.d.ts +41 -0
- package/lib/GCP/ComputeEnginePool.d.ts.map +1 -0
- package/lib/GCP/ComputeEnginePool.js +172 -0
- package/lib/GCP/ComputeEnginePool.js.map +1 -0
- package/lib/GCP/Database.d.ts +48 -0
- package/lib/GCP/Database.d.ts.map +1 -0
- package/lib/GCP/Database.js +115 -0
- package/lib/GCP/Database.js.map +1 -0
- package/lib/GCP/index.d.ts +7 -0
- package/lib/GCP/index.d.ts.map +1 -0
- package/lib/GCP/index.js +7 -0
- package/lib/GCP/index.js.map +1 -0
- package/lib/Hetzner/Database.d.ts +48 -0
- package/lib/Hetzner/Database.d.ts.map +1 -0
- package/lib/Hetzner/Database.js +91 -0
- package/lib/Hetzner/Database.js.map +1 -0
- package/lib/Hetzner/ServerPool.d.ts +35 -0
- package/lib/Hetzner/ServerPool.d.ts.map +1 -0
- package/lib/Hetzner/ServerPool.js +98 -0
- package/lib/Hetzner/ServerPool.js.map +1 -0
- package/lib/Hetzner/index.d.ts +7 -0
- package/lib/Hetzner/index.d.ts.map +1 -0
- package/lib/Hetzner/index.js +7 -0
- package/lib/Hetzner/index.js.map +1 -0
- package/lib/Host.d.ts +60 -0
- package/lib/Host.d.ts.map +1 -0
- package/lib/Host.js +31 -0
- package/lib/Host.js.map +1 -0
- package/lib/Kubernetes/Database.d.ts +40 -0
- package/lib/Kubernetes/Database.d.ts.map +1 -0
- package/lib/Kubernetes/Database.js +56 -0
- package/lib/Kubernetes/Database.js.map +1 -0
- package/lib/Kubernetes/DeploymentPool.d.ts +54 -0
- package/lib/Kubernetes/DeploymentPool.d.ts.map +1 -0
- package/lib/Kubernetes/DeploymentPool.js +49 -0
- package/lib/Kubernetes/DeploymentPool.js.map +1 -0
- package/lib/Kubernetes/index.d.ts +7 -0
- package/lib/Kubernetes/index.d.ts.map +1 -0
- package/lib/Kubernetes/index.js +7 -0
- package/lib/Kubernetes/index.js.map +1 -0
- package/lib/Pool.d.ts +66 -0
- package/lib/Pool.d.ts.map +1 -0
- package/lib/Pool.js +47 -0
- package/lib/Pool.js.map +1 -0
- package/lib/Providers.d.ts +19 -0
- package/lib/Providers.d.ts.map +1 -0
- package/lib/Providers.js +31 -0
- package/lib/Providers.js.map +1 -0
- package/lib/ShardGroups.d.ts +24 -0
- package/lib/ShardGroups.d.ts.map +1 -0
- package/lib/ShardGroups.js +19 -0
- package/lib/ShardGroups.js.map +1 -0
- package/lib/Singleton.d.ts +110 -0
- package/lib/Singleton.d.ts.map +1 -0
- package/lib/Singleton.js +60 -0
- package/lib/Singleton.js.map +1 -0
- package/lib/Workflow.d.ts +251 -0
- package/lib/Workflow.d.ts.map +1 -0
- package/lib/Workflow.js +288 -0
- package/lib/Workflow.js.map +1 -0
- package/lib/index.d.ts +12 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +12 -0
- package/lib/index.js.map +1 -0
- package/lib/internal/Bootstrap.d.ts +28 -0
- package/lib/internal/Bootstrap.d.ts.map +1 -0
- package/lib/internal/Bootstrap.js +257 -0
- package/lib/internal/Bootstrap.js.map +1 -0
- package/lib/internal/EntityRuntime.d.ts +40 -0
- package/lib/internal/EntityRuntime.d.ts.map +1 -0
- package/lib/internal/EntityRuntime.js +698 -0
- package/lib/internal/EntityRuntime.js.map +1 -0
- package/lib/internal/Gateway.d.ts +82 -0
- package/lib/internal/Gateway.d.ts.map +1 -0
- package/lib/internal/Gateway.js +219 -0
- package/lib/internal/Gateway.js.map +1 -0
- package/lib/internal/HostedPlatform.d.ts +36 -0
- package/lib/internal/HostedPlatform.d.ts.map +1 -0
- package/lib/internal/HostedPlatform.js +36 -0
- package/lib/internal/HostedPlatform.js.map +1 -0
- package/lib/internal/HostedRuntime.d.ts +58 -0
- package/lib/internal/HostedRuntime.d.ts.map +1 -0
- package/lib/internal/HostedRuntime.js +72 -0
- package/lib/internal/HostedRuntime.js.map +1 -0
- package/lib/internal/Naming.d.ts +12 -0
- package/lib/internal/Naming.d.ts.map +1 -0
- package/lib/internal/Naming.js +40 -0
- package/lib/internal/Naming.js.map +1 -0
- package/lib/internal/ObjectSharding.d.ts +141 -0
- package/lib/internal/ObjectSharding.d.ts.map +1 -0
- package/lib/internal/ObjectSharding.js +337 -0
- package/lib/internal/ObjectSharding.js.map +1 -0
- package/lib/internal/PoolHost.d.ts +80 -0
- package/lib/internal/PoolHost.d.ts.map +1 -0
- package/lib/internal/PoolHost.js +203 -0
- package/lib/internal/PoolHost.js.map +1 -0
- package/lib/internal/PoolHostService.d.ts +25 -0
- package/lib/internal/PoolHostService.d.ts.map +1 -0
- package/lib/internal/PoolHostService.js +10 -0
- package/lib/internal/PoolHostService.js.map +1 -0
- package/lib/internal/Wire.d.ts +60 -0
- package/lib/internal/Wire.d.ts.map +1 -0
- package/lib/internal/Wire.js +36 -0
- package/lib/internal/Wire.js.map +1 -0
- package/lib/internal/WorkflowGateway.d.ts +36 -0
- package/lib/internal/WorkflowGateway.d.ts.map +1 -0
- package/lib/internal/WorkflowGateway.js +124 -0
- package/lib/internal/WorkflowGateway.js.map +1 -0
- package/lib/internal/external-runner.d.ts +2 -0
- package/lib/internal/external-runner.d.ts.map +1 -0
- package/lib/internal/external-runner.js +7 -0
- package/lib/internal/external-runner.js.map +1 -0
- package/lib/internal/runner.d.ts +2 -0
- package/lib/internal/runner.d.ts.map +1 -0
- package/lib/internal/runner.js +5 -0
- package/lib/internal/runner.js.map +1 -0
- package/package.json +128 -3
- package/src/AWS/Database.ts +139 -0
- package/src/AWS/EcsPool.ts +118 -0
- package/src/AWS/index.ts +6 -0
- package/src/AWS/internal/Gateway.ts +82 -0
- package/src/Cloudflare/DurableObjectPool.ts +198 -0
- package/src/Cloudflare/PoolWorker.ts +124 -0
- package/src/Cloudflare/index.ts +6 -0
- package/src/Cloudflare/internal/Objects.ts +160 -0
- package/src/Cron.ts +202 -0
- package/src/Database.ts +116 -0
- package/src/Docker/Database.ts +94 -0
- package/src/Docker/SwarmPool.ts +83 -0
- package/src/Docker/index.ts +6 -0
- package/src/Entity.ts +621 -0
- package/src/Fly/Database.ts +102 -0
- package/src/Fly/MachinePool.ts +72 -0
- package/src/Fly/index.ts +6 -0
- package/src/GCP/ComputeEnginePool.ts +203 -0
- package/src/GCP/Database.ts +168 -0
- package/src/GCP/index.ts +6 -0
- package/src/Hetzner/Database.ts +142 -0
- package/src/Hetzner/ServerPool.ts +116 -0
- package/src/Hetzner/index.ts +6 -0
- package/src/Host.ts +68 -0
- package/src/Kubernetes/Database.ts +89 -0
- package/src/Kubernetes/DeploymentPool.ts +77 -0
- package/src/Kubernetes/index.ts +6 -0
- package/src/Pool.ts +104 -0
- package/src/Providers.ts +49 -0
- package/src/ShardGroups.ts +41 -0
- package/src/Singleton.ts +153 -0
- package/src/Workflow.ts +556 -0
- package/src/index.ts +11 -0
- package/src/internal/Bootstrap.ts +368 -0
- package/src/internal/EntityRuntime.ts +996 -0
- package/src/internal/Gateway.ts +367 -0
- package/src/internal/HostedPlatform.ts +83 -0
- package/src/internal/HostedRuntime.ts +118 -0
- package/src/internal/Naming.ts +44 -0
- package/src/internal/ObjectSharding.ts +578 -0
- package/src/internal/PoolHost.ts +330 -0
- package/src/internal/PoolHostService.ts +25 -0
- package/src/internal/Wire.ts +83 -0
- package/src/internal/WorkflowGateway.ts +188 -0
- package/src/internal/external-runner.ts +10 -0
- 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.layerNdjson)),
|
|
118
|
+
Effect.provideContext(hosted.services),
|
|
119
|
+
);
|
|
120
|
+
return yield* app;
|
|
121
|
+
}),
|
|
122
|
+
),
|
|
123
|
+
};
|
|
124
|
+
});
|
|
@@ -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;
|