diff --git a/.drive/projects/state-under-branch/assets/pdp-asks.md b/.drive/projects/state-under-branch/assets/pdp-asks.md new file mode 100644 index 00000000..4c51f6ff --- /dev/null +++ b/.drive/projects/state-under-branch/assets/pdp-asks.md @@ -0,0 +1,58 @@ +# Two questions from the Composer team about the Management API + +Context in three sentences: **Prisma Composer** is the TypeScript framework +that deploys multi-service apps onto Prisma Cloud through the public +Management API — one app becomes one Project, each environment (production, +staging, per-PR preview) is a Branch, and the app's services and databases are +that Branch's Apps and Databases. We're changing where Composer keeps its own +deploy state (the record of what it has provisioned, used to diff deploys and +drive destroys): instead of a separate workspace-level project, each +environment will keep its state in one small, framework-owned Prisma Postgres +database named `prisma-composer-state`, attached to that environment's Branch +and created through the ordinary database-create endpoint. The point is +lifecycle containment: deleting a Branch (or the Project) then cleans up our +state automatically, with no special handling on your side. + +Two things we'd like to confirm before we ship it: + +## 1. Quota / billing for one extra small database per Branch + +Every environment gets one additional database (tiny: a handful of rows of +JSON state). For apps using per-PR preview environments, these come and go +with the PR — created on first deploy of the branch, deleted when the branch +is destroyed. + +- Does this count against any per-project or per-workspace database quota we + should design around? +- Is there a billing floor per database that would make "one small extra DB + per PR preview" surprising for customers, or is a mostly idle database + effectively free? + +## 2. The dependency contract of `DELETE /v1/projects/{id}` + +When Composer destroys an app's production environment, it deletes the +resources it created, then makes a best-effort `DELETE /v1/projects/{id}` so +empty projects don't accumulate toward the workspace plan limit. We rely on +the API's own 400 ("still has dependencies") to keep the project when other +environments still exist. + +What we observe: the delete succeeds when the project holds only its implicit +default Branch and the auto-provisioned default database, and 400s when more +exists. We'd like to confirm that's the contract, not incidental behavior: + +- Which children count as blocking dependencies? Specifically: does an + **empty non-default Branch** block deletion? Does a **non-default + database** block it (we assume yes — we delete our state database before + calling project-delete)? +- Is "implicit default Branch + default database don't block deletion" + guaranteed, or should we delete anything else first? + +## FYI, not asks (yet) + +Two things we'll likely bring to you later, flagged now so they're not a +surprise: a way to mark a database as framework-owned/protected (so users +don't hand-delete their environment's state DB from Console), and — for the +future GitHub App integration of Composer repos — short-lived, +project-scoped service tokens. Nothing needed on either today. + +— Composer team (Will) diff --git a/.drive/projects/state-under-branch/design-notes.md b/.drive/projects/state-under-branch/design-notes.md new file mode 100644 index 00000000..dc6e7436 --- /dev/null +++ b/.drive/projects/state-under-branch/design-notes.md @@ -0,0 +1,600 @@ +# Design notes — State Under Branch + +Running design record. The decision here becomes **ADR-0034** at slice S1; this +file is the exhaustive implementation-grade design the slices execute against. +It is written to leave zero interpretation gaps: where a choice existed, the +choice is made and recorded here. + +Settled in operator/agent discussion 2026-07-17 (session +`0feb1bb2-7810-4fd6-9cbb-7d348f8c32f0` and its continuation), including two +corrections sourced from the PDP team (see § Verified platform facts, items +1–2). + +## The decision + +Each stage's Alchemy deploy state lives in a **framework-owned Prisma Postgres +database attached to that stage's own Branch**, inside the app's own Project. +Production's state database attaches to the Project's **implicit default +Branch** (a platform invariant — every live Project owns one). The +workspace-level `prisma-composer-state` Project is retired. + +Consequence: state has exactly the lifetime of the environment it describes. +Deleting a Branch — by GitHub webhook, by Console, or by the CLI — deletes the +stage's state with it, without the platform knowing Composer exists. Deleting +the Project deletes everything. This supersedes +[ADR-0009](../../../docs/design/90-decisions/ADR-0009-deploy-state-is-hosted-in-the-workspace.md). + +### Why the inputs changed (the supersession case, not a relitigation) + +ADR-0009 rejected "state inside the app's own project" on two grounds: + +1. **Circularity** — "the project is created and destroyed *by* the deploys + whose state it would hold." Dissolved by ADR-0023/0024: `ensure-containers` + creates the Project and Branch via the Management API **before** Alchemy + runs, and deletes them **after** destroy, entirely outside state + ([ensure-containers.ts](../../../packages/0-framework/3-tooling/cli/src/ensure-containers.ts)). + The container lifecycle is already framework-managed; a state store + bootstrapped in the container-ensure phase is container-scoped + infrastructure, not a circular dependency. +2. **Fragmentation** — workspace-level questions (list all stacks, cross-stack + references, fresh-machine bootstrap) want one place to look. Neither feature + exists today; "list Projects" is a Management API query the platform owns; + and bootstrap discovery gets *simpler* (resolve Project/Branch by id instead + of scanning same-named candidate projects). + +Two forces that did not exist when ADR-0009 was written now push the other way: + +- **Teardown correctness.** Platform-side Branch/Project deletion (Console + today; GitHub-webhook branch-delete in the successor project) orphans + workspace-hosted stage state. Under containment every platform teardown path + is correct by construction — the state database is an ordinary enumerable + platform child. +- **Credential scoping** (successor project). A webhook build against a + workspace-hosted store needs a workspace-scoped token in an untrusted + sandbox; with in-project state, everything a deploy touches is + project-scoped. + +## Verified platform facts + +Everything below was read from source on 2026-07-17. **Read the checkout in +this worktree (`./pdp-control-plane`, at `e79d07bd8`), not +`~/Projects/prisma/pdp-control-plane`** — the home checkout is older +(`b44a38615`) and already diverges: `deleteProject` has moved to its own file +and the offer-limit enforcement has flipped to Prisma Next. Facts 1–6 below +were originally taken from the home checkout and have since been re-verified +against the worktree copy; `resolveDefaultOrPinnedBranchId` (fact 1, the one +the whole design rests on) is byte-identical in both, invariant comment +included. + +1. **Resources always belong to a Branch.** `POST /v1/compute-services` and + `POST /v1/projects/{projectId}/databases` resolve an omitted `branchId` to + the project's **default Branch** via `resolveDefaultOrPinnedBranchId` + (`packages/interactors/src/branch/resolveDefaultOrPinnedBranchId.ts`), whose + doc comment states the invariant: "every live Project is supposed to own + one (the post-#3902 invariant + the production backfill)." A project with + no live default Branch is a recoverable data error, not a supported state. + The nullable `branchId` schema columns and the link-time + `updateMany({ branchId: null } → defaultBranchId)` sweep + (`packages/interactors/src/scm/linkProjectToScmRepo.ts:241-254`) are + backfill remnants, **not** the live contract. (Confirmed directly with the + PDP team 2026-07-17.) +2. **Production therefore already lives on a Branch.** Composer's ADR-0024 + phase 1 "ensures only the Project" and the lowering omits `branchId` for + the default stage — but the platform lands those resources on the implicit + default Branch. ADR-0024's *addressing* model is unchanged; its "lives at + the Project level" wording gets a correction note (S1). +3. **Two different database endpoints exist, and only the flat one speaks + Branch.** Corrected 2026-07-17 after the implementer caught the original + claim here being wrong — it attributed the flat endpoint's parameters to + the project-scoped one. + - **Project-scoped** `…/v1/projects/{projectId}/databases` + (`services/management-api/routes/v1/projects/databases.ts`): `GET` takes + `{ cursor, limit }` **only** — no branch filter. `POST` takes + `name`/`region`/`isDefault`/`source` and has **no `branchId` field**; its + response even hard-codes `branchId: null`, which is a reporting bug, not + the truth (see fact 3a). + - **Flat** `…/v1/databases` (`services/management-api/routes/v1/databases.ts`): + `GET` accepts `projectId` + `branchId`/`branchGitName` filters (mutually + exclusive) and returns `branchId` and `createdAt` per row. `POST` + (`FlatCreateDatabaseInputSchema`, ~:394) accepts `projectId`, `name`, + `region` (including `"inherit"` = the project's default database region), + `isDefault`, and `branchId`/`branchGitName` — **everything needed, in one + call**. + - **Use the flat endpoint for both discovery and creation.** See § Bootstrap + step 3 for why the client-side create-then-`PATCH` two-step is rejected. + - **The flat create is not atomic** (corrected 2026-07-17, second time this + endpoint has caught us out — found by the reviewer). It is a *server-side* + create-then-attach: `services/management-api/routes/v1/databases.ts:574` + calls `createPrismaPostgresDatabase` **without** `branchId` (so by fact 3a + the row is born on the default Branch), then `:629` calls + `attachResourceToBranch` to move it and returns an HTTP error if that + fails. The route's authors document the window themselves at `:526-530` + and narrow it with a Branch pre-check at `:531-540`. Narrowed, not closed. + What this buys over the client-side two-step is still decisive — one + request instead of two, the Branch validated before the row exists, and no + client-crash window between the two calls — but "atomic" was never true + and must not be claimed. +3a. **A database created without a `branchId` lands on the default Branch — + it is never branchless.** `createPrismaPostgresDatabase` + (`packages/interactors/src/database.ts:1010-1027`) calls + `getOrCreateDefaultBranch(tx, { projectId })` inside the create transaction + and connects `branch: { connect: { id: branchId } }`. This is the database + half of fact 1, verified directly rather than inferred from the + compute-service path — and it is what makes the two-step create hazardous. +4. **Composer's own `Database` provider uses the project-scoped create then + attaches** via `PATCH /v1/databases/{databaseId}` `{ branchId }` + ([Database.ts:56-77](../../../packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Database.ts)). + That pattern predates this design; the state store deliberately does not + copy it (§ Bootstrap step 3). Whether the provider itself should move to + the flat single-call endpoint is a separate question — recorded under + Follow-ups, not fixed here. +5. **Branch deletion via the Management API refuses live members.** + `DELETE /v1/branches/{branchId}` "refuses if the Branch still has live + members or is the production/default Branch" + ([container.ts:166-179](../../../packages/1-prisma-cloud/0-lowering/lowering/src/container.ts)); + `Database.branchId` is `onDelete: Restrict` in the platform schema. +6. **Platform teardown is enumerate-and-delete, best-effort, cron-backstopped.** + The GitHub branch-delete webhook path + (`services/github-webhook/webhook-handlers/handleDelete.ts` → + `tearDownBranchByGitName` → `deprovisionBranchDatabases`) soft-deletes the + Branch first, then deletes each App/version, each **non-default** database + (tenant then row; `where: { branchId, isDefault: false }`), and non-production + config vars. It refuses default and production branches. Console project + deletion (`packages/interactors/src/project.ts:314` `deleteProject`) + refuses while deployments are active, then deletes **all** the project's + database tenants (all branches) before removing the project. +7. **`deleteAppProject` works today against a project that has its implicit + default Branch and auto-provisioned default database** — the API's + dependency check tolerates those implicit children (observed: "Removed the + Project — nothing was left in it" on hand-run stacks). A non-default + database (our state DB) **is** expected to count as a dependency. Open + question OQ-2 asks PDP to confirm the contract. +8. **Container ids already reach the state layer's process.** `runAlchemy` + sets `PRISMA_PROJECT_ID` (always) and `PRISMA_BRANCH_ID` (named stages + only) on the alchemy child + ([run-alchemy.ts:55-58](../../../packages/0-framework/3-tooling/cli/src/run-alchemy.ts)), + where `prismaState()`'s layer runs. + +## Current implementation inventory (what changes, what doesn't) + +All in `packages/1-prisma-cloud/0-lowering/lowering/src/state/` unless noted. + +| Surface | Today | Under this design | +| --- | --- | --- | +| `bootstrap.ts` `resolveStateProject`, `listStateProjects`, `listAllProjects`, `createStateProject`, `bareWorkspaceId`, `findDefaultDatabase`, `STATE_PROJECT_NAME` | find-or-create workspace project by name, adopt its default DB | **Deleted.** Replaced by branch-scoped database find-or-create (§ Bootstrap) | +| `bootstrap.ts` `verifyOwnership`, `OwnershipVerdict`, `mintConnection`, `cleanupAgedConnections`, `listAllConnections`, `deleteConnection` | ownership marker check; fresh connection per run; aged-connection GC | **Retained**, re-pointed at the per-branch database | +| `schema.ts` (`migratePrismaState`, `STATE_META_MARKER`, tables `alchemy_resource_state`/`alchemy_stack_output`/`prisma_app_state_meta`) | idempotent DDL + marker | **Unchanged.** The `stack`/`stage` key columns become redundant inside a per-stage DB; keeping them means zero store-code changes and zero migration risk. Do not remove them. | +| `lock.ts` (ADR-0010 session advisory lock keyed `hash(stack, stage)`) | serializes deploys per app+stage on the shared store | **Unchanged.** Key is redundant inside a per-stage DB; harmless. New emergent property recorded in ADR: deleting the state DB severs the lock connection, so an in-flight deploy of that stage fails its lease check within the trust window — platform teardown doubles as a kill switch. | +| `service.ts`, `transient.ts` | store CRUD, transient store | **Unchanged** | +| `errors.ts` | `hostedStateBootstrapError(workspaceId, step, cause)` | **One rename** (amended 2026-07-17): the error's only identifying field is `workspaceId`, and there is no workspace at this layer anymore. Carrying the project id in a field called `workspaceId` produces the operator-facing line "hosted-state bootstrap failed for workspace prj_x/br_y", which is simply false. Rename the field to `target` and pass `projectId` (or `projectId/branchId` for a named stage). The "unchanged" list protects the store's *behaviour*; it was never a licence to print a wrong noun. | +| `layer.ts` `prismaState()` | requires `PRISMA_WORKSPACE_ID`; bootstrap by workspace | Requires `PRISMA_PROJECT_ID`; optional `PRISMA_BRANCH_ID` (§ Bootstrap). `workspaceId` option and its env fallback are deleted. | +| CLI `main.ts` destroy tail (steps after alchemy exit 0) | `deleteStageBranch` (named stage) / `deleteAppProject` (production) | New step **before** each: delete the stage's state database (§ Destroy ordering) | +| `ensure-containers.ts` | resolve/delete containers | gains `deleteStateDatabase` wiring (the implementation lives in lowering; see § Destroy ordering) | + +## The design, exhaustively + +### Constants + +- State database display name: `prisma-composer-state` (reuse the existing + constant value; rename the constant `STATE_PROJECT_NAME` → + `STATE_DATABASE_NAME`). +- Marker value `STATE_META_MARKER = 'prisma-composer-state-v1'` — unchanged + (the marker proves ownership of a *database*; nothing in it encodes + workspace-vs-branch placement). +- Connection-name prefix and 24 h GC threshold — unchanged. +- The state database is **never** created with `isDefault: true` and never + adopts a database whose `isDefault` is true (two reasons: the platform's + webhook teardown only auto-deletes `isDefault: false` databases, and the + default database is the user's). + +### Bootstrap (replaces `resolveStateProject`) + +`bootstrapStateConnection` signature changes from `(workspaceId)` to +`(input: { projectId: string; branchId?: string })`. `prismaState()` reads +`PRISMA_PROJECT_ID` (throw the existing-style error if missing/empty: +`` `prismaState(): environment variable PRISMA_PROJECT_ID is required (the CLI sets it — deploy via \`prisma-composer deploy\`).` ``) +and `PRISMA_BRANCH_ID` (optional; empty string = absent). The test seam +(`bootstrapStateConnectionWith`, injectable `OwnershipVerifier`) is preserved. + +Algorithm, in order: + +1. **Resolve the target branch id.** + - `branchId` provided (named stage): use it as-is. + - `branchId` absent (default stage / production): resolve the project's + default Branch — `GET` the project's branches (the same endpoint family + `resolveBranch` in `container.ts` already pages through), select the + branch with `isDefault: true`. Exactly this filter; not "first", not + "role production". If none exists, fail with a `PrismaApiError`-wrapped + message: `` `project ${projectId} has no default Branch — the platform guarantees every live Project owns one; contact support.` `` + Do not create a Branch here under any circumstances. +2. **List candidates.** `GET /v1/databases` (the **flat** endpoint — the + project-scoped one has no branch filter, fact 3) with query + `{ projectId, branchId: }`, paged, filtered to + `name === STATE_DATABASE_NAME` and `isDefault === false`. +3. **Zero candidates → create, in one call.** + `POST /v1/databases` (the **flat** endpoint) with body + `{ projectId, name: STATE_DATABASE_NAME, region: 'inherit', branchId: }`. + Never hard-code a region literal; if `'inherit'` is missing from the + generated SDK's region union, update the SDK dependency and say so. + + **The client-side create-then-`PATCH` two-step is rejected** (amended + 2026-07-17). The project-scoped create has no `branchId` field, so a + database made that way is born on the **default Branch** (fact 3a) and only + moves on the follow-up `PATCH`. Two calls means two failure points plus a + client-crash window between them. + + **What the flat endpoint actually buys, stated honestly** (corrected + 2026-07-17 after the reviewer read the route): not atomicity — it is a + server-side create-then-attach with the same shape (fact 3). It buys one + request instead of two, a Branch validated *before* the row exists, and no + window in which our own process can die between create and attach. The + window narrows to "the platform's attach failed after its create + succeeded"; it does not vanish. + + **The residual, and why it is acceptable.** A failed attach strands a + database named `prisma-composer-state` — empty, never migrated, + `isDefault: false` — on the project's default Branch. Follow it through: + a later *production* bootstrap resolves that same default Branch and lists + it as a candidate. If production already has a real store, that store is + older, wins the oldest-first tiebreak, and the stray is ignored. If + production has no store yet, the stray verifies as `empty` and is adopted — + which is **correct**: an empty state database on production's own Branch is + exactly what production's bootstrap would have created. Either way there is + no corruption and no wrong adoption. The real cost is junk: one stranded + database per failed attach, each holding a quota slot (§ Resolved questions, + OQ-1). That is worth accepting for a failure that requires the platform's + own attach to fail after its own pre-check passed. + + Then mint a connection (§ below) and return — a brand-new database needs no + ownership check (only this run can have touched it; `migratePrismaState` + writes the marker on first use). Log to stderr using this wording: + `` `hosted state: provisioned state database ${databaseId} on branch ${branchId} (project ${projectId})` ``. +4. **One or more candidates → verify, oldest first.** Sort by `createdAt` + ascending (add `createdAt` to the summary selection; the list response + carries it). For each: mint a connection, run `verifyOwnership`: + - `ours` → adopt. + - `empty` → adopt (freshly provisioned earlier run that died before + migrating). + - `legacy` → adopt (cannot occur on a per-branch DB in practice — legacy + shape lives in the workspace store — but adoption is harmless and keeps + the verifier unchanged). + - `squatter` → record and skip. + Log on adopt: + `` `hosted state: using state database ${databaseId} on branch ${branchId} (${verdict.kind}) — ${candidates.length} candidate(s) named ${STATE_DATABASE_NAME}` ``. +5. **All candidates squatters → fail** with a message naming every rejected + database id and its foreign tables, and the remedy: + `` `found N database(s) named "prisma-composer-state" on branch ${branchId}, but none verified as Composer's state store: . Rename or remove the offending database(s).` `` + Never create a second same-named database next to a squatter. +6. **Connection minting and GC** — unchanged in mechanism + (`mintConnection` fresh per run reading `endpoints.direct.connectionString` + only; `cleanupAgedConnections` best-effort against the resolved state + database id). + +The layer sequence in `prismaState()` after bootstrap is byte-for-byte today's: +pool (`max: 5`), finalizer, `migratePrismaState` with the 2-minute +fresh-database retry schedule, `acquireStateLock(sql, stack.name, stack.stage)`, +lease-guarded service, `Layer.orDie` with `hostedStateBootstrapError` wrapping. +The bootstrap-error step strings change to name the new steps +(`'resolving the state database on the stage branch'` for steps 1–5). + +### Destroy ordering (CLI `main.ts` + lowering) + +New exported operation in lowering (`state/` or next to `container.ts` — +implementer's choice of file, the seam is what's fixed): +`deleteStateDatabase(input: { projectId: string; branchId?: string })`, which: + +1. Resolves the branch id exactly as bootstrap step 1. +2. Finds candidates exactly as bootstrap step 2, sorted as step 4. +3. For each candidate: mint connection, `verifyOwnership`; on `ours`, + `legacy`, or `empty` → `DELETE /v1/databases/{databaseId}` (tolerate 404), + log `` `removed state database ${databaseId} from branch ${branchId}` ``, + and continue through remaining candidates (duplicates from a crashed run + are all ours to remove). `squatter` → leave it, log a warning naming it. +4. Zero candidates → no-op success (idempotent; a retried destroy after a + partial run lands here). + +Ownership verification before deletion is **mandatory** — deleting by name +alone would destroy a user database that happens to share the name, which is +exactly the guessing ADR-0005 bans. + +`main.ts` step 9's destroy tail becomes: + +``` +if destroy && exit 0: + named stage: await deleteStateDatabase({ projectId, branchId }) // throws CliError on failure + await deleteStageBranch({ branchId }) // unchanged + production: await deleteStateDatabase({ projectId }) // warn-and-continue on failure + await deleteAppProject({ projectId }) // unchanged +``` + +Failure semantics, deliberately asymmetric: + +- **Named stage: throw.** If the state DB can't be deleted, `deleteStageBranch` + would fail anyway (live member, fact 5); failing at the state step names the + actual cause. The command is retryable end-to-end: re-run destroy → alchemy + destroys nothing (state already empty or DB absent → bootstrap provisions an + empty store, destroy over empty state is a no-op) → state delete no-ops or + completes → branch delete proceeds. Every crash window between alchemy + success and branch deletion converges on retry. +- **Production: warn and continue.** `deleteAppProject` is already best-effort + ("failing the command over a cleanup step would be worse than leaving a + Project shell"); the state step inherits that stance. If state deletion + failed, `deleteAppProject` reports "Kept the Project — it still has another + stage's resources", which is now accurate in spirit; the warning from the + state step tells the operator what actually remains. + +Ordering rule, recorded in the ADR: **the CLI deletes state last among the +stage's members it destroys itself, and before the container** — Alchemy +destroy reads state, so state must outlive every managed resource; the +container delete refuses while state remains. The platform's own teardown +paths are the opposite (state DB deleted like any child, in arbitrary order) — +correct for them, because they enumerate platform children and never read +Composer state, and deleting the state DB early severs in-flight deploys' +locks (the kill-switch property). + +### What is deliberately unchanged + +- **Store schema and lock** (see inventory table) — including the redundant + `stack`/`stage` columns and lock key. +- **`ensure-containers` deploy path** — the default stage still ensures only + the Project (ADR-0024 addressing unchanged); resource lowering still omits + `branchId` for production (the platform's implicit default Branch receives + them, fact 1). Only *state* resolves the default branch explicitly, read-only. +- **Credentials contract** — `PRISMA_SERVICE_TOKEN` everywhere; + `PRISMA_WORKSPACE_ID` still required by the CLI (`ensure-containers` + resolves the Project by name within the workspace); it is only the *state + layer* that stops needing it. +- **`transient.ts` / non-hosted stores** — untouched (ADR-0011: targets supply + the state layer; only what `prismaState()` constructs changes). + +### Legacy workspace store: manual cutover, no migration code + +Decision: **no automated migration.** The store is dogfood-stage; the only +known real deployment is the datahub port (forcing-function-apps S4, not yet +cut over to production use). Redeploying an existing app under the new store +with empty state would make providers re-create live resources (duplicates / +409s), so the documented cutover is: + +1. On the **old** framework version: `prisma-composer destroy` each stage, + then `--production` (tears down resources using the workspace store). +2. Upgrade the framework. +3. Deploy again (fresh state provisions per branch). +4. Delete the now-idle `prisma-composer-state` Project(s) from Console at + leisure — nothing reads them after the upgrade. + +The `legacy` ownership verdict and `migratePrismaState`'s marker-on-adopt +behavior are retained solely so an in-place workspace-store database never +breaks mid-transition; they are not a migration path. This procedure goes in +the deploying guide's upgrade note (S1). + +### Teardown-path matrix (the containment audit, recorded for the ADR) + +| Path | Mechanism | Outcome | +| --- | --- | --- | +| CLI `destroy --stage` | alchemy destroy (reads state) → delete state DB → delete Branch | Correct; every crash window retry-converges | +| CLI `destroy --production` | alchemy destroy → delete state DB (warn-only) → best-effort project delete | Correct; project shell only if state delete failed, with a warning naming it | +| **Webhook branch delete** (successor project) and the **idle-preview reclaim cron** — both call `tearDownBranchByGitName` | soft-deletes the Branch row directly (bypassing the guarded delete), then enumerates and deletes apps, versions, non-default databases (incl. state), and non-production `ConfigVariable`s; cron backstop for stragglers | **Correct by construction** — verified live 2026-07-17. No Composer involvement; severed lock kills in-flight deploys within the lease window. This is the row the containment argument rests on. | +| **Console branch delete** — calls the guarded `deleteBranch` | `branch.repository.prisma.ts:122-131`'s `updateMany` requires `databases: { none: {} }` and `apps: { none: { deletedAt: null } }` | **Refuses (409 "Branch has live members")** — corrected 2026-07-17; the original row wrongly claimed this path cascades. Our state database is a database, so a deployed stage refuses here where a compute-only stage previously would not. Safe (nothing is orphaned), not silent, and the remedy is `destroy --stage`. But "delete the preview from Console" is not a working teardown for a Composer stage, and the ADR now says so. | +| Console project delete | refuses while deployments active; deletes all tenants incl. every state DB | Correct by construction | +| Workspace delete | cascades projects | Correct | +| User manually deletes a state DB | resources orphaned from state; next deploy provisions fresh empty state and re-creates resources (duplicates/conflicts possible) | Same exposure class as today's deletable state project, smaller blast radius (one stage). Accepted; platform ask filed for a framework-owned/protected flag (platform-ask.md) | +| Branch/Project deleted while a deploy is in flight | teardown deletes state DB → lock lease check fails within its trust window → deploy halts | Bounded, accepted; full policy work belongs to the successor project | +| Resources provisioned outside Prisma Cloud (future non-platform extensions) | platform teardown deletes the state DB — the only record of external resources | **Documented limitation** in the ADR: platform-side teardown covers platform resources only; graphs with non-platform resources must be destroyed via the CLI | + +### Resolved questions (answered from source 2026-07-17; no PDP ask needed) + +Both were answered by reading the newer pdp-control-plane checkout in this +worktree (`e79d07bd8`). `assets/pdp-asks.md` is retained only as the record of +what was asked and why; nothing is outstanding with the PDP team. + +- **OQ-1 — quota: real; billing: negligible.** + - *Quota:* the plan's `createDatabase` offer is a **workspace-wide database + cap**, counted as `prisma.database.count({ where: { project: { + organizationId: workspaceId } } })` — every database in every project of + the workspace, so each stage's state database consumes one slot. Limits: + **50** (free — `FREE_PLAN_DATABASE_LIMIT`), **1000** (starter/pro/business/ + enterprise — `PAID_PLAN_DATABASE_LIMIT_DEFAULT`), 5000 (partner entry); + `packages/billing/src/domain/limits/constants.ts`, enforced in + `services/management-api/models/helpers/offerLimitHelpers.ts`. + - *Billing:* `createDatabase` carries a **limit but never a price** in any + plan — there is no per-database fee or floor. Postgres bills on usage: + storage in GiB-hours ($0.00278/GiB-hour beyond 720 GiB-hours ≈ 1 GiB for a + 30-day month) and queries ($0.0018 beyond 100k/cycle). A state database + holding a few rows of JSON, queried only during deploys, rounds to + nothing. + - *Conclusion:* the cost of a per-stage state database is **a quota slot, not + money**. Relevant only to a free-plan workspace running many concurrent PR + stages (50 databases total, and the app's own databases compete for the + same pool). Worth a line in the ADR's consequences, not a design change. +- **OQ-2 — only active compute deployments block project deletion.** + `deleteProject` (`packages/interactors/src/project/deleteProject.ts`) checks + auth, calls `guardNoActiveDeployments(projectId)`, syncs Stripe usage, + **deletes every one of the project's database tenants itself**, then calls + `projectRepository.deleteWithGuard`, which re-checks for a live `Deployment` + (ComputeVersion) on any of the project's Apps, soft-deletes the Apps, and + hard-deletes the Project row (children cascade). **Neither Branches nor + databases — default or not — ever block it**; the schema comment on + `App.branchId` says so outright ("Project hard-delete now cascades through + ComputeService so branch membership does not block it"). The 400 that + `deleteAppProject` relies on comes from *another stage's live compute + versions*, which is exactly the semantic it wants. + + **This corrects an earlier claim of mine** ("the production state DB is + always a dependency, so empty projects would accumulate") — false. A project + delete would take the state database with it. The production state-delete + step therefore stays, but for a different reason: when the project delete is + *refused* because another stage is live, production's state database would + otherwise outlive production and hold a quota slot. It is tidiness plus + quota, not a precondition. The named-stage step **is** a precondition and is + unaffected: `DELETE /v1/branches/{branchId}` genuinely refuses a Branch with + live members (`Database.branchId` is `onDelete: Restrict`). + +## The latency cost, measured (CI, 2026-07-17) + +The design listed "first-deploy-of-stage latency for provisioning it" as an +unquantified cost. Measured on the passing CI run: + +``` +12:44:30.8 prisma-composer deploy starts +12:44:37.1 hosted state: provisioned state database … ← 6.3s, whole bootstrap +12:44:57.4 Done: 20 succeeded ← 27s, whole deploy +``` + +**6.3 seconds** for the entire state bootstrap on a stage's first deploy — +resolve the Branch, list candidates, create the database, mint a connection, +migrate the schema, take the lock. Not the minute-plus I claimed. `pn-widgets` +end to end: **89s on this branch vs 92s on main**. There is no measurable +regression; the branch is marginally faster, which is noise. + +**How I got this wrong, because the mistake is instructive.** Two e2e runs +timed out at exactly 3m22s. I read "52 seconds" as the state-database +provisioning time — it was the time from *job* start, which is checkout, +install and build, work both branches do identically. The deploy command hadn't +even started. I then built a causal story on that number ("two databases +provisioned end to end, roughly a minute each"), wrote it into ADR-0034's +consequences, raised the CI budget from 3 to 8 minutes to accommodate it, and +told the operator the decision had a per-preview latency cost. All of it false. + +What actually happened: **a platform degradation window**. Three different +branches died between 12:18 and 12:26 — `spi-inversion` (failure), this branch +(timeout), `streams-minted-key` (failure) — and every run from 12:37 onward +succeeded, including this one. My two timeouts sat inside that window. The +timeouts were environmental and had nothing to do with this change. + +The commit built on that story (`1e41f0e`) was dropped rather than reverted, so +the false claim never enters the ADR's record. The CI budget stays at 3 +minutes, which the measurements say is correct. + +**The lesson is the same one this project has now taught four times**, twice +against me: I asserted a cause from a number I hadn't checked the provenance +of. The implementer caught it on the endpoint contract, the reviewer caught it +on atomicity, and here CI caught it by passing. The pattern to break: when a +number supports a story, find out what the number measures *before* writing the +story down. + +## The e2e budget is marginal — a real issue, separate from this change + +Worth filing on its own honest evidence rather than smuggled in here: the 3 +minute e2e budget has little headroom (the storefront-auth job took 119s of its +180s on a healthy run), so a slow platform window fails multiple branches at +once rather than running slow. That is a pre-existing flakiness question about +CI budgets and platform variance. It deserves its own change, with the +degradation-window data above as the argument — not a rationale invented to fit +a misdiagnosis. + +## What the live run taught us that the tests could not (D3, 2026-07-17) + +All four Project-DoD conditions passed against the real workspace, and it was +left clean. Three findings the unit tests could never have produced: + +1. **There are two platform Branch-delete paths, not one** — see the teardown + matrix. This corrected a wrong row in an already-approved ADR. The claim + "platform-side teardown cleans state up" is true of the cascading path + (webhook, reclaim cron) and false of the guarded one (Console), which + refuses instead. Found only because the live run tried the Console's path + and got a 409. +2. **A Branch's configuration and its state must die together.** Deleting a + Branch's children by hand — resources and state, but not its preview-class + `ConfigVariable`s — leaves variables whose `branchId` points at a deleted + Branch, and the next deploy of that stage **fails**: it finds a reserved + `COMPOSER_*` variable untracked in its (fresh) state and refuses to + overwrite it. That refusal is correct — it is the guard doing its job — and + the real cascading teardown deletes the config vars, so no platform or CLI + path reaches this state. Only manual API surgery does. Recorded because the + coupling is invisible until you break it. +3. **A failed deploy strands its containers.** `ensureContainers` runs *before* + preflight, so a deploy that fails preflight (e.g. a missing `envParam`) has + already created the Project, Branch, and default database. This is ordering, + not luck, and it predates this slice — but it means "a failed deploy costs + nothing" is false, and the quota arithmetic in § Resolved questions (OQ-1) + should assume failed attempts leave residue until someone cleans it. + +Also observed and worth keeping: the documented convergence window is real. A +`destroy --production` against a project that never had a production deploy +provisions a state database purely so it can destroy over empty state and +delete it seconds later. Correct, and cheap; noted so nobody reads it as a bug. + +## Known limit of the test suite (recorded, accepted) + +The residual accepted in § Bootstrap step 3 — the platform's attach failing +after its own create succeeded, stranding an empty database on the default +Branch — **cannot be regression-tested as the fake stands.** The fake models +`POST /v1/databases` as one step, because from the client's side it *is* one +call; the two-step is the platform's business. So no test reaches the +failed-attach outcome. That is the right shape for a fake of a client-visible +API, and inventing a two-phase fake to chase a failure mode we cannot trigger +from the client would be modelling the platform's internals in our suite. +Recorded because the gap is real and someone should know it exists before +assuming green tests cover this path. If the residual ever needs pinning, the +fake would have to model create-then-attach with an injectable attach failure. + +## Follow-ups (out of scope here, worth their own change) + +- **`postgres/Database.ts` has the same stray-database window this slice + rejected for the state store.** The user-facing database provider creates + through the project-scoped endpoint and then attaches with + `PATCH /v1/databases/{databaseId}` `{ branchId }`. By fact 3a the database is + born on the project's **default** Branch, so a failed `PATCH` on a named-stage + deploy strands a user database on production's Branch. The flat + `POST /v1/databases` would create it attached in one call. Not fixed here: + resource lowering is explicitly out of this slice's scope, and the fix wants + its own tests and its own review. +- **`errors.ts`'s error carries one identifying field.** After the `target` + rename it is honest, but a bootstrap failure still cannot say *which* of + project/branch/database it was resolving beyond the step string. Fine for + now; worth revisiting if operators report confusion. +- **`scripts/ci-cleanup-utils.ts` protects the wrong thing after cutover.** It + exempts a *project* named `prisma-composer-state` from CI cleanup. That + project is the retired workspace store; the live store is now a database + inside each app's own project. The exemption is harmless but guards a ghost, + and CI cleanup that deletes app projects will now take their state databases + with them — which is correct and is the whole point, but nobody has looked + at whether CI cleanup ordering assumes otherwise. Worth one pass. + +## Key decisions log + +1. **Containment model** (operator, 2026-07-17): one branch holds its own + state, production included; delete the branch → stage and state go + together; delete the project → everything goes. +2. **Production state rides the implicit default Branch** (operator + PDP + correction, 2026-07-17): no branchless special case exists on the platform; + no Composer-side production Branch creation either — resolve, never create. +3. **CLI owns explicit state-DB deletion; platform delegation later** + (operator, 2026-07-17): "We can add special handling for it in the CLI... + we can delegate deleting the branch to the management API in future." +4. **Schema/lock unchanged** (agent proposal, operator-unchallenged): redundant + keys are cheaper than a store rewrite and keep the diff reviewable. +5. **No migration code; manual cutover** (operator-ratified 2026-07-17). +6. **GitHub App integration explicitly out of scope**, recorded as the + successor project in [plan.md](plan.md) § Successor project. +7. **The state database is created in one call against the flat + `POST /v1/databases`, never a client-side create-then-`PATCH`** + (orchestrator, 2026-07-17, after the implementer surfaced the endpoint + error; **reason corrected the same day** after the reviewer read the route). + The decision stands; the first justification for it did not. I claimed the + flat endpoint "attaches at birth and closes the window entirely" — it does + not. It is a server-side create-then-attach with a Branch pre-check, so the + window narrows rather than closing. The decision survives on what is + actually true: one request instead of two, the Branch validated before the + row exists, no client-crash window, and a residual failure that costs a + stranded empty database (a quota slot) rather than any corruption — see + § Bootstrap step 3. **Lesson worth carrying:** I asserted atomicity from an + endpoint's *input shape* without reading its body. Twice now this one + endpoint has punished reading the schema instead of the implementation. +8. **`errors.ts`'s `workspaceId` field is renamed to `target`** (orchestrator, + 2026-07-17). Pinning a file unchanged does not license printing a false + noun at an operator. + +## References + +- ADR-0009, ADR-0010, ADR-0011, ADR-0012, ADR-0023, ADR-0024 + (`docs/design/90-decisions/`) +- `packages/1-prisma-cloud/0-lowering/lowering/src/state/{bootstrap,layer,schema,lock}.ts` +- `packages/1-prisma-cloud/0-lowering/lowering/src/{container,postgres/Database}.ts` +- `packages/0-framework/3-tooling/cli/src/{main,ensure-containers,run-alchemy}.ts` +- pdp-control-plane: `services/management-api/routes/v1/databases.ts`, + `packages/interactors/src/branch/{resolveDefaultOrPinnedBranchId,tearDownBranchByGitName}.ts`, + `packages/interactors/src/scm/linkProjectToScmRepo.ts`, + `packages/interactors/src/project.ts`, + `services/github-webhook/webhook-handlers/{handleDelete,deprovisionBranchDatabases}.ts` +- Discussion transcript: `/Users/will/claude-other/rendered/0feb1bb2-7810-4fd6-9cbb-7d348f8c32f0.md` diff --git a/.drive/projects/state-under-branch/plan.md b/.drive/projects/state-under-branch/plan.md new file mode 100644 index 00000000..96bf911c --- /dev/null +++ b/.drive/projects/state-under-branch/plan.md @@ -0,0 +1,145 @@ +# State Under Branch — Project Plan + +## Summary + +**One slice** — ADR-0034 and its implementation in one PR +([#113](https://github.com/prisma/composer/pull/113)). The binding design it +executes is [design-notes.md](design-notes.md). + +This started as two strictly-sequenced slices (ADR first, then code) on the +reasoning that principles bind until an ADR supersedes them. That was wrong: +it produces a docs-only PR, which delivers nothing on its own and costs a +review cycle to say so. Operator direction, 2026-07-17: *"Don't separate docs +and implementation. A docs PR on its own is useless."* The ADR half is already +written, reviewed, and approved on #113; the implementation lands on the same +branch before it merges. + +**Spec:** [spec.md](spec.md) · **Design notes:** [design-notes.md](design-notes.md) + +## Tracker + +Slices are identified by their S-number here; this plan is the source of +truth. Linear issues are created per-slice when the slice starts, not during +planning. Tracker project: +[Prisma Composer: State Under Branch](https://linear.app/prisma-company/project/prisma-composer-state-under-branch-5754597a6981). + +## External dependencies + +- ~~**PDP team answers**~~ — both resolved from source 2026-07-17, no ask + outstanding; see design-notes § Resolved questions. Quota is a real + constraint (a state DB per stage takes one of the workspace's 50 free / + 1000 paid database slots); billing is negligible (no per-database fee). + Project deletion is blocked only by active compute deployments — never by + Branches or databases. +- **`@prisma/management-api-sdk` types** — the implementation needs `branchId` + + `region: 'inherit'` admitted on database create (or falls back to the + documented create-then-PATCH; a stale region union requires an SDK update, + never a hard-coded region). Verified against the live API source; only the + generated types may lag. + +## The slice — State under Branch (TML-3049, PR #113) + +Two halves of one PR, in this order on the branch. + +### Half 1 — ADR-0034 + documentation corrections — DONE, APPROVED + +ADR-0034 ("Deploy state lives in the stage's Branch"); ADR-0009 marked +superseded; the consequence note on ADR-0010 (lock scoped within the per-stage +DB; severed-lock kill-switch property; redundant key retained) and the +correction note on ADR-0024 (resources land on the platform's implicit default +Branch; addressing model unchanged); the cutover note in +`docs/guides/deploying.md`; `docs/design/10-domains/deploy-cli.md`'s state +section and the ADR index. The staleness sweep also reached ADR-0011, +ADR-0023, ADR-0030, `layering.md`, and `alchemy-lowering.md`. + +### Half 2 — Per-branch state bootstrap + destroy ordering + +Implement design-notes § The design, exhaustively: bootstrap rework in +`packages/1-prisma-cloud/0-lowering/lowering/src/state/`, `prismaState()` env +contract change, `deleteStateDatabase`, and the CLI destroy-tail ordering in +`packages/0-framework/3-tooling/cli/src/main.ts`. Tests reworked/added per +[slices/per-branch-state/spec.md](slices/per-branch-state/spec.md). + +- **Builds on:** half 1, already on the branch. +- **Hands to:** project close-out; the successor project's precondition + (project-scoped state) is now true. + +Nothing here merges until both halves are on #113 and green. + +## Successor project (recorded, not started): decouple the CLI from Prisma Cloud + +Operator-directed 2026-07-17, after PR #113's review found Prisma Cloud +semantics in the framework domain. The pragmatic call for #113 was to ship the +`teardown` hook mirroring `preflight` — pattern-consistent, no new concept — +and do the real decoupling separately. That project's scope, from the sites +that leak today: + +- `core/app-config.ts`: `PreflightInput`/`TeardownInput` carry + `projectId`/`branchId` — replace with a target-owned opaque context + produced by container resolution and passed through core untyped. +- `cli/ensure-containers.ts`: Project/Branch resolution via the Management + API — the "known debt" named by `architecture.config.json`'s + cross-domain exception; ADR-0017's config-driven model absorbs it (the + target's descriptor supplies the container-resolution step). +- `cli/run-alchemy.ts`: sets `PRISMA_PROJECT_ID`/`PRISMA_BRANCH_ID` on the + child — becomes target-owned env the extension contributes. +- Exit criterion: the `crossDomainExceptions` entry for `cli → lowering` is + deleted and `lint:deps` still passes. + +## Successor project (recorded, not started): Compute GitHub App integration + +Out of scope for this project by operator decision (2026-07-17), to be opened +as its own Drive project afterwards. Recording the settled inputs so shaping +starts warm: + +**Goal.** Push-to-deploy for Composer repos through pdp-control-plane's +existing GitHub App pipeline (webhook → build-runner → E2B sandbox), with the +sandbox running the user's declared build + `prisma-composer deploy` instead +of the `BuildStrategy` pipeline. + +**Already settled in discussion** (transcript +`0feb1bb2-7810-4fd6-9cbb-7d348f8c32f0` + continuation): + +- The webhook service, repo↔Project link (`ProjectScmRepo`), Branch + resolution, `Build` row lifecycle, sandbox spawning, and log streaming are + reused unchanged; the runner's kickoff/finalize legs (App find-or-create, + ComputeVersion + presigned upload, Foundry start/promote) are skipped for + Composer builds — the CLI performs versions/promote itself. +- Composer detection must be deterministic (repo-link flag or + `prisma-composer.config.ts` presence), never inferred. +- `Build.appId`/`deploymentId` stay null for Composer builds; + `computeProjectId` is the scope; two pdp consumers need re-scoping + (failure-email cron, setup-activation notifications). +- Branch-delete teardown needs no Composer involvement — this project's + containment model makes the platform's existing enumerate-and-delete + correct, and deleting the state DB severs in-flight deploys' locks. + +**Open design points carried forward** (the hard ones first): + +1. Per-build credential: short-lived **project-scoped** service token minted + by the control plane into the sandbox — machinery and Management API + scoping support don't exist yet (PDP dependency). +2. Where the `{ entry, build command }` declaration lives (Composer-side ADR; + ADR-0017 currently forbids app settings in `prisma-composer.config.ts`). +3. Supersede-vs-lock ordering: runner must kill the old sandbox (dropping its + lock connection) before dispatching the successor, plus a short acquire + retry. +4. Pinned-id deploy mode: CLI accepts pinned Project/Branch ids and fails + rather than find-or-creates (closes the delete/push resurrection race — + GitHub webhook ordering is not guaranteed). +5. Stage seeding policy for `envParam`/`envSecret` on first webhook deploy of + a fresh stage (likely: copy production's platform variables, else fail with + a "set these in Console" build error). +6. Restrict webhook-managed apps to platform-backed extensions until a + pre-teardown destroy-run exists (external-resource limitation). + +## Close-out (required) + +- [ ] Verify all acceptance criteria in [spec.md](spec.md) § Project-DoD +- [ ] Migrate long-lived docs into `docs/` (S1 lands them; verify nothing + else accrued — the teardown matrix and cutover note must live in + `docs/`, not here) +- [ ] Open the successor project from § Successor project above (or + explicitly defer it with the operator) +- [ ] Strip repo-wide references to `.drive/projects/state-under-branch/**` +- [ ] Delete `.drive/projects/state-under-branch/` diff --git a/.drive/projects/state-under-branch/slices/adr-supersession/spec.md b/.drive/projects/state-under-branch/slices/adr-supersession/spec.md new file mode 100644 index 00000000..24a495a8 --- /dev/null +++ b/.drive/projects/state-under-branch/slices/adr-supersession/spec.md @@ -0,0 +1,80 @@ +# S1 — ADR-0034 + documentation corrections + +Docs-only slice. Records the settled decision so S2's code is +principle-compliant when it lands. The content source is +[design-notes.md](../../design-notes.md) — the ADR is a transcription into the +repo's ADR voice, not a re-derivation; where design-notes and this spec are +silent, the repo's existing ADR conventions decide, nothing else. + +## At a glance + +One PR touching only `docs/`: + +1. **New `docs/design/90-decisions/ADR-0034-deploy-state-lives-in-the-stage-branch.md`.** + Sections and required content: + - **Decision** — the containment rule (design-notes § The decision), naming: + per-stage database `prisma-composer-state` attached to the stage's + Branch; production on the implicit default Branch; workspace store + retired. + - **Rationale** — the supersession case verbatim in substance + (design-notes § Why the inputs changed): circularity dissolved by + ADR-0023/0024, fragmentation moved to the platform, two new forces + (teardown correctness, credential scoping). Must state explicitly this + supersedes ADR-0009 because its inputs changed, not because its reasoning + was wrong. + - **The ordering rules** — CLI deletes state last-among-members and + before the container; platform teardown deletes it like any child, and + the severed lock is a kill switch (design-notes § Destroy ordering, + § teardown matrix). + - **The teardown-path matrix** (design-notes § Teardown-path matrix), + including the documented limitation: platform-side teardown covers + platform resources only. + - **Consequences** — per-stage DB cost (OQ-1 noted), state DB visible in + Console per stage (platform ask: protected flag), manual cutover for the + legacy store, ADR-0010/0011/0012 survive. +2. **ADR-0009** — status line → superseded by ADR-0034 (repo's existing + supersession convention). +3. **ADR-0010** — consequence note: lock now lives in the per-stage database; + `(stack, stage)` key retained though redundant; state-DB deletion severs + the lease (kill-switch property). +4. **ADR-0024** — correction note: "lives at the Project level" describes + Composer's addressing (no Branch ensured or named); physically the platform + attaches default-stage resources to the Project's implicit default Branch + (post-#3902 invariant, confirmed with PDP 2026-07-17). +5. **`docs/design/90-decisions/README.md`** — index entry for ADR-0034. +6. **`docs/guides/deploying.md`** — upgrade note with the 4-step manual + cutover (design-notes § Legacy workspace store). +7. **`docs/design/10-domains/deploy-cli.md`** — state section updated: where + state lives, destroy-tail ordering. + +## Coherence rationale + +One reviewer, one sitting: a single new ADR plus five surgical edits, all +tracing to one design record. Rollback is one revert. + +## Scope + +**In:** the seven document changes above. **Deliberately out:** any code; +platform-ask.md additions beyond the protected-DB flag mention if the file +convention requires an entry (implementer checks `platform-ask.md`'s existing +format and adds the protected-flag + reserved-name asks there if that is where +asks live). + +## Slice-specific done conditions + +- ADR-0034 contains the ordering rules and teardown matrix (S2's tests cite + them). +- No document still describes the workspace store as current except ADR-0009 + itself, which is marked superseded. + +## Dispatch plan + +Single dispatch (designer agent per repo convention, model per operator's +global rule for implementer/reviewer subagents does not apply to the designer +persona — use the designer agent as configured): + +1. **D1 — author the seven document changes.** Outcome: the PR-ready docs + diff. Builds on: design-notes.md (read-only source). Hands to: PR open. + Completed when: all seven items exist, `git grep -l "prisma-composer-state" + docs/` shows no doc presenting the workspace store as current outside + ADR-0009, and the ADR index lists 0033. diff --git a/.drive/projects/state-under-branch/slices/per-branch-state/spec.md b/.drive/projects/state-under-branch/slices/per-branch-state/spec.md new file mode 100644 index 00000000..4dc90a0b --- /dev/null +++ b/.drive/projects/state-under-branch/slices/per-branch-state/spec.md @@ -0,0 +1,108 @@ +# S2 — Per-branch state bootstrap + destroy ordering + +Implements [design-notes.md](../../design-notes.md) § The design, exhaustively. +The design doc is binding: where it specifies an algorithm step, message +string, failure semantic, or "unchanged" surface, the implementation follows +it without reinterpretation. Deviations discovered necessary during execution +go back through discussion mode, not into the diff. Requires S1 merged +(ADR-0034 exists). + +## At a glance + +One PR across two packages: + +- `packages/1-prisma-cloud/0-lowering/lowering/src/state/bootstrap.ts` — + replace workspace-project discovery with branch-scoped database + find-or-create (design-notes § Bootstrap, steps 1–6, including exact error + and log messages and the SDK-types fallback rule). +- `.../state/layer.ts` — `prismaState()` reads `PRISMA_PROJECT_ID` + (required) + `PRISMA_BRANCH_ID` (optional); `workspaceId` option deleted; + bootstrap-error step strings updated. +- New `deleteStateDatabase({ projectId, branchId? })` exported from lowering + (design-notes § Destroy ordering, steps 1–4; ownership verification before + deletion is mandatory). +- `packages/0-framework/3-tooling/cli/src/main.ts` — destroy tail gains the + state-delete step with the asymmetric failure semantics (stage: throw + `CliError`; production: warn-and-continue). +- `schema.ts`, `lock.ts`, `service.ts`, `errors.ts`, `transient.ts`, + resource lowering, `ensure-containers` deploy path: **unchanged** (the + review should verify the absence of changes here as much as the presence + elsewhere). + +## Coherence rationale + +One reviewer, one sitting: the diff is one algorithm swap plus one new +operation plus two-line-order changes in the CLI, all specified in a single +design section, with the test rework tracking the same seams. Rollback is one +revert (no schema or state migration involved — the store format is +unchanged). + +## Scope + +**In:** the code above; test rework (below); `pnpm run deploy`-based live +verification of the DoD scenarios that don't need PDP answers. +**Deliberately out:** docs (S1); migration tooling (manual cutover is +documented); any lowering/provider change; anything in § Successor project. + +## Pre-investigated edge cases + +(From the design discussion — knowledge the implementer's grep would not +surface.) + +- ~~The e2e noop assertion greps deploy output for bare create/update verbs.~~ + **False — it no longer exists** (deleted with the `makerkit-hello` example; + nothing in `.github/`, `scripts/`, `test/`, or `examples/` greps for this). + Follow design-notes' log phrasings anyway, because they read well and match + the surrounding code — but do not repeat the claim that a check enforces it. +- PDP allows duplicate names; a user database named `prisma-composer-state` + on the same branch must be skipped on adopt and left alone on delete + (squatter verdict), never adopted by name, never deleted by name. +- A freshly provisioned database can refuse connections for minutes — the + existing 2-minute migrate retry schedule must survive the rework untouched. +- `region: 'inherit'` and body `branchId` may be missing from the generated + SDK types even though the API supports them — fallback is create-then-PATCH + (mirroring `postgres/Database.ts`); a stale region union means updating the + SDK dependency, never hard-coding a region literal. +- Destroy retry semantics: every crash window between alchemy exit 0 and + container deletion must converge on re-run (design-notes documents each + window; the tests assert the two interesting ones). + +## Slice-specific done conditions + +- `bootstrap.test.ts` reworked to the new discovery (branch resolution, + candidate verdicts, squatter failure, default-branch-missing failure), and + `main`/`ensure-containers` tests cover the destroy tail ordering + both + failure semantics and idempotent re-run. +- Live round-trip per Project-DoD items 2–4 (named-stage zero-residue, + production state on default branch, Console-delete-then-redeploy) executed + against the dogfood workspace and evidence recorded in the PR. The + production `deleteAppProject` leg may be waived pending OQ-2 with an + explicit note. +- `git grep STATE_PROJECT_NAME` returns nothing. + +## Dispatch plan + +Sequential; implementer subagents on Sonnet-4.6-mid, reviewer on Opus-4.8-mid +(operator's standing rule). + +1. **D1 — bootstrap rework in lowering.** Outcome: `bootstrapStateConnection` + takes `{ projectId, branchId? }` and implements design-notes § Bootstrap + steps 1–6; `prismaState()` env contract changed; `STATE_PROJECT_NAME` + machinery deleted; `bootstrap.test.ts` reworked and green. Builds on: S1's + merged ADR. Hands to: a lowering package whose deploy path is fully + per-branch, destroy path not yet wired. Completed when: package tests + green; `git grep STATE_PROJECT_NAME` empty; no changes outside + `state/`. +2. **D2 — `deleteStateDatabase` + CLI destroy tail.** Outcome: the new + operation (design-notes § Destroy ordering steps 1–4) exported from + lowering; `main.ts` destroy tail ordered stage/production with asymmetric + failure semantics; CLI tests cover ordering, both failure modes, and + no-op-on-retry. Builds on: D1's bootstrap-shaped discovery (shared + resolution helpers). Hands to: feature-complete code. Completed when: CLI + + lowering tests green; the only `main.ts` diff is the destroy tail. +3. **D3 — live verification + review evidence.** Outcome: the three live DoD + scenarios executed against the dogfood workspace (credentials per + memory: copy `makerkit/.env`, `pnpm run deploy`), transcripts/evidence in + the PR body; full-repo checks green. Builds on: D2. Hands to: PR open. + Completed when: evidence for each scenario is recorded, or a scenario is + explicitly waived with the OQ-2 note. diff --git a/.drive/projects/state-under-branch/spec.md b/.drive/projects/state-under-branch/spec.md new file mode 100644 index 00000000..af93f11a --- /dev/null +++ b/.drive/projects/state-under-branch/spec.md @@ -0,0 +1,94 @@ +# Purpose + +Make deploy-state lifetime equal the lifetime of the environment it describes, +so that every teardown path — CLI destroy, Console branch/project deletion, +and (in the successor project) GitHub-webhook branch deletion — cleans up +state correctly **without the platform knowing Composer exists**. This removes +the one place where Composer state can outlive or orphan the thing it +describes, and it is the prerequisite that shrinks the future GitHub App +integration's sandbox credential from workspace-scoped to project-scoped. + +# At a glance + +Alchemy state moves from the workspace-level `prisma-composer-state` Project +(ADR-0009) into a framework-owned database named `prisma-composer-state` +attached to each stage's own Branch; production's attaches to the Project's +implicit default Branch. The CLI's destroy tail gains one explicit step — +delete the state database after `alchemy destroy`, before deleting the +Branch/Project. Everything else in the store (schema, lock, connection +minting, ownership verification) is retained. + +The full, binding design is in [design-notes.md](design-notes.md); it is +implementation-grade and the slices execute it without reinterpretation. + +# Non-goals + +- **The GitHub App / webhook integration.** Recorded as the successor project + in [plan.md](plan.md) § Successor project; nothing here depends on it. +- **A platform-side state API** (ADR-0009's declared end state). This project + proves the Branch-scoped shape the eventual API should inherit; it does not + build the API. Likewise no delegation of state-DB deletion to the + Management API's branch delete — noted as a future platform ask. +- **Automated migration from the workspace store.** Manual cutover only + (design-notes § Legacy workspace store). +- **Any change to resource lowering.** Production resources keep omitting + `branchId`; the platform's implicit default Branch receives them. Only the + state layer changes. +- **A platform "protected/framework-owned database" flag.** Filed as a + platform ask, not built. + +# Place in the larger world + +Supersedes ADR-0009; leaves ADR-0010 (lock), ADR-0011 (targets supply the +state layer), and ADR-0012 (store speaks SQL) standing with consequence notes +only. Corrects ADR-0024's description of where production resources +physically live (platform's implicit default Branch). Direct successor: +**Compute GitHub App integration** (see plan), which consumes this project's +containment guarantee for webhook-driven teardown and credential scoping. + +# Cross-cutting requirements + +- **No guessing (ADR-0005 discipline applied to state):** the state database + is found by branch-scoped listing + exact name + ownership-marker + verification; it is deleted only after the same verification; the default + Branch is resolved by `isDefault: true`, never inferred; no region literal + is hard-coded (`region: 'inherit'`). +- **Retry convergence:** every crash window in deploy bootstrap and in the + destroy tail must converge on command re-run (documented per-window in + design-notes § Destroy ordering). +- **Output wording:** state bootstrap/teardown log lines follow the existing + "provisioned"/"using"/"removed" vocabulary. (An earlier draft justified this + with an end-to-end noop assertion that greps deploy output for bare + create/update verbs. That assertion was deleted along with the + `makerkit-hello` example and no longer exists anywhere in the repo — the + vocabulary stays because it reads well and matches the surrounding code, not + because a check enforces it.) + +# Project-DoD + +- [ ] ADR-0034 merged; ADR-0009 marked superseded; consequence/correction + notes landed on ADR-0010 and ADR-0024; deploying guide carries the + upgrade cutover note. +- [ ] A named-stage deploy → destroy round-trip against a real workspace + leaves the workspace with **zero** residue: no state project, no state + database, no Branch, and (when it was the only stage) no Project. +- [ ] A production deploy places state on the implicit default Branch + (verified via the Management API), and `destroy --production` removes + the state database before the best-effort project delete. +- [ ] Deleting a stage's Branch from Console while nothing is in flight, then + re-deploying the same stage name, produces a working stage from fresh + state (the orphan-state scenario ADR-0009 could not handle). +- [ ] The workspace-level `prisma-composer-state` project is no longer + created by any code path. + +# Open questions + +Tracked in design-notes § Open questions: OQ-1 (per-stage DB quota/billing — +non-blocking), OQ-2 (project-delete dependency contract — blocking only for +the production-destroy leg of S2 sign-off). Both are asks on the PDP team, +owned by the operator. + +# References + +[design-notes.md](design-notes.md) · [plan.md](plan.md) · tracker: +[Prisma Composer: State Under Branch](https://linear.app/prisma-company/project/prisma-composer-state-under-branch-5754597a6981) diff --git a/architecture.config.json b/architecture.config.json index 3a5988c6..09e61244 100644 --- a/architecture.config.json +++ b/architecture.config.json @@ -228,6 +228,12 @@ "layer": "extensions", "plane": "control" }, + { + "glob": "packages/1-prisma-cloud/1-extensions/target/src/teardown.ts", + "domain": "prisma-cloud", + "layer": "extensions", + "plane": "control" + }, { "glob": "packages/1-prisma-cloud/1-extensions/target/src/s3-credentials-resource.ts", "domain": "prisma-cloud", diff --git a/docs/design/03-domain-model/layering.md b/docs/design/03-domain-model/layering.md index a675cd5a..209210a3 100644 --- a/docs/design/03-domain-model/layering.md +++ b/docs/design/03-domain-model/layering.md @@ -123,31 +123,35 @@ reproduce-in-the-emulator goal (see `../00-purpose/goals.md`). Provisioning runs through **Alchemy's engine**, invoked from the client or a privileged CD environment (see claim 3). The engine keeps a **state store** — the source of truth for what's provisioned. State sits on a spectrum from -local, to workspace-hosted, to eventually platform-run: +local, to branch-hosted, to eventually platform-run: - **Local** — Alchemy's local or Cloudflare-backed state. Fine for a solo developer; nothing else needs to see it. -- **Workspace-hosted** — a `StateService` implementation - (`@internal/lowering/state`) backed by a Prisma Postgres database in a - workspace-scoped project, native to the Workspace → Project → Environment - hierarchy (Pulumi/Terraform-Cloud-style hosted state, without the - BYO-state bootstrap). Bootstrap is automatic: the Management API finds or - creates the store's project and its default database on first use, so a - deployer needs nothing beyond the service token and workspace id it already - has. Concurrency is a per-`(stack, stage)` advisory lock, so two deployers - can never race the same stack. `prismaCloud()` supplies this as the default - deploy state for every service and Module; an explicit state layer always - overrides it. This is framework-owned operational infrastructure, not a - user-topology Resource — ambient per project, never declared by a Module - (which also sidesteps the chicken-and-egg of provisioning the store - itself). Like hosted-state backends generally, it also holds state for the - user's BYO resources in other clouds. +- **Branch-hosted** — a `StateService` implementation + (`@internal/lowering/state`) backed by a framework-owned Prisma Postgres + database in each stage's Branch of the app's own Project (ADR-0034), + native to the Workspace → Project → Branch hierarchy + (Pulumi/Terraform-Cloud-style hosted state, without the BYO-state + bootstrap). Bootstrap is automatic: the Management API finds or creates + the stage's state database from the container ids the CLI already + resolves, so a deployer needs nothing beyond the service token and + workspace id it already has, and the state's lifetime is the + environment's — deleting the Branch or Project deletes it. Concurrency is + a per-`(stack, stage)` advisory lock, so two deployers can never race the + same stack. `prismaCloud()` supplies this as the default deploy state for + every service and Module; an explicit state layer always overrides it. + This is framework-owned operational infrastructure, not a user-topology + Resource — ambient per stage, never declared by a Module (the containers + it lives in are created before the engine runs, which sidesteps the + chicken-and-egg of provisioning the store itself). Like hosted-state + backends generally, it also holds state for the user's BYO resources in + other clouds. - **Server-side runs** — the platform executes the apply loop itself - (git-push-style deploys). Once state is workspace-hosted, moving the engine + (git-push-style deploys). Once state is platform-hosted, moving the engine server-side is incremental — the same evolution Pulumi/Terraform Cloud followed. This step's platform surface is implementing Alchemy's own HTTP `StateApi` (bearer auth → workspace RBAC) as a Management API endpoint; once - it exists, the workspace-hosted store's visible project disappears and the + it exists, the branch-hosted store's visible databases disappear and the platform can answer "what's provisioned in this project" natively (the platform side of the inspectable-topology goal). diff --git a/docs/design/05-prisma-cloud/alchemy-lowering.md b/docs/design/05-prisma-cloud/alchemy-lowering.md index e9778fb8..b4f6efdc 100644 --- a/docs/design/05-prisma-cloud/alchemy-lowering.md +++ b/docs/design/05-prisma-cloud/alchemy-lowering.md @@ -87,17 +87,19 @@ named stage, its Branch (found by `gitName`, created if absent); `ensure: false` makes it find-only, for `destroy`. It reuses the same Management API client and the same adopt-oldest / tolerate-a-racing-409 idiom the state store's own bootstrap uses -([ADR-0009](../90-decisions/ADR-0009-deploy-state-is-hosted-in-the-workspace.md)) -— the two resolve different things (deploy containers vs. the state store's -own project) through the same client and idiom. `deleteBranch` soft-deletes -a stage's Branch once `destroy` has removed its members. +([ADR-0034](../90-decisions/ADR-0034-deploy-state-lives-in-the-stage-branch.md)) +— the two resolve different things (deploy containers vs. the stage's state +database) through the same client and idiom. Once `destroy` has removed a +stage's members, the CLI removes the stage's state database +(ownership-verified) and `deleteBranch` then soft-deletes its Branch. Deploy state keeps its existing shape — keyed per Alchemy `--stage` -(ADR-0009) — unchanged by this: under stage-as-branch, **the Project is the +(ADR-0034) — unchanged by this: under stage-as-branch, **the Project is the stack and the Branch is the stage** ([ADR-0023](../90-decisions/ADR-0023-a-prisma-app-is-one-project-a-stage-is-a-branch.md)), -so a stage's effective identity is the pair (Project, Branch). The state -store's location and internal logic are untouched. +so a stage's effective identity is the pair (Project, Branch), and the +stage's state database lives inside that same Branch. The store's internal +logic is untouched. ## The mapping, both directions diff --git a/docs/design/10-domains/deploy-cli.md b/docs/design/10-domains/deploy-cli.md index fce41d39..abc4c38d 100644 --- a/docs/design/10-domains/deploy-cli.md +++ b/docs/design/10-domains/deploy-cli.md @@ -115,12 +115,22 @@ targets **production**, at the Project level; `--stage ` targets a parent — *before* the ids exist — and again in the alchemy child, where they are set. So an extension's constructor must tolerate the ids being absent; only its lowering hooks (which run in the child) may require them. +- **State rides the Branch.** Each stage's deploy state lives in a + framework-owned `prisma-composer-state` database attached to the stage's + Branch — production's on the Project's implicit default Branch + ([ADR-0034](../90-decisions/ADR-0034-deploy-state-lives-in-the-stage-branch.md)). + The state layer bootstraps it from the threaded ids; the default Branch is + resolved read-only (`isDefault`), never created. - **Destroy is explicit.** `prisma-composer destroy` requires `--stage ` or `--production`; a bare `destroy` is an error, so an omitted or mistyped stage can never silently tear down production. `destroy` resolves find-only (no container is ever created); after `alchemy destroy` removes - a named stage's resources, the CLI soft-deletes its Branch. The production - Branch is never deleted. + a named stage's resources, the CLI removes the stage's state database + (ownership-verified, never by name alone) and then soft-deletes its Branch. + The production Branch is never deleted; destroying production removes the + production state database before the best-effort Project removal. State is + deleted **last among the stage's members and before its container**: destroy + reads state, and the platform refuses to delete a Branch with live members. See [ADR-0023](../90-decisions/ADR-0023-a-prisma-app-is-one-project-a-stage-is-a-branch.md) (App = one Project, Stage = Branch) and diff --git a/docs/design/90-decisions/ADR-0009-deploy-state-is-hosted-in-the-workspace.md b/docs/design/90-decisions/ADR-0009-deploy-state-is-hosted-in-the-workspace.md index f786e262..5fe8df24 100644 --- a/docs/design/90-decisions/ADR-0009-deploy-state-is-hosted-in-the-workspace.md +++ b/docs/design/90-decisions/ADR-0009-deploy-state-is-hosted-in-the-workspace.md @@ -1,5 +1,10 @@ # ADR-0009: Deploy state is hosted in the workspace, not in local files +> Superseded by +> [ADR-0034](ADR-0034-deploy-state-lives-in-the-stage-branch.md): state is +> still hosted, but lives in a framework-owned database in each stage's +> Branch, not in a workspace-level project. + ## Decision Deploy state — the provisioning engine's record of what exists in the cloud — diff --git a/docs/design/90-decisions/ADR-0010-deploys-hold-a-session-advisory-lock.md b/docs/design/90-decisions/ADR-0010-deploys-hold-a-session-advisory-lock.md index b4611d62..3e411348 100644 --- a/docs/design/90-decisions/ADR-0010-deploys-hold-a-session-advisory-lock.md +++ b/docs/design/90-decisions/ADR-0010-deploys-hold-a-session-advisory-lock.md @@ -2,8 +2,8 @@ ## Decision -A deploy acquires a Postgres session advisory lock on the hosted state -database ([ADR-0009](ADR-0009-deploy-state-is-hosted-in-the-workspace.md)), +A deploy acquires a Postgres session advisory lock on the stage's hosted +state database ([ADR-0034](ADR-0034-deploy-state-lives-in-the-stage-branch.md)), keyed by the application being deployed (its stack name and stage), and holds it on a dedicated connection for the whole run. A second deploy of the same application fails immediately with an error naming what is locked; it never @@ -94,6 +94,13 @@ operation's latency. - There is no queueing affordance yet. If waiting turns out to be the common want, a `--wait` flag can layer over the same lock without changing its semantics. +- The store is per-stage + ([ADR-0034](ADR-0034-deploy-state-lives-in-the-stage-branch.md)), so the + `(stack, stage)` key is redundant within any one database — kept anyway, + since removing it buys nothing — and deleting a stage's state database + severs the lock's session: an in-flight deploy of a stage being torn down + fails its lease check within the trust window instead of continuing to + provision into a deleted container. ## Alternatives considered @@ -114,7 +121,9 @@ operation's latency. ## Related -- [`ADR-0009`](ADR-0009-deploy-state-is-hosted-in-the-workspace.md) — the - shared store that makes the concurrent case real. +- [`ADR-0034`](ADR-0034-deploy-state-lives-in-the-stage-branch.md) — the + per-stage hosted store this lock lives in (superseding + [`ADR-0009`](ADR-0009-deploy-state-is-hosted-in-the-workspace.md), whose + shared store made the concurrent case real). - [`../03-domain-model/layering.md`](../03-domain-model/layering.md) — where state and its guarantees sit in the provisioning plane. diff --git a/docs/design/90-decisions/ADR-0011-targets-supply-the-deploy-state-layer.md b/docs/design/90-decisions/ADR-0011-targets-supply-the-deploy-state-layer.md index d906056c..4598446d 100644 --- a/docs/design/90-decisions/ADR-0011-targets-supply-the-deploy-state-layer.md +++ b/docs/design/90-decisions/ADR-0011-targets-supply-the-deploy-state-layer.md @@ -4,8 +4,9 @@ `Target.state` is a required field: every target constructs the Alchemy state layer its deploys use, and core resolves `opts.state ?? target.state()` with -no fallback of its own. The Prisma Cloud target supplies the workspace-hosted -store; a caller can still pin a specific layer per stack (CI, tests, air-gapped +no fallback of its own. The Prisma Cloud target supplies the branch-hosted +store ([ADR-0034](ADR-0034-deploy-state-lives-in-the-stage-branch.md)); a +caller can still pin a specific layer per stack (CI, tests, air-gapped work) through `opts.state`, which always wins. ## Reasoning diff --git a/docs/design/90-decisions/ADR-0023-a-prisma-app-is-one-project-a-stage-is-a-branch.md b/docs/design/90-decisions/ADR-0023-a-prisma-app-is-one-project-a-stage-is-a-branch.md index 8180d9f8..800d9817 100644 --- a/docs/design/90-decisions/ADR-0023-a-prisma-app-is-one-project-a-stage-is-a-branch.md +++ b/docs/design/90-decisions/ADR-0023-a-prisma-app-is-one-project-a-stage-is-a-branch.md @@ -97,6 +97,6 @@ down the environment that wrote it. - [ADR-0024](ADR-0024-a-stage-is-a-deploy-time-environment-resolved-to-project-and-branch.md) — how a deploy names a stage and resolves it to this Project + Branch. -- [ADR-0009](ADR-0009-deploy-state-is-hosted-in-the-workspace.md) — deploy state - hosted in the workspace; its keys map to (Project, Branch). +- [ADR-0034](ADR-0034-deploy-state-lives-in-the-stage-branch.md) — deploy state + hosted per stage in the Branch this mapping defines. - `docs/design/03-domain-model/glossary.md` — Stage → Environment. diff --git a/docs/design/90-decisions/ADR-0024-a-stage-is-a-deploy-time-environment-resolved-to-project-and-branch.md b/docs/design/90-decisions/ADR-0024-a-stage-is-a-deploy-time-environment-resolved-to-project-and-branch.md index fa5891c7..fbd1242c 100644 --- a/docs/design/90-decisions/ADR-0024-a-stage-is-a-deploy-time-environment-resolved-to-project-and-branch.md +++ b/docs/design/90-decisions/ADR-0024-a-stage-is-a-deploy-time-environment-resolved-to-project-and-branch.md @@ -29,7 +29,10 @@ The CLI resolves and ensures the two **containers** — the app's Project and th stage's Branch — before Alchemy runs; Alchemy provisions only the resources *within* them. The default stage (no `--stage`) is the production environment and lives at the Project level: phase 1 ensures only the Project, and no Branch -is created. +is created. That is an addressing statement, not a physical one: the platform +attaches every resource to a Branch, and a create call without a `branchId` +lands on the Project's **implicit default Branch** (every live Project owns +one). The framework never creates or names that Branch. ## Rationale @@ -85,7 +88,10 @@ derived from Branch presence, never from a role lookup. silently normalizing them. - **The default stage is the Project level.** No Branch is created and no Branch id threads through lowering. On a named stage, every resource the - target provisions carries the Branch id. + target provisions carries the Branch id. Resources provisioned without one + are attached by the platform to the Project's implicit default Branch; the + only framework code that resolves that Branch is the state layer + ([ADR-0034](ADR-0034-deploy-state-lives-in-the-stage-branch.md)), read-only. - **Destroy names its target explicitly.** `prisma-composer destroy` requires `--stage ` or `--production`; a bare destroy is an error, so an omitted or mistyped stage can never silently tear down production. Destroying a named @@ -116,8 +122,8 @@ derived from Branch presence, never from a role lookup. - [ADR-0023](ADR-0023-a-prisma-app-is-one-project-a-stage-is-a-branch.md) — App = one Project, Stage = Branch; the mapping this decision operationalizes. -- [ADR-0009](ADR-0009-deploy-state-is-hosted-in-the-workspace.md) — the - workspace-hosted deploy state this keying lands in. +- [ADR-0034](ADR-0034-deploy-state-lives-in-the-stage-branch.md) — the + per-branch hosted deploy state this keying lands in. - [ADR-0003](ADR-0003-deploy-derives-everything-from-the-root-node.md) — deploy derives everything from the root node; app name → Project extends it. - `docs/design/03-domain-model/glossary.md` — Stage → Environment. diff --git a/docs/design/90-decisions/ADR-0030-rpc-callers-verified-with-an-auto-provisioned-service-key.md b/docs/design/90-decisions/ADR-0030-rpc-callers-verified-with-an-auto-provisioned-service-key.md index aea7ace7..30c61eab 100644 --- a/docs/design/90-decisions/ADR-0030-rpc-callers-verified-with-an-auto-provisioned-service-key.md +++ b/docs/design/90-decisions/ADR-0030-rpc-callers-verified-with-an-auto-provisioned-service-key.md @@ -26,7 +26,7 @@ connection parameter on the RPC dependency (`serviceKey`, alongside `url`), serialized to a reserved `COMPOSER_*` environment variable and hydrated into the client through the framework's host shim — the developer's code never reads the environment (see the *No globals* principle). Its value is minted at deploy and -kept in the workspace-hosted deploy state store, so it is stable across +kept in the hosted deploy state store, so it is stable across redeploys and never appears in the Prisma Cloud project's own variable list. ## Reasoning @@ -60,10 +60,11 @@ different in kind. It is not a credential protecting data at rest; it is a per-deployment capability token whose only job is to separate a wired peer from an anonymous caller over an already-encrypted channel. That lower bar lets the framework do the thing ADR-0029 forbids for secrets: **mint the value itself and -keep it in deploy state.** The deploy state store is the workspace-hosted -`prisma-composer-state` project (ADR-0009) — not the user-facing Prisma Cloud -project — so the value stays out of the surface a developer reads, and "transient -state for this deployment" is exactly what it is. +keep it in deploy state.** The deploy state store is the framework-owned +`prisma-composer-state` database in the stage's Branch (ADR-0034) — not a +resource a developer declares or reads — so the value stays out of the surface +a developer works with, and "transient state for this deployment" is exactly +what it is. **Why the env-var rail, not the artifact.** The value has to reach both running instances. A Compute version takes its environment from the project's config @@ -148,8 +149,8 @@ supplies the brand and the wire contract. - [ADR-0029](ADR-0029-secrets-are-a-forwardable-slot.md) — the secret slot this qualifies: a service key is a minted, deploy-state value, deliberately *not* a name-only secret. -- [ADR-0009](ADR-0009-deploy-state-is-hosted-in-the-workspace.md) — the - workspace-hosted state store the key's value lives in. +- [ADR-0034](ADR-0034-deploy-state-lives-in-the-stage-branch.md) — the + per-stage hosted state store the key's value lives in. - [ADR-0015](ADR-0015-dependencies-resolve-to-bindings-clients-are-app-side.md) — the binding the key rides on, alongside the URL. - [ADR-0019](ADR-0019-the-target-owns-config-serialization.md) — the target owns diff --git a/docs/design/90-decisions/ADR-0034-deploy-state-lives-in-the-stage-branch.md b/docs/design/90-decisions/ADR-0034-deploy-state-lives-in-the-stage-branch.md new file mode 100644 index 00000000..df5b343c --- /dev/null +++ b/docs/design/90-decisions/ADR-0034-deploy-state-lives-in-the-stage-branch.md @@ -0,0 +1,170 @@ +# ADR-0034: Deploy state lives in a framework-owned database in the stage's Branch + +## Decision + +Each stage's deploy state — the provisioning engine's record of what exists in +the cloud — lives in a small framework-owned Prisma Postgres database named +`prisma-composer-state`, attached to that stage's **Branch** inside the app's +own Project. Production's state database attaches to the Project's **implicit +default Branch**. This supersedes +[ADR-0009](ADR-0009-deploy-state-is-hosted-in-the-workspace.md)'s +workspace-level store; everything else ADR-0009 decided — hosted state, +automatic bootstrap, mint-per-run connections, ownership verification — +carries over unchanged. + +``` +Workspace +└── Project "storefront-auth" ← the app (ADR-0023) + ├── Branch "main" (implicit default) ← production + │ ├── App "auth" · App "storefront" · Database "database" + │ └── Database "prisma-composer-state" ← production's deploy state + └── Branch "pr-42" ← a preview stage + ├── App "auth" · App "storefront" · Database "database" + └── Database "prisma-composer-state" ← pr-42's deploy state +``` + +State now has exactly the lifetime of the environment it describes. Delete a +Branch — from the CLI, from the Console, or from any future git integration — +and the stage's state goes with it, because it is an ordinary child of the +Branch. Delete the Project and everything goes. The platform needs no +knowledge of the framework for any of this to be true. + +## Reasoning + +ADR-0009 put state in a dedicated workspace-level project because the app's +own project looked circular — "created and destroyed *by* the deploys whose +state it would hold." That was true when the state engine managed the project. +It stopped being true with +[ADR-0023](ADR-0023-a-prisma-app-is-one-project-a-stage-is-a-branch.md)/[ADR-0024](ADR-0024-a-stage-is-a-deploy-time-environment-resolved-to-project-and-branch.md): +the CLI now creates the Project and Branch via the Management API *before* +Alchemy runs, and deletes them *after* destroy, entirely outside state. A +state database bootstrapped in that same container-ensure phase is not a +circular dependency — it is container-scoped infrastructure, provisioned and +removed on the same side of the state boundary as the containers themselves. + +Two platform facts make the placement precise. Every resource belongs to a +Branch: a create call without a `branchId` is resolved by the platform to the +Project's default Branch, and every live Project owns one. So "production +lives at the Project level" (ADR-0024) is an addressing statement, not a +physical one — production's resources, and now its state database, sit on the +implicit default Branch. And a Branch's children are enumerable and deletable +by the platform without reading any of them: which is what makes containment +work. When a Branch is torn down from the platform side, the state database +is deleted like any other database — no special case, no framework hook, no +orphaned rows waiting for a same-named stage to reappear. + +The workspace store could not offer that. Platform-side Branch deletion left +the stage's rows stranded in a store the platform doesn't know about, and any +teardown driven from outside the CLI was wrong by default. Under containment, +every teardown path is right by construction, and they obey two deliberate, +opposite ordering rules: + +- **The CLI deletes state last.** `destroy` reads state to know what to + remove, so the state database must outlive every resource the destroy + removes; and the platform refuses to delete a Branch that still has live + members, so the state database is removed after the resources and before + the Branch. Every crash window between those steps converges on re-running + the destroy: an already-empty or absent store makes the remaining steps + no-ops. +- **The platform deletes state in any order it likes.** Its teardown + enumerates children and never reads state. Deleting the state database + early is even a feature: it severs the session that holds the deploy lock + ([ADR-0010](ADR-0010-deploys-hold-a-session-advisory-lock.md)), so an + in-flight deploy of a stage being torn down fails its lease check within + seconds instead of provisioning into a deleted container. + +Discovery keeps ADR-0009's no-guessing discipline, relocated. The database is +found by listing the Branch's databases and matching the well-known name; a +name match alone proves nothing (names are not unique), so adoption requires +the ownership marker, exactly as before. The default Branch is resolved by +its `isDefault` flag — never inferred, and never created: its absence is a +platform-invariant violation reported as an error. Deletion is +ownership-verified too; the framework never deletes a database it cannot +prove is its own. + +ADR-0009's second objection — per-app stores fragment workspace-level +questions — has a cleaner answer than a workspace store: those questions +belong to the platform. "Which apps exist" is listing Projects; "what is +provisioned in this stage" is listing a Branch's children. Bootstrap, far +from needing one place to look, gets simpler: the CLI already threads the +resolved Project and Branch ids to the deploy, so the store is addressed +directly instead of discovered by scanning same-named candidate projects +across the workspace. + +## Consequences + +- **Zero-residue teardown.** Destroying a stage leaves nothing: resources, + state, Branch. Destroying production removes its state database before the + best-effort Project removal, so empty Projects still get cleaned up. + Deleting a Branch or Project from the Console (or any platform surface) + cleans up state without the platform knowing the framework exists, and a + recreated stage of the same name starts from genuinely fresh state. +- **One extra small database per stage**, visible in the Console next to the + user's own databases. A user can delete it, orphaning live resources from + their state (a later deploy re-provisions from scratch and may duplicate). + Marking it protected/framework-owned is a platform capability we do not + have — the same standing limitation as ADR-0009's name-squatting note, at + smaller blast radius (one stage, not the whole workspace). +- **That database costs a quota slot, not money.** The platform's plan limit on + databases is workspace-wide, counting every database in every project, so + each stage's state store consumes one — against a cap of 50 on the free plan + and 1000 on the paid plans. Nothing bills per database: Postgres bills on + storage (GiB-hours) and queries, and a store holding a few rows of JSON that + is read only during deploys rounds to nothing on both. The constraint that + can actually bite is a free-plan workspace running many concurrent preview + stages, where the app's own databases compete for the same 50 slots. +- **The lock is unchanged and better scoped.** ADR-0010's per-`(stack, + stage)` advisory lock now lives inside a per-stage store, so its key is + redundant — kept anyway, because removing it buys nothing. Cross-stage and + cross-app contention on a shared store disappears. +- **The store's schema is unchanged**, including its now-redundant + `stack`/`stage` key columns. The wire shape stays identical to every other + Alchemy store; nothing in the engine or service layer changes. +- **Platform-side teardown covers platform resources only.** State can track + resources outside Prisma Cloud (the point of borrowing a general + provisioning engine); deleting the state database from the platform side + deletes the only record of them. A graph containing non-platform resources + must be destroyed through the CLI, which walks state. Documented + limitation, not an invisible one. +- **No migration.** The legacy workspace store is not read or migrated; the + cutover is destroy-then-redeploy (see the deploying guide), after which the + workspace-level `prisma-composer-state` project is inert and can be deleted. +- **Everything a deploy touches is project-scoped.** No state operation needs + workspace-level reach anymore — which is what a future CD/webhook + integration needs to hand a build a project-scoped credential instead of a + workspace-scoped one. + +## Alternatives considered + +- **Keep the workspace store and repair orphans lazily** — a staleness check + (state records its Branch id; a mismatch on deploy resets the stage's + state). Rejected: it patches the one symptom while leaving platform-side + teardown incorrect by default, and it keeps every deploy needing + workspace-scoped state access. +- **One state database per Project, holding all stages.** Rejected: deleting + a Branch would orphan that stage's rows — the platform can enumerate a + Branch's children, but it cannot reach inside a database. +- **The platform reaches into the store on teardown.** Rejected: couples the + platform to the store's private schema, the inverse of the + platform-stays-ignorant property this decision buys. +- **A platform-side state API** — still the end state, as ADR-0009 said. This + store now proves the *right* shape for it: state scoped as a child of the + Branch, cascading on delete, rather than workspace-global rows behind + coarse access control. + +## Related + +- [ADR-0009](ADR-0009-deploy-state-is-hosted-in-the-workspace.md) — the + workspace-level store this supersedes; its hosted-state reasoning, + bootstrap, and credential model carry over. +- [ADR-0010](ADR-0010-deploys-hold-a-session-advisory-lock.md) — the deploy + lock, now scoped within the per-stage store; state-database deletion severs + its lease. +- [ADR-0011](ADR-0011-targets-supply-the-deploy-state-layer.md) / + [ADR-0012](ADR-0012-the-state-store-speaks-sql-directly.md) — unchanged: + the target still supplies this store; it still speaks SQL. +- [ADR-0023](ADR-0023-a-prisma-app-is-one-project-a-stage-is-a-branch.md) / + [ADR-0024](ADR-0024-a-stage-is-a-deploy-time-environment-resolved-to-project-and-branch.md) + — the container model that dissolved the circularity objection. +- [`../03-domain-model/layering.md`](../03-domain-model/layering.md) — the + provisioning-state spectrum this updates. diff --git a/docs/design/90-decisions/README.md b/docs/design/90-decisions/README.md index 36d25f4d..fa037578 100644 --- a/docs/design/90-decisions/README.md +++ b/docs/design/90-decisions/README.md @@ -30,7 +30,7 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [ADR-0006](ADR-0006-every-node-is-named.md) — Every node is named; the root's name names the application. - [ADR-0007](ADR-0007-deploy-drives-alchemy-through-a-generated-stack-file.md) — Deploy drives Alchemy through a generated, inspectable stack file. - [ADR-0008](ADR-0008-wrapper-inlines-everything-except-runtime-builtins.md) — The boot wrapper inlines everything except runtime built-ins. -- [ADR-0009](ADR-0009-deploy-state-is-hosted-in-the-workspace.md) — Deploy state is hosted in the workspace, not in local files. +- [ADR-0009](ADR-0009-deploy-state-is-hosted-in-the-workspace.md) — Deploy state is hosted in the workspace, not in local files. *(Superseded by ADR-0034: still hosted, now per-stage in the app's own Project.)* - [ADR-0010](ADR-0010-deploys-hold-a-session-advisory-lock.md) — Deploys hold a session advisory lock per stack and stage. - [ADR-0011](ADR-0011-targets-supply-the-deploy-state-layer.md) — Targets supply the deploy state layer; core owns no default. - [ADR-0012](ADR-0012-the-state-store-speaks-sql-directly.md) — The state store speaks SQL directly; Prisma Next adoption is deferred. @@ -51,7 +51,8 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [ADR-0027](ADR-0027-two-packages-compose-and-compose-prisma-cloud.md) — Ship two **public** packages: `@prisma/composer` (core + CLI + agnostic subpaths) and `@prisma/composer-prisma-cloud` (target + first-party modules as entrypoints, cron first). Boundary = the user's one choice: where does it run. - [ADR-0028](ADR-0028-numbered-domains-and-layers-enforced-by-dependency-cruiser.md) — `packages/` organizes into numbered domains (0-framework, 1-prisma-cloud, 9-public) and layers; planes as config-mapped entrypoints; internals are `@internal/*`; only 9-public publishes; dependency-cruiser enforces. - [ADR-0029](ADR-0029-secrets-are-a-forwardable-slot.md) — A secret is a distinct forwardable slot (not a param): a service declares a nameless `secret()` need, the root binds it to a platform env-var via `envSecret`, and it reads back as a redacting `SecretBox`; the framework carries only the name (pointer rows + boot double-lookup + preflight). *(Proposed)* -- [ADR-0030](ADR-0030-rpc-callers-verified-with-an-auto-provisioned-service-key.md) — Every RPC binding carries a distinct, framework-minted **service key**: the client sends it, `serve()` rejects a caller without one (401). Auto-provisioned per edge at deploy, carried on the binding's own `COMPOSER_*` env-var rail; its value is minted and kept in the workspace deploy state (a capability token, deliberately not an ADR-0029 name-only secret). *(Proposed)* +- [ADR-0030](ADR-0030-rpc-callers-verified-with-an-auto-provisioned-service-key.md) — Every RPC binding carries a distinct, framework-minted **service key**: the client sends it, `serve()` rejects a caller without one (401). Auto-provisioned per edge at deploy, carried on the binding's own `COMPOSER_*` env-var rail; its value is minted and kept in the hosted deploy state (a capability token, deliberately not an ADR-0029 name-only secret). *(Proposed)* - [ADR-0031](ADR-0031-provisioned-param-values-are-a-need-resolved-through-a-target-registry.md) — A framework-minted param value is an opaque, branded **provisioning need** (not a named facet on the param): core forwards it and resolves its brand against the deploy target's `provisions` registry, failing loudly on a miss. The provisioner owns all mint/size/stability/rotation policy; core's surface stays one field. Resolves against the consumer's extension; cross-extension edges fail closed. *(Proposed)* - [ADR-0032](ADR-0032-params-bind-at-provision-env-sourcing-is-a-target-source.md) — A param can be bound at `provision()` — a schema-validated literal (beats `default`) or a target-owned source (`envParam('NAME')`, mirroring ADR-0029's need/source split): a pointer row on the wire, boot double-lookup, the raw string handed to the param's own schema, read through `config()` unredacted; preflight covers the names like secrets. - [ADR-0033](ADR-0033-lowering-types-are-defined-by-their-readers.md) — Every value in the lowering pipeline is typed by the code that reads it, not the code that writes it, retiring the shared `LoweredNode` record: a descriptor types the values it passes between its own phases (`ServiceLowering`); the application hook's product reaches core as `unknown` and its own extension narrows it with a guard; a node's values for the nodes downstream are name-keyed `WiringOutputs`, resolved against the consumer's connection declaration. The lowering loop is the only party that knows which output feeds which input. Records the alchemy execution facts (a value passed between phases legitimately holds an unresolved `Output`), that the heterogeneous registry's type safety rests on the loop rather than the compiler, and that an unchecked claim is acceptable only when it is named, justified, and singular. +- [ADR-0034](ADR-0034-deploy-state-lives-in-the-stage-branch.md) — Deploy state lives in a framework-owned `prisma-composer-state` database in the stage's own Branch (production: the implicit default Branch). State has the environment's lifetime: platform-side Branch/Project deletion cleans it up with no framework involvement; the CLI deletes it last-among-members on destroy. Supersedes ADR-0009's workspace-level store. diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index 6847b560..d68bb7de 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -37,10 +37,15 @@ turbo run build && prisma-composer deploy module.ts ``` Deploy state (what's already provisioned, so re-deploys diff instead of -recreate) is stored in your workspace, not on your machine — that's the -`prismaState()` line in `prisma-composer.config.ts`. Everyone deploying the -app shares it, your laptop and CI see the same world, and two concurrent -deploys of the same environment lock each other out instead of corrupting it. +recreate) is stored with the environment it describes, not on your machine — +that's the `prismaState()` line in `prisma-composer.config.ts`. Each +environment keeps a small framework-owned database named +`prisma-composer-state` inside the app's Project, attached to that +environment's Branch. Everyone deploying the app shares it, your laptop and +CI see the same world, and two concurrent deploys of the same environment +lock each other out instead of corrupting it. Destroying or deleting an +environment removes its state with it — don't delete the state database by +hand, or the next deploy will re-provision from scratch. ## Production and stages @@ -112,8 +117,9 @@ prisma-composer destroy module.ts --production # production's resources ``` `--stage` and `--production` together is an error too. Destroying a stage -removes its resources and then deletes its Branch; destroying production -removes the resources but the production Branch itself always survives. +removes its resources, then its state database, then deletes its Branch; +destroying production removes the resources and its state database, but the +production Branch itself always survives. Destroy never creates: tearing down a stage that was never deployed fails with "nothing deployed" rather than provisioning one first. @@ -216,6 +222,20 @@ When something misbehaves in ways these don't explain, check [`gotchas.md`](../../gotchas.md) at the repo root — the catalogue of platform footguns with diagnoses, kept current as we hit them. +## Upgrading from workspace-hosted state + +Older framework versions kept deploy state in a workspace-level +`prisma-composer-state` project instead of inside each environment. There is +no automated migration — a deploy under the new store starts from empty state +and would re-provision resources it can't see. Cut over per app: + +1. On the **old** framework version, destroy every environment: each + `--stage`, then `--production`. +2. Upgrade the framework packages. +3. Deploy again — each environment provisions fresh state in its own Branch. +4. Delete the workspace-level `prisma-composer-state` project from the + Console whenever convenient; nothing reads it after the upgrade. + ## The full picture [`docs/design/10-domains/deploy-cli.md`](../design/10-domains/deploy-cli.md) diff --git a/packages/0-framework/1-core/core/src/app-config.ts b/packages/0-framework/1-core/core/src/app-config.ts index 72e23d25..176ad516 100644 --- a/packages/0-framework/1-core/core/src/app-config.ts +++ b/packages/0-framework/1-core/core/src/app-config.ts @@ -35,6 +35,15 @@ export interface ExtensionDescriptor { * throws to abort the deploy. Async: it talks to the platform (ADR-0029). */ readonly preflight?: (input: PreflightInput) => Promise; + /** + * Destroy-time cleanup — the CLI runs it once, after `alchemy destroy` + * succeeds and BEFORE the stage's Project/Branch are removed. A target uses + * it to remove infrastructure it owns outside the stack (e.g. the deploy + * state store, which the destroy above was still reading). Throwing aborts + * the destroy before the containers go; a target that would rather warn than + * fail the command handles that itself. Async: it talks to the platform. + */ + readonly teardown?: (input: TeardownInput) => Promise; } /** The resolved deploy context handed to an extension's `preflight` hook. */ @@ -49,6 +58,16 @@ export interface PreflightInput { readonly stage: string | undefined; } +/** The resolved destroy context handed to an extension's `teardown` hook. */ +export interface TeardownInput { + /** The resolved Prisma Cloud Project id. */ + readonly projectId: string; + /** The resolved Branch id for a named stage; `undefined` for the default (production) stage. */ + readonly branchId: string | undefined; + /** The stage name (`--stage`), or `undefined` for the default stage — for diagnostics/scope. */ + readonly stage: string | undefined; +} + /** * What one registry entry can do. The `kind` discriminant is checked at every * lookup site against what the site needs — a resource node looked up against diff --git a/packages/0-framework/3-tooling/cli/src/__tests__/run.test.ts b/packages/0-framework/3-tooling/cli/src/__tests__/run.test.ts index b318726e..03406a28 100644 --- a/packages/0-framework/3-tooling/cli/src/__tests__/run.test.ts +++ b/packages/0-framework/3-tooling/cli/src/__tests__/run.test.ts @@ -17,7 +17,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import type { ServiceNode } from '@internal/core'; -import type { PrismaAppConfig } from '@internal/core/config'; +import type { ExtensionDescriptor, PrismaAppConfig } from '@internal/core/config'; import type { ResolvedContainer } from '@internal/lowering'; import { CliError } from '../cli-error.ts'; import type { EnsureContainersInput } from '../ensure-containers.ts'; @@ -51,7 +51,7 @@ const originalCwd = process.cwd(); * only validates coverage; the service SPI runs inside the (faked) alchemy * stack, and the build assemble is substituted by the runAssembler seam. */ -function fakeConfig(): PrismaAppConfig { +function fakeConfig(hooks: Partial> = {}): PrismaAppConfig { const unused = () => { throw new Error('descriptor body must not run inside run() — only coverage is checked'); }; @@ -68,6 +68,7 @@ function fakeConfig(): PrismaAppConfig { deploy: unused, }, }, + ...(hooks.teardown !== undefined ? { teardown: hooks.teardown } : {}), }, { id: 'fixture-build', nodes: { node: { kind: 'build', assemble: unused } } }, ], @@ -750,4 +751,157 @@ describe('run() — the full pipeline over fakes', () => { expect(deleteCalls).toEqual([]); }); }); + + describe('the extension teardown hook on destroy', () => { + test('destroy --stage staging runs teardown after alchemy and before the Branch goes', async () => { + const app = makeAppDir(); + process.chdir(app.dir); + const order: string[] = []; + + const status = await run(['destroy', app.entryPath, '--stage', 'staging'], { + config: fakeConfig({ + teardown: async (input) => { + order.push(`teardown:${input.projectId}/${input.branchId}/${input.stage}`); + }, + }), + runAssembler: fakeAssembler, + ensureContainers: fakeEnsureContainers, + alchemy: () => { + order.push('alchemy'); + return 0; + }, + deleteBranch: async () => { + order.push('branch'); + }, + }); + + expect(status).toBe(0); + expect(order).toEqual(['alchemy', 'teardown:proj-fake/branch-staging/staging', 'branch']); + }); + + test('destroy --production runs teardown before the Project goes, naming no branch', async () => { + const app = makeAppDir(); + process.chdir(app.dir); + const order: string[] = []; + + const status = await run(['destroy', app.entryPath, '--production'], { + config: fakeConfig({ + teardown: async (input) => { + order.push(`teardown:${input.projectId}/${input.branchId ?? 'none'}`); + }, + }), + runAssembler: fakeAssembler, + ensureContainers: fakeEnsureContainers, + alchemy: () => { + order.push('alchemy'); + return 0; + }, + deleteProject: async () => { + order.push('project'); + }, + }); + + expect(status).toBe(0); + expect(order).toEqual(['alchemy', 'teardown:proj-fake/none', 'project']); + }); + + test('a throwing teardown aborts the destroy and the container is left alone', async () => { + const app = makeAppDir(); + process.chdir(app.dir); + let branchDeleted = false; + + await expect( + run(['destroy', app.entryPath, '--stage', 'staging'], { + config: fakeConfig({ + teardown: async () => { + throw new Error('teardown said no'); + }, + }), + runAssembler: fakeAssembler, + ensureContainers: fakeEnsureContainers, + alchemy: () => 0, + deleteBranch: async () => { + branchDeleted = true; + }, + }), + ).rejects.toThrow(/teardown said no/); + + expect(branchDeleted).toBe(false); + }); + + test('a teardown failure surfaces as a CliError', async () => { + const app = makeAppDir(); + process.chdir(app.dir); + + await expect( + run(['destroy', app.entryPath, '--stage', 'staging'], { + config: fakeConfig({ + teardown: async () => { + throw new Error('teardown said no'); + }, + }), + runAssembler: fakeAssembler, + ensureContainers: fakeEnsureContainers, + alchemy: () => 0, + deleteBranch: async () => {}, + }), + ).rejects.toBeInstanceOf(CliError); + }); + + test('a FAILED alchemy destroy runs no teardown', async () => { + const app = makeAppDir(); + process.chdir(app.dir); + let torndown = false; + + const status = await run(['destroy', app.entryPath, '--stage', 'staging'], { + config: fakeConfig({ + teardown: async () => { + torndown = true; + }, + }), + runAssembler: fakeAssembler, + ensureContainers: fakeEnsureContainers, + alchemy: () => 1, + deleteBranch: async () => {}, + }); + + expect(status).toBe(1); + expect(torndown).toBe(false); + }); + + test('deploy never runs teardown', async () => { + const app = makeAppDir(); + process.chdir(app.dir); + let torndown = false; + + const status = await run(['deploy', app.entryPath, '--stage', 'staging'], { + config: fakeConfig({ + teardown: async () => { + torndown = true; + }, + }), + runAssembler: fakeAssembler, + ensureContainers: fakeEnsureContainers, + alchemy: () => 0, + }); + + expect(status).toBe(0); + expect(torndown).toBe(false); + }); + + test('an extension without a teardown hook is skipped, and the destroy completes', async () => { + const app = makeAppDir(); + process.chdir(app.dir); + + const status = await run(['destroy', app.entryPath, '--stage', 'staging'], { + config: fakeConfig(), + runAssembler: fakeAssembler, + ensureContainers: fakeEnsureContainers, + alchemy: () => 0, + deleteBranch: async () => {}, + }); + + expect(status).toBe(0); + }); + }); }); diff --git a/packages/0-framework/3-tooling/cli/src/main.ts b/packages/0-framework/3-tooling/cli/src/main.ts index ee698649..61b729b1 100644 --- a/packages/0-framework/3-tooling/cli/src/main.ts +++ b/packages/0-framework/3-tooling/cli/src/main.ts @@ -298,6 +298,24 @@ export async function run(argv: readonly string[], deps: RunDeps = {}): Promise< ); return status; } + // 9.5 Teardown (destroy only): each extension removes infrastructure it + // owns outside the stack — the destroy above may still have been reading + // it, and the containers below may refuse to go while it exists. What that + // infrastructure is, and whether losing it should fail the command, is the + // extension's business, not this module's. + if (args.command === 'destroy') { + for (const extension of config.extensions) { + if (extension.teardown === undefined) continue; + try { + await extension.teardown({ projectId, branchId, stage }); + } catch (error) { + throw error instanceof CliError + ? error + : new CliError(error instanceof Error ? error.message : String(error)); + } + } + } + if (args.command === 'destroy' && branchId !== undefined) { await (deps.deleteBranch ?? ((input) => deleteStageBranch(input)))({ branchId }); } else if (args.command === 'destroy') { diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/bootstrap.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/bootstrap.test.ts index 6d8dfb05..a1f0640e 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/bootstrap.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/bootstrap.test.ts @@ -1,190 +1,35 @@ import { beforeEach, describe, expect, test } from 'bun:test'; import * as Effect from 'effect/Effect'; import * as Redacted from 'effect/Redacted'; -import type { ManagementApiClient } from '../../client.ts'; import { ManagementClient } from '../../client.ts'; +import type { ResolvedContainer } from '../../container.ts'; import { bootstrapStateConnection, bootstrapStateConnectionWith, - type OwnershipVerdict, type OwnershipVerifier, } from '../bootstrap.ts'; - -interface FakeProject { - id: string; - name: string; - createdAt: string; - workspace: { id: string }; -} - -interface FakeDatabase { - id: string; - name: string; - isDefault: boolean; -} - -interface FakeConnection { - id: string; - name: string; - createdAt: string; -} - -interface FakeState { - projects: FakeProject[]; - databases: Record; - connections: Record; - createShouldFail: boolean; - createCalls: number; - listCalls: number; - databaseCreateCalls: number; - connectionCalls: string[]; - deletedConnectionIds: string[]; -} - -const newFakeState = (overrides: Partial = {}): FakeState => ({ - projects: [], - databases: {}, - connections: {}, - createShouldFail: false, - createCalls: 0, - listCalls: 0, - databaseCreateCalls: 0, - connectionCalls: [], - deletedConnectionIds: [], - ...overrides, -}); - -const okResponse = (data: T, status = 200) => ({ - data, - error: undefined, - response: new Response(null, { status }), -}); - -const errorResponse = (status: number) => ({ - data: undefined, - error: { message: 'stubbed failure' }, - response: new Response(null, { status }), -}); - -/** - * A stubbed `ManagementApiClient` — just enough of `GET`/`POST`/`DELETE` to - * exercise `bootstrapStateConnection`'s discovery, default-database, - * connection-mint, and aged-connection-cleanup paths without touching the - * cloud. `as ManagementApiClient` is acceptable here (test file — exempt - * from the no-bare-cast rule) because hand-writing the full openapi-fetch - * generic signature adds no safety this fake's shape doesn't already - * guarantee at each call site below. - */ -const fakeClient = (state: FakeState): ManagementApiClient => { - const GET = (path: string, init: { params?: { path?: Record } } = {}) => { - if (path === '/v1/projects') { - state.listCalls++; - return Promise.resolve( - okResponse({ data: state.projects, pagination: { nextCursor: null, hasMore: false } }), - ); - } - if (path === '/v1/projects/{projectId}/databases') { - const projectId = init.params?.path?.['projectId'] ?? ''; - const databases = state.databases[projectId] ?? []; - return Promise.resolve( - okResponse({ data: databases, pagination: { nextCursor: null, hasMore: false } }), - ); - } - if (path === '/v1/databases/{databaseId}/connections') { - const databaseId = init.params?.path?.['databaseId'] ?? ''; - const connections = state.connections[databaseId] ?? []; - return Promise.resolve( - okResponse({ data: connections, pagination: { nextCursor: null, hasMore: false } }), - ); - } - throw new Error(`fakeClient: unexpected GET ${path}`); - }; - - const POST = ( - path: string, - init: { params?: { path?: Record }; body?: Record } = {}, - ) => { - if (path === '/v1/projects') { - state.createCalls++; - if (state.createShouldFail) { - return Promise.resolve(errorResponse(409)); - } - const id = `proj-${state.createCalls}`; - const project: FakeProject = { - id, - name: String(init.body?.['name']), - createdAt: new Date(state.createCalls).toISOString(), - workspace: { id: String(init.body?.['workspaceId']) }, - }; - state.projects.push(project); - state.databases[id] = [{ id: `${id}-db`, name: 'default', isDefault: true }]; - return Promise.resolve(okResponse({ data: project }, 201)); - } - if (path === '/v1/projects/{projectId}/databases') { - state.databaseCreateCalls++; - throw new Error('fakeClient: bootstrap must never create a database (FT-5220)'); - } - if (path === '/v1/databases/{databaseId}/connections') { - const databaseId = init.params?.path?.['databaseId'] ?? ''; - state.connectionCalls.push(databaseId); - return Promise.resolve( - okResponse({ - data: { - id: `conn-${databaseId}-${state.connectionCalls.length}`, - endpoints: { - direct: { - host: 'fake', - port: 5432, - connectionString: `postgres://fake/${databaseId}`, - }, - }, - }, - }), - ); - } - throw new Error(`fakeClient: unexpected POST ${path}`); - }; - - const DELETE = (path: string, init: { params?: { path?: Record } } = {}) => { - if (path === '/v1/connections/{id}') { - const id = init.params?.path?.['id'] ?? ''; - state.deletedConnectionIds.push(id); - for (const databaseId of Object.keys(state.connections)) { - const list = state.connections[databaseId]; - if (list === undefined) continue; - state.connections[databaseId] = list.filter((c) => c.id !== id); - } - return Promise.resolve(okResponse(undefined, 204)); - } - throw new Error(`fakeClient: unexpected DELETE ${path}`); - }; - - // biome-ignore lint/suspicious/noExplicitAny: test stub — see the doc comment above. - return { GET, POST, DELETE } as any as ManagementApiClient; -}; - -/** A verifier stub that maps each fake database id to a canned verdict, and fails the test if asked about one it wasn't told to expect. */ -const verifierFor = ( - verdicts: Record, - calls: string[] = [], -): OwnershipVerifier => { - return (connectionString) => { - const dsn = Redacted.value(connectionString); - calls.push(dsn); - const databaseId = dsn.replace('postgres://fake/', ''); - const verdict = verdicts[databaseId]; - if (verdict === undefined) throw new Error(`verifierFor: no verdict stubbed for ${databaseId}`); - return Effect.succeed(verdict); - }; -}; +import { + DEFAULT_BRANCH_ID, + type FakeState, + fakeClient, + newFakeState, + PROJECT_ID, + stateDatabase, + verifierFor, + withDefaultBranch, +} from './fake-management-api.ts'; const neverCalled = (): OwnershipVerifier => () => { throw new Error('verifyOwnership must not be called on the create path — nothing to verify yet'); }; -const run = (state: FakeState, verify: OwnershipVerifier, workspaceId = 'ws-1') => +const run = ( + state: FakeState, + verify: OwnershipVerifier, + container: ResolvedContainer = { projectId: PROJECT_ID }, +) => Effect.runPromise( - bootstrapStateConnectionWith(workspaceId, verify).pipe( + bootstrapStateConnectionWith(container, verify).pipe( Effect.provideService(ManagementClient, fakeClient(state)), ), ); @@ -196,115 +41,110 @@ describe('bootstrapStateConnection', () => { state = newFakeState(); }); - test('create path: no existing project creates one, and ownership is never checked (nothing to verify yet)', async () => { + test('production, with no branch given, uses the project’s default branch', async () => { + withDefaultBranch(state); + + await run(state, neverCalled()); + + expect(state.branchListCalls).toBe(1); + expect(state.databases[0]?.branchId).toBe(DEFAULT_BRANCH_ID); + }); + + test('a named stage uses the branch it was given, without looking any up', async () => { + state.databases.push(stateDatabase('db-existing', 'br-named')); + + const result = await run(state, verifierFor({ 'db-existing': { kind: 'ours' } }), { + projectId: PROJECT_ID, + branchId: 'br-named', + }); + + expect(state.branchListCalls).toBe(0); + expect(result.databaseId).toBe('db-existing'); + }); + + test('a project with no default branch fails, naming the project, and creates nothing', async () => { + state.branches[PROJECT_ID] = []; + + await expect(run(state, neverCalled())).rejects.toThrow( + new RegExp(`${PROJECT_ID}.*no default Branch`), + ); + expect(state.databaseCreateCalls).toBe(0); + }); + + test('with no state database present, one is made on the stage’s branch and its ownership is never questioned', async () => { + withDefaultBranch(state); + const result = await run(state, neverCalled()); - expect(result.projectId).toBe('proj-1'); - expect(state.createCalls).toBe(1); - expect(state.projects).toHaveLength(1); - expect(state.projects[0]?.name).toBe('prisma-composer-state'); + expect(result.databaseId).toBe('db-1'); + expect(state.databaseCreateCalls).toBe(1); + expect(state.databases[0]?.name).toBe('prisma-composer-state'); }); - test('adopt-marked: a candidate whose database already carries our marker is adopted outright', async () => { - state.projects.push({ - id: 'proj-existing', + test('the branch’s own default database is left alone even when it shares our name', async () => { + withDefaultBranch(state); + state.databases.push({ + id: 'db-users-default', name: 'prisma-composer-state', + isDefault: true, createdAt: new Date(1).toISOString(), - workspace: { id: 'ws-1' }, + branchId: DEFAULT_BRANCH_ID, + projectId: PROJECT_ID, }); - state.databases['proj-existing'] = [{ id: 'db-existing', name: 'default', isDefault: true }]; + + const result = await run(state, neverCalled()); + + expect(result.databaseId).not.toBe('db-users-default'); + expect(state.databaseCreateCalls).toBe(1); + }); + + test('a database carrying our marker is adopted', async () => { + withDefaultBranch(state); + state.databases.push(stateDatabase('db-existing')); const result = await run(state, verifierFor({ 'db-existing': { kind: 'ours' } })); - expect(result.projectId).toBe('proj-existing'); expect(result.databaseId).toBe('db-existing'); - expect(state.createCalls).toBe(0); + expect(state.databaseCreateCalls).toBe(0); }); - test('workspace-id shape mismatch: a wksp_-prefixed API id still matches a bare configured id (and vice versa)', async () => { - state.projects.push({ - id: 'proj-existing', - name: 'prisma-composer-state', - createdAt: new Date(1).toISOString(), - workspace: { id: 'wksp_ws-1' }, - }); - state.databases['proj-existing'] = [{ id: 'db-existing', name: 'default', isDefault: true }]; - - // Configured bare, API returns prefixed — the CI shape that caused a - // fresh state project to be provisioned on every run. - const result = await run(state, verifierFor({ 'db-existing': { kind: 'ours' } }), 'ws-1'); - expect(result.projectId).toBe('proj-existing'); - expect(state.createCalls).toBe(0); - - // Configured prefixed, API returns prefixed (the local shape). - const result2 = await run(state, verifierFor({ 'db-existing': { kind: 'ours' } }), 'wksp_ws-1'); - expect(result2.projectId).toBe('proj-existing'); - expect(state.createCalls).toBe(0); + test('a database holding our tables but no marker yet is adopted', async () => { + withDefaultBranch(state); + state.databases.push(stateDatabase('db-legacy')); + + const result = await run(state, verifierFor({ 'db-legacy': { kind: 'legacy' } })); + + expect(result.databaseId).toBe('db-legacy'); + expect(state.databaseCreateCalls).toBe(0); }); - test('adopt-legacy: a candidate with our tables but no marker yet is adopted (migratePrismaState writes the marker on the way in)', async () => { - state.projects.push({ - id: 'proj-legacy', - name: 'prisma-composer-state', - createdAt: new Date(1).toISOString(), - workspace: { id: 'ws-1' }, - }); - state.databases['proj-legacy'] = [{ id: 'db-legacy', name: 'default', isDefault: true }]; + test('an empty database, left by a run that died before migrating, is adopted', async () => { + withDefaultBranch(state); + state.databases.push(stateDatabase('db-empty')); - const result = await run(state, verifierFor({ 'db-legacy': { kind: 'legacy' } })); + const result = await run(state, verifierFor({ 'db-empty': { kind: 'empty' } })); - expect(result.projectId).toBe('proj-legacy'); - expect(state.createCalls).toBe(0); + expect(result.databaseId).toBe('db-empty'); + expect(state.databaseCreateCalls).toBe(0); }); - test('squatter rejection: the only candidate has foreign data — bootstrap fails loudly, naming the project', async () => { - state.projects.push({ - id: 'proj-squatter', - name: 'prisma-composer-state', - createdAt: new Date(1).toISOString(), - workspace: { id: 'ws-1' }, - }); - state.databases['proj-squatter'] = [{ id: 'db-squatter', name: 'default', isDefault: true }]; + test('a database holding someone else’s data fails the deploy, naming it, and no second one is made beside it', async () => { + withDefaultBranch(state); + state.databases.push(stateDatabase('db-squatter')); await expect( - run( - state, - verifierFor({ - 'db-squatter': { kind: 'squatter', tables: ['users', 'orders'] }, - }), - ), - ).rejects.toThrow(/proj-squatter/); - expect(state.createCalls).toBe(0); + run(state, verifierFor({ 'db-squatter': { kind: 'squatter', tables: ['users', 'orders'] } })), + ).rejects.toThrow(/db-squatter/); + expect(state.databaseCreateCalls).toBe(0); }); - test('multi-candidate tiebreak: candidates are tried oldest-createdAt first, and the loop stops at the first that verifies', async () => { - state.projects.push( - { - id: 'proj-newest', - name: 'prisma-composer-state', - createdAt: new Date(3).toISOString(), - workspace: { id: 'ws-1' }, - }, - { - id: 'proj-oldest-squatter', - name: 'prisma-composer-state', - createdAt: new Date(1).toISOString(), - workspace: { id: 'ws-1' }, - }, - { - id: 'proj-middle-ours', - name: 'prisma-composer-state', - createdAt: new Date(2).toISOString(), - workspace: { id: 'ws-1' }, - }, + test('candidates are tried oldest first, stopping at the first that proves ours', async () => { + withDefaultBranch(state); + state.databases.push( + stateDatabase('db-newest', DEFAULT_BRANCH_ID, 3), + stateDatabase('db-oldest-squatter', DEFAULT_BRANCH_ID, 1), + stateDatabase('db-middle-ours', DEFAULT_BRANCH_ID, 2), ); - state.databases['proj-newest'] = [{ id: 'db-newest', name: 'default', isDefault: true }]; - state.databases['proj-oldest-squatter'] = [ - { id: 'db-oldest-squatter', name: 'default', isDefault: true }, - ]; - state.databases['proj-middle-ours'] = [ - { id: 'db-middle-ours', name: 'default', isDefault: true }, - ]; const calls: string[] = []; const result = await run( @@ -319,47 +159,29 @@ describe('bootstrapStateConnection', () => { ), ); - expect(result.projectId).toBe('proj-middle-ours'); - // Tried in createdAt order: the oldest (squatter) first, then the - // middle one (adopted) — the newest is never even checked. + expect(result.databaseId).toBe('db-middle-ours'); expect(calls).toEqual(['postgres://fake/db-oldest-squatter', 'postgres://fake/db-middle-ours']); }); - test('a real create failure is surfaced, not swallowed', async () => { + test('a failure creating the database surfaces rather than being swallowed', async () => { + withDefaultBranch(state); state.createShouldFail = true; await expect(run(state, neverCalled())).rejects.toThrow(); }); - test('connection minting reads endpoints.direct.connectionString and never creates a database', async () => { + test('the connection string comes from the direct endpoint', async () => { + withDefaultBranch(state); + const result = await run(state, neverCalled()); expect(Redacted.value(result.connectionString)).toBe(`postgres://fake/${result.databaseId}`); expect(state.connectionCalls).toEqual([result.databaseId]); - expect(state.databaseCreateCalls).toBe(0); - }); - - test('a workspace with no default database fails loudly rather than creating one', async () => { - state.projects.push({ - id: 'proj-nodefault', - name: 'prisma-composer-state', - createdAt: new Date(1).toISOString(), - workspace: { id: 'ws-1' }, - }); - state.databases['proj-nodefault'] = [{ id: 'db-x', name: 'not-default', isDefault: false }]; - - await expect(run(state, neverCalled())).rejects.toThrow(); - expect(state.databaseCreateCalls).toBe(0); }); - test('aged-connection cleanup: connections matching our naming pattern older than 24h are deleted, others are left alone', async () => { - state.projects.push({ - id: 'proj-existing', - name: 'prisma-composer-state', - createdAt: new Date(1).toISOString(), - workspace: { id: 'ws-1' }, - }); - state.databases['proj-existing'] = [{ id: 'db-existing', name: 'default', isDefault: true }]; + test('our own connections older than 24h are cleaned up; fresh and foreign ones are left', async () => { + withDefaultBranch(state); + state.databases.push(stateDatabase('db-existing')); const now = Date.now(); const dayMs = 24 * 60 * 60 * 1000; @@ -382,30 +204,18 @@ describe('bootstrapStateConnection', () => { expect(state.deletedConnectionIds).toEqual(['conn-aged']); }); - test('aged-connection cleanup is best-effort: a listing failure never fails bootstrap', async () => { - state.projects.push({ - id: 'proj-existing', - name: 'prisma-composer-state', - createdAt: new Date(1).toISOString(), - workspace: { id: 'ws-1' }, - }); - state.databases['proj-existing'] = [{ id: 'db-existing', name: 'default', isDefault: true }]; - // No `connections` entry for 'db-existing' — the fake GET returns `[]`, - // which is the easy case; a real listing failure would surface as a - // PrismaApiError from `call()`, and `Effect.ignore` on - // `cleanupAgedConnections` swallows it the same way — asserting the - // happy path here keeps the fake simple while `Effect.ignore`'s - // behaviour is a one-line, self-evident guarantee from the effect - // library itself. + test('a connection cleanup that finds nothing still lets the deploy through', async () => { + withDefaultBranch(state); + state.databases.push(stateDatabase('db-existing')); const result = await run(state, verifierFor({ 'db-existing': { kind: 'ours' } })); - expect(result.projectId).toBe('proj-existing'); + expect(result.databaseId).toBe('db-existing'); }); }); describe('bootstrapStateConnection (public entry point)', () => { test('wires the real verifyOwnership — typechecked here, not run (that would touch a real Postgres)', () => { - const typed: (workspaceId: string) => ReturnType = + const typed: (container: ResolvedContainer) => ReturnType = bootstrapStateConnection; expect(typed).toBe(bootstrapStateConnection); }); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/delete.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/delete.test.ts new file mode 100644 index 00000000..7431c0ca --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/delete.test.ts @@ -0,0 +1,184 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; +import * as Effect from 'effect/Effect'; +import { ManagementClient } from '../../client.ts'; +import type { ResolvedContainer } from '../../container.ts'; +import type { OwnershipVerifier } from '../bootstrap.ts'; +import { deleteStateDatabase, deleteStateDatabaseWith } from '../delete.ts'; +import { + DEFAULT_BRANCH_ID, + type FakeState, + fakeClient, + newFakeState, + PROJECT_ID, + stateDatabase, + verifierFor, + withDefaultBranch, +} from './fake-management-api.ts'; + +const neverCalled = (): OwnershipVerifier => () => { + throw new Error('verifyOwnership must be consulted before any database is deleted'); +}; + +const run = ( + state: FakeState, + verify: OwnershipVerifier, + container: ResolvedContainer = { projectId: PROJECT_ID }, +) => + Effect.runPromise( + deleteStateDatabaseWith(container, verify).pipe( + Effect.provideService(ManagementClient, fakeClient(state)), + ), + ); + +describe('deleteStateDatabase', () => { + let state: FakeState; + + beforeEach(() => { + state = newFakeState(); + }); + + test('production, with no branch given, looks on the project’s default branch', async () => { + withDefaultBranch(state); + state.databases.push(stateDatabase('db-prod')); + + await run(state, verifierFor({ 'db-prod': { kind: 'ours' } })); + + expect(state.branchListCalls).toBe(1); + expect(state.deletedDatabaseIds).toEqual(['db-prod']); + }); + + test('a named stage looks on the branch it was given, without looking any up', async () => { + state.databases.push(stateDatabase('db-stage', 'br-named')); + + await run(state, verifierFor({ 'db-stage': { kind: 'ours' } }), { + projectId: PROJECT_ID, + branchId: 'br-named', + }); + + expect(state.branchListCalls).toBe(0); + expect(state.deletedDatabaseIds).toEqual(['db-stage']); + }); + + test('finding no state database succeeds, so a repeated destroy is a no-op', async () => { + withDefaultBranch(state); + + await run(state, neverCalled()); + + expect(state.deletedDatabaseIds).toEqual([]); + }); + + test('a database carrying our marker is deleted', async () => { + withDefaultBranch(state); + state.databases.push(stateDatabase('db-ours')); + + await run(state, verifierFor({ 'db-ours': { kind: 'ours' } })); + + expect(state.deletedDatabaseIds).toEqual(['db-ours']); + }); + + test('a database holding our tables but no marker yet is deleted', async () => { + withDefaultBranch(state); + state.databases.push(stateDatabase('db-legacy')); + + await run(state, verifierFor({ 'db-legacy': { kind: 'legacy' } })); + + expect(state.deletedDatabaseIds).toEqual(['db-legacy']); + }); + + test('an empty database, left by a run that died before migrating, is deleted', async () => { + withDefaultBranch(state); + state.databases.push(stateDatabase('db-empty')); + + await run(state, verifierFor({ 'db-empty': { kind: 'empty' } })); + + expect(state.deletedDatabaseIds).toEqual(['db-empty']); + }); + + test('a database holding someone else’s data is left alone, and the destroy still succeeds', async () => { + withDefaultBranch(state); + state.databases.push(stateDatabase('db-squatter')); + + await run(state, verifierFor({ 'db-squatter': { kind: 'squatter', tables: ['users'] } })); + + expect(state.deletedDatabaseIds).toEqual([]); + }); + + test('the branch’s own default database is never deleted, even when it shares our name', async () => { + withDefaultBranch(state); + state.databases.push({ + id: 'db-users-default', + name: 'prisma-composer-state', + isDefault: true, + createdAt: new Date(1).toISOString(), + branchId: DEFAULT_BRANCH_ID, + projectId: PROJECT_ID, + }); + + await run(state, neverCalled()); + + expect(state.deletedDatabaseIds).toEqual([]); + }); + + test('every database we own is deleted, so duplicates left by a crashed run all go', async () => { + withDefaultBranch(state); + state.databases.push( + stateDatabase('db-first', DEFAULT_BRANCH_ID, 1), + stateDatabase('db-second', DEFAULT_BRANCH_ID, 2), + ); + + await run(state, verifierFor({ 'db-first': { kind: 'ours' }, 'db-second': { kind: 'empty' } })); + + expect(state.deletedDatabaseIds).toEqual(['db-first', 'db-second']); + }); + + test('someone else’s database does not stop ours from being deleted', async () => { + withDefaultBranch(state); + state.databases.push( + stateDatabase('db-squatter', DEFAULT_BRANCH_ID, 1), + stateDatabase('db-ours', DEFAULT_BRANCH_ID, 2), + ); + + await run( + state, + verifierFor({ + 'db-squatter': { kind: 'squatter', tables: ['users'] }, + 'db-ours': { kind: 'ours' }, + }), + ); + + expect(state.deletedDatabaseIds).toEqual(['db-ours']); + }); + + test('a database already gone counts as deleted, so a retried destroy still completes', async () => { + withDefaultBranch(state); + state.databases.push(stateDatabase('db-ours')); + state.deleteShouldFailWith = 404; + + await expect(run(state, verifierFor({ 'db-ours': { kind: 'ours' } }))).resolves.toBeUndefined(); + }); + + test('a deletion the platform refuses fails the destroy', async () => { + withDefaultBranch(state); + state.databases.push(stateDatabase('db-ours')); + state.deleteShouldFailWith = 409; + + await expect(run(state, verifierFor({ 'db-ours': { kind: 'ours' } }))).rejects.toThrow(); + }); + + test('a project with no default branch fails, naming the project, and deletes nothing', async () => { + state.branches[PROJECT_ID] = []; + + await expect(run(state, neverCalled())).rejects.toThrow( + new RegExp(`${PROJECT_ID}.*no default Branch`), + ); + expect(state.deletedDatabaseIds).toEqual([]); + }); +}); + +describe('deleteStateDatabase (public entry point)', () => { + test('wires the real verifyOwnership — typechecked here, not run (that would touch a real Postgres)', () => { + const typed: (container: ResolvedContainer) => ReturnType = + deleteStateDatabase; + expect(typed).toBe(deleteStateDatabase); + }); +}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/fake-management-api.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/fake-management-api.ts new file mode 100644 index 00000000..4654d425 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/fake-management-api.ts @@ -0,0 +1,241 @@ +import { blindCast } from '@internal/foundation/casts'; +import * as Effect from 'effect/Effect'; +import * as Redacted from 'effect/Redacted'; +import type { ManagementApiClient } from '../../client.ts'; +import type { OwnershipVerdict, OwnershipVerifier } from '../bootstrap.ts'; + +export interface FakeBranch { + id: string; + isDefault: boolean; +} + +export interface FakeDatabase { + id: string; + name: string; + isDefault: boolean; + createdAt: string; + branchId: string | null; + projectId: string; +} + +export interface FakeConnection { + id: string; + name: string; + createdAt: string; +} + +export interface FakeState { + branches: Record; + databases: FakeDatabase[]; + connections: Record; + createShouldFail: boolean; + deleteShouldFailWith: number | undefined; + branchListCalls: number; + databaseCreateCalls: number; + connectionCalls: string[]; + deletedDatabaseIds: string[]; + deletedConnectionIds: string[]; +} + +export const newFakeState = (overrides: Partial = {}): FakeState => ({ + branches: {}, + databases: [], + connections: {}, + createShouldFail: false, + deleteShouldFailWith: undefined, + branchListCalls: 0, + databaseCreateCalls: 0, + connectionCalls: [], + deletedDatabaseIds: [], + deletedConnectionIds: [], + ...overrides, +}); + +const okResponse = (data: T, status = 200) => ({ + data, + error: undefined, + response: new Response(null, { status }), +}); + +const errorResponse = (status: number) => ({ + data: undefined, + error: { message: 'stubbed failure' }, + response: new Response(null, { status }), +}); + +type FakeInit = { + params?: { path?: Record; query?: Record }; + body?: Record; +}; + +/** + * A stubbed `ManagementApiClient` — just enough of the Management API to + * exercise branch resolution, state-database discovery, creation, deletion, + * and connection handling without touching the cloud. + * + * The project-scoped create and any PATCH throw instead of answering, so + * attaching the database with a second call from our side fails loudly here. + * + * The flat create is modelled as a single step that lands the database on the + * given Branch. The real platform creates the row on the default Branch and + * attaches it afterwards, so no test here can reach the failed-attach outcome — + * that risk is accepted, not impossible. + */ +export const fakeClient = (state: FakeState): ManagementApiClient => { + const GET = (path: string, init: FakeInit = {}) => { + if (path === '/v1/projects/{projectId}/branches') { + const projectId = init.params?.path?.['projectId'] ?? ''; + state.branchListCalls++; + return Promise.resolve( + okResponse({ + data: state.branches[projectId] ?? [], + pagination: { nextCursor: null, hasMore: false }, + }), + ); + } + if (path === '/v1/databases') { + const query = init.params?.query ?? {}; + const filtered = state.databases.filter( + (d) => + d.branchId === (query['branchId'] ?? null) && + (query['projectId'] === undefined || d.projectId === query['projectId']), + ); + return Promise.resolve( + okResponse({ data: filtered, pagination: { nextCursor: null, hasMore: false } }), + ); + } + if (path === '/v1/databases/{databaseId}/connections') { + const databaseId = init.params?.path?.['databaseId'] ?? ''; + return Promise.resolve( + okResponse({ + data: state.connections[databaseId] ?? [], + pagination: { nextCursor: null, hasMore: false }, + }), + ); + } + throw new Error(`fakeClient: unexpected GET ${path}`); + }; + + const POST = (path: string, init: FakeInit = {}) => { + if (path === '/v1/databases') { + state.databaseCreateCalls++; + if (state.createShouldFail) return Promise.resolve(errorResponse(409)); + const id = `db-${state.databaseCreateCalls}`; + const branchId = init.body?.['branchId']; + if (typeof branchId !== 'string') { + throw new Error('fakeClient: a state database must be created with a branchId'); + } + const database: FakeDatabase = { + id, + name: String(init.body?.['name']), + isDefault: false, + createdAt: new Date(state.databaseCreateCalls).toISOString(), + branchId, + projectId: String(init.body?.['projectId']), + }; + state.databases.push(database); + return Promise.resolve(okResponse({ data: database }, 201)); + } + if (path === '/v1/projects/{projectId}/databases') { + throw new Error( + 'fakeClient: the state database must be created via POST /v1/databases with a branchId — ' + + 'the project-scoped endpoint has no branchId field, so the database would be born on ' + + "the default Branch (production's) and only move afterwards.", + ); + } + if (path === '/v1/databases/{databaseId}/connections') { + const databaseId = init.params?.path?.['databaseId'] ?? ''; + state.connectionCalls.push(databaseId); + return Promise.resolve( + okResponse({ + data: { + id: `conn-${databaseId}-${state.connectionCalls.length}`, + endpoints: { + direct: { + host: 'fake', + port: 5432, + connectionString: `postgres://fake/${databaseId}`, + }, + }, + }, + }), + ); + } + throw new Error(`fakeClient: unexpected POST ${path}`); + }; + + const PATCH = (path: string) => { + if (path === '/v1/databases/{databaseId}') { + throw new Error( + 'fakeClient: a state database must be attached to its Branch at creation, never moved ' + + 'onto it by a follow-up PATCH.', + ); + } + throw new Error(`fakeClient: unexpected PATCH ${path}`); + }; + + const DELETE = (path: string, init: FakeInit = {}) => { + if (path === '/v1/databases/{databaseId}') { + const databaseId = init.params?.path?.['databaseId'] ?? ''; + if (state.deleteShouldFailWith !== undefined) { + return Promise.resolve(errorResponse(state.deleteShouldFailWith)); + } + state.deletedDatabaseIds.push(databaseId); + state.databases = state.databases.filter((d) => d.id !== databaseId); + return Promise.resolve(okResponse(undefined, 204)); + } + if (path === '/v1/connections/{id}') { + const id = init.params?.path?.['id'] ?? ''; + state.deletedConnectionIds.push(id); + for (const databaseId of Object.keys(state.connections)) { + const list = state.connections[databaseId]; + if (list === undefined) continue; + state.connections[databaseId] = list.filter((c) => c.id !== id); + } + return Promise.resolve(okResponse(undefined, 204)); + } + throw new Error(`fakeClient: unexpected DELETE ${path}`); + }; + + return blindCast< + ManagementApiClient, + 'a hand-written fake of openapi-fetch’s generated client: its four methods answer only the paths these suites exercise, and reproducing the real generic signature would add no safety the per-path handlers above do not already give' + >({ GET, POST, PATCH, DELETE }); +}; + +/** A verifier stub that maps each fake database id to a canned verdict, and fails the test if asked about one it wasn't told to expect. */ +export const verifierFor = ( + verdicts: Record, + calls: string[] = [], +): OwnershipVerifier => { + return (connectionString) => { + const dsn = Redacted.value(connectionString); + calls.push(dsn); + const databaseId = dsn.replace('postgres://fake/', ''); + const verdict = verdicts[databaseId]; + if (verdict === undefined) throw new Error(`verifierFor: no verdict stubbed for ${databaseId}`); + return Effect.succeed(verdict); + }; +}; + +export const PROJECT_ID = 'proj-1'; +export const DEFAULT_BRANCH_ID = 'br-default'; + +/** Registers the default Branch every live Project is guaranteed to own. Its absence is its own test scenario. */ +export const withDefaultBranch = (state: FakeState): void => { + state.branches[PROJECT_ID] = [{ id: DEFAULT_BRANCH_ID, isDefault: true }]; +}; + +/** A state database on the given branch, named ours and non-default — a candidate for adoption or deletion. */ +export const stateDatabase = ( + id: string, + branchId: string = DEFAULT_BRANCH_ID, + createdAtMs = 1, +): FakeDatabase => ({ + id, + name: 'prisma-composer-state', + isDefault: false, + createdAt: new Date(createdAtMs).toISOString(), + branchId, + projectId: PROJECT_ID, +}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/layer.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/layer.test.ts index a6fb4e0b..1a7b4194 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/layer.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/layer.test.ts @@ -4,34 +4,50 @@ import type { State } from 'alchemy/State'; import type * as Layer from 'effect/Layer'; import { prismaState } from '../layer.ts'; -const originalWorkspaceId = process.env['PRISMA_WORKSPACE_ID']; +const originalProjectId = process.env['PRISMA_PROJECT_ID']; +const originalBranchId = process.env['PRISMA_BRANCH_ID']; afterEach(() => { - if (originalWorkspaceId === undefined) delete process.env['PRISMA_WORKSPACE_ID']; - else process.env['PRISMA_WORKSPACE_ID'] = originalWorkspaceId; + if (originalProjectId === undefined) delete process.env['PRISMA_PROJECT_ID']; + else process.env['PRISMA_PROJECT_ID'] = originalProjectId; + if (originalBranchId === undefined) delete process.env['PRISMA_BRANCH_ID']; + else process.env['PRISMA_BRANCH_ID'] = originalBranchId; }); describe('prismaState', () => { test('its signature satisfies core’s LowerOptions.state contract — typechecked, never run (that would touch the cloud)', () => { - const typed: (opts?: { workspaceId?: string }) => Layer.Layer = - prismaState; + const typed: () => Layer.Layer = prismaState; expect(typed).toBe(prismaState); }); - test('constructing the layer is inert — an explicit workspaceId builds a Layer without touching the network', () => { + test('constructing the layer is inert — a set PRISMA_PROJECT_ID builds a Layer without touching the network', () => { // Layer.effect(...) only builds a lazy Effect description — no Management // API call, no Postgres connection, no PRISMA_SERVICE_TOKEN read — until // something actually provides/runs the layer, which this test never does. - expect(prismaState({ workspaceId: 'ws_1' })).toBeDefined(); + process.env['PRISMA_PROJECT_ID'] = 'prj_1'; + delete process.env['PRISMA_BRANCH_ID']; + expect(prismaState()).toBeDefined(); + }); + + test('PRISMA_BRANCH_ID is optional — a named-stage deploy sets it, the default stage leaves it unset', () => { + process.env['PRISMA_PROJECT_ID'] = 'prj_1'; + process.env['PRISMA_BRANCH_ID'] = 'br_1'; + expect(prismaState()).toBeDefined(); }); - test('omitted workspaceId falls back to PRISMA_WORKSPACE_ID', () => { - process.env['PRISMA_WORKSPACE_ID'] = 'ws_env'; + test('an empty-string PRISMA_BRANCH_ID is treated as absent, same as unset', () => { + process.env['PRISMA_PROJECT_ID'] = 'prj_1'; + process.env['PRISMA_BRANCH_ID'] = ''; expect(prismaState()).toBeDefined(); }); - test('missing PRISMA_WORKSPACE_ID fails at construction naming the variable', () => { - delete process.env['PRISMA_WORKSPACE_ID']; - expect(() => prismaState()).toThrow(/PRISMA_WORKSPACE_ID/); + test('missing PRISMA_PROJECT_ID fails at construction naming the variable', () => { + delete process.env['PRISMA_PROJECT_ID']; + expect(() => prismaState()).toThrow(/PRISMA_PROJECT_ID/); + }); + + test('empty-string PRISMA_PROJECT_ID fails at construction, same as unset', () => { + process.env['PRISMA_PROJECT_ID'] = ''; + expect(() => prismaState()).toThrow(/PRISMA_PROJECT_ID/); }); }); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/bootstrap.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/bootstrap.ts index af299190..aaa3ea72 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/bootstrap.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/bootstrap.ts @@ -2,38 +2,20 @@ import * as Effect from 'effect/Effect'; import * as Redacted from 'effect/Redacted'; import postgres from 'postgres'; import { type ManagementApiClient, ManagementClient } from '../client.ts'; +import type { ResolvedContainer } from '../container.ts'; import { call, callVoid, PrismaApiError } from '../http.ts'; +import { + CONNECTION_NAME_PREFIX, + createConnection, + type DatabaseSummary, + listStateDatabaseCandidates, + resolveBranchId, + STATE_DATABASE_NAME, +} from './discovery.ts'; import { STATE_META_MARKER } from './schema.ts'; -/** - * The workspace's dedicated project for hosted deploy state. A project is - * the closest expressible stand-in for "ambient platform infrastructure" — - * PDP has no workspace-level database, and the app's own project is - * circular (it doesn't exist before the first apply, and is itself tracked - * in the state it would have to host). - */ -const STATE_PROJECT_NAME = 'prisma-composer-state'; - -/** Every connection this bootstrap mints carries this prefix — see `cleanupAgedConnections`. */ -const CONNECTION_NAME_PREFIX = 'prisma-composer-state-'; const CONNECTION_MAX_AGE_MS = 24 * 60 * 60 * 1000; -const DEFAULT_DATABASE_POLL_ATTEMPTS = 5; -const DEFAULT_DATABASE_POLL_DELAY = '500 millis'; - -interface ProjectSummary { - readonly id: string; - readonly name: string; - readonly createdAt: string; - readonly workspace: { readonly id: string }; -} - -interface DatabaseSummary { - readonly id: string; - readonly name: string; - readonly isDefault: boolean; -} - interface ConnectionSummary { readonly id: string; readonly name: string; @@ -46,118 +28,37 @@ export interface StateConnection { readonly connectionString: Redacted.Redacted; } -// ——— Projects ——— - -const listAllProjects = ( - client: ManagementApiClient, -): Effect.Effect => - Effect.gen(function* () { - const projects: ProjectSummary[] = []; - let cursor: string | undefined; - for (;;) { - const query = cursor === undefined ? {} : { cursor }; - const page = yield* call(() => client.GET('/v1/projects', { params: { query } })); - projects.push(...page.data); - if (!page.pagination.hasMore || page.pagination.nextCursor === null) break; - cursor = page.pagination.nextCursor; - } - return projects; - }); - -/** - * Workspace ids circulate in two shapes: the API returns them `wksp_`-prefixed - * (`wksp_abc…`), while tokens/config often carry the bare id (`abc…`) — the - * API accepts both on writes. Comparing them raw silently never matches when - * the shapes differ, which made bootstrap re-provision a fresh state project - * on every run in CI. Compare bare-to-bare. - */ -const bareWorkspaceId = (id: string): string => - id.startsWith('wksp_') ? id.slice('wksp_'.length) : id; - -/** - * All projects named `prisma-composer-state` in the workspace — plural, because PDP - * allows duplicate project names (verified 2026-07-09), so name-based - * discovery can never assume there is at most one. See `resolveStateProject` - * for how candidates get disambiguated. - */ -const listStateProjects = ( - client: ManagementApiClient, - workspaceId: string, -): Effect.Effect => - listAllProjects(client).pipe( - Effect.map((projects) => - projects.filter( - (p) => - bareWorkspaceId(p.workspace.id) === bareWorkspaceId(workspaceId) && - p.name === STATE_PROJECT_NAME, - ), - ), - ); - -const createStateProject = ( - client: ManagementApiClient, - workspaceId: string, -): Effect.Effect => - call(() => - client.POST('/v1/projects', { - body: { name: STATE_PROJECT_NAME, workspaceId }, - }), - ).pipe(Effect.map((r) => r.data)); - // ——— Databases ——— -const listAllDatabases = ( - client: ManagementApiClient, - projectId: string, -): Effect.Effect => - Effect.gen(function* () { - const databases: DatabaseSummary[] = []; - let cursor: string | undefined; - for (;;) { - const query = cursor === undefined ? {} : { cursor }; - const page = yield* call(() => - client.GET('/v1/projects/{projectId}/databases', { - params: { path: { projectId }, query }, - }), - ); - databases.push(...page.data); - if (!page.pagination.hasMore || page.pagination.nextCursor === null) break; - cursor = page.pagination.nextCursor; - } - return databases; - }); - /** - * The project's default database — auto-provisioned at project creation. - * Never create a database here: a project already has exactly one default, - * and creating another 409s (FT-5220). Whether the default is listable in - * the same tick as the project-create response is not a documented - * contract, so a fresh project polls a few times with a short backoff - * before giving up — the observed live behaviour is synchronous, but this - * does not assume that holds on every run. + * Creates the state database on the stage's Branch, using the flat endpoint + * because it is the only one that accepts a `branchId`. The platform still + * creates the row on the project's default Branch and attaches it afterwards, + * so a failed attach can leave a database behind on the default Branch. It + * checks the Branch exists before creating, and this is one request instead of + * two, so that window is much narrower than attaching from here — but it is + * narrowed, not closed. + * + * `isDefault` is left at the API's `false` default: the state database must + * never be the stage's default database, which belongs to the user. */ -const findDefaultDatabase = ( +const createStateDatabase = ( client: ManagementApiClient, projectId: string, + branchId: string, ): Effect.Effect => - Effect.gen(function* () { - for (let attempt = 1; attempt <= DEFAULT_DATABASE_POLL_ATTEMPTS; attempt++) { - const databases = yield* listAllDatabases(client, projectId); - const found = databases.find((d) => d.isDefault); - if (found !== undefined) return found; - if (attempt < DEFAULT_DATABASE_POLL_ATTEMPTS) { - yield* Effect.sleep(DEFAULT_DATABASE_POLL_DELAY); - } - } - return yield* Effect.fail( - new PrismaApiError({ - status: 0, - message: - `project ${projectId} (${STATE_PROJECT_NAME}) has no default database after ` + - `${DEFAULT_DATABASE_POLL_ATTEMPTS} attempts — it may still be provisioning; re-run the deploy.`, - }), - ); - }); + call(() => + client.POST('/v1/databases', { + body: { projectId, name: STATE_DATABASE_NAME, region: 'inherit', branchId }, + }), + ).pipe( + Effect.map((created) => ({ + id: created.data.id, + name: created.data.name, + isDefault: created.data.isDefault, + createdAt: created.data.createdAt, + })), + ); // ——— Connections ——— @@ -189,13 +90,12 @@ const deleteConnection = ( callVoid(() => client.DELETE('/v1/connections/{id}', { params: { path: { id: connectionId } } })); /** - * Every deploy mints a fresh connection (`mintConnection`) and nothing ever - * closes it, so the store's default database otherwise accumulates one - * connection resource per run without bound. Best-effort, never blocks - * bootstrap: lists this database's connections, deletes the ones matching - * our naming pattern older than the age threshold, and swallows any failure - * (a transient API error here must never fail the deploy it's cleaning up - * after). + * Every deploy creates a fresh connection (`createConnection`) and nothing ever + * closes it, so the state database otherwise accumulates one connection + * resource per run without bound. Best-effort, never blocks bootstrap: lists + * this database's connections, deletes the ones matching our naming pattern + * older than the age threshold, and swallows any failure (a transient API + * error here must never fail the deploy it's cleaning up after). */ const cleanupAgedConnections = ( client: ManagementApiClient, @@ -210,37 +110,6 @@ const cleanupAgedConnections = ( yield* Effect.forEach(aged, (c) => deleteConnection(client, c.id), { discard: true }); }).pipe(Effect.ignore); -/** - * Mints a fresh Postgres connection and reads the direct endpoint's DSN. - * Never `endpoints.pooled`, the deprecated top-level `connectionString`/`url` - * (PRO-212) — those are not guaranteed by the platform. The DSN is - * write-only on read (a stored connection can't be re-read later), which is - * exactly why a fresh connection is minted every run instead of reusing one. - */ -const mintConnection = ( - client: ManagementApiClient, - databaseId: string, -): Effect.Effect, PrismaApiError> => - call(() => - client.POST('/v1/databases/{databaseId}/connections', { - params: { path: { databaseId } }, - body: { name: `${CONNECTION_NAME_PREFIX}${Date.now()}` }, - }), - ).pipe( - Effect.flatMap((r) => { - const created = r.data; - const dsn = created.endpoints.direct?.connectionString; - return dsn === undefined - ? Effect.fail( - new PrismaApiError({ - status: 0, - message: `connection ${created.id} returned no endpoints.direct.connectionString (PRO-212)`, - }), - ) - : Effect.succeed(Redacted.make(dsn)); - }), - ); - // ——— Ownership verification ——— export type OwnershipVerdict = @@ -249,16 +118,16 @@ export type OwnershipVerdict = | { readonly kind: 'empty' } | { readonly kind: 'squatter'; readonly tables: readonly string[] }; -/** The seam `resolveStateProject` calls to decide whether a candidate project's default database is ours — see {@link verifyOwnership} for the real implementation and `bootstrap.test.ts` for the stubbed one. */ +/** Decides whether a candidate database is ours. {@link verifyOwnership} is the real implementation; the tests pass a stub instead. */ export type OwnershipVerifier = ( connectionString: Redacted.Redacted, ) => Effect.Effect; /** - * PDP allows duplicate project names, so a project named `prisma-composer-state` + * PDP allows duplicate database names, so a database named `prisma-composer-state` * found by listing is not proof it's ours — it could be an unrelated - * project that happens to share the name (a squatter, deliberate or not). - * Connects to the candidate's default database and inspects its tables: + * database that happens to share the name (a squatter, deliberate or not). + * Connects to the candidate and inspects its tables: * * - our marker table with our marker row present → `ours`, adopt outright. * - our state tables (`alchemy_resource_state`/`alchemy_stack_output`) but no @@ -266,7 +135,7 @@ export type OwnershipVerifier = ( * The real, currently-in-use workspace state is in this shape today, so it * must keep working — adopt it, and `migratePrismaState` (idempotent) * writes the marker on the way in. - * - no tables at all → `empty`, a freshly-created default database — adopt. + * - no tables at all → `empty`, a freshly-created database — adopt. * - anything else → `squatter`: foreign data occupies the name; refuse it. */ export const verifyOwnership: OwnershipVerifier = (connectionString) => @@ -306,104 +175,95 @@ export const verifyOwnership: OwnershipVerifier = (connectionString) => // ——— Orchestration ——— -interface ResolvedStateProject { - readonly project: ProjectSummary; +interface ResolvedStateDatabase { readonly database: DatabaseSummary; readonly connectionString: Redacted.Redacted; } /** - * Finds the workspace's `prisma-composer-state` project, verifying ownership rather - * than trusting the name alone (PDP allows duplicate names — see - * `verifyOwnership`). Zero candidates creates one: nothing to verify, since - * only this run could possibly have touched the brand-new database between - * create and here (`migratePrismaState` writes the marker once bootstrap - * hands the connection off). One or more candidates are tried - * oldest-`createdAt` first, deterministically, and the first that verifies - * as ours (or adoptable legacy/empty) wins; a candidate that fails - * verification is skipped, not fatal, unless every candidate fails, in - * which case the failure names every rejected project id so an operator can - * act on it. + * Finds the Branch's `prisma-composer-state` database, verifying ownership + * rather than trusting the name alone (PDP allows duplicate names — see + * `verifyOwnership`). Finding none creates one, with nothing to verify: only + * this run can have touched a database it just created. + * + * Candidates are tried oldest first, so repeated runs pick the same one. The + * first that verifies as ours is used. A candidate that fails verification is + * skipped. If every candidate fails, bootstrap fails and names each rejected + * database id, so an operator knows which to rename or remove. */ -const resolveStateProject = ( +const resolveStateDatabase = ( client: ManagementApiClient, - workspaceId: string, + projectId: string, + branchId: string, verify: OwnershipVerifier, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { - const candidates = yield* listStateProjects(client, workspaceId); + const candidates = yield* listStateDatabaseCandidates(client, projectId, branchId); if (candidates.length === 0) { - const project = yield* createStateProject(client, workspaceId); - const database = yield* findDefaultDatabase(client, project.id); - const connectionString = yield* mintConnection(client, database.id); - // Wording note here and below: the e2e noop assertion greps deploy - // output for bare create/update verbs, so these lines must not use them. + const database = yield* createStateDatabase(client, projectId, branchId); + const connectionString = yield* createConnection(client, database.id); console.error( - `hosted state: provisioned new state project ${project.id} (db ${database.id}) in workspace ${workspaceId}`, + `hosted state: provisioned state database ${database.id} on branch ${branchId} (project ${projectId})`, ); - return { project, database, connectionString }; + return { database, connectionString }; } - const sorted = [...candidates].sort((a, b) => a.createdAt.localeCompare(b.createdAt)); const rejected: string[] = []; - for (const candidate of sorted) { - const database = yield* findDefaultDatabase(client, candidate.id); - const connectionString = yield* mintConnection(client, database.id); + for (const candidate of candidates) { + const connectionString = yield* createConnection(client, candidate.id); const verdict = yield* verify(connectionString); if (verdict.kind === 'squatter') { - rejected.push( - `${candidate.id} (foreign tables: ${verdict.tables.join(', ') || 'none named'})`, - ); + rejected.push(`${candidate.id} (foreign tables: ${verdict.tables.join(', ')})`); continue; } console.error( - `hosted state: using state project ${candidate.id} (db ${database.id}, ${verdict.kind}) — ` + - `${sorted.length} candidate(s) named ${STATE_PROJECT_NAME} in workspace ${workspaceId}`, + `hosted state: using state database ${candidate.id} on branch ${branchId} (${verdict.kind}) — ` + + `${candidates.length} candidate(s) named ${STATE_DATABASE_NAME}`, ); - return { project: candidate, database, connectionString }; + return { database: candidate, connectionString }; } return yield* Effect.fail( new PrismaApiError({ status: 0, message: - `found ${sorted.length} project(s) named "${STATE_PROJECT_NAME}" in workspace ` + - `${workspaceId}, but none verified as Prisma App's state store: ${rejected.join('; ')}. ` + - 'The name is squatted by unrelated data — rename or remove the offending project(s), ' + - 'or see platform-ask.md (reserved/unique state project names).', + `found ${candidates.length} database(s) named "${STATE_DATABASE_NAME}" on branch ${branchId}, ` + + `but none verified as Composer's state store: ${rejected.join('; ')}. ` + + 'Rename or remove the offending database(s).', }), ); }); /** - * Find-or-create the workspace's `prisma-composer-state` project, resolve its - * default database, and mint a fresh connection — the automatic bootstrap - * every deploy runs once, needing nothing beyond the service token and - * workspace id a deployer already has. + * Resolves the stage's Branch, find-or-creates its `prisma-composer-state` + * database, and creates a fresh connection — the automatic bootstrap every + * deploy runs once, needing nothing beyond the service token and the + * Project/Branch ids the CLI already resolved. */ export const bootstrapStateConnection = ( - workspaceId: string, + container: ResolvedContainer, ): Effect.Effect => - bootstrapStateConnectionWith(workspaceId, verifyOwnership); + bootstrapStateConnectionWith(container, verifyOwnership); /** - * Test seam: identical to {@link bootstrapStateConnection} but with the - * ownership verifier injectable, so `bootstrap.test.ts` can stub ownership - * decisions against its fake DSNs without opening a real Postgres - * connection to them. + * Identical to {@link bootstrapStateConnection}, except the ownership check is + * a parameter so `bootstrap.test.ts` can supply a fake instead of opening a + * real Postgres connection. */ export const bootstrapStateConnectionWith = ( - workspaceId: string, + container: ResolvedContainer, verify: OwnershipVerifier, ): Effect.Effect => Effect.gen(function* () { const client = yield* ManagementClient; - const { project, database, connectionString } = yield* resolveStateProject( + const branchId = yield* resolveBranchId(client, container); + const { database, connectionString } = yield* resolveStateDatabase( client, - workspaceId, + container.projectId, + branchId, verify, ); yield* cleanupAgedConnections(client, database.id); - return { projectId: project.id, databaseId: database.id, connectionString }; + return { projectId: container.projectId, databaseId: database.id, connectionString }; }); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/delete.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/delete.ts new file mode 100644 index 00000000..b704158e --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/delete.ts @@ -0,0 +1,63 @@ +import * as Effect from 'effect/Effect'; +import { type ManagementApiClient, ManagementClient } from '../client.ts'; +import type { ResolvedContainer } from '../container.ts'; +import { callVoid, type PrismaApiError } from '../http.ts'; +import { type OwnershipVerifier, verifyOwnership } from './bootstrap.ts'; +import { + createConnection, + listStateDatabaseCandidates, + resolveBranchId, + STATE_DATABASE_NAME, +} from './discovery.ts'; + +const deleteDatabase = ( + client: ManagementApiClient, + databaseId: string, +): Effect.Effect => + callVoid(() => client.DELETE('/v1/databases/{databaseId}', { params: { path: { databaseId } } })); + +/** + * Removes the stage's state database, so the CLI's destroy leaves nothing + * behind: for a named stage the Branch cannot be deleted while this database + * is still a live member, and for production it would otherwise outlive the + * stage and hold a quota slot. + * + * Every candidate is checked for our ownership marker before deletion — + * deleting by name alone would destroy a user's database that happens to + * share the name. All owned candidates are removed, not just the first: a + * crashed earlier run can leave duplicates, and they are all ours. Finding + * none succeeds, which is what makes a retried destroy a no-op. + */ +export const deleteStateDatabase = ( + container: ResolvedContainer, +): Effect.Effect => + deleteStateDatabaseWith(container, verifyOwnership); + +/** + * Identical to {@link deleteStateDatabase}, except the ownership check is a + * parameter so tests can supply a fake instead of opening a real Postgres + * connection. + */ +export const deleteStateDatabaseWith = ( + container: ResolvedContainer, + verify: OwnershipVerifier, +): Effect.Effect => + Effect.gen(function* () { + const client = yield* ManagementClient; + const branchId = yield* resolveBranchId(client, container); + const candidates = yield* listStateDatabaseCandidates(client, container.projectId, branchId); + + for (const candidate of candidates) { + const connectionString = yield* createConnection(client, candidate.id); + const verdict = yield* verify(connectionString); + if (verdict.kind === 'squatter') { + console.warn( + `hosted state: left database ${candidate.id} on branch ${branchId} alone — it is named ` + + `${STATE_DATABASE_NAME} but holds unrelated data (tables: ${verdict.tables.join(', ')}).`, + ); + continue; + } + yield* deleteDatabase(client, candidate.id); + console.error(`hosted state: removed state database ${candidate.id} from branch ${branchId}`); + } + }); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/discovery.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/discovery.ts new file mode 100644 index 00000000..b3ba4d93 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/discovery.ts @@ -0,0 +1,154 @@ +import * as Effect from 'effect/Effect'; +import * as Redacted from 'effect/Redacted'; +import type { ManagementApiClient } from '../client.ts'; +import type { ResolvedContainer } from '../container.ts'; +import { call, PrismaApiError } from '../http.ts'; + +/** The framework-owned database a stage's deploy state lives in — a child of that stage's Branch (ADR-0034). */ +export const STATE_DATABASE_NAME = 'prisma-composer-state'; + +/** Every connection created against a state database carries this prefix — see `cleanupAgedConnections`. */ +export const CONNECTION_NAME_PREFIX = 'prisma-composer-state-'; + +interface BranchSummary { + readonly id: string; + readonly isDefault: boolean; +} + +export interface DatabaseSummary { + readonly id: string; + readonly name: string; + readonly isDefault: boolean; + readonly createdAt: string; +} + +const listAllBranches = ( + client: ManagementApiClient, + projectId: string, +): Effect.Effect => + Effect.gen(function* () { + const branches: BranchSummary[] = []; + let cursor: string | undefined; + for (;;) { + const query = cursor === undefined ? {} : { cursor }; + const page = yield* call(() => + client.GET('/v1/projects/{projectId}/branches', { + params: { path: { projectId }, query }, + }), + ); + branches.push(...page.data); + if (!page.pagination.hasMore || page.pagination.nextCursor === null) break; + cursor = page.pagination.nextCursor; + } + return branches; + }); + +/** + * The project's implicit default Branch — every live Project owns exactly + * one (a platform invariant). The list endpoint has no `isDefault` filter, so + * this pages through every Branch and picks it out client-side. Never creates + * one: its absence means the platform's invariant is broken, which is not + * something a deploy can repair. + */ +const resolveDefaultBranchId = ( + client: ManagementApiClient, + projectId: string, +): Effect.Effect => + Effect.gen(function* () { + const branches = yield* listAllBranches(client, projectId); + const found = branches.find((b) => b.isDefault); + if (found !== undefined) return found.id; + return yield* Effect.fail( + new PrismaApiError({ + status: 0, + message: `project ${projectId} has no default Branch — the platform guarantees every live Project owns one; contact support.`, + }), + ); + }); + +/** A named stage carries its `branchId`; production omits it, and its state lives on the Project's default Branch. */ +export const resolveBranchId = ( + client: ManagementApiClient, + container: ResolvedContainer, +): Effect.Effect => + container.branchId !== undefined + ? Effect.succeed(container.branchId) + : resolveDefaultBranchId(client, container.projectId); + +/** + * Every database on this Branch. Uses the flat `GET /v1/databases`, which + * accepts `projectId` and `branchId` together — the project-scoped listing + * has no branch filter at all. + */ +const listAllDatabasesOnBranch = ( + client: ManagementApiClient, + projectId: string, + branchId: string, +): Effect.Effect => + Effect.gen(function* () { + const databases: DatabaseSummary[] = []; + let cursor: string | undefined; + for (;;) { + const query = + cursor === undefined ? { projectId, branchId } : { projectId, branchId, cursor }; + const page = yield* call(() => client.GET('/v1/databases', { params: { query } })); + databases.push(...page.data); + if (!page.pagination.hasMore || page.pagination.nextCursor === null) break; + cursor = page.pagination.nextCursor; + } + return databases; + }); + +/** + * Databases on this Branch named `prisma-composer-state`, oldest first, + * excluding the Branch's own default database. The default database is always + * the user's, never ours to adopt or delete. A name match alone proves + * nothing — the platform allows duplicate names — so every caller must still + * verify ownership before acting on a candidate. + */ +export const listStateDatabaseCandidates = ( + client: ManagementApiClient, + projectId: string, + branchId: string, +): Effect.Effect => + listAllDatabasesOnBranch(client, projectId, branchId).pipe( + Effect.map((databases) => + databases + .filter((d) => d.name === STATE_DATABASE_NAME && !d.isDefault) + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)), + ), + ); + +/** + * Creates a fresh Postgres connection and reads its connection string. Reads + * `endpoints.direct.connectionString` only — never `endpoints.pooled`, and + * never the deprecated top-level `connectionString`/`url` (PRO-212), neither + * of which the platform guarantees. + * + * The connection string is returned only when the connection is created and + * cannot be read back afterwards, which is why every run creates a fresh + * connection instead of reusing one. + */ +export const createConnection = ( + client: ManagementApiClient, + databaseId: string, +): Effect.Effect, PrismaApiError> => + call(() => + client.POST('/v1/databases/{databaseId}/connections', { + params: { path: { databaseId } }, + body: { name: `${CONNECTION_NAME_PREFIX}${Date.now()}` }, + }), + ).pipe( + Effect.flatMap((r) => { + const created = r.data; + const dsn = created.endpoints.direct?.connectionString; + return dsn === undefined + ? Effect.fail( + new PrismaApiError({ + status: 0, + message: `connection ${created.id} returned no endpoints.direct.connectionString (PRO-212)`, + }), + ) + : Effect.succeed(Redacted.make(dsn)); + }), + ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/errors.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/errors.ts index 8b7d3897..83d1cd98 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/errors.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/errors.ts @@ -9,17 +9,18 @@ export const toStateStoreError = (cause: unknown): StateStoreError => /** * An operator-facing failure from the hosted-state bootstrap pipeline - * (project/database discovery, connection mint, schema migration, or lock + * (branch/database discovery, connection creation, schema migration, or lock * acquisition) — what a deployer actually sees, instead of a raw Effect * defect. */ export class HostedStateBootstrapError extends Data.TaggedError('HostedStateBootstrapError')<{ - readonly workspaceId: string; + /** The container the state store lives in: a Project id, or `projectId/branchId` for a named stage. */ + readonly container: string; readonly step: string; readonly reason: string; }> { override get message(): string { - return `hosted-state bootstrap failed for workspace ${this.workspaceId}: ${this.step} — ${this.reason}`; + return `hosted-state bootstrap failed in ${this.container}: ${this.step} — ${this.reason}`; } } @@ -31,12 +32,12 @@ export class HostedStateBootstrapError extends Data.TaggedError('HostedStateBoot * into the operator-facing error. */ export const hostedStateBootstrapError = ( - workspaceId: string, + container: string, step: string, cause: unknown, ): HostedStateBootstrapError => new HostedStateBootstrapError({ - workspaceId, + container, step, reason: cause instanceof Error ? cause.message : String(cause), }); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/index.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/index.ts index cf965b3e..f71852bd 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/index.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/index.ts @@ -1,3 +1,5 @@ +export { type OwnershipVerifier, verifyOwnership } from './bootstrap.ts'; +export { deleteStateDatabase, deleteStateDatabaseWith } from './delete.ts'; export { prismaState } from './layer.ts'; export { migratePrismaState } from './schema.ts'; export { makePrismaStateService } from './service.ts'; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts index a078c59a..2bb016b5 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts @@ -15,42 +15,45 @@ import { guardStateService, makePrismaStateService } from './service.ts'; /** * The hosted Alchemy state store. On layer init (scoped, once per stack - * run): find-or-create the workspace's `prisma-composer-state` project, mint a - * fresh connection to its default database, migrate the schema, and - * acquire the (stack, stage) advisory lock — see `bootstrap.ts` and - * `lock.ts`. The Management API plumbing (`ManagementClient`, - * `PrismaCredentials`) is provided internally, so the returned layer's - * only requirements are the ones alchemy itself already provides to every - * state store (`StackServices`). + * run): resolve the stage's Branch, find-or-create its `prisma-composer-state` + * database, create a fresh connection, migrate the schema, and acquire the + * (stack, stage) advisory lock — see `bootstrap.ts` and `lock.ts`. The + * Management API plumbing (`ManagementClient`, `PrismaCredentials`) is + * provided internally, so the returned layer's only requirements are the + * ones alchemy itself already provides to every state store + * (`StackServices`). * * Any bootstrap/lock/migration failure is wrapped into an operator-facing - * `HostedStateBootstrapError` (naming the workspace and the step that + * `HostedStateBootstrapError` (naming the Project/Branch and the step that * failed, never the raw driver/API error — see `errors.ts`) before dying the * layer (loud, immediate, unrecoverable) rather than surfacing as a typed * error — matching core's `LowerOptions.state: Layer.Layer` contract and alchemy's own convention (e.g. a missing * state store is `Effect.die` in `Stack.make`). */ -export const prismaState = ( - opts: { - /** Defaults to the PRISMA_WORKSPACE_ID environment variable. */ - workspaceId?: string; - } = {}, -): Layer.Layer => { - const workspaceId = opts.workspaceId ?? process.env['PRISMA_WORKSPACE_ID']; - if (workspaceId === undefined || workspaceId.length === 0) { - throw new Error('prismaState(): environment variable PRISMA_WORKSPACE_ID is required.'); +export const prismaState = (): Layer.Layer => { + const projectId = process.env['PRISMA_PROJECT_ID']; + if (projectId === undefined || projectId.length === 0) { + throw new Error( + 'prismaState(): environment variable PRISMA_PROJECT_ID is required (the CLI sets it — ' + + 'deploy via `prisma-composer deploy`).', + ); } + const branchIdEnv = process.env['PRISMA_BRANCH_ID']; + const branchId = branchIdEnv === undefined || branchIdEnv.length === 0 ? undefined : branchIdEnv; + return Layer.effect( State, Effect.gen(function* () { const stack = yield* Stack; + const container = branchId === undefined ? projectId : `${projectId}/${branchId}`; const bootstrapError = (step: string) => (cause: unknown) => - hostedStateBootstrapError(workspaceId, step, cause); + hostedStateBootstrapError(container, step, cause); - const { connectionString } = yield* bootstrapStateConnection(workspaceId).pipe( + const bootstrapInput = branchId === undefined ? { projectId } : { projectId, branchId }; + const { connectionString } = yield* bootstrapStateConnection(bootstrapInput).pipe( Effect.provide(client.layer().pipe(Layer.provide(credentials.fromEnv()))), - Effect.mapError(bootstrapError('finding/creating the prisma-composer-state project')), + Effect.mapError(bootstrapError('resolving the state database on the stage branch')), ); // The pool reconnects on demand for ordinary (non-reserved) queries — diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts index 95fbd29e..8b0e783d 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts @@ -91,7 +91,7 @@ describe('invariant 2: authoring imports stay lean (core + pack)', () => { }); describe('invariant 4: environment touches are confined to the config serializer and the control factory', () => { - test("the process-env token appears only in serializer.ts (param read+stash, secret double-lookup+stash, env-sourced param double-lookup), control.ts's prismaCloud(), preflight.ts (shell token + fill-missing lookup), and compute.ts (boot exposes the resolved port as PORT; ADR-0030's accepted-keys re-stash) (the extension factory's env read, ADR-0017 — PRISMA_WORKSPACE_ID + optional PRISMA_REGION; ADR-0019 — PRISMA_PROJECT_ID + optional PRISMA_BRANCH_ID)", () => { + test("the process-env token appears only in serializer.ts (param read+stash, secret double-lookup+stash, env-sourced param double-lookup), control.ts's prismaCloud(), preflight.ts (shell token + fill-missing lookup), teardown.ts (shell token), and compute.ts (boot exposes the resolved port as PORT; ADR-0030's accepted-keys re-stash) (the extension factory's env read, ADR-0017 — PRISMA_WORKSPACE_ID + optional PRISMA_REGION; ADR-0019 — PRISMA_PROJECT_ID + optional PRISMA_BRANCH_ID)", () => { const sources = shippedSources(); expect(sources.length).toBeGreaterThan(0); @@ -106,6 +106,7 @@ describe('invariant 4: environment touches are confined to the config serializer { file: 'control.ts', count: 4 }, { file: 'preflight.ts', count: 2 }, { file: 'serializer.ts', count: 7 }, + { file: 'teardown.ts', count: 1 }, ]); }); }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/teardown.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/teardown.test.ts new file mode 100644 index 00000000..94aa13aa --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/teardown.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, spyOn, test } from 'bun:test'; +import type { ManagementApiClient } from '@internal/lowering'; +import type { OwnershipVerifier } from '@internal/lowering/state'; +import * as Effect from 'effect/Effect'; +import { runTeardown } from '../teardown.ts'; + +/** Every candidate verifies as ours — the real verifier would open a Postgres connection. */ +const ours: OwnershipVerifier = () => Effect.succeed({ kind: 'ours' }); + +interface FakeDatabase { + id: string; + name: string; + isDefault: boolean; + createdAt: string; + branchId: string | null; +} + +interface FakeState { + branches: { id: string; isDefault: boolean }[]; + databases: FakeDatabase[]; + deletedDatabaseIds: string[]; + /** Overrides the database DELETE status — defaults to a 204 success. */ + deleteStatus: number; +} + +const newFakeState = (overrides: Partial = {}): FakeState => ({ + branches: [{ id: 'br-default', isDefault: true }], + databases: [], + deletedDatabaseIds: [], + deleteStatus: 204, + ...overrides, +}); + +const ok = (data: T, status = 200) => ({ + data, + error: undefined, + response: new Response(null, { status }), +}); + +/** + * A stubbed Management API client — test file, exempt from the no-bare-cast + * rule. Answers only the paths teardown's discovery walks: the project's + * branches, the flat database listing, connection creation, and the database + * delete. + */ +const fakeClient = (state: FakeState): ManagementApiClient => + ({ + GET: async (path: string, init: { params?: { query?: Record } }) => { + if (path === '/v1/projects/{projectId}/branches') { + return ok({ data: state.branches, pagination: { nextCursor: null, hasMore: false } }); + } + if (path === '/v1/databases') { + const branchId = init.params?.query?.['branchId']; + return ok({ + data: state.databases.filter((d) => d.branchId === branchId), + pagination: { nextCursor: null, hasMore: false }, + }); + } + throw new Error(`fakeClient: unexpected GET ${path}`); + }, + POST: async (path: string, init: { params?: { path?: Record } }) => { + if (path === '/v1/databases/{databaseId}/connections') { + const databaseId = init.params?.path?.['databaseId'] ?? ''; + return ok({ + data: { + id: `conn-${databaseId}`, + endpoints: { direct: { connectionString: `postgres://fake/${databaseId}` } }, + }, + }); + } + throw new Error(`fakeClient: unexpected POST ${path}`); + }, + DELETE: async (path: string, init: { params?: { path?: Record } }) => { + if (path === '/v1/databases/{databaseId}') { + const databaseId = init.params?.path?.['databaseId'] ?? ''; + if (state.deleteStatus !== 204) { + return { + data: undefined, + error: { code: 'conflict', message: 'refused' }, + response: new Response(null, { status: state.deleteStatus }), + }; + } + state.deletedDatabaseIds.push(databaseId); + return ok(undefined, 204); + } + throw new Error(`fakeClient: unexpected DELETE ${path}`); + }, + }) as unknown as ManagementApiClient; + +const stateDatabase = (id: string, branchId: string): FakeDatabase => ({ + id, + name: 'prisma-composer-state', + isDefault: false, + createdAt: new Date(1).toISOString(), + branchId, +}); + +describe('runTeardown', () => { + let state: FakeState; + + beforeEach(() => { + state = newFakeState(); + }); + + test('a named stage removes the state database on its own branch', async () => { + state.databases.push(stateDatabase('db-stage', 'br-stage')); + + await runTeardown( + { projectId: 'proj-1', branchId: 'br-stage', stage: 'staging' }, + { client: fakeClient(state), verify: ours }, + ); + + expect(state.deletedDatabaseIds).toEqual(['db-stage']); + }); + + test('production removes the state database on the default branch', async () => { + state.databases.push(stateDatabase('db-prod', 'br-default')); + + await runTeardown( + { projectId: 'proj-1', branchId: undefined, stage: undefined }, + { client: fakeClient(state), verify: ours }, + ); + + expect(state.deletedDatabaseIds).toEqual(['db-prod']); + }); + + test('a named stage whose state database cannot be removed fails, naming the cause', async () => { + state.databases.push(stateDatabase('db-stage', 'br-stage')); + state.deleteStatus = 409; + + await expect( + runTeardown( + { projectId: 'proj-1', branchId: 'br-stage', stage: 'staging' }, + { client: fakeClient(state), verify: ours }, + ), + ).rejects.toThrow(/deploy-state database/); + }); + + test('production whose state database cannot be removed warns and does not fail the command', async () => { + state.databases.push(stateDatabase('db-prod', 'br-default')); + state.deleteStatus = 409; + const warnSpy = spyOn(console, 'warn').mockImplementation(() => {}); + + try { + await expect( + runTeardown( + { projectId: 'proj-1', branchId: undefined, stage: undefined }, + { client: fakeClient(state), verify: ours }, + ), + ).resolves.toBeUndefined(); + + expect(warnSpy.mock.calls.flat().join(' ')).toMatch(/deploy-state database/); + } finally { + warnSpy.mockRestore(); + } + }); + + test('finding no state database succeeds, so a repeated destroy is a no-op', async () => { + await runTeardown( + { projectId: 'proj-1', branchId: 'br-stage', stage: 'staging' }, + { client: fakeClient(state), verify: ours }, + ); + + expect(state.deletedDatabaseIds).toEqual([]); + }); +}); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/control.ts b/packages/1-prisma-cloud/1-extensions/target/src/control.ts index fad1b69c..23c7947e 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/control.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/control.ts @@ -21,6 +21,7 @@ import { PgWarmProvider } from './pg-warm-resource.ts'; import { PnMigrationProvider } from './pn-migration-resource.ts'; import { runPreflight } from './preflight.ts'; import { S3CredentialsProvider } from './s3-credentials-resource.ts'; +import { runTeardown } from './teardown.ts'; /** * ADR-0031's registered provisioner for RPC_PEER_KEY: mints one `ServiceKey` @@ -114,6 +115,11 @@ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor // in-shell names via a direct API POST — before any stack file or Alchemy. preflight: (input) => runPreflight(input), + // Destroy-time cleanup (ADR-0034): remove the stage's deploy-state + // database, once alchemy destroy has finished reading it and before the + // CLI removes the Branch/Project. + teardown: (input) => runTeardown(input), + // Runs once per lowering, before any service: references the CLI-ensured // Project, with the poison DATABASE_URL variables written immediately so // nothing can ever rely on the platform default. Per-binding service keys diff --git a/packages/1-prisma-cloud/1-extensions/target/src/teardown.ts b/packages/1-prisma-cloud/1-extensions/target/src/teardown.ts new file mode 100644 index 00000000..087b7fe7 --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/teardown.ts @@ -0,0 +1,76 @@ +/** + * Destroy teardown (ADR-0034): after `alchemy destroy` has removed the stage's + * resources, remove the stage's deploy-state database — the store the destroy + * was reading until a moment ago, and the last thing Composer owns on the + * stage's Branch. + * + * Control-plane only (imported by control.ts → prisma-composer.config.ts); runs + * in the CLI parent, so it builds its own Management API client from env — the + * same credential path preflight uses. + */ +import type { TeardownInput } from '@internal/core/config'; +import { + fromEnv, + type ManagementApiClient, + ManagementClient, + managementClientLayer, +} from '@internal/lowering'; +import { + deleteStateDatabaseWith, + type OwnershipVerifier, + verifyOwnership, +} from '@internal/lowering/state'; +import * as Effect from 'effect/Effect'; +import * as Layer from 'effect/Layer'; + +const tokenRequiredError = (): Error => + new Error('environment variable PRISMA_SERVICE_TOKEN is required for destroy teardown.'); + +async function managementClient(): Promise { + if ((process.env['PRISMA_SERVICE_TOKEN'] ?? '').length === 0) throw tokenRequiredError(); + return Effect.runPromise( + Effect.gen(function* () { + return yield* ManagementClient; + }).pipe(Effect.provide(managementClientLayer().pipe(Layer.provide(fromEnv())))), + ); +} + +/** + * The Prisma Cloud extension's `teardown`. Removes the stage's state database, + * with failure handling that differs by stage because the consequences do: + * + * - **Named stage: throw.** The Branch delete that follows would fail anyway — + * the platform refuses a Branch that still has a database attached — so + * failing here names the actual cause instead of a confusing symptom. + * - **Production: warn and continue.** Nothing blocks the Project delete on + * this; removing the database only stops production's state outliving + * production and holding a quota slot. That is a cleanup step, and a cleanup + * step must not fail the command. + * + * Accepts an injected client and ownership verifier for tests; otherwise + * builds a client from env and verifies against the real database. + */ +export async function runTeardown( + input: TeardownInput, + deps?: { readonly client?: ManagementApiClient; readonly verify?: OwnershipVerifier }, +): Promise { + const isNamedStage = input.branchId !== undefined; + try { + const client = deps?.client ?? (await managementClient()); + await Effect.runPromise( + deleteStateDatabaseWith( + { + projectId: input.projectId, + ...(input.branchId !== undefined ? { branchId: input.branchId } : {}), + }, + deps?.verify ?? verifyOwnership, + ).pipe(Effect.provideService(ManagementClient, client)), + ); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + if (isNamedStage) { + throw new Error(`Failed to delete the deploy-state database: ${reason}`); + } + console.warn(`Could not remove production's deploy-state database: ${reason}`); + } +} diff --git a/skills/prisma-composer/SKILL.md b/skills/prisma-composer/SKILL.md index 4462ce38..29c8c36f 100644 --- a/skills/prisma-composer/SKILL.md +++ b/skills/prisma-composer/SKILL.md @@ -243,7 +243,7 @@ import { prismaCloud, prismaState } from '@prisma/composer-prisma-cloud/control' export default defineConfig({ extensions: [prismaCloud(), nodeBuild()], - state: () => prismaState(), // workspace-hosted deploy state, shared by every deployer + state: () => prismaState(), // deploy state, in its own database on the stage's branch }); ```