Ctrl K
ComunidadGuías…

alchemy.run.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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<string>): 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,
		}),
	),
)