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.
Files changed (186) hide show
  1. package/bin/alchemy.js +12590 -2452
  2. package/bin/alchemy.js.map +1 -1
  3. package/bin/alchemy.ts +2 -0
  4. package/bin/cli.js +18 -0
  5. package/bin/commands/bootstrap.ts +88 -4
  6. package/bin/commands/deploy.ts +54 -20
  7. package/bin/commands/dev.ts +45 -0
  8. package/bin/commands/logs.ts +1 -1
  9. package/bin/commands/tail.ts +1 -1
  10. package/bin/exec.ts +22 -0
  11. package/lib/AWS/Lambda/Function.d.ts.map +1 -1
  12. package/lib/AWS/Lambda/Function.js +25 -21
  13. package/lib/AWS/Lambda/Function.js.map +1 -1
  14. package/lib/AWS/Lambda/HttpServer.d.ts.map +1 -1
  15. package/lib/AWS/Lambda/HttpServer.js +3 -1
  16. package/lib/AWS/Lambda/HttpServer.js.map +1 -1
  17. package/lib/AdoptPolicy.d.ts +2 -2
  18. package/lib/AdoptPolicy.d.ts.map +1 -1
  19. package/lib/AdoptPolicy.js.map +1 -1
  20. package/lib/AlchemyContext.d.ts +1 -0
  21. package/lib/AlchemyContext.d.ts.map +1 -1
  22. package/lib/AlchemyContext.js +1 -0
  23. package/lib/AlchemyContext.js.map +1 -1
  24. package/lib/Build/Command.d.ts.map +1 -1
  25. package/lib/Build/Command.js +8 -1
  26. package/lib/Build/Command.js.map +1 -1
  27. package/lib/Bundle/Bundle.d.ts +21 -9
  28. package/lib/Bundle/Bundle.d.ts.map +1 -1
  29. package/lib/Bundle/Bundle.js +29 -15
  30. package/lib/Bundle/Bundle.js.map +1 -1
  31. package/lib/Cloudflare/Container/Container.d.ts +55 -2
  32. package/lib/Cloudflare/Container/Container.d.ts.map +1 -1
  33. package/lib/Cloudflare/Container/Container.js +55 -2
  34. package/lib/Cloudflare/Container/Container.js.map +1 -1
  35. package/lib/Cloudflare/D1/D1Export.d.ts.map +1 -1
  36. package/lib/Cloudflare/D1/D1Export.js.map +1 -1
  37. package/lib/Cloudflare/D1/D1SqlFile.d.ts.map +1 -1
  38. package/lib/Cloudflare/D1/D1SqlFile.js.map +1 -1
  39. package/lib/Cloudflare/Local/Sidecar.d.ts +27 -0
  40. package/lib/Cloudflare/Local/Sidecar.d.ts.map +1 -0
  41. package/lib/Cloudflare/Local/Sidecar.js +24 -0
  42. package/lib/Cloudflare/Local/Sidecar.js.map +1 -0
  43. package/lib/Cloudflare/Local/SidecarHandlers.d.ts +5 -0
  44. package/lib/Cloudflare/Local/SidecarHandlers.d.ts.map +1 -0
  45. package/lib/Cloudflare/Local/SidecarHandlers.js +119 -0
  46. package/lib/Cloudflare/Local/SidecarHandlers.js.map +1 -0
  47. package/lib/Cloudflare/Local/SidecarServer.d.ts +2 -0
  48. package/lib/Cloudflare/Local/SidecarServer.d.ts.map +1 -0
  49. package/lib/Cloudflare/Local/SidecarServer.js +27 -0
  50. package/lib/Cloudflare/Local/SidecarServer.js.map +1 -0
  51. package/lib/Cloudflare/Queue/Queue.d.ts +34 -4
  52. package/lib/Cloudflare/Queue/Queue.d.ts.map +1 -1
  53. package/lib/Cloudflare/Queue/Queue.js +34 -4
  54. package/lib/Cloudflare/Queue/Queue.js.map +1 -1
  55. package/lib/Cloudflare/Queue/QueueBinding.d.ts +33 -0
  56. package/lib/Cloudflare/Queue/QueueBinding.d.ts.map +1 -1
  57. package/lib/Cloudflare/Queue/QueueBinding.js +33 -0
  58. package/lib/Cloudflare/Queue/QueueBinding.js.map +1 -1
  59. package/lib/Cloudflare/SecretsStore/Secret.d.ts.map +1 -1
  60. package/lib/Cloudflare/SecretsStore/Secret.js +55 -7
  61. package/lib/Cloudflare/SecretsStore/Secret.js.map +1 -1
  62. package/lib/Cloudflare/SecretsStore/SecretsStore.d.ts.map +1 -1
  63. package/lib/Cloudflare/SecretsStore/SecretsStore.js +35 -11
  64. package/lib/Cloudflare/SecretsStore/SecretsStore.js.map +1 -1
  65. package/lib/Cloudflare/StateStore/State.d.ts +35 -0
  66. package/lib/Cloudflare/StateStore/State.d.ts.map +1 -1
  67. package/lib/Cloudflare/StateStore/State.js +187 -45
  68. package/lib/Cloudflare/StateStore/State.js.map +1 -1
  69. package/lib/Cloudflare/StateStore/index.d.ts +1 -1
  70. package/lib/Cloudflare/StateStore/index.d.ts.map +1 -1
  71. package/lib/Cloudflare/StateStore/index.js +1 -1
  72. package/lib/Cloudflare/StateStore/index.js.map +1 -1
  73. package/lib/Cloudflare/Workers/Assets.d.ts +1 -1
  74. package/lib/Cloudflare/Workers/Assets.d.ts.map +1 -1
  75. package/lib/Cloudflare/Workers/Assets.js +6 -1
  76. package/lib/Cloudflare/Workers/Assets.js.map +1 -1
  77. package/lib/Cloudflare/Workers/Compatibility.d.ts +6 -0
  78. package/lib/Cloudflare/Workers/Compatibility.d.ts.map +1 -0
  79. package/lib/Cloudflare/Workers/Compatibility.js +10 -0
  80. package/lib/Cloudflare/Workers/Compatibility.js.map +1 -0
  81. package/lib/Cloudflare/Workers/DurableObjectNamespace.d.ts +103 -6
  82. package/lib/Cloudflare/Workers/DurableObjectNamespace.d.ts.map +1 -1
  83. package/lib/Cloudflare/Workers/DurableObjectNamespace.js +103 -6
  84. package/lib/Cloudflare/Workers/DurableObjectNamespace.js.map +1 -1
  85. package/lib/Cloudflare/Workers/LocalWorkerProvider.d.ts +7 -0
  86. package/lib/Cloudflare/Workers/LocalWorkerProvider.d.ts.map +1 -0
  87. package/lib/Cloudflare/Workers/LocalWorkerProvider.js +81 -0
  88. package/lib/Cloudflare/Workers/LocalWorkerProvider.js.map +1 -0
  89. package/lib/Cloudflare/Workers/Worker.d.ts +3 -1
  90. package/lib/Cloudflare/Workers/Worker.d.ts.map +1 -1
  91. package/lib/Cloudflare/Workers/Worker.js +54 -7
  92. package/lib/Cloudflare/Workers/Worker.js.map +1 -1
  93. package/lib/Cloudflare/Workers/WorkerBundle.d.ts +31 -0
  94. package/lib/Cloudflare/Workers/WorkerBundle.d.ts.map +1 -0
  95. package/lib/Cloudflare/Workers/WorkerBundle.js +174 -0
  96. package/lib/Cloudflare/Workers/WorkerBundle.js.map +1 -0
  97. package/lib/Cloudflare/Workers/WorkerName.d.ts +3 -0
  98. package/lib/Cloudflare/Workers/WorkerName.d.ts.map +1 -0
  99. package/lib/Cloudflare/Workers/WorkerName.js +9 -0
  100. package/lib/Cloudflare/Workers/WorkerName.js.map +1 -0
  101. package/lib/Cloudflare/Workers/Workflow.d.ts +94 -0
  102. package/lib/Cloudflare/Workers/Workflow.d.ts.map +1 -1
  103. package/lib/Cloudflare/Workers/Workflow.js +94 -0
  104. package/lib/Cloudflare/Workers/Workflow.js.map +1 -1
  105. package/lib/Cloudflare/Workers/index.d.ts +1 -0
  106. package/lib/Cloudflare/Workers/index.d.ts.map +1 -1
  107. package/lib/Cloudflare/Workers/index.js +1 -0
  108. package/lib/Cloudflare/Workers/index.js.map +1 -1
  109. package/lib/Platform.d.ts +1 -1
  110. package/lib/Platform.d.ts.map +1 -1
  111. package/lib/Platform.js +1 -1
  112. package/lib/Platform.js.map +1 -1
  113. package/lib/Sidecar/Lock.d.ts +24 -0
  114. package/lib/Sidecar/Lock.d.ts.map +1 -0
  115. package/lib/Sidecar/Lock.js +102 -0
  116. package/lib/Sidecar/Lock.js.map +1 -0
  117. package/lib/Sidecar/RpcClient.d.ts +15 -0
  118. package/lib/Sidecar/RpcClient.d.ts.map +1 -0
  119. package/lib/Sidecar/RpcClient.js +75 -0
  120. package/lib/Sidecar/RpcClient.js.map +1 -0
  121. package/lib/Sidecar/RpcHandler.d.ts +48 -0
  122. package/lib/Sidecar/RpcHandler.d.ts.map +1 -0
  123. package/lib/Sidecar/RpcHandler.js +64 -0
  124. package/lib/Sidecar/RpcHandler.js.map +1 -0
  125. package/lib/Sidecar/RpcPaths.d.ts +14 -0
  126. package/lib/Sidecar/RpcPaths.d.ts.map +1 -0
  127. package/lib/Sidecar/RpcPaths.js +22 -0
  128. package/lib/Sidecar/RpcPaths.js.map +1 -0
  129. package/lib/Sidecar/RpcServer.d.ts +10 -0
  130. package/lib/Sidecar/RpcServer.d.ts.map +1 -0
  131. package/lib/Sidecar/RpcServer.js +50 -0
  132. package/lib/Sidecar/RpcServer.js.map +1 -0
  133. package/lib/Sidecar/RpcTransport.d.ts +22 -0
  134. package/lib/Sidecar/RpcTransport.d.ts.map +1 -0
  135. package/lib/Sidecar/RpcTransport.js +81 -0
  136. package/lib/Sidecar/RpcTransport.js.map +1 -0
  137. package/lib/Telemetry/Metrics.d.ts +15 -0
  138. package/lib/Telemetry/Metrics.d.ts.map +1 -1
  139. package/lib/Telemetry/Metrics.js +20 -0
  140. package/lib/Telemetry/Metrics.js.map +1 -1
  141. package/lib/Util/PlatformServices.d.ts +3 -0
  142. package/lib/Util/PlatformServices.d.ts.map +1 -1
  143. package/lib/Util/PlatformServices.js +13 -0
  144. package/lib/Util/PlatformServices.js.map +1 -1
  145. package/lib/Util/layer-scoped.d.ts +16 -0
  146. package/lib/Util/layer-scoped.d.ts.map +1 -0
  147. package/lib/Util/layer-scoped.js +14 -0
  148. package/lib/Util/layer-scoped.js.map +1 -0
  149. package/package.json +4 -2
  150. package/src/AWS/Lambda/Function.ts +30 -24
  151. package/src/AWS/Lambda/HttpServer.ts +3 -1
  152. package/src/AdoptPolicy.ts +9 -9
  153. package/src/AlchemyContext.ts +2 -0
  154. package/src/Build/Command.ts +8 -1
  155. package/src/Bundle/Bundle.ts +65 -25
  156. package/src/Cloudflare/Container/Container.ts +55 -2
  157. package/src/Cloudflare/D1/D1Export.ts +1 -5
  158. package/src/Cloudflare/D1/D1SqlFile.ts +1 -3
  159. package/src/Cloudflare/Local/Sidecar.ts +44 -0
  160. package/src/Cloudflare/Local/SidecarHandlers.ts +182 -0
  161. package/src/Cloudflare/Local/SidecarServer.ts +57 -0
  162. package/src/Cloudflare/Queue/Queue.ts +34 -4
  163. package/src/Cloudflare/Queue/QueueBinding.ts +33 -0
  164. package/src/Cloudflare/SecretsStore/Secret.ts +78 -7
  165. package/src/Cloudflare/SecretsStore/SecretsStore.ts +47 -12
  166. package/src/Cloudflare/StateStore/State.ts +281 -64
  167. package/src/Cloudflare/StateStore/index.ts +1 -1
  168. package/src/Cloudflare/Workers/Assets.ts +10 -1
  169. package/src/Cloudflare/Workers/Compatibility.ts +12 -0
  170. package/src/Cloudflare/Workers/DurableObjectNamespace.ts +103 -6
  171. package/src/Cloudflare/Workers/LocalWorkerProvider.ts +93 -0
  172. package/src/Cloudflare/Workers/Worker.ts +102 -19
  173. package/src/Cloudflare/Workers/WorkerBundle.ts +234 -0
  174. package/src/Cloudflare/Workers/WorkerName.ts +10 -0
  175. package/src/Cloudflare/Workers/Workflow.ts +94 -0
  176. package/src/Cloudflare/Workers/index.ts +1 -0
  177. package/src/Platform.ts +2 -2
  178. package/src/Sidecar/Lock.ts +190 -0
  179. package/src/Sidecar/RpcClient.ts +141 -0
  180. package/src/Sidecar/RpcHandler.ts +169 -0
  181. package/src/Sidecar/RpcPaths.ts +29 -0
  182. package/src/Sidecar/RpcServer.ts +80 -0
  183. package/src/Sidecar/RpcTransport.ts +85 -0
  184. package/src/Telemetry/Metrics.ts +38 -0
  185. package/src/Util/PlatformServices.ts +19 -0
  186. 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 response = yield* createStoreSecret({
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
- const secret = response.result[0]!;
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: secret.id,
140
- secretName: secret.name,
141
- storeId: secret.storeId,
209
+ secretId: existing.id,
210
+ secretName: existing.name,
211
+ storeId: existing.storeId,
142
212
  accountId: news.store.accountId,
143
- status: secret.status,
213
+ status: patched?.status ?? existing.status,
144
214
  scopes,
145
- comment: secret.comment ?? undefined,
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 stores = yield* listStores({ accountId });
59
- if (stores.result.length > 0) {
60
- // No name specified — adopt the first (and likely only) store.
61
- const first = stores.result[0]!;
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
- // No store exists yet — create one.
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 store = response.result[0]!;
76
- return {
77
- storeId: store.id,
78
- storeName: store.name,
79
- accountId,
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 { syncState } from "../../State/Sync.ts";
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
- } else {
67
- // our profile does not contain a reference to the state store, let's try and resolve it
68
- const { accountId } =
69
- yield* CloudflareEnvironment.CloudflareEnvironment;
70
- const workerExists = yield* workers
71
- .getScriptSetting({
72
- accountId,
73
- scriptName,
74
- })
75
- .pipe(
76
- Effect.map((setting) => setting !== undefined),
77
- Effect.catchTag("WorkerNotFound", () => Effect.succeed(false)),
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
- if (workerExists) {
81
- // it exists, so fetch the secret token
82
- const credentials = yield* loginWithCloudflare();
83
- if (!isCI) {
84
- yield* writeCredentials<HttpStateStoreProps>(
85
- profileName,
86
- CREDENTIALS_FILE,
87
- credentials,
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
- const shouldDeploy = yield* Clank.confirm({
108
- message:
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
- const { url, authToken, localState } =
117
- yield* deployStateStore(scriptName);
118
-
119
- const httpState = yield* makeHttpStateStore({ url, authToken });
120
-
121
- yield* syncState(localState, httpState);
228
+ return httpState;
229
+ }
122
230
 
123
- yield* writeCredentials<HttpStateStoreProps>(
124
- profileName,
125
- CREDENTIALS_FILE,
126
- {
127
- url,
128
- authToken,
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
- yield* localState.deleteStack({
133
- stack: "CloudflareStateStore",
134
- stage: scriptName,
135
- });
241
+ const shouldDeploy = yield* Clank.confirm({
242
+ message: "Cloudflare State Store not found. Do you want to deploy it?",
243
+ });
136
244
 
137
- return httpState;
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
- fs.readDirectory(resolvedDirectory, { recursive: true }),
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
+ });