Moved to samebase/samebase
The Convex plane for Alchemy v2: projects, deployments, their environment
variables, and the push of the functions. One Alchemy stack owns a Convex app end to end. The
deployment resource pushes your convex/ folder when its content changes and gives the deployment
URLs to a Cloudflare Worker or a Vite site as ordinary outputs. No deploy key goes from one resource
to another. The Convex CLI that your app installs keeps doing typecheck, codegen, bundling, and the
push.
Alchemy has no built-in Convex provider (alchemy-run/alchemy-async#1306). This is a community provider, maintained by Samebase. The resource model follows the "Convex Plain" mode of rjdellecese/confect#408. docs/design/convex-plane.md records the decisions.
Status: pinned to alchemy@2.0.0-beta.84 and Effect 4. Alchemy ships breaking changes between
betas; upgrade this package and Alchemy together. A live test runs the full sequence with the real
Convex CLI: create, push, no-op, change, preview, deploy keys alone, and destroy.
pnpm add -D @samebase/alchemy-convex alchemy@2.0.0-beta.84 effect@^4.0.5 @effect/platform-node@^4.0.5 convexUse one version for effect and for each @effect/* package. A mix fails at load time, such as
@effect/platform-node-shared 4.0.5 next to effect 4.0.3.
Alchemy providers are separate packages. The example below also needs
@alchemy.run/cloudflare@2.0.0-beta.84, the same version as alchemy.
export default Alchemy.Stack(
"MyApp",
{
providers: Layer.mergeAll(Cloudflare.providers(), Convex.providers()),
state: Cloudflare.state(),
},
Effect.gen(function* () {
const { stage } = yield* Alchemy.Stack;
const backend =
stage === "prod"
? yield* Convex.Deployment("Backend", {
// An existing project. A new one: { team: "my-team", name: "my-app" }.
project: yield* Convex.Project("Project", { team: "my-team", slug: "my-app" }),
type: "prod",
source: "./convex",
env: { AUTH_SECRET: Config.Redacted("AUTH_SECRET") },
})
: yield* Convex.Deployment("Backend", {
// The project that the stage "prod" owns, read from its state.
project: yield* Convex.Project.ref("Project", { stage: "prod" }),
type: "preview",
name: stage, // such as "pr-42"
source: "./convex",
}).pipe(RemovalPolicy.destroy());
const site = yield* Cloudflare.Website.Vite("Site", {
env: { VITE_CONVEX_URL: backend.url, VITE_CONVEX_SITE_URL: backend.siteUrl },
});
return { url: site.url, convexUrl: backend.url };
}),
);The imports are alchemy, @alchemy.run/cloudflare, alchemy/RemovalPolicy, effect/Config,
effect/Effect, effect/Layer, and @samebase/alchemy-convex as Convex. Order follows the
outputs: the project exists before its deployment, and the push finishes before the site build. The
first alchemy deploy --stage prod takes over the existing project, and its production
deployment if it has code, only with --adopt.
alchemy destroy --stage pr-42 deletes the preview deployment. Without RemovalPolicy.destroy(),
it stays until Convex deletes it at expiry (14 days by default).
examples/app/alchemy.run.ts is the same stack.
rootDir is the app directory, relative to the directory where alchemy deploy runs, which is
the directory of the stack file. The default is ".". The push runs in rootDir, and source is
relative to it:
Effect.gen(function* () {
const project = yield* Convex.Project("Project", { team: "my-team", slug: "my-app" });
const backend = yield* Convex.Deployment("Backend", {
project,
type: "prod",
rootDir: "apps/web", // the Convex app, with its package.json
source: "./convex", // apps/web/convex
});
return { convexUrl: backend.url };
});State always holds rootDir, "." included, so a reader of the state finds the app directory of
each deployment.
Keep the component's native Alchemy resource declarations. Connect their namespace to the installed
component name in the consuming app's stack. The name comes from the app's convex.config.ts:
app.use(mail, { name: "mail" }) installs the component as "mail".
import { bindComponent } from "@samebase/alchemy-convex";
import * as Effect from "effect/Effect";
// `production` is this app's Convex.Deployment. EmailInbox creates its resources under "Mail".
Effect.gen(function* () {
const inbox = yield* EmailInbox("Mail", {
convexSiteUrl: production.siteUrl,
zone,
sendingDomain,
});
yield* bindComponent({
deployment: production,
component: "mail",
namespace: inbox.worker.Namespace,
});
});The deployment's Alchemy state row stores this entry in its native bindings array:
{
"sid": "convex.component:[\"Mail\"]",
"data": { "type": "component", "path": "mail", "namespace": ["Mail"] }
}Readers inspect each binding's data. The deployment attributes identify the Convex deployment;
the row's stack and stage identify the Alchemy scope. The live repository reader includes the
binding through its existing resource JSON. No extra resource list, state table, or subscription
is needed.
Use actual namespace ancestry when grouping resources. A slash in a resource's logical ID is not a
namespace boundary. For a nested install, join the installed names with slashes, such as
"workspace/mail". An undefined namespace means the stack root. Each namespace can be assigned to
one component per deployment; a component can have multiple namespaces. A more specific child
namespace takes precedence when displaying resources.
The connection is declared metadata. It does not check that the component is installed, push code, set environment values, or change resource deletion policies. Removing the call removes the binding on the next apply.
Credentials are a layer, never props, so they do not go into Alchemy state. Convex.providers()
reads them through the stack's config provider (process env, .env, stack secrets):
CONVEX_ACCESS_TOKEN, a team access token, for the Management API. Without it, the provider uses the token thatnpx convex loginsaved (~/.convex/config.json).Convex.Projectneeds it, and so does a deployment that the deploy key does not reach.CONVEX_DEPLOY_KEY, optional, the same variable that the Convex CLI reads:- A deploy key of one deployment (
prod:<name>|...) sets variables and pushes to that deployment. That stage then needs no access token if it passesproject: { projectId }or aConvex.Project.ref. The key must havedeployment:data:view(dashboard keys have all actions), because the provider reads the project and type of its deployment first. - A preview deploy key of a project (
preview:<team>:<project>|...) creates, pushes to, and deletes preview deployments of that project. It cannot change variables: withenv, the provider uses the access token, and without one it stops withEnvironmentNeedsDeployKeybefore it creates anything. Use project default variables in the Convex dashboard for previews.
- A deploy key of one deployment (
A key that does not fit the deployment is not used: the provider uses the access token. To pass
values explicitly: Convex.providers(Convex.fromValues({ accessToken, deployKey })).
state: Convex.state({ team }) keeps the Alchemy state of every stack and stage of a Convex team in
one project of that team, alchemy-state-store, the same name as the Cloudflare state store of
Alchemy, so a repository holds one secret and a stack can create
and destroy its own project. docs/design/convex-state.md records the
decisions and the measurements.
export default Alchemy.Stack(
process.env["GITHUB_REPOSITORY"] ?? "my-org/my-app", // the stack name: the repository
{ providers: Convex.providers(), state: Convex.state({ team: "my-team" }) },
Effect.gen(function* () {
/* ... */
}),
);Every run needs the team token in CONVEX_ACCESS_TOKEN, or the Convex CLI login on a laptop. A
team access token names its team, so Convex.state() needs no team with one; a team slug that
is set must be the slug of that team, checked against its projects, and a token of another team
fails the run. The Convex CLI login needs the slug or the id. The first run of a team creates
the project alchemy-state-store and pushes the store functions to its
production deployment with the Convex CLI of the app; a later version of this package pushes a
newer store when it finds an older one. A store that is newer than the package fails the run with
both versions, so the repositories of a team upgrade the package together. Each run makes a
deploy key of that deployment that can only run the store's internal functions, and deletes it at
the end. Rows are in the tables resources and outputs of the component alchemyState in the
dashboard, under the stack name.
The store has no lock: run one deploy of a stage at a time, such as with a CI concurrency group.
The store and its readers (Samebase) are released and upgraded together.
Store version 3 supports native subscriptions. A trusted server checks repository access, then
calls internal reader:authorize({ repository, subject }) with a deployment credential. It gives
the returned token to a separate ConvexClient connected to the state deployment. The client
subscribes to reader:repository({}); the verified token selects the repository. The token has a
five-minute lifetime and permits redacted reads only. The server checks access again on renewal.
The store uses its own native signing key and JWKS, so no extra signing secret is required.
See the reader contract.
Alchemy state holds no time, commit, branch, or run. Convex.state() adds them to the stage output:
the document that setOutput stores is the stack's output plus the key samebase.run. On GitHub
Actions the record comes from the run's variables and the event payload; on a laptop from git, which
needs a branch and an origin remote. It holds no secret.
{
"convexUrl": "https://modest-guanaco-252.convex.cloud",
"samebase.run": {
"version": 1,
"repository": "samebase/base-alchemy",
"stackFile": "alchemy.run.ts",
"branch": "feature",
"pullRequest": 42,
"headCommit": "8af3c5b7932c528939cbc7aa0597b1fc36c8dc83",
"runUrl": "https://github.com/samebase/base-alchemy/actions/runs/12345678901/attempts/1",
"source": "github-actions",
"recordedAt": "2026-10-21T10:00:00.000Z"
}
}stackFile is the working directory of the run below the repository root plus alchemy.run.ts.
pullRequest and runUrl are null for a push and for a laptop, where source is laptop. A
value that the stack itself returns under samebase.run stays. A checkout that gives no record,
such as a tarball without git or a detached HEAD, writes its outputs without one, and the log says
why. Alchemy writes the output once after a full successful deploy, so a failed deploy leaves the
previous record, and a deploy of selected resources writes none. A reader gets every stage of a
stack, with its resources and output, from the store function state:readStack({ stack }) in one
call.
-
Convex.Project:{ team, name, deploymentRegion?, deploymentType? }creates a project with its default deployment, or takes over one with that exact name with--adopt. A changednamerenames the project in place.{ team, slug }is an existing project, found by its slug and taken over with--adopt; it never creates or renames a project. OutputsprojectId,slug,name,teamId,prodDeploymentName,prodDeploymentUrl. -
Convex.Deployment:{ project, type: "prod" }or{ project, type: "dev" | "preview", name }, each with optionalrootDir,source, andenv. Outputsname(such ashappy-animal-123),url,siteUrl,type,projectId,reference,previewName,hash, andenvNames.prodis the project's default production deployment (with a deploy key: the key's production deployment). The resource never creates or deletes one. A production deployment with code that is not in state is taken over only with--adopt.devcreates a dev deployment withnameas its reference;previewcreates a preview deployment withnameas its preview name. An expired preview is created again on the next deploy.rootDiris the app directory, relative to the directory where the deploy runs,"."by default. It must be relative (AbsoluteRootDir) and have a package.json (ConvexAppNotFound). There is no search in the directories above it. When convex.json puts the functions outsiderootDir, the hash covers them too.sourceis the Convex functions directory relative torootDir:convex/, or thefunctionsfield of convex.json (SourceMismatch). The CLI runs inrootDir, with the Node.js that runs Alchemy, and is never downloaded (ConvexCliNotFound).- The content identity is a hash of every file of
rootDirthat git does not ignore, the nearest lockfile, the deployment name, andenv. The same hash is a no-op. Otherwise the update setsenv, then pushes. A failed push sets the changed variables back, and deletes a dev or preview deployment that the same run created. envvalues are strings or secrets such asConfig.Redacted("AUTH_SECRET"). A name that leavesenvis removed from the deployment.source: { path, command?, include? }replacesconvex deploywithcommand, run inrootDirwith the push key inCONVEX_DEPLOY_KEY, such as["node_modules/.bin/convex-hosting", "deploy", "--build", "node_modules/.bin/vp build", "--dist", "dist/client"]. The command must push withconvex deploy(convex-hosting does, through--cmd).includenarrows the hash topath, package.json, convex.json, the lockfile, and the listed globs, such as the build inputs["src/**", "public/**", "vite.config.ts"]. A minted key for a command has every action, because the command can do more than a push.
-
Convex.HubClient:{ team, app, redirectUri, deploymentType, clientName, softwareVersion? }registers one deployment as a client of the identity hub of the team. The hub is the production deployment of the projectidentity-hub. The resource finds it by name and runs its internal functionidentityBroker:registerClientwith a short-lived key, so the stack needs no other credential and no hub address. The first client of anappcreates the app. Outputsissuer,clientId,clientSecret,redirectUri,deploymentType, andclientName, which theenvof theConvex.Deploymentwrites to the deployment. AnotherredirectUriis a new client, and a destroy deletes the client.
A development deployment has no stack, so the package has a command for it. Run it in the directory
of the app, before the first push: it registers the deployment of that directory with
CONVEX_ACCESS_TOKEN or the Convex CLI login, and writes HUB_ISSUER, HUB_CLIENT_ID, and
HUB_CLIENT_SECRET to it. A deployment with all three variables keeps them.
npx alchemy-convex hub-client --team my-team --app scout --callback-path /auth/hub/callbackalchemy-convex stack-comment keeps one comment on a pull request. The comment has a card for each
stack of the repository at the stage pr-<number>: the folder, the address, the last run, and the
Convex deployments, Workers, and R2 buckets. A stack sees only itself, so a comment that a stack
declares cannot show this.
Run it after every deploy of the workflow. The run record gives the repository and the pull request, so the command needs no argument with a team access token:
permissions:
pull-requests: write
steps:
# The deploy steps of each stack.
- name: Update the stack comment
if: github.event_name == 'pull_request'
run: pnpm exec alchemy-convex stack-comment
env:
CONVEX_ACCESS_TOKEN: ${{ secrets.CONVEX_ACCESS_TOKEN }}
GITHUB_TOKEN: ${{ github.token }}A stack named owner/name, or owner/name/<folder> for a stack in a folder, belongs to the
repository owner/name. A stack without the stage of the pull request has no card.
Convex.ProjectandConvex.Deploymenthave the removal policyretain.alchemy destroyforgets them and Convex keeps them. AddRemovalPolicy.destroy()to delete with the resource.- A project is never replaced. A change of
team,slug,deploymentType, ordeploymentRegionstops the deploy with a typed error that names the fix. - A deployment keeps its
project,type, andname; a change stops the deploy withDeploymentIdentityChange. A production deployment is never deleted, also not withRemovalPolicy.destroy(), and also not a dev deployment that became one. - A dev or preview deployment that exists but is not in state belongs to someone else, such as a
preview that CI made. The resource takes it over only with
--adopt, and never creates over it: a Convex create for an existing preview name deletes that preview. - With the access token, each push uses a new deploy key with only
deployment:deploy,deployment:env:view, anddeployment:env:write, which expires after two hours and is deleted after the push, also after a failure. With an OAuth token, Convex returns the token itself as the key, so the delete is skipped. No other key is created or revoked. - The key reaches the CLI only in an env file with mode 0600, in a temporary home directory that is
removed after the run. Variables named
CONVEX_*are not passed on. The CLI output stays out of the logs; a failure (CodePushFailed) shows the last 20 lines with the key removed. A push to another deployment stops the deploy withPushTargetMismatch. - A variable in
envthat exists with another value and that the resource did not set stops the deploy withVariablesOwnedBySomeoneElse.--adoptoverwrites it. Destroy leaves variables in place: they go with the deployment. - A variable write runs again after a Convex write conflict or a 5xx answer, at most four more times.
envvalues are props, so Alchemy state holds them. Keep.alchemy/git-ignored, and use an encrypted state store, such asCloudflare.state(), in CI.- The hash does not see a change in a workspace package outside
rootDir, or a push from outside Alchemy. Change a file of the app to push again. - With a deploy key, the provider reaches the deployment at
https://<name>.convex.cloud, so the deployment must answer there. - A run that stops between a deployment create and the state write leaves a deployment that state
does not know. The next run stops with
OwnedBySomeoneElse; run it with--adopt. - Team slugs for
{ team, name }resolve through an unofficial dashboard endpoint. Pass the numeric team id to avoid it.{ team, slug }uses the official endpoint. - Not covered: deployment regions and classes, custom domains, log streams, teams and members, components, snapshots.
pnpm install
pnpm run check # format, lint, typecheck, unit tests
pnpm run build # lib/ with declarations, then publint and attw
pnpm run test:live # real API calls, see belowUse Node 24 (.node-version) and pnpm 11. Unit tests run against recorded Management API payloads
in test/fixtures/management/ and a fake Convex CLI. Live tests run only with
ALCHEMY_CONVEX_LIVE=1, which pnpm run test:live sets. They need a Convex login or
CONVEX_ACCESS_TOKEN that can create projects in ALCHEMY_CONVEX_LIVE_TEAM. Each test creates a
project whose name starts with ALCHEMY_CONVEX_LIVE_PREFIX (default tmp-alchemy-convex-),
deletes it at the end, also after a failure, and refuses to delete a project, or a deployment of a
project, without that prefix. A file argument runs one live file only, for example
pnpm run test:live test/live/plane.live.test.ts.
Releases: change version in package.json in a pull request. After the merge,
.github/workflows/release.yml publishes that version from main with npm trusted publishing.
License: Apache 2.0. Parts follow Confect; see NOTICE.