feat(app-cloud): deploy migrate lowering for pnPostgres + pn-widgets example (slice 2)#46
Conversation
…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>
@prisma/alchemy
@prisma/app
@prisma/app-assemble
@prisma/app-cli
@prisma/app-cloud
@prisma/app-nextjs
@prisma/app-node
@prisma/app-rpc
prisma-app
commit: |
…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
left a comment
There was a problem hiding this comment.
This is an excellent start. I'm truly shocked how little code there is.
There was a problem hiding this comment.
Take the .gitattributes from prisma-next so these are collapsed in git diffs.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Why not do this in TS so we can read it?
There was a problem hiding this comment.
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.
| // 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 }) => |
There was a problem hiding this comment.
Why does this centralized control.ts file need special case logic for a single provider?
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
This needs to accommodate PN refs: either ref identifier, or targetHash + invariantId (from a ref)
There was a problem hiding this comment.
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.
| * resource kind, the `prisma-next` routing type, and that `config` is a | ||
| * string. | ||
| */ | ||
| export function isPnPostgresResourceNode(node: unknown): node is PnPostgresResourceNode { |
There was a problem hiding this comment.
wtf is this? why from unknown? if you want to downcast, use the base type as input
There was a problem hiding this comment.
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.
| return Object.freeze({ | ||
| ...resource({ | ||
| name: arg.name, | ||
| extension: '@prisma/app-cloud', | ||
| provides: arg.contract, | ||
| }), | ||
| config: arg.config, |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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>
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
left a comment
There was a problem hiding this comment.
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
| 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 |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
This is an unbelievably huge amount of deploy script config. Why is it so huge? And reduce the timeout to ~2 mins
There was a problem hiding this comment.
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.
| 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. |
There was a problem hiding this comment.
This is a 30 line bullet point. wtf
There was a problem hiding this comment.
Rewritten as short prose plus a three-branch list (no-op / dbInit / migrate). All decision facts kept.
| * 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. | ||
| */ |
There was a problem hiding this comment.
WHY ARE YOUR COMMENTS SO GIGANTIC
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
What is a "compute control"? Missing a noun here
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| /** | ||
| * 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. | ||
| */ |
There was a problem hiding this comment.
SCAN YOUR DIFF FOR TRANSIENT PROJECT IDS
There was a problem hiding this comment.
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.
| * 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`. |
There was a problem hiding this comment.
Sincerely doubt anyone will understand this
There was a problem hiding this comment.
Rewritten to 3 plain lines. Same sweep applied to node.ts, control.ts, pg-connection.ts, deploy.ts, and the handler modules.
| * 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. |
There was a problem hiding this comment.
Stop suppressing these. If you need an AnyResource or AnyDeps, make the type alias ONCE and reuse it
There was a problem hiding this comment.
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>
|
Round-2 feedback addressed in 7ad5f67. Cold-connect canary (review body): 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. |
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'; |
There was a problem hiding this comment.
Why aren't you using the management api client?
There was a problem hiding this comment.
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>
…#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>
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>
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>
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>
What & why
Slice 2 of the Prisma Next data contract project (slice 1 = the typed primitive, #44). This makes a
pnPostgresdeploy 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'sstorageHash, 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 })— theprisma-next.config.tspath rides on the resource as a first-class field (sibling to the consumed contract), built by augmentingresource()'s frozen node (the[NODE]brand survives the spread). ExportedPnPostgresResourceNode+isPnPostgresResourceNodepredicate for the lowering to read it. No coreResourceNodechange; the path never touches the contract's__cmp.The deploy migrate lowering (D2). A new
nodes['prisma-next']incontrol.ts:postgres(Prisma.Database + Prisma.Connection).PnMigration, keyed on the targetstorageHash) runs the migration at apply-time (the resolved url). Read the live marker → no marker:dbInitapply; different hash:migrate(authored graph); already at target: no-op.dbInit/migraterun — never adbUpdatesynthesized 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 noacceptDataLosshook —migrate()has none, and authored destructive DDL is the user's explicit intent; the protection against accidental loss is that nothing is ever synthesized.PnMigrationprovider is defined in app-cloud and merged into the descriptor'sproviders()layer —@prisma/alchemystays 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 intoe2e-deploy.ymlas a deploy/verify/destroy job (ephemeral name, guaranteed teardown).storefront-authis untouched, so the bare-postgres()(untyped) path stays covered.Proof
pnpm lint/typecheck/lint:casts(cast-ratchet delta −3, net fewer casts) /@prisma/app-cloud test(81) +test:types(9).dbInit(marker signed); re-run →noop; no-authored-path → fails with the marker + schema unchanged.PnMigrationandPrisma.Database(no shadow).pn-widgetsexample proven locally end-to-end: topology loads (resource carriesconfig), the authored migration applies to a local DB via the example's ownprisma-next.config.ts, the typed client compiles against the emitted contract, and the server bundle is Compute-ready (PN runtime +pginlined; onlynode:builtins external).dist/index.mjsand the app-facingdist/prisma-next.mjsare both asserted free of the deploy-only machinery (@prisma-next/cli,postgres/control, thepn-*modules) — apnPostgresconsumer's runtime never pulls the CLI/control layer.The live deploy
The
pn-widgetsE2E job deploys to real Prisma Cloud, verifies the typed-client round trip, and always destroys. It runs on push tomainand 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 (
storageHashkeying + marker read); no-path deploy fails DB-unchanged (proven); bare-postgres()still covered (storefront-auth untouched).🤖 Generated with Claude Code