Skip to content

Cloudflare.state({ workerName }) is documented but unsupported, and bootstrap workerName is only partially propagated #912

Description

@chrisherold

Summary

The state-store documentation says that a separate Cloudflare state store can
be selected with:

state: Cloudflare.state({
  workerName: "alchemy-state-store-team-a",
}),

However, Cloudflare.state() currently accepts no options and always uses
STATE_STORE_SCRIPT_NAME ("alchemy-state-store").

The lower-level exported bootstrap() function does accept a workerName,
but that value is only propagated to parts of the bootstrap lifecycle. The
physical Worker definition, existing-store detection, login URL, credential
cache, and secret lookup continue to use the default state-store identity.

As a result, the documented per-team/dedicated-store configuration does not
appear to be usable end to end.

Versions checked

  • alchemy@2.0.0-beta.63
  • Upstream main at 7c926aa628c70d15610bb469634bda5bf70c769b

Documentation

The current documentation says:

Pass workerName to use a separate state store — for example, when you want
a dedicated store per Cloudflare account or per team.

### Customizing the worker name
By default the state-store Worker is named `alchemy-state-store`.
Pass `workerName` to use a separate state store — for example, when
you want a dedicated store per Cloudflare account or per team:
```typescript
state: Cloudflare.state({ workerName: "alchemy-state-store-team-a" }),
```

Compile-time reproduction

import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";

export default Alchemy.Stack(
  "Example",
  {
    providers: Cloudflare.providers(),
    state: Cloudflare.state({
      workerName: "alchemy-state-store-team-a",
    }),
  },
  Effect.void,
);

The call does not type-check because state has a zero-argument signature:

export const state = () => ...

Source:

export const state = () =>
Layer.effect(
State,
Effect.gen(function* () {
const isCI = yield* CI;
const scriptName = STATE_STORE_SCRIPT_NAME;
const profileName = yield* ALCHEMY_PROFILE;
const localStage = `${profileName}_${scriptName}`;

Bootstrap propagation

bootstrap() does expose workerName and resolves it into scriptName:

export interface BootstrapOptions {
/** @default "alchemy-state-store" */
workerName?: string;
/** @default false */
force?: boolean;
/** @default "default" */
profile?: string;
}
export const bootstrap = (options: BootstrapOptions = {}) =>
Effect.gen(function* () {
const isCI = yield* CI;
const profileName = options.profile ?? (yield* ALCHEMY_PROFILE);
const scriptName = options.workerName ?? STATE_STORE_SCRIPT_NAME;
const force = options.force ?? false;
const localStage = `${profileName}_${scriptName}`;

That name is used for the bootstrap stage and related messages, but several
subsequent operations still use the default identity:

  1. The physical Worker resource is explicitly named
    STATE_STORE_SCRIPT_NAME:

    export default Worker(
    "Api",
    {
    name: STATE_STORE_SCRIPT_NAME,
    main: import.meta.url,

  2. isStateStoreServing() always probes the default Worker URL:

    /**
    * Does this account have a *functioning* state-store worker,
    * verified by checking the /version endpoint
    *
    */
    const isStateStoreServing = (accountId: string) =>
    Effect.gen(function* () {
    const url = yield* workers.getSubdomain({ accountId }).pipe(
    Effect.map(({ subdomain }) =>
    subdomain
    ? `https://${STATE_STORE_SCRIPT_NAME}.${subdomain}.workers.dev`
    : undefined,
    ),
    Effect.catch(() => Effect.succeed(undefined)),
    );
    if (url === undefined) return false;
    const { observed } = yield* checkStateStoreVersion(url);
    return observed !== undefined;

  3. loginWithCloudflare() reads the default auth-token secret, derives the
    default Worker URL, and writes the shared default credentials file:

    export const loginWithCloudflare = (profileName: string, force: boolean) =>
    Effect.gen(function* () {
    const credStore = yield* CredentialsStore;
    const isCI = yield* CI;
    const { accountId } =
    yield* yield* CloudflareEnvironment.CloudflareEnvironment;
    if (!force) {
    // try and read from the cached credentials first if not forcing (force will always refresh)
    const credentials = yield* credStore.read<StoredStateStoreCredentials>(
    profileName,
    CREDENTIALS_FILE,
    );
    // Ignore a cache minted for a different account (or a legacy file with
    // no `accountId`) — reusing it would hand back the wrong account's
    // state-store URL. Fall through to re-derive against `accountId`.
    if (
    credentials &&
    !isStateStoreCredentialsStale(credentials, accountId)
    ) {
    return credentials;
    }
    }
    // 1. Locate the single Secrets Store on the account.
    const stores = yield* SecretsStore.listStores({ accountId });
    const store = stores.result[0];
    if (!store) {
    return yield* Effect.fail(
    new AuthError({
    message:
    "No Secrets Store found on this account. Deploy the state store first.",
    }),
    );
    }
    // 2. Fetch the auth-token from Secrets Store with a temporary edge-preview worker
    const authToken = yield* readSecretViaEdge(
    STATE_STORE_SCRIPT_NAME,
    store.id,
    AuthTokenSecretName,
    ).pipe(
    Effect.retry({
    while: (error) =>
    isWorkersPreviewConfigurationError(error) ||
    isTransientEdgeSessionError(error),
    // Cap the exponential delay at 2s so 15 retries stay within
    // ~30s instead of doubling unboundedly.
    schedule: Schedule.max([
    Schedule.min([
    Schedule.exponential(200),
    Schedule.spaced("2 seconds"),
    ]),
    Schedule.recurs(15),
    ]),
    }),
    );
    // 3. Derive the deployed worker URL.
    const { subdomain } = yield* workers.getSubdomain({ accountId });
    const url = `https://${STATE_STORE_SCRIPT_NAME}.${subdomain}.workers.dev`;
    if (!isCI) {
    // 4. Persist credentials. The profile entry is managed by
    // `loadOrConfigure` when this is invoked through `configure`.
    yield* credStore
    .write<StoredStateStoreCredentials>(profileName, CREDENTIALS_FILE, {
    url,
    authToken: authToken.trim(),
    accountId,
    })

This suggests that calling:

yield* Cloudflare.bootstrap({
  workerName: "alchemy-state-store-team-a",
});

can change the bootstrap stack/stage identity without consistently changing
the state-store service to which later operations attach.

I have not run this reproduction against a disposable Cloudflare account
because bootstrap can create or update account-level state infrastructure.
The compile-time mismatch and incomplete propagation above are visible
directly in the published API and current source.

Expected behavior

One of the following contracts should be made explicit:

Option A: named state stores are supported

  • Cloudflare.state({ workerName }) accepts the documented option.
  • The same name is used consistently for:
    • bootstrap state
    • physical Worker identity
    • availability and version checks
    • Worker URL derivation
    • login and credential-cache identity
  • The intended treatment of the authentication and encryption secrets is
    documented and tested: either per-store identities or explicitly shared
    account-level identities.
  • A test proves that the default store and a named store can coexist without
    attaching to or refreshing the wrong backend.

Option B: one Cloudflare state store per account is supported

  • Remove the Cloudflare.state({ workerName }) example from the documentation.
  • Remove or clarify BootstrapOptions.workerName so it cannot imply a separate
    usable state service.
  • Document the account-wide Worker, credential, authentication, encryption,
    and upgrade failure domain.

Actual behavior

The documentation advertises Option A, while the public state() API and
several runtime paths currently behave like Option B. bootstrap.workerName
sits between those two contracts and appears incomplete.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions