diff --git a/.github/workflows/deploy-prd.yml b/.github/workflows/deploy-prd.yml index 59d2ce358..c3273572f 100644 --- a/.github/workflows/deploy-prd.yml +++ b/.github/workflows/deploy-prd.yml @@ -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: diff --git a/.infisical.json b/.infisical.json new file mode 100644 index 000000000..81fbe7193 --- /dev/null +++ b/.infisical.json @@ -0,0 +1,5 @@ +{ + "workspaceId": "4aa48f0a-9ba5-4401-937a-25f7d5118124", + "defaultEnvironment": "", + "gitBranchToEnvironmentMapping": null +} \ No newline at end of file diff --git a/README.md b/README.md index b93fa7c04..29a3822d2 100644 --- a/README.md +++ b/README.md @@ -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-`, 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-` / 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 ``` @@ -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) diff --git a/alchemy.run.ts b/alchemy.run.ts index 867f88bf6..6b828e812 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -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" @@ -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, + } + }), +) diff --git a/apps/alerting/alchemy.run.ts b/apps/alerting/alchemy.run.ts index 01967423d..f9983d576 100644 --- a/apps/alerting/alchemy.run.ts +++ b/apps/alerting/alchemy.run.ts @@ -1,10 +1,12 @@ import path from "node:path" -import alchemy from "alchemy" -import { EmailSender, Worker, Workflow, type Hyperdrive } from "alchemy/cloudflare" +import * as Cloudflare from "alchemy/Cloudflare" +import * as Effect from "effect/Effect" +import * as Redacted from "effect/Redacted" import type { MapleDomains, MapleStage } from "@maple/infra/cloudflare" import { CLOUDFLARE_WORKER_PLACEMENT, resolveDeploymentEnvironment, + resolveHyperdriveRefId, resolveWorkerName, } from "@maple/infra/cloudflare" @@ -21,91 +23,103 @@ const optionalPlain = (key: string, fallback?: string): Record = return value ? { [key]: value } : {} } -const optionalSecret = (key: string): Record> => { +const optionalSecret = (key: string): Record> => { const value = process.env[key]?.trim() - return value ? { [key]: alchemy.secret(value) } : {} + return value ? { [key]: Redacted.make(value) } : {} } export interface CreateAlertingWorkerOptions { stage: MapleStage domains: MapleDomains - mapleDb: Hyperdrive + /** Managed per-branch Hyperdrive from the api factory; undefined on ref stages (stg/prd). */ + mapleDb: Cloudflare.Hyperdrive.Connection | undefined } -export const createAlertingWorker = async ({ stage, mapleDb }: CreateAlertingWorkerOptions) => { - // Cross-script binding to the AI triage Workflow hosted by the api worker — - // the error/anomaly ticks enqueue triage runs when incidents open. - const aiTriageWorkflow = Workflow<{ - orgId: string - incidentKind: string - incidentId: string - issueId?: string - runId: string - }>("ai-triage-workflow", { - workflowName: resolveWorkerName("ai-triage", stage), - className: "AiTriageWorkflow", - scriptName: resolveWorkerName("api", stage), - }) +export const createAlertingWorker = ({ stage, mapleDb }: CreateAlertingWorkerOptions) => + Effect.gen(function* () { + const hyperdriveRefId = resolveHyperdriveRefId(stage) + // Cross-script binding to the AI triage Workflow hosted by the api worker — + // the error/anomaly ticks enqueue triage runs when incidents open. The + // first arg is the physical workflow name; `scriptName` makes this a + // reference-only binding (the api worker owns the workflow resource). + const aiTriageWorkflow = Cloudflare.Workflow<{ + orgId: string + incidentKind: string + incidentId: string + issueId?: string + runId: string + }>(resolveWorkerName("ai-triage", stage), { + className: "AiTriageWorkflow", + scriptName: resolveWorkerName("api", stage), + }) - const worker = await Worker("alerting", { - name: resolveWorkerName("alerting", stage), - cwd: import.meta.dirname, - entrypoint: path.join(import.meta.dirname, "src", "worker.ts"), - compatibility: "node", - compatibilityDate: "2026-04-08", - placement: CLOUDFLARE_WORKER_PLACEMENT, - adopt: true, - crons: ["* * * * *", "*/5 * * * *", "*/15 * * * *", "0 * * * *", "0 9 * * *"], - bindings: { - MAPLE_DB: mapleDb, - AI_TRIAGE_WORKFLOW: aiTriageWorkflow, - EMAIL: EmailSender({ - allowedSenderAddresses: ["notifications@noreply.maple.dev"], - dev: { remote: true }, - }), - TINYBIRD_HOST: requireEnv("TINYBIRD_HOST"), - TINYBIRD_TOKEN: alchemy.secret(requireEnv("TINYBIRD_TOKEN")), - MAPLE_AUTH_MODE: process.env.MAPLE_AUTH_MODE?.trim() || "self_hosted", - MAPLE_DEFAULT_ORG_ID: process.env.MAPLE_DEFAULT_ORG_ID?.trim() || "default", - MAPLE_INGEST_KEY_ENCRYPTION_KEY: alchemy.secret(requireEnv("MAPLE_INGEST_KEY_ENCRYPTION_KEY")), - MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY: alchemy.secret(requireEnv("MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY")), - MAPLE_INGEST_PUBLIC_URL: - process.env.MAPLE_INGEST_PUBLIC_URL?.trim() || "https://ingest.maple.dev", - MAPLE_APP_BASE_URL: process.env.MAPLE_APP_BASE_URL?.trim() || "https://app.maple.dev", - EMAIL_FROM: process.env.EMAIL_FROM?.trim() || "Maple ", - ...optionalPlain("MAPLE_ENDPOINT"), - ...optionalPlain("MAPLE_ENVIRONMENT", resolveDeploymentEnvironment(stage)), - ...optionalPlain("COMMIT_SHA"), - MAPLE_INGEST_KEY: alchemy.secret(requireEnv("MAPLE_OTEL_INGEST_KEY")), - ...optionalSecret("MAPLE_ROOT_PASSWORD"), - ...optionalSecret("CLERK_SECRET_KEY"), - ...optionalPlain("CLERK_PUBLISHABLE_KEY"), - ...optionalSecret("CLERK_JWT_KEY"), - ...optionalSecret("AUTUMN_SECRET_KEY"), - ...optionalSecret("INTERNAL_SERVICE_TOKEN"), - // Cloudflare integration (account OAuth — Authorization Code + PKCE). - // The alerting worker runs the cloudflare analytics poller (cloudflareAnalyticsTick - // → CloudflareAnalyticsService.pollAllOrgs), which resolves + refreshes each org's - // OAuth token via CloudflareOAuthService and needs the same config as the api worker. - ...optionalPlain("CLOUDFLARE_OAUTH_CLIENT_ID"), - ...optionalSecret("CLOUDFLARE_OAUTH_CLIENT_SECRET"), - ...optionalPlain("CLOUDFLARE_OAUTH_SCOPES"), - ...optionalPlain("CLOUDFLARE_OAUTH_AUTHORIZE_URL"), - ...optionalPlain("CLOUDFLARE_OAUTH_TOKEN_URL"), - ...optionalPlain("CLOUDFLARE_OAUTH_REVOKE_URL"), - ...optionalPlain("MAPLE_CLOUDFLARE_API_BASE_URL"), - // PlanetScale integration (OAuth application — confidential client). The - // alerting worker runs the inventory poller (planetScaleTick → - // PlanetScaleService.pollAllOrgs), which resolves + refreshes each org's - // OAuth token via PlanetScaleOAuthService and needs the same config as - // the api worker. - ...optionalPlain("PLANETSCALE_OAUTH_CLIENT_ID"), - ...optionalSecret("PLANETSCALE_OAUTH_CLIENT_SECRET"), - ...optionalPlain("PLANETSCALE_OAUTH_AUTHORIZE_URL"), - ...optionalPlain("PLANETSCALE_OAUTH_TOKEN_URL"), - ...optionalPlain("MAPLE_PLANETSCALE_API_BASE_URL"), - }, - }) + const worker = yield* Cloudflare.Worker("alerting", { + name: resolveWorkerName("alerting", stage), + main: path.join(import.meta.dirname, "src", "worker.ts"), + compatibility: { date: "2026-04-08", flags: ["nodejs_compat"] }, + placement: CLOUDFLARE_WORKER_PLACEMENT, + url: false, + crons: ["* * * * *", "*/5 * * * *", "*/15 * * * *", "0 * * * *", "0 9 * * *"], + env: { + // Ref stages attach MAPLE_DB via worker.bind below. + ...(mapleDb ? { MAPLE_DB: mapleDb } : {}), + AI_TRIAGE_WORKFLOW: aiTriageWorkflow, + EMAIL: Cloudflare.Email.SendEmail("email", { + allowedSenderAddresses: ["notifications@noreply.maple.dev"], + }), + TINYBIRD_HOST: requireEnv("TINYBIRD_HOST"), + TINYBIRD_TOKEN: Redacted.make(requireEnv("TINYBIRD_TOKEN")), + MAPLE_AUTH_MODE: process.env.MAPLE_AUTH_MODE?.trim() || "self_hosted", + MAPLE_DEFAULT_ORG_ID: process.env.MAPLE_DEFAULT_ORG_ID?.trim() || "default", + MAPLE_INGEST_KEY_ENCRYPTION_KEY: Redacted.make(requireEnv("MAPLE_INGEST_KEY_ENCRYPTION_KEY")), + MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY: Redacted.make( + requireEnv("MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY"), + ), + MAPLE_INGEST_PUBLIC_URL: + process.env.MAPLE_INGEST_PUBLIC_URL?.trim() || "https://ingest.maple.dev", + MAPLE_APP_BASE_URL: process.env.MAPLE_APP_BASE_URL?.trim() || "https://app.maple.dev", + EMAIL_FROM: process.env.EMAIL_FROM?.trim() || "Maple ", + ...optionalPlain("MAPLE_ENDPOINT"), + ...optionalPlain("MAPLE_ENVIRONMENT", resolveDeploymentEnvironment(stage)), + ...optionalPlain("COMMIT_SHA"), + MAPLE_INGEST_KEY: Redacted.make(requireEnv("MAPLE_OTEL_INGEST_KEY")), + ...optionalSecret("MAPLE_ROOT_PASSWORD"), + ...optionalSecret("CLERK_SECRET_KEY"), + ...optionalPlain("CLERK_PUBLISHABLE_KEY"), + ...optionalSecret("CLERK_JWT_KEY"), + ...optionalSecret("AUTUMN_SECRET_KEY"), + ...optionalSecret("INTERNAL_SERVICE_TOKEN"), + // Cloudflare integration (account OAuth — Authorization Code + PKCE). + // The alerting worker runs the cloudflare analytics poller (cloudflareAnalyticsTick + // → CloudflareAnalyticsService.pollAllOrgs), which resolves + refreshes each org's + // OAuth token via CloudflareOAuthService and needs the same config as the api worker. + ...optionalPlain("CLOUDFLARE_OAUTH_CLIENT_ID"), + ...optionalSecret("CLOUDFLARE_OAUTH_CLIENT_SECRET"), + ...optionalPlain("CLOUDFLARE_OAUTH_SCOPES"), + ...optionalPlain("CLOUDFLARE_OAUTH_AUTHORIZE_URL"), + ...optionalPlain("CLOUDFLARE_OAUTH_TOKEN_URL"), + ...optionalPlain("CLOUDFLARE_OAUTH_REVOKE_URL"), + ...optionalPlain("MAPLE_CLOUDFLARE_API_BASE_URL"), + // PlanetScale integration (OAuth application — confidential client). The + // alerting worker runs the inventory poller (planetScaleTick → + // PlanetScaleService.pollAllOrgs), which resolves + refreshes each org's + // OAuth token via PlanetScaleOAuthService and needs the same config as + // the api worker. + ...optionalPlain("PLANETSCALE_OAUTH_CLIENT_ID"), + ...optionalSecret("PLANETSCALE_OAUTH_CLIENT_SECRET"), + ...optionalPlain("PLANETSCALE_OAUTH_AUTHORIZE_URL"), + ...optionalPlain("PLANETSCALE_OAUTH_TOKEN_URL"), + ...optionalPlain("MAPLE_PLANETSCALE_API_BASE_URL"), + }, + }) - return worker -} + if (hyperdriveRefId) { + // v1 `HyperdriveRef` equivalent: bind the dashboard-managed config by ID + // (see apps/api/alchemy.run.ts for the full rationale). + yield* worker.bind("MAPLE_DB", { + bindings: [{ type: "hyperdrive", name: "MAPLE_DB", id: hyperdriveRefId }], + }) + } + + return worker + }) diff --git a/apps/api/alchemy.run.ts b/apps/api/alchemy.run.ts index b104b531c..8d1214670 100644 --- a/apps/api/alchemy.run.ts +++ b/apps/api/alchemy.run.ts @@ -1,20 +1,13 @@ import path from "node:path" -import alchemy from "alchemy" -import { - EmailSender, - Hyperdrive, - HyperdriveRef, - KVNamespace, - Queue, - Worker, - WorkerStub, - Workflow, -} from "alchemy/cloudflare" +import * as Cloudflare from "alchemy/Cloudflare" +import * as Effect from "effect/Effect" +import * as Redacted from "effect/Redacted" import type { MapleDomains, MapleStage } from "@maple/infra/cloudflare" import { CLOUDFLARE_WORKER_PLACEMENT, resolveDeploymentEnvironment, resolveHyperdriveName, + resolveHyperdriveRefId, resolveWorkerName, } from "@maple/infra/cloudflare" @@ -31,201 +24,203 @@ const optionalPlain = (key: string, fallback?: string): Record = return value ? { [key]: value } : {} } -const optionalSecret = (key: string): Record> => { +const optionalSecret = (key: string): Record> => { const value = process.env[key]?.trim() - return value ? { [key]: alchemy.secret(value) } : {} -} - -// Managed Hyperdrive for non-prod stages (stg / per-PR preview / local dev): the -// origin is pushed from MAPLE_PG_URL (a standard Postgres connection string, direct -// port 5432) — the same env var the CI `drizzle-kit migrate` step + import scripts -// use. Cloudflare Hyperdrive needs a STRUCTURED origin (discrete host/user/…), not a -// URL, so we parse it here. Schema migrations run in CI before deploy, never at boot. -const makeManagedHyperdrive = (stage: MapleStage) => { - const pgUrl = new URL(requireEnv("MAPLE_PG_URL")) - return Hyperdrive("maple-db", { - name: resolveHyperdriveName(stage), - adopt: true, - origin: { - host: pgUrl.hostname, - port: Number(pgUrl.port || "5432"), - // Connect-time db (`postgres`, the PlanetScale cluster default), not the - // PS resource name. - database: pgUrl.pathname.replace(/^\//, "") || "postgres", - user: decodeURIComponent(pgUrl.username), - password: alchemy.secret(decodeURIComponent(pgUrl.password)), - }, - // Read-after-write everywhere (alert state CAS, dashboard versioning) — - // revisit caching once read paths that tolerate staleness are identified. - caching: { disabled: true }, - dev: { - origin: { - host: "localhost", - port: 5499, - database: "maple", - user: "maple", - password: "maple", - }, - }, - }) + return value ? { [key]: Redacted.make(value) } : {} } export interface CreateMapleApiOptions { stage: MapleStage domains: MapleDomains + /** The chat-flue worker, service-bound as CHAT_FLUE (hosts the Flue `triage` workflow). */ + chatFlue: Cloudflare.Worker } -export const createMapleApi = async ({ stage, domains }: CreateMapleApiOptions) => { - // Prod binds to the PRE-CONFIGURED Hyperdrive `maple-prd` — its origin and - // credentials are managed directly in the Cloudflare dashboard, NOT pushed - // from MAPLE_PG_URL. Reference it by name so a deploy never rewrites (or needs - // to know) the prod database connection. Other stages manage their own - // Hyperdrive from MAPLE_PG_URL below. - const mapleDb = - stage.kind === "prd" - ? await HyperdriveRef({ name: resolveHyperdriveName(stage) }) - : await makeManagedHyperdrive(stage) +export const createMapleApi = ({ stage, domains, chatFlue }: CreateMapleApiOptions) => + Effect.gen(function* () { + // MAPLE_DB Hyperdrive comes in two flavors: + // + // - stg/prd bind a DASHBOARD-MANAGED config by ID (v1's `HyperdriveRef`, + // which v2 lacks — the binding is attached as raw `{ type: "hyperdrive", + // id }` metadata after the Worker exists, see below). Origin/credentials + // live only in the Cloudflare dashboard; deploys never see them and + // MAPLE_PG_URL is not required. + // + // - pr/dev stages get an alchemy-MANAGED per-branch Hyperdrive whose origin + // is pushed from MAPLE_PG_URL (a standard Postgres connection string, + // direct port 5432) — the same env var the CI `drizzle-kit migrate` step + // + import scripts use. Cloudflare Hyperdrive needs a STRUCTURED origin + // (discrete host/user/…), not a URL, so we parse it here. Schema + // migrations run in CI before deploy, never at boot. + const hyperdriveRefId = resolveHyperdriveRefId(stage) + const mapleDb = hyperdriveRefId + ? undefined + : yield* Effect.gen(function* () { + const pgUrl = new URL(requireEnv("MAPLE_PG_URL")) + return yield* Cloudflare.Hyperdrive.Connection("maple-db", { + name: resolveHyperdriveName(stage), + origin: { + scheme: "postgres", + host: pgUrl.hostname, + port: Number(pgUrl.port || "5432"), + // Connect-time db (`postgres`, the PlanetScale cluster default), + // not the PS resource name. + database: pgUrl.pathname.replace(/^\//, "") || "postgres", + user: decodeURIComponent(pgUrl.username), + password: Redacted.make(decodeURIComponent(pgUrl.password)), + }, + // Read-after-write everywhere (alert state CAS, dashboard versioning) — + // revisit caching once read paths that tolerate staleness are identified. + caching: { disabled: true }, + dev: { + scheme: "postgres", + host: "localhost", + port: 5499, + database: "maple", + user: "maple", + password: Redacted.make("maple"), + }, + }) + }) - const mcpSessions = await KVNamespace("MCP_SESSIONS", { - title: resolveWorkerName("mcp-sessions", stage), - adopt: true, - }) + const mcpSessions = yield* Cloudflare.KV.Namespace("MCP_SESSIONS", { + title: resolveWorkerName("mcp-sessions", stage), + }) - // Long-running schema-apply: chunks heavy backfill migrations across durable - // steps so they never hit the Worker request budget. Class is exported from - // src/worker.ts. - const schemaApplyWorkflow = Workflow<{ orgId: string }>("clickhouse-schema-apply-workflow", { - workflowName: resolveWorkerName("schema-apply", stage), - className: "ClickHouseSchemaApplyWorkflow", - }) + // Long-running schema-apply: chunks heavy backfill migrations across durable + // steps so they never hit the Worker request budget. Class is exported from + // src/worker.ts. The first Workflow arg IS the physical workflow name; the + // api worker hosts it (no scriptName), so alchemy registers it after deploy. + const schemaApplyWorkflow = Cloudflare.Workflow<{ orgId: string }>( + resolveWorkerName("schema-apply", stage), + { className: "ClickHouseSchemaApplyWorkflow" }, + ) - // Headless AI triage agent: investigates freshly opened incidents (error or - // anomaly) with read-only tools and writes a structured summary back to the - // run row. Class is exported from src/worker.ts. - const aiTriageWorkflow = Workflow<{ - orgId: string - incidentKind: string - incidentId: string - issueId?: string - runId: string - }>("ai-triage-workflow", { - workflowName: resolveWorkerName("ai-triage", stage), - className: "AiTriageWorkflow", - }) + // Headless AI triage agent: investigates freshly opened incidents (error or + // anomaly) with read-only tools and writes a structured summary back to the + // run row. Class is exported from src/worker.ts. + const aiTriageWorkflow = Cloudflare.Workflow<{ + orgId: string + incidentKind: string + incidentId: string + issueId?: string + runId: string + }>(resolveWorkerName("ai-triage", stage), { className: "AiTriageWorkflow" }) - // Vendor-agnostic VCS sync queue (commit backfill + webhook deltas). The same - // `api` worker is both producer (binding) and consumer (eventSources). Local - // dev is wired separately in wrangler.jsonc so miniflare runs it in-process. - const vcsSyncQueue = await Queue("vcs-sync", { - name: resolveWorkerName("vcs-sync", stage), - adopt: true, - }) + // Vendor-agnostic VCS sync queue (commit backfill + webhook deltas). The same + // `api` worker is both producer (binding) and consumer (Queues.Consumer + // below). Local dev is wired separately in wrangler.jsonc so miniflare runs + // it in-process. + const vcsSyncQueue = yield* Cloudflare.Queues.Queue("vcs-sync", { + name: resolveWorkerName("vcs-sync", stage), + }) - // Service binding to the chat-flue worker that hosts the Flue `triage` - // workflow (the AI triage agent's investigation step). chat-flue is created - // AFTER api in the root alchemy.run.ts (it needs api's URL), so a plain - // service-binding ref to its name fails the api upload with CF error 10143 - // ("references Worker ... which was not found"). Reserve the name with an - // empty WorkerStub first; chat-flue's real deploy adopts it (adopt: true). - // This breaks the api↔chat-flue cycle without a URL dependency. - const chatFlue = await WorkerStub("chat-flue-stub", { - name: resolveWorkerName("chat-flue", stage), - url: false, - }) + const worker = yield* Cloudflare.Worker("api", { + name: resolveWorkerName("api", stage), + main: path.join(import.meta.dirname, "src", "worker.ts"), + compatibility: { date: "2026-04-08", flags: ["nodejs_compat"] }, + placement: CLOUDFLARE_WORKER_PLACEMENT, + url: true, + // Custom domain (not a zone route): routes don't create DNS records, so + // pr-stage hostnames would be authoritative NXDOMAIN. Custom domains + // provision DNS + edge certs automatically. + domain: domains.api, + // Periodic VCS sync backstop (every 12h) — enqueues a refresh per installation; see worker.ts `scheduled`. + crons: ["0 */12 * * *"], + env: { + // Ref stages attach MAPLE_DB via worker.bind below. + ...(mapleDb ? { MAPLE_DB: mapleDb } : {}), + MCP_SESSIONS: mcpSessions, + VCS_SYNC_QUEUE: vcsSyncQueue, + CLICKHOUSE_SCHEMA_APPLY_WORKFLOW: schemaApplyWorkflow, + AI_TRIAGE_WORKFLOW: aiTriageWorkflow, + CHAT_FLUE: chatFlue, + EMAIL: Cloudflare.Email.SendEmail("email", { + allowedSenderAddresses: ["notifications@noreply.maple.dev"], + }), + TINYBIRD_HOST: requireEnv("TINYBIRD_HOST"), + TINYBIRD_TOKEN: Redacted.make(requireEnv("TINYBIRD_TOKEN")), + ...optionalPlain("CLICKHOUSE_URL"), + ...optionalPlain("CLICKHOUSE_USER"), + ...optionalPlain("CLICKHOUSE_DATABASE"), + ...optionalSecret("CLICKHOUSE_PASSWORD"), + MAPLE_AUTH_MODE: process.env.MAPLE_AUTH_MODE?.trim() || "self_hosted", + MAPLE_DEFAULT_ORG_ID: process.env.MAPLE_DEFAULT_ORG_ID?.trim() || "default", + MAPLE_INGEST_KEY_ENCRYPTION_KEY: Redacted.make(requireEnv("MAPLE_INGEST_KEY_ENCRYPTION_KEY")), + MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY: Redacted.make( + requireEnv("MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY"), + ), + MAPLE_INGEST_PUBLIC_URL: + process.env.MAPLE_INGEST_PUBLIC_URL?.trim() || "https://ingest.maple.dev", + MAPLE_APP_BASE_URL: process.env.MAPLE_APP_BASE_URL?.trim() || "https://app.maple.dev", + EMAIL_FROM: process.env.EMAIL_FROM?.trim() || "Maple ", + // Bucket-cache knobs: on by default in deployed stages. Override via + // deploy-time env (e.g. `QE_BUCKET_CACHE_ENABLED=false`) if needed. + QE_BUCKET_CACHE_ENABLED: process.env.QE_BUCKET_CACHE_ENABLED?.trim() || "true", + QE_BUCKET_CACHE_TTL_SECONDS: process.env.QE_BUCKET_CACHE_TTL_SECONDS?.trim() || "86400", + QE_BUCKET_CACHE_FLUX_SECONDS: process.env.QE_BUCKET_CACHE_FLUX_SECONDS?.trim() || "60", + ...optionalPlain("MAPLE_ENDPOINT"), + ...optionalPlain("MAPLE_ENVIRONMENT", resolveDeploymentEnvironment(stage)), + ...optionalPlain("COMMIT_SHA"), + MAPLE_INGEST_KEY: Redacted.make(requireEnv("MAPLE_OTEL_INGEST_KEY")), + ...optionalSecret("MAPLE_ROOT_PASSWORD"), + ...optionalSecret("CLERK_SECRET_KEY"), + ...optionalPlain("CLERK_PUBLISHABLE_KEY"), + ...optionalSecret("CLERK_JWT_KEY"), + ...optionalSecret("AUTUMN_SECRET_KEY"), + ...optionalSecret("SD_INTERNAL_TOKEN"), + ...optionalSecret("INTERNAL_SERVICE_TOKEN"), + ...optionalPlain("HAZEL_API_BASE_URL"), + ...optionalPlain("HAZEL_OAUTH_DISCOVERY_URL"), + ...optionalPlain("HAZEL_OAUTH_CLIENT_ID"), + ...optionalSecret("HAZEL_OAUTH_CLIENT_SECRET"), + ...optionalPlain("HAZEL_OAUTH_SCOPES"), + ...optionalPlain("GITHUB_APP_ID"), + ...optionalPlain("GITHUB_APP_SLUG"), + ...optionalSecret("GITHUB_APP_PRIVATE_KEY"), + ...optionalPlain("GITHUB_APP_CLIENT_ID"), + ...optionalSecret("GITHUB_APP_CLIENT_SECRET"), + ...optionalSecret("GITHUB_APP_WEBHOOK_SECRET"), + ...optionalPlain("GITHUB_API_BASE_URL"), + // Cloudflare integration (account OAuth — Authorization Code + PKCE) + ...optionalPlain("CLOUDFLARE_OAUTH_CLIENT_ID"), + ...optionalSecret("CLOUDFLARE_OAUTH_CLIENT_SECRET"), + ...optionalPlain("CLOUDFLARE_OAUTH_SCOPES"), + ...optionalPlain("CLOUDFLARE_OAUTH_AUTHORIZE_URL"), + ...optionalPlain("CLOUDFLARE_OAUTH_TOKEN_URL"), + ...optionalPlain("CLOUDFLARE_OAUTH_REVOKE_URL"), + ...optionalPlain("MAPLE_CLOUDFLARE_API_BASE_URL"), + // PlanetScale integration (OAuth application — confidential client, no PKCE) + ...optionalPlain("PLANETSCALE_OAUTH_CLIENT_ID"), + ...optionalSecret("PLANETSCALE_OAUTH_CLIENT_SECRET"), + ...optionalPlain("PLANETSCALE_OAUTH_AUTHORIZE_URL"), + ...optionalPlain("PLANETSCALE_OAUTH_TOKEN_URL"), + ...optionalPlain("PLANETSCALE_OAUTH_TOKEN_INFO_URL"), + ...optionalPlain("MAPLE_PLANETSCALE_API_BASE_URL"), + }, + }) + + if (hyperdriveRefId) { + // v1 `HyperdriveRef` equivalent: bind the dashboard-managed config by ID + // as raw binding metadata (same mechanism the env binder uses). No cloud + // resource is created and the origin credentials stay in the dashboard. + yield* worker.bind("MAPLE_DB", { + bindings: [{ type: "hyperdrive", name: "MAPLE_DB", id: hyperdriveRefId }], + }) + } - const worker = await Worker("api", { - name: resolveWorkerName("api", stage), - cwd: import.meta.dirname, - entrypoint: path.join(import.meta.dirname, "src", "worker.ts"), - compatibility: "node", - compatibilityDate: "2026-04-08", - placement: CLOUDFLARE_WORKER_PLACEMENT, - url: true, - adopt: true, - routes: domains.api ? [{ pattern: `${domains.api}/*`, adopt: true }] : undefined, - // Periodic VCS sync backstop (every 12h) — enqueues a refresh per installation; see worker.ts `scheduled`. - crons: ["0 */12 * * *"], - eventSources: [ - { - queue: vcsSyncQueue, - settings: { - batchSize: 10, - maxConcurrency: 2, - maxRetries: 3, - maxWaitTimeMs: 5000, - }, + // Attach the api worker as the vcs-sync queue consumer (v1 `eventSources`). + yield* Cloudflare.Queues.Consumer("vcs-sync-consumer", { + queueId: vcsSyncQueue.queueId, + scriptName: worker.workerName, + settings: { + batchSize: 10, + maxConcurrency: 2, + maxRetries: 3, + maxWaitTimeMs: 5000, }, - ], - bindings: { - MAPLE_DB: mapleDb, - MCP_SESSIONS: mcpSessions, - VCS_SYNC_QUEUE: vcsSyncQueue, - CLICKHOUSE_SCHEMA_APPLY_WORKFLOW: schemaApplyWorkflow, - AI_TRIAGE_WORKFLOW: aiTriageWorkflow, - CHAT_FLUE: chatFlue, - EMAIL: EmailSender({ - allowedSenderAddresses: ["notifications@noreply.maple.dev"], - dev: { remote: true }, - }), - TINYBIRD_HOST: requireEnv("TINYBIRD_HOST"), - TINYBIRD_TOKEN: alchemy.secret(requireEnv("TINYBIRD_TOKEN")), - ...optionalPlain("CLICKHOUSE_URL"), - ...optionalPlain("CLICKHOUSE_USER"), - ...optionalPlain("CLICKHOUSE_DATABASE"), - ...optionalSecret("CLICKHOUSE_PASSWORD"), - MAPLE_AUTH_MODE: process.env.MAPLE_AUTH_MODE?.trim() || "self_hosted", - MAPLE_DEFAULT_ORG_ID: process.env.MAPLE_DEFAULT_ORG_ID?.trim() || "default", - MAPLE_INGEST_KEY_ENCRYPTION_KEY: alchemy.secret(requireEnv("MAPLE_INGEST_KEY_ENCRYPTION_KEY")), - MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY: alchemy.secret(requireEnv("MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY")), - MAPLE_INGEST_PUBLIC_URL: - process.env.MAPLE_INGEST_PUBLIC_URL?.trim() || "https://ingest.maple.dev", - MAPLE_APP_BASE_URL: process.env.MAPLE_APP_BASE_URL?.trim() || "https://app.maple.dev", - EMAIL_FROM: process.env.EMAIL_FROM?.trim() || "Maple ", - // Bucket-cache knobs: on by default in deployed stages. Override via - // deploy-time env (e.g. `QE_BUCKET_CACHE_ENABLED=false`) if needed. - QE_BUCKET_CACHE_ENABLED: process.env.QE_BUCKET_CACHE_ENABLED?.trim() || "true", - QE_BUCKET_CACHE_TTL_SECONDS: process.env.QE_BUCKET_CACHE_TTL_SECONDS?.trim() || "86400", - QE_BUCKET_CACHE_FLUX_SECONDS: process.env.QE_BUCKET_CACHE_FLUX_SECONDS?.trim() || "60", - ...optionalPlain("MAPLE_ENDPOINT"), - ...optionalPlain("MAPLE_ENVIRONMENT", resolveDeploymentEnvironment(stage)), - ...optionalPlain("COMMIT_SHA"), - MAPLE_INGEST_KEY: alchemy.secret(requireEnv("MAPLE_OTEL_INGEST_KEY")), - ...optionalSecret("MAPLE_ROOT_PASSWORD"), - ...optionalSecret("CLERK_SECRET_KEY"), - ...optionalPlain("CLERK_PUBLISHABLE_KEY"), - ...optionalSecret("CLERK_JWT_KEY"), - ...optionalSecret("AUTUMN_SECRET_KEY"), - ...optionalSecret("SD_INTERNAL_TOKEN"), - ...optionalSecret("INTERNAL_SERVICE_TOKEN"), - ...optionalPlain("HAZEL_API_BASE_URL"), - ...optionalPlain("HAZEL_OAUTH_DISCOVERY_URL"), - ...optionalPlain("HAZEL_OAUTH_CLIENT_ID"), - ...optionalSecret("HAZEL_OAUTH_CLIENT_SECRET"), - ...optionalPlain("HAZEL_OAUTH_SCOPES"), - ...optionalPlain("GITHUB_APP_ID"), - ...optionalPlain("GITHUB_APP_SLUG"), - ...optionalSecret("GITHUB_APP_PRIVATE_KEY"), - ...optionalPlain("GITHUB_APP_CLIENT_ID"), - ...optionalSecret("GITHUB_APP_CLIENT_SECRET"), - ...optionalSecret("GITHUB_APP_WEBHOOK_SECRET"), - ...optionalPlain("GITHUB_API_BASE_URL"), - // Cloudflare integration (account OAuth — Authorization Code + PKCE) - ...optionalPlain("CLOUDFLARE_OAUTH_CLIENT_ID"), - ...optionalSecret("CLOUDFLARE_OAUTH_CLIENT_SECRET"), - ...optionalPlain("CLOUDFLARE_OAUTH_SCOPES"), - ...optionalPlain("CLOUDFLARE_OAUTH_AUTHORIZE_URL"), - ...optionalPlain("CLOUDFLARE_OAUTH_TOKEN_URL"), - ...optionalPlain("CLOUDFLARE_OAUTH_REVOKE_URL"), - ...optionalPlain("MAPLE_CLOUDFLARE_API_BASE_URL"), - // PlanetScale integration (OAuth application — confidential client, no PKCE) - ...optionalPlain("PLANETSCALE_OAUTH_CLIENT_ID"), - ...optionalSecret("PLANETSCALE_OAUTH_CLIENT_SECRET"), - ...optionalPlain("PLANETSCALE_OAUTH_AUTHORIZE_URL"), - ...optionalPlain("PLANETSCALE_OAUTH_TOKEN_URL"), - ...optionalPlain("PLANETSCALE_OAUTH_TOKEN_INFO_URL"), - ...optionalPlain("MAPLE_PLANETSCALE_API_BASE_URL"), - }, - }) + }) - return { worker, db: mapleDb } -} + // `db` is undefined on ref stages — alerting resolves the same ref itself. + return { worker, db: mapleDb } + }) diff --git a/apps/api/package.json b/apps/api/package.json index 76cdd4ea5..35a0f7c9b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -41,7 +41,6 @@ "@maple/query-engine": "workspace:*", "@react-email/components": "^1.0.11", "@tinybirdco/sdk": "catalog:tinybird", - "alchemy": "^0.93.12", "autumn-js": "^1.2.29", "drizzle-orm": "^0.45.1", "effect": "catalog:effect" diff --git a/apps/chat-flue/alchemy.run.ts b/apps/chat-flue/alchemy.run.ts index 26d6c7b28..c7017401a 100644 --- a/apps/chat-flue/alchemy.run.ts +++ b/apps/chat-flue/alchemy.run.ts @@ -1,7 +1,9 @@ -import { execFileSync } from "node:child_process" import path from "node:path" -import alchemy from "alchemy" -import { Ai, DurableObjectNamespace, Worker } from "alchemy/cloudflare" +import * as Cloudflare from "alchemy/Cloudflare" +import * as Command from "alchemy/Command" +import * as Output from "alchemy/Output" +import * as Effect from "effect/Effect" +import * as Redacted from "effect/Redacted" import type { MapleDomains, MapleStage } from "@maple/infra/cloudflare" import { CLOUDFLARE_WORKER_PLACEMENT, @@ -22,9 +24,9 @@ const optionalPlain = (key: string, fallback?: string): Record = return value ? { [key]: value } : {} } -const optionalSecret = (key: string): Record> => { +const optionalSecret = (key: string): Record> => { const value = process.env[key]?.trim() - return value ? { [key]: alchemy.secret(value) } : {} + return value ? { [key]: Redacted.make(value) } : {} } export interface CreateChatFlueWorkerOptions { @@ -33,89 +35,96 @@ export interface CreateChatFlueWorkerOptions { mapleApiUrl: string } + /** * Deploy the Flue chat worker (`apps/chat-flue`) via alchemy, consistent with * the rest of the stack. * - * Flue builds its own Cloudflare entrypoint + Durable Object classes, so this - * runs `flue build` first, then deploys the prebuilt bundle with `noBundle` - * (alchemy uploads `index.js` + the code-split `assets/*.js` modules as-is). - * Alchemy owns the bindings here — the generated `dist/.../wrangler.json` vars - * and `.dev.vars` are NOT read (only `.js`/`.mjs` are uploaded), so no local - * secret leaks into the deploy. Keep the DO binding NAMES and class names in - * sync with the generated `dist/maple_chat_flue/wrangler.json`. + * Flue builds its own Cloudflare entrypoint + Durable Object classes, so a + * `Command.Build` runs `flue build` first (memoized on the app's source files, + * skipped on destroy), then the Worker deploys the prebuilt bundle with + * `bundle: false` (alchemy uploads `index.js` + the code-split `assets/*.js` + * modules as-is). Alchemy owns the bindings here — the generated + * `dist/.../wrangler.json` vars and `.dev.vars` are NOT read (only `.js`/`.mjs` + * are uploaded), so no local secret leaks into the deploy. Keep the DO binding + * NAMES and class names in sync with the generated + * `dist/maple_chat_flue/wrangler.json`. * * Manual fallback (Flue-native): `cd apps/chat-flue && bun run build && * wrangler deploy --config dist/maple_chat_flue/wrangler.json`. */ -export const createChatFlueWorker = async ({ stage, domains, mapleApiUrl }: CreateChatFlueWorkerOptions) => { - // Flue generates the Worker entrypoint + DO classes; build before deploy. - execFileSync("bun", ["run", "build"], { cwd: import.meta.dirname, stdio: "inherit" }) +export const createChatFlueWorker = ({ stage, domains, mapleApiUrl }: CreateChatFlueWorkerOptions) => + Effect.gen(function* () { + // Flue generates the Worker entrypoint + DO classes; build before deploy. + const build = yield* Command.Build("chat-flue-build", { + command: "bun run build", + cwd: import.meta.dirname, + outdir: path.join("dist", "maple_chat_flue"), + }) - const distDir = path.join(import.meta.dirname, "dist", "maple_chat_flue") + // Flue-generated Durable Objects. Binding names come from the `env` keys + // below and class names must match the exports of the built `index.js`. + // v2 provisions new DO classes as SQLite-backed by default (the v1 + // `sqlite: true` prop is gone). + const chatAgent = Cloudflare.DurableObject("flue-maple-chat-agent", { + className: "FlueMapleChatAgent", + }) + const triageWorkflow = Cloudflare.DurableObject("flue-triage-workflow", { + className: "FlueTriageWorkflow", + }) + const registry = Cloudflare.DurableObject("flue-registry", { + className: "FlueRegistry", + }) - // Flue-generated Durable Objects. Names must match `env.FLUE_*` and the - // class names exported by the built `index.js`; alchemy derives the v1 - // `new_sqlite_classes` migration from these sqlite namespaces. - const chatAgent = DurableObjectNamespace("flue-maple-chat-agent", { - className: "FlueMapleChatAgent", - sqlite: true, - }) - const triageWorkflow = DurableObjectNamespace("flue-triage-workflow", { - className: "FlueTriageWorkflow", - sqlite: true, - }) - const registry = DurableObjectNamespace("flue-registry", { - className: "FlueRegistry", - sqlite: true, - }) + const worker = yield* Cloudflare.Worker("chat-flue", { + name: resolveWorkerName("chat-flue", stage), + // Derived from the Build output so the Worker upload depends on (and + // waits for) the flue build. + main: Output.map(build.outdir, (outdir) => path.resolve(outdir, "index.js")), + // Deploy Flue's prebuilt bundle as-is (index.js + assets/*.js modules). + bundle: false, + rules: [{ globs: ["**/*.js", "**/*.mjs"] }], + compatibility: { date: "2026-06-01", flags: ["nodejs_compat"] }, + placement: CLOUDFLARE_WORKER_PLACEMENT, + // Workers Observability. `traces.enabled` is required for the `tracing.enterSpan` + // custom spans in src/agents/maple-chat.ts to emit; the `"maple"` destination + // forwards both logs and traces over Cloudflare's native pipeline → Maple ingest + // (the same path the existing auto-spans use, unlike the @flue/opentelemetry + // export which doesn't flush reliably from DO isolates). `"maple"` is the + // account-level telemetry destination id. + observability: { + enabled: true, + logs: { enabled: true, invocationLogs: true, destinations: ["maple"] }, + traces: { enabled: true, destinations: ["maple"] }, + }, + url: true, + domain: domains.chat, + env: { + // Workers AI (`env.AI`, the v1 `Ai()` binding). v2 emits the + // `{ type: "ai" }` binding by attaching an AI Gateway resource — the + // gateway also fronts model calls with caching/rate-limits/logging. + // NOTE: the deploy token needs the account-level "AI Gateway: Edit" + // permission for this resource. + AI: Cloudflare.AI.Gateway("chat-flue-ai"), + FLUE_MAPLE_CHAT_AGENT: chatAgent, + FLUE_TRIAGE_WORKFLOW: triageWorkflow, + FLUE_REGISTRY: registry, + MAPLE_API_URL: mapleApiUrl, + INTERNAL_SERVICE_TOKEN: Redacted.make(requireEnv("INTERNAL_SERVICE_TOKEN")), + // OpenTelemetry → Maple ingest. Provide the internal-org ingest key so + // chat-flue spans land beside `maple-api`; telemetry no-ops when unset. + ...optionalSecret("MAPLE_INGEST_KEY"), + ...optionalPlain("MAPLE_ENDPOINT"), + ...optionalPlain("MAPLE_ENVIRONMENT", resolveDeploymentEnvironment(stage)), + ...optionalPlain("MAPLE_CHAT_MODEL"), + ...optionalPlain("MAPLE_TRIAGE_MODEL"), + ...optionalPlain("MAPLE_AUTH_MODE", "self_hosted"), + ...optionalSecret("MAPLE_ROOT_PASSWORD"), + ...optionalSecret("CLERK_SECRET_KEY"), + ...optionalPlain("CLERK_PUBLISHABLE_KEY"), + ...optionalSecret("CLERK_JWT_KEY"), + }, + }) - const worker = await Worker("chat-flue", { - name: resolveWorkerName("chat-flue", stage), - cwd: distDir, - entrypoint: path.join(distDir, "index.js"), - // Deploy Flue's prebuilt bundle as-is (index.js + assets/*.js modules). - noBundle: true, - format: "esm", - rules: [{ globs: ["**/*.js", "**/*.mjs"] }], - compatibilityDate: "2026-06-01", - compatibilityFlags: ["nodejs_compat"], - placement: CLOUDFLARE_WORKER_PLACEMENT, - // Workers Observability. `traces.enabled` is required for the `tracing.enterSpan` - // custom spans in src/agents/maple-chat.ts to emit; the `"maple"` destination - // forwards both logs and traces over Cloudflare's native pipeline → Maple ingest - // (the same path the existing auto-spans use, unlike the @flue/opentelemetry - // export which doesn't flush reliably from DO isolates). `"maple"` is the - // account-level telemetry destination id. - observability: { - enabled: true, - logs: { destinations: ["maple"] }, - traces: { enabled: true, destinations: ["maple"] }, - }, - url: true, - adopt: true, - domains: domains.chat ? [{ domainName: domains.chat, adopt: true }] : undefined, - bindings: { - AI: Ai(), - FLUE_MAPLE_CHAT_AGENT: chatAgent, - FLUE_TRIAGE_WORKFLOW: triageWorkflow, - FLUE_REGISTRY: registry, - MAPLE_API_URL: mapleApiUrl, - INTERNAL_SERVICE_TOKEN: alchemy.secret(requireEnv("INTERNAL_SERVICE_TOKEN")), - // OpenTelemetry → Maple ingest. Provide the internal-org ingest key so - // chat-flue spans land beside `maple-api`; telemetry no-ops when unset. - ...optionalSecret("MAPLE_INGEST_KEY"), - ...optionalPlain("MAPLE_ENDPOINT"), - ...optionalPlain("MAPLE_ENVIRONMENT", resolveDeploymentEnvironment(stage)), - ...optionalPlain("MAPLE_CHAT_MODEL"), - ...optionalPlain("MAPLE_TRIAGE_MODEL"), - ...optionalPlain("MAPLE_AUTH_MODE", "self_hosted"), - ...optionalSecret("MAPLE_ROOT_PASSWORD"), - ...optionalSecret("CLERK_SECRET_KEY"), - ...optionalPlain("CLERK_PUBLISHABLE_KEY"), - ...optionalSecret("CLERK_JWT_KEY"), - }, + return worker }) - - return worker -} diff --git a/apps/electric-sync/alchemy.run.ts b/apps/electric-sync/alchemy.run.ts index 933717fcc..1f338bc66 100644 --- a/apps/electric-sync/alchemy.run.ts +++ b/apps/electric-sync/alchemy.run.ts @@ -1,6 +1,7 @@ import path from "node:path" -import alchemy from "alchemy" -import { Worker } from "alchemy/cloudflare" +import * as Cloudflare from "alchemy/Cloudflare" +import * as Effect from "effect/Effect" +import * as Redacted from "effect/Redacted" import type { MapleDomains, MapleStage } from "@maple/infra/cloudflare" import { CLOUDFLARE_WORKER_PLACEMENT, @@ -21,9 +22,9 @@ const optionalPlain = (key: string, fallback?: string): Record = return value ? { [key]: value } : {} } -const optionalSecret = (key: string): Record> => { +const optionalSecret = (key: string): Record> => { const value = process.env[key]?.trim() - return value ? { [key]: alchemy.secret(value) } : {} + return value ? { [key]: Redacted.make(value) } : {} } export interface CreateElectricSyncWorkerOptions { @@ -34,38 +35,39 @@ export interface CreateElectricSyncWorkerOptions { // Standalone ElectricSQL shape-proxy worker. Deliberately DB-free: it authenticates // callers from the Clerk / self-hosted session bearer only (no Hyperdrive / MAPLE_DB // binding), pins each shape's org scope, and forwards to Electric. -export const createElectricSyncWorker = async ({ stage, domains }: CreateElectricSyncWorkerOptions) => { - const worker = await Worker("electric-sync", { - name: resolveWorkerName("electric-sync", stage), - cwd: import.meta.dirname, - entrypoint: path.join(import.meta.dirname, "src", "worker.ts"), - compatibility: "node", - compatibilityDate: "2026-04-08", - placement: CLOUDFLARE_WORKER_PLACEMENT, - url: true, - adopt: true, - routes: domains.sync ? [{ pattern: `${domains.sync}/*`, adopt: true }] : undefined, - bindings: { - // Auth (same AuthEnv subset the api worker sets; no DB). - MAPLE_AUTH_MODE: process.env.MAPLE_AUTH_MODE?.trim() || "self_hosted", - MAPLE_DEFAULT_ORG_ID: process.env.MAPLE_DEFAULT_ORG_ID?.trim() || "default", - ...optionalPlain("MAPLE_ORG_ID_OVERRIDE"), - ...optionalSecret("MAPLE_ROOT_PASSWORD"), - ...optionalSecret("CLERK_SECRET_KEY"), - ...optionalPlain("CLERK_PUBLISHABLE_KEY"), - ...optionalSecret("CLERK_JWT_KEY"), - // ElectricSQL upstream: base URL (Electric Cloud in prod) + Cloud source - // credentials. The shape proxy 503s if URL is unset. - ...optionalPlain("ELECTRIC_URL"), - ...optionalPlain("ELECTRIC_SOURCE_ID"), - ...optionalSecret("ELECTRIC_SECRET"), - // Self-observability (OTLP export through the ingest gateway). - MAPLE_INGEST_KEY: alchemy.secret(requireEnv("MAPLE_OTEL_INGEST_KEY")), - ...optionalPlain("MAPLE_ENDPOINT"), - ...optionalPlain("MAPLE_ENVIRONMENT", resolveDeploymentEnvironment(stage)), - ...optionalPlain("COMMIT_SHA"), - }, - }) +export const createElectricSyncWorker = ({ stage, domains }: CreateElectricSyncWorkerOptions) => + Effect.gen(function* () { + const worker = yield* Cloudflare.Worker("electric-sync", { + name: resolveWorkerName("electric-sync", stage), + main: path.join(import.meta.dirname, "src", "worker.ts"), + compatibility: { date: "2026-04-08", flags: ["nodejs_compat"] }, + placement: CLOUDFLARE_WORKER_PLACEMENT, + url: true, + // Custom domain (not a zone route): routes don't create DNS records, so + // pr-stage hostnames would be authoritative NXDOMAIN. Custom domains + // provision DNS + edge certs automatically. + domain: domains.sync, + env: { + // Auth (same AuthEnv subset the api worker sets; no DB). + MAPLE_AUTH_MODE: process.env.MAPLE_AUTH_MODE?.trim() || "self_hosted", + MAPLE_DEFAULT_ORG_ID: process.env.MAPLE_DEFAULT_ORG_ID?.trim() || "default", + ...optionalPlain("MAPLE_ORG_ID_OVERRIDE"), + ...optionalSecret("MAPLE_ROOT_PASSWORD"), + ...optionalSecret("CLERK_SECRET_KEY"), + ...optionalPlain("CLERK_PUBLISHABLE_KEY"), + ...optionalSecret("CLERK_JWT_KEY"), + // ElectricSQL upstream: base URL (Electric Cloud in prod) + Cloud source + // credentials. The shape proxy 503s if URL is unset. + ...optionalPlain("ELECTRIC_URL"), + ...optionalPlain("ELECTRIC_SOURCE_ID"), + ...optionalSecret("ELECTRIC_SECRET"), + // Self-observability (OTLP export through the ingest gateway). + MAPLE_INGEST_KEY: Redacted.make(requireEnv("MAPLE_OTEL_INGEST_KEY")), + ...optionalPlain("MAPLE_ENDPOINT"), + ...optionalPlain("MAPLE_ENVIRONMENT", resolveDeploymentEnvironment(stage)), + ...optionalPlain("COMMIT_SHA"), + }, + }) - return worker -} + return worker + }) diff --git a/apps/landing/alchemy.run.ts b/apps/landing/alchemy.run.ts index 8c82155fb..41a60b7d0 100644 --- a/apps/landing/alchemy.run.ts +++ b/apps/landing/alchemy.run.ts @@ -1,6 +1,8 @@ -import { spawnSync } from "node:child_process" import path from "node:path" -import { Assets, Worker } from "alchemy/cloudflare" +import * as Cloudflare from "alchemy/Cloudflare" +import * as Command from "alchemy/Command" +import * as Output from "alchemy/Output" +import * as Effect from "effect/Effect" import { CLOUDFLARE_WORKER_PLACEMENT, resolveWorkerName, @@ -13,32 +15,25 @@ export interface CreateLandingWorkerOptions { domains: MapleDomains } -export const createLandingWorker = async ({ stage, domains }: CreateLandingWorkerOptions) => { - const isDestroy = process.argv.some((arg) => arg === "destroy") - if (!isDestroy) { - const build = spawnSync("bun", ["run", "build"], { - stdio: "inherit", +export const createLandingWorker = ({ stage, domains }: CreateLandingWorkerOptions) => + Effect.gen(function* () { + // Astro static build (memoized on the app's source files, skipped on destroy). + const build = yield* Command.Build("landing-build", { + command: "bun run build", cwd: import.meta.dirname, - env: process.env, + outdir: "dist", }) - if (build.status !== 0) { - throw new Error(`landing build failed with exit code ${build.status ?? "unknown"}`) - } - } - const worker = await Worker("landing", { - name: resolveWorkerName("landing", stage), - cwd: import.meta.dirname, - entrypoint: path.join(import.meta.dirname, "src", "worker.ts"), - compatibility: "node", - placement: CLOUDFLARE_WORKER_PLACEMENT, - url: true, - adopt: true, - domains: domains.landing ? [{ domainName: domains.landing, adopt: true }] : undefined, - bindings: { - ASSETS: await Assets({ path: path.join(import.meta.dirname, "dist") }), - }, - }) + const worker = yield* Cloudflare.Worker<{}, Cloudflare.AssetsWithHash>("landing", { + name: resolveWorkerName("landing", stage), + main: path.join(import.meta.dirname, "src", "worker.ts"), + // The `assets` prop auto-adds the ASSETS binding `src/worker.ts` reads. + assets: { directory: build.outdir, hash: Output.map(build.hash, (h) => h.output ?? "") }, + compatibility: { date: "2026-04-08", flags: ["nodejs_compat"] }, + placement: CLOUDFLARE_WORKER_PLACEMENT, + url: true, + domain: domains.landing, + }) - return worker -} + return worker + }) diff --git a/apps/landing/messages/en.json b/apps/landing/messages/en.json index 3349c8e1b..345464882 100644 --- a/apps/landing/messages/en.json +++ b/apps/landing/messages/en.json @@ -4,8 +4,6 @@ "cta_learn_more": "Learn more \u2192", "cta_see_full_story": "See the full story \u2192", - "nav_announcement": "Maple is live \u00b7 ", - "nav_announcement_link": "start your free trial", "nav_features": "Features", "nav_use_cases": "Use Cases", "nav_integrations": "Integrations", diff --git a/apps/landing/messages/ja.json b/apps/landing/messages/ja.json index 194b450ee..df2901a40 100644 --- a/apps/landing/messages/ja.json +++ b/apps/landing/messages/ja.json @@ -4,8 +4,6 @@ "cta_learn_more": "\u8a73\u3057\u304f\u898b\u308b \u2192", "cta_see_full_story": "\u8a73\u7d30\u3092\u898b\u308b \u2192", - "nav_announcement": "Maple\u304c\u5229\u7528\u53ef\u80fd\u306b\u306a\u308a\u307e\u3057\u305f \u2014 ", - "nav_announcement_link": "\u7121\u6599\u30c8\u30e9\u30a4\u30a2\u30eb\u3092\u958b\u59cb\u3059\u308b", "nav_features": "\u6a5f\u80fd", "nav_use_cases": "\u30e6\u30fc\u30b9\u30b1\u30fc\u30b9", "nav_integrations": "\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3", diff --git a/apps/landing/messages/ko.json b/apps/landing/messages/ko.json index f5e36f6fa..442510965 100644 --- a/apps/landing/messages/ko.json +++ b/apps/landing/messages/ko.json @@ -4,8 +4,6 @@ "cta_learn_more": "\uc790\uc138\ud788 \ubcf4\uae30 \u2192", "cta_see_full_story": "\uc804\uccb4 \uc2a4\ud1a0\ub9ac \ubcf4\uae30 \u2192", - "nav_announcement": "Maple\uc774 \ucd9c\uc2dc\ub418\uc5c8\uc2b5\ub2c8\ub2e4 \u2014 ", - "nav_announcement_link": "\ubb34\ub8cc \ud3c9\uac00\ud310 \uc2dc\uc791\ud558\uae30", "nav_features": "\uae30\ub2a5", "nav_use_cases": "\uc0ac\uc6a9 \uc0ac\ub840", "nav_integrations": "\ud1b5\ud569", diff --git a/apps/landing/package.json b/apps/landing/package.json index 76569c13f..7a434b869 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -26,7 +26,6 @@ "@types/react": "catalog:react", "@types/react-dom": "catalog:react", "@xyflow/react": "catalog:ui", - "alchemy": "^0.93.12", "astro": "^5.17.1", "autumn-js": "^1.2.29", "class-variance-authority": "catalog:ui", diff --git a/apps/landing/src/components/FeatureHero.astro b/apps/landing/src/components/FeatureHero.astro index a5525ea34..73d74fb2d 100644 --- a/apps/landing/src/components/FeatureHero.astro +++ b/apps/landing/src/components/FeatureHero.astro @@ -8,7 +8,7 @@ interface Props { const { label, title, subtitle } = Astro.props; --- -
+
- + Start free trial diff --git a/apps/landing/src/components/Hero.astro b/apps/landing/src/components/Hero.astro index 921a01539..0766f6749 100644 --- a/apps/landing/src/components/Hero.astro +++ b/apps/landing/src/components/Hero.astro @@ -3,7 +3,7 @@ import AppMock from './AppMock.astro'; import * as m from "../paraglide/messages"; --- -
+
- + {m.cta_get_started()} diff --git a/apps/landing/src/components/Nav.astro b/apps/landing/src/components/Nav.astro index 20077f28a..0ca4c2108 100644 --- a/apps/landing/src/components/Nav.astro +++ b/apps/landing/src/components/Nav.astro @@ -1,5 +1,4 @@ --- -import * as m from "../paraglide/messages"; import { NavBar } from "./NavBar" import { getGitHubStars } from "../lib/github-stars" @@ -7,15 +6,7 @@ const locale = Astro.currentLocale ?? "en"; const stars = await getGitHubStars(); --- - - - -