// The Maple stack: each Worker is its app's `src/worker.ts`; the ECS services (ingest, // electric, scraper) are `create*` factories. Decision history lives in docs/infra.md. import { appendFileSync } from "node:fs" import path from "node:path" import * as Alchemy from "alchemy" import * as AWS from "@alchemy.run/aws" import * as Cloudflare from "@alchemy.run/cloudflare" import * as Command from "alchemy/Command" import * as Neon from "@alchemy.run/neon" import * as Output from "alchemy/Output" import * as Planetscale from "@alchemy.run/planetscale" import * as RemovalPolicy from "alchemy/RemovalPolicy" import { ConfigError } from "effect/Config" import { SourceError } from "effect/ConfigProvider" import * as Effect from "effect/Effect" import * as Layer from "effect/Layer" import * as Redacted from "effect/Redacted" import { AwsRegionMismatchError, resolveAwsRegion } from "@maple/infra/aws" import { ApiWorker, AiWorker, SandboxWorker, formatMapleDeployment, formatMapleStage, ManagedMapleDb, type MapleDbConsumer, type MapleDbLogin, type MapleDbResources, type MapleDeployment, MapleStack, type MapleStackContext, parseMapleDeploymentEffect, prdOrigin, resolveMapleDomains, resolvePlanetscaleDatabase, resolveWorkerName, } from "@maple/infra/cloudflare" import { DB_ACCESS_ROLES } from "@maple/db/roles" import { deployedDomains, type MapleProfile, resolveMapleProfile } from "@maple/infra/profile" import * as Acm from "@maple/infra/acm" import { createAwsSecurityBaseline } from "@maple/infra/security-baseline" import { optionalPlain, plainWithDefault, requiredPlain } from "@maple/infra/env" import * as Portless from "@maple/alchemy-portless" import { DEV_PROCESS_APPS, selectedDevApps, type DevApp } from "@maple/infra/dev-urls" import MapleAiLive, { MapleAi } from "./apps/ai/src/worker.ts" import Alerting from "./apps/alerting/src/worker.ts" import ChatBotLive, { ChatBot } from "./apps/chat-bot/src/worker.ts" import MapleApi from "./apps/api/src/worker.ts" import MapleSandbox from "./apps/sandbox/alchemy.run.ts" import { createMapleElectric } from "./apps/electric/alchemy.run.ts" import ElectricSync from "./apps/electric-sync/src/worker.ts" import StatusWorker from "./apps/status/src/worker.ts" import { createMapleIngest } from "./apps/ingest/alchemy.run.ts" import { createMapleScraper } from "./apps/scraper/alchemy.run.ts" import Landing from "./apps/landing/src/worker.ts" import LocalUi from "./apps/local-ui/src/worker.ts" import Web from "./apps/web/src/worker.ts" import { Array as Arr } from "effect" // Infisical defines CLOUDFLARE_DEFAULT_ACCOUNT_ID; alchemy reads CLOUDFLARE_ACCOUNT_ID. if (!process.env.CLOUDFLARE_ACCOUNT_ID && process.env.CLOUDFLARE_DEFAULT_ACCOUNT_ID) { process.env.CLOUDFLARE_ACCOUNT_ID = process.env.CLOUDFLARE_DEFAULT_ACCOUNT_ID } // Inter-app URLs must be plain strings at plan time (`worker.url` is a lazy Output), so // deployed stages use custom domains and dev stages fall back to env-supplied URLs. const resolveUrl = (domain: string | undefined, envKey: string, fallback = "") => domain ? Effect.succeed(`https://${domain}`) : Effect.map(plainWithDefault(envKey, fallback), (record) => record[envKey] ?? fallback) /** A typed deploy-setting failure as the `ConfigError` the stack can surface; the original is the cause. */ const asConfigError = (error: { readonly message: string }) => Effect.fail(new ConfigError(new SourceError({ message: error.message, cause: error }))) /** Append `key=value` lines to the GitHub Actions step-output file, if any. */ const appendStepOutputs = (lines: string[]): void => { const file = process.env.GITHUB_OUTPUT if (file) { appendFileSync(file, `${Arr.join(lines, "\n")}\n`) } } /** * `alchemy dev` sets ALCHEMY_DEV on its exec child. Not stage-derived: a dev * stage can still be deployed, and this must stay false when it is. */ const isDevServer = process.env.ALCHEMY_DEV === "true" /** The apps this dev run serves; undefined on a deploy, which is never partial. */ const devApps = isDevServer ? selectedDevApps() : undefined /** Every resource is declared on every run; a subset run only leaves the others unserved. */ const workerDev = (app: DevApp) => devApps === undefined ? undefined : devApps.has(app) ? Portless.workerDev(app) : Portless.workerUnserved /** Inter-app URLs handed to the Workers as env, so `.env.local` cannot override them. */ const devEnv = devApps ? { MAPLE_API_BASE_URL: Portless.routeUrl("api"), MAPLE_APP_BASE_URL: Portless.routeUrl("web"), MAPLE_ELECTRIC_SYNC_URL: Portless.routeUrl("electric-sync"), MAPLE_INGEST_URL: Portless.routeUrl("ingest"), MAPLE_STATUS_PAGE_BASE_URL: Portless.routeUrl("status"), } : undefined /** A Hyperdrive config for every consumer of one shared config. */ const sharedHyperdrive = (connection: Cloudflare.Hyperdrive.Connection) => ({ api: connection, ai: connection, "chat-bot": connection, alerting: connection, }) /** * A PR's own Neon branch, migrated by its deploy, behind one Hyperdrive config. Branches fork the * `NEON_PROJECT_ID` project's empty default branch, whose project must allow logical replication. */ const declarePreviewDb = ({ stage, region }: MapleDeployment, profile: MapleProfile) => Effect.gen(function* () { const branch = yield* Neon.Branch("maple-db-preview", { project: { projectId: yield* requiredPlain("NEON_PROJECT_ID") }, name: formatMapleStage(stage), migrations: "packages/db/drizzle", }) const hyperdrive = yield* Cloudflare.Hyperdrive.Connection("db-preview", { name: resolveWorkerName("db", stage, region), origin: branch.origin, caching: { disabled: true }, }) // The branch owner logs in everywhere; with logical replication on it may replicate. const login = (url: Output.Output): MapleDbLogin => ({ id: branch.branchId, url: Output.map(url, Redacted.make), }) return { branch: branch.branchName, hyperdrives: sharedHyperdrive(hyperdrive), ingest: login(branch.pooledConnectionUri), electric: profile.deploys.electric ? login(branch.connectionUri) : undefined, } satisfies MapleDbResources }) /** * prd's `main` branch (its deploy applies migrations) and the ECS services' roles. On EU, also a * role and a Hyperdrive config per consumer on the role's direct origin; US prd binds dashboard * configs by id. */ const declarePrdDb = ({ stage, region }: MapleDeployment, profile: MapleProfile) => Effect.gen(function* () { const database = resolvePlanetscaleDatabase(region) const schema = yield* Planetscale.PostgresBranch("maple-db-main", { database, name: "main", migrations: "packages/db/drizzle", }).pipe(RemovalPolicy.retain()) // Runtime logins read and write rows but own nothing; migrations run as the branch owner. // Changing roles is a create-first replace (the ingest role's id is in the task env). const ingestRole = yield* Planetscale.PostgresRole("ingest-gateway", { database, branch: schema, inheritedRoles: [...DB_ACCESS_ROLES.readWrite], }) // Id must not be `"electric"` (the ECS service's id): alchemy keys state by id alone. // Stays on `postgres`: PlanetScale grants REPLICATION only alongside it. const electricRole = profile.deploys.electric ? yield* Planetscale.PostgresRole("electric-db-role", { database, branch: schema, inheritedRoles: ["postgres"], withReplication: true, }) : undefined const resources = { branch: schema.name, ingest: { id: ingestRole.id, url: ingestRole.connectionUrlPooled }, electric: electricRole && { id: electricRole.id, url: electricRole.connectionUrl }, } if (profile.database !== "declared") return { ...resources, hyperdrives: undefined } satisfies MapleDbResources // Distinct ids on purpose: alchemy keys state by id alone, across resource types. const hyperdrive = (consumer: MapleDbConsumer) => Effect.gen(function* () { const role = yield* Planetscale.PostgresRole(`db-${consumer}-role`, { database, branch: schema, inheritedRoles: [...DB_ACCESS_ROLES.readWrite], }) return yield* Cloudflare.Hyperdrive.Connection(`db-${consumer}`, { name: resolveWorkerName(`db-${consumer}`, stage, region), origin: role.origin, caching: { disabled: true }, // EU cluster max_connections=25, shared with Electric and the gateway. originConnectionLimit: 8, }) }) // alerting has its own config; the rest share api's (docs/infra.md). const api = yield* hyperdrive("api") const alerting = yield* hyperdrive("alerting") return { ...resources, hyperdrives: { ...sharedHyperdrive(api), alerting }, } satisfies MapleDbResources }) const declareMapleDb = (deployment: MapleDeployment, profile: MapleProfile) => !profile.migratesDatabase ? Effect.undefined : profile.database === "preview" ? declarePreviewDb(deployment, profile) : declarePrdDb(deployment, profile) /** What this deploy is, read by the Worker classes' props. */ const MapleStackLive = Layer.effect( MapleStack, Effect.gen(function* () { // The stage string (`prd`, `prd-eu`) names the instance; alchemy keys state by it. const stage = yield* Alchemy.Stage const deployment = yield* parseMapleDeploymentEffect(stage) // The `preview:web` label: Workers and seeded branches only, no AWS (docs/pr-previews.md). const webOnlyPreview = deployment.stage.kind === "pr" && (yield* optionalPlain("MAPLE_PREVIEW_WEB_ONLY")).MAPLE_PREVIEW_WEB_ONLY === "1" // The `preview:collector` label: one preview tests the collector (scripts/ingest-preview-verify.sh). const forceCollector = (yield* optionalPlain("MAPLE_DEPLOY_AWS_COLLECTOR")).MAPLE_DEPLOY_AWS_COLLECTOR === "1" const profile = resolveMapleProfile(deployment, { webOnlyPreview, forceCollector }) const domains = deployedDomains(resolveMapleDomains(deployment), profile) const context: MapleStackContext = { ...deployment, domains, profile, urls: { api: devEnv?.MAPLE_API_BASE_URL ?? (yield* resolveUrl(domains.api, "MAPLE_API_BASE_URL")), // Web's browser SDK posts here; without the dev branch, local telemetry // would go to production ingest. ingest: devEnv?.MAPLE_INGEST_URL ?? (yield* resolveUrl(domains.ingest, "VITE_INGEST_URL", prdOrigin("ingest"))), electricSync: devEnv?.MAPLE_ELECTRIC_SYNC_URL ?? (yield* resolveUrl(domains.sync, "MAPLE_ELECTRIC_SYNC_URL")), status: devEnv?.MAPLE_STATUS_PAGE_BASE_URL ?? (yield* resolveUrl(domains.status, "MAPLE_STATUS_PAGE_BASE_URL")), }, workerDev, devEnv, db: yield* declareMapleDb(deployment, profile), } return context }), ) const serveWorker = (app: DevApp, worker: Cloudflare.Worker) => devApps?.has(app) ? Effect.asVoid(Portless.Route(`${app}-route`, { name: app, port: Portless.workerPort(worker.url) })) : Effect.void /** A non-Worker app's own `dev` script under `Command.Dev`, which is a no-op on deploys. */ const createDevProcess = (app: DevApp, route: Portless.Route) => Command.Dev(`${app}-dev`, { command: "bun run --silent dev", cwd: path.join(import.meta.dirname, "apps", app), env: { PORT: Output.map(Output.asOutput(route.port), String), PORTLESS_URL: Portless.routeUrl(app), MAPLE_API_URL: Portless.routeUrl("api"), // The scraper's default is a fixed port; ingest's dev script listens on its route's port. MAPLE_INGEST_URL: Portless.routeUrl("ingest"), // The alchemy CLI sets NODE_ENV=production and children inherit it; vite and // astro read it for `import.meta.env.DEV`/`PROD`, so dev servers must override it. NODE_ENV: "development", }, }) /** Both clouds, unconditionally: stack options are evaluated before the stage is readable. */ const providers = // `Acm.providers` needs the AWS credentials, so it is the layer provided TO. Acm.providers.pipe( Layer.provideMerge(Cloudflare.providers()), Layer.provideMerge(AWS.providers()), // Its credential lookup runs when the layer is built, and `bun dev` never yields the branch. Layer.provideMerge(isDevServer ? Layer.empty : Planetscale.providers()), // Previews only; prd and dev deploys carry no NEON_API_KEY. Layer.provideMerge(!isDevServer && process.env.NEON_API_KEY ? Neon.providers() : Layer.empty), Layer.provideMerge(Portless.providers()), ) export default Alchemy.Stack( "maple", { // AWS_ACCOUNT_ID is REQUIRED in CI: without it alchemy's STS lookup deadlocks // silently (docs/infra.md). providers, // ALCHEMY_LOCAL_STATE=1 uses .alchemy/ file state instead of the account-wide store. state: process.env.ALCHEMY_LOCAL_STATE ? Alchemy.localState() : Cloudflare.state(), }, Effect.gen(function* () { const { stage, region, domains, profile, urls, db } = yield* MapleStack // A mismatched AWS_REGION would split the ACM cert from its ALB and cross the // EU residency boundary. const { AWS_REGION } = yield* optionalPlain("AWS_REGION") const expectedAwsRegion = resolveAwsRegion(region) if (AWS_REGION && AWS_REGION !== expectedAwsRegion) { return yield* new AwsRegionMismatchError({ message: `AWS_REGION="${AWS_REGION}" does not match the "${region}" instance (expects "${expectedAwsRegion}").`, awsRegion: AWS_REGION, mapleRegion: region, expectedAwsRegion, }) } // Every stage that deploys a fleet has its hostname (`resolveMapleDomains`). const ingest = profile.deploys.ingest && domains.ingest ? yield* createMapleIngest({ stage, region, hostname: domains.ingest, profile, dbLogin: db?.ingest, }) : undefined // Account audit controls (CloudTrail, Config, GuardDuty): prd instances only. const { awsSecurityBaseline } = profile.deploys const securityBaseline = awsSecurityBaseline === "none" ? undefined : yield* createAwsSecurityBaseline({ stage, region, baseline: awsSecurityBaseline }) // Yielded here first so its `MAPLE_PG_URL` read happens outside any Worker init, // where alchemy would bind it as a secret. if (profile.database === "managed") yield* ManagedMapleDb // Yielded before ai, which binds it as `SANDBOX`. const sandbox = profile.deploys.sandbox ? yield* MapleSandbox : undefined // Yielded before api, which binds it. The Live layer registers the chat Durable // Object class in the bundle's exports. // oxlint-disable-next-line effecttsgo/strict-effect-provide const ai = yield* Effect.provide(MapleAi, MapleAiLive).pipe((withLive) => sandbox === undefined ? withLive : Effect.provideService(withLive, SandboxWorker, sandbox), ) yield* serveWorker("ai", ai) const api = yield* Effect.provideService(MapleApi, AiWorker, ai) yield* serveWorker("api", api) // prd's electric-sync reads `ELECTRIC_URL` from the secret store, so its cutover is // separate; a preview's points at this one. Electric runs in ingest's VPC, hence `ingest &&`. const electric = ingest && db?.electric && domains.electric ? yield* createMapleElectric({ stage, region, hostname: domains.electric, profile, network: ingest.network, dbLogin: db.electric, }) : undefined // The scraper runs in ingest's VPC and posts to its public hostname. if (ingest && profile.deploys.scraper && domains.api && domains.ingest) { yield* createMapleScraper({ stage, region, profile, network: ingest.network, apiUrl: `https://${domains.api}`, ingestUrl: `https://${domains.ingest}`, }) } const electricSync = yield* ElectricSync yield* serveWorker("electric-sync", electricSync) // Public status pages, served from edge storage alone so they outlive a Maple outage. const statusWorker = yield* StatusWorker yield* serveWorker("status", statusWorker) // A vite-source Worker: alchemy owns its build and dev server. Binds api as `API`. const web = yield* Effect.provideService(Web, ApiWorker, api) yield* serveWorker("web", web) // StaticSites run their production build even on `alchemy dev`, so dev uses // `DEV_PROCESS_APPS` instead. Shared across instances: deployed by `us` alone. const sharedApps = !isDevServer && profile.deploys.sharedApps const landing = sharedApps ? yield* Landing : undefined const localUi = sharedApps ? yield* LocalUi : undefined const alerting = yield* Effect.provideService(Alerting, AiWorker, ai) yield* serveWorker("alerting", alerting) // Chat-platform ingress. Its Live layer registers its Durable Object classes. // oxlint-disable-next-line effecttsgo/strict-effect-provide const chatBot = yield* Effect.provide(ChatBot, ChatBotLive) yield* serveWorker("chat-bot", chatBot) // Dev only. Non-Worker processes get their port from the route; Workers bind // theirs in `precreate`, so their route follows the Worker. for (const app of DEV_PROCESS_APPS) { if (!devApps?.has(app)) continue const route = yield* Portless.Route(`${app}-route`, { name: app }) yield* createDevProcess(app, route) } const summary = { stage: formatMapleDeployment({ stage, region }), region, apiUrl: urls.api, ingestUrl: urls.ingest, electricSyncUrl: urls.electricSync, webUrl: domains.web ? `https://${domains.web}` : "", landingUrl: domains.landing ? `https://${domains.landing}` : "", localUiUrl: domains.local ? `https://${domains.local}` : "", } // Plan-time strings only. yield* Effect.sync(() => appendStepOutputs([ `web_url=${summary.webUrl}`, `api_url=${summary.apiUrl}`, `sync_url=${summary.electricSyncUrl}`, `ingest_url=${ingest && domains.ingest ? `https://${domains.ingest}` : ""}`, ]), ) return { ...summary, // The ALB itself, behind `domains.ingest`. ingestServiceUrl: ingest?.serviceUrl, ingestCollectorEndpoint: ingest?.collectorEndpoint, electricServiceUrl: electric?.serviceUrl, auditTrailArn: securityBaseline?.trailArn, guardDutyDetectorId: securityBaseline?.guardDutyDetectorId, apiWorker: api.workerName, electricSyncWorker: electricSync.workerName, webWorker: web?.workerName, landingWorker: landing?.workerName, localUiWorker: localUi?.workerName, alertingWorker: alerting.workerName, chatBotWorker: chatBot.workerName, } }).pipe( // The stack IS the entry point: the one place `MapleStack` is provided. // oxlint-disable-next-line effecttsgo/strict-effect-provide Effect.provide(MapleStackLive), // `Alchemy.Stack` admits only `ConfigError`. Effect.catchTags({ "@maple/infra/MapleStageError": asConfigError, "@maple/infra/AwsRegionMismatchError": asConfigError, "@maple/infra/CloudflareRangesError": asConfigError, }), ), )