Skip to content

fix(postgres): the user database names its Branch in the create#122

Closed
wmadden-electric wants to merge 7 commits into
mainfrom
claude/flat-database-create-on-113
Closed

fix(postgres): the user database names its Branch in the create#122
wmadden-electric wants to merge 7 commits into
mainfrom
claude/flat-database-create-on-113

Conversation

@wmadden-electric

Copy link
Copy Markdown
Contributor

Stacks on #113. The two-step create in postgres/Database.tsPOST /v1/projects/{projectId}/databases then PATCH /v1/databases/{databaseId} { branchId } — is the same shape ADR-0033 removed from the state database, and it carries the same unsafe window for user databases. #113 filed it as a known latent bug and left it out of scope; this closes it.

Why the two-step is unsafe

A database created without a branchId is born on the project's default Branch, never branchlesscreatePrismaPostgresDatabase calls getOrCreateDefaultBranch inside its create transaction. ADR-0033 records the same platform fact (line 46). So on a named-stage deploy the two-step put the user's database on the default Branch — production's — and only moved it on the PATCH. A failed PATCH, or a CLI that died between the two calls, stranded a user database on production's Branch.

The fix

Use the flat POST /v1/databases, which takes projectId, name, region ('inherit' supported), isDefault, and branchId in one request and validates the Branch before creating the row. This is what state/bootstrap.ts's createStateDatabase does — the reference for this change.

The flat endpoint is not atomic: the platform still creates the row and attaches the Branch in two server-side steps. This narrows the window — one request, Branch validated before the row exists, no client-crash window between two calls — it does not close it. A failed attach can still leave a stray empty database, which costs a quota slot rather than correctness: the same residual #113 accepted for the state database. The comment in the code says exactly this and does not claim atomicity.

The PATCH stays on the observed path only, where there is no create to strand anything and it is what moves a database that drifted off its Branch back onto it.

Scope checked

compute/ComputeService.ts and compute/EnvironmentVariable.ts already name the Branch in their create bodies — ComputeService with a comment explaining why — so neither needed a change. Only postgres/Database.ts had the two-step.

Verified

pnpm typecheck clean (which is what proves the flat body conforms to the SDK's postV1Databases schema, branchId included), 123/123 lowering tests pass, cast ratchet delta=0. The tests were confirmed to catch the regression, not merely pass: reverting the source alone fails 3 of 5, and the fake client now throws on the project-scoped route so reaching for it fails loudly.

One follow-up for #113's description

#113's "Known and accepted" section still says postgres/Database.ts "carries the same two-step … Out of scope … filed." Merging this makes that false. Worth updating that bullet to point here.

🤖 Generated with Claude Code

Deploy state moves from the workspace-level prisma-composer-state project
into a framework-owned database of the same name inside each stage's own
Branch; production's rides the Project's implicit default Branch. State then
has exactly the lifetime of the environment it describes: deleting a Branch
or Project — from the CLI, the Console, or any future git integration —
takes its state with it, with no knowledge of the framework on the platform
side.

ADR-0009 rejected this as circular ("the project is created and destroyed by
the deploys whose state it would hold"). ADR-0023/0024 dissolved that: the
CLI creates the containers via the Management API before Alchemy runs and
deletes them after destroy, outside state entirely. The remaining objection
(fragmentation of workspace-level questions) answers to the platform — those
are Project and Branch listings, not state queries.

ADR-0009 is marked superseded and annotated in the index; ADR-0010 records
that the lock now lives in a per-stage store (redundant key kept) and that
deleting the store severs the lease, killing an in-flight deploy of a stage
being torn down. ADR-0024 gains the distinction the PDP team confirmed: "the
default stage lives at the Project level" describes addressing, not physics
— the platform attaches every resource to a Branch, defaulting to the
Project's implicit one.

The sweep for prose describing the workspace store as current also reached
ADR-0011, ADR-0023, ADR-0030 (service-key values), layering.md's
provisioning-state spectrum, and alchemy-lowering.md. The deploying guide
gains the destroy ordering and a manual cutover procedure — there is no
automated migration.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Two slices: this one (ADR-0033 and the documentation corrections) and the
implementation of the per-branch bootstrap plus the destroy ordering the ADR
commits us to. design-notes.md carries the implementation-grade design the
second slice executes, including the platform facts it rests on, verified
against pdp-control-plane rather than inferred.

The plan also records the successor project — the Compute GitHub App
integration for Composer repos — which this project is a prerequisite for:
containment makes webhook-driven branch teardown correct, and project-scoped
state is what lets a build hold a project-scoped credential instead of a
workspace-scoped one.

assets/pdp-asks.md is the standalone write-up of the two open questions for
the PDP team (per-stage database quota/billing; the dependency contract of
project deletion).

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Both questions this project was going to ask the PDP team are answerable from
their source, so they are answered rather than asked.

The plan limit on databases is workspace-wide — counted across every project —
so each stage's state store takes one of the workspace's 50 (free) or 1000
(paid) slots. Nothing bills per database: `createDatabase` carries a limit and
never a price, and Postgres bills storage in GiB-hours and queries per cycle,
both of which round to nothing for a few rows of JSON read only at deploy.
The case that can bite is a free-plan workspace running many preview stages
against the same 50 slots, so the ADR says that rather than "one extra small
database, no big deal".

The second answer corrects something I had wrong. Project deletion is blocked
only by *active compute deployments*: deleteProject deletes every one of the
project's database tenants itself, and deleteWithGuard refuses only on a live
ComputeVersion, then cascades. Neither Branches nor databases ever block it —
the schema comment on App.branchId says so outright. So the production state
database was never going to wedge deleteAppProject the way I claimed; that
step stays because production's state should not outlive production and hold a
slot when another stage keeps the Project alive. The named-stage step is
unaffected and genuinely is a precondition: DELETE /v1/branches does refuse a
Branch with live members.

The design notes now also point at the pdp-control-plane checkout in this
worktree rather than the older one in ~/Projects — they had already diverged.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Implements ADR-0033. Bootstrap no longer hunts the workspace for a project
named prisma-composer-state; it resolves the state database inside the Branch
the CLI already resolved for the deploy, and destroy removes that database
before the container it lives in.

`prismaState()` now reads PRISMA_PROJECT_ID and (named stages only)
PRISMA_BRANCH_ID — the ids run-alchemy already sets on the child process — so
the store is addressed directly instead of discovered by listing every project
in the workspace and inspecting same-named candidates. `PRISMA_WORKSPACE_ID`
leaves the state layer entirely. Production passes no branch and the store
resolves the project's default Branch read-only, by its isDefault flag: the
platform guarantees every live project owns one, and creating it is the
platform's business, not ours.

The store's schema, its lock, its service, and the transient store are
untouched, as is resource lowering. `discovery.ts` exists because bootstrap and
delete must resolve the branch and list candidates identically — two copies
would drift, and drifting apart is exactly how a delete removes something a
bootstrap would have adopted.

Ownership is verified by marker before adopting a database and again before
deleting one. Deleting by name alone would take a user's database that happens
to share the name, which is the guessing ADR-0005 forbids; delete.test.ts pins
this with a stub that throws rather than a stub that returns.

Destroy's two failure modes are deliberately different. A named stage throws:
the Branch delete would fail anyway while a database is attached, so failing at
the state step names the real cause. Production warns and continues, inheriting
deleteAppProject's stance that failing a command over a cleanup step is worse
than leaving a shell behind.

Verified live against a real workspace, not only in tests: a named stage's
state database lands on that stage's Branch and the round trip leaves no
database, no Branch, and no Project; production's lands on the implicit default
Branch and is removed before the Project; a stage whose Branch is torn down by
the platform's cascading path loses its state with it and redeploys from a
genuinely fresh store. No workspace-level state project is created by any path.

That run also corrected the ADR. The platform has two Branch-delete paths, not
one: the cascading teardown (git integration, idle-preview reclaim) removes the
state database along with every other child, while the guarded delete the
Console uses refuses outright while a database is attached. The consequences now
say so instead of claiming every platform surface cascades.

skills/prisma-composer/SKILL.md said state was workspace-hosted and shared by
every deployer. It is neither, and it is a file agents read.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
The design notes now carry what actually happened rather than what I first
claimed: the flat database endpoint is a server-side create-then-attach and
narrows the stray-database window rather than closing it; project deletion is
blocked only by active compute deployments; there are two platform Branch-delete
paths and only the cascading one cleans state up. Each was found by the
implementer or the reviewer reading source I had asserted from a schema.

Also recorded: the residual we accepted cannot be regression-tested with an
honest fake, a failed deploy strands its containers because ensureContainers
runs before preflight, and the follow-ups this slice deliberately left alone.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
The state bootstrap takes 6.3 seconds on a stage's first deploy, not the
minute-plus I claimed. pn-widgets runs 89s here against 92s on main — no
measurable regression.

Two e2e runs timed out at 3m22s and I read "52 seconds" as the state database
provisioning. It was time from job start: checkout, install, build — work both
branches do identically, before the deploy command runs at all. The real cause
was a platform window that killed three branches between 12:18 and 12:26
(spi-inversion, this one, streams-minted-key) and cleared by 12:37, after which
every run passed including this one.

The commit that carried the invented cost into ADR-0033's consequences and
raised the CI budget to accommodate it is dropped rather than reverted, so the
claim never enters the record. The budget stays at 3 minutes. That the budget
is marginal is true and worth its own change, argued from the degradation-window
data rather than from a misdiagnosis.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
A database created without a branchId is born on the project's default
Branch, never branchless: createPrismaPostgresDatabase calls
getOrCreateDefaultBranch inside its create transaction. So the
create-then-PATCH two-step put a named stage's database on production's
Branch first and only moved it on the PATCH — and a failed PATCH, or a
CLI that died between the two calls, stranded a user's database on
production's Branch.

The flat POST /v1/databases takes projectId, name, region, isDefault and
branchId in one request and validates the Branch before creating the
row; the project-scoped route 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 — a failed attach can still leave a stray
database, which costs a quota slot rather than correctness.

The PATCH stays on the observed path, where there is no create to strand
anything and it is what moves a drifted database back onto its Branch.

ComputeService and EnvironmentVariable already name the Branch in their
create bodies and needed no change.

Supports ADR-0033.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
@wmadden-electric
wmadden-electric force-pushed the claude/compute-app-containment-model-179b9a branch 2 times, most recently from 04cb231 to 0ad59f4 Compare July 17, 2026 15:24
@wmadden-electric
wmadden-electric deleted the branch main July 17, 2026 15:31
Base automatically changed from claude/compute-app-containment-model-179b9a to main July 17, 2026 15:31
@wmadden-electric
wmadden-electric deleted the claude/flat-database-create-on-113 branch July 20, 2026 14:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant