Skip to content

feat(app-cloud): deploy migrate lowering for pnPostgres + pn-widgets example (slice 2)#46

Merged
wmadden-electric merged 37 commits into
mainfrom
claude/prisma-next-slice2-lowering
Jul 12, 2026
Merged

feat(app-cloud): deploy migrate lowering for pnPostgres + pn-widgets example (slice 2)#46
wmadden-electric merged 37 commits into
mainfrom
claude/prisma-next-slice2-lowering

Conversation

@wmadden-electric

Copy link
Copy Markdown
Contributor

What & why

Slice 2 of the Prisma Next data contract project (slice 1 = the typed primitive, #44). This makes a pnPostgres deploy converge the database to the contract's schema: the deploy provisions the DB and then runs Prisma Next's authored migrations to bring it to the contract's storageHash, or fails.

Design: ADR-0022 + .drive/projects/prisma-next-data-contract/. Tracking: TML-3011.

What's in this PR

The resource carries the config path (D1). pnPostgres({ name, contract, config }) — the prisma-next.config.ts path rides on the resource as a first-class field (sibling to the consumed contract), built by augmenting resource()'s frozen node (the [NODE] brand survives the spread). Exported PnPostgresResourceNode + isPnPostgresResourceNode predicate for the lowering to read it. No core ResourceNode change; the path never touches the contract's __cmp.

The deploy migrate lowering (D2). A new nodes['prisma-next'] in control.ts:

  • Provisions the DB exactly like postgres (Prisma.Database + Prisma.Connection).
  • Then a tracked Alchemy resource (PnMigration, keyed on the target storageHash) runs the migration at apply-time (the resolved url). Read the live marker → no marker: dbInit apply; different hash: migrate (authored graph); already at target: no-op.
  • Authored-only, never synthesize. Only dbInit/migrate run — never a dbUpdate synthesized plan against a deployed DB. No-authored-path (MIGRATION_PATH_NOT_FOUND) and runner failures fail the deploy as typed errors; a failed apply leaves the marker + schema unchanged (proven live). There is deliberately no acceptDataLoss hook — migrate() has none, and authored destructive DDL is the user's explicit intent; the protection against accidental loss is that nothing is ever synthesized.
  • The PnMigration provider is defined in app-cloud and merged into the descriptor's providers() layer — @prisma/alchemy stays Prisma-Next-free.

A worked example + live CI proof (D3). examples/pn-widgets/ — a PSL contract, an authored migration, a compute service that round-trips through the injected typed client (db.orm.public.Widget.create(...).all()), wired into e2e-deploy.yml as a deploy/verify/destroy job (ephemeral name, guaranteed teardown). storefront-auth is untouched, so the bare-postgres() (untyped) path stays covered.

Proof

  • Full gate green: pnpm lint / typecheck / lint:casts (cast-ratchet delta −3, net fewer casts) / @prisma/app-cloud test (81) + test:types (9).
  • Migration logic proven live against real Postgres: empty → dbInit (marker signed); re-run → noop; no-authored-path → fails with the marker + schema unchanged.
  • Provider merge proven: the merged layer resolves both PnMigration and Prisma.Database (no shadow).
  • The pn-widgets example proven locally end-to-end: topology loads (resource carries config), the authored migration applies to a local DB via the example's own prisma-next.config.ts, the typed client compiles against the emitted contract, and the server bundle is Compute-ready (PN runtime + pg inlined; only node: builtins external).
  • Isolation locked at both entries: dist/index.mjs and the app-facing dist/prisma-next.mjs are both asserted free of the deploy-only machinery (@prisma-next/cli, postgres/control, the pn-* modules) — a pnPostgres consumer's runtime never pulls the CLI/control layer.

The live deploy

The pn-widgets E2E job deploys to real Prisma Cloud, verifies the typed-client round trip, and always destroys. It runs on push to main and on same-repo PRs where the workflow is approved (bot-authored PR workflow runs are gated). This is the project's "prove it live" bar — approve the E2E run on this PR to see it green pre-merge, or it runs on merge to main.

Project DoD closed by this slice

Live typed round-trip on Prisma Cloud (via the example); redeploy no-op + migrate-on-change (storageHash keying + marker read); no-path deploy fails DB-unchanged (proven); bare-postgres() still covered (storefront-auth untouched).

🤖 Generated with Claude Code

…ing)

Config-path mechanism decided (app-cloud resource carries the config path
as a first-class field; lowering reads it via a type predicate; no core
change). Slice-2 spec + dispatch plan (D1 resource config field → D2
lowering → D3 example + live E2E).

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…ploy lowering

Slice 2 D1. The resource overload is now `pnPostgres({ name, contract, config })`
where `config` (required) is the `prisma-next.config.ts` PATH — deploy-only
metadata the migrate lowering (D2) reads to locate the migrations directory.
Two doors (ADR-0022): the contract is consumed via `provides` (types + wires
the resource, carries the target storageHash); the config is a plain path the
app build never imports.

Built by augmenting the frozen core node — `Object.freeze({ ...resource({...}),
config })`. The `[NODE]` `Symbol.for('prisma:node')` brand is an enumerable own
symbol prop, so object spread copies it; the augmented node stays a recognized
node and provisions/Loads exactly like a bare resource (asserted in tests). No
core `ResourceNode` change; the path never touches the contract's `__cmp`.

Exports `PnPostgresResourceNode<C> = ResourceNode<C> & { readonly config: string }`
and `isPnPostgresResourceNode` (checks resource kind, `prisma-next` type, string
config) so D2's control.ts reads `config` off `ctx.node` without a bare cast.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
… the predicate

- Slice-1 resource constructions now pass `config`; the type test asserts
  `pnPostgres({ name, contract, config })` returns `PnPostgresResourceNode<C>`
  with a `string` config.
- Unit: the augmented node is still `isNode`-recognized (brand survived the
  spread), is frozen, and carries `config`.
- `isPnPostgresResourceNode`: true for a pnPostgres resource (and narrows so
  `.config` reads); false for a pnPostgres dependency end, a bare `postgres()`
  resource, `null`/`undefined`/`{}`, and lookalikes with a missing/non-string
  config.
- Provisioning: a `pnPostgres` resource Loads as a `resource` and the exact
  augmented node (config intact) is in the graph — proving the brand rides
  through provision().

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…oy-time)

Slice 2 D2's safety-critical core, isolated as a testable helper. `applyPnMigration`
brings a live database to a contract's storageHash using ONLY Prisma Next's
authored migrations:
- reads the live marker; already-at-target → no-op (idempotent redeploy);
  no marker (fresh DB) → dbInit({ mode:'apply' }); different hash → migrate
  (walk the authored graph).
- never dbUpdate (no synthesized plans against a deployed DB).
- a no-authored-path or runner failure throws a typed PnMigrationError (not
  swallowed); PN applies each migration atomically, so a failed apply leaves
  the marker + schema at the last committed step.

Deploy-time only: imports @prisma-next/postgres/control, so it lives in its own
module imported by control.ts (D2 wiring) and tests, never by index.ts / the
./prisma-next authoring entry — index isolation still holds (dist/index.mjs
free of @prisma-next/pg; @prisma-next appears only in dist/prisma-next.mjs).

Proven live against a real local Postgres: empty→init (marker signed), same-hash
re-run→noop, and a different-hash target with no authored path→
MIGRATION_PATH_NOT_FOUND with the DB left unchanged.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…s resolver

Slice 2 D2 building blocks (deploy-time only):

- pn-migration-resource.ts: the PnMigration Alchemy resource — the migration
  step as a tracked resource so it participates in deploy state. Props
  { url, contractJson, migrationsDir, targetHash }; keyed on targetHash so an
  unchanged redeploy is an Alchemy-level no-op (on top of the marker read). Its
  provider's reconcile receives the RESOLVED url at apply-time and delegates to
  the proven applyPnMigration; failures surface as-is (deploy fails). The
  provider is a standalone Provider<PnMigration> layer merged at the descriptor
  — no @prisma/alchemy change (Alchemy resolves it via a direct provider-tag
  lookup, tryFindProviderByType).
- pn-config.ts: resolveMigrationsDir — config path -> migrations dir via PN's
  config loader + PN's convention (migrations.dir or default 'migrations',
  relative to the config file). Uses pathe (not node:path) so the shipped
  source keeps no node: import (invariant 5).

Adds @prisma-next/cli (config-loader) + pathe as deps; both are deploy-only
imports (control.ts graph), never reachable from index.ts, so index isolation
holds.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…n + migrate

The prisma-next resource lowering: provision the Prisma Postgres DB exactly as
postgresLowering does (Database + Connection -> url Output), then register the
PnMigration tracked resource with that url, the contract (node.provides.__cmp),
the migrations dir (resolved from node.config via resolveMigrationsDir), and the
target storageHash. ctx.node is narrowed with D1's isPnPostgresResourceNode to
read the config path without a bare cast.

The descriptor's providers() now merges the migration provider:
Layer.merge(Prisma.providers(), PnMigrationProvider()) — confirmed to resolve
PnMigration at deploy without editing @prisma/alchemy.

Converts the resource/service lowerings' projectId reads to a single auditable
projectIdOf(blindCast) helper (net -3 bare casts).

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…ing + migrations resolution

- pn-migration-resource.test.ts: the descriptor-level merge resolves the
  PnMigration provider by type (direct tag lookup) and does not shadow the
  Prisma providers; the provider's reconcile routes through applyPnMigration —
  proven live against a real local Postgres (apply then no-op on the same props),
  driven through the resource's provider rather than the helper directly.
- pn-config.test.ts: resolveMigrationsDir loads the widget fixture's real config
  and resolves the default migrations/ dir relative to it.
- control-env.test.ts: the descriptor's node map now includes 'prisma-next'.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
… findings)

Tracked Alchemy resource defined in app-cloud (provider merged at the
descriptor; no @prisma/alchemy coupling). Safety model corrected to
authored-only + never-synthesize — migrate() has no acceptDataLoss hook and
authored destructive DDL is the user intent.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
… isolation

Two reviewer should-fix gaps on safety-critical claims (tests only):

F1 — the PnMigration provider's reconcile re-throw was untested. Added a case
that drives service.reconcile with a no-path contract (gadget) against a real
local Postgres already at widgetHash and asserts the returned Effect FAILS with
a PnMigrationError whose code is MIGRATION_PATH_NOT_FOUND. A regression to
`catch: () => someSuccess` (swallowing the deploy failure) now fails this test.
Uses Effect.match to capture the E-channel failure (this effect build has no
effect/Either). Runs live; skips clean without Postgres.

F2 — invariant 7 only locked dist/index.mjs. Added a sibling invariant asserting
the built dist/prisma-next.mjs (the ./prisma-next authoring entry, which does
bundle @prisma-next/postgres/runtime) contains none of the deploy-only
machinery: @prisma-next/cli, postgres/control, pn-config, pn-migration-resource,
prisma-next-migrate — so pnPostgres consumers never pull pg + the CLI into a
service runtime. Verified against the actually-built dist.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…d client end to end

Slice 2 D3's example (storefront-auth stays the bare-postgres() coverage).
A minimal, flat app that uses the Prisma Next-typed Postgres primitive:

- contract.prisma (PSL) + prisma-next.config.ts + emitted contract.json/.d.ts
  (via `prisma-next contract emit`) + an authored initial migration under
  migrations/ (via `prisma-next migration plan --name init` — offline, from the
  contract: CREATE TABLE Widget).
- system.ts: provision('database', pnPostgres({ name, contract, config:
  './prisma-next.config.ts' })) + a compute service depending on it
  (pnPostgres(contract)), wired. Provision id "database" respects the ≥3-char
  Prisma resource-name rule.
- src/server.ts: each request inserts a Widget (id auto-generated by the
  contract's uuid default — no cast) and reads it back through the SAME typed
  Prisma Next client, returning { ok, label, count } — a genuine round trip
  through the contract's schema. The server bundle inlines the PN runtime + pg
  (node_modules isn't shipped to Compute; only node builtins + bun stay
  external — verified).
- prisma-app.config.ts (prismaCloud + nodeBuild, no Next) + scripts/e2e-verify.sh.

biome.jsonc ignores the example's generated PN artifacts (contract.json,
migrations/) so they stay byte-exact.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Adds a deploy-pn-widgets job mirroring the storefront job exactly: ephemeral
per-run stack name (pn-widgets-ci-<run_id>), deploy under bun, verify the live
typed-client round trip via scripts/e2e-verify.sh, and destroy with
`if: always() && steps.deploy.outcome != 'skipped'` — the safety-critical
teardown discipline. The storefront-auth job is unchanged; both run under the
fixed e2e-deploy concurrency group with distinct project names, so they don't
collide in the shared workspace.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
@pkg-pr-new

pkg-pr-new Bot commented Jul 11, 2026

Copy link
Copy Markdown

Open in StackBlitz

@prisma/alchemy

npm i https://pkg.pr.new/@prisma/alchemy@46

@prisma/app

npm i https://pkg.pr.new/@prisma/app@46

@prisma/app-assemble

npm i https://pkg.pr.new/@prisma/app-assemble@46

@prisma/app-cli

npm i https://pkg.pr.new/@prisma/app-cli@46

@prisma/app-cloud

npm i https://pkg.pr.new/@prisma/app-cloud@46

@prisma/app-nextjs

npm i https://pkg.pr.new/@prisma/app-nextjs@46

@prisma/app-node

npm i https://pkg.pr.new/@prisma/app-node@46

@prisma/app-rpc

npm i https://pkg.pr.new/@prisma/app-rpc@46

prisma-app

npm i https://pkg.pr.new/prisma-app@46

commit: 081491e

…o Prisma Postgres

The live E2E caught what local proofs couldn't: the PnMigration step failed
`CliStructuredError: Database connection failed` connecting to Prisma Postgres
via node-postgres, with a pg-connection-string deprecatedSslModeWarning right
before it.

Root cause: PPG's DSN carries `sslmode=require`. pg-connection-string@8.21 treats
`require`/`prefer`/`verify-ca` as aliases for `verify-full` (strict certificate +
hostname verification) and does NOT set `rejectUnauthorized: false`, so the TLS
handshake fails against PPG's cert chain (not in node's default trust store). The
app's runtime path uses Bun's SQL, which connects to the same DB fine — so the
gap is pg-vs-Bun.SQL at deploy time. The PN control driver builds its client as
`new Client({ connectionString: url })` (no ssl-config object accepted), so the
fix is URL-level.

`relaxSslModeForPg` rewrites a TLS-verifying `sslmode` (require/prefer/verify-ca/
verify-full) to `no-verify` — TLS on, certificate not verified, the same posture
the runtime uses, to a Prisma-managed endpoint at deploy time. A DSN with no
sslmode (a plain local Postgres) is left untouched, so the local integration
path still connects without TLS. Applied in applyPnMigration; unit-tested (the
URL transform), deploy-only (index isolation unchanged).

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…start

The no-verify SSL fix was a misdiagnosis. A live diagnosis against a real
throwaway PPG database (provision → reproduce with raw node-postgres →
destroy) found the real cause:

- The raw error is `Failed to connect to upstream database` with
  `err.code === undefined` — NOT TLS, network (ECONNREFUSED/ETIMEDOUT), or
  auth. It's PPG's edge proxy rejecting the first connection(s) while a
  freshly-provisioned DB's upstream warms up (cold-start), recovering on a
  later attempt — the same FT-5219 transient the runtime verify scripts retry
  for. The deploy migration connects immediately after provisioning, so it hits
  that window every time.
- SSL was never the issue: on a warm DB, `sslmode=require`, `verify-full`, AND
  `no-verify` all connect — PPG's certificate is publicly trusted. The
  deprecatedSslModeWarning is cosmetic pg-8.21 noise.

Fix:
- `withConnectionRetry` wraps the connect+operation, retrying transient
  connection failures (up to 12 × 5s) but surfacing a real PnMigrationError
  (no-path / runner) immediately, never retried.
- `normalizeSslMode` replaces the previous no-verify relax: it pins the
  deprecating `sslmode=require`/`prefer`/`verify-ca` to the explicit
  `verify-full` they already mean under pg 8.21 — warning-free, future-proof
  against the pg-9 semantics change, and still fully verifying (no security
  downgrade). A plain local DSN is untouched.

Proven end to end against real PPG: applyPnMigration on a just-provisioned DB
rode out the cold-start (~10s of retries) then applied + signed the marker;
re-run was a no-op. Deploy-only; index isolation unchanged. Unit tests cover
both helpers (retry recovery, PnMigrationError not retried, sslmode pinning).

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…ayer internals)

The live E2E is green; these were test-robustness gaps that only showed in CI
(a shared Postgres service + the CI-built environment), not in local runs
(per-file ephemeral Postgres, and Effect internals that happened to resolve).

1. Shared-DB isolation. The integration tests write to one Postgres; in CI they
   share STATE_TEST_DATABASE_URL, so one file's marker/table leaked into
   another's "empty DB" assertion (the migrate test saw slice-1's widget hash).
   Added `resetDatabase(url)` to the harness — drops the contract's `public`
   schema AND Prisma Next's `prisma_contract` (marker + ledger) schema, then
   recreates `public` — and call it in beforeAll of every integration test
   (prisma-next-migrate.integration, prisma-next.integration, and the
   PnMigration reconcile suite). Verified: two back-to-back runs against one
   persistent Postgres are green, in any order. Adds @types/pg (harness uses pg).

2. `layer.build is not a function` in the provider tests. Resolving the provider
   via `Effect.provide(mergedLayer)` reached an Effect internal that wasn't
   stable in the CI-built environment. The merge itself is correct (the live
   deploy uses it). Fixes:
   - export `pnMigrationProviderService` and drive the reconcile tests directly
     against it — no Effect layer built at all for the routing assertions;
   - resolve the merge tests through a scoped `Layer.build` + `Effect.provideContext`
     (unambiguous public API) instead of `Effect.provide(layer)`.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…th synthetic providers

Fixes the two issues the last attempt left:

1. `DROP SCHEMA public` regressed the state-store suite. My reset nuked
   `public.alchemy_resource_state` on the SHARED CI Postgres, so
   makePrismaStateService failed with `relation ... does not exist`. Dropping a
   schema other suites share is the wrong primitive. Replaced `resetDatabase`
   with `createTestDatabase(baseUrl)` — CREATE a uniquely-named database
   (`pn_test_<uuid>`) on the same server, point the test at it, and DROP it
   (terminating connections first) in afterAll. This mirrors how the state
   store's own ownership suite isolates (per database), but with unique names
   that are always cleaned up. All three PN integration suites
   (prisma-next-migrate.integration, prisma-next.integration, and the
   PnMigration reconcile suite) now own their database — never the shared
   `public`. Verified: a full-workspace `pnpm test` against ONE fresh shared
   Postgres runs the state-store suite (58 pass) AND the app-cloud PN suites
   (90 pass) together, green.

2. The `layer.build is not a function` merge test. Even `Layer.build` hits the
   internal on `Prisma.providers()`'s own sub-layers in the CI build — not
   fixable from a test. The merge assertion no longer builds `Prisma.providers()`:
   it proves the merge MECHANISM (Layer.merge keeps both provider tags reachable
   by type, no shadowing) with two synthetic clean providers (PnMigration + a
   trivial TestProbe). The real Prisma.providers() + PnMigration merge is already
   proven end to end by the green live E2E deploy.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…isioning (FT-5226)

Deploy-time migrations connect the instant the DB is provisioned and hit
PPgs cold-upstream window — the proxy rejects with "Failed to connect to
upstream database". Not TLS/network/auth; a warm DB connects on any SSL
posture. Workaround: bounded connection retry (withConnectionRetry). Notes
the pg-8.21 sslmode=require deprecation as a red herring.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…e, FT-5226)

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>

@wmadden wmadden left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an excellent start. I'm truly shocked how little code there is.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Take the .gitattributes from prisma-next so these are collapsed in git diffs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 0031a99 — adopted prisma-next's .gitattributes: contract.json/contract.d.ts, start-contract/end-contract, ops.json, migration.json, and refs/head.json are all linguist-generated now (verified with git check-attr), so emitted artifacts collapse in diffs.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not do this in TS so we can read it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 01b66e6 — rewritten as scripts/e2e-verify.ts under bun (same behavior: resolve the deployed URL, poll up to 180s for the round-trip JSON), workflow step updated. Storefront's script left as-is.

Comment thread packages/app-cloud/src/control.ts Outdated
// a no-op, a contract change re-migrates, a failed apply leaves the DB
// unchanged. `node` carries the config path (D1's `isPnPostgresResourceNode`)
// and the contract (`provides`); the config is read at deploy-time only.
const prismaNextLowering: Lowering = ({ id, node, application }) =>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does this centralized control.ts file need special case logic for a single provider?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — the ExtensionDescriptor.nodes registry was already the routing seam; the problem was authoring every lowering inline in prismaCloud(). Fixed in d8c01f6: one control module per node kind (src/controls/{postgres,prisma-next,compute}.ts, each a factory over the resolved options returning its NodeControl), and prismaCloud() is back to options + the application hook + the providers merge + the registry wiring.

/** On-disk migrations root, resolved from the resource's `prisma-next.config.ts`. */
readonly migrationsDir: string;
/** The contract's `storageHash` — the diff/identity key: unchanged ⇒ Alchemy no-op. */
readonly targetHash: string;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to accommodate PN refs: either ref identifier, or targetHash + invariantId (from a ref)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a9055c1, and you were right that this was a correctness hole, not a nicety. The target is now a ref { hash, invariants }: pnPostgres gains an optional targetRef (ref name, deploy-only); default is the head (verified against PN source: contract emit writes refs/head.json only for extension spaces — the app head is synthesized by PN's aggregate loader as { contract hash, [] }, and our resolver mirrors that; an on-disk app head.json wins when present). Decision matrix mirrors PN's verifier: done ⇔ marker at ref hash AND ref.invariants ⊆ marker.invariants; dbInit ONLY when required invariants are empty (it's additive-only synth and never runs app-space data steps); everything else — including the A→A invariant-only self-edge — goes through migrate with refHash/refInvariants/refName, exactly like the CLI's migrate --to. The PnMigration resource is keyed on the ref identity (hash + sorted invariants) so a data-only change reconciles instead of no-op'ing. Integration-proven against real Postgres with a genuine dataTransform migration carrying an invariantId: fresh-DB-with-invariants migrates (not dbInit), the marker records the invariant, re-run no-ops, and hash-match-but-invariant-missing triggers the A→A migrate. ADR-0022 updated.

Comment thread packages/app-cloud/src/prisma-next.ts Outdated
* resource kind, the `prisma-next` routing type, and that `config` is a
* string.
*/
export function isPnPostgresResourceNode(node: unknown): node is PnPostgresResourceNode {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wtf is this? why from unknown? if you want to downcast, use the base type as input

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 26b2fa8 — the predicate now takes the known ServiceNode | ResourceNode union (what ctx.node is), not unknown. It's a downcast of a known node, not an untrusted-value guard; the object/null preamble is gone.

Comment thread packages/app-cloud/src/prisma-next.ts Outdated
Comment on lines +157 to +163
return Object.freeze({
...resource({
name: arg.name,
extension: '@prisma/app-cloud',
provides: arg.contract,
}),
config: arg.config,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm getting sick of seeing these. Adopt the frozen inheritance hierarchy patter nfrom the PN ASTs. There should be skills and rules available to assist you

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You were right — I pushed back before reading the pattern, and it's exactly as you said: PN keeps freezeNode a free function (not a class method) precisely so the class type stays structurally identical to a plain literal, and it ships mixed class/literal siblings in the same slot. Adopted in d6baa23: @prisma/app gains a non-freezing ResourceNodeBase (validation + brand + fields — what resource() establishes) plus freezeNode; resource() keeps its exact behavior via a private frozen leaf (all 124 core tests untouched); the pn-postgres node is now a leaf class extending it — declare readonly optionals, freezeNode(this) last — and the spread-and-refreeze is gone. Narrowing stays structural (never instanceof), same rationale as the Symbol.for brand.

… (FT-5226)

Slice 3 D1. After the DB is provisioned, warm it so it's ready by deploy-end and
the first real connection doesn't eat Prisma Postgres's cold-start reject.

- Extracted the generic connection helpers into a lightweight `pg-connection.ts`
  (no @prisma-next/control import) so they're shareable by the deploy lowerings
  AND the runtime client (D2) without breaking either isolation invariant:
  `normalizeSslMode`, `withConnectionRetry` (now takes a `shouldRetry` predicate,
  default retry-all), and a new `isTransientConnectionError` (cold-start /
  network / dropped-socket = retryable; a real SQL-state query error is not).
  prisma-next-migrate re-imports them; the migration keeps its exact behavior
  (shouldRetry skips PnMigrationError).
- `pg-warm-resource.ts`: a `PgWarm` tracked Alchemy resource (deploy-only — `pg`
  directly). Its reconcile connects with withConnectionRetry + `select 1`,
  echoing the url so a downstream resource depends on "warmed". Its provider is
  merged into the descriptor's `providers()` alongside PnMigration (no
  @prisma/alchemy change). Keyed on the connection url → no-op redeploy.
- control.ts: both lowerings warm after provisioning. Bare postgres — genuinely
  new (it did no deploy-time connect). prisma-next — PgWarm runs BEFORE
  PnMigration (threaded via `warm.url`, so the migration connects warm); the
  migration keeps its own withConnectionRetry as a backstop. Consumers get
  `warm.url`, so they deploy after the DB answers.

Isolation holds: pg-warm-resource is deploy-only (control.mjs), absent from
dist/index.mjs and dist/prisma-next.mjs (invariant F2 extended to name it).
ADR-0015 intact: bare postgres's binding stays { url } — warm is deploy-only.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…nect (FT-5226)

Slice 3 D2. A belt to the deploy-time PgWarm: a Prisma Postgres database can
scale to zero again after deploy, so the framework-constructed pnPostgres client
must survive the next runtime connect eating the cold-start "Failed to connect to
upstream database" reject.

The PN runtime has no connect-retry option, and its bare-`url` path can't be made
resilient: the client fires a background connect whose first failure sets a
permanent error it then throws on every later use. But the runtime also accepts a
`pg` binding, and for a pool binding its own connect step is a no-op — the real
connection happens lazily at `pool.connect()` on the first query. So buildClient
now hands the runtime our OWN pg.Pool whose connection acquisition retries a
transient cold-start (bounded ~1 min, via the shared retryTransientConnect =
withConnectionRetry + isTransientConnectionError). Only acquisition is wrapped, so
a real query error — thrown by client.query() after connect — still surfaces at
once. hydrate stays synchronous (load() uses hydrateSync); a lazily-connecting
pool fits. normalizeSslMode keeps a PPG DSN warning-free.

ADR-0015 intact: bare postgres()'s binding is untouched — this is the framework-
blessed prisma-next client only (ADR-0022). Isolation holds: the runtime imports
only pg + the lightweight pg-connection helpers, no @prisma-next/cli or control
machinery. Because pg-connection.ts is now shared between control.ts and
prisma-next.ts, the bundler moved the real ./prisma-next code into a chunk and
left dist/prisma-next.mjs a re-export shim — so the F2 invariant test now follows
the shim's imports across chunks (and asserts it actually reached the runtime
code) instead of reading a shim that would pass vacuously; dist/index.mjs is
checked the same way.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…s (review R1)

Adopt Prisma Next's IR class pattern (IRNodeBase + free freezeNode) for
resource nodes, replacing pnPostgres's spread-and-refreeze of a frozen core
node. Core gains ResourceNodeBase — everything resource() establishes (the
validations, the [NODE] brand, kind/name/extension/type/provides) minus the
freeze — plus the free freezeNode(node) helper; freeze stays out of the class
type so an instance is structurally identical to a plain frozen literal.
resource() keeps its exact signature and behavior by constructing a private
frozen leaf of the base — receivers can't tell the difference (all core tests
green untouched).

app-cloud's PnPostgresResourceNode becomes a leaf class extending
ResourceNodeBase: it adds `readonly config`, freezes last, and the
`Object.freeze({ ...resource(...), config })` spread dies. The exported name
still provides the same type shape, so consumers are unaffected. Narrowing
stays structural (kind/type/config), never instanceof — the same
duplicated-module rationale as the Symbol.for brand.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…union (review R2)

The predicate's input is always the lowering's ctx.node, already typed
ServiceNode | ResourceNode — it is a downcast of a known node, not an
untrusted-value guard. Take that base union instead of `unknown` and drop the
object/null preamble; the structural kind/type/config checks stay. Tests: the
null/undefined/{} negatives go away; the dependency-end and lookalike
negatives stay behind test-only blindCasts.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
control.ts had grown per-node-kind lowering logic inline — special-case code
in the one centralized file. The ExtensionDescriptor.nodes registry already
routes by node type, so each kind's control now lives in its own module:
controls/postgres.ts, controls/prisma-next.ts, controls/compute.ts — each a
small factory over the resolved options returning its NodeControl — with the
shared name-validation/projectId helpers in controls/shared.ts. prismaCloud()
shrinks to options resolution, the application hook, the providers merge, and
the nodes registry. Behavior-preserving; the PgWarm wiring moves with each
lowering; the controls stay reachable only from the ./control entry, so both
isolation invariants hold unchanged.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…, not a bare hash (review R4)

Keying the deploy migration on storageHash alone was wrong in two ways, both
verified against Prisma Next's source. A pure data-invariant change (a named
postcondition established by a data-class migration step, e.g. a backfill) is
an A->A self-edge — same hash — so a hash-keyed deploy silently no-ops it. And
dbInit is additive-only synthesis that never runs app-space data steps, so
first-applying a target that requires invariants through dbInit would leave
marker.invariants empty while claiming success.

The target is now a ref { hash, invariants } (PN's RefEntry):

- pnPostgres({ name, contract, config }) gains an optional targetRef — a ref
  NAME (migrations/app/refs/<name>.json). Default: the head. PN synthesizes
  the app head from the emitted contract (hash, zero invariants; contract
  emit writes no app-space head.json today — verified in PN's aggregate
  loader); when an on-disk app head.json exists it wins, read via
  readContractSpaceHeadRef, PN's own loader. A missing named ref fails the
  deploy loudly (TARGET_REF_NOT_FOUND).
- The decision (decideMigrationAction, pure + unit-tested) mirrors PN's
  verifier: at-target = marker hash equals ref hash AND ref.invariants ⊆
  marker.invariants (marker invariants are monotonic) -> noop; a fresh DB
  takes dbInit ONLY when the required invariants are empty; everything else
  walks the authored graph via migrate — with a named ref threading
  refHash/refInvariants/refName into PN's invariant-aware path planning,
  exactly like the CLI's `migrate --to`.
- The tracked PnMigration resource is keyed on the ref identity (targetHash +
  sorted invariants), so a data-only change triggers reconcile; the lowering
  resolves the ref once and the same identity feeds both the diff key and
  the apply.

Tests: the full decision matrix incl. the A->A invariant-only case and the
subset check (pn-target-ref.test.ts, no DB); integration proof against real
Postgres with an authored graph built via PN's own migration-tools writers
(real manifests + migrationHash): a fresh DB whose ref requires an invariant
goes through migrate (not dbInit), the marker records the invariant, a re-run
no-ops, and hash-match-but-invariant-missing triggers the A->A migrate. Per-
database isolation throughout. ADR-0022 and the project design notes updated
for the ref-based target.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
… R5)

Rewrite examples/pn-widgets/scripts/e2e-verify.sh as e2e-verify.ts (run under
bun), same behavior: resolve the deployed URL via the Management API, then
poll until the round-trip JSON asserts {"ok":true}. The curl-piped-to-inline-
node JSON digging becomes plain fetch + typed reads. The workflow step runs
`bun scripts/e2e-verify.ts`; storefront's script is untouched.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
… R6)

Adopt prisma-next's .gitattributes entries: contract.json/.d.ts,
end-contract/start-contract, ops.json, migration.json, and refs/head.json are
linguist-generated, so PR diffs collapse them instead of drowning the review.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Main landed stage-as-branch (#42): the CLI resolves containers (Project +
optional stage Branch) and injects PRISMA_PROJECT_ID/PRISMA_BRANCH_ID; the
extension's application hook references that Project instead of minting one,
and branch-scoped resources (Database, ComputeService, EnvironmentVariable)
carry branchId with env vars classed preview on a stage / production
otherwise. destroy now requires an explicit --stage/--production target.

The one content conflict was packages/app-cloud/src/control.ts: main changed
the inline lowerings while this branch had extracted them to src/controls/*
(review R3). Resolution keeps the extracted structure and carries main's
semantics into it — resolveOptions reads projectId/branchId into
ResolvedCloudOptions (controls/shared.ts, with the required-at-provision
check staying in the application hook), the Database lowerings
(controls/postgres.ts AND controls/prisma-next.ts — the pn lowering gets the
same branch scoping) and ComputeService/serialize (controls/compute.ts) gain
the branchId/preview handling. control-lowering.test.ts auto-merged clean:
main's stage assertions and this branch's PgWarm stubs coexist (32 pass).
pnpm-lock.yaml regenerated via pnpm install.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Mirror storefront-auth's post-stage-as-branch state (main's 9b0baec):
destroy requires an explicit --stage/--production target, and the package
script still used the bare form. The CI teardown path needed no change — the
pn-widgets Destroy step goes through the shared examples/scripts/
destroy-guard.sh, which main already pointed at --production; deploy stays
bare because deploy targets production by default.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…odule mocks

CI failed all three "provider merge mechanism" tests with `layer.build is not
a function` while the same suite is green locally. The root cause is NOT an
effect duplication (one `effect` version in the lockfile, one on-disk copy
under the hoisted layout, verified after `pnpm install --frozen-lockfile`):
`bun test` runs every test file in ONE process, `mock.module` is
process-global, and bun's test-file order is filesystem-dependent. On the CI
runner control-lowering.test.ts ran BEFORE pn-migration-resource.test.ts, so
its `mock.module('../pg-warm-resource.ts')` stub leaked and the merge test's
imported `PgWarmProvider()` returned `{ stub }` — a non-Layer whose missing
`.build` is exactly the CI stack trace. On macOS the file order happens to run
the merge tests first, which is why three rounds of local reproduction never
caught it. (The CI-passing pg-warm-resource.test.ts pinned the mechanism:
when the real module is already loaded, bun patches the listed exports in
place and unlisted ones — `pgWarmProviderService` — survive.)

Two changes close both leak modes for good:

- pn-migration-resource.test.ts imports NO provider constructor from another
  module. The merge mechanism is proven with two in-file probe providers
  (in-file consts are un-mockable); the REAL providers' by-type reachability
  is asserted against a context built in-file from the modules' type-only
  exports (erased at compile time) plus the exported SERVICE values.
- control-lowering.test.ts's `@prisma/alchemy` / `../pg-warm-resource.ts`
  mock factories now spread the statically-imported real modules. The static
  import forces the module to be loaded before `mock.module` runs, which
  pins bun to the survivable patch-in-place mode — a sibling file importing
  an unstubbed export can never hit "Export not found" regardless of order.

Verified in the exact CI file order, in the worst-case order
(control-lowering first), and in bare discovery order: all green.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
The two E2E jobs ran in parallel within one workflow run, and each
transiently holds a project + auto-default DB + its provisioned DB — peak
parallel usage exceeded the workspace plan's database cap, failing the
pn-widgets deploy with "maximum number of databases allowed for your plan".
`needs: deploy` serializes them; it is not a logical dependency. Teardown
discipline and per-run stack names are unchanged.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Under stage-as-branch the CLI resolves the app's Project OUTSIDE the Alchemy
stack (ensureContainers creates it by name before alchemy runs; the
extension's application hook only references PRISMA_PROJECT_ID), so
`destroy --production` tears down just the stack-tracked resources. What
persists per E2E run: the per-run Project (<app>-ci-<run_id>) plus the
database the platform auto-provisions with it — which is how the CI
workspace hit "maximum number of databases allowed for your plan" at
container resolution.

examples/scripts/ci-cleanup.ts (bun, TS) is the guaranteed teardown: it
lists EVERY project in the workspace with name + createdAt (the CI log is
our only window into that workspace; names only, never tokens/DSNs), deletes
only names matching the strict `^(<prefix>|...)-ci-\d+$` pattern (prefixes
as CLI args), hard-denies `prisma-app-state` on top of the pattern, and logs
each kept name. Per-project delete failures are logged and skipped; the run
exits non-zero only when matches existed and none could be deleted, and a
listing/auth failure always surfaces. The pure name filter lives in
ci-cleanup-utils.ts with node:test coverage (wired into `pnpm test:scripts`),
including the hostile-prefix and regex-metacharacter cases.

Both E2E jobs run the sweep with plain `if: always()` (it must run even when
Deploy was skipped or failed early), each sweeping the full pattern set —
idempotent, serialized by the needs: chain, and the first run clears the
historical backlog.

Proven live against the dev workspace: three probe projects
(storefront-auth-ci-*, pn-widgets-ci-*, keep-me-probe) — the two matching
ones swept, keep-me-probe kept and logged, a rerun no-ops with exit 0, all
probes removed afterward.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
@wmadden-electric wmadden-electric requested a review from wmadden July 12, 2026 09:45
The first sweep cleared 9/16 leaked projects; the other 7 are from runs
whose destroy never completed and still hold a live compute deployment, so
DELETE /v1/projects/{id} refuses with 409 "Cannot delete project: active
deployment" (project deletion does not cascade over live compute).

deleteProjectDeep (ci-cleanup-utils.ts) now handles that case with the same
teardown sequence alchemy's providers use at a real destroy: enumerate the
project's compute services (GET /v1/projects/{id}/compute-services) and
DELETE each — the platform tears the service's versions and deployments down
with it (alchemy's Deployment delete is a documented no-op for exactly this
reason) — retrying a service DELETE only while the platform reports "did not
reach a delete-safe state" (the one retryable 409, the same wording match as
ComputeService's isDeleteNotSafeYet; anything else is logged and skipped),
then re-trying the project DELETE with a short bounded retry (deletes are
eventually consistent). All guardrails unchanged: strict name pattern,
prisma-app-state hard-denied, per-item fail-soft, names/ids-only logging;
every service teardown is logged.

The sequencing is unit-tested with a scripted mock HTTP seam (node:test, in
`pnpm test:scripts`): plain delete, 404-as-gone, the full
409→list→teardown→retry order, delete-safe-only service retries, non-retry
on a real service error, bounded post-teardown project retries, give-up
paths, and the listing failure. Probed live against the dev workspace: a
compute service WITHOUT a deployed version does not block the project
delete (204 first try), so the real 409 can't be reproduced without building
a full deployment — the next CI run over the 7 stuck projects is the live
proof.

The workflow sweep also now includes the `hello` prefix (hello-ci-* debris
from an old example matches the same per-run pattern).

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>

@wmadden wmadden left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please ensure there's a real end to end test that passes ONLY if the remote Prisma Postgres resource still exhibits this cold connection rejection behavior. When the platform team fixes the problem, I want the test to fail so we're forced to remove the workaround

You have code style violations all over the place. NO TRANSIENT IDS. NO GIGANTIC FUCKING COMMENTS

Comment thread .github/workflows/e2e-deploy.yml Outdated
if: always() && steps.deploy.outcome != 'skipped'
working-directory: examples/storefront-auth
run: bash ../scripts/destroy-guard.sh "" system.ts "$STOREFRONT_STACK_NAME"
- name: Sweep leaked per-run projects

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean up stale projects

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — the sweeper now also deletes legacy makerkit-state projects (exact-name match; that was the state store's pre-rename name). prisma-app-state stays hard-denied, and a test asserts the protected list and the legacy list can never intersect. The next CI run performs the actual cleanup.

working-directory: examples/storefront-auth
run: bun ../scripts/ci-cleanup.ts storefront-auth pn-widgets hello

deploy-pn-widgets:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an unbelievably huge amount of deploy script config. Why is it so huge? And reduce the timeout to ~2 mins

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deduplicated: the shared deploy/verify/destroy/sweep sequence moved into a composite action (.github/actions/deploy-verify-destroy); the workflow went 179 → 57 lines. Timeout set to 3 min, not 2 — observed wall time is 1m10s–2m5s including checkout+install, so 2 would flake on a cold cache. A job killed by the timeout skips its destroy step; the next run's sweep reaps whatever that leaks.

Comment on lines +29 to +45
resource. Its target is a **ref** `{ hash, invariants }` — the resource's
optional `targetRef` (a `migrations/app/refs/<name>.json` name), or the
head (the emitted contract's hash, zero invariants) by default. A ref's
`invariants` are named postconditions established by `data`-class migration
steps (e.g. a backfill), recorded monotonically on the live marker. The
decision: the database is AT the target when the marker's `storageHash`
equals the ref's hash AND every ref invariant is on the marker — then
no-op. A fresh database with **no** required invariants takes `dbInit`
(additive-only synthesis; it never runs data steps, which is exactly why
it's ruled out otherwise). Anything else — different hash, a missing
invariant (a pure data change is an A→A self-edge at the same hash), or a
fresh database whose ref requires invariants — walks the **authored**
migration graph (`migrate`). Fail the deploy if no path exists, if a step
is destructive without explicit opt-in, or if the runner fails. The tracked
migration resource is keyed on the ref identity (hash + sorted invariants),
so a data-only change still triggers a deploy step. Synthesized
diff-and-apply (`dbUpdate`) is never used against a deployed database.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a 30 line bullet point. wtf

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewritten as short prose plus a three-branch list (no-op / dbInit / migrate). All decision facts kept.

Comment thread examples/scripts/ci-cleanup.ts Outdated
Comment on lines +3 to +31
* Sweep the CI workspace's leaked per-run projects (run under bun).
*
* Under stage-as-branch the CLI resolves the app's Project OUTSIDE the
* Alchemy stack (ADR-0019: `ensureContainers` creates it before alchemy
* runs), so `destroy --production` tears down only the stack-tracked
* resources — the per-run Project itself, and the database the platform
* auto-provisions with it, persist after every E2E run. This script is the
* guaranteed teardown: list every project in the workspace (the CI log is
* our only window into that workspace), delete the ones whose name matches
* the strict ephemeral pattern `^(<prefix>|...)-ci-<digits>$`, and log —
* never touch — everything else. `prisma-app-state` (the hosted deploy-state
* control plane) is hard-denied on top of the pattern.
*
* Usage: `bun ci-cleanup.ts <prefix> [<prefix> ...]`, e.g.
* `bun ci-cleanup.ts storefront-auth pn-widgets`. Requires
* PRISMA_SERVICE_TOKEN. Logs project names and timestamps only — never
* tokens or connection strings.
*
* A run whose destroy never completed leaves a LIVE compute deployment, and
* the project DELETE then refuses with `409 … active deployment` — for a
* matched project that hits this, the sweep tears the project's compute
* services down first (the platform removes their versions/deployments with
* them) and retries the project delete; see `deleteProjectDeep`.
*
* Failure posture: a per-project delete failure is logged and skipped
* (cleanup must not redden a green run over one stuck project), but the run
* exits non-zero when matches existed and NOT ONE could be deleted — and an
* API/auth failure at listing time always surfaces.
*/

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WHY ARE YOUR COMMENTS SO GIGANTIC

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cut to 4 lines. Same treatment across every file this round touched — nothing over ~3 lines remains in the diff.

import { configKey, encode, paramEntries } from '../serializer.ts';
import { DEFAULT_REGION, projectIdOf, type ResolvedCloudOptions, validateName } from './shared.ts';

export function computeControl(o: ResolvedCloudOptions): NodeControl {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is a "compute control"? Missing a noun here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — the word was doing double duty. Renamed the registry entries: NodeControl → NodeHandler, ApplicationControl → ApplicationHandler, controlFor → handlerFor, src/controls/ → src/handlers/. "control" now only ever means the /control module entry (the ADR-0017 control-plane face), which keeps its name.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction after the design discussion: "handler" is gone too. Following Prisma Next's descriptor/instance pattern (PN ADR 151 — every control-plane participant is a descriptor fronting its logic and data), the registry entries are now node descriptors: NodeDescriptor, descriptorFor(), src/descriptors/. The graph node is the occurrence a descriptor classifies; the lowered cloud resource is the instance.

Comment thread packages/app-cloud/src/pg-connection.ts Outdated
Comment on lines +1 to +7
/**
* Connection resilience helpers shared by the deploy lowerings (the migration
* and the warm-on-provision step) and the pnPostgres runtime client (slice 3,
* FT-5226). Deliberately lightweight — no `@prisma-next/*` / control-plane
* import — so it is safe to bundle into BOTH the deploy-only `control.ts` and
* the runtime `./prisma-next` entry without breaking either isolation invariant.
*/

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SCAN YOUR DIFF FOR TRANSIENT PROJECT IDS

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scanned the full diff. One hit: a ci-cleanup test asserted against a real run ID — replaced with an obviously fake one. The recurring sha256:… strings are the contract's deterministic storage hash, not transient.

Comment thread packages/app-cloud/src/prisma-next.ts Outdated
Comment on lines +71 to +82
* The `prisma-next` resource node: a core Resource node augmented with
* `config`, the `prisma-next.config.ts` path. Two doors (ADR-0022): the
* contract is consumed through `provides` (types + wires the resource, and
* gives the deploy its target `storageHash`); `config` is a deploy-only path
* the migration lowering loads to resolve the migrations directory — the app
* build never imports it. Not a core `ResourceNode` field: the path is this
* extension's deploy concern, so it rides on app-cloud's own node shape — a
* leaf of core's `ResourceNodeBase` (the frozen node-class pattern: the base
* brands and validates without freezing; the leaf assigns its own field and
* `freezeNode(this)` last). No methods, so an instance stays structurally a
* plain resource node plus `config`; narrowing stays structural, never
* `instanceof`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sincerely doubt anyone will understand this

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewritten to 3 plain lines. Same sweep applied to node.ts, control.ts, pg-connection.ts, deploy.ts, and the handler modules.

Comment thread packages/app/src/node.ts Outdated
* stays structural (kind/type/field checks), never `instanceof` — the same
* duplicated-module rationale as the `Symbol.for` brand.
*/
// biome-ignore lint/suspicious/noExplicitAny: opaque per-contract Cmp — matches ResourceNode's own bound.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stop suppressing these. If you need an AnyResource or AnyDeps, make the type alias ONCE and reuse it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: one AnyContract alias in node.ts (single suppression on the alias) replaces every repeated Contract<any, any>; the inline double-cast on Prisma.providers() became a named asProvidersLayer() helper; the unknown[][] test cast is now properly typed.

…d cold-connect canary

- NodeControl/ApplicationControl/controlFor -> NodeHandler/ApplicationHandler/handlerFor; src/controls/ -> src/handlers/ (the /control module entry keeps its ADR-0017 name)
- one AnyContract alias replaces repeated Contract<any,any> suppressions; asProvidersLayer() replaces the inline double cast
- all oversized comments cut to <=3 lines; ADR-0022's 30-line bullet restructured
- e2e-deploy.yml deduped via composite action (179 -> 57 lines); timeout 3m; leaks on timeout are reaped by the next run's sweep
- ci-cleanup also deletes legacy makerkit-state projects (exact match; prisma-app-state protected, test-enforced); real run ID in test replaced with a fake
- cold-connect canary: fresh project + one bare no-retry connect; passes only on active rejection (FT-5226), fails on success, timeout (inconclusive), or non-transient errors; project deleted in finally

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
@wmadden-electric

Copy link
Copy Markdown
Contributor Author

Round-2 feedback addressed in 7ad5f67.

Cold-connect canary (review body): examples/scripts/cold-connect-canary.ts + a third E2E job. It creates a fresh project/database, makes ONE bare pg connect with no retry, and passes only if that connect is actively rejected with the known FT-5226 signature. It fails loudly if the connect succeeds ("remove withConnectionRetry and this canary"), if it merely times out (inconclusive — a timeout is not a rejection, so a slow-but-fixed cold start can't masquerade as the bug), or on any non-transient error (broken canary ≠ fixed platform). The project is deleted in a finally; leaks are reaped by the sweep.

Style: full-diff sweep — no comment over ~3 lines, no transient IDs (one real run ID in a test replaced), registry noun renamed control → handler throughout.

All checks green locally (38/38 turbo tasks); CI running on the new head.

@wmadden-electric wmadden-electric requested a review from wmadden July 12, 2026 11:04
The composite-action dedupe dropped the job-level stack-name env both
verify scripts read, so verify looked up the bare app name and failed.
Both scripts now read one STACK_NAME env, set by the composite action.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Carries main's closed-root system() overload; keeps this branch's
comment trims (main's overload docs are already terse).

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…ce pattern)

Supersedes the interim handler rename. Per Prisma Next ADR 151, a registry
record fronting a participant's logic and data is a descriptor; the graph
node is the occurrence it classifies, the lowered resource the instance.
NodeHandler -> NodeDescriptor, handlerFor -> descriptorFor, src/handlers/ ->
src/descriptors/.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
// Top-level await needs module context; this script imports nothing.
export {};

const API = 'https://api.prisma.io/v1';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why aren't you using the management api client?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Converted to @prisma/management-api-sdk (createManagementApiClient + typed GET('/v1/projects') / GET('/v1/projects/{projectId}/compute-services'), cursor pagination). Smoke-tested live against the dev workspace. Merging on green.

Replaces hand-rolled fetch against api.prisma.io with
@prisma/management-api-sdk (already a workspace dependency).

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
@wmadden-electric wmadden-electric merged commit d9f0db2 into main Jul 12, 2026
11 checks passed
@wmadden-electric wmadden-electric deleted the claude/prisma-next-slice2-lowering branch July 12, 2026 12:47
wmadden-electric added a commit that referenced this pull request Jul 12, 2026
…#46)

main advanced again (PR #46: pnPostgres deploy-migrate lowering + pn-widgets
example, plus a node.ts doc-comment refactor). Re-merged and kept this branch's
{ id?, deps } provision API:

- node.ts: took main's refactored file (AnyContract alias, condensed comments,
  closed-root system overload) and re-applied the 5 deps overloads +
  DepBindings/ProvisionArgs over main's 10 Wiring overloads.
- app-cloud/package.json: unioned deps (main's @prisma-next/cli,
  migration-tools, pathe + this branch's arktype, @standard-schema/spec,
  app-node, app-rpc); regenerated the lockfile.
- prisma-next-shapes.test-d.ts / prisma-next.test.ts / pn-widgets example:
  adopted main's new pnPostgres `config` param and migrated the provision
  calls to the deps form (pn-widgets also made closed-root).
- invariants.test.ts: kept main's builtEntryGraph helper + this branch's
  cron-inclusive exports assertion.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
wmadden-electric added a commit that referenced this pull request Jul 12, 2026
storefront-auth/systems/ → modules/ (pnpm-workspace glob + lockfile links
follow); the deploy roots system.ts → module.ts across storefront-auth, cron
(#45), and pn-widgets (#46), with each package.json script, exports map, and
tsconfig include updated. The deploy-verify-destroy GitHub action and the
bug-report issue template follow suit (deploy module.ts; module() comment).

The @storefront-auth/* package names are unchanged — scoped names, not paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
wmadden-electric added a commit that referenced this pull request Jul 12, 2026
storefront-auth/systems/ → modules/ (pnpm-workspace glob + lockfile links
follow); the deploy roots system.ts → module.ts across storefront-auth, cron
(#45), and pn-widgets (#46), with each package.json script, exports map, and
tsconfig include updated. The deploy-verify-destroy GitHub action and the
bug-report issue template follow suit (deploy module.ts; module() comment).

The @storefront-auth/* package names are unchanged — scoped names, not paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
wmadden-electric added a commit that referenced this pull request Jul 12, 2026
storefront-auth/systems/ → modules/ (pnpm-workspace glob + lockfile links
follow); the deploy roots system.ts → module.ts across storefront-auth, cron
(#45), and pn-widgets (#46), with each package.json script, exports map, and
tsconfig include updated. The deploy-verify-destroy GitHub action and the
bug-report issue template follow suit (deploy module.ts; module() comment).

The @storefront-auth/* package names are unchanged — scoped names, not paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
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.

2 participants