Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/deploy-prd.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
name: Deploy PRD (Cloudflare via Alchemy)

# Manual-only while prd is not yet migrated to alchemy v2 (the stack program
# fails fast on --stage prd until the maple-prd Hyperdrive is wired by ref).
# Restore the push-to-main trigger when prd cuts over.
on:
push:
branches:
- main
workflow_dispatch:

concurrency:
Expand Down
5 changes: 5 additions & 0 deletions .infisical.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"workspaceId": "4aa48f0a-9ba5-4401-937a-25f7d5118124",
"defaultEnvironment": "",
"gitBranchToEnvironmentMapping": null
}
34 changes: 22 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,26 +97,38 @@ Services:

## Cloudflare Deploy (Alchemy)

Deployments are per-app Alchemy runs pinned to Cloudflare Workers + D1:

- `apps/api/alchemy.run.ts` — D1 database `MAPLE_DB` + api Worker with all env bindings
- `apps/landing/alchemy.run.ts` — Astro build + Worker serving static assets
- `apps/web/alchemy.run.ts` — TanStack Start app via the `Vite()` resource

Stage grammar is `prd` / `stg` / `pr-<number>`, resolved via `@maple/infra/cloudflare` (`parseMapleStage`, `resolveMapleDomains`, `resolveWorkerName`, `resolveD1Name`).
Deployments run on **Alchemy v2** (Effect-based): the root `alchemy.run.ts` exports a
single `Alchemy.Stack("maple", …)` whose program composes per-app factories:

- `apps/api/alchemy.run.ts` — Hyperdrive (PlanetScale Postgres) `MAPLE_DB`, KV, queue,
workflows + api Worker with all env bindings
- `apps/alerting/alchemy.run.ts` — cron-driven alerting Worker (cross-script workflow ref)
- `apps/chat-flue/alchemy.run.ts` — Flue chat Worker (prebuilt bundle, Durable Objects, AI)
- `apps/electric-sync/alchemy.run.ts` — ElectricSQL shape-proxy Worker
- `apps/web/alchemy.run.ts` / `apps/landing/alchemy.run.ts` / `apps/local-ui/alchemy.run.ts`
— static builds via `Command.Build` + asset-serving Workers

Stage grammar is `prd` / `stg` / `pr-<number>` / dev names, resolved via
`@maple/infra/cloudflare` (`parseMapleStage`, `resolveMapleDomains`, `resolveWorkerName`,
`resolveHyperdriveName`, `resolveHyperdriveRefId`). stg/prd bind the dashboard-managed
Hyperdrive by config ID (`resolveHyperdriveRefId`) — origin credentials never touch a
deploy and `MAPLE_PG_URL` is only needed for pr/dev stages, whose per-branch Hyperdrive
alchemy manages itself.

Run locally:

```bash
bun run alchemy:deploy:prd
bun run alchemy:deploy:stg
PR_NUMBER=123 bun run alchemy:deploy:pr
```

The first v2 deploy against a stage with live v1-created resources needs `--adopt`
(the pr script passes it already); v1 state is incompatible and simply abandoned —
never run a v1 `alchemy destroy` against a live stage.

Tear down:

```bash
bun run alchemy:destroy:prd
bun run alchemy:destroy:stg
PR_NUMBER=123 bun run alchemy:destroy:pr
```
Expand All @@ -139,10 +151,8 @@ Secrets source model (CI):
- GitHub repo **secret** `INFISICAL_MACHINE_IDENTITY_ID` (the machine identity ID)
- Infisical environments (`prod`, `staging`, `dev` — mapped from the old Doppler
`prd`/`stg`/`pr` configs) must define:
- `ALCHEMY_PASSWORD`
- `ALCHEMY_STATE_TOKEN`
- `CLOUDFLARE_API_TOKEN`
- `CLOUDFLARE_DEFAULT_ACCOUNT_ID`
- `CLOUDFLARE_DEFAULT_ACCOUNT_ID` (bridged to alchemy v2's `CLOUDFLARE_ACCOUNT_ID` in the root `alchemy.run.ts`; `ALCHEMY_PASSWORD`/`ALCHEMY_STATE_TOKEN` were v1-only and are no longer read)
- `TINYBIRD_HOST`
- `TINYBIRD_TOKEN`
- `EMAIL_FROM` (sender address on an onboarded Cloudflare Email Service domain; delivery uses the `EMAIL` worker binding, no API key)
Expand Down
197 changes: 103 additions & 94 deletions alchemy.run.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { appendFileSync } from "node:fs"
import alchemy from "alchemy"
import { CloudflareStateStore } from "alchemy/state"
import { parseMapleStage, resolveMapleDomains } from "@maple/infra/cloudflare"
import * as Alchemy from "alchemy"
import * as Cloudflare from "alchemy/Cloudflare"
import * as Effect from "effect/Effect"
import { formatMapleStage, parseMapleStage, resolveMapleDomains } from "@maple/infra/cloudflare"
import { createAlertingWorker } from "./apps/alerting/alchemy.run.ts"
import { createMapleApi } from "./apps/api/alchemy.run.ts"
import { createChatFlueWorker } from "./apps/chat-flue/alchemy.run.ts"
Expand All @@ -10,96 +11,104 @@ import { createLandingWorker } from "./apps/landing/alchemy.run.ts"
import { createLocalUiWorker } from "./apps/local-ui/alchemy.run.ts"
import { createMapleWeb } from "./apps/web/alchemy.run.ts"

const requireEnv = (key: string): string => {
const value = process.env[key]?.trim()
if (!value) {
throw new Error(`Missing required deployment env: ${key}`)
}
return value
// v1 read the account id from CLOUDFLARE_DEFAULT_ACCOUNT_ID (the name Infisical
// still defines); v2's auth provider reads CLOUDFLARE_ACCOUNT_ID. Bridge the
// old name so CI keeps working without an Infisical rename.
if (!process.env.CLOUDFLARE_ACCOUNT_ID && process.env.CLOUDFLARE_DEFAULT_ACCOUNT_ID) {
process.env.CLOUDFLARE_ACCOUNT_ID = process.env.CLOUDFLARE_DEFAULT_ACCOUNT_ID
}

const app = await alchemy("maple", {
password: requireEnv("ALCHEMY_PASSWORD"),
...(process.env.ALCHEMY_STATE_TOKEN ? { stateStore: (scope) => new CloudflareStateStore(scope) } : {}),
})

const stage = parseMapleStage(app.stage)
const domains = resolveMapleDomains(stage)

const { worker: api, db: mapleDb } = await createMapleApi({ stage, domains })

const resolvedApiUrl = domains.api ? `https://${domains.api}` : api.url
if (!resolvedApiUrl) {
throw new Error("api worker deployed without a url — set `url: true` or provide a custom domain")
}

const chatFlue = await createChatFlueWorker({
stage,
domains,
mapleApiUrl: resolvedApiUrl,
})

const resolvedChatUrl = domains.chat ? `https://${domains.chat}` : chatFlue.url
if (!resolvedChatUrl) {
throw new Error("chat-flue worker deployed without a url — set `url: true` or provide a custom domain")
}

// ingest is not currently deployed via alchemy; for non-custom-domain stages,
// fall back to a caller-supplied env var or the public Maple ingest endpoint.
const resolvedIngestUrl = domains.ingest
? `https://${domains.ingest}`
: process.env.VITE_INGEST_URL?.trim() || "https://ingest.maple.dev"

// Standalone ElectricSQL shape-proxy worker (DB-free). Created before web so its
// public origin can be baked into the web build (VITE_ELECTRIC_SYNC_URL).
const electricSync = await createElectricSyncWorker({ stage, domains })

const resolvedElectricSyncUrl = domains.sync ? `https://${domains.sync}` : electricSync.url
if (!resolvedElectricSyncUrl) {
throw new Error("electric-sync worker deployed without a url — set `url: true` or provide a custom domain")
}

const web = await createMapleWeb({
stage,
domains,
apiUrl: resolvedApiUrl,
ingestUrl: resolvedIngestUrl,
flueChatUrl: resolvedChatUrl,
electricSyncUrl: resolvedElectricSyncUrl,
})

const landing = await createLandingWorker({ stage, domains })

const localUi = await createLocalUiWorker({ stage, domains })

const alerting = await createAlertingWorker({ stage, domains, mapleDb })

const summary = {
stage: app.stage,
apiUrl: resolvedApiUrl,
chatUrl: resolvedChatUrl,
ingestUrl: resolvedIngestUrl,
electricSyncUrl: resolvedElectricSyncUrl,
webUrl: domains.web ? `https://${domains.web}` : web.url,
landingUrl: domains.landing ? `https://${domains.landing}` : landing.url,
localUiUrl: domains.local ? `https://${domains.local}` : localUi.url,
alertingWorker: alerting.name,
}

console.log(summary)

// In GitHub Actions, expose the deployed URLs as step outputs so the workflow
// can attach the web preview to the PR as a clickable deployment.
if (process.env.GITHUB_OUTPUT) {
appendFileSync(
process.env.GITHUB_OUTPUT,
`${[
`web_url=${summary.webUrl ?? ""}`,
`api_url=${summary.apiUrl ?? ""}`,
`chat_url=${summary.chatUrl ?? ""}`,
`landing_url=${summary.landingUrl ?? ""}`,
].join("\n")}\n`,
)
}

await app.finalize()
// Inter-app URLs must be plain strings at plan time: alchemy v2 resource
// attributes (e.g. `worker.url`) are lazy Outputs that only resolve when fed
// into other resources' props — they cannot be string-interpolated here. Every
// deployed stage therefore gets custom domains (see resolveMapleDomains); dev
// stages fall back to env-supplied URLs (cloud-deploying a dev stage is rare —
// local dev runs through wrangler/portless instead).
const resolveUrl = (domain: string | undefined, envKey: string, fallback = ""): string =>
domain ? `https://${domain}` : process.env[envKey]?.trim() || fallback

export default Alchemy.Stack(
"maple",
{
providers: Cloudflare.providers(),
// Shared account-wide state store (Worker + DO SQLite) — bootstrapped once
// per Cloudflare account (`alchemy bootstrap cloudflare` or the first
// `deploy --yes`). ALCHEMY_LOCAL_STATE=1 opts into .alchemy/ file state for
// throwaway local experiments (dev stages) without touching the account.
state: process.env.ALCHEMY_LOCAL_STATE ? Alchemy.localState() : Cloudflare.state(),
},
Effect.gen(function* () {
const stage = parseMapleStage(yield* Alchemy.Stage)
const domains = resolveMapleDomains(stage)

const apiUrl = resolveUrl(domains.api, "MAPLE_API_BASE_URL")
const chatUrl = resolveUrl(domains.chat, "MAPLE_CHAT_BASE_URL")
const electricSyncUrl = resolveUrl(domains.sync, "MAPLE_ELECTRIC_SYNC_URL")
// ingest is not deployed via alchemy; for non-custom-domain stages, fall
// back to a caller-supplied env var or the public Maple ingest endpoint.
const ingestUrl = resolveUrl(domains.ingest, "VITE_INGEST_URL", "https://ingest.maple.dev")

// chat-flue deploys before api so api can service-bind the real worker (the
// v1 WorkerStub cycle-breaker is gone — chat-flue's api URL is now static).
const chatFlue = yield* createChatFlueWorker({ stage, domains, mapleApiUrl: apiUrl })

const { worker: api, db: mapleDb } = yield* createMapleApi({ stage, domains, chatFlue })

// Standalone ElectricSQL shape-proxy worker (DB-free); its public origin is
// baked into the web build (VITE_ELECTRIC_SYNC_URL).
const electricSync = yield* createElectricSyncWorker({ stage, domains })

const web = yield* createMapleWeb({
stage,
domains,
apiUrl,
ingestUrl,
flueChatUrl: chatUrl,
electricSyncUrl,
})

const landing = yield* createLandingWorker({ stage, domains })

const localUi = yield* createLocalUiWorker({ stage, domains })

const alerting = yield* createAlertingWorker({ stage, domains, mapleDb })

const summary = {
stage: formatMapleStage(stage),
apiUrl,
chatUrl,
ingestUrl,
electricSyncUrl,
webUrl: domains.web ? `https://${domains.web}` : "",
landingUrl: domains.landing ? `https://${domains.landing}` : "",
localUiUrl: domains.local ? `https://${domains.local}` : "",
}

// In GitHub Actions, expose the deployed URLs as step outputs so the
// workflow can attach the web preview to the PR as a clickable deployment.
yield* Effect.sync(() => {
if (process.env.GITHUB_OUTPUT) {
appendFileSync(
process.env.GITHUB_OUTPUT,
`${[
`web_url=${summary.webUrl}`,
`api_url=${summary.apiUrl}`,
`chat_url=${summary.chatUrl}`,
`landing_url=${summary.landingUrl}`,
].join("\n")}\n`,
)
}
})

// Reference the remaining workers so nothing is tree-shaken out of the plan
// and the summary carries their identity for the CLI output.
return {
...summary,
electricSyncWorker: electricSync.workerName,
webWorker: web.workerName,
landingWorker: landing.workerName,
localUiWorker: localUi.workerName,
alertingWorker: alerting.workerName,
}
}),
)
Loading
Loading