diff --git a/.github/actions/deploy-setup/action.yml b/.github/actions/deploy-setup/action.yml index ca734b42c..92858cc46 100644 --- a/.github/actions/deploy-setup/action.yml +++ b/.github/actions/deploy-setup/action.yml @@ -2,12 +2,12 @@ name: deploy setup description: >- Everything an alchemy deploy job needs after checkout: the toolchain, Infisical secrets, AWS OIDC credentials, dependencies, and (optionally) - the prebuilt ingest binary. Shared by deploy-prd, deploy-stg, - deploy-pr-preview and aws-probe so the order is decided once. + the prebuilt ingest binary. Shared by deploy-prd, deploy-pr-preview and + aws-probe so the order is decided once. inputs: infisical-env-slug: - description: Infisical environment to export (prod, staging, dev). + description: Infisical environment to export (prod, dev). required: true infisical-identity-id: description: The Infisical machine identity (a repo secret; composites cannot read secrets). diff --git a/.github/workflows/aws-probe.yml b/.github/workflows/aws-probe.yml index 2e0b1fe0e..dd4fae4a4 100644 --- a/.github/workflows/aws-probe.yml +++ b/.github/workflows/aws-probe.yml @@ -58,9 +58,11 @@ jobs: # so a slow build shows up as a slow build rather than masquerading as # the hang being investigated. timeout-minutes: 20 - # The OIDC role's trust policy only accepts - # `repo:MapleTechLabs/maple:environment:{production,staging}` subs, so the - # job has to declare one of them to get credentials at all. + # The OIDC role's trust policy accepts the + # `repo:MapleTechLabs/maple:environment:production` sub (it was also + # written for a `staging` subject, a GitHub Environment that no longer + # exists), so the job has to declare `production` to get credentials at + # all. environment: production env: PROBE_LEVEL: ${{ inputs.level }} diff --git a/.github/workflows/build-ingest-binary.yml b/.github/workflows/build-ingest-binary.yml index 1826959c3..278d5b814 100644 --- a/.github/workflows/build-ingest-binary.yml +++ b/.github/workflows/build-ingest-binary.yml @@ -1,7 +1,7 @@ name: Build ingest binary # Compiles `apps/ingest` for the deploy image and hands it to the caller as an -# artifact. Called by deploy-prd / deploy-stg / deploy-pr-preview / aws-probe, +# artifact. Called by deploy-prd / deploy-pr-preview / aws-probe, # which all COPY the result via `apps/ingest/Dockerfile.prebuilt`. # # It is a separate job purely because of the RUNNER. The ECS tasks run on diff --git a/.github/workflows/deploy-pr-preview.yml b/.github/workflows/deploy-pr-preview.yml index 0ab1d2b14..e61ed8618 100644 --- a/.github/workflows/deploy-pr-preview.yml +++ b/.github/workflows/deploy-pr-preview.yml @@ -114,8 +114,8 @@ jobs: # ⚠️ The deploy role's trust policy must accept this job's OIDC # subject (`repo:/:environment:pr-preview`). It was - # written for the production and staging subjects only; previews get - # AccessDenied until it is widened. + # written for the production subject only; previews get AccessDenied + # until it is widened. - name: Deploy setup id: setup uses: ./.github/actions/deploy-setup diff --git a/.github/workflows/deploy-stg.yml b/.github/workflows/deploy-stg.yml deleted file mode 100644 index 270022ba1..000000000 --- a/.github/workflows/deploy-stg.yml +++ /dev/null @@ -1,103 +0,0 @@ -name: Deploy STG (Cloudflare via Alchemy) - -# Gated on CI rather than `push: main` — see the note in deploy-prd.yml. -on: - workflow_run: - workflows: ["CI"] - types: [completed] - branches: [main] - workflow_dispatch: - -concurrency: - group: deploy-stg - cancel-in-progress: false - -permissions: - contents: read - id-token: write # Infisical OIDC machine-identity auth - -jobs: - # Separate job because it needs an arm64 runner — see the workflow itself. - ingest-binary: - uses: ./.github/workflows/build-ingest-binary.yml - - deploy-stg: - needs: ingest-binary - runs-on: ubuntu-latest - timeout-minutes: 45 - if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} - environment: staging - env: - INFISICAL_ENV_SLUG: staging - # Stamped onto deployed telemetry as `vcs.ref.head.revision` (server SDK - # reads COMMIT_SHA; web build reads VITE_COMMIT_SHA via Vite define). - COMMIT_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} - VITE_COMMIT_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 - with: - ref: ${{ github.event.workflow_run.head_sha || github.ref }} - - # Toolchain, Infisical secrets, AWS OIDC (after Infisical, so its - # credentials win), dependencies, and the ingest binary the - # `ingest-binary` job compiled — one composite, one order. - - name: Deploy setup - id: setup - uses: ./.github/actions/deploy-setup - with: - infisical-env-slug: ${{ env.INFISICAL_ENV_SLUG }} - infisical-identity-id: ${{ secrets.INFISICAL_MACHINE_IDENTITY_ID }} - infisical-project-slug: ${{ vars.INFISICAL_PROJECT_SLUG }} - aws-role-arn: ${{ vars.AWS_DEPLOY_ROLE_ARN }} - - # BEFORE migrate: installs default privileges granting PUBLIC on every - # table created from here on, so a new or rebuilt table can never land - # owner-only and leave a consumer with "permission denied for table …". - # Default privileges only apply to objects created after they are set, - # hence the ordering. See packages/db/scripts/ensure-privileges.ts. - - name: Ensure database privileges - run: DATABASE_URL="$MAPLE_PG_URL" bun run --cwd packages/db db:ensure-privileges - - # Schema migrations run against the PlanetScale branch over the - # DIRECT port (5432, never the PSBouncer/Hyperdrive poolers — DDL - # through a transaction pooler misbehaves). MAPLE_PG_URL is the - # single Infisical secret (direct 5432, admin role). - - name: Run database migrations - run: DATABASE_URL="$MAPLE_PG_URL" bun run --cwd packages/db db:migrate - - # AWS_ACCOUNT_ID: see the note in deploy-prd.yml. - # - # One pass, no retry. A stage's first deploy used to fail here — its - # ACM certificates were created PENDING_VALIDATION and their 443 - # listeners refused them — and was recovered by a second step that - # published the validation CNAMEs with `scripts/acm-cert-validate.sh` - # and deployed again. The stack now publishes those records itself and - # waits for ISSUED (`@maple/infra/acm`), so a first deploy completes - # like any other and a failure here is a real failure. - - name: Deploy STG stack with Alchemy - id: deploy - run: bun run alchemy:deploy:stg - env: - AWS_ACCOUNT_ID: ${{ steps.setup.outputs.aws-account-id }} - - # A skipped job does not fail its run, so with CI red this workflow reported - # SUCCESS while nothing deployed — which is how a broken `main` looked like a - # string of clean deploys for several commits, and why an Electric deploy - # nobody had noticed was never running took an hour to spot. This job always - # runs, so the run's conclusion says what actually happened. - deployment-gate: - needs: deploy-stg - if: ${{ always() }} - runs-on: ubuntu-latest - steps: - - name: Report whether the deploy ran - run: | - result="${{ needs.deploy-stg.result }}" - if [ "$result" = "skipped" ]; then - echo "::error::Nothing was deployed: CI did not pass for ${{ github.event.workflow_run.head_sha || github.sha }}. Fix main, then re-run." - exit 1 - fi - # A genuine deploy failure already fails its own job and reddens - # the run; there is nothing to add here. - echo "deploy-stg: $result" diff --git a/.github/workflows/tinybird-cd.yml b/.github/workflows/tinybird-cd.yml index 573452e11..7c021bfdf 100644 --- a/.github/workflows/tinybird-cd.yml +++ b/.github/workflows/tinybird-cd.yml @@ -30,16 +30,12 @@ jobs: # One leg per long-lived Tinybird workspace. Each GitHub Environment supplies # its own TINYBIRD_HOST/TINYBIRD_TOKEN, so the matching workspace admin token # scopes each deploy to that workspace — no `--branch`/`__tb__` params needed. - # Staging is a separate workspace (Tinybird's documented long-lived pattern; - # branches are ephemeral and share prod compute, so they're unsuitable here). strategy: fail-fast: false matrix: target: - label: production environment: tinybird-cd - - label: staging - environment: tinybird-cd-stg # The us-east-1 workspace being migrated to. Both production # legs must receive every schema deploy for the whole # dual-emit window — a month of drift between the two @@ -61,7 +57,7 @@ jobs: ALLOW_DESTRUCTIVE: ${{ github.event.inputs.allow_destructive }} run: | set -euo pipefail - # Graceful no-op until an environment is provisioned: lets the staging + # Graceful no-op until an environment is provisioned: lets a new # leg stay green before its workspace/secrets exist, without ever # affecting the production leg (whose token is always set). if [ -z "${TINYBIRD_TOKEN:-}" ]; then diff --git a/CLAUDE.md b/CLAUDE.md index 9eae28695..efa269753 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -148,8 +148,8 @@ isolated database and exercises run/compare/inspect against real Maple builders. ## Application database (PlanetScale Postgres) Relational state (issues, alert rules, dashboards, org config, keys) is Drizzle/`pgTable` in -`packages/db/src/schema/`, one PS branch per deployed stage (`main`=prd, `stg`), reached from -Workers via the Hyperdrive binding `MAPLE_DB`. +`packages/db/src/schema/`, on the PlanetScale `main` branch (prd — the only stage with a +database), reached from Workers via the Hyperdrive binding `MAPLE_DB`. - App code keeps epoch-ms numbers and converts at the drizzle boundary — use `msToDate` / `dateToMs` from `packages/backend/src/platform/time.ts` rather than bare `new Date(ms)` / @@ -269,12 +269,12 @@ refuses to run the command at all rather than running it with egress. Every bug that the unit tests could not see (a `mktemp -d` mode, a git flag this image predates, `runuser` adding `USER` and `LOGNAME` after `env -i`) was found by running the image. -End to end needs a real deployment: `stageDeploysSandbox` is `prd`/`stg` only, so `bun dev` binds no +End to end needs a real deployment: `stageDeploysSandbox` is `prd` only, so `bun dev` binds no `SANDBOX` and the four tools report that no sandbox is available. The container is **not** in `apps/api` — Cloudflare's Sandbox is a Durable Object class the script must export, and an Effect-native Worker's generated entry exports only its own bridge classes. It -lives in `apps/sandbox`, on `prd`/`stg` only; see `docs/infra.md` § Single-module Workers. +lives in `apps/sandbox`, on `prd` only; see `docs/infra.md` § Single-module Workers. ## Self-observability (trace loop prevention) diff --git a/README.md b/README.md index 483a9bd24..4c21e81bd 100644 --- a/README.md +++ b/README.md @@ -104,9 +104,9 @@ single `Alchemy.Stack("maple", …)` whose program yields one module per app: - `apps/web/src/worker.ts` / `apps/landing/src/worker.ts` / `apps/local-ui/src/worker.ts` — static builds via `Command.Build` + asset-serving Workers -Stage grammar is `prd` / `stg` / `pr-` / dev names, resolved via +Stage grammar is `prd` / `pr-` / dev names, resolved via `@maple/infra/cloudflare` (`parseMapleStage`, `resolveMapleDomains`, `resolveWorkerName`, -`resolveHyperdriveRefId`, `resolveDatabaseMode`). stg/prd bind the +`resolveHyperdriveRefId`, `resolveDatabaseMode`). prd binds the dashboard-managed Hyperdrive by config ID (`resolveHyperdriveRefId`) — origin credentials never touch a deploy. `MAPLE_PG_URL` is only needed for dev stages, whose Hyperdrive alchemy manages itself. PR previews bind **no database at all** (`resolveDatabaseMode` → `"none"`): @@ -115,7 +115,6 @@ DB-backed routes 500, everything else in the preview works. Run locally: ```bash -bun run alchemy:deploy:stg PR_NUMBER=123 bun run alchemy:deploy:pr ``` @@ -126,13 +125,11 @@ never run a v1 `alchemy destroy` against a live stage. Tear down: ```bash -bun run alchemy:destroy:stg PR_NUMBER=123 bun run alchemy:destroy:pr ``` CI workflows: -- STG (default on push to `main`): `.github/workflows/deploy-stg.yml` - PRD (manual only via `workflow_dispatch`): `.github/workflows/deploy-prd.yml` - PR preview lifecycle: `.github/workflows/deploy-pr-preview.yml` (`pull_request` opened/synchronize/reopened/closed) @@ -146,8 +143,8 @@ Secrets source model (CI): slug like `maple` would then blank out the PR-preview deployment URL `app-pr-.maple.dev`) - 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: +- Infisical environments (`prod`, `dev` — mapped from the old Doppler + `prd`/`pr` configs) must define: - `CLOUDFLARE_API_TOKEN` - `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` @@ -161,11 +158,11 @@ Secrets source model (CI): - `CLERK_PUBLISHABLE_KEY` - `CLERK_JWT_KEY` -Setup note: the machine identity must have a **GitHub OIDC** auth method configured in Infisical (scoped to this repo, ideally to the `production`/`staging`/`pr-preview` GitHub environments) and read access to the project. The workflows select secrets via `project-slug` (`INFISICAL_PROJECT_SLUG`) and per-stage `env-slug` (`prod`/`staging`/`dev`). +Setup note: the machine identity must have a **GitHub OIDC** auth method configured in Infisical (scoped to this repo, ideally to the `production`/`pr-preview` GitHub environments) and read access to the project. The workflows select secrets via `project-slug` (`INFISICAL_PROJECT_SLUG`) and per-stage `env-slug` (`prod`/`dev`). Runtime API URL behavior: -- Deploy-time web builds resolve `VITE_API_BASE_URL` from the Cloudflare api worker domain (`api.maple.dev` in `prd`, `api-staging.maple.dev` in `stg`, worker.dev URL for `pr-*`). +- Deploy-time web builds resolve `VITE_API_BASE_URL` from the Cloudflare api worker domain (`api.maple.dev` in `prd`, worker.dev URL for `pr-*`). - Local `bun --filter=@maple/web dev` can still use root `.env` `VITE_API_BASE_URL` for local API routing. ## Environment @@ -193,7 +190,7 @@ For ingest + key auth, set these at minimum in your root `.env` when running the Maple persists application state in PostgreSQL: -- Production and staging use PlanetScale Postgres through Cloudflare Hyperdrive. +- Production uses PlanetScale Postgres through Cloudflare Hyperdrive. - Wrangler development uses the Docker Postgres started by `bun db:up`. - Non-Worker local entrypoints use embedded PGlite under `apps/api/.data/pglite`; set `MAPLE_DB_URL=memory://` for an ephemeral database. diff --git a/alchemy.run.ts b/alchemy.run.ts index 9d5ae792a..30f222bdc 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -199,8 +199,8 @@ export default Alchemy.Stack( ) } - // The Rust OTLP gateway on ECS Fargate (prd/stg/pr — dev stages run it - // through docker-compose instead). On prd/stg `domains.ingest` reaches it + // The Rust OTLP gateway on ECS Fargate (prd/pr — dev stages run it + // through docker-compose instead). On prd `domains.ingest` reaches it // via a Cloudflare CNAME at the ALB, so the URL below stays a plain string // and does not depend on the service resource; a PR preview gets no ingest // domain, so its ALB answers plain HTTP on 80 at `ingest.serviceUrl`. @@ -210,7 +210,7 @@ export default Alchemy.Stack( // The application database. Each Worker binds `MAPLE_DB` from its own init // (`MapleDb` in `@maple/infra/cloudflare`: the managed Hyperdrive on dev - // stages, a dashboard-managed config by id on stg/prd, nothing on previews). + // stages, a dashboard-managed config by id on prd, nothing on previews). // The managed declaration is yielded here first so its `MAPLE_PG_URL` read // happens outside any Worker init, where alchemy would bind it as a secret. if (resolveDatabaseMode(stage) === "managed") yield* ManagedMapleDb @@ -234,7 +234,7 @@ export default Alchemy.Stack( ) yield* serveWorker("api", api) - // Self-hosted ElectricSQL on ECS Fargate (prd/stg — dev stages use the + // Self-hosted ElectricSQL on ECS Fargate (prd — dev stages use the // docker `electric` service, and PR previews have no database to replicate // from). Deliberately NOT wired into the sync worker's env here: the worker // reads `ELECTRIC_URL` from the secret store, so standing this service up diff --git a/apps/alerting/src/worker.ts b/apps/alerting/src/worker.ts index 54fd10e92..e15e48192 100644 --- a/apps/alerting/src/worker.ts +++ b/apps/alerting/src/worker.ts @@ -145,10 +145,10 @@ const props = Effect.gen(function* () { const ALERTING_CRONS = ["* * * * *", "*/5 * * * *", "*/15 * * * *", "0 * * * *"] as const /** - * Non-prod stages (stg, PR previews) share live org data — stg's Hyperdrive - * points at the prod database — so their crons would iterate real orgs with - * stage-local Tinybird/Clerk credentials: every tick fails per-org and floods - * the error dashboards (and historically sent duplicate emails, see #237). + * Non-prod stages (PR previews, dev) share live org data, so their crons would + * iterate real orgs with stage-local Tinybird/Clerk credentials: every tick + * fails per-org and floods the error dashboards (and historically sent + * duplicate emails, see #237). * Same gating philosophy as the prd-only EMAIL binding, with an explicit * override for deliberately exercising crons on a non-prod stage. */ @@ -166,7 +166,7 @@ export default class Alerting extends Cloudflare.Worker()( // validation or in the deploy process. A rejected import is retried on // the next fire rather than pinned (`Effect.cached` keeps the failure). const scheduled = yield* cachedRecoverable(Effect.promise(() => import("./scheduled"))) - // `MAPLE_DB` in the stage's flavor — on stg/prd its own dashboard-managed + // `MAPLE_DB` in the stage's flavor — on prd its own dashboard-managed // config: `alerting` issues ~97% of the workers' Postgres traffic and was // starving the api's connection pool when the two shared one. The ticks // read it off the fire's env. diff --git a/apps/api/scripts/BENCH.md b/apps/api/scripts/BENCH.md index b63bb4352..a51e4737d 100644 --- a/apps/api/scripts/BENCH.md +++ b/apps/api/scripts/BENCH.md @@ -75,8 +75,8 @@ only the raw ClickHouse HTTP interface exposes. ``` Skim the table to confirm the queries look real — they should start with - `SELECT`, contain `OrgId =`, and the p95 column should match the staging - trace dashboard. + `SELECT`, contain `OrgId =`, and the p95 column should match the trace + dashboard. 2. **Measure baseline.** Replay them locally, 5 runs each: @@ -102,9 +102,10 @@ only the raw ClickHouse HTTP interface exposes. — add a `PREWHERE`, narrow the projection, switch from `Traces` to an already-aggregated MV, whatever the EXPLAIN suggested. -5. **Re-run.** Either deploy the change to staging and run `bench:fetch` - again (so the new fingerprint shows up in traces), or hand-edit the SQL in - a copy of the baseline JSON for a faster local loop. Then: +5. **Re-run.** Either deploy the change to **production** and run `bench:fetch` + again (so the new fingerprint shows up in the traces this tool mines — a PR + or dev stage's traces are not queried), or hand-edit the SQL in a copy of the + baseline JSON for a faster local loop. Then: ``` bun bench:run .bench/errorsByType-after-prewhere.json --runs 5 \ diff --git a/apps/api/src/resources/replay-blobs.ts b/apps/api/src/resources/replay-blobs.ts index 4cce66459..2a6cbf390 100644 --- a/apps/api/src/resources/replay-blobs.ts +++ b/apps/api/src/resources/replay-blobs.ts @@ -4,7 +4,7 @@ * bucket-scoped token is minted in `apps/ingest/alchemy.run.ts`, next to the * writer — and the api Worker binds the same bucket to hydrate * `session_replay_events` rows whose `Events` is empty. Stage-isolated, so a - * pr/stg deploy can never serve or overwrite prd recordings. Declared once + * pr deploy can never serve or overwrite prd recordings. Declared once * here; alchemy registers a resource by id, so the second yield returns the * first's registration. * diff --git a/apps/api/src/routes/v2/integrations-origin.test.ts b/apps/api/src/routes/v2/integrations-origin.test.ts index c7258cd17..6a2654534 100644 --- a/apps/api/src/routes/v2/integrations-origin.test.ts +++ b/apps/api/src/routes/v2/integrations-origin.test.ts @@ -62,8 +62,8 @@ describe("resolveRequestOrigin (untrusted — reflects client-controlled headers }) it("preserves an explicit port on a non-local host", () => { - const origin = resolveRequestOrigin(fakeRequest({ host: "api.staging.maple.dev:8443" })) - assert.strictEqual(origin, "https://api.staging.maple.dev:8443") + const origin = resolveRequestOrigin(fakeRequest({ host: "api.maple.dev:8443" })) + assert.strictEqual(origin, "https://api.maple.dev:8443") }) it("falls back to parsing an absolute request url when no host headers exist", () => { @@ -91,7 +91,6 @@ describe("isTrustedCallbackOrigin", () => { it("accepts sibling hosts under the same registrable domain, per stage", () => { for (const [origin, appBaseUrl] of [ ["https://api.maple.dev", "https://app.maple.dev"], - ["https://api-staging.maple.dev", "https://staging.maple.dev"], ["https://api-pr-12.maple.dev", "https://app-pr-12.maple.dev"], ] as const) { assert.isTrue(isTrustedCallbackOrigin(origin, appBaseUrl), `${origin} vs ${appBaseUrl}`) diff --git a/apps/api/src/routes/v2/integrations.http.ts b/apps/api/src/routes/v2/integrations.http.ts index 53e9f649a..8beefdb32 100644 --- a/apps/api/src/routes/v2/integrations.http.ts +++ b/apps/api/src/routes/v2/integrations.http.ts @@ -94,8 +94,8 @@ const parentDomain = (hostname: string): string => { * as the trusted `MAPLE_APP_BASE_URL`: * * - every deployed stage puts the web app and the API on sibling hosts under - * one registrable domain (`app.maple.dev` / `api.maple.dev`, `staging` / - * `api-staging`, `app-pr-` / `api-pr-`), and + * one registrable domain (`app.maple.dev` / `api.maple.dev`, + * `app-pr-` / `api-pr-`), and * - local dev puts them on sibling `*.localhost` hosts (portless proxy) or on * loopback ports. * diff --git a/apps/cli/src/commands/server.ts b/apps/cli/src/commands/server.ts index 6f93367a2..0cf601280 100644 --- a/apps/cli/src/commands/server.ts +++ b/apps/cli/src/commands/server.ts @@ -71,8 +71,8 @@ const prettyPath = (p: string): string => { return p.startsWith(home) ? `~${p.slice(home.length)}` : p } -/** Public origin of the deployed local-mode dashboard SPA. Overridable for - * testing against staging (`local-staging.maple.dev`). */ +/** Public origin of the deployed local-mode dashboard SPA. Overridable + * (`MAPLE_LOCAL_UI_URL`) for testing against another build of it. */ const DEFAULT_REMOTE_UI_URL = "https://local.maple.dev" const remoteUiUrl = (): Effect.Effect => { diff --git a/apps/cli/test/server-args.test.ts b/apps/cli/test/server-args.test.ts index bb819cd60..7fcc6c24c 100644 --- a/apps/cli/test/server-args.test.ts +++ b/apps/cli/test/server-args.test.ts @@ -45,8 +45,8 @@ describe("local server bind host", () => { it("marks custom hosted dashboards as loopback clients without discarding their URL", () => { strictEqual( - hostedDashboardUrl("https://local-staging.maple.dev/preview?channel=next", 4418), - "https://local-staging.maple.dev/preview?channel=next&port=4418&maple-local-api=loopback", + hostedDashboardUrl("https://local-preview.maple.dev/preview?channel=next", 4418), + "https://local-preview.maple.dev/preview?channel=next&port=4418&maple-local-api=loopback", ) }) diff --git a/apps/local-ui/src/lib/constants.test.ts b/apps/local-ui/src/lib/constants.test.ts index cf150b2a5..e842a3f76 100644 --- a/apps/local-ui/src/lib/constants.test.ts +++ b/apps/local-ui/src/lib/constants.test.ts @@ -12,7 +12,7 @@ describe("local UI endpoint selection", () => { it("uses loopback for a custom hosted UI carrying the startup marker", () => { const page = location( - "https://local-staging.maple.dev/preview?channel=next&port=4418&maple-local-api=loopback", + "https://local-preview.maple.dev/preview?channel=next&port=4418&maple-local-api=loopback", ) expect(localApiBaseForLocation(page)).toBe("http://127.0.0.1:4418") expect(localOtlpEndpointForLocation(page)).toBe("http://127.0.0.1:4418") diff --git a/apps/sandbox/alchemy.run.ts b/apps/sandbox/alchemy.run.ts index 352301f6d..233e2a635 100644 --- a/apps/sandbox/alchemy.run.ts +++ b/apps/sandbox/alchemy.run.ts @@ -50,8 +50,7 @@ const props = Effect.gen(function* () { // Sized for the work, not for the stage. The checkout is a full clone, // so the smaller tiers are not a cheaper version of this container — // `lite`/`dev` is 1/16 vCPU with 256 MiB and 2 GB of disk, which any - // real repository exhausts. Staging runs the same shape as production - // because a sandbox that only fails there tells us nothing. + // real repository exhausts. // The tier carries its own disk (`standard-2` 1 vCPU/6 GiB/12 GB, // `standard-1` 1/2 vCPU/4 GiB/8 GB) and Cloudflare rejects a request that // also sets vcpu/memory/disk, so the named tier is the only dial we have. diff --git a/apps/web/src/lib/services/common/clerk-cookie-guard.ts b/apps/web/src/lib/services/common/clerk-cookie-guard.ts index 942bfce5e..56da2a5d9 100644 --- a/apps/web/src/lib/services/common/clerk-cookie-guard.ts +++ b/apps/web/src/lib/services/common/clerk-cookie-guard.ts @@ -2,10 +2,10 @@ * Guard against cross-environment Clerk cookie collisions. * * Every deployed Maple environment lives on a subdomain of the same - * registrable domain (`app.maple.dev`, `staging.maple.dev`, - * `app-pr-.maple.dev`, …), but they do NOT share one Clerk instance: - * production runs the production Clerk instance (FAPI at `clerk.maple.dev`) - * while PR previews and staging run the development instance. ClerkJS writes + * registrable domain (`app.maple.dev`, `app-pr-.maple.dev`, …), but they do + * NOT share one Clerk instance: production runs the production Clerk instance + * (FAPI at `clerk.maple.dev`) while PR previews run the development + * instance. ClerkJS writes * its `__client_uat` cookies (both the shared legacy name and the * per-instance `__client_uat_` variant) on the eTLD+1 — * `Domain=maple.dev` — so each instance sees, and fights over, the other @@ -15,7 +15,7 @@ * documents this limitation: independent environments must not share the * production application's domain, even on separate subdomains. * - * Until preview/staging move to their own registrable domain, this guard + * Until previews move to their own registrable domain, this guard * runs before ClerkJS initializes and deletes any parent-domain * `__client_uat*` cookie that does not belong to the current deployment's * Clerk instance (plus the un-suffixed legacy cookie, which the instances diff --git a/docs/electric-sync.md b/docs/electric-sync.md index 92e4cbbf8..719b9be6c 100644 --- a/docs/electric-sync.md +++ b/docs/electric-sync.md @@ -144,7 +144,7 @@ a build-time constant, so a Vite restart is needed after changing it. ## Production (PlanetScale + self-hosted Electric on ECS) Electric Cloud is gone. `apps/electric` runs the upstream `electricsql/electric` -image on ECS Fargate at `electric.maple.dev` / `electric-staging.maple.dev`, with +image on ECS Fargate at `electric.maple.dev`, with its own cluster, ALB, security groups and certificate **inside the ingest fleet's VPC**. The shared VPC is forced, not an economy: two `AWS.EC2.Network`s in one alchemy stack fight over the internet gateway — under `--adopt` the second one's diff --git a/docs/infra.md b/docs/infra.md index e8a677456..da8101f62 100644 --- a/docs/infra.md +++ b/docs/infra.md @@ -243,7 +243,7 @@ impl)` over the plain `ChatSession` class — the outer Effect resolves state an request carries `SANDBOX_INTERNAL_SERVICE_TOKEN` — deliberately not the shared `INTERNAL_SERVICE_TOKEN`, which lets its holder act as any organization. - Only `prd` and `stg` get one (`stageDeploysSandbox`). A PR preview has no application + Only `prd` gets one (`stageDeploysSandbox`). A PR preview has no application database, so no repository resolves there; and on a dev stage `alchemy dev` would put a multi-gigabyte `docker pull` between every developer and `bun dev`. @@ -275,7 +275,7 @@ impl)` over the plain `ChatSession` class — the outer Effect resolves state an landing's negotiation is a plain function in `src/handler.ts` for the same test reason. - **The application database** (`alerting`, `api`): `yield* MapleDb(consumer)` in the init binds `MAPLE_DB` in the stage's flavor — `Hyperdrive.Connect(ManagedMapleDb)` on dev - stages, `host.bind` of the dashboard-managed config by id on stg/prd (alchemy has no `env` + stages, `host.bind` of the dashboard-managed config by id on prd (alchemy has no `env` form for a Hyperdrive it did not create; its own `ConnectBinding` attaches the same raw metadata), nothing on previews. The api's Workflows yield it too, from their outer phase. The root yields `ManagedMapleDb` first on dev stages so its `MAPLE_PG_URL` read happens @@ -378,7 +378,7 @@ ingest resources, so an unset variable produced a byte-identical pure-Cloudflare left to protect: `AWS.providers()` is registered unconditionally (it cannot be stage-derived — the `Alchemy.Stack` options are evaluated before `Alchemy.Stage` is readable inside the stack effect), and `stageDeploysIngest` alone decides which stages get -a fleet. It covers prd, stg **and PR previews**; dev stages run the gateway through +a fleet. It covers prd **and PR previews**; dev stages run the gateway through docker-compose. The spend gate moved to where the spend is: a preview only exists while its PR carries the `preview` label. @@ -416,13 +416,15 @@ per-config `origin_connection_limit`s sum against the branch's `max_connections` Hyperdrive will not coordinate between them, so over-provisioning one starves the other at the database rather than at the pool. -**Open item — staging points at production.** `resolveHyperdriveRefId` returns the prd -config for `stg` (owner decision, 2026-07-14). stg workers therefore read and write the -production database, and the stg alerting crons overlap prod's. `MAPLE_ALERTING_ALLOW_NONPROD` -exists to keep those crons off for exactly this reason. Fixing it means a PlanetScale `stg` -branch plus dedicated `maple-stg` / `maple-alerting-stg` dashboard configs, split per -consumer the same way prd is — and then a deliberate decision about whether stg crons -should run. +**Resolved by deletion — staging pointed at production.** `resolveHyperdriveRefId` used to +return the prd config for `stg` (owner decision, 2026-07-14), so stg workers read and wrote +the production database and the stg alerting crons overlapped prod's. The stage was removed +in full (2026-09): its deploy workflow had been disabled with no run history and neither +`api-staging.maple.dev` nor `ingest-staging.maple.dev` resolved, so the hazard was the only +thing it still cost. `prd` is now the only stage `resolveHyperdriveRefId` answers for, and +`parseMapleStage` rejects `stg` outright rather than letting it fall through to a dev stage. +A future staging stage needs its own PlanetScale branch and its own dashboard configs, split +per consumer the way prd is, before it gets a `MAPLE_DB` binding at all. ## The cold-start regression (`strictExecutionOrder: false`) diff --git a/docs/local-mode.md b/docs/local-mode.md index bf3515e98..2417ad403 100644 --- a/docs/local-mode.md +++ b/docs/local-mode.md @@ -343,9 +343,9 @@ Because the remote UI auto-updates independently of the binary, keep the ([apps/cli/src/server/schema/local-schema.sql](../apps/cli/src/server/schema/local-schema.sql)) backward compatible — a newer UI may run against an older binary. -`MAPLE_LOCAL_UI_URL` overrides the default UI origin (e.g. point a binary at -`https://local-staging.maple.dev` for testing). The startup link marks that -custom origin as a hosted loopback client. +`MAPLE_LOCAL_UI_URL` overrides the default UI origin (e.g. point a binary at a +locally served build for testing). The startup link marks that custom origin as +a hosted loopback client. `MAPLE_LOCAL_BIND_HOST` sets the `maple start` listening address and defaults to `127.0.0.1`; the `--host` flag overrides it for one invocation. diff --git a/docs/persistence.md b/docs/persistence.md index e36b29c7e..28085584b 100644 --- a/docs/persistence.md +++ b/docs/persistence.md @@ -5,7 +5,7 @@ Maple stores relational application state in PostgreSQL with a schema defined by ## Runtime modes -- **Production and staging:** one PlanetScale Postgres branch per stage. Cloudflare Workers +- **Production:** the PlanetScale Postgres `main` branch. Cloudflare Workers connect through the `MAPLE_DB` Hyperdrive binding; the application never opens the direct administrative connection. - **Wrangler development:** Docker Postgres on port 5499 through Hyperdrive's diff --git a/package.json b/package.json index 7da6bdb0e..c9df025cd 100644 --- a/package.json +++ b/package.json @@ -31,10 +31,8 @@ "alchemy:build-deps": "turbo build --filter=@maple-dev/effect-sdk --filter=@maple-dev/alchemy --filter=@maple-dev/browser", "alchemy:deploy": "bun run alchemy:build-deps && alchemy deploy --yes", "alchemy:deploy:prd": "bun run alchemy:build-deps && alchemy deploy --yes --adopt --stage prd", - "alchemy:deploy:stg": "bun run alchemy:build-deps && alchemy deploy --yes --adopt --stage stg", "alchemy:deploy:pr": "bun run alchemy:build-deps && alchemy deploy --yes --adopt --stage pr-${PR_NUMBER}", "alchemy:destroy": "alchemy destroy --yes", - "alchemy:destroy:stg": "alchemy destroy --yes --stage stg", "alchemy:destroy:pr": "alchemy destroy --yes --stage pr-${PR_NUMBER}", "test": "turbo test", "tinybird:manifest": "bun run ./scripts/generate-tinybird-project-manifest.ts", diff --git a/packages/alchemy-maple/test/telemetry.test.ts b/packages/alchemy-maple/test/telemetry.test.ts index 2a914c008..516f9aa5f 100644 --- a/packages/alchemy-maple/test/telemetry.test.ts +++ b/packages/alchemy-maple/test/telemetry.test.ts @@ -37,7 +37,7 @@ describe("Maple.Telemetry", () => { serviceName: "api", ingestKey: Redacted.make("maple_sk_test"), endpoint: "https://ingest.test", - environment: "staging", + environment: "production", }), ).pipe(Effect.provide(Layer.succeed(RuntimeContext, context))) assert.deepStrictEqual([...bound.keys()].sort(), [ diff --git a/packages/backend/src/platform/EmailService.ts b/packages/backend/src/platform/EmailService.ts index d2513b267..837508568 100644 --- a/packages/backend/src/platform/EmailService.ts +++ b/packages/backend/src/platform/EmailService.ts @@ -45,7 +45,7 @@ export class EmailService extends Context.Service const workerEnv = yield* WorkerEnvironment const binding = (workerEnv as Record).EMAIL - // Real sends are production-only: preview/stg stages share real user data + // Real sends are production-only: non-prod stages share real user data // (branched DBs, Clerk members), so a live binding there would deliver // duplicate copies of every cron-driven email. The alchemy configs no // longer attach EMAIL outside prd; this guard covers any binding that diff --git a/packages/backend/src/platform/Env.ts b/packages/backend/src/platform/Env.ts index b540b4901..6600848e0 100644 --- a/packages/backend/src/platform/Env.ts +++ b/packages/backend/src/platform/Env.ts @@ -48,9 +48,9 @@ export interface EnvConfig { * `Host`/`X-Forwarded-*` headers, which a client controls. */ readonly MAPLE_API_BASE_URL: string - /** Deployment environment (`production`, `staging`, `pr-`, `development`) — set by alchemy from the stage. */ + /** Deployment environment (`production`, `pr-`, `development`) — set by alchemy from the stage. */ readonly MAPLE_ENVIRONMENT: string - /** Escape hatch: allow real email sends outside production (e.g. a dedicated stg test run). */ + /** Escape hatch: allow real email sends outside production (e.g. a deliberate test run on a dev stage). */ readonly MAPLE_EMAIL_ALLOW_NONPROD: string /** Route every org to the managed warehouse; honoured only in development. */ readonly MAPLE_IGNORE_ORG_CLICKHOUSE: string diff --git a/packages/db/scripts/ensure-privileges.ts b/packages/db/scripts/ensure-privileges.ts index d7f272b73..33bacc22b 100644 --- a/packages/db/scripts/ensure-privileges.ts +++ b/packages/db/scripts/ensure-privileges.ts @@ -70,7 +70,7 @@ const quoteIdent = (role: string): string => { * and `drizzle-kit migrate` runs later as a separate process whose connections * authenticate as the login role. Unless that login carries a persisted * `role=postgres` (the brokered prod path's `ALTER ROLE … SET role`, see - * planetscale-connection.ts — the standalone stg path has no such guarantee), + * planetscale-connection.ts — a standalone run has no such guarantee), * its objects are created by the login role and postgres-keyed defaults never * fire — recreating exactly the owner-only-table outage this script prevents. * diff --git a/packages/db/scripts/normalize-preview-ownership.ts b/packages/db/scripts/normalize-preview-ownership.ts index b37fdf974..491172cf3 100644 --- a/packages/db/scripts/normalize-preview-ownership.ts +++ b/packages/db/scripts/normalize-preview-ownership.ts @@ -45,7 +45,7 @@ const main = async (): Promise => { process.exit(1) } // Tripwire (same as reset-preview-branch.ts): reassigning ownership is far - // less destructive than the reset, but run against prod/stg it would still + // less destructive than the reset, but run against prod it would still // silently rewrite object ownership. Only ever meant for ephemeral // PR-preview branches, driven by CI. if (!process.env.CI && process.env.RESET_PREVIEW_CONFIRM !== "1") { diff --git a/packages/db/scripts/planetscale-apply-schema.ts b/packages/db/scripts/planetscale-apply-schema.ts index f8f19129d..32d670e47 100644 --- a/packages/db/scripts/planetscale-apply-schema.ts +++ b/packages/db/scripts/planetscale-apply-schema.ts @@ -7,7 +7,6 @@ * * # examples * bun packages/db/scripts/planetscale-apply-schema.ts main # prd - * bun packages/db/scripts/planetscale-apply-schema.ts stg * bun packages/db/scripts/planetscale-apply-schema.ts pr-123 * * Mints an ephemeral credential for the branch (direct port 5432 — DDL must NOT diff --git a/packages/db/scripts/reset-preview-branch.test.ts b/packages/db/scripts/reset-preview-branch.test.ts index b77214f5d..1488adf3e 100644 --- a/packages/db/scripts/reset-preview-branch.test.ts +++ b/packages/db/scripts/reset-preview-branch.test.ts @@ -21,7 +21,6 @@ describe("resetGuardError", () => { it("refuses a non-preview branch name, even in CI", () => { expect(resetGuardError({ RESET_EXPECTED_BRANCH: "main" })).not.toBeNull() - expect(resetGuardError({ RESET_EXPECTED_BRANCH: "stg" })).not.toBeNull() expect(resetGuardError({ RESET_EXPECTED_BRANCH: "pr-" })).not.toBeNull() expect(resetGuardError({ RESET_EXPECTED_BRANCH: "xpr-12" })).not.toBeNull() }) diff --git a/packages/db/scripts/reset-preview-branch.ts b/packages/db/scripts/reset-preview-branch.ts index 1bb2d46c8..dc47955f7 100644 --- a/packages/db/scripts/reset-preview-branch.ts +++ b/packages/db/scripts/reset-preview-branch.ts @@ -79,7 +79,7 @@ export const isPreviewBranchName = (name: string | undefined): boolean => /** * Tripwire: this script empties whatever DATABASE_URL points at, and the * connected role inherits `postgres`, so nothing downstream would stop it from - * gutting prod/stg. The caller must therefore assert WHAT it is resetting — + * gutting prod. The caller must therefore assert WHAT it is resetting — * `RESET_EXPECTED_BRANCH=pr-` (planetscale-pr-branch.ts sets it from the * branch it minted the credential for) — or a human must set * `RESET_PREVIEW_CONFIRM=1`. The old guard keyed off `process.env.CI`, which diff --git a/packages/infra/src/aws/stage.test.ts b/packages/infra/src/aws/stage.test.ts index 09aeb6ce3..f9ade48d7 100644 --- a/packages/infra/src/aws/stage.test.ts +++ b/packages/infra/src/aws/stage.test.ts @@ -35,7 +35,7 @@ describe("parseMapleRegion", () => { describe("resolveAwsResourceName", () => { it("leaves us unsuffixed so adding eu renames nothing", () => { expect(resolveAwsResourceName("ingest", parseMapleStage("prd"), "us")).toBe("maple-ingest") - expect(resolveAwsResourceName("ingest", parseMapleStage("stg"), "us")).toBe("maple-ingest-stg") + expect(resolveAwsResourceName("ingest", parseMapleStage("pr-12"), "us")).toBe("maple-ingest-pr-12") }) it("defaults to us when no region is passed", () => { @@ -44,7 +44,11 @@ describe("resolveAwsResourceName", () => { it("suffixes eu, keeping the two instances distinct at every stage", () => { expect(resolveAwsResourceName("ingest", parseMapleStage("prd"), "eu")).toBe("maple-ingest-eu") - expect(resolveAwsResourceName("ingest", parseMapleStage("stg"), "eu")).toBe("maple-ingest-eu-stg") + expect(resolveAwsResourceName("ingest", parseMapleStage("pr-12"), "eu")).toBe("maple-ingest-eu-pr-12") + }) + + it("rejects the removed stg stage rather than naming a dev stack after it", () => { + expect(() => parseMapleStage("stg")).toThrow(/"stg" stage was removed/) }) }) @@ -59,7 +63,6 @@ describe("region topology", () => { describe("collector service discovery", () => { it("puts the collector in a per-stage namespace the gateway can name at plan time", () => { expect(resolveIngestNamespaceName(parseMapleStage("prd"))).toBe("maple-ingest.internal") - expect(resolveIngestNamespaceName(parseMapleStage("stg"))).toBe("maple-ingest-stg.internal") expect(resolveIngestNamespaceName(parseMapleStage("pr-12"))).toBe("maple-ingest-pr-12.internal") expect(resolveIngestNamespaceName(parseMapleStage("prd"), "eu")).toBe("maple-ingest-eu.internal") }) @@ -75,20 +78,16 @@ describe("collector service discovery", () => { it("deploys the gateway to every deployed stage, but never to a dev stage", () => { expect(stageDeploysIngest(parseMapleStage("prd"))).toBe(true) - expect(stageDeploysIngest(parseMapleStage("stg"))).toBe(true) expect(stageDeploysIngest(parseMapleStage("pr-12"))).toBe(true) expect(stageDeploysIngest(parseMapleStage("dev-alice"))).toBe(false) }) - it("writes replay blobs on stg and prd, and only where the gateway runs", () => { + it("writes replay blobs on prd, and only where the gateway runs", () => { expect(stageEnablesReplayBlobs(parseMapleStage("prd"))).toBe(true) - // stg deliberately included: production must not be the first place the - // R2 write path ever runs. - expect(stageEnablesReplayBlobs(parseMapleStage("stg"))).toBe(true) expect(stageEnablesReplayBlobs(parseMapleStage("pr-12"))).toBe(false) expect(stageEnablesReplayBlobs(parseMapleStage("dev-alice"))).toBe(false) // A stage cannot write blobs without a gateway to write them. - for (const stage of ["prd", "stg", "pr-12", "dev-alice"]) { + for (const stage of ["prd", "pr-12", "dev-alice"]) { if (stageEnablesReplayBlobs(parseMapleStage(stage))) { expect(stageDeploysIngest(parseMapleStage(stage))).toBe(true) } @@ -97,9 +96,8 @@ describe("collector service discovery", () => { it("deploys the collector to prd only for now, a subset of the gateway stages", () => { expect(stageDeploysCollector(parseMapleStage("prd"))).toBe(true) - expect(stageDeploysCollector(parseMapleStage("stg"))).toBe(false) expect(stageDeploysCollector(parseMapleStage("pr-12"))).toBe(false) - for (const stage of ["prd", "stg", "pr-12", "dev-alice"]) { + for (const stage of ["prd", "pr-12", "dev-alice"]) { if (stageDeploysCollector(parseMapleStage(stage))) { expect(stageDeploysIngest(parseMapleStage(stage))).toBe(true) } @@ -108,8 +106,8 @@ describe("collector service discovery", () => { it("sizes the collector task with 1 GiB everywhere so the memory limiter can fire", () => { expect(resolveCollectorTaskSize(parseMapleStage("prd"))).toEqual({ cpu: 512, memory: 1024 }) - expect(resolveCollectorTaskSize(parseMapleStage("stg"))).toEqual({ cpu: 256, memory: 1024 }) expect(resolveCollectorTaskSize(parseMapleStage("pr-12"))).toEqual({ cpu: 256, memory: 1024 }) + expect(resolveCollectorTaskSize(parseMapleStage("dev-alice"))).toEqual({ cpu: 256, memory: 1024 }) }) }) @@ -124,7 +122,6 @@ describe("resolveIngestScaling", () => { }) it("keeps every other stage at a fixed count", () => { - expect(resolveIngestScaling(parseMapleStage("stg"))).toBeUndefined() expect(resolveIngestScaling(parseMapleStage("pr-12"))).toBeUndefined() expect(resolveIngestScaling(parseMapleStage("dev-alice"))).toBeUndefined() }) diff --git a/packages/infra/src/aws/stage.ts b/packages/infra/src/aws/stage.ts index fcad47ed8..04ca889bf 100644 --- a/packages/infra/src/aws/stage.ts +++ b/packages/infra/src/aws/stage.ts @@ -4,7 +4,7 @@ import type { MapleStage } from "../cloudflare/stage.ts" /** * Geographic instance a deployment belongs to. * - * Orthogonal to `MapleStage`: stage is prd/stg/pr/dev, region is which + * Orthogonal to `MapleStage`: stage is prd/pr/dev, region is which * geographic instance. A full EU instance is `region: "eu"` at every stage, * with its OWN Tinybird workspace, application database, and ingest fleet — * telemetry that lands in `eu` must never transit `us`, which is the whole @@ -87,8 +87,6 @@ export function resolveAwsResourceName( switch (stage.kind) { case "prd": return `maple-${base}${suffix}` - case "stg": - return `maple-${base}${suffix}-stg` case "pr": return `maple-${base}${suffix}-pr-${stage.prNumber}` case "dev": @@ -118,7 +116,7 @@ export function resolveIngestDesiredCount(stage: MapleStage): number { * into 5xx at the edge; scale-in is lazy (5 min) so a lull does not thrash. * Note the per-org replay byte budget is process-local, so the effective * ceiling scales with the task count (see `resolveIngestDesiredCount`). - * Other stages stay fixed: nothing bursts at staging or a preview. + * Other stages stay fixed: nothing bursts at a preview. */ export interface IngestScaling { min: number @@ -158,8 +156,8 @@ export function resolveIngestTaskSize(stage: MapleStage): IngestTaskSize { /** * Whether a stage gets an AWS ingest deployment at all. * - * Every deployed stage does — prd, stg and PR previews. Dev stages run the - * gateway through docker-compose instead and never reach AWS. + * Every deployed stage does — prd and PR previews. Dev stages run the gateway + * through docker-compose instead and never reach AWS. * * A VPC + ALB + ECS cluster per preview is real money, so the spend gate is not * here: previews only deploy at all when the PR carries the `preview` label @@ -173,13 +171,14 @@ export function resolveIngestTaskSize(stage: MapleStage): IngestTaskSize { * skew" alert rule depends on. `env.test.ts` fails if the two disagree. */ export function stageDeploysIngest(stage: MapleStage): boolean { - return stage.kind === "prd" || stage.kind === "stg" || stage.kind === "pr" + return stage.kind === "prd" || stage.kind === "pr" } /** * Cloud Map private DNS namespace the ingest fleet's internal services live - * in — one per stage VPC (`maple-ingest.internal`, `maple-ingest-stg.internal`, - * …). `.internal` is the TLD ICANN reserved for exactly this. The name follows + * in — one per stage VPC (`maple-ingest.internal`, + * `maple-ingest-pr-12.internal`, …). `.internal` is the TLD ICANN reserved for + * exactly this. The name follows * `resolveAwsResourceName` so the two read the same in a console; changing it * replaces the namespace and every service registered in it. */ @@ -213,8 +212,8 @@ export function resolveCollectorEndpoint( * Whether a stage gets the OTel collector beside its gateway. * * prd only for now — a cash-flow call, not a design one. The intent is every - * stage that deploys the gateway (`stageDeploysIngest`), so stg and previews - * carry their own self-telemetry too; flip this to `stageDeploysIngest(stage)` + * stage that deploys the gateway (`stageDeploysIngest`), so previews carry + * their own self-telemetry too; flip this to `stageDeploysIngest(stage)` * when the budget allows (~$13.5/mo per stage at the non-prd size). Until * then a preview can opt in by also carrying the `preview:collector` label, * which sets MAPLE_DEPLOY_AWS_COLLECTOR=1 for that deploy — this is how the @@ -235,14 +234,14 @@ export function stageDeploysCollector(stage: MapleStage): boolean { * only rollback lever, which was "unset the secret and redeploy". Flipping this * function is now that lever. * - * stg + prd. Deliberately NOT prd-only like `stageDeploysCollector`: that gate - * is a cash-flow call and R2 costs pennies here, whereas gating this to prd - * would make production the first place the write path ever runs. Previews stay - * off — a PR preview writing real objects into its own bucket buys nothing and - * leaves more to reap. + * prd only, which is where it has run since staging was removed — staging was + * the other stage on this gate, and the argument for it was that production + * should not be the first place the write path ever runs. Previews stay off: a + * PR preview writing real objects into its own bucket buys nothing and leaves + * more to reap. */ export function stageEnablesReplayBlobs(stage: MapleStage): boolean { - return stage.kind === "prd" || stage.kind === "stg" + return stage.kind === "prd" } /** @@ -270,7 +269,7 @@ export function resolveCollectorTaskSize(stage: MapleStage): IngestTaskSize { * skew" alert rule depends on. `env.test.ts` fails if the two disagree. */ export function stageDeploysElectric(stage: MapleStage): boolean { - return stage.kind === "prd" || stage.kind === "stg" + return stage.kind === "prd" } /** diff --git a/packages/infra/src/cloudflare/maple-db.ts b/packages/infra/src/cloudflare/maple-db.ts index afac224f3..13e2b0e5a 100644 --- a/packages/infra/src/cloudflare/maple-db.ts +++ b/packages/infra/src/cloudflare/maple-db.ts @@ -5,7 +5,7 @@ * * - `"managed"` (dev stages): `ManagedMapleDb`, the alchemy-managed Hyperdrive * below, bound through `Hyperdrive.Connect`. - * - `"ref"` (stg/prd): a dashboard-managed config, attached by id. Alchemy has + * - `"ref"` (prd): a dashboard-managed config, attached by id. Alchemy has * no `env` form for a Hyperdrive it did not create; its own `ConnectBinding` * attaches the same raw metadata with `host.bind`, so this does too. The * origin and credentials live only in the Cloudflare dashboard. diff --git a/packages/infra/src/cloudflare/stage.test.ts b/packages/infra/src/cloudflare/stage.test.ts index be3db88a9..bb38b83a3 100644 --- a/packages/infra/src/cloudflare/stage.test.ts +++ b/packages/infra/src/cloudflare/stage.test.ts @@ -1,12 +1,26 @@ import { describe, expect, it } from "vitest" -import { parseMapleStage, resolveDatabaseMode, stageDeploysSandbox } from "./stage.ts" +import { parseMapleStage, resolveDatabaseMode, resolveHyperdriveRefId, stageDeploysSandbox } from "./stage.ts" const stage = (name: string) => parseMapleStage(name) +describe("parseMapleStage", () => { + it("rejects the removed stg stage instead of parsing it as a dev stage", () => { + // `stg` matches the dev-stage pattern, so removing its case alone would + // have silently produced a `maple-*-dev-stg` stack. + expect(() => stage("stg")).toThrow(/"stg" stage was removed/) + expect(() => stage(" STG ")).toThrow(/"stg" stage was removed/) + }) + + it("still parses the stages that remain", () => { + expect(stage("prd")).toEqual({ kind: "prd" }) + expect(stage("pr-123")).toEqual({ kind: "pr", prNumber: 123 }) + expect(stage("dev_makisuo")).toEqual({ kind: "dev", name: "dev-makisuo" }) + }) +}) + describe("stageDeploysSandbox", () => { - it("runs the agents' repository sandbox on deployed stages only", () => { + it("runs the agents' repository sandbox on prd, the only stage with a database", () => { expect(stageDeploysSandbox(stage("prd"))).toBe(true) - expect(stageDeploysSandbox(stage("stg"))).toBe(true) }) it("skips a PR preview, which has no database to resolve a repository with", () => { @@ -20,3 +34,15 @@ describe("stageDeploysSandbox", () => { expect(stageDeploysSandbox(stage("dev_makisuo"))).toBe(false) }) }) + +describe("resolveHyperdriveRefId", () => { + it("hands the production configs to prd and to nothing else", () => { + // These ids are the live dashboard configs for the production database. + // `stg` used to be handed `maple-prd` too, which is why the stage is gone. + expect(resolveHyperdriveRefId({ kind: "prd" }, "api")).toBe("ad4c487838594b89810b23e5fb14e129") + expect(resolveHyperdriveRefId({ kind: "prd" }, "ai")).toBe("ad4c487838594b89810b23e5fb14e129") + expect(resolveHyperdriveRefId({ kind: "prd" }, "alerting")).toBe("f473167201af4d2cae494f9989f1d742") + expect(resolveHyperdriveRefId(stage("pr-123"), "api")).toBeUndefined() + expect(resolveHyperdriveRefId(stage("dev_makisuo"), "api")).toBeUndefined() + }) +}) diff --git a/packages/infra/src/cloudflare/stage.ts b/packages/infra/src/cloudflare/stage.ts index c96ed065f..70ea34cfb 100644 --- a/packages/infra/src/cloudflare/stage.ts +++ b/packages/infra/src/cloudflare/stage.ts @@ -1,8 +1,4 @@ -export type MapleStage = - | { kind: "prd" } - | { kind: "stg" } - | { kind: "pr"; prNumber: number } - | { kind: "dev"; name: string } +export type MapleStage = { kind: "prd" } | { kind: "pr"; prNumber: number } | { kind: "dev"; name: string } const PR_STAGE_RE = /^pr-(\d+)$/ // Underscores allowed so alchemy's default `dev_${USER}` stage parses as a dev stage. @@ -37,16 +33,6 @@ const PRD_DOMAINS: MapleDomains = { local: "local.maple.dev", } -const STG_DOMAINS: MapleDomains = { - web: "staging.maple.dev", - api: "api-staging.maple.dev", - ingest: "ingest-staging.maple.dev", - sync: "sync-staging.maple.dev", - electric: "electric-staging.maple.dev", - landing: "staging-landing.maple.dev", - local: "local-staging.maple.dev", -} - export function parseMapleStage(stage: string): MapleStage { const normalized = stage.trim().toLowerCase() @@ -54,8 +40,15 @@ export function parseMapleStage(stage: string): MapleStage { return { kind: "prd" } } + // `stg` is rejected rather than left to fall through to the dev-stage + // pattern, which it matches. The staging stage was removed (2026-09) after + // sitting disabled and unreachable with its Hyperdrive ref pointed at the + // production database; a `--stage stg` that quietly built a dev stack named + // `maple-*-dev-stg` is not the failure anyone typing it wants. if (normalized === "stg") { - return { kind: "stg" } + throw new Error( + 'The "stg" stage was removed. Deploy prd, a pr- preview, or a dev stage name.', + ) } const prMatch = normalized.match(PR_STAGE_RE) @@ -74,7 +67,7 @@ export function parseMapleStage(stage: string): MapleStage { } throw new Error( - `Unsupported deployment stage "${stage}". Expected prd, stg, pr-, or a dev stage name matching [a-z0-9][a-z0-9_-]*.`, + `Unsupported deployment stage "${stage}". Expected prd, pr-, or a dev stage name matching [a-z0-9][a-z0-9_-]*.`, ) } @@ -82,8 +75,6 @@ export function formatMapleStage(stage: MapleStage): string { switch (stage.kind) { case "prd": return "prd" - case "stg": - return "stg" case "pr": return `pr-${stage.prNumber}` case "dev": @@ -95,8 +86,6 @@ export function resolveDeploymentEnvironment(stage: MapleStage): string { switch (stage.kind) { case "prd": return "production" - case "stg": - return "staging" case "pr": return `pr-${stage.prNumber}` case "dev": @@ -108,8 +97,6 @@ export function resolveMapleDomains(stage: MapleStage): MapleDomains { switch (stage.kind) { case "prd": return PRD_DOMAINS - case "stg": - return STG_DOMAINS case "pr": // Give PR previews stable, secret-free URLs. The default workers.dev URL // embeds the Cloudflare account subdomain, which Infisical masks as a @@ -136,7 +123,7 @@ export type MapleDatabaseMode = "ref" | "managed" | "none" /** * How a stage reaches the application database. * - * - `"ref"` — bind a dashboard-managed Hyperdrive config by ID (stg/prd). + * - `"ref"` — bind a dashboard-managed Hyperdrive config by ID (prd). * - `"managed"` — alchemy creates a Hyperdrive whose origin is pushed from * `MAPLE_PG_URL` (dev stages, against the docker-compose Postgres). * - `"none"` — no `MAPLE_DB` binding at all. `DatabasePgLive` then fails every @@ -152,7 +139,6 @@ export type MapleDatabaseMode = "ref" | "managed" | "none" export function resolveDatabaseMode(stage: MapleStage): MapleDatabaseMode { switch (stage.kind) { case "prd": - case "stg": return "ref" case "pr": return "none" @@ -164,16 +150,16 @@ export function resolveDatabaseMode(stage: MapleStage): MapleDatabaseMode { /** * Which stages get the agents' repository sandbox. * - * Deployed stages only. A PR preview has no application database - * ({@link resolveDatabaseMode} returns `"none"`), so no repository can be - * resolved there and a container would be provisioned to do nothing but cost - * money. Dev stages are excluded for a sharper reason: `alchemy dev` resolves a - * container image by pulling it locally, so provisioning one would put a - * multi-gigabyte pull and a running Docker daemon between every developer and - * `bun dev`, whichever apps they asked for. + * prd only, which is now the only stage with an application database. A PR + * preview has none ({@link resolveDatabaseMode} returns `"none"`), so no + * repository can be resolved there and a container would be provisioned to do + * nothing but cost money. Dev stages are excluded for a sharper reason: + * `alchemy dev` resolves a container image by pulling it locally, so + * provisioning one would put a multi-gigabyte pull and a running Docker daemon + * between every developer and `bun dev`, whichever apps they asked for. */ export function stageDeploysSandbox(stage: MapleStage): boolean { - return stage.kind === "prd" || stage.kind === "stg" + return stage.kind === "prd" } /** Which worker is binding `MAPLE_DB`. prd gives each its own Hyperdrive config — see docs/infra.md. */ @@ -198,14 +184,6 @@ export function resolveHyperdriveRefId(stage: MapleStage, consumer: MapleDbConsu return consumer === "alerting" ? "f473167201af4d2cae494f9989f1d742" // `maple-alerting-prd` : "ad4c487838594b89810b23e5fb14e129" // `maple-prd` - case "stg": - // TEMPORARY: staging shares prod's `maple-prd` config (owner decision, - // 2026-07-14) — stg workers therefore read/write the PRODUCTION - // database and the stg alerting crons overlap prod's. Replace with a - // dedicated `maple-stg` config (PlanetScale `stg` branch) ASAP. - // Deliberately NOT split per consumer here: stg is already pointed at the - // wrong database, and splitting it would add a second wrong pool. - return "ad4c487838594b89810b23e5fb14e129" case "pr": case "dev": return undefined @@ -216,8 +194,6 @@ export function resolveWorkerName(base: string, stage: MapleStage): string { switch (stage.kind) { case "prd": return `maple-${base}` - case "stg": - return `maple-${base}-stg` case "pr": return `maple-${base}-pr-${stage.prNumber}` case "dev": diff --git a/packages/infra/src/env.test.ts b/packages/infra/src/env.test.ts index bc304563a..88e17a42a 100644 --- a/packages/infra/src/env.test.ts +++ b/packages/infra/src/env.test.ts @@ -130,7 +130,6 @@ describe("selfObservabilityEnv", () => { it("derives MAPLE_ENVIRONMENT from the stage and refuses a provider override", () => { const env = { ...base, MAPLE_ENVIRONMENT: "production" } expect(run(selfObservabilityEnv({ kind: "pr", prNumber: 42 }), env).MAPLE_ENVIRONMENT).toBe("pr-42") - expect(run(selfObservabilityEnv({ kind: "stg" }), env).MAPLE_ENVIRONMENT).toBe("staging") expect(run(selfObservabilityEnv({ kind: "prd" }), env).MAPLE_ENVIRONMENT).toBe("production") expect(run(selfObservabilityEnv({ kind: "dev", name: "x" }), env).MAPLE_ENVIRONMENT).toBe( "development", @@ -160,7 +159,6 @@ describe("selfObservabilityEnv", () => { it("fails when the ingest key is missing", () => { expect(runExit(selfObservabilityEnv({ kind: "prd" }), {})._tag).toBe("Failure") - expect(runExit(selfObservabilityEnv({ kind: "stg" }), {})._tag).toBe("Failure") expect(runExit(selfObservabilityEnv({ kind: "pr", prNumber: 7 }), {})._tag).toBe("Failure") }) @@ -295,10 +293,10 @@ describe("parity with the pre-refactor per-worker expressions", () => { const old = { MAPLE_INGEST_KEY: Redacted.make(oldRequireEnv(env, "MAPLE_OTEL_INGEST_KEY")), ...oldOptionalPlain(env, "MAPLE_ENDPOINT"), - MAPLE_ENVIRONMENT: "staging", + MAPLE_ENVIRONMENT: "production", ...oldOptionalPlain(env, "COMMIT_SHA", env.GITHUB_SHA?.trim()), } - expect(unwrap(run(selfObservabilityEnv({ kind: "stg" }), env))).toEqual(unwrap(old)) + expect(unwrap(run(selfObservabilityEnv({ kind: "prd" }), env))).toEqual(unwrap(old)) }) it("apnsEnv", () => { diff --git a/scripts/cloudflare-worker-orphan-sweep.ts b/scripts/cloudflare-worker-orphan-sweep.ts index e184f0dae..993fb28a2 100644 --- a/scripts/cloudflare-worker-orphan-sweep.ts +++ b/scripts/cloudflare-worker-orphan-sweep.ts @@ -32,7 +32,7 @@ * * Deletion is double-gated like the sibling sweeps: the queue/worker name must * match `maple--pr-` exactly, where may not contain - * `-dev-` (prd `maple-api`, stg `maple-api-stg` can never match; a dev stage + * `-dev-` (prd `maple-api` can never match; a dev stage * named "pr-3" would produce `maple-api-dev-pr-3`, hence the -dev- exclusion), * AND the GitHub API must affirmatively report that PR closed — unknown/open → * keep. Worker deletes use ?force=true because preview workers service-bind diff --git a/scripts/hyperdrive-orphan-sweep.ts b/scripts/hyperdrive-orphan-sweep.ts index a3ca7c1ea..60f26ddf1 100644 --- a/scripts/hyperdrive-orphan-sweep.ts +++ b/scripts/hyperdrive-orphan-sweep.ts @@ -20,7 +20,7 @@ * There is no up/down here — alchemy owns the config's lifecycle; this script * exists only as the sweep safety net. Deletion is double-gated like the * sibling sweeps: the config name must match `maple-db-pr-` exactly - * (prd `maple-prd`, stg `maple-db-stg`, and dev `maple-db-dev-` can + * (prd `maple-prd` and dev `maple-db-dev-` can * never match), AND the GitHub API must affirmatively report that PR closed — * unknown/open → keep. Deleting a config out from under a later alchemy * destroy of the same stage is fine: alchemy tolerates already-deleted diff --git a/scripts/planetscale-pr-branch.ts b/scripts/planetscale-pr-branch.ts index b4a582370..d5d53dc09 100644 --- a/scripts/planetscale-pr-branch.ts +++ b/scripts/planetscale-pr-branch.ts @@ -398,8 +398,8 @@ const fetchPrState = async (prNumber: string): Promise<"open" | "closed" | "unkn /** * Delete every `pr-` branch whose PR is closed. Only branches matching the - * exact `pr-` shape are considered — `main`, `stg`, and anything else - * are never candidates. Branches whose PR state cannot be determined are + * exact `pr-` shape are considered — `main` and anything else are + * never candidates. Branches whose PR state cannot be determined are * skipped (deleting on uncertainty risks tearing down a live preview). */ const sweepOrphanBranches = async (database: string): Promise => {