alchemy 2.0.0-beta.45 → 2.0.0-beta.47
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/exec.js +18 -10
- package/bin/exec.js.map +1 -1
- package/lib/AlchemyContext.d.ts +0 -1
- package/lib/AlchemyContext.d.ts.map +1 -1
- package/lib/AlchemyContext.js.map +1 -1
- package/lib/Apply.js +2 -2
- package/lib/Apply.js.map +1 -1
- package/lib/Cli/commands/_shared.d.ts +7 -1
- package/lib/Cli/commands/_shared.d.ts.map +1 -1
- package/lib/Cli/commands/_shared.js +1 -0
- package/lib/Cli/commands/_shared.js.map +1 -1
- package/lib/Cli/commands/cloudflare.d.ts.map +1 -1
- package/lib/Cli/commands/cloudflare.js +5 -1
- package/lib/Cli/commands/cloudflare.js.map +1 -1
- package/lib/Cli/commands/login.d.ts +1 -1
- package/lib/Cli/commands/login.d.ts.map +1 -1
- package/lib/Cli/commands/login.js +45 -42
- package/lib/Cli/commands/login.js.map +1 -1
- package/lib/Cli/commands/logs.js +1 -1
- package/lib/Cli/commands/logs.js.map +1 -1
- package/lib/Cli/commands/state.js +1 -1
- package/lib/Cli/commands/state.js.map +1 -1
- package/lib/Cli/commands/tail.js +1 -1
- package/lib/Cli/commands/tail.js.map +1 -1
- package/lib/Cloudflare/AiGateway/AiGateway.d.ts +112 -1
- package/lib/Cloudflare/AiGateway/AiGateway.d.ts.map +1 -1
- package/lib/Cloudflare/AiGateway/AiGateway.js +112 -1
- package/lib/Cloudflare/AiGateway/AiGateway.js.map +1 -1
- package/lib/Cloudflare/AiGateway/AiGatewayBinding.d.ts +43 -10
- package/lib/Cloudflare/AiGateway/AiGatewayBinding.d.ts.map +1 -1
- package/lib/Cloudflare/AiGateway/AiGatewayBinding.js +38 -10
- package/lib/Cloudflare/AiGateway/AiGatewayBinding.js.map +1 -1
- package/lib/Cloudflare/AiGateway/LanguageModel.d.ts +35 -0
- package/lib/Cloudflare/AiGateway/LanguageModel.d.ts.map +1 -0
- package/lib/Cloudflare/AiGateway/LanguageModel.js +576 -0
- package/lib/Cloudflare/AiGateway/LanguageModel.js.map +1 -0
- package/lib/Cloudflare/AiGateway/index.d.ts +1 -0
- package/lib/Cloudflare/AiGateway/index.d.ts.map +1 -1
- package/lib/Cloudflare/AiGateway/index.js +1 -0
- package/lib/Cloudflare/AiGateway/index.js.map +1 -1
- package/lib/Cloudflare/Providers.d.ts.map +1 -1
- package/lib/Cloudflare/Providers.js +5 -1
- package/lib/Cloudflare/Providers.js.map +1 -1
- package/lib/Cloudflare/Queue/QueueConsumer.d.ts.map +1 -1
- package/lib/Cloudflare/Queue/QueueConsumer.js +1 -2
- package/lib/Cloudflare/Queue/QueueConsumer.js.map +1 -1
- package/lib/Cloudflare/StateStore/Api.d.ts +1 -1
- package/lib/Cloudflare/StateStore/Api.js +1 -1
- package/lib/Cloudflare/StateStore/State.d.ts +11 -65
- package/lib/Cloudflare/StateStore/State.d.ts.map +1 -1
- package/lib/Cloudflare/StateStore/State.js +313 -368
- package/lib/Cloudflare/StateStore/State.js.map +1 -1
- package/lib/Cloudflare/StateStore/Store.d.ts.map +1 -1
- package/lib/Cloudflare/StateStore/Store.js +16 -29
- package/lib/Cloudflare/StateStore/Store.js.map +1 -1
- package/lib/Cloudflare/Vectorize/VectorizeIndex.d.ts +114 -0
- package/lib/Cloudflare/Vectorize/VectorizeIndex.d.ts.map +1 -0
- package/lib/Cloudflare/Vectorize/VectorizeIndex.js +157 -0
- package/lib/Cloudflare/Vectorize/VectorizeIndex.js.map +1 -0
- package/lib/Cloudflare/Vectorize/VectorizeIndexBinding.d.ts +38 -0
- package/lib/Cloudflare/Vectorize/VectorizeIndexBinding.d.ts.map +1 -0
- package/lib/Cloudflare/Vectorize/VectorizeIndexBinding.js +44 -0
- package/lib/Cloudflare/Vectorize/VectorizeIndexBinding.js.map +1 -0
- package/lib/Cloudflare/Vectorize/VectorizeMetadataIndex.d.ts +72 -0
- package/lib/Cloudflare/Vectorize/VectorizeMetadataIndex.d.ts.map +1 -0
- package/lib/Cloudflare/Vectorize/VectorizeMetadataIndex.js +131 -0
- package/lib/Cloudflare/Vectorize/VectorizeMetadataIndex.js.map +1 -0
- package/lib/Cloudflare/Vectorize/index.d.ts +4 -0
- package/lib/Cloudflare/Vectorize/index.d.ts.map +1 -0
- package/lib/Cloudflare/Vectorize/index.js +4 -0
- package/lib/Cloudflare/Vectorize/index.js.map +1 -0
- package/lib/Cloudflare/Workers/DurableObjectBridge.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/DurableObjectBridge.js +10 -1
- package/lib/Cloudflare/Workers/DurableObjectBridge.js.map +1 -1
- package/lib/Cloudflare/Workers/LocalWorkerProvider.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/LocalWorkerProvider.js +13 -1
- package/lib/Cloudflare/Workers/LocalWorkerProvider.js.map +1 -1
- package/lib/Cloudflare/Workers/Rpc.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/Rpc.js +20 -2
- package/lib/Cloudflare/Workers/Rpc.js.map +1 -1
- package/lib/Cloudflare/Workers/WorkerAsyncBindings.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/WorkerAsyncBindings.js +8 -0
- package/lib/Cloudflare/Workers/WorkerAsyncBindings.js.map +1 -1
- package/lib/Cloudflare/Workers/WorkerBinding.d.ts +2 -1
- package/lib/Cloudflare/Workers/WorkerBinding.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/WorkerBinding.js.map +1 -1
- package/lib/Cloudflare/Workers/WorkerBridge.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/WorkerBridge.js +20 -2
- package/lib/Cloudflare/Workers/WorkerBridge.js.map +1 -1
- package/lib/Cloudflare/index.d.ts +1 -0
- package/lib/Cloudflare/index.d.ts.map +1 -1
- package/lib/Cloudflare/index.js +1 -0
- package/lib/Cloudflare/index.js.map +1 -1
- package/lib/Output.js +2 -2
- package/lib/Output.js.map +1 -1
- package/lib/Plan.d.ts +2 -2
- package/lib/Plan.d.ts.map +1 -1
- package/lib/Plan.js +3 -3
- package/lib/Plan.js.map +1 -1
- package/lib/Stack.d.ts.map +1 -1
- package/lib/Stack.js +6 -0
- package/lib/Stack.js.map +1 -1
- package/lib/State/HttpStateStore.d.ts +4 -0
- package/lib/State/HttpStateStore.d.ts.map +1 -1
- package/lib/State/HttpStateStore.js +13 -0
- package/lib/State/HttpStateStore.js.map +1 -1
- package/lib/State/InMemoryState.d.ts +2 -1
- package/lib/State/InMemoryState.d.ts.map +1 -1
- package/lib/State/InMemoryState.js +3 -3
- package/lib/State/InMemoryState.js.map +1 -1
- package/lib/State/LocalState.d.ts.map +1 -1
- package/lib/State/LocalState.js +5 -1
- package/lib/State/LocalState.js.map +1 -1
- package/lib/State/State.d.ts +2 -2
- package/lib/State/State.d.ts.map +1 -1
- package/lib/State/State.js.map +1 -1
- package/lib/Util/poll.d.ts +30 -0
- package/lib/Util/poll.d.ts.map +1 -0
- package/lib/Util/poll.js +26 -0
- package/lib/Util/poll.js.map +1 -0
- package/lib/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +7 -7
- package/src/AlchemyContext.ts +0 -1
- package/src/Apply.ts +2 -2
- package/src/Cli/commands/_shared.ts +7 -1
- package/src/Cli/commands/cloudflare.ts +5 -3
- package/src/Cli/commands/login.ts +69 -60
- package/src/Cli/commands/logs.ts +1 -1
- package/src/Cli/commands/state.ts +1 -1
- package/src/Cli/commands/tail.ts +1 -1
- package/src/Cloudflare/AiGateway/AiGateway.ts +112 -1
- package/src/Cloudflare/AiGateway/AiGatewayBinding.ts +52 -10
- package/src/Cloudflare/AiGateway/LanguageModel.ts +907 -0
- package/src/Cloudflare/AiGateway/index.ts +1 -0
- package/src/Cloudflare/Providers.ts +7 -0
- package/src/Cloudflare/Queue/QueueConsumer.ts +0 -2
- package/src/Cloudflare/StateStore/Api.ts +1 -1
- package/src/Cloudflare/StateStore/State.ts +452 -508
- package/src/Cloudflare/StateStore/Store.ts +22 -35
- package/src/Cloudflare/Vectorize/VectorizeIndex.ts +261 -0
- package/src/Cloudflare/Vectorize/VectorizeIndexBinding.ts +103 -0
- package/src/Cloudflare/Vectorize/VectorizeMetadataIndex.ts +208 -0
- package/src/Cloudflare/Vectorize/index.ts +3 -0
- package/src/Cloudflare/Workers/DurableObjectBridge.ts +10 -1
- package/src/Cloudflare/Workers/LocalWorkerProvider.ts +13 -1
- package/src/Cloudflare/Workers/Rpc.ts +38 -9
- package/src/Cloudflare/Workers/WorkerAsyncBindings.ts +7 -0
- package/src/Cloudflare/Workers/WorkerBinding.ts +2 -0
- package/src/Cloudflare/Workers/WorkerBridge.ts +20 -2
- package/src/Cloudflare/index.ts +1 -0
- package/src/Output.ts +2 -2
- package/src/Plan.ts +4 -4
- package/src/Stack.ts +6 -0
- package/src/State/HttpStateStore.ts +28 -0
- package/src/State/InMemoryState.ts +87 -85
- package/src/State/LocalState.ts +15 -1
- package/src/State/State.ts +5 -4
- package/src/Util/poll.ts +47 -0
|
@@ -8,9 +8,9 @@ import * as HttpClient from "effect/unstable/http/HttpClient";
|
|
|
8
8
|
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
|
|
9
9
|
import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
|
|
10
10
|
import crypto from "node:crypto";
|
|
11
|
-
import * as Output from "../../Output.ts";
|
|
12
11
|
|
|
13
12
|
import * as Config from "effect/Config";
|
|
13
|
+
import { isHttpClientError } from "effect/unstable/http/HttpClientError";
|
|
14
14
|
import { adopt } from "../../AdoptPolicy.ts";
|
|
15
15
|
import { AuthError } from "../../Auth/AuthProvider.ts";
|
|
16
16
|
import {
|
|
@@ -20,16 +20,14 @@ import {
|
|
|
20
20
|
import { ALCHEMY_PROFILE, ProfileLive } from "../../Auth/Profile.ts";
|
|
21
21
|
import * as Cloudflare from "../../Cloudflare/Providers.ts";
|
|
22
22
|
import { deploy } from "../../Deploy.ts";
|
|
23
|
+
import * as Output from "../../Output.ts";
|
|
23
24
|
import * as Alchemy from "../../Stack.ts";
|
|
24
25
|
import { StateApi } from "../../State/HttpStateApi.ts";
|
|
25
26
|
import {
|
|
27
|
+
checkHttpStateStoreAuth,
|
|
26
28
|
makeHttpStateStore,
|
|
27
29
|
type HttpStateStoreCredentials,
|
|
28
30
|
} from "../../State/HttpStateStore.ts";
|
|
29
|
-
import * as CloudflareEnvironment from "../CloudflareEnvironment.ts";
|
|
30
|
-
import * as Credentials from "../Credentials.ts";
|
|
31
|
-
|
|
32
|
-
import { AlchemyContext } from "../../AlchemyContext.ts";
|
|
33
31
|
import { makeLocalState } from "../../State/LocalState.ts";
|
|
34
32
|
import { State, type StateService } from "../../State/State.ts";
|
|
35
33
|
import {
|
|
@@ -39,94 +37,176 @@ import {
|
|
|
39
37
|
import * as Clank from "../../Util/Clank.ts";
|
|
40
38
|
import * as Access from "../Access.ts";
|
|
41
39
|
import { CloudflareAuth } from "../Auth/AuthProvider.ts";
|
|
40
|
+
import * as CloudflareEnvironment from "../CloudflareEnvironment.ts";
|
|
41
|
+
import * as Credentials from "../Credentials.ts";
|
|
42
42
|
import { EdgeSessionError, createEdgeSession } from "../EdgeSession.ts";
|
|
43
43
|
import Api, { STATE_STORE_SCRIPT_NAME, STATE_STORE_VERSION } from "./Api.ts";
|
|
44
44
|
import { AuthToken, AuthTokenSecretName, TokenValue } from "./Token.ts";
|
|
45
45
|
|
|
46
|
+
const CI = Config.boolean("CI").pipe(Config.withDefault(false));
|
|
47
|
+
|
|
46
48
|
/** Filename used for stored credentials under the profile directory. */
|
|
47
49
|
const CREDENTIALS_FILE = "cloudflare-state-store";
|
|
48
50
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
51
|
+
export const state = () =>
|
|
52
|
+
Layer.effect(
|
|
53
|
+
State,
|
|
54
|
+
Effect.gen(function* () {
|
|
55
|
+
const isCI = yield* CI;
|
|
56
|
+
const scriptName = STATE_STORE_SCRIPT_NAME;
|
|
57
|
+
const profileName = yield* ALCHEMY_PROFILE;
|
|
58
|
+
const localStage = `${profileName}_${scriptName}`;
|
|
59
|
+
const credStore = yield* CredentialsStore;
|
|
60
|
+
const context = yield* Effect.context<Effect.Services<typeof init>>();
|
|
61
|
+
|
|
62
|
+
const init = Effect.gen(function* () {
|
|
63
|
+
if (yield* hasLocalStack(localStage)) {
|
|
64
|
+
// if there's still a local stack, then we need to finish the bootstrap
|
|
65
|
+
// TODO(sam): what if the local stack was
|
|
66
|
+
return yield* deployWithLocalState({
|
|
67
|
+
scriptName,
|
|
68
|
+
profileName,
|
|
69
|
+
isCI,
|
|
70
|
+
force: false,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
60
73
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
74
|
+
const ensureLatest = ({
|
|
75
|
+
url,
|
|
76
|
+
authToken,
|
|
77
|
+
}: {
|
|
78
|
+
url: string;
|
|
79
|
+
authToken: string;
|
|
80
|
+
}) =>
|
|
81
|
+
Effect.gen(function* () {
|
|
82
|
+
const { matches, expected, observed } =
|
|
83
|
+
yield* checkStateStoreVersion(url);
|
|
84
|
+
|
|
85
|
+
if (observed === undefined) {
|
|
86
|
+
const shouldDeploy = yield* Clank.confirm({
|
|
87
|
+
message: `Cloudflare State Store '${scriptName}' is not available. Do you want to deploy it?`,
|
|
88
|
+
});
|
|
89
|
+
if (shouldDeploy) {
|
|
90
|
+
return yield* bootstrap({
|
|
91
|
+
workerName: scriptName,
|
|
92
|
+
profile: profileName,
|
|
93
|
+
});
|
|
94
|
+
} else {
|
|
95
|
+
return yield* Effect.die(new Clank.PromptCancelled());
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const httpState = yield* ensureAccess({ url, authToken });
|
|
100
|
+
if (matches) {
|
|
101
|
+
return httpState;
|
|
102
|
+
} else if (isCI) {
|
|
103
|
+
return yield* Effect.die(
|
|
104
|
+
new AuthError({
|
|
105
|
+
message: `Cloudflare State store not found. Run 'alchemy bootstrap cloudflare --profile <your-ci-profile>' to deploy it first.`,
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
} else {
|
|
109
|
+
const shouldDeploy = yield* Clank.confirm({
|
|
110
|
+
message:
|
|
111
|
+
`Cloudflare State Store '${scriptName}' is out of date ` +
|
|
112
|
+
`(expected v${expected}, observed v${observed ?? "unknown"})`,
|
|
113
|
+
});
|
|
114
|
+
if (shouldDeploy) {
|
|
115
|
+
const stateStoreOptions = yield* deployStateStore({
|
|
116
|
+
stage: scriptName,
|
|
117
|
+
state: httpState,
|
|
118
|
+
force: false,
|
|
119
|
+
});
|
|
120
|
+
return yield* makeCloudflareStateStore(stateStoreOptions);
|
|
121
|
+
} else {
|
|
122
|
+
return yield* Effect.die(new Clank.PromptCancelled());
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const ensureAccess = (credentials: HttpStateStoreCredentials) =>
|
|
128
|
+
Effect.gen(function* () {
|
|
129
|
+
const isAuth = yield* checkHttpStateStoreAuth(credentials);
|
|
130
|
+
if (!isAuth) {
|
|
131
|
+
// our token is wrong, force a refresh
|
|
132
|
+
yield* Clank.info(
|
|
133
|
+
`Cloudflare State store authentication failed, refreshing credentials...`,
|
|
134
|
+
);
|
|
135
|
+
const credentials = yield* loginWithCloudflare(profileName, true);
|
|
136
|
+
if (!(yield* checkHttpStateStoreAuth(credentials))) {
|
|
137
|
+
return yield* Effect.die(
|
|
138
|
+
new AuthError({
|
|
139
|
+
message: `Cloudflare State store authentication failed, after refreshing credentials.`,
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return yield* makeCloudflareStateStore(credentials);
|
|
144
|
+
}
|
|
145
|
+
return yield* makeCloudflareStateStore(credentials);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const credentials = yield* credStore.read<HttpStateStoreCredentials>(
|
|
149
|
+
profileName,
|
|
150
|
+
CREDENTIALS_FILE,
|
|
151
|
+
);
|
|
152
|
+
if (credentials) {
|
|
153
|
+
return yield* ensureLatest(credentials);
|
|
154
|
+
}
|
|
155
|
+
const workerExists = yield* isStateStoreAvailable(scriptName);
|
|
156
|
+
if (workerExists) {
|
|
157
|
+
return yield* ensureLatest(
|
|
158
|
+
yield* loginWithCloudflare(profileName, false),
|
|
159
|
+
);
|
|
160
|
+
} else if (isCI) {
|
|
161
|
+
// TODO(sam): do we want to support bootstrapping the state store from CI?
|
|
162
|
+
// for now - just die here
|
|
163
|
+
return yield* Effect.die(
|
|
164
|
+
new AuthError({
|
|
165
|
+
message: `Cloudflare State store not found. Run 'alchemy bootstrap cloudflare --profile <your-ci-profile>' to deploy it first.`,
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
} else {
|
|
169
|
+
return yield* Clank.confirm({
|
|
170
|
+
message:
|
|
171
|
+
"Cloudflare State Store not found. Do you want to deploy it?",
|
|
172
|
+
}).pipe(
|
|
173
|
+
Effect.flatMap((shouldDeploy) =>
|
|
174
|
+
shouldDeploy
|
|
175
|
+
? bootstrap()
|
|
176
|
+
: Effect.die(new Clank.PromptCancelled()),
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}).pipe(recordStateStoreInit, Effect.orDie);
|
|
181
|
+
|
|
182
|
+
return yield* Effect.cached(init.pipe(Effect.provideContext(context)));
|
|
183
|
+
}),
|
|
184
|
+
).pipe(
|
|
185
|
+
Layer.provideMerge(Credentials.fromAuthProvider()),
|
|
186
|
+
Layer.provideMerge(CloudflareEnvironment.fromProfile()),
|
|
187
|
+
Layer.provideMerge(CloudflareAuth),
|
|
188
|
+
Layer.provideMerge(Access.AccessLive),
|
|
189
|
+
Layer.provideMerge(ProfileLive),
|
|
190
|
+
Layer.provideMerge(CredentialsStoreLive),
|
|
191
|
+
Layer.orDie,
|
|
192
|
+
);
|
|
89
193
|
|
|
90
194
|
export interface BootstrapOptions {
|
|
91
|
-
/**
|
|
92
|
-
* The name of the script to use for the state store.
|
|
93
|
-
* @default "alchemy-state-store"
|
|
94
|
-
*/
|
|
195
|
+
/** @default "alchemy-state-store" */
|
|
95
196
|
workerName?: string;
|
|
96
|
-
/**
|
|
97
|
-
* Re-run the deploy even if the worker already exists in Cloudflare.
|
|
98
|
-
*
|
|
99
|
-
* Without this flag, an already-deployed worker is **adopted**: the
|
|
100
|
-
* auth-token secret is re-fetched via an edge probe and the local
|
|
101
|
-
* credentials file is refreshed, but no new code is uploaded. With
|
|
102
|
-
* `force: true` the full deploy runs again, redeploying the worker
|
|
103
|
-
* code and reconciling every resource in place.
|
|
104
|
-
*/
|
|
197
|
+
/** @default false */
|
|
105
198
|
force?: boolean;
|
|
199
|
+
/** @default "default" */
|
|
200
|
+
profile?: string;
|
|
106
201
|
}
|
|
107
202
|
|
|
108
|
-
/**
|
|
109
|
-
* Manually bootstrap (or repair) the Cloudflare-hosted HTTP State
|
|
110
|
-
* Store. Used by `alchemy bootstrap cloudflare`.
|
|
111
|
-
*
|
|
112
|
-
* The flow mirrors what {@link state} does on first use, but is
|
|
113
|
-
* exposed as a standalone effect so users can trigger it (or recover
|
|
114
|
-
* from a partially-failed previous deploy) explicitly:
|
|
115
|
-
*
|
|
116
|
-
* 1. Resume any in-progress local `CloudflareStateStore` stack.
|
|
117
|
-
* 2. If the worker already exists in Cloudflare and `force` is not
|
|
118
|
-
* set, adopt it: read the auth token via an edge-preview probe
|
|
119
|
-
* and persist credentials.
|
|
120
|
-
* 3. Otherwise run the full deploy with adoption enabled, sync local
|
|
121
|
-
* state into the deployed store, persist credentials, and delete
|
|
122
|
-
* the local stack.
|
|
123
|
-
*/
|
|
124
203
|
export const bootstrap = (options: BootstrapOptions = {}) =>
|
|
125
204
|
Effect.gen(function* () {
|
|
126
|
-
const isCI = yield*
|
|
127
|
-
const profileName = yield* ALCHEMY_PROFILE;
|
|
205
|
+
const isCI = yield* CI;
|
|
206
|
+
const profileName = options.profile ?? (yield* ALCHEMY_PROFILE);
|
|
128
207
|
const scriptName = options.workerName ?? STATE_STORE_SCRIPT_NAME;
|
|
129
208
|
const force = options.force ?? false;
|
|
209
|
+
const localStage = `${profileName}_${scriptName}`;
|
|
130
210
|
yield* Effect.annotateCurrentSpan({
|
|
131
211
|
"alchemy.state_store.script_name": scriptName,
|
|
132
212
|
"alchemy.state_store.profile": profileName,
|
|
@@ -135,42 +215,41 @@ export const bootstrap = (options: BootstrapOptions = {}) =>
|
|
|
135
215
|
});
|
|
136
216
|
yield* annotateAccountHash();
|
|
137
217
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
localState.listStages("CloudflareStateStore"),
|
|
141
|
-
(stages) => stages.includes(scriptName),
|
|
142
|
-
);
|
|
143
|
-
|
|
144
|
-
if (hasLocalStack) {
|
|
218
|
+
if (yield* hasLocalStack(localStage)) {
|
|
219
|
+
// if there's a local stack still, we can assume we did not finish hoisting it, so finish that
|
|
145
220
|
yield* Clank.info(
|
|
146
221
|
`Resuming Cloudflare State Store '${scriptName}' deployment...`,
|
|
147
222
|
);
|
|
148
|
-
|
|
223
|
+
// resume deployment
|
|
224
|
+
return yield* deployWithLocalState({
|
|
149
225
|
scriptName,
|
|
150
226
|
profileName,
|
|
151
|
-
localState,
|
|
152
227
|
isCI,
|
|
153
228
|
force,
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const { accountId } = yield* CloudflareEnvironment.CloudflareEnvironment;
|
|
160
|
-
const workerExists = yield* workers
|
|
161
|
-
.getScriptSetting({ accountId, scriptName })
|
|
162
|
-
.pipe(
|
|
163
|
-
Effect.map((setting) => setting !== undefined),
|
|
164
|
-
Effect.catchTag("WorkerNotFound", () => Effect.succeed(false)),
|
|
229
|
+
}).pipe(
|
|
230
|
+
Effect.tap(() =>
|
|
231
|
+
Clank.success(`Cloudflare State Store '${scriptName}' is ready.`),
|
|
232
|
+
),
|
|
165
233
|
);
|
|
234
|
+
}
|
|
166
235
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
236
|
+
const workerExists = yield* isStateStoreAvailable(scriptName);
|
|
237
|
+
if (workerExists) {
|
|
238
|
+
// this is a regular update, let's check if it needs an update and refresh credentials
|
|
239
|
+
if (!force) {
|
|
240
|
+
yield* Clank.info(
|
|
241
|
+
`Worker '${scriptName}' already exists; adopting and refreshing credentials. ` +
|
|
242
|
+
`Use --force to redeploy.`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
const credentials = yield* loginWithCloudflare(
|
|
246
|
+
profileName,
|
|
247
|
+
// force refresh during
|
|
248
|
+
true,
|
|
171
249
|
);
|
|
172
|
-
const
|
|
250
|
+
const { url, authToken } = credentials;
|
|
173
251
|
if (!isCI) {
|
|
252
|
+
// we don't write credentials in CI because the file system is ephemeral
|
|
174
253
|
const store = yield* CredentialsStore;
|
|
175
254
|
yield* store.write<HttpStateStoreCredentials>(
|
|
176
255
|
profileName,
|
|
@@ -178,34 +257,44 @@ export const bootstrap = (options: BootstrapOptions = {}) =>
|
|
|
178
257
|
credentials,
|
|
179
258
|
);
|
|
180
259
|
}
|
|
181
|
-
|
|
260
|
+
const { matches, expected, observed } =
|
|
261
|
+
yield* checkStateStoreVersion(url);
|
|
262
|
+
const httpState = yield* makeCloudflareStateStore({ url, authToken });
|
|
263
|
+
if (!matches || force) {
|
|
264
|
+
if (matches && force) {
|
|
265
|
+
yield* Clank.info(
|
|
266
|
+
`Cloudflare State Store '${scriptName}' is up to date; force redeploying...`,
|
|
267
|
+
);
|
|
268
|
+
} else {
|
|
269
|
+
yield* Clank.info(
|
|
270
|
+
`Cloudflare State Store '${scriptName}' is out of date ` +
|
|
271
|
+
`(expected v${expected}, observed v${observed ?? "unknown"}); redeploying...`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
return yield* makeCloudflareStateStore(
|
|
275
|
+
yield* deployStateStore({
|
|
276
|
+
stage: scriptName,
|
|
277
|
+
state: httpState,
|
|
278
|
+
force,
|
|
279
|
+
}),
|
|
280
|
+
);
|
|
281
|
+
} else {
|
|
282
|
+
return httpState;
|
|
283
|
+
}
|
|
284
|
+
} else {
|
|
285
|
+
// fresh deploy - deploy from local for the first time
|
|
286
|
+
yield* Clank.info(`Deploying Cloudflare State Store '${scriptName}'...`);
|
|
287
|
+
return yield* deployWithLocalState({
|
|
182
288
|
scriptName,
|
|
183
289
|
profileName,
|
|
184
|
-
localState,
|
|
185
290
|
isCI,
|
|
186
|
-
url: credentials.url,
|
|
187
291
|
force,
|
|
188
|
-
})
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
if (workerExists) {
|
|
194
|
-
yield* Clank.info(
|
|
195
|
-
`Forcing redeploy of Cloudflare State Store '${scriptName}'...`,
|
|
292
|
+
}).pipe(
|
|
293
|
+
Effect.tap(() =>
|
|
294
|
+
Clank.success(`Cloudflare State Store '${scriptName}' is ready.`),
|
|
295
|
+
),
|
|
196
296
|
);
|
|
197
|
-
} else {
|
|
198
|
-
yield* Clank.info(`Deploying Cloudflare State Store '${scriptName}'...`);
|
|
199
297
|
}
|
|
200
|
-
|
|
201
|
-
yield* finishBootstrap({
|
|
202
|
-
scriptName,
|
|
203
|
-
profileName,
|
|
204
|
-
localState,
|
|
205
|
-
isCI,
|
|
206
|
-
force,
|
|
207
|
-
});
|
|
208
|
-
yield* Clank.success(`Cloudflare State Store '${scriptName}' is ready.`);
|
|
209
298
|
}).pipe(
|
|
210
299
|
Effect.withSpan("state_store.bootstrap", {
|
|
211
300
|
attributes: {
|
|
@@ -216,303 +305,22 @@ export const bootstrap = (options: BootstrapOptions = {}) =>
|
|
|
216
305
|
}),
|
|
217
306
|
);
|
|
218
307
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
Effect.gen(function* () {
|
|
223
|
-
const isCI = yield* Config.boolean("CI").pipe(Config.withDefault(false));
|
|
224
|
-
const alchemyContext = yield* AlchemyContext;
|
|
225
|
-
const profileName = yield* ALCHEMY_PROFILE;
|
|
226
|
-
const store = yield* CredentialsStore;
|
|
227
|
-
const credentials = yield* store.read<HttpStateStoreCredentials>(
|
|
228
|
-
profileName,
|
|
229
|
-
CREDENTIALS_FILE,
|
|
230
|
-
);
|
|
231
|
-
|
|
232
|
-
const scriptName = STATE_STORE_SCRIPT_NAME;
|
|
233
|
-
yield* Effect.annotateCurrentSpan({
|
|
234
|
-
"alchemy.state_store.script_name": scriptName,
|
|
235
|
-
"alchemy.state_store.profile": profileName,
|
|
236
|
-
"alchemy.state_store.ci": isCI,
|
|
237
|
-
"alchemy.state_store.force": false,
|
|
238
|
-
});
|
|
239
|
-
yield* annotateAccountHash(
|
|
240
|
-
yield* Config.boolean("NO_TRACK").pipe(Config.withDefault(false)),
|
|
241
|
-
);
|
|
242
|
-
|
|
243
|
-
// The bootstrap of the Cloudflare State Store is only considered
|
|
244
|
-
// successful once two invariants hold:
|
|
245
|
-
// 1. the worker has been deployed and the credentials persisted
|
|
246
|
-
// 2. the local CloudflareStateStore stack has been synced into
|
|
247
|
-
// the HTTP store and removed from disk
|
|
248
|
-
//
|
|
249
|
-
// If a previous run failed anywhere between deploying the worker
|
|
250
|
-
// and deleting the local stack (e.g. the worker got created but
|
|
251
|
-
// sync/credential-write/delete never ran) we will detect the
|
|
252
|
-
// leftover local stack here and resume the bootstrap. This loop
|
|
253
|
-
// re-runs deploy (idempotent over already-applied resources) and
|
|
254
|
-
// finishes the sync/credential/cleanup steps until both
|
|
255
|
-
// invariants are satisfied.
|
|
256
|
-
const localState = yield* makeLocalState();
|
|
257
|
-
|
|
258
|
-
const hasLocalStack = yield* Effect.map(
|
|
259
|
-
localState.listStages("CloudflareStateStore"),
|
|
260
|
-
(stages) => stages.includes(scriptName),
|
|
261
|
-
);
|
|
262
|
-
|
|
263
|
-
if (hasLocalStack) {
|
|
264
|
-
yield* Clank.info(
|
|
265
|
-
`Resuming Cloudflare State Store '${scriptName}' deployment...`,
|
|
266
|
-
);
|
|
267
|
-
return yield* finishBootstrap({
|
|
268
|
-
scriptName,
|
|
269
|
-
profileName,
|
|
270
|
-
localState,
|
|
271
|
-
isCI,
|
|
272
|
-
force: false,
|
|
273
|
-
});
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
if (credentials) {
|
|
277
|
-
// We have local credentials. Before trusting them, verify
|
|
278
|
-
// the deployed worker is on the version this CLI was built
|
|
279
|
-
// against. If it isn't, `redeployIfStale` runs the idempotent
|
|
280
|
-
// bootstrap flow and short-circuits with the freshly-deployed
|
|
281
|
-
// store.
|
|
282
|
-
const fresh = yield* redeployIfStale({
|
|
283
|
-
scriptName,
|
|
284
|
-
profileName,
|
|
285
|
-
localState,
|
|
286
|
-
isCI,
|
|
287
|
-
url: credentials.url,
|
|
288
|
-
force: false,
|
|
289
|
-
});
|
|
290
|
-
if (fresh) return fresh;
|
|
291
|
-
|
|
292
|
-
const httpState = yield* makeCloudflareStateStore(credentials);
|
|
293
|
-
|
|
294
|
-
if (alchemyContext.updateStateStore) {
|
|
295
|
-
yield* deployStateStore(scriptName, httpState);
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
return httpState;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// our profile does not contain a reference to the state store, let's try and resolve it
|
|
302
|
-
const { accountId } = yield* CloudflareEnvironment.CloudflareEnvironment;
|
|
303
|
-
const workerExists = yield* workers
|
|
304
|
-
.getScriptSetting({
|
|
305
|
-
accountId,
|
|
306
|
-
scriptName,
|
|
307
|
-
})
|
|
308
|
-
.pipe(
|
|
309
|
-
Effect.map((setting) => setting !== undefined),
|
|
310
|
-
Effect.catchTag("WorkerNotFound", () => Effect.succeed(false)),
|
|
311
|
-
);
|
|
312
|
-
|
|
313
|
-
if (workerExists) {
|
|
314
|
-
// it exists, so fetch the secret token
|
|
315
|
-
const credentials = yield* loginWithCloudflare();
|
|
316
|
-
if (!isCI) {
|
|
317
|
-
yield* store.write<HttpStateStoreCredentials>(
|
|
318
|
-
profileName,
|
|
319
|
-
CREDENTIALS_FILE,
|
|
320
|
-
credentials,
|
|
321
|
-
);
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
const fresh = yield* redeployIfStale({
|
|
325
|
-
scriptName,
|
|
326
|
-
profileName,
|
|
327
|
-
localState,
|
|
328
|
-
isCI,
|
|
329
|
-
url: credentials.url,
|
|
330
|
-
force: false,
|
|
331
|
-
});
|
|
332
|
-
if (fresh) return fresh;
|
|
333
|
-
|
|
334
|
-
const httpState = yield* makeCloudflareStateStore(credentials);
|
|
335
|
-
|
|
336
|
-
if (alchemyContext.updateStateStore) {
|
|
337
|
-
yield* deployStateStore(scriptName, httpState);
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
return httpState;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
if (isCI) {
|
|
344
|
-
// TODO(sam): do we want to support bootstrapping the state store from CI?
|
|
345
|
-
// for now - just die here
|
|
346
|
-
return yield* Effect.die(
|
|
347
|
-
new AuthError({
|
|
348
|
-
message: `State store not found for script ${scriptName}. Deploy the state store first.`,
|
|
349
|
-
}),
|
|
350
|
-
);
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
const shouldDeploy = yield* Clank.confirm({
|
|
354
|
-
message: "Cloudflare State Store not found. Do you want to deploy it?",
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
if (!shouldDeploy) {
|
|
358
|
-
return yield* Effect.die(new Clank.PromptCancelled());
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
return yield* finishBootstrap({
|
|
362
|
-
scriptName,
|
|
363
|
-
profileName,
|
|
364
|
-
localState,
|
|
365
|
-
isCI,
|
|
366
|
-
force: false,
|
|
367
|
-
});
|
|
368
|
-
}).pipe(recordStateStoreInit, Effect.orDie),
|
|
369
|
-
).pipe(
|
|
370
|
-
Layer.provideMerge(Credentials.fromAuthProvider()),
|
|
371
|
-
Layer.provideMerge(CloudflareEnvironment.fromProfile()),
|
|
372
|
-
Layer.provideMerge(CloudflareAuth),
|
|
373
|
-
Layer.provideMerge(Access.AccessLive),
|
|
374
|
-
Layer.provideMerge(ProfileLive),
|
|
375
|
-
Layer.provideMerge(CredentialsStoreLive),
|
|
376
|
-
Layer.orDie,
|
|
377
|
-
);
|
|
378
|
-
|
|
379
|
-
const makeCloudflareStateStore = Effect.fnUntraced(function* ({
|
|
380
|
-
url,
|
|
381
|
-
authToken,
|
|
382
|
-
}: {
|
|
383
|
-
url: string;
|
|
384
|
-
authToken: string;
|
|
385
|
-
}) {
|
|
386
|
-
const access = yield* Access.Access;
|
|
387
|
-
const accessHeaders = yield* access.getAccessHeaders(new URL(url).host);
|
|
388
|
-
return yield* makeHttpStateStore({
|
|
389
|
-
url,
|
|
390
|
-
authToken,
|
|
391
|
-
transformClient: HttpClientRequest.setHeaders(accessHeaders),
|
|
392
|
-
id: "cloudflare-http",
|
|
393
|
-
});
|
|
394
|
-
});
|
|
395
|
-
|
|
396
|
-
/**
|
|
397
|
-
* Non-destructively copy every resource in the
|
|
398
|
-
* `CloudflareStateStore/<scriptName>` stack from `source` into
|
|
399
|
-
* `destination`, leaving every other stack in `destination` untouched.
|
|
400
|
-
*
|
|
401
|
-
* This intentionally does not delete anything from `destination`: at
|
|
402
|
-
* bootstrap time the destination is the user's live remote state
|
|
403
|
-
* store, and removing entries that happen to be missing locally would
|
|
404
|
-
* be catastrophic.
|
|
405
|
-
*/
|
|
406
|
-
const hoistBootstrapStack = Effect.fnUntraced(function* (
|
|
407
|
-
source: StateService,
|
|
408
|
-
destination: StateService,
|
|
409
|
-
scriptName: string,
|
|
410
|
-
) {
|
|
411
|
-
const stack = "CloudflareStateStore";
|
|
412
|
-
const stage = scriptName;
|
|
413
|
-
const fqns = yield* source.list({ stack, stage });
|
|
414
|
-
yield* Effect.annotateCurrentSpan({
|
|
415
|
-
"alchemy.state_store.stack": stack,
|
|
416
|
-
"alchemy.state_store.stage": stage,
|
|
417
|
-
"alchemy.state_store.resources.count": fqns.length,
|
|
418
|
-
});
|
|
419
|
-
yield* Effect.forEach(
|
|
420
|
-
fqns,
|
|
421
|
-
Effect.fnUntraced(function* (fqn) {
|
|
422
|
-
const value = yield* source.get({ stack, stage, fqn });
|
|
423
|
-
if (value) {
|
|
424
|
-
yield* destination.set({ stack, stage, fqn, value });
|
|
425
|
-
}
|
|
426
|
-
}),
|
|
427
|
-
{ concurrency: "unbounded" },
|
|
428
|
-
);
|
|
429
|
-
}, Effect.withSpan("state_store.hoist_bootstrap_stack"));
|
|
430
|
-
|
|
431
|
-
/**
|
|
432
|
-
* Finish (or resume) the bootstrap of the Cloudflare State Store using
|
|
433
|
-
* the provided local state as the source of truth. This is idempotent
|
|
434
|
-
* and safe to re-run: any resources already applied during a previous
|
|
435
|
-
* partial run will be reconciled, the local stack will be synced into
|
|
436
|
-
* the deployed HTTP store, credentials persisted, and finally the
|
|
437
|
-
* local stack removed - which is what marks the bootstrap as complete.
|
|
438
|
-
*/
|
|
439
|
-
const finishBootstrap = ({
|
|
440
|
-
scriptName,
|
|
441
|
-
profileName,
|
|
442
|
-
localState,
|
|
443
|
-
isCI,
|
|
308
|
+
const deployStateStore = ({
|
|
309
|
+
stage,
|
|
310
|
+
state,
|
|
444
311
|
force,
|
|
445
312
|
}: {
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
isCI: boolean;
|
|
450
|
-
force: boolean;
|
|
313
|
+
stage: string;
|
|
314
|
+
state: StateService;
|
|
315
|
+
force?: boolean;
|
|
451
316
|
}) =>
|
|
452
|
-
Effect.gen(function* () {
|
|
453
|
-
const { authToken } = yield* deployStateStore(
|
|
454
|
-
scriptName,
|
|
455
|
-
localState,
|
|
456
|
-
force,
|
|
457
|
-
);
|
|
458
|
-
|
|
459
|
-
// Don't trust the `authToken` returned by `deploy(...)`: when
|
|
460
|
-
// adoption kicks in (the Secrets Store secret already existed),
|
|
461
|
-
// the locally-generated `Random` value won't match the value
|
|
462
|
-
// actually persisted in Cloudflare, and any HTTP call to the
|
|
463
|
-
// worker would 401. Re-read the live token from the deployed
|
|
464
|
-
// worker via the same edge-preview probe `loginWithCloudflare`
|
|
465
|
-
// uses, so the credentials we persist always reflect what is
|
|
466
|
-
// actually deployed. `loginWithCloudflare` also persists the
|
|
467
|
-
// credentials file (skipping the write in CI), so we don't need
|
|
468
|
-
// to do that explicitly here.
|
|
469
|
-
const { url } = yield* loginWithCloudflare();
|
|
470
|
-
const httpState = yield* makeCloudflareStateStore({ url, authToken });
|
|
471
|
-
// `profileName` is intentionally unused here — `loginWithCloudflare`
|
|
472
|
-
// resolves it itself. Reference it to keep the surrounding API
|
|
473
|
-
// explicit and to avoid an unused-parameter lint.
|
|
474
|
-
void profileName;
|
|
475
|
-
void isCI;
|
|
476
|
-
|
|
477
|
-
// Copy the freshly-deployed CloudflareStateStore stack into the
|
|
478
|
-
// remote store. We deliberately do NOT call `syncState` here:
|
|
479
|
-
// syncState is a *mirror* that deletes everything in the
|
|
480
|
-
// destination that is not in the source, which would wipe every
|
|
481
|
-
// user stack in the remote store on any subsequent bootstrap
|
|
482
|
-
// (resume / --force / partial-failure recovery), since by that
|
|
483
|
-
// point local state only contains the bootstrap stack itself.
|
|
484
|
-
yield* hoistBootstrapStack(localState, httpState, scriptName);
|
|
485
|
-
|
|
486
|
-
yield* localState.deleteStack({
|
|
487
|
-
stack: "CloudflareStateStore",
|
|
488
|
-
stage: scriptName,
|
|
489
|
-
});
|
|
490
|
-
|
|
491
|
-
return httpState;
|
|
492
|
-
}).pipe(
|
|
493
|
-
Effect.withSpan("state_store.finish_bootstrap", {
|
|
494
|
-
attributes: {
|
|
495
|
-
"alchemy.state_store.op": "finish_bootstrap",
|
|
496
|
-
"alchemy.state_store.script_name": scriptName,
|
|
497
|
-
"alchemy.state_store.profile": profileName,
|
|
498
|
-
"alchemy.state_store.ci": isCI,
|
|
499
|
-
},
|
|
500
|
-
}),
|
|
501
|
-
);
|
|
502
|
-
|
|
503
|
-
const deployStateStore = (
|
|
504
|
-
scriptName: string,
|
|
505
|
-
state?: StateService,
|
|
506
|
-
force?: boolean,
|
|
507
|
-
) =>
|
|
508
317
|
Effect.gen(function* () {
|
|
509
318
|
yield* annotateAccountHash();
|
|
510
|
-
const localState = state ?? (yield* makeLocalState());
|
|
511
319
|
// deploy it with local state (which we will then hoist into the Cloudflare state store)
|
|
512
|
-
const stateLayer = Layer.succeed(State,
|
|
320
|
+
const stateLayer = Layer.succeed(State, Effect.succeed(state));
|
|
513
321
|
const { url, authToken } = yield* deploy({
|
|
514
322
|
// use the script name as the stage name (so the user can have multiple state stores)
|
|
515
|
-
stage
|
|
323
|
+
stage,
|
|
516
324
|
force,
|
|
517
325
|
stack: Alchemy.Stack(
|
|
518
326
|
"CloudflareStateStore",
|
|
@@ -557,17 +365,138 @@ const deployStateStore = (
|
|
|
557
365
|
// observe stale data or trip the staleness check and recurse into
|
|
558
366
|
// another redeploy.
|
|
559
367
|
yield* waitForStateStoreVersion(url);
|
|
560
|
-
return { url, authToken
|
|
368
|
+
return { url, authToken };
|
|
561
369
|
}).pipe(
|
|
562
370
|
Effect.withSpan("state_store.deploy", {
|
|
563
371
|
attributes: {
|
|
564
|
-
"alchemy.state_store.script_name": scriptName,
|
|
565
372
|
"alchemy.state_store.op": "deploy",
|
|
566
373
|
},
|
|
567
374
|
}),
|
|
568
375
|
recordStateStoreOp("deploy"),
|
|
569
376
|
);
|
|
570
377
|
|
|
378
|
+
const deployWithLocalState = ({
|
|
379
|
+
scriptName,
|
|
380
|
+
isCI,
|
|
381
|
+
force,
|
|
382
|
+
profileName,
|
|
383
|
+
}: {
|
|
384
|
+
scriptName: string;
|
|
385
|
+
isCI: boolean;
|
|
386
|
+
force: boolean;
|
|
387
|
+
profileName: string;
|
|
388
|
+
}) =>
|
|
389
|
+
Effect.gen(function* () {
|
|
390
|
+
const localState = yield* makeLocalState();
|
|
391
|
+
const localStage = `${profileName}_${scriptName}`;
|
|
392
|
+
const remoteStage = scriptName;
|
|
393
|
+
const { authToken } = yield* deployStateStore({
|
|
394
|
+
stage: localStage,
|
|
395
|
+
state: localState,
|
|
396
|
+
force,
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const { url } = yield* loginWithCloudflare(profileName, force);
|
|
400
|
+
const httpState = yield* makeCloudflareStateStore({ url, authToken });
|
|
401
|
+
|
|
402
|
+
yield* hoistBootstrapStack({
|
|
403
|
+
source: {
|
|
404
|
+
state: localState,
|
|
405
|
+
stage: localStage,
|
|
406
|
+
},
|
|
407
|
+
destination: {
|
|
408
|
+
state: httpState,
|
|
409
|
+
stage: remoteStage,
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
yield* localState.deleteStack({
|
|
414
|
+
stack: "CloudflareStateStore",
|
|
415
|
+
stage: localStage,
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
return httpState;
|
|
419
|
+
}).pipe(
|
|
420
|
+
Effect.withSpan("state_store.finish_bootstrap", {
|
|
421
|
+
attributes: {
|
|
422
|
+
"alchemy.state_store.op": "finish_bootstrap",
|
|
423
|
+
"alchemy.state_store.ci": isCI,
|
|
424
|
+
},
|
|
425
|
+
}),
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
// check if there's a local stack that wasn't properly hoisted
|
|
429
|
+
const hasLocalStack = (stage: string) =>
|
|
430
|
+
Effect.gen(function* () {
|
|
431
|
+
const localState = yield* makeLocalState();
|
|
432
|
+
return yield* Effect.map(
|
|
433
|
+
localState.listStages("CloudflareStateStore"),
|
|
434
|
+
// key off the profile name to avoid conflicts with other profiles
|
|
435
|
+
(stages) => stages.includes(stage),
|
|
436
|
+
);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Non-destructively copy every resource in the
|
|
441
|
+
* `CloudflareStateStore/<scriptName>` stack from `source` into
|
|
442
|
+
* `destination`, leaving every other stack in `destination` untouched.
|
|
443
|
+
*
|
|
444
|
+
* This intentionally does not delete anything from `destination`: at
|
|
445
|
+
* bootstrap time the destination is the user's live remote state
|
|
446
|
+
* store, and removing entries that happen to be missing locally would
|
|
447
|
+
* be catastrophic.
|
|
448
|
+
*/
|
|
449
|
+
const hoistBootstrapStack = Effect.fnUntraced(function* ({
|
|
450
|
+
source,
|
|
451
|
+
destination,
|
|
452
|
+
}: {
|
|
453
|
+
source: {
|
|
454
|
+
state: StateService;
|
|
455
|
+
stage: string;
|
|
456
|
+
};
|
|
457
|
+
destination: {
|
|
458
|
+
state: StateService;
|
|
459
|
+
stage: string;
|
|
460
|
+
};
|
|
461
|
+
}) {
|
|
462
|
+
const stack = "CloudflareStateStore";
|
|
463
|
+
const fqns = yield* source.state.list({ stack, stage: source.stage });
|
|
464
|
+
yield* Effect.annotateCurrentSpan({
|
|
465
|
+
"alchemy.state_store.stack": stack,
|
|
466
|
+
"alchemy.state_store.stage": source.stage,
|
|
467
|
+
"alchemy.state_store.resources.count": fqns.length,
|
|
468
|
+
});
|
|
469
|
+
yield* Effect.forEach(
|
|
470
|
+
fqns,
|
|
471
|
+
Effect.fnUntraced(function* (fqn) {
|
|
472
|
+
const value = yield* source.state.get({
|
|
473
|
+
stack,
|
|
474
|
+
stage: source.stage,
|
|
475
|
+
fqn,
|
|
476
|
+
});
|
|
477
|
+
if (value) {
|
|
478
|
+
yield* destination.state
|
|
479
|
+
.set({
|
|
480
|
+
stack,
|
|
481
|
+
stage: destination.stage,
|
|
482
|
+
fqn,
|
|
483
|
+
value,
|
|
484
|
+
})
|
|
485
|
+
.pipe(
|
|
486
|
+
Effect.retry({
|
|
487
|
+
while: (error) =>
|
|
488
|
+
isHttpClientError(error.cause) &&
|
|
489
|
+
// worker can 404 for a bit on first deploy, retry these
|
|
490
|
+
error.cause.response?.status === 404,
|
|
491
|
+
schedule: Schedule.fixed(200),
|
|
492
|
+
}),
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
}),
|
|
496
|
+
{ concurrency: "unbounded" },
|
|
497
|
+
);
|
|
498
|
+
}, Effect.withSpan("state_store.hoist_bootstrap_stack"));
|
|
499
|
+
|
|
571
500
|
/**
|
|
572
501
|
* Log in to a Cloudflare-deployed HTTP state-store.
|
|
573
502
|
*
|
|
@@ -584,12 +513,23 @@ const deployStateStore = (
|
|
|
584
513
|
* `CloudflareEnvironment`, `Credentials`, `HttpClient`, and
|
|
585
514
|
* `FileSystem`.
|
|
586
515
|
*/
|
|
587
|
-
export const loginWithCloudflare = () =>
|
|
516
|
+
export const loginWithCloudflare = (profileName: string, force: boolean) =>
|
|
588
517
|
Effect.gen(function* () {
|
|
589
|
-
const
|
|
590
|
-
const
|
|
518
|
+
const credStore = yield* CredentialsStore;
|
|
519
|
+
const isCI = yield* CI;
|
|
591
520
|
const { accountId } = yield* CloudflareEnvironment.CloudflareEnvironment;
|
|
592
521
|
|
|
522
|
+
if (!force) {
|
|
523
|
+
// try and read from the cached credentials first if not forcing (force will always refresh)
|
|
524
|
+
const credentials = yield* credStore.read<HttpStateStoreCredentials>(
|
|
525
|
+
profileName,
|
|
526
|
+
CREDENTIALS_FILE,
|
|
527
|
+
);
|
|
528
|
+
if (credentials) {
|
|
529
|
+
return credentials;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
593
533
|
// 1. Locate the single Secrets Store on the account.
|
|
594
534
|
const stores = yield* SecretsStore.listStores({ accountId });
|
|
595
535
|
const store = stores.result[0];
|
|
@@ -602,15 +542,7 @@ export const loginWithCloudflare = () =>
|
|
|
602
542
|
);
|
|
603
543
|
}
|
|
604
544
|
|
|
605
|
-
// 2. Fetch the auth-token
|
|
606
|
-
// We piggy-back on the already-deployed state-store script so the
|
|
607
|
-
// `cf-workers-preview-token` header has a real workers.dev route to
|
|
608
|
-
// swap onto. Uploading the probe under a brand-new script name fails
|
|
609
|
-
// on accounts where that script has never been deployed (or where
|
|
610
|
-
// workers.dev preview URLs are off by default — the post-2024
|
|
611
|
-
// Cloudflare default for new accounts) because the host doesn't
|
|
612
|
-
// resolve and Cloudflare's edge serves a 400 HTML error page
|
|
613
|
-
// instead of routing to the preview.
|
|
545
|
+
// 2. Fetch the auth-token from Secrets Store with a temporary edge-preview worker
|
|
614
546
|
const authToken = yield* readSecretViaEdge(
|
|
615
547
|
STATE_STORE_SCRIPT_NAME,
|
|
616
548
|
store.id,
|
|
@@ -631,7 +563,6 @@ export const loginWithCloudflare = () =>
|
|
|
631
563
|
if (!isCI) {
|
|
632
564
|
// 4. Persist credentials. The profile entry is managed by
|
|
633
565
|
// `loadOrConfigure` when this is invoked through `configure`.
|
|
634
|
-
const credStore = yield* CredentialsStore;
|
|
635
566
|
yield* credStore
|
|
636
567
|
.write<HttpStateStoreCredentials>(profileName, CREDENTIALS_FILE, {
|
|
637
568
|
url,
|
|
@@ -674,96 +605,33 @@ export const loginWithCloudflare = () =>
|
|
|
674
605
|
}),
|
|
675
606
|
);
|
|
676
607
|
|
|
677
|
-
const
|
|
608
|
+
const isStateStoreAvailable = (scriptName: string = "alchemy-state-store") =>
|
|
678
609
|
Effect.gen(function* () {
|
|
679
|
-
|
|
680
|
-
const
|
|
681
|
-
yield*
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
{
|
|
685
|
-
url,
|
|
686
|
-
authToken,
|
|
687
|
-
},
|
|
610
|
+
// otherwise, the remote one might exist
|
|
611
|
+
const { accountId } = yield* CloudflareEnvironment.CloudflareEnvironment;
|
|
612
|
+
return yield* workers.getScriptSetting({ accountId, scriptName }).pipe(
|
|
613
|
+
Effect.map((setting) => setting !== undefined),
|
|
614
|
+
Effect.catchTag("WorkerNotFound", () => Effect.succeed(false)),
|
|
688
615
|
);
|
|
689
616
|
});
|
|
690
617
|
|
|
691
|
-
const
|
|
692
|
-
error instanceof EdgeSessionError &&
|
|
693
|
-
(error.message.includes("Invalid Workers Preview configuration") ||
|
|
694
|
-
error.message.includes("Error 1031"));
|
|
695
|
-
|
|
696
|
-
/**
|
|
697
|
-
* Run {@link finishBootstrap} iff the worker at `url` fails the
|
|
698
|
-
* version probe; otherwise resolve to `undefined`. Centralises the
|
|
699
|
-
* "is the deployed worker still compatible?" guard used by every
|
|
700
|
-
* code path that already has a presumably-valid worker URL.
|
|
701
|
-
*/
|
|
702
|
-
const redeployIfStale = ({
|
|
618
|
+
const makeCloudflareStateStore = Effect.fnUntraced(function* ({
|
|
703
619
|
url,
|
|
704
|
-
|
|
705
|
-
profileName,
|
|
706
|
-
localState,
|
|
707
|
-
isCI,
|
|
708
|
-
force,
|
|
620
|
+
authToken,
|
|
709
621
|
}: {
|
|
710
622
|
url: string;
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
`(expected v${expected}, observed v${observed ?? "unknown"}); redeploying...`,
|
|
723
|
-
);
|
|
724
|
-
return yield* finishBootstrap({
|
|
725
|
-
scriptName,
|
|
726
|
-
profileName,
|
|
727
|
-
localState,
|
|
728
|
-
isCI,
|
|
729
|
-
force,
|
|
730
|
-
});
|
|
731
|
-
}).pipe(
|
|
732
|
-
Effect.withSpan("state_store.redeploy_if_stale", {
|
|
733
|
-
attributes: {
|
|
734
|
-
"alchemy.state_store.op": "redeploy_if_stale",
|
|
735
|
-
"alchemy.state_store.script_name": scriptName,
|
|
736
|
-
"alchemy.state_store.url": url,
|
|
737
|
-
},
|
|
738
|
-
}),
|
|
739
|
-
);
|
|
623
|
+
authToken: string;
|
|
624
|
+
}) {
|
|
625
|
+
const access = yield* Access.Access;
|
|
626
|
+
const accessHeaders = yield* access.getAccessHeaders(new URL(url).host);
|
|
627
|
+
return yield* makeHttpStateStore({
|
|
628
|
+
url,
|
|
629
|
+
authToken,
|
|
630
|
+
transformClient: HttpClientRequest.setHeaders(accessHeaders),
|
|
631
|
+
id: "cloudflare-http",
|
|
632
|
+
});
|
|
633
|
+
});
|
|
740
634
|
|
|
741
|
-
/**
|
|
742
|
-
* Probe the deployed worker's `/version` endpoint and decide whether
|
|
743
|
-
* it satisfies the current {@link STATE_STORE_VERSION} contract.
|
|
744
|
-
*
|
|
745
|
-
* Reuses the same typed {@link StateApi} client the rest of the store
|
|
746
|
-
* speaks, just without the bearer-token transform — the `/version`
|
|
747
|
-
* route is intentionally unauthenticated so we can probe it before
|
|
748
|
-
* trusting any locally-cached credentials.
|
|
749
|
-
*
|
|
750
|
-
* Returns `true` only on a successful response carrying the matching
|
|
751
|
-
* version. Any failure (404 from a pre-`/version` deploy, transport
|
|
752
|
-
* error, schema mismatch, version mismatch) collapses to `false`,
|
|
753
|
-
* since the caller's response in every case is the same: fall
|
|
754
|
-
* through to the idempotent bootstrap flow.
|
|
755
|
-
*/
|
|
756
|
-
/**
|
|
757
|
-
* Block until the worker at `url` reports the {@link STATE_STORE_VERSION}
|
|
758
|
-
* this CLI was built against, with bounded exponential retry. Used
|
|
759
|
-
* post-deploy to wait out Cloudflare's edge cache so any subsequent
|
|
760
|
-
* read or write goes to the new worker.
|
|
761
|
-
*
|
|
762
|
-
* Failures (transport errors, 404 from a pre-`/version` build, schema
|
|
763
|
-
* mismatch, version mismatch) all collapse to "not ready yet" and are
|
|
764
|
-
* retried; once the budget is exhausted we surface a hard failure
|
|
765
|
-
* since continuing would talk to the wrong worker.
|
|
766
|
-
*/
|
|
767
635
|
class StateStoreVersionNotReady extends Error {
|
|
768
636
|
readonly _tag = "StateStoreVersionNotReady";
|
|
769
637
|
constructor(
|
|
@@ -806,6 +674,9 @@ const waitForStateStoreVersion = (url: string) =>
|
|
|
806
674
|
const checkStateStoreVersion = (url: string) =>
|
|
807
675
|
Effect.gen(function* () {
|
|
808
676
|
const client = yield* HttpApiClient.make(StateApi, { baseUrl: url });
|
|
677
|
+
const isAvailable = yield* Effect.cached(
|
|
678
|
+
isStateStoreAvailable(STATE_STORE_SCRIPT_NAME),
|
|
679
|
+
);
|
|
809
680
|
// The /version route may 404 transiently after a fresh deploy
|
|
810
681
|
// while Cloudflare propagates the new script to the edge, and may
|
|
811
682
|
// also surface transport-level blips on cold workers.dev hosts.
|
|
@@ -813,6 +684,19 @@ const checkStateStoreVersion = (url: string) =>
|
|
|
813
684
|
// exhausting that budget do we collapse to `undefined` and let
|
|
814
685
|
// the caller treat it as a version mismatch.
|
|
815
686
|
const result = yield* client.version.getVersion().pipe(
|
|
687
|
+
Effect.catchTag("HttpClientError", (e) =>
|
|
688
|
+
// if we get a 404 here, it means we assumed the worker shoudl exist, but it does not
|
|
689
|
+
// we should do a check to see if it does
|
|
690
|
+
e.response?.status === 404
|
|
691
|
+
? isAvailable.pipe(
|
|
692
|
+
Effect.flatMap((isAvailable) =>
|
|
693
|
+
// if the worker is available, then we should assume it was recently created and retry by propagating the error
|
|
694
|
+
// otherwise, return undefined (we don't know the version, there is no worker)
|
|
695
|
+
isAvailable ? Effect.fail(e) : Effect.succeed(undefined),
|
|
696
|
+
),
|
|
697
|
+
)
|
|
698
|
+
: Effect.fail(e),
|
|
699
|
+
),
|
|
816
700
|
Effect.retry({
|
|
817
701
|
schedule: Schedule.spaced("250 millis").pipe(
|
|
818
702
|
Schedule.both(Schedule.recurs(40)),
|
|
@@ -920,3 +804,63 @@ const readSecretViaEdge = (
|
|
|
920
804
|
},
|
|
921
805
|
}),
|
|
922
806
|
);
|
|
807
|
+
|
|
808
|
+
const writeCredentials = (url: string, authToken: string) =>
|
|
809
|
+
Effect.gen(function* () {
|
|
810
|
+
const profileName = yield* ALCHEMY_PROFILE;
|
|
811
|
+
const credStore = yield* CredentialsStore;
|
|
812
|
+
yield* credStore.write<HttpStateStoreCredentials>(
|
|
813
|
+
profileName,
|
|
814
|
+
CREDENTIALS_FILE,
|
|
815
|
+
{
|
|
816
|
+
url,
|
|
817
|
+
authToken,
|
|
818
|
+
},
|
|
819
|
+
);
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
const isWorkersPreviewConfigurationError = (error: unknown) =>
|
|
823
|
+
error instanceof EdgeSessionError &&
|
|
824
|
+
(error.message.includes("Invalid Workers Preview configuration") ||
|
|
825
|
+
error.message.includes("Error 1031"));
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* SHA-256 hex digest of the Cloudflare account ID. Used as a stable
|
|
829
|
+
* pseudonymous identifier on telemetry spans so the dashboard can
|
|
830
|
+
* count distinct state-store deployments without leaking the raw
|
|
831
|
+
* accountId. Mirrors the `alchemy.git.origin_hash` pattern in
|
|
832
|
+
* `Telemetry/Attributes.ts`.
|
|
833
|
+
*/
|
|
834
|
+
const hashAccountId = (accountId: string) =>
|
|
835
|
+
Effect.sync(() =>
|
|
836
|
+
crypto.createHash("sha256").update(accountId).digest("hex"),
|
|
837
|
+
);
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Best-effort Cloudflare-account-hash annotation on the current span.
|
|
841
|
+
* Resolves the accountId from {@link CloudflareEnvironment} and
|
|
842
|
+
* attaches `alchemy.cloudflare.account_hash` to whichever span is
|
|
843
|
+
* active. Silently no-ops if the environment isn't resolvable so
|
|
844
|
+
* State-store layer construction still succeeds in degraded paths.
|
|
845
|
+
*
|
|
846
|
+
* `noTrack` controls whether the hash is attached:
|
|
847
|
+
* - `true` — never annotate (caller-level opt-out).
|
|
848
|
+
* - `false` — always annotate, regardless of env.
|
|
849
|
+
* - `undefined` — fall back to the `NO_TRACK` env var; default off.
|
|
850
|
+
*/
|
|
851
|
+
const annotateAccountHash = (noTrack?: boolean) =>
|
|
852
|
+
Effect.gen(function* () {
|
|
853
|
+
if (noTrack === true) return;
|
|
854
|
+
if (noTrack === undefined) {
|
|
855
|
+
const fromEnv = yield* Config.boolean("NO_TRACK").pipe(
|
|
856
|
+
Config.withDefault(false),
|
|
857
|
+
);
|
|
858
|
+
if (fromEnv) return;
|
|
859
|
+
}
|
|
860
|
+
const env = yield* Effect.serviceOption(
|
|
861
|
+
CloudflareEnvironment.CloudflareEnvironment,
|
|
862
|
+
);
|
|
863
|
+
if (env._tag !== "Some") return;
|
|
864
|
+
const hash = yield* hashAccountId(env.value.accountId);
|
|
865
|
+
yield* Effect.annotateCurrentSpan("alchemy.cloudflare.account_hash", hash);
|
|
866
|
+
}).pipe(Effect.catch(() => Effect.void));
|