alchemy 2.0.0-beta.32 → 2.0.0-beta.34

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alchemy",
3
- "version": "2.0.0-beta.32",
3
+ "version": "2.0.0-beta.34",
4
4
  "homepage": "https://alchemy.run",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Sam Goodwin <sam@alchemy.run>",
@@ -275,14 +275,14 @@
275
275
  "@ai-sdk/provider": "^3.0.8",
276
276
  "@aws-sdk/credential-providers": "^3.0.0",
277
277
  "@clack/prompts": "^0.11.0",
278
- "@distilled.cloud/aws": "^0.16.7",
279
- "@distilled.cloud/axiom": "^0.16.7",
280
- "@distilled.cloud/cloudflare": "^0.16.8",
278
+ "@distilled.cloud/aws": "^0.17.0",
279
+ "@distilled.cloud/axiom": "^0.17.0",
280
+ "@distilled.cloud/cloudflare": "^0.17.0",
281
281
  "@distilled.cloud/cloudflare-rolldown-plugin": "0.3.0",
282
282
  "@distilled.cloud/cloudflare-runtime": "0.2.1",
283
283
  "@distilled.cloud/cloudflare-vite-plugin": "0.2.0",
284
- "@distilled.cloud/core": "^0.16.7",
285
- "@distilled.cloud/neon": "^0.16.7",
284
+ "@distilled.cloud/core": "^0.17.0",
285
+ "@distilled.cloud/neon": "^0.17.0",
286
286
  "pg": "^8.13.0",
287
287
  "@effect/vitest": ">=4.0.0-beta.60 || >=4.0.0",
288
288
  "@libsql/client": "^0.17.0",
@@ -32,14 +32,19 @@ export const devCommand = Command.make(
32
32
  dev: true,
33
33
  });
34
34
  const bin = typeof globalThis.Bun !== "undefined" ? "bun" : "node";
35
+ const main = fileURLToPath(import.meta.resolve(execEntry));
35
36
  const child = yield* ChildProcess.make(
36
37
  bin,
37
- [
38
- "run",
39
- "--watch",
40
- "--no-clear-screen",
41
- fileURLToPath(import.meta.resolve(execEntry)),
42
- ],
38
+ {
39
+ bun: ["run", ...process.execArgv, "--watch", "--no-clear-screen", main],
40
+ node: [
41
+ ...process.execArgv,
42
+ "--experimental-transform-types",
43
+ "--watch",
44
+ "--watch-preserve-output",
45
+ main,
46
+ ],
47
+ }[bin],
43
48
  {
44
49
  stdin: "inherit",
45
50
  stdout: "inherit",
@@ -1,22 +1,17 @@
1
- import {
2
- BearerTokenValidator,
3
- StateApi,
4
- StateAuthLive,
5
- } from "../../State/HttpStateApi.ts";
6
1
  import * as Effect from "effect/Effect";
7
2
  import * as Layer from "effect/Layer";
8
3
  import * as Path from "effect/Path";
9
4
  import * as Etag from "effect/unstable/http/Etag";
10
- import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
11
5
  import * as HttpPlatform from "effect/unstable/http/HttpPlatform";
12
6
  import * as HttpRouter from "effect/unstable/http/HttpRouter";
13
7
  import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
14
8
  import * as HttpApiError from "effect/unstable/httpapi/HttpApiError";
15
- import * as OtlpLogger from "effect/unstable/observability/OtlpLogger";
16
- import * as OtlpMetrics from "effect/unstable/observability/OtlpMetrics";
17
- import * as OtlpSerialization from "effect/unstable/observability/OtlpSerialization";
18
- import * as OtlpTracer from "effect/unstable/observability/OtlpTracer";
19
9
  import crypto from "node:crypto";
10
+ import {
11
+ BearerTokenValidator,
12
+ StateApi,
13
+ StateAuthLive,
14
+ } from "../../State/HttpStateApi.ts";
20
15
  import * as Secret from "../SecretsStore/Secret.ts";
21
16
  import { SecretBindingLive } from "../SecretsStore/SecretBinding.ts";
22
17
  import { Worker } from "../Workers/Worker.ts";
@@ -34,7 +29,7 @@ export const STATE_STORE_SCRIPT_NAME = "alchemy-state-store" as const;
34
29
  * compare against this constant; a mismatch (or 404) triggers a
35
30
  * forced redeploy via the bootstrap flow.
36
31
  */
37
- export const STATE_STORE_VERSION = 2 as const;
32
+ export const STATE_STORE_VERSION = 3 as const;
38
33
 
39
34
  /**
40
35
  * Hard-coded OTLP/HTTP endpoints. Point at the public ingest relay
@@ -43,9 +38,9 @@ export const STATE_STORE_VERSION = 2 as const;
43
38
  * coded on purpose: the worker has no env-var plumbing and the relay
44
39
  * is account-level infra that lives outside any single deploy.
45
40
  */
46
- const OTEL_TRACES_URL = "https://otel.alchemy.run/v1/traces";
47
- const OTEL_METRICS_URL = "https://otel.alchemy.run/v1/metrics";
48
- const OTEL_LOGS_URL = "https://otel.alchemy.run/v1/logs";
41
+ // const OTEL_TRACES_URL = "https://otel.alchemy.run/v1/traces";
42
+ // const OTEL_METRICS_URL = "https://otel.alchemy.run/v1/metrics";
43
+ // const OTEL_LOGS_URL = "https://otel.alchemy.run/v1/logs";
49
44
 
50
45
  /**
51
46
  * OTLP traces + metrics + logs Layer for the state-store worker.
@@ -55,38 +50,38 @@ const OTEL_LOGS_URL = "https://otel.alchemy.run/v1/logs";
55
50
  * brand each signal with the worker's service name + contract version
56
51
  * so they're easy to slice in Axiom.
57
52
  */
58
- const otelResource = {
59
- serviceName: STATE_STORE_SCRIPT_NAME,
60
- serviceVersion: String(STATE_STORE_VERSION),
61
- attributes: {
62
- "alchemy.state_store.script_name": STATE_STORE_SCRIPT_NAME,
63
- "alchemy.state_store.version": STATE_STORE_VERSION,
64
- },
65
- } as const;
53
+ // const otelResource = {
54
+ // serviceName: STATE_STORE_SCRIPT_NAME,
55
+ // serviceVersion: String(STATE_STORE_VERSION),
56
+ // attributes: {
57
+ // "alchemy.state_store.script_name": STATE_STORE_SCRIPT_NAME,
58
+ // "alchemy.state_store.version": STATE_STORE_VERSION,
59
+ // },
60
+ // } as const;
66
61
 
67
- const TelemetryLive = Layer.mergeAll(
68
- OtlpTracer.layer({
69
- url: OTEL_TRACES_URL,
70
- resource: otelResource,
71
- exportInterval: "1 second",
72
- }),
73
- OtlpMetrics.layer({
74
- url: OTEL_METRICS_URL,
75
- resource: otelResource,
76
- exportInterval: "1 second",
77
- }),
78
- // Replace (don't merge with) the default stdout logger so worker logs
79
- // ship to Axiom instead of just `wrangler tail`.
80
- OtlpLogger.layer({
81
- url: OTEL_LOGS_URL,
82
- resource: otelResource,
83
- exportInterval: "1 second",
84
- mergeWithExisting: false,
85
- }),
86
- ).pipe(
87
- Layer.provide(OtlpSerialization.layerJson),
88
- Layer.provide(FetchHttpClient.layer),
89
- );
62
+ // const TelemetryLive = Layer.mergeAll(
63
+ // OtlpTracer.layer({
64
+ // url: OTEL_TRACES_URL,
65
+ // resource: otelResource,
66
+ // exportInterval: "1 second",
67
+ // }),
68
+ // OtlpMetrics.layer({
69
+ // url: OTEL_METRICS_URL,
70
+ // resource: otelResource,
71
+ // exportInterval: "1 second",
72
+ // }),
73
+ // // Replace (don't merge with) the default stdout logger so worker logs
74
+ // // ship to Axiom instead of just `wrangler tail`.
75
+ // OtlpLogger.layer({
76
+ // url: OTEL_LOGS_URL,
77
+ // resource: otelResource,
78
+ // exportInterval: "1 second",
79
+ // mergeWithExisting: false,
80
+ // }),
81
+ // ).pipe(
82
+ // Layer.provide(OtlpSerialization.layerJson),
83
+ // Layer.provide(FetchHttpClient.layer),
84
+ // );
90
85
 
91
86
  /**
92
87
  * Path on disk to *this* file, used as the worker's bundling entry.
@@ -288,7 +283,7 @@ export default Worker(
288
283
  // file-response surface is stubbed.
289
284
  Layer.provide([Etag.layer, HttpPlatformStub, Path.layer]),
290
285
  HttpRouter.toHttpEffect,
291
- Effect.provide(TelemetryLive),
286
+ // Effect.provide(TelemetryLive),
292
287
  ),
293
288
  };
294
289
  }).pipe(Effect.provide(Layer.mergeAll(SecretBindingLive))),
@@ -1,13 +1,14 @@
1
1
  import * as SecretsStore from "@distilled.cloud/cloudflare/secrets-store";
2
2
  import * as workers from "@distilled.cloud/cloudflare/workers";
3
3
  import * as Effect from "effect/Effect";
4
- import * as Output from "../../Output.ts";
5
4
  import * as Layer from "effect/Layer";
6
5
  import * as Redacted from "effect/Redacted";
6
+ import * as Schedule from "effect/Schedule";
7
7
  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";
11
12
 
12
13
  import * as Config from "effect/Config";
13
14
  import { adopt } from "../../AdoptPolicy.ts";
@@ -198,32 +199,7 @@ export const bootstrap = (options: BootstrapOptions = {}) =>
198
199
  }),
199
200
  );
200
201
 
201
- export const state = (props?: {
202
- /**
203
- * The name of the script to use for the state store.
204
- * @default "alchemy-state-store"
205
- */
206
- workerName?: string;
207
- /**
208
- * Suppress the per-deployment Cloudflare account hash on telemetry
209
- * spans. Lets a caller opt out of having their state-store's
210
- * accountId pseudonymously counted on the maintainer dashboard
211
- * without disabling all telemetry.
212
- *
213
- * - `true` — always opt out, regardless of env.
214
- * - `false` — always opt in, regardless of env.
215
- * - `undefined` (default) — fall back to the `NO_TRACK` env var
216
- * (`true`/`1` → opt out). Default-default is opt-in.
217
- *
218
- * Independent of the global telemetry kill-switch
219
- * (`DO_NOT_TRACK` / `ALCHEMY_TELEMETRY_DISABLED`), which kills the
220
- * entire OTLP exporter — `noTrack` here only suppresses the
221
- * `alchemy.cloudflare.account_hash` attribute on state-store
222
- * spans, leaving the rest of the telemetry intact.
223
- * @default undefined
224
- */
225
- noTrack?: boolean;
226
- }) =>
202
+ export const state = () =>
227
203
  Layer.effect(
228
204
  State,
229
205
  Effect.gen(function* () {
@@ -235,13 +211,15 @@ export const state = (props?: {
235
211
  CREDENTIALS_FILE,
236
212
  );
237
213
 
238
- const scriptName = props?.workerName ?? STATE_STORE_SCRIPT_NAME;
214
+ const scriptName = STATE_STORE_SCRIPT_NAME;
239
215
  yield* Effect.annotateCurrentSpan({
240
216
  "alchemy.state_store.script_name": scriptName,
241
217
  "alchemy.state_store.profile": profileName,
242
218
  "alchemy.state_store.ci": isCI,
243
219
  });
244
- yield* annotateAccountHash(props?.noTrack);
220
+ yield* annotateAccountHash(
221
+ yield* Config.boolean("NO_TRACK").pipe(Config.withDefault(false)),
222
+ );
245
223
 
246
224
  // The bootstrap of the Cloudflare State Store is only considered
247
225
  // successful once two invariants hold:
@@ -529,6 +507,16 @@ const deployStateStore = (scriptName: string, state?: StateService) =>
529
507
  // TODO(sam): we should not need to do this, but types do complain. fix deploy
530
508
  Effect.provide(stateLayer),
531
509
  );
510
+ // Cloudflare's worker upload is eventually consistent: the deploy
511
+ // call returns as soon as the script upload is accepted, but the
512
+ // edge can keep serving the previous version for several seconds
513
+ // afterwards. Block here until `/version` reports the version this
514
+ // CLI was built against — otherwise downstream steps (syncing
515
+ // local state into the deployed store, version probes during
516
+ // adoption) end up talking to the old worker and may either
517
+ // observe stale data or trip the staleness check and recurse into
518
+ // another redeploy.
519
+ yield* waitForStateStoreVersion(url);
532
520
  return { url, authToken, localState };
533
521
  }).pipe(
534
522
  Effect.withSpan("state_store.deploy", {
@@ -694,19 +682,84 @@ const redeployIfStale = ({
694
682
  * since the caller's response in every case is the same: fall
695
683
  * through to the idempotent bootstrap flow.
696
684
  */
685
+ /**
686
+ * Block until the worker at `url` reports the {@link STATE_STORE_VERSION}
687
+ * this CLI was built against, with bounded exponential retry. Used
688
+ * post-deploy to wait out Cloudflare's edge cache so any subsequent
689
+ * read or write goes to the new worker.
690
+ *
691
+ * Failures (transport errors, 404 from a pre-`/version` build, schema
692
+ * mismatch, version mismatch) all collapse to "not ready yet" and are
693
+ * retried; once the budget is exhausted we surface a hard failure
694
+ * since continuing would talk to the wrong worker.
695
+ */
696
+ class StateStoreVersionNotReady extends Error {
697
+ readonly _tag = "StateStoreVersionNotReady";
698
+ constructor(
699
+ readonly expected: number,
700
+ readonly observed: number | undefined,
701
+ ) {
702
+ super(
703
+ `Cloudflare State Store version not ready (expected v${expected}, observed v${observed ?? "unknown"}).`,
704
+ );
705
+ }
706
+ }
707
+
708
+ const waitForStateStoreVersion = (url: string) =>
709
+ Effect.gen(function* () {
710
+ const { matches, expected, observed } = yield* checkStateStoreVersion(url);
711
+ if (!matches) {
712
+ return yield* Effect.fail(
713
+ new StateStoreVersionNotReady(expected, observed),
714
+ );
715
+ }
716
+ }).pipe(
717
+ Effect.retry({
718
+ while: (error) => error instanceof StateStoreVersionNotReady,
719
+ // Edge propagation is usually sub-second; poll fast and cap the
720
+ // overall wait at ~10s so we fail loudly if something is really
721
+ // wrong rather than silently hanging.
722
+ schedule: Schedule.spaced("200 millis").pipe(
723
+ Schedule.both(Schedule.recurs(50)),
724
+ ),
725
+ }),
726
+ Effect.withSpan("state_store.wait_for_version", {
727
+ attributes: {
728
+ "alchemy.state_store.op": "wait_for_version",
729
+ "alchemy.state_store.url": url,
730
+ "alchemy.state_store.expected_version": STATE_STORE_VERSION,
731
+ },
732
+ }),
733
+ );
734
+
697
735
  const checkStateStoreVersion = (url: string) =>
698
736
  Effect.gen(function* () {
699
737
  const client = yield* HttpApiClient.make(StateApi, { baseUrl: url });
700
- const result = yield* client.version
701
- .getVersion()
702
- .pipe(Effect.catch(() => Effect.succeed(undefined)));
738
+ // The /version route may 404 transiently after a fresh deploy
739
+ // while Cloudflare propagates the new script to the edge, and may
740
+ // also surface transport-level blips on cold workers.dev hosts.
741
+ // Retry the probe itself for ~10s before giving up — only after
742
+ // exhausting that budget do we collapse to `undefined` and let
743
+ // the caller treat it as a version mismatch.
744
+ const result = yield* client.version.getVersion().pipe(
745
+ Effect.retry({
746
+ schedule: Schedule.spaced("250 millis").pipe(
747
+ Schedule.both(Schedule.recurs(40)),
748
+ ),
749
+ }),
750
+ Effect.catch(() => Effect.succeed(undefined)),
751
+ );
703
752
  const matches = result?.version === STATE_STORE_VERSION;
704
753
  yield* Effect.annotateCurrentSpan({
705
754
  "alchemy.state_store.expected_version": STATE_STORE_VERSION,
706
755
  "alchemy.state_store.observed_version": result?.version ?? -1,
707
756
  "alchemy.state_store.version_match": matches,
708
757
  });
709
- return { matches, expected: STATE_STORE_VERSION, observed: result?.version };
758
+ return {
759
+ matches,
760
+ expected: STATE_STORE_VERSION,
761
+ observed: result?.version,
762
+ };
710
763
  }).pipe(
711
764
  Effect.withSpan("state_store.check_version", {
712
765
  attributes: { "alchemy.state_store.op": "check_version" },
@@ -893,8 +893,8 @@ export const Worker: Platform<
893
893
  const scope = Scope.makeUnsafe();
894
894
  return eff
895
895
  .pipe(
896
- Scope.provide(scope),
897
896
  Effect.provideContext(services),
897
+ Scope.provide(scope),
898
898
  Effect.provide(
899
899
  Layer.succeed(WorkerExecutionContext, context),
900
900
  ),
@@ -1091,11 +1091,18 @@ export const LiveWorkerProvider = () =>
1091
1091
  accountId,
1092
1092
  }).pipe(Effect.map((result) => result.subdomain));
1093
1093
 
1094
+ // Toggle the workers.dev subdomain via `POST /subdomain` with
1095
+ // `enabled: true | false`. Mirrors the upstream Alchemy
1096
+ // implementation in `.vendor/alchemy/.../worker-subdomain.ts`.
1097
+ // When enabling we also set `previewsEnabled: true` so the
1098
+ // script is reachable both at its stable workers.dev URL and at
1099
+ // version-preview URLs; on disable we send just `enabled: false`.
1094
1100
  const setWorkerSubdomain = (name: string, enabled: boolean) =>
1095
1101
  createScriptSubdomain({
1096
1102
  accountId,
1097
1103
  scriptName: name,
1098
1104
  enabled,
1105
+ previewsEnabled: enabled ? true : undefined,
1099
1106
  });
1100
1107
 
1101
1108
  const createWorkerName = (id: string, name: string | undefined) =>
@@ -1923,12 +1930,26 @@ export const LiveWorkerProvider = () =>
1923
1930
  name,
1924
1931
  expectedDurableObjectClassNames,
1925
1932
  );
1926
- if (!olds || news.url !== olds.url) {
1927
- const enable = news.url !== false;
1933
+ // Reconcile workers.dev subdomain against observed cloud state.
1934
+ // We can't diff `news.url` against `olds.url` here because both
1935
+ // default to `undefined` (meaning "enable") — that comparison
1936
+ // would skip the API call on every deploy where the user never
1937
+ // explicitly set `url`, leaving the subdomain in whatever state
1938
+ // Cloudflare currently has it (disabled by default, or whatever
1939
+ // a previous failed/external action left it as).
1940
+ const desiredSubdomainEnabled = news.url !== false;
1941
+ const observedSubdomainEnabled = yield* getScriptSubdomain({
1942
+ accountId,
1943
+ scriptName: name,
1944
+ }).pipe(
1945
+ Effect.map((s) => s.enabled === true),
1946
+ Effect.catch(() => Effect.succeed(false)),
1947
+ );
1948
+ if (desiredSubdomainEnabled !== observedSubdomainEnabled) {
1928
1949
  yield* session.note(
1929
- `${enable ? "Enabling" : "Disabling"} workers.dev subdomain...`,
1950
+ `${desiredSubdomainEnabled ? "Enabling" : "Disabling"} workers.dev subdomain...`,
1930
1951
  );
1931
- yield* setWorkerSubdomain(name, enable);
1952
+ yield* setWorkerSubdomain(name, desiredSubdomainEnabled);
1932
1953
  }
1933
1954
  const desiredDomains = normalizeDomains(news.domain);
1934
1955
  const previousDomains = output?.domains ?? [];
@@ -11,7 +11,21 @@ import * as HttpApiMiddleware from "effect/unstable/httpapi/HttpApiMiddleware";
11
11
  import * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema";
12
12
  import * as HttpApiSecurity from "effect/unstable/httpapi/HttpApiSecurity";
13
13
 
14
- export const ResourceStateSchema = Schema.Any;
14
+ /**
15
+ * Resource-state payload shape on the wire.
16
+ *
17
+ * Pinned to JSON encoding via {@link HttpApiSchema.asJson} so that both
18
+ * sides of the API agree on `Content-Type: application/json`. With a
19
+ * bare `Schema.Any` the server's payload-decoder map is keyed off the
20
+ * default content type only — any client request that arrives with a
21
+ * differently-shaped body (or a transport that drops the
22
+ * `Content-Type` header entirely) falls into the `payloadBy.get(
23
+ * contentType)` miss branch in `HttpApiBuilder.decodePayload` and
24
+ * surfaces as a confusing `415 Unsupported Media Type`. Annotating
25
+ * the schema makes the encoding explicit on both endpoints and the
26
+ * client encoder, so the wire format is unambiguous.
27
+ */
28
+ export const ResourceStateSchema = Schema.Any.pipe(HttpApiSchema.asJson());
15
29
 
16
30
  export class BearerTokenValidator extends Context.Service<
17
31
  BearerTokenValidator,
@@ -159,13 +159,12 @@ const isTransient = (e: any): boolean => {
159
159
  const retryTransient = <A, Err, Req>(eff: Effect.Effect<A, Err, Req>) =>
160
160
  Effect.retry(eff, {
161
161
  while: isTransient,
162
- // Exponential backoff capped at 2s, no attempt limit. The state
163
- // store is on the deploy critical path; we'd rather block than
164
- // surface a transient blip as a hard failure (which historically
165
- // stranded `syncState` mid-flight, leaving the remote store
166
- // partially populated).
162
+ // Exponential backoff capped at 2s, max 5 attempts. Beyond that
163
+ // the issue isn't transient and we'd rather surface a hard
164
+ // failure than block the deploy indefinitely.
167
165
  schedule: Schedule.exponential(100).pipe(
168
166
  Schedule.either(Schedule.spaced("2 seconds")),
167
+ Schedule.both(Schedule.recurs(5)),
169
168
  ),
170
169
  });
171
170