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..2295aa91 --- /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-0033** 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-0033'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..ea4ae690 --- /dev/null +++ b/.drive/projects/state-under-branch/plan.md @@ -0,0 +1,125 @@ +# State Under Branch — Project Plan + +## Summary + +**One slice** — ADR-0033 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-0033 + documentation corrections — DONE, APPROVED + +ADR-0033 ("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): 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..f46d05c0 --- /dev/null +++ b/.drive/projects/state-under-branch/slices/adr-supersession/spec.md @@ -0,0 +1,80 @@ +# S1 — ADR-0033 + 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-0033-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-0033 (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-0033. +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-0033 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..8ba4749b --- /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-0033 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..1a2815dc --- /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-0033 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/docs/design/03-domain-model/layering.md b/docs/design/03-domain-model/layering.md index de2514f9..83743560 100644 --- a/docs/design/03-domain-model/layering.md +++ b/docs/design/03-domain-model/layering.md @@ -121,31 +121,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-0033), + 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..36c26814 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-0033](../90-decisions/ADR-0033-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-0033) — 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..6a88431d 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-0033](../90-decisions/ADR-0033-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..dd10db6a 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-0033](ADR-0033-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..7b0d7b81 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-0033](ADR-0033-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-0033](ADR-0033-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-0033`](ADR-0033-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..f50db1ea 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-0033](ADR-0033-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..f316b700 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-0033](ADR-0033-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..9443d3fa 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-0033](ADR-0033-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-0033](ADR-0033-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..5934ba07 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-0033) — 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-0033](ADR-0033-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-0033-deploy-state-lives-in-the-stage-branch.md b/docs/design/90-decisions/ADR-0033-deploy-state-lives-in-the-stage-branch.md new file mode 100644 index 00000000..a9433c5d --- /dev/null +++ b/docs/design/90-decisions/ADR-0033-deploy-state-lives-in-the-stage-branch.md @@ -0,0 +1,170 @@ +# ADR-0033: 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 10a543b2..26b6b792 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-0033: 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,6 +51,7 @@ _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-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 fd2d1b26..8ba3c942 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 @@ -76,8 +81,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. @@ -145,6 +151,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/3-tooling/cli/src/__tests__/ensure-containers.test.ts b/packages/0-framework/3-tooling/cli/src/__tests__/ensure-containers.test.ts index 2e74ea5d..281c32f0 100644 --- a/packages/0-framework/3-tooling/cli/src/__tests__/ensure-containers.test.ts +++ b/packages/0-framework/3-tooling/cli/src/__tests__/ensure-containers.test.ts @@ -4,6 +4,7 @@ import { CliError } from '../cli-error.ts'; import { deleteAppProject, deleteStageBranch, + deleteStageStateDatabase, ensureContainers, validateStageName, } from '../ensure-containers.ts'; @@ -19,11 +20,14 @@ interface FakeBranch { id: string; gitName: string; createdAt: string; + isDefault?: boolean; } interface FakeState { projects: FakeProject[]; branches: Record; + /** Answers the flat `GET /v1/databases` — empty unless a test puts one there. */ + databases: { id: string; name: string; isDefault: boolean; createdAt: string }[]; projectCreateCalls: number; branchCreateCalls: number; deleteBranchCalls: string[]; @@ -37,6 +41,7 @@ interface FakeState { const newFakeState = (overrides: Partial = {}): FakeState => ({ projects: [], branches: {}, + databases: [], projectCreateCalls: 0, branchCreateCalls: 0, deleteBranchCalls: [], @@ -81,6 +86,11 @@ const fakeClient = (state: FakeState): ManagementApiClient => { okResponse({ data, pagination: { nextCursor: null, hasMore: false } }), ); } + if (path === '/v1/databases') { + return Promise.resolve( + okResponse({ data: state.databases, pagination: { nextCursor: null, hasMore: false } }), + ); + } throw new Error(`fakeClient: unexpected GET ${path}`); }; @@ -311,6 +321,38 @@ describe('ensureContainers()', () => { }); }); +describe('deleteStageStateDatabase()', () => { + test('missing PRISMA_SERVICE_TOKEN (no injected client) is a CliError', async () => { + await expect( + deleteStageStateDatabase({ projectId: 'proj-1', branchId: 'br-1', env: {} }), + ).rejects.toThrow(/PRISMA_SERVICE_TOKEN/); + }); + + test('a branch with no state database on it succeeds, so a repeated destroy is a no-op', async () => { + const state = newFakeState(); + + await expect( + deleteStageStateDatabase( + { projectId: 'proj-1', branchId: 'br-1' }, + { client: fakeClient(state) }, + ), + ).resolves.toBeUndefined(); + }); + + test('a Management API failure becomes a CliError naming the step', async () => { + // No default branch on the project, and no branchId given — the lookup fails. + const state = newFakeState(); + + const error: unknown = await deleteStageStateDatabase( + { projectId: 'proj-1' }, + { client: fakeClient(state) }, + ).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(CliError); + expect((error as CliError).message).toContain('Failed to delete the deploy-state database'); + }); +}); + describe('deleteStageBranch()', () => { test('missing PRISMA_SERVICE_TOKEN (no injected client) is a CliError', async () => { await expect(deleteStageBranch({ branchId: 'br-1', env: {} })).rejects.toThrow( 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..b4108f22 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 @@ -481,6 +481,7 @@ describe('run() — the full pipeline over fakes', () => { runAssembler: fakeAssembler, ensureContainers: fakeEnsureContainers, alchemy: () => 0, + deleteStateDatabase: async () => {}, deleteProject: async () => {}, }); @@ -552,6 +553,7 @@ describe('run() — the full pipeline over fakes', () => { alchemyCalls.push(input); return 0; }, + deleteStateDatabase: async () => {}, deleteProject: async () => {}, }); @@ -583,6 +585,7 @@ describe('run() — the full pipeline over fakes', () => { alchemyCalls.push(input); return 0; }, + deleteStateDatabase: async () => {}, deleteBranch: async () => {}, }); @@ -604,6 +607,7 @@ describe('run() — the full pipeline over fakes', () => { runAssembler: fakeAssembler, ensureContainers: fakeEnsureContainers, alchemy: () => 0, + deleteStateDatabase: async () => {}, deleteBranch: async (input) => { deleteCalls.push(input); }, @@ -623,6 +627,7 @@ describe('run() — the full pipeline over fakes', () => { runAssembler: fakeAssembler, ensureContainers: fakeEnsureContainers, alchemy: () => 0, + deleteStateDatabase: async () => {}, deleteBranch: async (input) => { deleteCalls.push(input); }, @@ -683,6 +688,7 @@ describe('run() — the full pipeline over fakes', () => { runAssembler: fakeAssembler, ensureContainers: fakeEnsureContainers, alchemy: () => 0, + deleteStateDatabase: async () => {}, deleteProject: async (input) => { deleteCalls.push(input); }, @@ -702,6 +708,7 @@ describe('run() — the full pipeline over fakes', () => { runAssembler: fakeAssembler, ensureContainers: fakeEnsureContainers, alchemy: () => 0, + deleteStateDatabase: async () => {}, deleteBranch: async () => {}, deleteProject: async (input) => { deleteCalls.push(input); @@ -750,4 +757,148 @@ describe('run() — the full pipeline over fakes', () => { expect(deleteCalls).toEqual([]); }); }); + + describe('removing the deploy-state database after a destroy (ADR-0033)', () => { + test('destroy --stage staging removes the state database before the Branch', async () => { + const app = makeAppDir(); + process.chdir(app.dir); + const order: string[] = []; + + const status = await run(['destroy', app.entryPath, '--stage', 'staging'], { + config: fakeConfig(), + runAssembler: fakeAssembler, + ensureContainers: fakeEnsureContainers, + alchemy: () => { + order.push('alchemy'); + return 0; + }, + deleteStateDatabase: async (input) => { + order.push(`state:${input.projectId}/${input.branchId}`); + }, + deleteBranch: async () => { + order.push('branch'); + }, + }); + + expect(status).toBe(0); + expect(order).toEqual(['alchemy', 'state:proj-fake/branch-staging', 'branch']); + }); + + test('destroy --production removes the state database before the Project, naming no branch', async () => { + const app = makeAppDir(); + process.chdir(app.dir); + const order: string[] = []; + + const status = await run(['destroy', app.entryPath, '--production'], { + config: fakeConfig(), + runAssembler: fakeAssembler, + ensureContainers: fakeEnsureContainers, + alchemy: () => { + order.push('alchemy'); + return 0; + }, + deleteStateDatabase: async (input) => { + order.push(`state:${input.projectId}/${input.branchId ?? 'default'}`); + }, + deleteProject: async () => { + order.push('project'); + }, + }); + + expect(status).toBe(0); + expect(order).toEqual(['alchemy', 'state:proj-fake/default', 'project']); + }); + + test('a named stage whose state database cannot be removed fails, and its Branch is left alone', async () => { + const app = makeAppDir(); + process.chdir(app.dir); + let branchDeleted = false; + + await expect( + run(['destroy', app.entryPath, '--stage', 'staging'], { + config: fakeConfig(), + runAssembler: fakeAssembler, + ensureContainers: fakeEnsureContainers, + alchemy: () => 0, + deleteStateDatabase: async () => { + throw new CliError('Failed to delete the deploy-state database: boom.'); + }, + deleteBranch: async () => { + branchDeleted = true; + }, + }), + ).rejects.toThrow(/deploy-state database/); + + expect(branchDeleted).toBe(false); + }); + + test('production whose state database cannot be removed warns and still removes the Project', async () => { + const app = makeAppDir(); + process.chdir(app.dir); + fs.mkdirSync(path.join(app.dir, '.alchemy'), { recursive: true }); + fs.writeFileSync(path.join(app.dir, '.alchemy', 'state.json'), '{}'); + const warnSpy = spyOn(console, 'warn').mockImplementation(() => {}); + let projectDeleted = false; + + try { + const status = await run(['destroy', app.entryPath, '--production'], { + config: fakeConfig(), + runAssembler: fakeAssembler, + ensureContainers: fakeEnsureContainers, + alchemy: () => 0, + deleteStateDatabase: async () => { + throw new CliError('Failed to delete the deploy-state database: boom.'); + }, + deleteProject: async () => { + projectDeleted = true; + }, + }); + + expect(status).toBe(0); + expect(projectDeleted).toBe(true); + expect(warnSpy.mock.calls.flat().join(' ')).toMatch(/deploy-state database/); + } finally { + warnSpy.mockRestore(); + } + }); + + test('a FAILED alchemy destroy removes no state database', async () => { + const app = makeAppDir(); + process.chdir(app.dir); + let stateDeleted = false; + + const status = await run(['destroy', app.entryPath, '--stage', 'staging'], { + config: fakeConfig(), + runAssembler: fakeAssembler, + ensureContainers: fakeEnsureContainers, + alchemy: () => 1, + deleteStateDatabase: async () => { + stateDeleted = true; + }, + deleteBranch: async () => {}, + }); + + expect(status).toBe(1); + expect(stateDeleted).toBe(false); + }); + + test('deploy never removes a state database', async () => { + const app = makeAppDir(); + process.chdir(app.dir); + let stateDeleted = false; + + const status = await run(['deploy', app.entryPath, '--stage', 'staging'], { + config: fakeConfig(), + runAssembler: fakeAssembler, + ensureContainers: fakeEnsureContainers, + alchemy: () => 0, + deleteStateDatabase: async () => { + stateDeleted = true; + }, + }); + + expect(status).toBe(0); + expect(stateDeleted).toBe(false); + }); + }); }); diff --git a/packages/0-framework/3-tooling/cli/src/ensure-containers.ts b/packages/0-framework/3-tooling/cli/src/ensure-containers.ts index 5a13b3e9..a1a1536a 100644 --- a/packages/0-framework/3-tooling/cli/src/ensure-containers.ts +++ b/packages/0-framework/3-tooling/cli/src/ensure-containers.ts @@ -14,6 +14,7 @@ import { type ResolvedContainer, resolveContainer, } from '@internal/lowering'; +import { deleteStateDatabase } from '@internal/lowering/state'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import { CliError } from './cli-error.ts'; @@ -89,6 +90,45 @@ export async function ensureContainers( return outcome.container; } +/** + * Removes the stage's deploy-state database after a successful `alchemy + * destroy` (ADR-0033). Runs before the Branch/Project delete: alchemy reads + * state to know what to remove, so the store must outlive every resource it + * describes, and the Branch delete is refused while the database is still a + * live member. + */ +export async function deleteStageStateDatabase( + input: { + readonly projectId: string; + readonly branchId?: string; + readonly env?: NodeJS.ProcessEnv; + }, + deps?: { readonly client?: ManagementApiClient }, +): Promise { + const env = input.env ?? process.env; + if (deps?.client === undefined && (env['PRISMA_SERVICE_TOKEN'] ?? '').length === 0) { + throw new CliError('environment variable PRISMA_SERVICE_TOKEN is required.'); + } + const program = deleteStateDatabase({ + projectId: input.projectId, + ...(input.branchId !== undefined ? { branchId: input.branchId } : {}), + }).pipe( + Effect.map(() => ({ ok: true as const })), + Effect.catchTag('PrismaApiError', (e) => + Effect.succeed({ + ok: false as const, + message: `Failed to delete the deploy-state database: ${e.message}.`, + }), + ), + ); + const provided = + deps?.client !== undefined + ? program.pipe(Effect.provideService(ManagementClient, deps.client)) + : program.pipe(Effect.provide(managementClientLayer().pipe(Layer.provide(fromEnv())))); + const outcome = await Effect.runPromise(provided); + if (!outcome.ok) throw new CliError(outcome.message); +} + /** * Soft-deletes a named stage's Branch after a successful `alchemy destroy` * has removed its members (spec §10) — the Management API refuses to delete diff --git a/packages/0-framework/3-tooling/cli/src/main.ts b/packages/0-framework/3-tooling/cli/src/main.ts index ee698649..52d7da3b 100644 --- a/packages/0-framework/3-tooling/cli/src/main.ts +++ b/packages/0-framework/3-tooling/cli/src/main.ts @@ -14,6 +14,7 @@ import { CliError } from './cli-error.ts'; import { deleteAppProject, deleteStageBranch, + deleteStageStateDatabase, type EnsureContainersInput, ensureContainers, } from './ensure-containers.ts'; @@ -136,6 +137,7 @@ export interface RunDeps { readonly runAssembler?: RunAssembler; readonly ensureContainers?: (input: EnsureContainersInput) => Promise; readonly alchemy?: (input: RunAlchemyInput) => number; + readonly deleteStateDatabase?: (input: { projectId: string; branchId?: string }) => Promise; readonly deleteBranch?: (input: { branchId: string }) => Promise; readonly deleteProject?: (input: { projectId: string }) => Promise; /** Substituted for the c12 evaluation of the discovered config file (discovery itself still runs — the generated stack file needs the real path). */ @@ -298,9 +300,30 @@ export async function run(argv: readonly string[], deps: RunDeps = {}): Promise< ); return status; } + // The state store is deleted last among this stage's members and before + // its container: alchemy has just read it to drive the destroy above, and + // the platform refuses to delete a Branch that still has live members. if (args.command === 'destroy' && branchId !== undefined) { + await (deps.deleteStateDatabase ?? ((input) => deleteStageStateDatabase(input)))({ + projectId, + branchId, + }); await (deps.deleteBranch ?? ((input) => deleteStageBranch(input)))({ branchId }); } else if (args.command === 'destroy') { + // Production's state delete only tidies up (nothing blocks the Project + // delete on it), so it inherits deleteAppProject's best-effort stance — + // failing the command over a cleanup step would be worse. + try { + await (deps.deleteStateDatabase ?? ((input) => deleteStageStateDatabase(input)))({ + projectId, + }); + } catch (error) { + console.warn( + `Could not remove production's deploy-state database: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } await (deps.deleteProject ?? ((input) => deleteAppProject(input)))({ projectId }); } return status; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/Database.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/Database.test.ts index 7c766b01..274d92be 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/Database.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/Database.test.ts @@ -33,6 +33,10 @@ const notFoundResponse = () => ({ * recording every call it receives — the container.test.ts fake-client * idiom. `as unknown as ManagementApiClient` is acceptable here (test file * — exempt from the no-bare-cast rule). + * + * The project-scoped create route is deliberately absent: it cannot carry a + * branchId, so reaching for it is the bug these tests exist to catch, and the + * throw below turns that into a failure rather than a silent pass. */ const fakeClient = (state: FakeState): ManagementApiClient => { const GET = (path: string) => { @@ -47,7 +51,7 @@ const fakeClient = (state: FakeState): ManagementApiClient => { const POST = (path: string, init: { body?: Record } = {}) => { state.calls.push({ method: 'POST', path, body: init.body }); - if (path === '/v1/projects/{projectId}/databases') { + if (path === '/v1/databases') { return Promise.resolve( okResponse({ data: { id: 'db-created', name: String(init.body?.['name']) } }, 201), ); @@ -82,29 +86,58 @@ const reconcile = async ( return Effect.runPromise(svc.reconcile(input as unknown as Parameters[0])); }; -describe('Database reconcile — Branch attachment via PATCH', () => { +describe('Database reconcile — Branch attachment', () => { let state: FakeState; beforeEach(() => { state = { calls: [] }; }); - test('branchId set, no prior output: creates, then PATCHes the Branch', async () => { + test('branchId set, no prior output: names the Branch in the create, and issues no PATCH', async () => { const result = await reconcile(state, { news: { projectId: 'proj-1', name: 'db', region: 'us-east-1', branchId: 'br-1' }, output: undefined, }); expect(result).toEqual({ id: 'db-created', name: 'db' }); - expect(state.calls.map((c) => c.method)).toEqual(['POST', 'PATCH']); - expect(state.calls[0]?.body).toEqual({ name: 'db', region: 'us-east-1' }); - expect(state.calls[1]).toEqual({ - method: 'PATCH', - path: '/v1/databases/{databaseId}', - body: { branchId: 'br-1' }, + // One request, not create-then-PATCH: a create without a branchId lands + // the database on the project's default Branch — production's — and a + // failed PATCH would leave it there. + expect(state.calls).toEqual([ + { + method: 'POST', + path: '/v1/databases', + body: { projectId: 'proj-1', name: 'db', region: 'us-east-1', branchId: 'br-1' }, + }, + ]); + }); + + test('isDefault set: rides the same create body', async () => { + await reconcile(state, { + news: { + projectId: 'proj-1', + name: 'db', + region: 'us-east-1', + branchId: 'br-1', + isDefault: true, + }, + output: undefined, + }); + + expect(state.calls[0]?.body).toEqual({ + projectId: 'proj-1', + name: 'db', + region: 'us-east-1', + isDefault: true, + branchId: 'br-1', }); }); + // The PATCH survives on the observed path only. There is no create here, so + // nothing can be stranded by a failed move; the database already exists and + // its id is already in state, so a failure fails the deploy and the next run + // retries. It stays because it is what moves a database that drifted off its + // Branch back onto it. test('branchId set, prior output exists: observes, and still PATCHes (idempotent/self-healing)', async () => { state.observed = { id: 'db-existing', name: 'db' }; @@ -122,15 +155,20 @@ describe('Database reconcile — Branch attachment via PATCH', () => { }); }); - test('branchId unset, no prior output: creates and issues no PATCH', async () => { + test('branchId unset, no prior output: creates without one and issues no PATCH', async () => { const result = await reconcile(state, { news: { projectId: 'proj-1', name: 'db', region: 'us-east-1' }, output: undefined, }); expect(result).toEqual({ id: 'db-created', name: 'db' }); - expect(state.calls.map((c) => c.method)).toEqual(['POST']); - expect(state.calls.filter((c) => c.method === 'PATCH')).toHaveLength(0); + expect(state.calls).toEqual([ + { + method: 'POST', + path: '/v1/databases', + body: { projectId: 'proj-1', name: 'db', region: 'us-east-1' }, + }, + ]); }); test('branchId unset, prior output exists: observes and issues no PATCH', async () => { diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Database.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Database.ts index aff2d73f..4deb965c 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Database.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Database.ts @@ -49,23 +49,31 @@ export const DatabaseProvider = () => }), ) : undefined; - let result: DatabaseAttributes; - if (observed) { - result = { id: observed.data.id, name: observed.data.name }; - } else { + if (!observed) { + // Name the Branch in the create body — NOT a later PATCH. A + // database created without a branchId is born on the project's + // default Branch, which on a named stage is production's, so + // create-then-PATCH stranded the database there whenever the move + // failed or the CLI died in between. This flat route takes a + // branchId and validates it before creating the row; the + // project-scoped one takes no branch parameters at all. The + // platform still attaches in a second step of its own, so this + // narrows the window rather than closing it. const created = yield* call(() => - client.POST('/v1/projects/{projectId}/databases', { - params: { path: { projectId: news.projectId } }, + client.POST('/v1/databases', { body: { + projectId: news.projectId, name: news.name, region: news.region, ...(news.isDefault !== undefined && { isDefault: news.isDefault }), + ...(news.branchId !== undefined && { branchId: news.branchId }), }, }), ); - result = { id: created.data.id, name: created.data.name }; + return { id: created.data.id, name: created.data.name }; } + const result: DatabaseAttributes = { id: observed.data.id, name: observed.data.name }; if (news.branchId !== undefined) { const branchId = news.branchId; yield* call(() => 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..fd682851 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 { 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 type { StateTarget } from '../discovery.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 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, + target: StateTarget = { projectId: PROJECT_ID }, +) => Effect.runPromise( - bootstrapStateConnectionWith(workspaceId, verify).pipe( + bootstrapStateConnectionWith(target, 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: (target: StateTarget) => 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..aef77755 --- /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 { OwnershipVerifier } from '../bootstrap.ts'; +import { deleteStateDatabase, deleteStateDatabaseWith } from '../delete.ts'; +import type { StateTarget } from '../discovery.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, + target: StateTarget = { projectId: PROJECT_ID }, +) => + Effect.runPromise( + deleteStateDatabaseWith(target, 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: (target: StateTarget) => 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..dbb712cd 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 @@ -3,37 +3,19 @@ import * as Redacted from 'effect/Redacted'; import postgres from 'postgres'; import { type ManagementApiClient, ManagementClient } from '../client.ts'; import { call, callVoid, PrismaApiError } from '../http.ts'; +import { + CONNECTION_NAME_PREFIX, + type DatabaseSummary, + listStateDatabaseCandidates, + mintConnection, + resolveBranchId, + STATE_DATABASE_NAME, + type StateTarget, +} 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 ——— @@ -190,12 +91,11 @@ const deleteConnection = ( /** * 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). + * 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,86 +175,76 @@ 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 database = yield* createStateDatabase(client, projectId, branchId); 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. 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* mintConnection(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 mints 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, + target: StateTarget, ): Effect.Effect => - bootstrapStateConnectionWith(workspaceId, verifyOwnership); + bootstrapStateConnectionWith(target, verifyOwnership); /** * Test seam: identical to {@link bootstrapStateConnection} but with the @@ -394,16 +253,18 @@ export const bootstrapStateConnection = ( * connection to them. */ export const bootstrapStateConnectionWith = ( - workspaceId: string, + target: StateTarget, verify: OwnershipVerifier, ): Effect.Effect => Effect.gen(function* () { const client = yield* ManagementClient; - const { project, database, connectionString } = yield* resolveStateProject( + const branchId = yield* resolveBranchId(client, target); + const { database, connectionString } = yield* resolveStateDatabase( client, - workspaceId, + target.projectId, + branchId, verify, ); yield* cleanupAgedConnections(client, database.id); - return { projectId: project.id, databaseId: database.id, connectionString }; + return { projectId: target.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..fe57e7e1 --- /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 { callVoid, type PrismaApiError } from '../http.ts'; +import { type OwnershipVerifier, verifyOwnership } from './bootstrap.ts'; +import { + listStateDatabaseCandidates, + mintConnection, + resolveBranchId, + STATE_DATABASE_NAME, + type StateTarget, +} 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 = ( + target: StateTarget, +): Effect.Effect => + deleteStateDatabaseWith(target, verifyOwnership); + +/** + * Test seam: identical to {@link deleteStateDatabase} but with the ownership + * verifier injectable, so the tests can stub ownership decisions against fake + * DSNs without opening a real Postgres connection to them. + */ +export const deleteStateDatabaseWith = ( + target: StateTarget, + verify: OwnershipVerifier, +): Effect.Effect => + Effect.gen(function* () { + const client = yield* ManagementClient; + const branchId = yield* resolveBranchId(client, target); + const candidates = yield* listStateDatabaseCandidates(client, target.projectId, branchId); + + for (const candidate of candidates) { + const connectionString = yield* mintConnection(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..327483ca --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/discovery.ts @@ -0,0 +1,158 @@ +import * as Effect from 'effect/Effect'; +import * as Redacted from 'effect/Redacted'; +import type { ManagementApiClient } from '../client.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-0033). */ +export const STATE_DATABASE_NAME = 'prisma-composer-state'; + +/** Every connection minted against a state database carries this prefix — see `cleanupAgedConnections`. */ +export const CONNECTION_NAME_PREFIX = 'prisma-composer-state-'; + +/** Which stage's state database to act on. A named stage passes its `branchId`; production omits it, and its state lives on the Project's default Branch. */ +export interface StateTarget { + readonly projectId: string; + readonly branchId?: string; +} + +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.`, + }), + ); + }); + +export const resolveBranchId = ( + client: ManagementApiClient, + target: StateTarget, +): Effect.Effect => + target.branchId !== undefined + ? Effect.succeed(target.branchId) + : resolveDefaultBranchId(client, target.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)), + ), + ); + +/** + * Mints 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 mints a fresh + * connection instead of reusing one. + */ +export 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)); + }), + ); 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..5ad36b61 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 mint, 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 stage the state store was being resolved for: a project id, or `projectId/branchId` for a named stage. */ + readonly target: 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 for ${this.target}: ${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, + target: string, step: string, cause: unknown, ): HostedStateBootstrapError => new HostedStateBootstrapError({ - workspaceId, + target, 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..aed63335 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 { deleteStateDatabase } from './delete.ts'; +export type { StateTarget } from './discovery.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..90912425 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, mint 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 target = branchId === undefined ? projectId : `${projectId}/${branchId}`; const bootstrapError = (step: string) => (cause: unknown) => - hostedStateBootstrapError(workspaceId, step, cause); + hostedStateBootstrapError(target, 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/skills/prisma-composer/SKILL.md b/skills/prisma-composer/SKILL.md index 562b5d5b..38416eeb 100644 --- a/skills/prisma-composer/SKILL.md +++ b/skills/prisma-composer/SKILL.md @@ -226,7 +226,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 }); ```