alchemy 2.0.0-beta.23 → 2.0.0-beta.25
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/bin/alchemy.js +12574 -2451
- package/bin/alchemy.js.map +1 -1
- package/bin/alchemy.ts +2 -0
- package/bin/cli.js +18 -0
- package/bin/commands/bootstrap.ts +88 -4
- package/bin/commands/deploy.ts +54 -20
- package/bin/commands/dev.ts +45 -0
- package/bin/exec.ts +22 -0
- package/lib/AdoptPolicy.d.ts +2 -2
- package/lib/AdoptPolicy.d.ts.map +1 -1
- package/lib/AdoptPolicy.js.map +1 -1
- package/lib/AlchemyContext.d.ts +1 -0
- package/lib/AlchemyContext.d.ts.map +1 -1
- package/lib/AlchemyContext.js +1 -0
- package/lib/AlchemyContext.js.map +1 -1
- package/lib/Build/Command.d.ts.map +1 -1
- package/lib/Build/Command.js +8 -1
- package/lib/Build/Command.js.map +1 -1
- package/lib/Bundle/Bundle.d.ts +21 -9
- package/lib/Bundle/Bundle.d.ts.map +1 -1
- package/lib/Bundle/Bundle.js +29 -15
- package/lib/Bundle/Bundle.js.map +1 -1
- package/lib/Cloudflare/Local/Sidecar.d.ts +27 -0
- package/lib/Cloudflare/Local/Sidecar.d.ts.map +1 -0
- package/lib/Cloudflare/Local/Sidecar.js +24 -0
- package/lib/Cloudflare/Local/Sidecar.js.map +1 -0
- package/lib/Cloudflare/Local/SidecarHandlers.d.ts +5 -0
- package/lib/Cloudflare/Local/SidecarHandlers.d.ts.map +1 -0
- package/lib/Cloudflare/Local/SidecarHandlers.js +119 -0
- package/lib/Cloudflare/Local/SidecarHandlers.js.map +1 -0
- package/lib/Cloudflare/Local/SidecarServer.d.ts +2 -0
- package/lib/Cloudflare/Local/SidecarServer.d.ts.map +1 -0
- package/lib/Cloudflare/Local/SidecarServer.js +27 -0
- package/lib/Cloudflare/Local/SidecarServer.js.map +1 -0
- package/lib/Cloudflare/SecretsStore/Secret.d.ts.map +1 -1
- package/lib/Cloudflare/SecretsStore/Secret.js +55 -7
- package/lib/Cloudflare/SecretsStore/Secret.js.map +1 -1
- package/lib/Cloudflare/SecretsStore/SecretsStore.d.ts.map +1 -1
- package/lib/Cloudflare/SecretsStore/SecretsStore.js +35 -11
- package/lib/Cloudflare/SecretsStore/SecretsStore.js.map +1 -1
- package/lib/Cloudflare/StateStore/State.d.ts +35 -0
- package/lib/Cloudflare/StateStore/State.d.ts.map +1 -1
- package/lib/Cloudflare/StateStore/State.js +187 -45
- package/lib/Cloudflare/StateStore/State.js.map +1 -1
- package/lib/Cloudflare/StateStore/index.d.ts +1 -1
- package/lib/Cloudflare/StateStore/index.d.ts.map +1 -1
- package/lib/Cloudflare/StateStore/index.js +1 -1
- package/lib/Cloudflare/StateStore/index.js.map +1 -1
- package/lib/Cloudflare/Workers/Compatibility.d.ts +6 -0
- package/lib/Cloudflare/Workers/Compatibility.d.ts.map +1 -0
- package/lib/Cloudflare/Workers/Compatibility.js +10 -0
- package/lib/Cloudflare/Workers/Compatibility.js.map +1 -0
- package/lib/Cloudflare/Workers/LocalWorkerProvider.d.ts +7 -0
- package/lib/Cloudflare/Workers/LocalWorkerProvider.d.ts.map +1 -0
- package/lib/Cloudflare/Workers/LocalWorkerProvider.js +81 -0
- package/lib/Cloudflare/Workers/LocalWorkerProvider.js.map +1 -0
- package/lib/Cloudflare/Workers/Worker.d.ts +3 -1
- package/lib/Cloudflare/Workers/Worker.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/Worker.js +89 -31
- package/lib/Cloudflare/Workers/Worker.js.map +1 -1
- package/lib/Cloudflare/Workers/WorkerBundle.d.ts +31 -0
- package/lib/Cloudflare/Workers/WorkerBundle.d.ts.map +1 -0
- package/lib/Cloudflare/Workers/WorkerBundle.js +174 -0
- package/lib/Cloudflare/Workers/WorkerBundle.js.map +1 -0
- package/lib/Cloudflare/Workers/WorkerName.d.ts +3 -0
- package/lib/Cloudflare/Workers/WorkerName.d.ts.map +1 -0
- package/lib/Cloudflare/Workers/WorkerName.js +9 -0
- package/lib/Cloudflare/Workers/WorkerName.js.map +1 -0
- package/lib/Cloudflare/Workers/index.d.ts +1 -0
- package/lib/Cloudflare/Workers/index.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/index.js +1 -0
- package/lib/Cloudflare/Workers/index.js.map +1 -1
- package/lib/Platform.d.ts +1 -1
- package/lib/Platform.d.ts.map +1 -1
- package/lib/Sidecar/Lock.d.ts +24 -0
- package/lib/Sidecar/Lock.d.ts.map +1 -0
- package/lib/Sidecar/Lock.js +102 -0
- package/lib/Sidecar/Lock.js.map +1 -0
- package/lib/Sidecar/RpcClient.d.ts +15 -0
- package/lib/Sidecar/RpcClient.d.ts.map +1 -0
- package/lib/Sidecar/RpcClient.js +75 -0
- package/lib/Sidecar/RpcClient.js.map +1 -0
- package/lib/Sidecar/RpcHandler.d.ts +48 -0
- package/lib/Sidecar/RpcHandler.d.ts.map +1 -0
- package/lib/Sidecar/RpcHandler.js +64 -0
- package/lib/Sidecar/RpcHandler.js.map +1 -0
- package/lib/Sidecar/RpcPaths.d.ts +14 -0
- package/lib/Sidecar/RpcPaths.d.ts.map +1 -0
- package/lib/Sidecar/RpcPaths.js +22 -0
- package/lib/Sidecar/RpcPaths.js.map +1 -0
- package/lib/Sidecar/RpcServer.d.ts +10 -0
- package/lib/Sidecar/RpcServer.d.ts.map +1 -0
- package/lib/Sidecar/RpcServer.js +50 -0
- package/lib/Sidecar/RpcServer.js.map +1 -0
- package/lib/Sidecar/RpcTransport.d.ts +22 -0
- package/lib/Sidecar/RpcTransport.d.ts.map +1 -0
- package/lib/Sidecar/RpcTransport.js +81 -0
- package/lib/Sidecar/RpcTransport.js.map +1 -0
- package/lib/Telemetry/Metrics.d.ts +15 -0
- package/lib/Telemetry/Metrics.d.ts.map +1 -1
- package/lib/Telemetry/Metrics.js +20 -0
- package/lib/Telemetry/Metrics.js.map +1 -1
- package/lib/Util/PlatformServices.d.ts +3 -0
- package/lib/Util/PlatformServices.d.ts.map +1 -1
- package/lib/Util/PlatformServices.js +13 -0
- package/lib/Util/PlatformServices.js.map +1 -1
- package/lib/Util/layer-scoped.d.ts +16 -0
- package/lib/Util/layer-scoped.d.ts.map +1 -0
- package/lib/Util/layer-scoped.js +14 -0
- package/lib/Util/layer-scoped.js.map +1 -0
- package/package.json +4 -2
- package/src/AdoptPolicy.ts +9 -9
- package/src/AlchemyContext.ts +2 -0
- package/src/Build/Command.ts +8 -1
- package/src/Bundle/Bundle.ts +65 -25
- package/src/Cloudflare/Local/Sidecar.ts +44 -0
- package/src/Cloudflare/Local/SidecarHandlers.ts +182 -0
- package/src/Cloudflare/Local/SidecarServer.ts +57 -0
- package/src/Cloudflare/SecretsStore/Secret.ts +78 -7
- package/src/Cloudflare/SecretsStore/SecretsStore.ts +47 -12
- package/src/Cloudflare/StateStore/State.ts +281 -64
- package/src/Cloudflare/StateStore/index.ts +1 -1
- package/src/Cloudflare/Workers/Compatibility.ts +12 -0
- package/src/Cloudflare/Workers/LocalWorkerProvider.ts +93 -0
- package/src/Cloudflare/Workers/Worker.ts +151 -55
- package/src/Cloudflare/Workers/WorkerBundle.ts +234 -0
- package/src/Cloudflare/Workers/WorkerName.ts +10 -0
- package/src/Cloudflare/Workers/index.ts +1 -0
- package/src/Platform.ts +1 -1
- package/src/Sidecar/Lock.ts +190 -0
- package/src/Sidecar/RpcClient.ts +141 -0
- package/src/Sidecar/RpcHandler.ts +169 -0
- package/src/Sidecar/RpcPaths.ts +29 -0
- package/src/Sidecar/RpcServer.ts +80 -0
- package/src/Sidecar/RpcTransport.ts +85 -0
- package/src/Telemetry/Metrics.ts +38 -0
- package/src/Util/PlatformServices.ts +19 -0
- package/src/Util/layer-scoped.ts +25 -0
|
@@ -7,6 +7,7 @@ import * as Redacted from "effect/Redacted";
|
|
|
7
7
|
import * as HttpClient from "effect/unstable/http/HttpClient";
|
|
8
8
|
|
|
9
9
|
import * as Config from "effect/Config";
|
|
10
|
+
import { adopt } from "../../AdoptPolicy.ts";
|
|
10
11
|
import { AuthError } from "../../Auth/AuthProvider.ts";
|
|
11
12
|
import { readCredentials, writeCredentials } from "../../Auth/Credentials.ts";
|
|
12
13
|
import { ALCHEMY_PROFILE } from "../../Auth/Profile.ts";
|
|
@@ -23,7 +24,7 @@ import * as Credentials from "../Credentials.ts";
|
|
|
23
24
|
import { AlchemyContext } from "../../AlchemyContext.ts";
|
|
24
25
|
import { makeLocalState } from "../../State/LocalState.ts";
|
|
25
26
|
import { State, type StateService } from "../../State/State.ts";
|
|
26
|
-
import {
|
|
27
|
+
import { recordStateStoreOp } from "../../Telemetry/Metrics.ts";
|
|
27
28
|
import * as Clank from "../../Util/Clank.ts";
|
|
28
29
|
import { CloudflareAuth } from "../Auth/AuthProvider.ts";
|
|
29
30
|
import { EdgeSessionError, createEdgeSession } from "../EdgeSession.ts";
|
|
@@ -33,6 +34,105 @@ import { AuthTokenSecretName, TokenValue } from "./Token.ts";
|
|
|
33
34
|
/** Filename used for stored credentials under the profile directory. */
|
|
34
35
|
const CREDENTIALS_FILE = "cloudflare-state-store";
|
|
35
36
|
|
|
37
|
+
export interface BootstrapOptions {
|
|
38
|
+
/**
|
|
39
|
+
* The name of the script to use for the state store.
|
|
40
|
+
* @default "alchemy-state-store"
|
|
41
|
+
*/
|
|
42
|
+
workerName?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Re-run the deploy even if the worker already exists in Cloudflare.
|
|
45
|
+
*
|
|
46
|
+
* Without this flag, an already-deployed worker is **adopted**: the
|
|
47
|
+
* auth-token secret is re-fetched via an edge probe and the local
|
|
48
|
+
* credentials file is refreshed, but no new code is uploaded. With
|
|
49
|
+
* `force: true` the full deploy runs again, redeploying the worker
|
|
50
|
+
* code and reconciling every resource in place.
|
|
51
|
+
*/
|
|
52
|
+
force?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Manually bootstrap (or repair) the Cloudflare-hosted HTTP State
|
|
57
|
+
* Store. Used by `alchemy bootstrap cloudflare`.
|
|
58
|
+
*
|
|
59
|
+
* The flow mirrors what {@link state} does on first use, but is
|
|
60
|
+
* exposed as a standalone effect so users can trigger it (or recover
|
|
61
|
+
* from a partially-failed previous deploy) explicitly:
|
|
62
|
+
*
|
|
63
|
+
* 1. Resume any in-progress local `CloudflareStateStore` stack.
|
|
64
|
+
* 2. If the worker already exists in Cloudflare and `force` is not
|
|
65
|
+
* set, adopt it: read the auth token via an edge-preview probe
|
|
66
|
+
* and persist credentials.
|
|
67
|
+
* 3. Otherwise run the full deploy with adoption enabled, sync local
|
|
68
|
+
* state into the deployed store, persist credentials, and delete
|
|
69
|
+
* the local stack.
|
|
70
|
+
*/
|
|
71
|
+
export const bootstrap = (options: BootstrapOptions = {}) =>
|
|
72
|
+
Effect.gen(function* () {
|
|
73
|
+
const isCI = yield* Config.boolean("CI").pipe(Config.withDefault(false));
|
|
74
|
+
const profileName = yield* ALCHEMY_PROFILE;
|
|
75
|
+
const scriptName = options.workerName ?? STATE_STORE_SCRIPT_NAME;
|
|
76
|
+
const force = options.force ?? false;
|
|
77
|
+
|
|
78
|
+
const localState = yield* makeLocalState();
|
|
79
|
+
const hasLocalStack = yield* Effect.map(
|
|
80
|
+
localState.listStages("CloudflareStateStore"),
|
|
81
|
+
(stages) => stages.includes(scriptName),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
if (hasLocalStack) {
|
|
85
|
+
yield* Clank.info(
|
|
86
|
+
`Resuming Cloudflare State Store '${scriptName}' deployment...`,
|
|
87
|
+
);
|
|
88
|
+
yield* finishBootstrap({ scriptName, profileName, localState, isCI });
|
|
89
|
+
yield* Clank.success(
|
|
90
|
+
`Cloudflare State Store '${scriptName}' is ready.`,
|
|
91
|
+
);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const { accountId } = yield* CloudflareEnvironment.CloudflareEnvironment;
|
|
96
|
+
const workerExists = yield* workers
|
|
97
|
+
.getScriptSetting({ accountId, scriptName })
|
|
98
|
+
.pipe(
|
|
99
|
+
Effect.map((setting) => setting !== undefined),
|
|
100
|
+
Effect.catchTag("WorkerNotFound", () => Effect.succeed(false)),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
if (workerExists && !force) {
|
|
104
|
+
yield* Clank.info(
|
|
105
|
+
`Worker '${scriptName}' already exists; adopting and refreshing credentials. ` +
|
|
106
|
+
`Use --force to redeploy.`,
|
|
107
|
+
);
|
|
108
|
+
const credentials = yield* loginWithCloudflare();
|
|
109
|
+
if (!isCI) {
|
|
110
|
+
yield* writeCredentials<HttpStateStoreProps>(
|
|
111
|
+
profileName,
|
|
112
|
+
CREDENTIALS_FILE,
|
|
113
|
+
credentials,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
yield* Clank.success(
|
|
117
|
+
`Cloudflare State Store '${scriptName}' is ready.`,
|
|
118
|
+
);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (workerExists) {
|
|
123
|
+
yield* Clank.info(
|
|
124
|
+
`Forcing redeploy of Cloudflare State Store '${scriptName}'...`,
|
|
125
|
+
);
|
|
126
|
+
} else {
|
|
127
|
+
yield* Clank.info(
|
|
128
|
+
`Deploying Cloudflare State Store '${scriptName}'...`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
yield* finishBootstrap({ scriptName, profileName, localState, isCI });
|
|
133
|
+
yield* Clank.success(`Cloudflare State Store '${scriptName}' is ready.`);
|
|
134
|
+
});
|
|
135
|
+
|
|
36
136
|
export const state = (props?: {
|
|
37
137
|
/**
|
|
38
138
|
* The name of the script to use for the state store.
|
|
@@ -53,6 +153,38 @@ export const state = (props?: {
|
|
|
53
153
|
|
|
54
154
|
const scriptName = props?.workerName ?? STATE_STORE_SCRIPT_NAME;
|
|
55
155
|
|
|
156
|
+
// The bootstrap of the Cloudflare State Store is only considered
|
|
157
|
+
// successful once two invariants hold:
|
|
158
|
+
// 1. the worker has been deployed and the credentials persisted
|
|
159
|
+
// 2. the local CloudflareStateStore stack has been synced into
|
|
160
|
+
// the HTTP store and removed from disk
|
|
161
|
+
//
|
|
162
|
+
// If a previous run failed anywhere between deploying the worker
|
|
163
|
+
// and deleting the local stack (e.g. the worker got created but
|
|
164
|
+
// sync/credential-write/delete never ran) we will detect the
|
|
165
|
+
// leftover local stack here and resume the bootstrap. This loop
|
|
166
|
+
// re-runs deploy (idempotent over already-applied resources) and
|
|
167
|
+
// finishes the sync/credential/cleanup steps until both
|
|
168
|
+
// invariants are satisfied.
|
|
169
|
+
const localState = yield* makeLocalState();
|
|
170
|
+
|
|
171
|
+
const hasLocalStack = yield* Effect.map(
|
|
172
|
+
localState.listStages("CloudflareStateStore"),
|
|
173
|
+
(stages) => stages.includes(scriptName),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
if (hasLocalStack) {
|
|
177
|
+
yield* Clank.info(
|
|
178
|
+
`Resuming Cloudflare State Store '${scriptName}' deployment...`,
|
|
179
|
+
);
|
|
180
|
+
return yield* finishBootstrap({
|
|
181
|
+
scriptName,
|
|
182
|
+
profileName,
|
|
183
|
+
localState,
|
|
184
|
+
isCI,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
56
188
|
// TODO(sam): support upgrading state store, right now we only deploy once
|
|
57
189
|
if (credentials) {
|
|
58
190
|
// it's in the profile, let's go
|
|
@@ -63,79 +195,63 @@ export const state = (props?: {
|
|
|
63
195
|
}
|
|
64
196
|
|
|
65
197
|
return httpState;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// our profile does not contain a reference to the state store, let's try and resolve it
|
|
201
|
+
const { accountId } = yield* CloudflareEnvironment.CloudflareEnvironment;
|
|
202
|
+
const workerExists = yield* workers
|
|
203
|
+
.getScriptSetting({
|
|
204
|
+
accountId,
|
|
205
|
+
scriptName,
|
|
206
|
+
})
|
|
207
|
+
.pipe(
|
|
208
|
+
Effect.map((setting) => setting !== undefined),
|
|
209
|
+
Effect.catchTag("WorkerNotFound", () => Effect.succeed(false)),
|
|
210
|
+
);
|
|
79
211
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
const httpState = yield* makeHttpStateStore(credentials);
|
|
91
|
-
|
|
92
|
-
if (alchemyContext.updateStateStore) {
|
|
93
|
-
yield* deployStateStore(scriptName, httpState);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
return httpState;
|
|
97
|
-
} else if (isCI) {
|
|
98
|
-
// TODO(sam): do we want to support bootstrapping the state store from CI?
|
|
99
|
-
// for now - just die here
|
|
100
|
-
return yield* Effect.die(
|
|
101
|
-
new AuthError({
|
|
102
|
-
message: `State store not found for script ${scriptName}. Deploy the state store first.`,
|
|
103
|
-
}),
|
|
212
|
+
if (workerExists) {
|
|
213
|
+
// it exists, so fetch the secret token
|
|
214
|
+
const credentials = yield* loginWithCloudflare();
|
|
215
|
+
if (!isCI) {
|
|
216
|
+
yield* writeCredentials<HttpStateStoreProps>(
|
|
217
|
+
profileName,
|
|
218
|
+
CREDENTIALS_FILE,
|
|
219
|
+
credentials,
|
|
104
220
|
);
|
|
105
221
|
}
|
|
222
|
+
const httpState = yield* makeHttpStateStore(credentials);
|
|
106
223
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
"Cloudflare State Store not found. Do you want to deploy it?",
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
if (!shouldDeploy) {
|
|
113
|
-
return yield* Effect.die(new Clank.PromptCancelled());
|
|
224
|
+
if (alchemyContext.updateStateStore) {
|
|
225
|
+
yield* deployStateStore(scriptName, httpState);
|
|
114
226
|
}
|
|
115
227
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const httpState = yield* makeHttpStateStore({ url, authToken });
|
|
120
|
-
|
|
121
|
-
yield* syncState(localState, httpState);
|
|
228
|
+
return httpState;
|
|
229
|
+
}
|
|
122
230
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
},
|
|
231
|
+
if (isCI) {
|
|
232
|
+
// TODO(sam): do we want to support bootstrapping the state store from CI?
|
|
233
|
+
// for now - just die here
|
|
234
|
+
return yield* Effect.die(
|
|
235
|
+
new AuthError({
|
|
236
|
+
message: `State store not found for script ${scriptName}. Deploy the state store first.`,
|
|
237
|
+
}),
|
|
130
238
|
);
|
|
239
|
+
}
|
|
131
240
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
});
|
|
241
|
+
const shouldDeploy = yield* Clank.confirm({
|
|
242
|
+
message: "Cloudflare State Store not found. Do you want to deploy it?",
|
|
243
|
+
});
|
|
136
244
|
|
|
137
|
-
|
|
245
|
+
if (!shouldDeploy) {
|
|
246
|
+
return yield* Effect.die(new Clank.PromptCancelled());
|
|
138
247
|
}
|
|
248
|
+
|
|
249
|
+
return yield* finishBootstrap({
|
|
250
|
+
scriptName,
|
|
251
|
+
profileName,
|
|
252
|
+
localState,
|
|
253
|
+
isCI,
|
|
254
|
+
});
|
|
139
255
|
}).pipe(Effect.orDie),
|
|
140
256
|
).pipe(
|
|
141
257
|
Layer.provideMerge(Credentials.fromAuthProvider()),
|
|
@@ -144,6 +260,93 @@ export const state = (props?: {
|
|
|
144
260
|
Layer.orDie,
|
|
145
261
|
);
|
|
146
262
|
|
|
263
|
+
/**
|
|
264
|
+
* Non-destructively copy every resource in the
|
|
265
|
+
* `CloudflareStateStore/<scriptName>` stack from `source` into
|
|
266
|
+
* `destination`, leaving every other stack in `destination` untouched.
|
|
267
|
+
*
|
|
268
|
+
* This intentionally does not delete anything from `destination`: at
|
|
269
|
+
* bootstrap time the destination is the user's live remote state
|
|
270
|
+
* store, and removing entries that happen to be missing locally would
|
|
271
|
+
* be catastrophic.
|
|
272
|
+
*/
|
|
273
|
+
const hoistBootstrapStack = Effect.fnUntraced(function* (
|
|
274
|
+
source: StateService,
|
|
275
|
+
destination: StateService,
|
|
276
|
+
scriptName: string,
|
|
277
|
+
) {
|
|
278
|
+
const stack = "CloudflareStateStore";
|
|
279
|
+
const stage = scriptName;
|
|
280
|
+
const fqns = yield* source.list({ stack, stage });
|
|
281
|
+
yield* Effect.forEach(
|
|
282
|
+
fqns,
|
|
283
|
+
Effect.fnUntraced(function* (fqn) {
|
|
284
|
+
const value = yield* source.get({ stack, stage, fqn });
|
|
285
|
+
if (value) {
|
|
286
|
+
yield* destination.set({ stack, stage, fqn, value });
|
|
287
|
+
}
|
|
288
|
+
}),
|
|
289
|
+
{ concurrency: "unbounded" },
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Finish (or resume) the bootstrap of the Cloudflare State Store using
|
|
295
|
+
* the provided local state as the source of truth. This is idempotent
|
|
296
|
+
* and safe to re-run: any resources already applied during a previous
|
|
297
|
+
* partial run will be reconciled, the local stack will be synced into
|
|
298
|
+
* the deployed HTTP store, credentials persisted, and finally the
|
|
299
|
+
* local stack removed - which is what marks the bootstrap as complete.
|
|
300
|
+
*/
|
|
301
|
+
const finishBootstrap = ({
|
|
302
|
+
scriptName,
|
|
303
|
+
profileName,
|
|
304
|
+
localState,
|
|
305
|
+
isCI,
|
|
306
|
+
}: {
|
|
307
|
+
scriptName: string;
|
|
308
|
+
profileName: string;
|
|
309
|
+
localState: StateService;
|
|
310
|
+
isCI: boolean;
|
|
311
|
+
}) =>
|
|
312
|
+
Effect.gen(function* () {
|
|
313
|
+
yield* deployStateStore(scriptName, localState);
|
|
314
|
+
|
|
315
|
+
// Don't trust the `authToken` returned by `deploy(...)`: when
|
|
316
|
+
// adoption kicks in (the Secrets Store secret already existed),
|
|
317
|
+
// the locally-generated `Random` value won't match the value
|
|
318
|
+
// actually persisted in Cloudflare, and any HTTP call to the
|
|
319
|
+
// worker would 401. Re-read the live token from the deployed
|
|
320
|
+
// worker via the same edge-preview probe `loginWithCloudflare`
|
|
321
|
+
// uses, so the credentials we persist always reflect what is
|
|
322
|
+
// actually deployed. `loginWithCloudflare` also persists the
|
|
323
|
+
// credentials file (skipping the write in CI), so we don't need
|
|
324
|
+
// to do that explicitly here.
|
|
325
|
+
const { url, authToken } = yield* loginWithCloudflare();
|
|
326
|
+
const httpState = yield* makeHttpStateStore({ url, authToken });
|
|
327
|
+
// `profileName` is intentionally unused here — `loginWithCloudflare`
|
|
328
|
+
// resolves it itself. Reference it to keep the surrounding API
|
|
329
|
+
// explicit and to avoid an unused-parameter lint.
|
|
330
|
+
void profileName;
|
|
331
|
+
void isCI;
|
|
332
|
+
|
|
333
|
+
// Copy the freshly-deployed CloudflareStateStore stack into the
|
|
334
|
+
// remote store. We deliberately do NOT call `syncState` here:
|
|
335
|
+
// syncState is a *mirror* that deletes everything in the
|
|
336
|
+
// destination that is not in the source, which would wipe every
|
|
337
|
+
// user stack in the remote store on any subsequent bootstrap
|
|
338
|
+
// (resume / --force / partial-failure recovery), since by that
|
|
339
|
+
// point local state only contains the bootstrap stack itself.
|
|
340
|
+
yield* hoistBootstrapStack(localState, httpState, scriptName);
|
|
341
|
+
|
|
342
|
+
yield* localState.deleteStack({
|
|
343
|
+
stack: "CloudflareStateStore",
|
|
344
|
+
stage: scriptName,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
return httpState;
|
|
348
|
+
});
|
|
349
|
+
|
|
147
350
|
const deployStateStore = (scriptName: string, state?: StateService) =>
|
|
148
351
|
Effect.gen(function* () {
|
|
149
352
|
const localState = state ?? (yield* makeLocalState());
|
|
@@ -172,11 +375,25 @@ const deployStateStore = (scriptName: string, state?: StateService) =>
|
|
|
172
375
|
}),
|
|
173
376
|
),
|
|
174
377
|
}).pipe(
|
|
378
|
+
// The Cloudflare State Store is account-level infrastructure that
|
|
379
|
+
// outlives any single deploy: its underlying Secrets Store and
|
|
380
|
+
// auth-token secret may already exist from a previous (possibly
|
|
381
|
+
// partially-failed) bootstrap. Opt in to adoption so the
|
|
382
|
+
// resources reconcile in place instead of failing on conflict.
|
|
383
|
+
adopt(true),
|
|
175
384
|
// TODO(sam): we should not need to do this, but types do complain. fix deploy
|
|
176
385
|
Effect.provide(stateLayer),
|
|
177
386
|
);
|
|
178
387
|
return { url, authToken, localState };
|
|
179
|
-
})
|
|
388
|
+
}).pipe(
|
|
389
|
+
Effect.withSpan("state_store.deploy", {
|
|
390
|
+
attributes: {
|
|
391
|
+
"alchemy.state_store.script_name": scriptName,
|
|
392
|
+
"alchemy.state_store.op": "deploy",
|
|
393
|
+
},
|
|
394
|
+
}),
|
|
395
|
+
recordStateStoreOp("deploy"),
|
|
396
|
+
);
|
|
180
397
|
|
|
181
398
|
/**
|
|
182
399
|
* Log in to a Cloudflare-deployed HTTP state-store.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { state } from "./State.ts";
|
|
1
|
+
export { bootstrap, state, type BootstrapOptions } from "./State.ts";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { WorkerProps } from "./Worker.ts";
|
|
2
|
+
|
|
3
|
+
// TODO: figure out why the later one from workerd breaks
|
|
4
|
+
const DEFAULT_COMPATIBILITY_DATE = "2026-03-17";
|
|
5
|
+
|
|
6
|
+
export const getCompatibility = (props: WorkerProps) => ({
|
|
7
|
+
date: props.compatibility?.date ?? DEFAULT_COMPATIBILITY_DATE,
|
|
8
|
+
flags: [
|
|
9
|
+
...(props.compatibility?.flags ?? []),
|
|
10
|
+
...(props.isExternal ? [] : ["nodejs_compat"]),
|
|
11
|
+
].filter((value, index, self) => self.indexOf(value) === index),
|
|
12
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Redacted from "effect/Redacted";
|
|
3
|
+
import * as Provider from "../../Provider.ts";
|
|
4
|
+
import type { ResourceBinding } from "../../Resource.ts";
|
|
5
|
+
import { Stack } from "../../Stack.ts";
|
|
6
|
+
import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
|
|
7
|
+
import { Sidecar } from "../Local/Sidecar.ts";
|
|
8
|
+
import { getCompatibility } from "./Compatibility.ts";
|
|
9
|
+
import { Worker, type WorkerBinding, type WorkerProps } from "./Worker.ts";
|
|
10
|
+
import { createWorkerName } from "./WorkerName.ts";
|
|
11
|
+
|
|
12
|
+
export const LocalWorkerProvider = () =>
|
|
13
|
+
Provider.effect(
|
|
14
|
+
Worker,
|
|
15
|
+
Effect.gen(function* () {
|
|
16
|
+
const { accountId } = yield* CloudflareEnvironment;
|
|
17
|
+
const stack = yield* Stack;
|
|
18
|
+
const sidecar = yield* Sidecar;
|
|
19
|
+
|
|
20
|
+
const run = Effect.fn(function* (
|
|
21
|
+
id: string,
|
|
22
|
+
props: WorkerProps,
|
|
23
|
+
bindings: ResourceBinding<Worker["Binding"]>[],
|
|
24
|
+
) {
|
|
25
|
+
const name = yield* createWorkerName(id, props.name);
|
|
26
|
+
const workerBindings: WorkerBinding[] = [];
|
|
27
|
+
const durableObjectNamespaces: Record<string, string> = {};
|
|
28
|
+
for (const { sid, data } of bindings) {
|
|
29
|
+
for (const binding of data.bindings ?? []) {
|
|
30
|
+
workerBindings.push(binding);
|
|
31
|
+
if (binding.type === "durable_object_namespace") {
|
|
32
|
+
durableObjectNamespaces[binding.name] = sid;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
for (const [key, value] of Object.entries(props.env ?? {})) {
|
|
37
|
+
if (Redacted.isRedacted(value)) {
|
|
38
|
+
workerBindings.push({
|
|
39
|
+
type: "secret_text",
|
|
40
|
+
name: key,
|
|
41
|
+
text: Redacted.value(value),
|
|
42
|
+
});
|
|
43
|
+
} else {
|
|
44
|
+
workerBindings.push({
|
|
45
|
+
type: "plain_text",
|
|
46
|
+
name: key,
|
|
47
|
+
text: value,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const result = yield* sidecar.serve({
|
|
52
|
+
id,
|
|
53
|
+
name,
|
|
54
|
+
main: props.main,
|
|
55
|
+
compatibility: getCompatibility(props),
|
|
56
|
+
entry: props.isExternal
|
|
57
|
+
? {
|
|
58
|
+
kind: "external",
|
|
59
|
+
}
|
|
60
|
+
: {
|
|
61
|
+
kind: "effect",
|
|
62
|
+
exports: (props.exports ?? {}) as any,
|
|
63
|
+
},
|
|
64
|
+
stack: { name: stack.name, stage: stack.stage },
|
|
65
|
+
bindings: workerBindings,
|
|
66
|
+
durableObjectNamespaces: Object.entries(durableObjectNamespaces).map(
|
|
67
|
+
([className, namespaceId]) => ({
|
|
68
|
+
className,
|
|
69
|
+
uniqueKey: namespaceId,
|
|
70
|
+
sql: true,
|
|
71
|
+
}),
|
|
72
|
+
),
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
workerId: name,
|
|
76
|
+
workerName: name,
|
|
77
|
+
logpush: undefined,
|
|
78
|
+
url: result.address,
|
|
79
|
+
tags: [],
|
|
80
|
+
durableObjectNamespaces,
|
|
81
|
+
domains: [],
|
|
82
|
+
accountId,
|
|
83
|
+
} satisfies Worker["Attributes"];
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
diff: () => Effect.succeed({ action: "update" }),
|
|
88
|
+
create: ({ id, news, bindings }) => run(id, news, bindings),
|
|
89
|
+
update: ({ id, news, bindings }) => run(id, news, bindings),
|
|
90
|
+
delete: ({ output }) => sidecar.stop(output.workerName),
|
|
91
|
+
};
|
|
92
|
+
}),
|
|
93
|
+
);
|