alchemy 2.0.0-beta.22 → 2.0.0-beta.24
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 +12590 -2452
- 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/commands/logs.ts +1 -1
- package/bin/commands/tail.ts +1 -1
- package/bin/exec.ts +22 -0
- package/lib/AWS/Lambda/Function.d.ts.map +1 -1
- package/lib/AWS/Lambda/Function.js +25 -21
- package/lib/AWS/Lambda/Function.js.map +1 -1
- package/lib/AWS/Lambda/HttpServer.d.ts.map +1 -1
- package/lib/AWS/Lambda/HttpServer.js +3 -1
- package/lib/AWS/Lambda/HttpServer.js.map +1 -1
- 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/Container/Container.d.ts +55 -2
- package/lib/Cloudflare/Container/Container.d.ts.map +1 -1
- package/lib/Cloudflare/Container/Container.js +55 -2
- package/lib/Cloudflare/Container/Container.js.map +1 -1
- package/lib/Cloudflare/D1/D1Export.d.ts.map +1 -1
- package/lib/Cloudflare/D1/D1Export.js.map +1 -1
- package/lib/Cloudflare/D1/D1SqlFile.d.ts.map +1 -1
- package/lib/Cloudflare/D1/D1SqlFile.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/Queue/Queue.d.ts +34 -4
- package/lib/Cloudflare/Queue/Queue.d.ts.map +1 -1
- package/lib/Cloudflare/Queue/Queue.js +34 -4
- package/lib/Cloudflare/Queue/Queue.js.map +1 -1
- package/lib/Cloudflare/Queue/QueueBinding.d.ts +33 -0
- package/lib/Cloudflare/Queue/QueueBinding.d.ts.map +1 -1
- package/lib/Cloudflare/Queue/QueueBinding.js +33 -0
- package/lib/Cloudflare/Queue/QueueBinding.js.map +1 -1
- 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/Assets.d.ts +1 -1
- package/lib/Cloudflare/Workers/Assets.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/Assets.js +6 -1
- package/lib/Cloudflare/Workers/Assets.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/DurableObjectNamespace.d.ts +103 -6
- package/lib/Cloudflare/Workers/DurableObjectNamespace.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/DurableObjectNamespace.js +103 -6
- package/lib/Cloudflare/Workers/DurableObjectNamespace.js.map +1 -1
- 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 +54 -7
- 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/Workflow.d.ts +94 -0
- package/lib/Cloudflare/Workers/Workflow.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/Workflow.js +94 -0
- package/lib/Cloudflare/Workers/Workflow.js.map +1 -1
- 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/Platform.js +1 -1
- package/lib/Platform.js.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/AWS/Lambda/Function.ts +30 -24
- package/src/AWS/Lambda/HttpServer.ts +3 -1
- 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/Container/Container.ts +55 -2
- package/src/Cloudflare/D1/D1Export.ts +1 -5
- package/src/Cloudflare/D1/D1SqlFile.ts +1 -3
- 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/Queue/Queue.ts +34 -4
- package/src/Cloudflare/Queue/QueueBinding.ts +33 -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/Assets.ts +10 -1
- package/src/Cloudflare/Workers/Compatibility.ts +12 -0
- package/src/Cloudflare/Workers/DurableObjectNamespace.ts +103 -6
- package/src/Cloudflare/Workers/LocalWorkerProvider.ts +93 -0
- package/src/Cloudflare/Workers/Worker.ts +102 -19
- package/src/Cloudflare/Workers/WorkerBundle.ts +234 -0
- package/src/Cloudflare/Workers/WorkerName.ts +10 -0
- package/src/Cloudflare/Workers/Workflow.ts +94 -0
- package/src/Cloudflare/Workers/index.ts +1 -0
- package/src/Platform.ts +2 -2
- 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
|
@@ -3,6 +3,7 @@ import * as Effect from "effect/Effect";
|
|
|
3
3
|
import * as Option from "effect/Option";
|
|
4
4
|
import * as Redacted from "effect/Redacted";
|
|
5
5
|
import * as Stream from "effect/Stream";
|
|
6
|
+
import { AdoptPolicy } from "../../AdoptPolicy.ts";
|
|
6
7
|
import { isResolved } from "../../Diff.ts";
|
|
7
8
|
import * as Provider from "../../Provider.ts";
|
|
8
9
|
import { Resource } from "../../Resource.ts";
|
|
@@ -122,7 +123,11 @@ export const StoreSecretProvider = () =>
|
|
|
122
123
|
create: Effect.fn(function* ({ id, news }) {
|
|
123
124
|
const name = resolveName(id, news.name);
|
|
124
125
|
const scopes = resolveScopes(news.scopes);
|
|
125
|
-
const
|
|
126
|
+
const adoptEnabled = yield* Effect.serviceOption(AdoptPolicy).pipe(
|
|
127
|
+
Effect.map(Option.getOrElse(() => false)),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const create = createStoreSecret({
|
|
126
131
|
accountId: news.store.accountId,
|
|
127
132
|
storeId: news.store.storeId,
|
|
128
133
|
body: [
|
|
@@ -134,15 +139,81 @@ export const StoreSecretProvider = () =>
|
|
|
134
139
|
},
|
|
135
140
|
],
|
|
136
141
|
});
|
|
137
|
-
|
|
142
|
+
// Only swallow the conflict when adoption is opted-in via
|
|
143
|
+
// `AdoptPolicy`; otherwise surface `SecretNameAlreadyExists`
|
|
144
|
+
// so the caller learns the secret already exists rather than
|
|
145
|
+
// silently taking ownership of someone else's secret.
|
|
146
|
+
const created = adoptEnabled
|
|
147
|
+
? yield* create.pipe(
|
|
148
|
+
Effect.catchTag("SecretNameAlreadyExists", () =>
|
|
149
|
+
Effect.succeed(undefined),
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
: yield* create;
|
|
153
|
+
|
|
154
|
+
if (created) {
|
|
155
|
+
const secret = created.result[0]!;
|
|
156
|
+
return {
|
|
157
|
+
secretId: secret.id,
|
|
158
|
+
secretName: secret.name,
|
|
159
|
+
storeId: secret.storeId,
|
|
160
|
+
accountId: news.store.accountId,
|
|
161
|
+
status: secret.status,
|
|
162
|
+
scopes,
|
|
163
|
+
comment: secret.comment ?? undefined,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Adopt-on-conflict path. A secret with this name already
|
|
168
|
+
// exists in Cloudflare — typically because a previous deploy
|
|
169
|
+
// partially failed (e.g. the secret was created server-side
|
|
170
|
+
// but the response was lost so local state never recorded
|
|
171
|
+
// it). We re-fetch the existing secret and reconcile
|
|
172
|
+
// scopes/comment. The value itself cannot be read back from
|
|
173
|
+
// the API and we trust that an identically-named secret in
|
|
174
|
+
// the same store reflects the same intent (this matches how
|
|
175
|
+
// SecretsStore itself adopts the account-wide store).
|
|
176
|
+
const existing = yield* listStoreSecrets
|
|
177
|
+
.items({
|
|
178
|
+
accountId: news.store.accountId,
|
|
179
|
+
storeId: news.store.storeId,
|
|
180
|
+
})
|
|
181
|
+
.pipe(
|
|
182
|
+
Stream.filter((s) => s.name === name),
|
|
183
|
+
Stream.runHead,
|
|
184
|
+
Effect.map(Option.getOrUndefined),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
if (!existing) {
|
|
188
|
+
return yield* Effect.die(
|
|
189
|
+
new Error(
|
|
190
|
+
`Secret '${name}' reported as already existing in store ${news.store.storeId} but could not be found on lookup.`,
|
|
191
|
+
),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// listStoreSecrets does not surface scopes, so we have no way
|
|
196
|
+
// to detect drift; reconcile to the requested scopes/comment
|
|
197
|
+
// unconditionally. PATCH is cheap and idempotent.
|
|
198
|
+
const patched = yield* patchStoreSecret({
|
|
199
|
+
accountId: news.store.accountId,
|
|
200
|
+
storeId: news.store.storeId,
|
|
201
|
+
secretId: existing.id,
|
|
202
|
+
scopes,
|
|
203
|
+
comment: news.comment,
|
|
204
|
+
}).pipe(
|
|
205
|
+
Effect.catchTag("SecretNotFound", () => Effect.succeed(undefined)),
|
|
206
|
+
);
|
|
207
|
+
|
|
138
208
|
return {
|
|
139
|
-
secretId:
|
|
140
|
-
secretName:
|
|
141
|
-
storeId:
|
|
209
|
+
secretId: existing.id,
|
|
210
|
+
secretName: existing.name,
|
|
211
|
+
storeId: existing.storeId,
|
|
142
212
|
accountId: news.store.accountId,
|
|
143
|
-
status:
|
|
213
|
+
status: patched?.status ?? existing.status,
|
|
144
214
|
scopes,
|
|
145
|
-
comment:
|
|
215
|
+
comment:
|
|
216
|
+
patched?.comment ?? news.comment ?? existing.comment ?? undefined,
|
|
146
217
|
};
|
|
147
218
|
}),
|
|
148
219
|
update: Effect.fn(function* ({ news, olds = {} as any, output }) {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import * as secretsStore from "@distilled.cloud/cloudflare/secrets-store";
|
|
2
2
|
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Option from "effect/Option";
|
|
4
|
+
import { AdoptPolicy } from "../../AdoptPolicy.ts";
|
|
3
5
|
import * as Provider from "../../Provider.ts";
|
|
4
6
|
import { Resource } from "../../Resource.ts";
|
|
5
7
|
import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
|
|
@@ -55,29 +57,62 @@ export const SecretsStoreProvider = () =>
|
|
|
55
57
|
return {
|
|
56
58
|
stables: ["storeId", "storeName", "accountId"],
|
|
57
59
|
create: Effect.fn(function* () {
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
const adoptEnabled = yield* Effect.serviceOption(AdoptPolicy).pipe(
|
|
61
|
+
Effect.map(Option.getOrElse(() => false)),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const adoptExisting = Effect.gen(function* () {
|
|
65
|
+
const stores = yield* listStores({ accountId });
|
|
66
|
+
const first = stores.result[0];
|
|
67
|
+
if (!first) return undefined;
|
|
62
68
|
return {
|
|
63
69
|
storeId: first.id,
|
|
64
70
|
storeName: first.name,
|
|
65
71
|
accountId,
|
|
66
72
|
};
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Cloudflare allows exactly one Secrets Store per account, so
|
|
76
|
+
// any account that's been touched before may already have one.
|
|
77
|
+
// Only adopt it if the caller opted in via `AdoptPolicy`,
|
|
78
|
+
// otherwise let `createStore` surface MaximumStoresExceeded.
|
|
79
|
+
if (adoptEnabled) {
|
|
80
|
+
const adopted = yield* adoptExisting;
|
|
81
|
+
if (adopted) return adopted;
|
|
67
82
|
}
|
|
68
83
|
|
|
69
|
-
|
|
70
|
-
const response = yield* createStore({
|
|
84
|
+
const create = createStore({
|
|
71
85
|
accountId,
|
|
72
86
|
//`default_secrets_store` is the name cloudflare uses to create a secret store
|
|
73
87
|
body: [{ name: "default_secrets_store" }],
|
|
74
88
|
});
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
89
|
+
const response = adoptEnabled
|
|
90
|
+
? yield* create.pipe(
|
|
91
|
+
// A concurrent process (or a previous partially-failed
|
|
92
|
+
// deploy) may have raced us between list and create.
|
|
93
|
+
Effect.catchTag("MaximumStoresExceeded", () =>
|
|
94
|
+
Effect.succeed(undefined),
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
: yield* create;
|
|
98
|
+
|
|
99
|
+
if (response) {
|
|
100
|
+
const store = response.result[0]!;
|
|
101
|
+
return {
|
|
102
|
+
storeId: store.id,
|
|
103
|
+
storeName: store.name,
|
|
104
|
+
accountId,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const recovered = yield* adoptExisting;
|
|
109
|
+
if (recovered) return recovered;
|
|
110
|
+
|
|
111
|
+
return yield* Effect.die(
|
|
112
|
+
new Error(
|
|
113
|
+
`Cloudflare reported MaximumStoresExceeded for account ${accountId} but no store could be listed.`,
|
|
114
|
+
),
|
|
115
|
+
);
|
|
81
116
|
}),
|
|
82
117
|
update: Effect.fn(function* ({ output }) {
|
|
83
118
|
return output;
|
|
@@ -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";
|
|
@@ -109,7 +109,16 @@ export const readAssets = Effect.fnUntraced(function* (props: AssetsProps) {
|
|
|
109
109
|
const path = yield* Path.Path;
|
|
110
110
|
const resolvedDirectory = path.resolve(props.directory);
|
|
111
111
|
const [files, ignore, _headers, _redirects] = yield* Effect.all([
|
|
112
|
-
|
|
112
|
+
// Tolerate a missing directory: state may carry an absolute path
|
|
113
|
+
// from a different machine (e.g. CI) and we'd rather upload no
|
|
114
|
+
// assets than crash the whole deploy.
|
|
115
|
+
fs.readDirectory(resolvedDirectory, { recursive: true }).pipe(
|
|
116
|
+
Effect.catchTag("PlatformError", (e) =>
|
|
117
|
+
e.reason._tag === "NotFound"
|
|
118
|
+
? Effect.succeed([] as readonly string[])
|
|
119
|
+
: Effect.fail(e),
|
|
120
|
+
),
|
|
121
|
+
),
|
|
113
122
|
maybeReadString(path.join(resolvedDirectory, ".assetsignore")),
|
|
114
123
|
maybeReadString(path.join(resolvedDirectory, "_headers")),
|
|
115
124
|
maybeReadString(path.join(resolvedDirectory, "_redirects")),
|
|
@@ -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
|
+
});
|