diff --git a/.drive/projects/alchemy-provider-adoption/design-notes.md b/.drive/projects/alchemy-provider-adoption/design-notes.md new file mode 100644 index 000000000..8fc3287f6 --- /dev/null +++ b/.drive/projects/alchemy-provider-adoption/design-notes.md @@ -0,0 +1,92 @@ +# Design notes — alchemy-provider-adoption + +## Principles + +- Own zero Management-API wrapper code that upstream also owns. +- Composer's local-dev iteration speed must not depend on upstream review latency (operator decision, 2026-08-03). +- Upstream's opinionated guards are adopted, not fought — each one we checked (named-DB+branch refusal, system-managed env refusal, pooled-first URL) was correct or workaroundable on our side. + +## The model + +Upstream's provider for the postgres and compute families (buckets stay Composer-provided until the upstream release ships them); two provider *layers* on Composer's side: + +- deploy: upstream's live providers (needs the `liveProviderLayer` export or a local rebuild of its wiring — client layer + individual `*Provider()`s). +- dev: Composer's emulator providers bound to upstream's resource classes, substituted at `LowerOptions.providers` (`deploy.ts:203`) exactly as ADR-0041 does today. + +State: hosted Postgres store unchanged; rows migrate off the colliding type-ids. Auth: `Layer.succeed(PrismaEnvironment, {token, baseUrl})`, skipping alchemy's profile store. + +## Alternatives considered + +- **Contribute emulators upstream** (original proposal, in wip notes): rejected for now — couples our dev loop to Sam's dual-mode design and review cadence. +- **Adopt `ProviderLayer.dual`**: solves cross-mode state stamping we don't need (dev and deploy use disjoint state stores). Revisit if that ever changes. +- **Vendor `src/Prisma/` into Composer**: works on beta.59 (provider uses no newer core APIs) but inherits `@prisma/dev` dep + permanent drift. Only a fallback if the beta bump stalls badly. +- **Keep our six resources**: rejected — the spike showed upstream is strictly more hardened on deploy lifecycle and we'd keep paying API drift. + +## Decision: the compute family adopts App + Deployment + EnvironmentVariable, not Compute + +Decided in slice 2, with the descriptor rewiring in front of us. Composer binds upstream's three low-level resources; `Prisma.Compute` is not used at all. + +**What decided it — a dependency cycle Compute cannot express.** Every Compute service gets a `COMPOSER_
_ORIGIN` environment row whose value is that same service's own platform-assigned endpoint domain (ADR-0039; the value function is `selfOriginValue` in `control/extension.ts`). `Prisma.Compute` is one resource that owns the app, its environment rows, and its deployment together, so that row would be an input of the very resource that produces the domain — a self-edge. Alchemy's planner fails such a cycle unless the resource implements `precreate` to signal an attribute early, and no Prisma provider implements `precreate`. Splitting the app out is what makes the wiring legal: `Prisma.App` is created in `provision` and hands out `appEndpointDomain` before any environment row is written, and `Prisma.Deployment` is created afterwards in `deploy`. The same split is what lets one service's row carry another service's origin without ordering the two deployments against each other. + +**Three more reasons, none of them decisive alone.** + +- *Environment ownership.* Compute manages the rows itself, keyed by an `environmentVariableIds` map it stores in its own attributes, and refuses any row in scope that is not in that map. Migrating Composer's existing per-key `EnvironmentVariable` state rows into one Compute resource's map has no honest mapping; keeping them as resources does. +- *ADR-0005.* Compute carries build, framework detection, entrypoint inference, and effect-native bundling. `artifactPath` bypasses all of it, but the bypass is a prop value, not a structural guarantee. `Prisma.Deployment` has no build path at all to fall through to. +- *The local emulators.* Compute is a `Platform` (runtime context, bindings, dev process spawning). The three low-level classes are plain resources, which the emulator providers bind to exactly as they bound Composer's own three. + +**What we give up by not taking Compute:** preview/stable health checks, automatic rollback, and — the one that matters — environment values folded into the fingerprint that decides whether a new deployment is needed. See below. + +## The environment→deployment edge after the swap (PRO-211) + +Upstream's `Prisma.Deployment` has no `environment` prop, so the edge rides `app`: the descriptor builds that prop as an expression over the app id AND every environment row's id, resolving to the app id itself (`compute/deployment-edge.ts`). Alchemy derives its dependency graph from the resource references a prop's value is built from, so every variable write is scheduled before the deployment is created. That is the ordering PRO-211 needs, and the ordering is what `docs/design/05-prisma-cloud/alchemy-lowering.md` records as the edge's job. + +**`app` is the only prop that can carry it**, and this is not a style preference. Upstream's diff reads `{portMapping, skipCodeUpload, artifactPath, artifactContentType}` as one block and returns "no opinion" the moment any of them is unresolved (`Deployment.ts:361-367`). A brand-new variable has no persisted state, so the planner resolves its reference to a bare resource expression (`Plan.ts:369-371`) — meaning a deploy that adds a variable would leave that whole block unresolved, the artifact comparison would never run, the engine would fall back to a plain update, and reconcile would keep the running deployment *while recording the new artifact's fingerprint as deployed*. The code change would be dropped, and every later deploy would agree it had already shipped. `app` sits outside that block and its own check treats an unresolved app as unchanged (`Deployment.ts:376-378`, `concreteIdsChanged`). The first implementation of this slice used `artifactPath` and had exactly that defect; `compute/__tests__/deployment-edge.test.ts` fails if it ever comes back, because it drives the real Output machinery and upstream's real diff rather than eager-collapse stubs. + +The swap initially lost a side effect the old provider had: because Composer's deleted `Deployment` created a brand-new deployment on every reconcile, a changed environment *value* shipped a new deployment as well. With upstream handed a stable artifact path, an unchanged artifact planned an update, its reconcile re-used the existing deployment, and a value-only change reached the platform's variable row but not the running deployment until the next artifact change. + +**That regression is closed Composer-side** (`compute/deploy-fingerprint.ts`): the artifact hard-link directory is named from a hash of the service's environment material, so upstream's resolved-path comparison replaces the deployment exactly when the environment (or artifact) changed and reuses it otherwise. The material is non-secret by construction (ADR-0042 rows carry literals and pointers, never values); pointed platform variables contribute their `updatedAt` metadata, read at preflight and transported across the CLI→Alchemy process boundary on the framework preflight channel (the transport is load-tested end to end — the first implementation lost the timestamps at the process boundary and no in-process test could see it). Secret-bearing rows contribute wiring identity only; the module comment records the accepted narrowing (a value re-issued under a stable resource identity waits for the next fingerprint-moving change) and the flows it affects. `redeployOn` (upstream, in review) is the eventual carrier at the marked seam. + +The mechanisms ruled out and why: value hashes in state (offline-guessing target — the rule survives, refined to "non-secret material only"); `EnvironmentVariable.updatedAt` through a Deployment replacement prop (not in the variable's stables, and it moves on every deploy anyway); a per-run generation path (shipped briefly — restored the old always-redeploy behavior at the cost of all reuse; superseded by the fingerprint). + +## The poison DATABASE_URL rows are gone + +`application.provision` used to overwrite the platform's seeded `DATABASE_URL` and `DATABASE_URL_POOLED` with `"-"` so nothing could rely on the platform default. The platform marks both system-managed, and upstream's `EnvironmentVariable` refuses to manage a system-managed variable, so those writes are removed rather than reshaped (they would fail the deploy). What still holds the line is the ban at the authoring end: `param.ts` and `secret.ts` reject both names, so no Composer-written row can carry one, and `configKey` puts every Composer row in the `COMPOSER_` namespace. + +Existing poison state rows are marked `removalPolicy: "retain"` on read (see `state/legacy-resources.ts`), so the engine drops the state row, calls no API, and reports `retained` — the truthful verb. The deployed smoke run caught the first version of this: it reported `deleted`, which told an operator the platform variable was gone when it was still there. + +Residual, and it differs by stage: + +- A stage Composer never deployed before the swap: `DATABASE_URL` holds the platform's own template value. An app reading it directly gets a working default rather than something that fails loudly — that is the protection we lost. +- A stage Composer HAD deployed: the `"-"` placeholder it wrote is still on the platform, user-managed (`isManagedBySystem: false`), and stays until an operator deletes it. `docs/guides/deploying.md` gives the call. So a migrated stage keeps the old fail-loudly behaviour by accident, indefinitely, unless someone cleans up. + +## What the swap costs us, precisely + +One behaviour got worse and is not mitigated on our side; a second was worse for a while and is now restored (see the PRO-211 section above). + +**App delete retry budget: 5 minutes → about 4 seconds.** Composer's deleted `ComputeService` provider retried the platform's "did not reach a delete-safe state" 409 on an exponential schedule capped at 5 minutes. Upstream's `destroyApp` (`ComputeLifecycle.ts:276-310`) retries any conflict up to 5 times, sleeping 250ms · 2^attempt between consecutive attempts (four waits: 250ms + 500ms + 1s + 2s = 3.75 seconds of waiting in total; the final failed attempt returns without sleeping) — and it does NOT drain the app's deployments first; it deletes the App and relies on the platform's cascade. Alchemy does delete a *tracked* `Prisma.Deployment` before the App that owns it, because the resource graph orders them, but any untracked deployment still winding down can still 409 the App delete past that budget. A destroy of a stage that was serving traffic seconds earlier is the case to watch. + +**Environment-value change redeploys again — by replacing every deployment on every deploy.** The gap and its Composer-side fix, its cost, and the `redeployOn` hand-off are covered above. + +## Upstream asks (slice 3) + +- **A `Prisma.Deployment` prop for "recreate when these inputs change" (`redeployOn`; companion upstream commit in flight).** Until it ships, Composer detects change itself via the deploy fingerprint (`deploy-fingerprint.ts`), which cannot see a value re-issued under a stable resource identity. `Compute` already folds `env` into its fingerprint and stores it `Redacted`; the low-level resource needs the same seam to close that last gap. +- **Raise or make configurable the App delete-retry budget** (or drain the app's deployments before deleting it). +- **Export `PrismaUploadClient` / open the `alchemy/Prisma/Internal/*` subpath.** Its package export is explicitly `null`, so the scoped upload client cannot be composed privately by an outside stack; the only alternative is overriding the ambient `HttpClient`, which is a much blunter instrument. + +## Why no environment-derived fingerprint exists yet (the search, recorded) + +Everything an `EnvironmentVariable` exposes was checked for "moves when the value moves": + +- `updatedAt` moves on EVERY deploy, not on every change: upstream's diff returns an update whenever the desired value is resolved, to heal out-of-band drift (`EnvironmentVariable.ts:290-296`), and reconcile then PATCHes unconditionally (`:378-386`). Folding it into a deployment prop would restore Composer's OLD behaviour of shipping a new deployment on every single deploy — not value-change detection. +- `valueKid` identifies the encryption key, not the value; it carries no change semantics. +- The plaintext is write-only and never read back, so nothing observable distinguishes "same value re-applied" from "new value". + +The durable statement: **the only attribute that moves at all fires on every deploy** — and it is not in the variable's stables, so it cannot even ride a plan-time diff. Any real fix must come from the deployment side, which is where the deploy fingerprint (and eventually `redeployOn`) sits. + +## Open questions + +Tracked in spec.md (state-migration mechanics; first released beta). The Compute-vs-App+Deployment question is settled above. + +## References + +`wip/alchemy-prisma-provider-notes-for-aman.md`; spike session artifacts; upstream PRs #416, #963. diff --git a/.drive/projects/alchemy-provider-adoption/plan.md b/.drive/projects/alchemy-provider-adoption/plan.md new file mode 100644 index 000000000..29ce547f0 --- /dev/null +++ b/.drive/projects/alchemy-provider-adoption/plan.md @@ -0,0 +1,43 @@ +# Project Plan — alchemy-provider-adoption + +## Summary + +Three slices: two stacked (postgres family, then compute family) and one parallel (upstream contributions). The spike that grounded this plan is this project's originating session; call-site inventory is in `spec.md` References. + +**Spec:** `.drive/projects/alchemy-provider-adoption/spec.md` + +## Slices + +### Slice 1 — Postgres family adoption (TML-3154) + +Bump alchemy to the first released beta containing the Prisma provider; wire upstream live providers + `PrismaEnvironment` auth; rename our collection tag; swap `Project`/`Database`/`Connection` to upstream classes; rewire postgres/prisma-next descriptors; create-then-PATCH branch attach; `directConnectionString`; state-row migration (mechanics decided here: aliases vs SQL); rebind postgres emulator provider. + +- **Builds on:** nothing (first slice). +- **Hands to:** slice 2 — alchemy bumped, upstream live-provider wiring + auth layer in place, collection tag renamed, state-migration mechanism proven on the postgres rows. + +### Slice 2 — Compute family adoption (TML-3155) + +Swap `ComputeService`/`Deployment`/`EnvironmentVariable`; decide Compute vs App+Deployment; `artifactPath`-only enforcement (ADR-0005); env parity + `DATABASE_URL` exclusion; state migration on compute rows; rebind compute emulator provider. + +- **Builds on:** slice 1's hand-off. +- **Hands to:** close-out — Composer fully on upstream for the six resources; old implementations deleted. + +### Slice 3 — Upstream contributions (TML-3156) — parallel + +Fork alchemy-run/alchemy (wmadden-electric), then ONE implementation PR (per the operator override below): `liveProviderLayer` export, bucket resources, and the generic `postgresState` backend, implemented directly — no asks filed. `PgWarm` offered in the same conversation. + +- **Builds on:** nothing (written against upstream shapes directly). +- **Hands to:** slice-1 dependency softening (the export); Composer bucket deletion at close-out if the bucket PR merges + releases in time (otherwise buckets stay per transitional constraint). + +## Sequencing + +- Stack: 1 → 2. +- Parallel: 3 alongside both. +- **Operator overrides (2026-08-03):** all Composer-side slices land on THIS branch (no per-slice branches; one Composer PR at the end). Slice 3 is ONE implementation PR to alchemy-run/alchemy — `liveProviderLayer` export, bucket resources, and the postgres state backend implemented directly, no asks filed. Upstream branch: `prisma-provider-composer-needs` in `~/Projects/prisma/alchemy` (push blocked until the wmadden-electric fork exists). + +## Close-out (required) + +- [ ] Verify all acceptance criteria in `.drive/projects/alchemy-provider-adoption/spec.md` +- [ ] Migrate long-lived docs into `docs/` (ADR for the adoption + revised local-dev seam; alchemy-lowering.md rewrite) +- [ ] Strip repo-wide references to `.drive/projects/alchemy-provider-adoption/**` +- [ ] Delete `.drive/projects/alchemy-provider-adoption/` diff --git a/.drive/projects/alchemy-provider-adoption/spec.md b/.drive/projects/alchemy-provider-adoption/spec.md new file mode 100644 index 000000000..cada9c6de --- /dev/null +++ b/.drive/projects/alchemy-provider-adoption/spec.md @@ -0,0 +1,70 @@ +# Purpose + +Stop maintaining Composer's own Alchemy resources for Prisma Cloud. The official `alchemy/Prisma` provider (alchemy-run/alchemy, PR #416) now covers the same Management API surface with a more hardened deployment lifecycle, and it is written by our own colleague. Every line of API-wrapper code we keep is drift risk against the Management API and duplicated effort against upstream. After this project, Composer consumes upstream for everything that is genuinely about Prisma Cloud, contributes the pieces upstream lacks that are generic, and keeps locally only what encodes Composer concepts. + +A second aim: keep Composer's **local dev emulation** iterating on our own timeline. The emulators stay in Composer, driving upstream's live providers and our emulator providers through the same provider-layer substitution seam we use today — explicitly *not* blocked on upstream's dev-mode design (Sam's `ProviderLayer.dual`, #963) settling. + +# At a glance + +Four workstreams: + +1. **Adopt** — replace Composer's six overlapping resources (`Project`, `Database`, `Connection`, `ComputeService`, `Deployment`, `EnvironmentVariable`, ~670 lines in `packages/1-prisma-cloud/0-lowering/lowering/src/`) with upstream's resource classes and live providers. Requires the alchemy bump beta.59 → beta.66+ (the provider ships inside the `alchemy` package; beta.59 has no `Prisma/` directory). +2. **Port** — rewire Composer to upstream's shapes: descriptor call sites to upstream prop/attribute names; state rows migrated off the five colliding type-ids; our provider collection tag renamed (upstream also uses `'Prisma'`, and Effect context merge silently drops one of two same-key collections); auth via `Layer.succeed(PrismaEnvironment, …)` instead of `fromProfile()` (which prompts on TTY / hard-fails non-interactive); `directConnectionString` bound explicitly (upstream's `databaseUrl` resolves pooled-first); platform-seeded `DATABASE_URL` kept out of the resource graph (verified `isManagedBySystem: true`); branch attachment via create-then-PATCH (verified in PDP: create+attach are separate transactions, no idempotency key). +3. **Contribute upstream** — object storage resources (~161 lines; upstream deferred exactly the routes we call) and the generic core of the Postgres state store (~450 lines; only alchemy state backend with distributed locking, which upstream's own `Compute` docstring asks users to find). `PgWarm` offered to upstream; drop ours if they solve cold-start in `Database`/`Connection`. +4. **Keep local** — the dev emulators (~3,200 lines + s3-protocol) and the five Composer-concept resources (`ServiceKey`, `GeneratedParam`, `S3Credentials`, `PnMigration`, state-store policy layer). Emulators plug in behind `LowerOptions.providers` exactly as today, now paired with upstream's live providers. + +Aman has agreed to the direction (call, 2026-08-03). Composition landed without waiting: the local wiring rebuilds the live provider layer from upstream's exported resource classes. The `liveProviderLayer()` export ask (or an equivalent override on `providers()`) remains as a temporary-dependency cleanup — once upstream exports it, the local rebuild is deleted. + +# Non-goals + +- **Contributing the emulators upstream.** Explicitly reversed from the original proposal: they stay in Composer so we iterate without upstream review latency. Revisit only after upstream's dev-mode design (dual) settles. +- **Adopting `ProviderLayer.dual` / upstream dev mode.** Composer keeps its layer-swap seam and split state universes (localState for dev, hosted store for deploy). Dual solves a problem we don't have yet. +- **Adopting upstream's `Prisma.Compute` build/bundle conveniences.** We hand upstream pre-built artifacts via `artifactPath` only (ADR-0005: the framework never bundles/transforms user code). Auto-build, framework detection, and Effect-native bundling are never exercised by Composer. +- **Adopting alchemy's profile/credential store.** Composer keeps env-var credentials (`PRISMA_SERVICE_TOKEN` via `Config.redacted`). +- **Deterministic database names with branch attachment in one create.** Upstream's guard is correct (verified against PDP); we adopt create-then-PATCH rather than asking for the guard to be relaxed. + +# Place in the larger world + +- Upstream: `alchemy-run/alchemy`, provider at `packages/alchemy/src/Prisma` (14.5k lines, merged 2026-07-29). Owner-of-record for merges is Sam Goodwin; Aman authored the Prisma provider. Contributions land there as PRs. +- Composer side: the lowering package (`packages/1-prisma-cloud/0-lowering/lowering`) shrinks to buckets (until upstreamed), state store, container resolution; the extension (`packages/1-prisma-cloud/1-extensions/target`) keeps descriptors + the five Composer-concept resources; `local-target` + `dev-emulators` unchanged in ownership, rewired to upstream resource shapes. +- The forcing-function-apps project consumes this: its object-storage and dev workstreams sit directly on the seams this project moves. + +# Cross-cutting requirements + +- **No regression for deployed stages.** Existing state rows reference the old type-ids and attribute shapes (`{id, name}` vs upstream's `databaseId`). Every stage must deploy cleanly across the migration without recreating live resources; destroy of pre-migration rows must still resolve a provider. +- **ADR-0005 holds everywhere.** Only `artifactPath` (or `Prisma.Deployment`'s equivalent) is ever exercised; no code path may fall through to upstream build/bundle/entrypoint inference. +- **Env parity rules survive the port.** ADR-0019/0029/0032 serialization, the `COMPOSER_*` namespace, and the poison-`DATABASE_URL` exclusion must behave identically on upstream `EnvironmentVariable`. +- **Local dev keeps working at every intermediate commit** — the emulator providers must bind to whichever resource classes are current. +- **Pinned upstream version.** Alchemy stays pinned exact (as today); each bump is a deliberate change with the beta-to-beta breaking-change review this project's spike established (beta.60–65 were all Cloudflare/AWS-scoped). + +# Transitional-shape constraints + +- Adoption is per-resource-family, not big-bang: postgres family (Project/Database/Connection) and compute family (App/Deployment/EnvironmentVariable) may land in separate slices, each leaving main deployable. +- Until the upstream `liveProviderLayer` export lands in a release, Composer may carry a small local reimplementation of upstream's provider wiring (client layer + individual `*Provider()` calls) — accepted drift risk, removed the moment the export ships. +- Bucket resources stay in Composer until the upstream contribution merges and releases; the s3/s3-store descriptors must tolerate either home. + +# Project DoD + +- [x] Composer's six overlapping resource implementations are deleted; lowering/descriptors consume `alchemy/Prisma` classes. +- [x] `alchemy` pinned at a released version ≥ the first beta containing the Prisma provider; CI green. +- [x] A pre-existing deployed stage (created before the migration) deploys and destroys cleanly on the new stack. +- [x] Local dev (`prisma-composer dev`) runs the full example topology on the emulators against upstream resource shapes. +- [x] Deployed smoke suite passes (storefront-auth or equivalent example) on Prisma Cloud. +- [x] Object-storage resources PR and state-store PR opened upstream (merge is not in our gift; opened + review-responsive is the bar). +- [x] The upstream ask (export `liveProviderLayer` or equivalent) is filed and either landed or worked around per the transitional constraint. +- [x] ADR recorded documenting the adoption and the revised local-dev seam (ADR-0048). + +# Open questions + +- ~~Compute vs App+Deployment~~ — resolved: the low-level `App`+`Deployment` pair, with Composer's own env dependency edge (`deployment-edge.ts`). The `COMPOSER_*_ORIGIN` self-edge needs the App before env rows, and composite `Compute` owns a build path ADR-0005 rules out (ADR-0048, design-notes.md). +- ~~State migration mechanics~~ — resolved: rows are rewritten on read in the hosted store (`state/legacy-resources.ts`), with type-id aliases so old rows resolve; no destroy-and-recreate (ADR-0048, design-notes.md). +- ~~Which released beta first contains the provider~~ — resolved: `alchemy@2.0.0-beta.67` is the adopted pin. + +# References + +- Session evaluation + notes for Aman: `wip/alchemy-prisma-provider-notes-for-aman.md` +- Memory: `alchemy-upstream-prisma-provider` (blockers now resolved by decisions above) +- Upstream provider: https://github.com/alchemy-run/alchemy/pull/416 +- Engine dual-mode: https://github.com/alchemy-run/alchemy/pull/963 +- Linear project: https://linear.app/prisma-company/project/alchemy-prisma-provider-adoption-79d1f6cc7bff +- ADR-0005 (no bundling), ADR-0019/0023/0024 (containers), ADR-0029/0032 (env serialization), ADR-0034 (hosted state), ADR-0041 (local dev pipeline) diff --git a/docs/design/03-domain-model/glossary.md b/docs/design/03-domain-model/glossary.md index b2d82f9fd..f792b3925 100644 --- a/docs/design/03-domain-model/glossary.md +++ b/docs/design/03-domain-model/glossary.md @@ -357,7 +357,7 @@ substituted at any Input) and a real deployment. ## Provisioning plane — the compile target (Alchemy / Effect) The exact substrate the authoring nouns lower **down to**, grounded in what our -providers already use (`packages/alchemy`, `alchemy@2.0.0-beta.59`, +providers already use (`packages/alchemy`, `alchemy@2.0.0-beta.67`, `effect@4-beta`). Building the next layer of abstraction means defining each authoring noun as *the compile-target terms it emits*. Two families: Alchemy's IaC definition language, and the Effect primitives Alchemy is itself built on. @@ -375,17 +375,16 @@ is in `layering.md`; this is the term-by-term catalogue. `→` **Topology / implicit root Module**. - **Resource\** — a managed entity with a string type tag, desired-input **Props**, and cloud-returned **Attributes**. Declared, then - `yield*`-ed. Ours: `Prisma.Project`, `Database`, `Connection`, - `ComputeService`, `Deployment`, `EnvironmentVariable`. - `→` a **Service** lowers to `ComputeService` + `Deployment` (+ - `EnvironmentVariable`); a first-class **Resource** (Postgres) lowers to - `Project` + `Database` + `Connection`. + `yield*`-ed. Composer binds upstream alchemy's: `Prisma.Project`, `Database`, + `Connection`, `App`, `Deployment`, `EnvironmentVariable`. + `→` a **Service** lowers to `App` + `Deployment` (+ `EnvironmentVariable`); a + first-class **Resource** (Postgres) lowers to `Database` + `Connection`. - **Props** — the desired configuration passed at declare time; diffed against - the last deploy to detect change. (We put the artifact's `artifactHash` in - Props so a rebuild registers as a change.) `→` a node's **Inputs** + + the last deploy to detect change. (A rebuild registers as a change because the + artifact is content-addressed: new bytes, new `artifactPath`.) `→` a node's **Inputs** + **Configuration**. -- **Attributes / Output\** — values the cloud returns (`deployedUrl`, - `versionId`, ids); lazy references that flow into other Resources' Props. +- **Attributes / Output\** — values the cloud returns (`appEndpointDomain`, + `deploymentId`, ids); lazy references that flow into other Resources' Props. Resource-to-resource wiring is Output → Props. `→` a node's **Outputs**; a **connection** (Output→Input) lowers to Output→Props, plus an `EnvironmentVariable` when the consumer reads it at runtime (what `AUTH_URL` @@ -413,9 +412,11 @@ These two Alchemy concepts exist but our stack does not use them — and that ga is where the framework's own binding layer gets built. - **Platform** — Alchemy's Resource-that-carries-runtime-code (Cloudflare - Worker, AWS Lambda, Container). We model Prisma Compute as **ordinary - Resources** (`ComputeService` + `Deployment` + artifact) instead, because - Compute isn't an Alchemy-native platform. + Worker, AWS Lambda, Container). Alchemy's `Prisma.Compute` is one, but + Composer lowers to the **ordinary Resources** (`App` + `Deployment` + + artifact) instead — see the compute-family decision in the adoption notes: + a service's own origin is an input to its own environment, which one + composite resource cannot express. - **Binding** (`bind()`) — Alchemy's "the binding *is* the client" for a Platform: one call emits permissions + env and hands back a typed SDK client. We do **not** use it. The framework's binding/DI (capability `Tag` + `Layer` + diff --git a/docs/design/03-domain-model/layering.md b/docs/design/03-domain-model/layering.md index 38b804964..b24acc0be 100644 --- a/docs/design/03-domain-model/layering.md +++ b/docs/design/03-domain-model/layering.md @@ -23,8 +23,8 @@ resource graph, which deploys to the cloud. wires, and provisions. Nouns: Resource, Platform, Binding, Layer, Provider, Stack, Config. The framework adopts Alchemy's *definition language*; the apply *engine* is an open question (see below). -- **Hosting plane (Prisma Cloud)** — what actually runs. Nouns: ComputeService / - ComputeVersion, Database (1:1 within an Environment), Stream, endpoint. Prisma +- **Hosting plane (Prisma Cloud)** — what actually runs. Nouns: App / + Deployment, Database (1:1 within an Environment), Stream, endpoint. Prisma Cloud is *one* target; another target's pack maps the same authoring nouns to its own hosting primitives. The framework's deploy report calls a thing on this plane a **Deployment entity** (`DeployedEntity`): its kind, platform id, @@ -35,7 +35,7 @@ resource graph, which deploys to the cloud. | Authoring (Prisma Composer) | Provisioning (Alchemy/Effect) | Hosting (Prisma Cloud) | | --- | --- | --- | | **Module** (bounded context) | a subgraph: Resources/Platforms + a Layer exposing its ports | **no single object** — spans Compute services + a DB schema slice + streams + endpoints | -| **Service** (your code; entrypoint + ingress) | Platform (compute Resource running the bundle) | ComputeService → ComputeVersion (tar.gz bundle + manifest + endpoint) | +| **Service** (your code; entrypoint + ingress) | App + Deployment (ordinary Resources) | App → Deployment (tar.gz bundle + manifest + endpoint) | | **Resource** (managed lifecycle, state-first) | Alchemy Resource + Provider (`reconcile`/`delete`/…); Postgres via the Prisma Postgres provider | a Database (1:1 in an Environment), bucket, cache, or provisioned third-party | | **Input/Output — communication** (request/response, stream) | Binding (RPC/HTTP client; stream pub/sub) | endpoint URL + injected client; stream | | **Data Input** (method TCP/HTTP + contract) | data binding to a Postgres Resource | connection injected, scoped by contract | diff --git a/docs/design/05-prisma-cloud/alchemy-lowering.md b/docs/design/05-prisma-cloud/alchemy-lowering.md index 6fbb08a65..3aef6b46f 100644 --- a/docs/design/05-prisma-cloud/alchemy-lowering.md +++ b/docs/design/05-prisma-cloud/alchemy-lowering.md @@ -1,10 +1,16 @@ -# Alchemy ↔ PDP — the resources we define and how they map +# Alchemy ↔ PDP — the resources we bind and how they map -The Alchemy resource types `packages/prisma-alchemy` defines over the +The Alchemy resource types Composer lowers to over the [PDP data model](pdp-data-model.md), the mapping in both directions, and the lowering graphs — including the correction that makes deploy ordering a property of the dependency graph rather than luck. +The postgres family (`Project`, `Database`, `Connection`) and the compute +family (`App`, `Deployment`, `EnvironmentVariable`) are **upstream alchemy's** +(`alchemy/Prisma`), not ours: Composer registers their providers and binds their +props. What `@internal/lowering` still owns is the artifact packager, the +buckets, `ServiceKey`, and the hosted state store. + ## Placement: one Project per application, one Branch per stage A PDP Project is a **shared config namespace** (every App on a branch snapshots @@ -38,41 +44,53 @@ Alchemy only diffs and provisions the resources *inside* a (Project, Branch), never the container itself (see [§ Stages and container resolution](#stages-and-container-resolution)). -## `DATABASE_URL` is forbidden — and actively poisoned +## `DATABASE_URL` is forbidden — and left to the platform The platform writes `DATABASE_URL` / `DATABASE_URL_POOLED` templates pointing at a project's default database — a convenience for hand-provisioned single services, and precisely the kind of **implicit ambient config the framework -exists to eliminate**. The framework never reads it, never depends on it, and -makes reliance on it impossible. First, the framework creates Projects with -`createDatabase: false`, so **no default database exists at all** on a -framework-provisioned Project (the opt-out is workspace-actor-only — fine, -deploys authenticate with service tokens). Second, as defense in depth (and -for Projects created before the opt-out, or adopted by name): when the -framework provisions a Project, it **writes user-level -`DATABASE_URL` and `DATABASE_URL_POOLED` variables with a poison value** (`"-"` — -a garbage value any direct reader fails to connect with; the API rejects an empty -string, `"String must contain at least 1 character"`, verified at the R4 deploy -proof). User-set values -permanently override the platform templates (`wireDefaultDatabaseUrl` leaves -them untouched), so nothing deployed by the framework can ever quietly work -off the default again. Every database URL a service consumes is an explicit, -per-service -variable the pack's `serialize` writes under its own named key. +exists to eliminate**. The framework never reads it and never depends on it. +Framework-provisioned Projects are created with `createDatabase: false`, so no +default database exists on them — but that alone does not keep the variable +away: the platform self-heals a missing `DATABASE_URL` template on the first +Compute deploy, wiring it from any ready database on the Project (default +first, then oldest) — on a Composer Project, one of the app's own databases. + +So the framework claims the keys first. `application.provision` writes +`DATABASE_URL` and `DATABASE_URL_POOLED` (production and preview class, +project-level) with the placeholder value `"-"` via create-only calls +(`lowering/src/database-url-claim.ts`). The platform's writes are also +create-only, so whoever writes first wins permanently: on a fresh Project the +claim lands first and the self-heal never fires; on a Project whose variables +the platform already seeded, the claim gets a 409 and no-ops. The rows are +plain platform variables, never Alchemy resources — nothing enters deploy +state and upstream's `EnvironmentVariable` never owns them. Alongside the +claim, the authoring-side ban holds: `param.ts` and `secret.ts` reject both +names, so no Composer-declared row can carry one. Every database URL a +service actually consumes is an explicit, per-service variable the pack's +`serialize` writes under its own named key, inside the `COMPOSER_` namespace. + +A service that reads `process.env.DATABASE_URL` behind the framework's back +therefore reads the placeholder `"-"` and fails loudly — or, on a Project the +platform seeded before the framework ever deployed to it, the platform's own +template ([deploying.md](../../guides/deploying.md) covers the leftover rows +and manual cleanup). ## The resource inventory -Each row is an Alchemy resource type we define (Alchemy has no built-in types — -it manages whatever a provider package registers). +Each row is a resource type the lowering binds. The `Prisma.*` families are +upstream `alchemy/Prisma` classes (ADR-0048) — Composer registers them in its +provider collection and defines resources only where the upstream provider +has no support yet (buckets) or no Management API exists behind them. -| Our resource | PDP entity it manages | Props (in) | Outputs (out) | Notes | +| Resource (upstream `alchemy/Prisma`) | PDP entity it manages | Props (in) | Outputs (out) | Notes | | --- | --- | --- | --- | --- | -| `Project` | Project | workspaceId, name | id | **one per Prisma Composer application**; the poison `DATABASE_URL` variables are written at provision (see above) | -| `Database` | Database | projectId, name | id, connection info | one per Module-provisioned postgres resource; never the project default; created project-scoped, then attached to a named stage's Branch by a follow-up `PATCH` (the create body doesn't accept `branchId`) | -| `Connection` | database connection info | databaseId | url | direct/pooled endpoints; the url is written as the service's own named variable via the pack's `serialize` | -| `ComputeService` | App | projectId, name, region, branchId? | id | `branchId` in the create body targets a named stage's Branch directly; omitted, PDP attaches it to the Project's default (production) Branch | -| `EnvironmentVariable` | ConfigVariable | projectId, class, key, value, branchId? | id | production-class with no `branchId` on the default stage; preview-class with `branchId` on a named stage | -| `Deployment` | Deployment (ComputeVersion) + Promotion | computeServiceId, artifactPath, artifactHash, port, **environment** (the env-var records the version boots with — see the graphs below) | versionId, deployedUrl | provider reconcile: create version → upload tar.gz → start → poll until running → promote; `deployedUrl` read **post-promote** (create-time domain is a placeholder — PRO-200) | +| `Prisma.Project` | Project | name | projectId | **one per Prisma Composer application**; resolved by the CLI before Alchemy runs, so no lowering yields one | +| `Prisma.Database` | Database | project, name?, region, branchId? | databaseId, connection strings | one per Module-provisioned postgres resource; never the project default; a branch-attached database is created with `branchId` and no display name (upstream refuses the combination — see [deploying.md](../../guides/deploying.md)) | +| `Prisma.Connection` | database connection info | database, name | connectionId, directConnectionString | Composer binds the DIRECT string explicitly; upstream's `databaseUrl` is pooled-first | +| `Prisma.App` | App | project, displayName, regionId, branchId? | appId, appEndpointDomain | `branchId` targets a named stage's Branch; omitted, upstream attaches the App to the project's default (production) Branch. `appEndpointDomain` is available at provision — that is what a service's own origin is read from | +| `Prisma.EnvironmentVariable` | ConfigVariable | project, class, key, value (Redacted), branchId? | environmentVariableId | production-class with no `branchId` on the default stage; preview-class with `branchId` on a named stage. Values are write-only, so upstream re-applies the desired one on every deploy | +| `Prisma.Deployment` | Deployment (ComputeVersion) + Promotion | app, artifactPath, artifactContentType, portMapping, start, promote | deploymentId, appEndpointDomain | provider reconcile: create → upload tar.gz → start → poll until running → promote; `appEndpointDomain` read **post-promote** (create-time domain is a placeholder — PRO-200). It is replaced, not updated, when its artifact fingerprint moves | What we deliberately do **not** model yet, and where it will bite: **Promotion** as a standalone resource (the Deployment provider @@ -119,7 +137,8 @@ logic is untouched. resolved and its lifecycle managed by the CLI's container-resolution client, outside the Alchemy graph entirely (see [§ Stages and container resolution](#stages-and-container-resolution)). - `serviceEndpointDomain` surfaces only as `Deployment.deployedUrl`. + `serviceEndpointDomain` surfaces as `App.appEndpointDomain` (before the first + deploy) and `Deployment.appEndpointDomain` (post-promote). ## The lowering graphs @@ -143,54 +162,73 @@ flowchart LR ```mermaid flowchart TB subgraph P [Project: storefront-auth] - POISON["EnvironmentVariable(DATABASE_URL = poison)"] DBa[(Database auth-db)] --> Ca[Connection] -- url --> EVa["EnvironmentVariable(AUTH_DB_URL)"] DBs[(Database storefront-db)] --> Cs[Connection] -- url --> EVs["EnvironmentVariable(STOREFRONT_DB_URL)"] - Sa[ComputeService auth] --> Da[Deployment_a] - Ss[ComputeService storefront] --> Ds[Deployment_s] - EVa -- record ref --> Da - EVs -- record ref --> Ds - Da -- deployedUrl --> EVu["EnvironmentVariable(STOREFRONT_AUTH_URL)"] - EVu -- record ref --> Ds + Sa[App auth] --> Da[Deployment_a] + Ss[App storefront] --> Ds[Deployment_s] + EVa -- id ref --> Da + EVs -- id ref --> Ds + Da -- appEndpointDomain --> EVu["EnvironmentVariable(STOREFRONT_AUTH_URL)"] + EVu -- id ref --> Ds end ``` How the pieces map: -- **The application** lowers to one `Project`, provisioned first, with the - poison `DATABASE_URL` variables written immediately (nothing downstream can - depend on the default). -- **Each service** lowers to a `ComputeService → Deployment` chain plus its own +- **The application** lowers to one `Project`, resolved by the CLI before + Alchemy runs. It provisions no variables of its own. +- **Each service** lowers to an `App → Deployment` chain plus its own `Database → Connection`, whose url is written as that service's **explicitly named** variable — the same `serialize` path as any other config value. -- **The connection** lowers to two edges: the producer's `deployedUrl` flows - into a named `EnvironmentVariable`, and that variable's **record reference - flows into the consumer's `Deployment`** via its `environment` prop. -- Every `EnvironmentVariable` a Deployment boots with appears in its - `environment` prop — database URLs and connection URLs alike — so the version +- **The connection** lowers to two edges: the producer's endpoint domain flows + into a named `EnvironmentVariable`, and that variable's **id flows into the + consumer's `Deployment`** through its `app` prop. +- Every `EnvironmentVariable` a Deployment boots with is threaded into its + `app` — database URLs and connection URLs alike — so the deployment depends on its config being written first. -- The Deployment's `port` prop rides the same seam: `serialize` resolves the - service's `port` param from the typed Config and surfaces it in its outputs, - and `deploy` routes the platform to it — so the routed port and the `PORT` - the app binds trace to one value and cannot drift. - -The `environment` prop is essential and mirrors PDP's own dataflow — the -version-create call literally contains the materialized env map, so the -environment is genuinely an input to a version (see the +- The Deployment's `portMapping.http` rides the same seam: `serialize` resolves + the service's `port` param from the typed Config and surfaces it in its + outputs, and `deploy` routes the platform to it — so the routed port and the + `PORT` the app binds trace to one value and cannot drift. + +That ordering edge is essential and mirrors PDP's own dataflow — the +deployment-create call literally contains the materialized env map, so the +environment is genuinely an input to a deployment (see the [config lifecycle](pdp-data-model.md#the-config-lifecycle--what-is-resolved-when)). -The edge's job today is **ordering**: the variable write completes before -version-create, so the first version boots with a complete environment. Without -it the two race — the failure documented as PRO-211 in `gotchas.md`. - -**Change propagation is a deferred follow-up, not yet wired.** The env-var -resource exposes only `{ id, key }`, so a *value* change (a rotated URL) does not -diff the consumer `Deployment`, and no new version is created. The intended fix is -provenance-based — the consumer depends on the **source node's** version, never on -the value or a hash of it (a hash of a secret is itself a leak, and persisting the -value would put a credential in Alchemy state). It is narrow in practice: promoted -service endpoints are stable across producer redeploys, so a wire's value rarely -moves, and true secrets are platform-sourced and rotate through the platform, not -this edge (see the [config/secret split](../03-domain-model/glossary.md#configuration--config-and-secrets)). +The edge's job is **ordering**: the variable write completes before +deployment-create, so the first deployment boots with a complete environment. +Without it the two race — the failure documented as PRO-211 in `gotchas.md`. + +**Why the edge rides `app`.** Upstream's `Prisma.Deployment` has no +`environment` prop (Composer's deleted one did). Alchemy derives its dependency +graph from the resource references a prop's *value* is built from, so the +descriptor builds `app` as an Output over the app id AND every variable's id, +resolving to the app id itself: the graph gains the edges. It cannot ride +`artifactPath` (or any of upstream's other replacement-block props): the diff +reads that block as one unit and gives no opinion the moment any member is +unresolved — and a brand-new variable's reference IS unresolved at plan time — +which would skip the artifact comparison and silently drop a code change. +`compute/deployment-edge.ts` records the full argument and its test drives +upstream's real diff. + +**Change propagation is wired by an environment fingerprint in the artifact +path.** The platform freezes a deployment's environment at create, so a +*value* change (a rotated URL) reaches a running service only through a new +deployment. The deploy hook names the artifact hard-link directory from a hash +of the service's environment material (`compute/deploy-fingerprint.ts`), so +the resolved path upstream compares moves exactly when the environment does: +unchanged service → identical path → reuse; changed environment or artifact → +new path → replace. The hashed material is non-secret by construction — +environment rows carry config literals and pointers, never secret values (see +the [config/secret split](../03-domain-model/glossary.md#configuration--config-and-secrets)) +— and out-of-band rotation of a pointed platform variable is detected via its +`updatedAt` metadata, read at preflight and carried across the CLI→Alchemy +process boundary on the framework's preflight-transport channel. Secret-bearing +rows contribute wiring identity only; a value re-issued under a stable resource +identity does not move the fingerprint (the module comment records the +accepted narrowing). When upstream's `Prisma.Deployment` gains `redeployOn` +(inputs a deployment must be recreated for) and the pinned alchemy version +includes it, the fingerprint moves onto that prop at the marked seam. The framework's core constructs these edges when lowering a connection (the `serialize` env-var records thread into `deploy` through the service SPI); no pack diff --git a/docs/design/05-prisma-cloud/pdp-data-model.md b/docs/design/05-prisma-cloud/pdp-data-model.md index f518e5aed..d83c07620 100644 --- a/docs/design/05-prisma-cloud/pdp-data-model.md +++ b/docs/design/05-prisma-cloud/pdp-data-model.md @@ -83,13 +83,18 @@ Consequences Prisma Composer designs around: restart-on-config-change and no live re-resolution; a late-written variable never reaches an existing version. Propagating a changed value (e.g. a producer's new URL) into a consumer therefore means creating a new consumer - version — which the Alchemy graph does via a property diff (see - [alchemy-lowering.md](alchemy-lowering.md)). -3. **`DATABASE_URL` is not a separate mechanism.** It is a module-written + version — which Composer does: a deploy whose environment changed replaces + the deployment, so the change reaches the running service (see the + change-propagation note in + [alchemy-lowering.md](alchemy-lowering.md#the-lowering-graphs)). +3. **`DATABASE_URL` is not a separate mechanism.** It is a platform-written template flowing through the same materialization as user variables — a - convenience for hand-provisioned single services. Prisma Composer - forbids its use and poisons it at project provision (see - [alchemy-lowering.md](alchemy-lowering.md#database_url-is-forbidden--and-actively-poisoned)); + convenience for hand-provisioned single services. The platform owns it + (system-managed), and Prisma Composer refuses to bind or manage the name — + though on a fresh Project it claims the keys once, create-only, with an + inert placeholder so the platform's self-heal cannot seed live credentials + (see + [alchemy-lowering.md](alchemy-lowering.md#database_url-is-forbidden--and-left-to-the-platform)); every database URL a service consumes is an explicit, service-named variable. 4. **Branch + class is the platform's environments model** (production templates vs preview templates + per-branch overrides) — the substrate diff --git a/docs/design/10-domains/config-params.md b/docs/design/10-domains/config-params.md index 465635811..bb7426bda 100644 --- a/docs/design/10-domains/config-params.md +++ b/docs/design/10-domains/config-params.md @@ -79,7 +79,7 @@ provision(web, { params: { appOrigin: envParam('APP_ORIGIN') } }); Both bindings suit an origin the operator genuinely knows — a custom domain they provisioned. A service's own *platform-assigned* origin is not a param at -all: the target resolves it and app code reads `ComputeService.origin()` +all: the target resolves it and app code reads the service's `origin()` (ADR-0039). Resolution order per param: binding, else `default`, else absent (only legal diff --git a/docs/design/10-domains/core-model.md b/docs/design/10-domains/core-model.md index cedfbf510..b4d711d80 100644 --- a/docs/design/10-domains/core-model.md +++ b/docs/design/10-domains/core-model.md @@ -141,7 +141,7 @@ SPI and never see the graph, never sequence anything, never call another tool. | Path | Where it executes | Core does (the actor) | Pack / adapter tools used | | --- | --- | --- | --- | -| **provision** | deploy machine, via Alchemy | provision the application once (Project + poison vars), then walk the DAG realizing each service's host | `ExtensionDescriptor.application.provision`, then `ServiceLowering.provision` → identity (App) | +| **provision** | deploy machine, via Alchemy | provision the application once (the Project reference), then walk the DAG realizing each service's host | `ExtensionDescriptor.application.provision`, then `ServiceLowering.provision` → identity (App) | | **deploy** | deploy machine, via Alchemy | build each service's typed `Config`, have the pack encode it *first*, assemble via the build adapter, then ship the build | `ServiceLowering.serialize`, the **build adapter's `assemble`**, then `package` + `deploy` | | **run** | inside the bundle, in the VM | provide `hydrate` (typed `Config` → each dependency's binding); the node's `run` resolves + stashes config and boots the entry, the node's `load` hydrates on demand | the node's `run` / `load`, each connection's `hydrate` | @@ -150,7 +150,7 @@ running": provision creates identity-bearing infrastructure that changes only wh the topology changes; deploy ships a specific build (keyed by artifact hash) and changes on every push. The seam between them is the only window where connection config can land — an environment variable needs the consumer's projectId (exists -after provision) and is read at version start, never after (PRO-211: so it must +after provision) and is read at deployment create, never after (PRO-211: so it must exist before deploy). Core sequences `provision → serialize → package → deploy` for every service, which **eliminates the fresh-deploy config race by construction**, for every target pack ever written. One producer-side asymmetry: a @@ -504,8 +504,7 @@ type NodeDescriptor = | { readonly kind: "build"; assemble(input: AssembleInput): Promise } // The application's shared infrastructure: on Prisma Cloud, the one Project -// (the config namespace and lifecycle boundary) plus the poison DATABASE_URL -// variables. Its product (e.g. { projectId }) reaches every later SPI call of +// (the config namespace and lifecycle boundary). Its product (e.g. { projectId }) reaches every later SPI call of // the SAME extension via LowerContext.application. Core declares it `unknown` // and never reads it — the extension narrows with its own guard (ADR-0033). interface ApplicationDescriptor { @@ -549,8 +548,8 @@ interface ServiceLowering

{ package(ctx: LowerContext, input: PackageInput): Effect.Effect // deploy: ship the packaged artifact into the provisioned thing and run it - // (version → upload → start → promote). Consumes `serialized`'s env records - // via the Deployment's environment prop (the edge). Returns the node's + // (create → upload → start → promote). Builds the deployment's props out of + // `serialized`'s env records, which is the ordering edge. Returns the node's // outputs — what dependent nodes' connection params resolve against — plus // the entities it became on the deployment target, for the deploy report. deploy(ctx: LowerContext, provisioned: P, artifact: Artifact, @@ -611,7 +610,7 @@ type Outputs = Readonly> // `url` is present only when the descriptor declares the address publicly // reachable — a connection string is never a `url` (no core-level rule is safe: // `url` on compute is an endpoint, on postgres it would be a DSN). A descriptor -// constructing one holds `deployment.deployedUrl` — an Output, not a T, +// constructing one holds `deployment.appEndpointDomain` — an Output, not a T, // because the stack effect runs before Alchemy applies — so construction sites // traffic in `Input` (LoweredResult.entities above); apply // resolves them before any reader sees them. @@ -693,8 +692,8 @@ In the mixed case the hand-written stack supplies providers itself, yields a the nodes it composes. **Core's deploy-path sequencing** — the control flow no extension can misorder. -First, each extension's `application.provision` runs once (the Project reference, -with the poison `DATABASE_URL` variables). Then walk the graph in topological +First, each extension's `application.provision` runs once (the Project +reference). Then walk the graph in topological order (the module body's provision order; the dependency DAG Load validated). Each module-provisioned **resource** lowers exactly once via its extension's `nodes[type]` `{ kind: "resource" }` entry (e.g. one Database + Connection — its @@ -723,18 +722,20 @@ resource descriptions — Alchemy executes them in dependency order and runs unordered resources concurrently; declaration order is never consulted. So core realizes the sequence as **dependency edges**: most arise naturally from value flow (the env var consumes the project id and the producer's URL), and the one that -doesn't — deploy-after-serialize — exists because the `Deployment` resource -declares the environment records it boots with as a prop, which is PDP's own -dataflow restored (the version-create call literally contains the materialized env -map). See the lowering graphs in +doesn't — deploy-after-serialize — exists because the service descriptor builds +the `Deployment`'s props out of the environment records it boots with, which is +PDP's own dataflow restored (the deployment-create call literally contains the +materialized env map). See the lowering graphs in [`../05-prisma-cloud/alchemy-lowering.md`](../05-prisma-cloud/alchemy-lowering.md). This is what makes the fresh-deploy config race (PRO-211) structurally impossible -on every target — the edge's **ordering** job. Its second job, **propagating** a -wire whose value genuinely changes, is not yet wired: the env-var resource exposes -only `{ id, key }`, so a changed value doesn't diff the consumer's `Deployment`. -The fix is provenance-based (the consumer depends on the *source node's* version, -never on the value or a hash of it) and is a deferred follow-up — narrow in -practice, since promoted service endpoints are stable across producer redeploys. +on every target — the edge's **ordering** job. Its second job, **propagating** +a wire whose value genuinely changes, is wired by the deploy hook: a deploy +whose environment differs from the running deployment's replaces the +deployment, so changed values reach the running service. Secret *values* are +never hashed or persisted for this — the mechanism keys off the non-secret +material Composer's rows carry (ADR-0042 pointers) and platform metadata. The +long-term carrier is the deployment resource itself (inputs it must be +recreated for), tracked as an upstream follow-up. Secrets are platform-sourced and rotate through the platform, not this edge (see the [config/secret glossary](../03-domain-model/glossary.md#configuration--config-and-secrets)). @@ -954,8 +955,8 @@ export const compute = (def: { // writer drifts. Keys are UPPER_SNAKE(address ▸ owner ▸ name): the address prefix // makes them unique per service within the shared project namespace (auth's db.url // ↔ AUTH_DB_URL); an empty address yields the address-free stash keys run() writes -// and load() reads (DB_URL). The platform's DATABASE_URL is never among them — it -// is forbidden and poisoned at project provision (see alchemy-lowering.md). +// and load() reads (DB_URL). The platform's DATABASE_URL is never among them — the +// name is rejected at authoring time (see alchemy-lowering.md). export const configKey = (address: string, d: ConfigDeclaration): string => /* UPPER_SNAKE(address ▸ owner ▸ name) */ // Boot readers/writers — process.env is touched ONLY here in the pack. @@ -1004,18 +1005,12 @@ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor preflight: (input) => runPreflight(input), // Runs ONCE per lowering, before any node — REFERENCES the CLI-ensured Project - // (it no longer creates one) and writes the poison DATABASE_URL variables so - // nothing can rely on the platform default. Its product reaches this - // extension's own nodes via ctx.application. + // (it neither creates one nor provisions anything of its own). Its product + // reaches this extension's own nodes via ctx.application. application: { provision: () => - Effect.gen(function* () { + Effect.sync(() => { const projectId = o.projectId // set by the CLI in the deploy env; required - for (const key of ["DATABASE_URL", "DATABASE_URL_POOLED"]) { - yield* Prisma.EnvironmentVariable(`${key}-poison`, { - projectId, key, value: "-", class: "production", // "-": the API rejects "" (verified at the deploy proof) - }) - } return { projectId } satisfies CloudApplication }), }, @@ -1038,10 +1033,10 @@ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor postgres: Object.assign( ({ id, application }) => Effect.gen(function* () { - const db = yield* Prisma.Database(`${id}-db`, { projectId: projectIdOf(application), name: id }) - const conn = yield* Prisma.Connection(`${id}-conn`, { databaseId: db.id, name: id }) - const warm = yield* Prisma.PgWarm(`${id}-warm`, { url: conn.connectionString }) // FT-5226 cold-start - return { outputs: { url: warm.url }, entities: [{ kind: "postgres-database", id: db.id }] } + const db = yield* Prisma.Database(`${id}-db`, { project: projectIdOf(application), name: id, region }) + const conn = yield* Prisma.Connection(`${id}-conn`, { database: db, name: id }) + const warm = yield* Prisma.PgWarm(`${id}-warm`, { url: conn.directConnectionString }) // FT-5226 cold-start + return { outputs: { url: warm.url }, entities: [{ kind: "postgres-database", id: db.databaseId }] } }), { kind: "resource" as const }, ), @@ -1058,10 +1053,10 @@ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor // real string (from the CLI env, not a resource attribute). provision: ({ id, application }) => Effect.gen(function* () { - const svc = yield* Prisma.ComputeService(`${id}-svc`, { - projectId: projectIdOf(application), name: id, region: o.region ?? "us-east-1", + const svc = yield* Prisma.App(`${id}-svc`, { + project: projectIdOf(application), displayName: id, regionId: o.region ?? "us-east-1", }) - return { serviceId: svc.id, projectId: projectIdOf(application) } // : ComputeProvisioned + return { serviceId: svc.appId, projectId: projectIdOf(application) } // : ComputeProvisioned }), // Encode the typed Config into the runtime environment — one env var per @@ -1076,8 +1071,8 @@ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor const value = d.owner === "service" ? config.service[d.name] : config.inputs[d.owner.input]?.[d.name] if (value === undefined) continue records.push(yield* Prisma.EnvironmentVariable(`${configKey(address, d)}-var`, { - projectId: provisioned.projectId, key: configKey(address, d), - value: encode(d.owner, value), class: "production", + project: provisioned.projectId, key: configKey(address, d), + value: Redacted.make(encode(d.owner, value)), class: "production", })) } const port = typeof config.service.port === "number" ? config.service.port : 3000 @@ -1091,21 +1086,23 @@ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor package: ({ id }, { assembled, address }) => Effect.try(() => Prisma.packageComputeArtifact({ id, bundleDir: assembled.dir, appEntry: assembled.entry, address })), - // version → upload → start → promote. The environment prop references - // serialize's records, so the version depends on them (the edge that kills - // PRO-211). Returns a LoweredResult: `url` IS published here — a Compute - // service's deployed URL is a public endpoint, and this descriptor is the - // only party that knows it. Both fields are still Output refs until apply. + // create → upload → start → promote. The app id is read through + // serialize's env-var records, so the deployment depends on them — the + // ordering edge that kills PRO-211. Returns a LoweredResult: `url` IS + // published here — a Compute service's deployed URL is a public endpoint, + // and this descriptor is the only party that knows it. Both fields are + // still Output refs until apply. deploy: ({ id }, provisioned, artifact, serialized) => Effect.gen(function* () { const deployment = yield* Prisma.Deployment(`${id}-deploy`, { - computeServiceId: provisioned.serviceId, // Input accepts the Output ref — no cast - artifactPath: artifact.path, artifactHash: artifact.sha256, - environment: serialized.environment, port: serialized.port, + app: appAfterEnvironment(provisioned.serviceId, serialized.environment), + artifactPath: artifact.path, + artifactContentType: "application/gzip", + portMapping: { http: serialized.port }, start: true, promote: true, }) return { - outputs: { url: deployment.deployedUrl, projectId: provisioned.projectId }, - entities: [{ kind: "compute-service", id: provisioned.serviceId, url: deployment.deployedUrl }], + outputs: { url: deployment.appEndpointDomain, projectId: provisioned.projectId }, + entities: [{ kind: "compute-service", id: provisioned.serviceId, url: deployment.appEndpointDomain }], } }), }, diff --git a/docs/design/10-domains/local-dev.md b/docs/design/10-domains/local-dev.md index e6cbad653..a84adab22 100644 --- a/docs/design/10-domains/local-dev.md +++ b/docs/design/10-domains/local-dev.md @@ -132,7 +132,8 @@ into a deployment at version-create; locally, the `Deployment` provider performs the same join from the `EnvironmentVariable` records the lowering emitted — against props defined in this repo, once, not an emulation of a foreign API. One pinned deviation: the local join is scoped to the -service's own rows (plus the unprefixed poison rows), because the platform +service's own rows (plus any row outside the `COMPOSER_` namespace, which is +a platform-owned name by definition), because the platform diffs a deployment only on its own referenced rows while an app-wide local snapshot diffs on bytes — which restart-amplified dependents on every first-after-cold converge. No sanctioned reader consumes sibling rows, so @@ -167,7 +168,7 @@ semantics): | `Project` | a local identity record; no platform | | `Database` | a database on the local Postgres server (ORM `prisma dev`) | | `Connection` | the local connection URL | -| `ComputeService` | registers the service with the Compute emulator, which allocates its stable port; `endpointDomain = http://localhost:` — which makes origin (ADR-0039) work unchanged | +| `App` | registers the service with the Compute emulator, which allocates its stable port; `appEndpointDomain = http://localhost:` — which makes origin (ADR-0039) work unchanged | | `Deployment` | unpacks the artifact once per hash, materializes the env, and puts the deployment at the Compute emulator, which (re)starts the child | | `EnvironmentVariable` | a key→value row in the dev state store | | `Bucket` | a directory under `.prisma-composer/dev/buckets//`, served by the bucket emulator | diff --git a/docs/design/90-decisions/ADR-0048-prisma-cloud-resources-come-from-the-upstream-alchemy-provider.md b/docs/design/90-decisions/ADR-0048-prisma-cloud-resources-come-from-the-upstream-alchemy-provider.md new file mode 100644 index 000000000..e8bab6596 --- /dev/null +++ b/docs/design/90-decisions/ADR-0048-prisma-cloud-resources-come-from-the-upstream-alchemy-provider.md @@ -0,0 +1,87 @@ +# ADR-0048: Prisma Cloud resources come from the upstream Alchemy provider + +## Decision + +Composer does not implement Alchemy resources for Prisma Cloud's Management API. It composes the official `alchemy/Prisma` provider's resources and providers, and defines its own resources only where the upstream provider has no support yet (buckets, whose routes upstream deferred) or where no Management API exists behind them (local mechanisms like migration steps). + +The live wiring is composition, not implementation: + +```ts +// lowering/src/providers.ts — deploys run on upstream's providers +Layer.mergeAll( + Prisma.ProjectProvider(), + Prisma.DatabaseProvider(), + Prisma.ConnectionProvider(), + Prisma.AppProvider(), + Prisma.DeploymentProvider(), + Prisma.EnvironmentVariableProvider(), +), +// + Composer's own resources: Bucket, BucketKey, ServiceKey, +// GeneratedParam, S3Credentials, PnMigration, PgWarm +``` + +and a lowered compute service is upstream resources wired by Composer's descriptors: + +```ts +const app = Prisma.App(`${id}-svc`, { project, regionId, branchId }); +const vars = records.map((r) => Prisma.EnvironmentVariable(...)); +const deployment = Prisma.Deployment(`${id}-deploy`, { + app: dependsOnEnvironment(app, vars), // see "The ordering edge" below + artifactPath, // Composer's own tar.gz — never built by Alchemy + start: true, + promote: true, +}); +``` + +Local dev keeps the shape ADR-0041 defined: the local target binds the same upstream resource *classes* to Composer's emulator providers at the `LowerOptions.providers` seam. Upstream's built-in dev mode (its providers register live and local variants and the engine picks by run mode) is never mounted; Composer swaps the whole layer. + +Why hand Composer's most platform-critical surface to an external package: the provider tracks the Management API at its source, and its deploy lifecycle is stronger than what it replaced — failed deployments are cleaned up rather than leaked, terminal statuses fail fast instead of polling to timeout, and the stable endpoint is read by observing the App after promote rather than trusting the promote response. + +## The compute family binds the low-level trio, not `Prisma.Compute` + +Upstream offers two shapes for compute: a composite `Prisma.Compute` that owns app, environment, and deployment in one resource, and the low-level `App` / `Deployment` / `EnvironmentVariable`. Composer uses the low-level trio. The deciding constraint is a cycle: + +Every service's environment includes `COMPOSER_

_ORIGIN` — the service's *own* platform-assigned endpoint domain (ADR-0039). A composite resource that owns both the environment rows and the app makes that row an input of the very resource that produces the domain: a self-edge the planner rejects. Split, the wiring is legal: the App exists first and hands out `appEndpointDomain`, environment rows are written from it, the Deployment comes last. + +Two supporting reasons: + +- `Compute` owns environment rows through an internal ownership map and refuses in-scope rows absent from it; Composer's per-key rows have no honest mapping into that map. +- `Compute` carries build, framework detection, and bundling. Its `artifactPath` prop bypasses them, but a bypass is a prop value; `Prisma.Deployment` has **no build path at all**, which is ADR-0005's guarantee in structural form. + +What the trio costs: `Compute`'s preview/stable health checks and automatic rollback are not inherited, and deployment reuse must be handled by Composer (next two sections). + +## The ordering edge rides the `app` prop + +Environment rows must be written before the deployment is created, because the platform snapshots the branch environment into a deployment at create. Upstream's `Deployment` has no prop for that dependency, so Composer builds the edge into the `app` prop: an Output over the app id *and* every environment row's id, resolving to the app id (`lowering/src/compute/deployment-edge.ts`). Alchemy derives its graph from the resource references inside prop values, so every row is scheduled first. + +The edge must not ride `artifactPath`. Upstream's diff reads `{portMapping, skipCodeUpload, artifactPath, artifactContentType}` as one block and offers no opinion when any member is unresolved — and a brand-new environment row is always unresolved at plan time. The consequence of getting this wrong is severe and quiet: the artifact comparison never runs, the engine falls back to a plain update, and the reconcile keeps the running deployment while recording the new artifact's fingerprint as deployed — a code change silently never ships, and every later deploy agrees it already did. The `app` prop sits outside that block and tolerates being unresolved. `compute/__tests__/deployment-edge.test.ts` drives upstream's real diff and real Output machinery and fails if the edge ever moves back. + +## A deployment is replaced when its environment changes + +The platform bakes environment values into a deployment at create, and upstream reuses a deployment whose artifact is unchanged — so a value-only change (a rotated secret) would update the platform's variable row and never reach the running app. Composer closes this with a deploy fingerprint (`compute/deploy-fingerprint.ts`): the artifact hard-link directory is named from a hash of the service's environment material, so the resolved `artifactPath` upstream compares moves exactly when the environment does — unchanged service, identical path, deployment reused; changed environment or artifact, new path, replace. + +The fingerprint hashes only non-secret material. Composer's environment rows carry none (ADR-0042: secrets are pointers to platform variables, not values); secret-bearing rows contribute their wiring identity, not a value. Out-of-band rotation of a pointed platform variable is detected through its `updatedAt` metadata, read at preflight and carried to the Alchemy process over the framework's preflight-transport channel (a timestamp, never a value). One accepted narrowing, recorded in the module: a value re-issued under a stable resource identity (a connection rotated in place, a re-minted service key) does not move the fingerprint; the deployment ships it on the next change that does. Upstream's `Deployment.redeployOn` closes that properly once released — alchemy resolves and diffs those inputs inside its own encrypted state — and the fingerprint then moves onto it at a marked seam. + +## Consequences + +- **Namespaces.** The `Prisma.*` resource type-id namespace and the `'Prisma'` collection tag belong to upstream. Composer's collection tag is `'PrismaComposer'` and its own resources are `PrismaComposer.*`. Rows persisted under retired Composer type-ids are rewritten on read by the hosted state store (`state/legacy-resources.ts`): ids, attribute shapes, and the retirement of the legacy claim rows below. The module is the durable compatibility boundary for state written by earlier Composer versions. Scope: "legacy" here means rows already INSIDE the platform state API (ADR-0045), written under retired type-ids or shapes. The older generation — the retired SQL `prisma-composer-state` stores — stays under ADR-0045's own rule: never read, cleaned up by destroy or Branch deletion. The two rules govern different stores and do not overlap. +- **Branch-stage databases carry generated physical names.** Upstream refuses an explicit name combined with branch attachment at create — and it is right to: the Management API creates the database and attaches the branch in separate transactions with no idempotency key, so a lost response is indistinguishable from a foreign database. Attaching after create does not survive either: upstream's reconcile detaches a branch its props don't declare. So branch stages attach at create and take the generated name; production keeps explicit names. +- **The platform's `DATABASE_URL` is left alone.** Prisma Cloud seeds `DATABASE_URL`/`DATABASE_URL_POOLED` on every app and marks them system-managed; upstream refuses to manage system-managed variables. Composer never overwrites, updates, deletes, or tracks them. It does CREATE them, once, on a fresh Project: `application.provision` claims both names with the placeholder `"-"` via create-only calls (`database-url-claim.ts`), because the platform otherwise self-heals a missing `DATABASE_URL` on the first compute deploy with a live credential to one of the app's own databases. An existing row — platform-seeded or the operator's — makes the claim a 409 no-op. The guarantee that apps read configuration through the framework is held at the authoring end too: `param.ts`/`secret.ts` reject the reserved names, and every Composer-written row is `COMPOSER_`-prefixed. An app that reads `process.env.DATABASE_URL` directly sees whatever the platform put there. +- **Auth never touches Alchemy's profile store.** Alchemy's own credential flow prompts on a TTY and hard-fails non-interactive; Composer runs alchemy as a subprocess with piped stdio. Composer provides `PrismaEnvironment` directly from `PRISMA_SERVICE_TOKEN`, with one base-URL resolver shared between upstream's providers and Composer's own SDK client so both always target the same host. +- **A known weakness is inherited:** upstream retries a conflicting App delete for only a few seconds, where Composer's own resource waited out deployment drain for minutes. Slow drains can fail a destroy and need a re-run. + +## Alternatives considered + +- **Keep Composer's own resources.** Six Management API wrappers whose drift Composer pays for alone, with a weaker deploy lifecycle than upstream's. +- **The composite `Prisma.Compute`.** Rejected for the self-edge, the environment-ownership map, and ADR-0005 (above). +- **Vendor the provider's source into Composer.** Mechanically possible; inherits its dependencies and permanent drift. Kept only as a fallback if a future alchemy upgrade proves unshippable. +- **Adopt upstream's built-in dev mode instead of the local target.** Would replace a whole-layer seam that already works with per-provider substitution, and tie local-dev iteration to an external release cadence. +- **Replace the deployment on every deploy** (the pre-adoption behavior). Ships every change by brute force but gives up upstream's reuse entirely; superseded by the fingerprint, which detects changes from non-secret material only. +- **Hash environment values into the fingerprint.** A hash of a secret in plaintext state is an offline-guessing target; rejected. The fingerprint hashes only material that is non-secret by construction. + +## References + +- ADR-0005 (the framework never builds or bundles user code), ADR-0034 (hosted deploy state), ADR-0039 (a service's origin is a target-resolved property), ADR-0041 (local dev runs the deploy pipeline against local providers). +- The provider: the `alchemy/Prisma` module of the `alchemy` package, 2.0.0-beta.67 or later. +- `docs/design/05-prisma-cloud/alchemy-lowering.md` — the current resource-by-resource lowering map. +- `docs/guides/deploying.md` — operator-facing upgrade notes (one-time deployment reship, branch-database renames, leftover placeholder rows). diff --git a/docs/design/90-decisions/README.md b/docs/design/90-decisions/README.md index 3e108f44d..46616d1c3 100644 --- a/docs/design/90-decisions/README.md +++ b/docs/design/90-decisions/README.md @@ -60,7 +60,7 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [ADR-0036](ADR-0036-the-rpc-kind-is-named-service-rpc.md) — The RPC kind is named **service RPC**: subpath `@prisma/composer/service-rpc`, unchanged call-site names (`rpc()`, `contract()`, `serve()`), kind brand stays `'rpc'`. Scope recorded in connection-contracts.md: edges internal to the application topology, agent-generatable by design — not an application API layer, not general distributed-systems infrastructure. - [ADR-0037](ADR-0037-service-rpc-calls-carry-an-idempotency-key.md) — The generated service RPC client carries an `Idempotency-Key` on every call — one per logical call, reused across a bounded retry — and the provider deduplicates on it: one call per key, replaying completed 2xx/4xx answers (never 5xx) from a bounded in-process store. A keyless request (a hand-rolled or older caller) is served once without deduplication rather than rejected. Retrying is permanent protocol behavior, not a platform workaround, and there is no per-method opt-in — a flag would be an unverifiable claim. Handlers may read the key via an optional third argument for their own durable exactly-once. - [ADR-0038](ADR-0038-containers-are-an-extension-descriptor.md) — Container lifecycle (ensure/locate/remove) is an optional `container` descriptor on `ExtensionDescriptor`, the same pattern as `preflight`/`teardown`; the resolved instance is opaque to core and crosses the CLI parent→alchemy child boundary as one framework-named environment variable per extension, via the extension's own `serialize()`/`deserialize()`. `StateDescriptor` names its owning extension so core can hand it that extension's resolved container. Deletes the `crossDomainExceptions` entry that let the CLI import `@internal/lowering` directly — `0-framework` imports nothing again. -- [ADR-0039](ADR-0039-a-compute-services-own-origin-is-a-target-resolved-property.md) — A compute service's own platform-assigned origin is a target-resolved property, read as `ComputeService.origin()` — never a declared param, never operator config, never in `config()`. It rides ADR-0031's reserved provider-param channel as the first *service-derived* entry (`valueForService(provisioned, address)`, written for every compute service, exposing or not), sourced from the provisioned service's own `endpointDomain` — made trustworthy pre-promote by the upstream PRO-200 fix. `envParam(…)` remains correct for operator-known origins (custom domains); narrows ADR-0032's `appOrigin` example accordingly. +- [ADR-0039](ADR-0039-a-compute-services-own-origin-is-a-target-resolved-property.md) — A compute service's own platform-assigned origin is a target-resolved property, read as the service's `origin()` — never a declared param, never operator config, never in `config()`. It rides ADR-0031's reserved provider-param channel as the first *service-derived* entry (`valueForService(provisioned, address)`, written for every compute service, exposing or not), sourced from the provisioned App's `appEndpointDomain` (ADR-0048) — made trustworthy pre-promote by the upstream PRO-200 fix. `envParam(…)` remains correct for operator-known origins (custom domains); narrows ADR-0032's `appOrigin` example accordingly. - [ADR-0040](ADR-0040-the-pn-binding-carries-the-url-and-a-lazy-client.md) — `pnPostgres(contract)`'s dependency binding is `{ url, client }`: the raw connection string plus the typed client, constructed lazily and memoized on first `client` access — `hydrate` builds nothing. The contract remains the compatibility interface (hash check and deploy-time migration unchanged, ADR-0022); the binding becomes a strict superset of plain `postgres()`'s `{ url }`, so an app that owns its database client still gets framework-run migrations. Contract validation cost and failure move from `load()` (where one bad input poisoned every input, unattributed) to the first `client` access. Cross-kind satisfaction (`'prisma-next'` satisfying `'postgres'`) rejected in its favor. - [ADR-0041](ADR-0041-local-dev-runs-the-deploy-pipeline-against-local-providers.md) — `prisma-composer dev` runs the **same deploy pipeline** (Load → assemble → lower → Alchemy converge) against local implementations of the same Alchemy resource types, declared on an optional `localTarget` field of `ExtensionDescriptor` (a lazy thunk resolving a `LocalTargetDescriptor`; subpaths `@prisma/composer/local-target` and `@prisma/composer-prisma-cloud/local-target` — "dev" names only the user-facing command/prefix/state dir) (providers, container, preflight, emulators, attach, teardown — **no** `nodes`/`provisions`, so the lowering cannot diverge; no `state` either — dev uses Alchemy's own `localState()` through `LowerOptions.state`). The target runs **emulators per node kind**: Compute and buckets are machine-global, multi-tenant daemons (the Compute emulator owns the service child processes — deployment PUTs, crash supervision, logs; buckets serve the S3 wire over plain files on disk), while Postgres runs one detached ORM `prisma dev` instance per `Database` resource under the ORM CLI's own manager. Providers provision instances by communicating with the emulators during converge, and the dev command is a view through `attach`; `ServiceKey`/`S3Credentials`/`PgWarm`/`PnMigration` are shared verbatim. Credential-free by requirement. Rejects a local Management API (reimplements another team's server-side semantics, drifts silently) and per-kind dev descriptors (an open-set parallel seam). - [ADR-0042](ADR-0042-service-input-is-one-standard-schema.md) — A compute service declares its entire incoming configuration — config and secrets together — as one Standard Schema (`input`), read back through one typed accessor; `params`/`secrets` and `config()`/`secrets()` are replaced. The framework never introspects the schema (validate-only, per the spec): the operator's binding is the traversable structure (sourcing: literals, `envParam`, `envSecret`), the schema is the black-box judge of legality (invoked at deploy over the resolved binding with secrets as opaque `SecretString` boxes, and again at boot), and secretness is a leaf *type* enforced by validation in both directions. The wire format is one self-describing JSON document row per service with `$secret` pointers to platform variables; an env-bound key whose variable is unset resolves to key-omitted and the schema arbitrates absence — subsuming optional secrets and conditional config (`stripeId` only when `stripeEnabled`) without a framework DSL. @@ -69,3 +69,4 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [ADR-0045](ADR-0045-deploy-state-lives-behind-the-platform-state-api.md) — Deploy state lives behind the platform state API (the Management API implements Alchemy's stock `HttpStateApi` wire contract per Branch; composer's state layer is Alchemy's stock HTTP client), and deploys hold a server-side per-`(stack, stage)` lease (TTL 60s, heartbeated, released on exit; contention fails fast naming the holder; state operations without a live lease fail 409). Supersedes ADR-0010 (lock → lease) and the storage half of ADR-0034 (Branch scoping and lifetime stand; the visible per-stage database is gone); closes ADR-0012 as obsolete. No migration: legacy stages are refused until destroyed or deleted. - [ADR-0046](ADR-0046-the-orm-facade-is-a-peer-dependency.md) — `@prisma/composer-prisma-cloud` takes the Prisma Next postgres facade (`@prisma/orm-postgres`) as a **peer** dependency at one exact version, not a regular dependency: Composer registers an extension pack against the application's copy of the target, and two copies of a shell in one tree means two codec/operation registries and two class identities — a value from one is rejected by the other, silently. As a peer, that combination fails at install instead. Every `@prisma/orm-*` spec in the workspace is one exact version and all name the same one (`scripts/lint-orm-pins.mjs`). `@prisma/orm-toolchain`, which Composer drives rather than extends, stays a regular dependency. Replaces ADR-0022's consequence bullet on how the ORM is installed. - [ADR-0047](ADR-0047-compute-assembly-preserves-safe-runtime-topology.md) — Compute assembly traces runtime files from the author-declared Node entry without rebundling app code, preserves only symlinks whose resolved targets remain inside the staged bundle, and installs narrowly runtime-gated bootstrap compatibility when a framework needs Node semantics. Each local mechanism is removed once the upstream Alchemy Compute provider owns the equivalent guarantee. Supersedes ADR-0005's blanket ban on symlinks and its assumption that directory output is already self-contained. +- [ADR-0048](ADR-0048-prisma-cloud-resources-come-from-the-upstream-alchemy-provider.md) — The six Management-API resource families (project, database, connection, app, deployment, environment variable) are the upstream `alchemy/Prisma` provider's classes, registered in Composer's `PrismaComposer` collection; Composer defines resources only where the upstream provider has no support yet (buckets) or no Management API exists behind them. Compute binds the low-level App/Deployment/EnvironmentVariable trio (the `COMPOSER_*_ORIGIN` self-edge and ADR-0005 rule out composite `Compute`); the env→deployment ordering edge rides the deployment's `app` prop, and a deployment is replaced exactly when its artifact or environment fingerprint changes — unchanged services are reused. Legacy rows in the platform state API (retired type-ids and shapes) migrate on read — the older retired SQL state stores stay destroy-only under ADR-0045 — branch-stage databases take generated physical names, and the platform's seeded `DATABASE_URL` is left system-managed. diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index 250d23635..b236c3305 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -268,6 +268,27 @@ Legacy leftovers are inert and safe to remove whenever convenient — nothing re - Branch-hosted generation: destroying on the old version already removed the environment's `prisma-composer-state` database. If you skipped that and deleted Branches by hand instead, each Branch took its database with it — but production's, on the default Branch, survives: delete it in the Console. - Workspace-hosted generation: delete the workspace-level `prisma-composer-state` project from the Console. +## Upgrading to the upstream Prisma resources + +Framework versions that manage databases, apps, deployments, and environment variables through upstream alchemy's Prisma provider adopt each environment's existing resources in place — deploy state already in the platform state API is migrated automatically on read (rows written under retired type-ids), and production environments redeploy with no changes to their databases or connections. Stages still on the older SQL state store are not migrated — destroy and redeploy them, as the section above describes. + +**A service's deployment is replaced exactly when its artifact or environment changed, and reused otherwise.** The platform freezes a deployment's environment when the deployment is created, so a changed value only takes effect through a new one — the framework fingerprints each service's environment material into the artifact path, so a changed variable (or an out-of-band rotation of a platform variable a row points at) ships a new deployment, and an unchanged service redeploys nothing. A replacement uploads the artifact, starts it, moves the stable endpoint over, and removes the old deployment; your service's URL does not change. + +One exception: secret VALUES never enter the fingerprint (by design — no secret-derived material may land in a path or state row). A secret re-issued under the same resource identity — a connection rotated in place, a re-minted service key — does not move the fingerprint, so the running deployment keeps the old value until the next deploy whose artifact or environment changed. Rotating a platform variable a row points at IS detected (via its `updatedAt` metadata); after an in-place re-issue that must ship immediately, deploy any code or environment change to force the replacement. + +**`DATABASE_URL` and `DATABASE_URL_POOLED` hold the placeholder `"-"`, and the framework never modifies or deletes them.** At provision the framework claims both names (production and preview class, project level) with the placeholder, using create-only writes: if the variable already exists — yours, or one Prisma Cloud seeded — the claim does nothing. The placeholder is deliberate. Without it, Prisma Cloud fills a missing `DATABASE_URL` in on the first deploy with a live credential to one of your app's own databases, and anything reading `process.env.DATABASE_URL` directly would quietly work against a database it was never wired to. With it, a direct read fails loudly. Nothing you declare can carry those names — `envSecret`/`envParam` reject them — and every database URL your services use comes from the connection they declare. + +On the first deploy after the upgrade, the framework also **stops tracking** the two variables in deploy state. The deploy log reports them as `retained`: the entry is dropped from state and no call is made to Prisma Cloud. + +Deleting the variables by hand is not useful: the next deploy's claim (or the platform's own template filler) recreates them. If you genuinely want a value there — for a tool outside the framework that insists on `DATABASE_URL` — set your own value in the Console; both the framework's claim and the platform's filler are create-only and will leave your value alone. + +**Stage (`--stage`) environments see two one-time effects on their first deploy after the upgrade**, because a branch-attached database can no longer carry an explicit display name at create: + +- Each existing stage database is **renamed** to a generated physical name (`--db--`). The database itself, its data, and its ID are untouched — only the display name in the Console changes. +- The database's **default connection credentials are rotated** during that same reconcile. The framework's own named connection — the one your services actually use — is NOT rotated and keeps working. Only credentials minted outside the framework from the database's *default* connection (for example, copied out of the Console) stop working and must be re-issued. + +Local dev state is not migrated: if `prisma-composer dev` fails at plan time with `No provider is registered for resource type 'PrismaComposer.…'`, run it once with `--fresh` to clear the stale local state. + ## Driving deploys from code Everything the CLI does is also callable in-process, from diff --git a/docs/guides/running-locally.md b/docs/guides/running-locally.md index 651576aa0..d80e122e1 100644 --- a/docs/guides/running-locally.md +++ b/docs/guides/running-locally.md @@ -46,6 +46,15 @@ buckets, and their data stay up, so the next `prisma-composer dev` is a warm start — same ports, same data. `--fresh` is what wipes this app's local instances and data before starting. +`--fresh` is also the fix when a framework upgrade leaves stale rows in this +app's local dev state — the symptom is a plan-time error naming an +unregistered resource type (for example +`No provider is registered for resource type 'PrismaComposer.Database'`). +Local dev state is never migrated across framework versions. Note `--fresh` +wipes local *data* too — database contents, bucket objects, instance state — +not just the resource bookkeeping; the next start rebuilds empty resources. +Use it when the local data is disposable, which in a dev loop it usually is. + ## Logs ```sh diff --git a/gotchas.md b/gotchas.md index 3a9abb668..dccb6027a 100644 --- a/gotchas.md +++ b/gotchas.md @@ -203,7 +203,7 @@ process.on("unhandledRejection", (e) => console.error(e)); **Cause (corrected after reading pdp-control-plane source).** Env vars are `ConfigVariable` rows **materialized into a version at version-create time** (`materializeBranchEnvVars` resolves the branch's map and hands it to Foundry with the version) and frozen there — version start does not re-resolve, and updating a variable touches only the row, never an existing version. So the race is the env-var POST vs the consumer's **version-create** call, issued by one apply with no dependency edge between them. Consequences: (1) a version created before the row exists never sees it, regardless of VM recycles; (2) config changes take effect only via a new version — there is no restart-on-config-change. _The original filing (and this entry's first version) claimed boot-time application and recycle-healing; the source model contradicts that. Our one observed recycle-heal is treated as a platform bug, not behavior to rely on._ -**Workaround.** Give the consumer's version-create a real dependency on the env-var write in the deploy graph — the version genuinely consumes the environment (PDP's version-create call contains the materialized map). In Prisma Composer this is the Connection primitive's corrected lowering: `Deployment` declares its expected environment records as a prop, which both orders the write first and redeploys the consumer when a value changes. Manual stacks: create the variable, then ship a new version. +**Workaround.** Two halves, both handled in Prisma Composer. **Ordering:** give the consumer's version-create a real dependency on the env-var write in the deploy graph — the version genuinely consumes the environment (PDP's version-create call contains the materialized map). The consumer's deployment reads its app id through every variable's id, so the planner schedules each write ahead of version-create (`compute/deployment-edge.ts` in `@internal/lowering`; alchemy's `Prisma.Deployment` has no prop for the environment itself). **Propagation:** since a version's environment is frozen at create, a changed variable *value* reaches a running service only via a new version — so the deploy hook fingerprints each service's environment material (non-secret by construction; ADR-0042 rows carry pointers, not values) into the artifact path (`compute/deploy-fingerprint.ts`): a changed environment yields a new path and the deployment is replaced; an unchanged service is reused. Out-of-band platform-variable rotation is caught via `updatedAt` metadata read at preflight. One accepted narrowing: a value re-issued under a stable resource identity (a connection rotated in place, a re-minted service key) does not move the fingerprint, so the running deployment keeps the old value until the next change that does — force it with a code change or any environment edit (ADR-0048 records the exception). The long-term carrier is alchemy's `Prisma.Deployment.redeployOn` — see the change-propagation note in [`docs/design/05-prisma-cloud/alchemy-lowering.md`](docs/design/05-prisma-cloud/alchemy-lowering.md). Manual stacks: create the variable, then ship a new version. **Reproduction.** @@ -214,7 +214,7 @@ process.on("unhandledRejection", (e) => console.error(e)); **References.** - Upstream: [PRO-211](https://linear.app/prisma-company/issue/PRO-211/compute-fresh-deploys-race-env-var-creation-against-first-version) -- Race + edge analysis: [`packages/app-cloud/src/target.ts`](packages/app-cloud/src/target.ts) (the corrected ordering comment — the `deploy`/`serialize` edge) +- Race + edge analysis: [`packages/1-prisma-cloud/0-lowering/lowering/src/compute/deployment-edge.ts`](packages/1-prisma-cloud/0-lowering/lowering/src/compute/deployment-edge.ts) (why the edge rides `app`, and what it does not do) - Related: [`dogfood-report.md`](dogfood-report.md) --- @@ -300,7 +300,7 @@ process.on("unhandledRejection", (e) => console.error(e)); **References.** - Upstream: [PRO-215](https://linear.app/prisma-company/issue/PRO-215/management-api-project-scoped-compute-service-create-collides-with) -- Fix: [`packages/alchemy/src/compute/ComputeService.ts`](packages/alchemy/src/compute/ComputeService.ts), [`packages/alchemy/src/postgres/Database.ts`](packages/alchemy/src/postgres/Database.ts) +- Fix: both resources are alchemy's `Prisma.App` / `Prisma.Database`, which encode the two opposite mechanisms the Cause describes: `App` passes `branchId` in the create body (`node_modules/alchemy/src/Prisma/App.ts`), while `Database` creates project-scoped and attaches the Branch afterwards via `PATCH` (`.../Database.ts`, `branchNeedsSync`) --- diff --git a/packages/0-framework/1-core/core/src/__tests__/container-transport.test.ts b/packages/0-framework/1-core/core/src/__tests__/container-transport.test.ts index 70155f926..83257b3d9 100644 --- a/packages/0-framework/1-core/core/src/__tests__/container-transport.test.ts +++ b/packages/0-framework/1-core/core/src/__tests__/container-transport.test.ts @@ -23,7 +23,7 @@ describe('containerEnvVarName()', () => { ); }); - test('trims leading/trailing underscores from the mangled id', () => { + test('trims leading/trailing underscores from the env-var-safe id', () => { expect(containerEnvVarName('/leading-and-trailing/')).toBe( 'PRISMA_COMPOSER_CONTAINER_LEADING_AND_TRAILING', ); @@ -41,7 +41,7 @@ class FakeInstance implements ContainerInstance { } describe('containerEnv()', () => { - test('one env var per extension, keyed by its mangled id', () => { + test('one env var per extension, keyed by its env-var-safe id', () => { const instances = new Map([ ['ext-a', new FakeInstance({ appName: 'shop', stage: undefined }, 'serialized-a')], ['ext-b', new FakeInstance({ appName: 'shop', stage: 'staging' }, 'serialized-b')], @@ -58,7 +58,7 @@ describe('containerEnv()', () => { }); test('two extension ids mangling to the same var name throws, naming both', () => { - // '@a/b' and '@a.b' both mangle to 'PRISMA_COMPOSER_CONTAINER_A_B'. + // '@a/b' and '@a.b' both map to 'PRISMA_COMPOSER_CONTAINER_A_B'. const instances = new Map([ ['@a/b', new FakeInstance({ appName: 'shop', stage: undefined }, 'x')], ['@a.b', new FakeInstance({ appName: 'shop', stage: undefined }, 'y')], diff --git a/packages/0-framework/1-core/core/src/__tests__/preflight-transport.test.ts b/packages/0-framework/1-core/core/src/__tests__/preflight-transport.test.ts new file mode 100644 index 000000000..35b15156c --- /dev/null +++ b/packages/0-framework/1-core/core/src/__tests__/preflight-transport.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from 'bun:test'; +import { containerEnvVarName } from '../container-transport.ts'; +import { preflightEnv, preflightEnvVarName, readPreflightPayload } from '../preflight-transport.ts'; + +describe('preflightEnvVarName()', () => { + test('the documented mangling — the exact @prisma/composer-prisma-cloud expectation', () => { + expect(preflightEnvVarName('@prisma/composer-prisma-cloud')).toBe( + 'PRISMA_COMPOSER_PREFLIGHT_PRISMA_COMPOSER_PRISMA_CLOUD', + ); + }); + + test('never collides with the container transport variable for the same extension', () => { + expect(preflightEnvVarName('@prisma/composer-prisma-cloud')).not.toBe( + containerEnvVarName('@prisma/composer-prisma-cloud'), + ); + }); +}); + +describe('preflightEnv()', () => { + test('one var per extension, holding the payload that extension wrote, verbatim', () => { + expect( + preflightEnv( + new Map([ + ['@prisma/composer-prisma-cloud', '{"STRIPE_KEY":"2026-05-05T12:00:00.000Z"}'], + ['acme.widgets/v2', 'whatever-this-extension-wrote'], + ]), + ), + ).toEqual({ + PRISMA_COMPOSER_PREFLIGHT_PRISMA_COMPOSER_PRISMA_CLOUD: + '{"STRIPE_KEY":"2026-05-05T12:00:00.000Z"}', + PRISMA_COMPOSER_PREFLIGHT_ACME_WIDGETS_V2: 'whatever-this-extension-wrote', + }); + }); + + test('an extension with nothing to carry sets no var', () => { + expect(preflightEnv(new Map([['acme.widgets', '']]))).toEqual({}); + expect(preflightEnv(new Map())).toEqual({}); + }); + + test('two ids that map to the same var name fail loudly, naming both', () => { + expect(() => + preflightEnv( + new Map([ + ['acme.widgets', 'a'], + ['acme/widgets', 'b'], + ]), + ), + ).toThrow(/"acme\.widgets" and "acme\/widgets" both map to the preflight transport variable/); + }); +}); + +describe('readPreflightPayload()', () => { + test('reads back exactly what the CLI process wrote', () => { + const payload = '{"STRIPE_KEY":"2026-05-05T12:00:00.000Z"}'; + const env = preflightEnv(new Map([['@prisma/composer-prisma-cloud', payload]])); + + expect(readPreflightPayload('@prisma/composer-prisma-cloud', env)).toBe(payload); + }); + + test('a var belonging to another extension is not read as this one', () => { + const env = preflightEnv(new Map([['acme.widgets', 'not-mine']])); + + expect(readPreflightPayload('@prisma/composer-prisma-cloud', env)).toBeUndefined(); + }); + + test('an absent or empty var reads as nothing carried', () => { + expect(readPreflightPayload('acme.widgets', {})).toBeUndefined(); + expect( + readPreflightPayload('acme.widgets', { PRISMA_COMPOSER_PREFLIGHT_ACME_WIDGETS: '' }), + ).toBeUndefined(); + }); +}); diff --git a/packages/0-framework/1-core/core/src/container-transport.ts b/packages/0-framework/1-core/core/src/container-transport.ts index c5c76c5fb..7d8fd2f18 100644 --- a/packages/0-framework/1-core/core/src/container-transport.ts +++ b/packages/0-framework/1-core/core/src/container-transport.ts @@ -74,18 +74,22 @@ export interface ContainerDescriptor`, * and an extension that types the input against its own client type only * assigns here through method bivariance. */ - preflight?(input: PreflightInput): Promise; + preflight?(input: PreflightInput): Promise; /** * Destroy-time cleanup — the CLI runs it once, after `alchemy destroy` * succeeds and BEFORE the stage's Project/Branch are removed. A target uses diff --git a/packages/0-framework/1-core/core/src/preflight-transport.ts b/packages/0-framework/1-core/core/src/preflight-transport.ts new file mode 100644 index 000000000..0f140a399 --- /dev/null +++ b/packages/0-framework/1-core/core/src/preflight-transport.ts @@ -0,0 +1,45 @@ +/** + * Carries an extension's deploy-preflight payload from the CLI process to the + * alchemy child (which re-imports the config from scratch) — one env var per + * extension, same channel as containers (container-transport.ts). The + * framework never reads the contents. Payloads carry metadata only, never + * secret values: the child's environment is not a secret store. + */ +import { envVarSafeExtensionId } from './container-transport.ts'; + +/** A string only this extension reads back, or `undefined` when it has nothing to carry. */ +export type PreflightPayload = string | undefined; + +/** '@prisma/composer-prisma-cloud' → 'PRISMA_COMPOSER_PREFLIGHT_PRISMA_COMPOSER_PRISMA_CLOUD' */ +export function preflightEnvVarName(extensionId: string): string { + return `PRISMA_COMPOSER_PREFLIGHT_${envVarSafeExtensionId(extensionId)}`; +} + +/** The env entries the CLI sets on the alchemy process: `{ [preflightEnvVarName(id)]: payload }` for every extension whose preflight returned one. */ +export function preflightEnv(payloads: ReadonlyMap): Record { + const env: Record = {}; + const ownerByVarName = new Map(); + for (const [extensionId, payload] of payloads) { + if (payload.length === 0) continue; + const varName = preflightEnvVarName(extensionId); + const owner = ownerByVarName.get(varName); + if (owner !== undefined) { + throw new Error( + `Extension ids "${owner}" and "${extensionId}" both map to the preflight transport ` + + `variable "${varName}" — rename one of the extensions.`, + ); + } + ownerByVarName.set(varName, extensionId); + env[varName] = payload; + } + return env; +} + +/** The alchemy-process side: the payload this extension's own preflight wrote, or `undefined` when it wrote none (or when nothing ran a preflight at all). */ +export function readPreflightPayload( + extensionId: string, + env: Readonly>, +): PreflightPayload { + const payload = env[preflightEnvVarName(extensionId)]; + return payload === undefined || payload.length === 0 ? undefined : payload; +} diff --git a/packages/0-framework/3-tooling/cli/src/family/__tests__/runtime.test.ts b/packages/0-framework/3-tooling/cli/src/family/__tests__/runtime.test.ts index 491740c8f..68394848c 100644 --- a/packages/0-framework/3-tooling/cli/src/family/__tests__/runtime.test.ts +++ b/packages/0-framework/3-tooling/cli/src/family/__tests__/runtime.test.ts @@ -189,6 +189,31 @@ describe('createRuntime()', () => { ).toBe('https://api.staging.invalid'); }); + test('PRISMA_API_URL wins over PRISMA_MANAGEMENT_API_URL — the same precedence every other client uses', () => { + expect( + createRuntime( + fakeHost({ + env: { + PRISMA_API_URL: 'https://api.first.invalid', + PRISMA_MANAGEMENT_API_URL: 'https://api.second.invalid', + }, + }), + noConfig, + ).managementApi.baseUrl, + ).toBe('https://api.first.invalid'); + expect( + createRuntime( + fakeHost({ + env: { PRISMA_API_URL: '', PRISMA_MANAGEMENT_API_URL: 'https://api.second.invalid' }, + }), + noConfig, + ).managementApi.baseUrl, + ).toBe('https://api.second.invalid'); + expect( + createRuntime(fakeHost({ env: { PRISMA_API_URL: '' } }), noConfig).managementApi.baseUrl, + ).toBe('https://api.prisma.io'); + }); + test('the environment credential manager is wired, and reads the two protocol variables', async () => { const manager = createRuntime( fakeHost({ env: { PRISMA_SERVICE_TOKEN: 'token', PRISMA_WORKSPACE_ID: 'ws_1' } }), diff --git a/packages/0-framework/3-tooling/cli/src/family/runtime.ts b/packages/0-framework/3-tooling/cli/src/family/runtime.ts index 1c503f136..3ed0d149e 100644 --- a/packages/0-framework/3-tooling/cli/src/family/runtime.ts +++ b/packages/0-framework/3-tooling/cli/src/family/runtime.ts @@ -124,9 +124,18 @@ export function detectPackageManager( * and `isCIOverride` exists only for hosts where that detection cannot be * right — composer is not one. */ +const nonEmpty = (value: string | undefined): string | undefined => + value !== undefined && value.length > 0 ? value : undefined; + export function createRuntime(host: HostProcess, loadConfig: Runtime['loadConfig']): Runtime { const env = host.env; - const apiBaseUrl = env['PRISMA_MANAGEMENT_API_URL'] ?? DEFAULT_MANAGEMENT_API_BASE_URL; + // Same precedence as @internal/lowering's managementApiBaseUrl, so + // PRISMA_API_URL can never point the engine's client and the deploy's other + // clients at different hosts. Empty means unset, matching that resolver. + const apiBaseUrl = + nonEmpty(env['PRISMA_API_URL']) ?? + nonEmpty(env['PRISMA_MANAGEMENT_API_URL']) ?? + DEFAULT_MANAGEMENT_API_BASE_URL; const packageManager = detectPackageManager(env); return { stdout: host.stdout, diff --git a/packages/0-framework/3-tooling/cli/src/generate-stack.ts b/packages/0-framework/3-tooling/cli/src/generate-stack.ts index 51247cdab..11255495d 100644 --- a/packages/0-framework/3-tooling/cli/src/generate-stack.ts +++ b/packages/0-framework/3-tooling/cli/src/generate-stack.ts @@ -62,7 +62,9 @@ export function renderStackFile(input: StackFileInput): string { const configImport = relativeImportSpecifier(generatedDir, input.configPath); return `// Generated by \`prisma-composer deploy\`/\`prisma-composer destroy\` — overwritten on every -// run; do not edit by hand. Independently runnable from ${quote(input.cwd)}: +// run; do not edit by hand. Runnable from ${quote(input.cwd)} WITH the env the +// CLI passes its child (the PRISMA_COMPOSER_CONTAINER_*/_PREFLIGHT_* vars — +// without them lower() refuses because no container is resolved): // // alchemy deploy ${GENERATED_DIR}/${GENERATED_FILE} // diff --git a/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts b/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts index d4c6d9373..a0c6f5c61 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts @@ -14,7 +14,7 @@ import type { ReporterDescriptor, RunReporter, } from '@internal/core/config'; -import { containerEnv } from '@internal/core/config'; +import { containerEnv, preflightEnv, preflightEnvVarName } from '@internal/core/config'; import { CliStructuredError } from '@internal/foundation/errors'; import { notOk, ok, okVoid, type Result } from '@internal/foundation/result'; import { @@ -295,6 +295,11 @@ async function runStackPipelineInner( let pipeline: PipelineResult; let containers: Map; let alchemyStage: string; + // What each preflight hands back, on its way to the alchemy child: preflight + // runs here, in the parent, and the child re-imports the config from scratch, + // so anything it learned reaches the lowering only through this transport. + const preflightPayloads = new Map(); + let preflightTransportEnv: Record = {}; try { // The shared prefix (pipeline.ts): config discovery/load, entry load, @@ -402,16 +407,32 @@ async function runStackPipelineInner( for (const extension of config.extensions) { if (extension.preflight === undefined) continue; try { - await extension.preflight({ + const payload = await extension.preflight({ graph, container: containers.get(extension.id), stage, credentials: deps.credentials, }); + if (payload !== undefined) preflightPayloads.set(extension.id, payload); } catch (error) { throw toStructured('DEPLOY.PREFLIGHT_FAILED', error); } } + // Serialized HERE, not at the alchemy invocation below: a transport + // collision (two extension ids mapping to one env var) is a preflight + // failure, and must surface before the stack file is written. + try { + preflightTransportEnv = preflightEnv(preflightPayloads); + } catch (error) { + throw toStructured('DEPLOY.PREFLIGHT_FAILED', error); + } + } + // The child inherits this process's environment, so every transport var + // this run did NOT produce is blanked — a stale value exported into the + // shell (a re-exported child env) must not read as this run's payload. + // The reader treats an empty var as absent. + for (const extension of config.extensions) { + preflightTransportEnv[preflightEnvVarName(extension.id)] ??= ''; } } catch (error) { if (CliStructuredError.is(error)) return notOk(error); @@ -463,6 +484,7 @@ async function runStackPipelineInner( cwd, stage: alchemyStage, containerEnv: containerEnv(containers), + preflightEnv: preflightTransportEnv, env: { ...reporterChildEnv(reporters), [DEPLOYMENT_RESULT_FILE_ENV]: resultFilePath, diff --git a/packages/0-framework/3-tooling/cli/src/run-alchemy.ts b/packages/0-framework/3-tooling/cli/src/run-alchemy.ts index 245d3778d..cce38864d 100644 --- a/packages/0-framework/3-tooling/cli/src/run-alchemy.ts +++ b/packages/0-framework/3-tooling/cli/src/run-alchemy.ts @@ -106,6 +106,8 @@ export interface AlchemyInvocationInput { readonly cwd: string; readonly stage: string; readonly containerEnv: Readonly>; + /** What each extension's deploy preflight handed back, serialized — one env var per extension (core's preflight-transport naming). Absent for destroy, which runs no preflight. Content-blind, like `containerEnv`. */ + readonly preflightEnv?: Readonly>; /** Extra additions beyond the containers — the deployment-result pointer. */ readonly env?: Readonly> | undefined; } @@ -117,7 +119,7 @@ export function alchemyInvocation(input: AlchemyInvocationInput): AlchemyInvocat stackFileRelativePath: input.stackFileRelativePath, cwd: input.cwd, stage: input.stage, - env: { ...input.containerEnv, ...input.env }, + env: { ...input.containerEnv, ...input.preflightEnv, ...input.env }, }; } diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/compute-scoped-env.test.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/compute-scoped-env.test.ts index df7140e5a..f5c3a99a4 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/compute-scoped-env.test.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/compute-scoped-env.test.ts @@ -4,12 +4,14 @@ import { scopedEnvRows } from '../compute.ts'; /** * Local-dev spec § 4's pinned parity note: the hosted platform diffs a * deployment only on its own referenced rows, so an app-wide LOCAL - * materialization restart-amplifies (an early-deployed service's snapshot - * looks "changed" on the very next converge, purely from a sibling's row - * landing afterward). `scopedEnvRows` is the fix — every service's + * materialization causes spurious restarts (an early-deployed service's + * snapshot looks "changed" on the very next converge, purely from a sibling's + * row landing afterward). `scopedEnvRows` is the fix — every service's * materialized env keeps only what it owns (`COMPOSER__*`) - * plus every row OUTSIDE the `COMPOSER_` namespace (the poison rows, which - * are deliberately app-wide). + * plus every row OUTSIDE the `COMPOSER_` namespace. Composer writes no + * unprefixed row itself — an unprefixed name is a platform-owned one — but the + * store is a plain file an operator can add to, and such a row is app-wide by + * nature, so the scoping keeps it. */ describe('scopedEnvRows()', () => { test("keeps only the service's own COMPOSER_ rows plus every non-COMPOSER_ row", () => { @@ -18,15 +20,13 @@ describe('scopedEnvRows()', () => { COMPOSER_WEB_ORIGIN: 'http://localhost:3000', COMPOSER_ORDERS_SERVICE_PORT: '3001', COMPOSER_ORDERS_SERVICE_CATALOG_URL: 'http://localhost:3002', - DATABASE_URL: '-', - DATABASE_URL_POOLED: '-', + SHARED_FEATURE_FLAG: 'on', }; expect(scopedEnvRows(all, 'web')).toEqual({ COMPOSER_WEB_PORT: '3000', COMPOSER_WEB_ORIGIN: 'http://localhost:3000', - DATABASE_URL: '-', - DATABASE_URL_POOLED: '-', + SHARED_FEATURE_FLAG: 'on', }); }); @@ -44,13 +44,13 @@ describe('scopedEnvRows()', () => { }); }); - test('a service with no rows of its own still gets every poison/app-wide row', () => { + test('a service with no rows of its own still gets every app-wide row', () => { const all = { COMPOSER_OTHER_PORT: '4000', - DATABASE_URL: '-', + SHARED_FEATURE_FLAG: 'on', }; - expect(scopedEnvRows(all, 'web')).toEqual({ DATABASE_URL: '-' }); + expect(scopedEnvRows(all, 'web')).toEqual({ SHARED_FEATURE_FLAG: 'on' }); }); test('an empty env store scopes to an empty object', () => { diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/postgres-instance-name-drift.test.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/postgres-instance-name-drift.test.ts index aea6df54b..d90d8f841 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/postgres-instance-name-drift.test.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/postgres-instance-name-drift.test.ts @@ -9,7 +9,7 @@ import { instanceNameFor, postgresClient, } from '@internal/dev-emulators'; -import { Connection, Database } from '@internal/lowering/postgres'; +import { Connection, Database } from 'alchemy/Prisma'; import * as Effect from 'effect/Effect'; import * as Redacted from 'effect/Redacted'; import { LocalConnectionProvider, LocalDatabaseProvider } from '../postgres.ts'; @@ -29,6 +29,10 @@ import { LocalConnectionProvider, LocalDatabaseProvider } from '../postgres.ts'; * longer drift. This test proves it end to end, against the real daemon, * for exactly the pathological shape that used to fail: an app name AND a * database id each ending in a hyphen. + * + * The providers back upstream alchemy's `Prisma.Database` / + * `Prisma.Connection` classes, so the attribute names asserted here are the + * upstream ones (`databaseId`, `connectionId`, `directConnectionString`). */ const APP = 'pgdrifttestapp-'; @@ -54,6 +58,18 @@ function fakeContainer(appName: string): ContainerInstance { return { input: { appName, stage: undefined }, serialize: () => 'x' }; } +const reconcileInput = (id: string, news: Record) => + ({ + id, + fqn: id, + instanceId: id, + news, + olds: undefined, + output: undefined, + session: undefined as never, + bindings: [], + }) as never; + // The default, machine-global daemon (the SAME one `postgresClient()` inside // the providers under test talks to) — this test's whole point is proving // the providers agree with the REAL daemon, so it must run against the one @@ -85,26 +101,19 @@ describe('instance-name drift (delta review finding A, #160)', () => { const databaseService = await Effect.runPromise( Database.Provider.pipe(Effect.provide(LocalDatabaseProvider(input))), ); - const databaseAttributes = await Effect.runPromise( - databaseService.reconcile({ - id: 'db', - fqn: 'db', - instanceId: 'db', - news: { projectId: 'p', name: DATABASE_ID, region: 'us-east-1' }, - olds: undefined, - output: undefined, - session: undefined as never, - bindings: [], - }), + const databaseAttributes: Database['Attributes'] = await Effect.runPromise( + databaseService.reconcile( + reconcileInput('db', { project: 'p', name: DATABASE_ID, region: 'us-east-1' }), + ), ); // The provider-derived id is exactly the daemon's own derivation — no // second implementation to drift from it. - expect(databaseAttributes.id).toBe(instanceNameFor(APP, DATABASE_ID)); - expect(databaseAttributes.id).toBe('pcdev-pgdrifttestapp-orders'); + expect(databaseAttributes.databaseId).toBe(instanceNameFor(APP, DATABASE_ID)); + expect(databaseAttributes.databaseId).toBe('pcdev-pgdrifttestapp-orders'); // Proves the trim/collapse actually happened — the pre-fix drift left a // doubled dash at the "pgdrifttestapp-" + "-" + "orders-" boundary. - expect(databaseAttributes.id.includes('--')).toBe(false); + expect(databaseAttributes.databaseId.includes('--')).toBe(false); // 2. Connection-resolve through the listing (LocalConnectionProvider's // own reconcile) — before the fix, this threw noRecordedInstanceError @@ -112,26 +121,21 @@ describe('instance-name drift (delta review finding A, #160)', () => { const connectionService = await Effect.runPromise( Connection.Provider.pipe(Effect.provide(LocalConnectionProvider(input))), ); - const connectionAttributes = await Effect.runPromise( - connectionService.reconcile({ - id: 'conn', - fqn: 'conn', - instanceId: 'conn', - news: { databaseId: databaseAttributes.id, name: 'conn' }, - olds: undefined, - output: undefined, - session: undefined as never, - bindings: [], - }), + const connectionAttributes: Connection['Attributes'] = await Effect.runPromise( + connectionService.reconcile( + reconcileInput('conn', { database: databaseAttributes, name: 'conn' }), + ), ); - expect(connectionAttributes.id).toBe(databaseAttributes.id); - expect(Redacted.value(connectionAttributes.connectionString)).toMatch(/^postgres:\/\//); + expect(connectionAttributes.databaseId).toBe(databaseAttributes.databaseId); + const direct = connectionAttributes.directConnectionString; + if (direct === undefined) throw new Error('expected a direct connection string'); + expect(Redacted.value(direct)).toMatch(/^postgres:\/\//); // 3. The daemon's own listing agrees on the same name too — the third // independent read of the same value. const listed = await postgresClient().listDatabases(APP); - const entry = listed.find((d) => d.instanceName === databaseAttributes.id); + const entry = listed.find((d) => d.instanceName === databaseAttributes.databaseId); expect(entry).toBeDefined(); }), 30_000, @@ -157,45 +161,33 @@ describe('instance-name drift (delta review finding A, #160)', () => { const databaseService = await Effect.runPromise( Database.Provider.pipe(Effect.provide(LocalDatabaseProvider(input))), ); - const databaseAttributes = await Effect.runPromise( - databaseService.reconcile({ - id: 'db', - fqn: 'db', - instanceId: 'db', - news: { projectId: 'p', name: DOTTED_ID, region: 'us-east-1' }, - olds: undefined, - output: undefined, - session: undefined as never, - bindings: [], - }), + const databaseAttributes: Database['Attributes'] = await Effect.runPromise( + databaseService.reconcile( + reconcileInput('db', { project: 'p', name: DOTTED_ID, region: 'us-east-1' }), + ), ); // slug() is idempotent, so the daemon's instanceNameFor(app, slug(name)) // equals the provider-recorded instanceNameFor(app, name). - expect(databaseAttributes.id).toBe(instanceNameFor(APP, DOTTED_ID)); - expect(databaseAttributes.id).toBe('pcdev-pgdrifttestapp-catalog-database'); + expect(databaseAttributes.databaseId).toBe(instanceNameFor(APP, DOTTED_ID)); + expect(databaseAttributes.databaseId).toBe('pcdev-pgdrifttestapp-catalog-database'); const connectionService = await Effect.runPromise( Connection.Provider.pipe(Effect.provide(LocalConnectionProvider(input))), ); - const connectionAttributes = await Effect.runPromise( - connectionService.reconcile({ - id: 'conn', - fqn: 'conn', - instanceId: 'conn', - news: { databaseId: databaseAttributes.id, name: 'conn' }, - olds: undefined, - output: undefined, - session: undefined as never, - bindings: [], - }), + const connectionAttributes: Connection['Attributes'] = await Effect.runPromise( + connectionService.reconcile( + reconcileInput('conn', { database: databaseAttributes.databaseId, name: 'conn' }), + ), ); - expect(connectionAttributes.id).toBe(databaseAttributes.id); - expect(Redacted.value(connectionAttributes.connectionString)).toMatch(/^postgres:\/\//); + expect(connectionAttributes.databaseId).toBe(databaseAttributes.databaseId); + const direct = connectionAttributes.directConnectionString; + if (direct === undefined) throw new Error('expected a direct connection string'); + expect(Redacted.value(direct)).toMatch(/^postgres:\/\//); const listed = await postgresClient().listDatabases(APP); - expect(listed.find((d) => d.instanceName === databaseAttributes.id)).toBeDefined(); + expect(listed.find((d) => d.instanceName === databaseAttributes.databaseId)).toBeDefined(); }), 30_000, ); diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/compute.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/compute.ts index 6bf2bdb49..a368c3577 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/compute.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/compute.ts @@ -1,29 +1,64 @@ /** - * Local compute-cluster providers (local-dev spec § 4): `ComputeService` and - * `Deployment` become clients of the machine-scoped Compute emulator; - * `EnvironmentVariable` becomes a row in the dev env store; `Project` is a - * total-but-unused identity stand-in (no lowering yields one today). Every - * factory takes `LocalTargetProvidersInput` — the app name is - * `input.container`'s `input.appName` (see `app-name.ts`), `devDir` is - * `input.devDir`; nothing here reads `process.cwd()` or the environment. + * Local compute-cluster providers: upstream alchemy's `Prisma.App` and + * `Prisma.Deployment` become clients of the machine-scoped Compute emulator; + * `Prisma.EnvironmentVariable` becomes a row in the dev env store; + * `Prisma.Project` is an identity stand-in. Attributes match upstream's + * shapes; fields the emulator cannot answer are left absent, and both + * `appEndpointDomain`s carry the emulator's local URL. Nothing here reads + * `process.cwd()` or the environment. */ +import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; import type { LocalTargetProvidersInput } from '@internal/core/config'; import { computeClient } from '@internal/dev-emulators'; -import { - ComputeService, - Deployment, - type DeploymentAttributes, - EnvironmentVariable, -} from '@internal/lowering/compute'; -import { Project } from '@internal/lowering/postgres'; +import { App, Deployment, EnvironmentVariable, Project } from 'alchemy/Prisma'; import * as Provider from 'alchemy/Provider'; import * as Effect from 'effect/Effect'; import type * as Layer from 'effect/Layer'; +import * as Predicate from 'effect/Predicate'; +import * as Redacted from 'effect/Redacted'; import { appNameOf } from './app-name.ts'; import { extractComputeArtifact } from './artifact-extract.ts'; import { envStore, secretsStore } from './dev-store.ts'; +import { DEV_TIMESTAMP, projectIdOfInput } from './upstream-attributes.ts'; + +/** Reads an app id from upstream's `app` input: a plain string or a resolved `Prisma.App` attributes record. */ +function appIdOfInput(value: unknown): string { + if (typeof value === 'string') return value; + if (Predicate.isObject(value) && typeof value['appId'] === 'string') return value['appId']; + throw new Error(`local Deployment received an app reference it cannot read: ${String(value)}`); +} + +/** + * The artifact's own sha256, streamed from its bytes — the digest that names + * the unpacked artifact directory and identifies the deployment. Memoized on + * (path, size, mtime): every converge re-runs providers and artifacts run to + * hundreds of megabytes. + */ +const artifactHashes = new Map(); + +async function artifactSha256(artifactPath: string): Promise { + const stat = await fs.promises.stat(artifactPath); + const identity = `${artifactPath}:${String(stat.size)}:${String(stat.mtimeMs)}`; + const memoized = artifactHashes.get(identity); + if (memoized !== undefined) return memoized; + const hash = crypto.createHash('sha256'); + const handle = await fs.promises.open(artifactPath, 'r'); + try { + const buffer = Buffer.allocUnsafe(1024 * 1024); + let { bytesRead } = await handle.read(buffer, 0, buffer.length, null); + while (bytesRead > 0) { + hash.update(buffer.subarray(0, bytesRead)); + ({ bytesRead } = await handle.read(buffer, 0, buffer.length, null)); + } + } finally { + await handle.close(); + } + const digest = hash.digest('hex'); + artifactHashes.set(identity, digest); + return digest; +} /** * The env-var key the app's own boot-side `deserialize()` reads for its @@ -48,19 +83,12 @@ function ownEnvKeyPrefix(address: string): string { const COMPOSER_NAMESPACE_PREFIX = 'COMPOSER_'; /** - * Scopes `env.json` to what THIS service is allowed to see: rows it owns - * (`COMPOSER__*`) plus every row OUTSIDE the `COMPOSER_` - * namespace entirely — the poison `DATABASE_URL(_POOLED)` rows are - * deliberately unprefixed and app-wide (local-dev spec § 4's pinned parity - * note). The hosted platform materializes the app-wide row set into every - * deployment but DIFFS a deployment only on its own referenced rows; an - * app-wide LOCAL materialization restart-amplifies instead — an - * early-deployed service's snapshot is incomplete on the first converge, - * "completes" on the second, and diffs as changed. Scoping the content here - * aligns local restart behavior with the platform's diff scope. The dropped - * sibling rows have no sanctioned reader: `run()`/`load()` consume only - * own-address rows, and ambient sibling reads are exactly what the poison - * rows exist to punish. + * Scopes `env.json` to what THIS service may see: rows it owns + * (`COMPOSER__*`) plus every unprefixed (platform-owned, + * app-wide) row. Materializing the app-wide set locally causes spurious + * restarts — an early service's snapshot "completes" on the second converge + * and diffs as changed — and the dropped sibling rows have no sanctioned + * reader. */ export function scopedEnvRows( allRows: Readonly>, @@ -121,16 +149,10 @@ function readManifestAddress(artifactDir: string): string { } /** - * The Compute emulator's `` path segment must match - * `/^[a-z0-9][a-z0-9-]*$/` (its API hygiene rule, local-dev spec § 2) — but a - * service's own address (`news.name`/`news.computeServiceId`) is - * hierarchical and dot-separated (e.g. `"orders.service"`, a nested - * module's service). This is the seam: every dot (or other disallowed char) - * becomes a dash, runs collapse, and the result is what both `ensureService` - * and `putDeployment` address the emulator with — the REAL address still - * rides the deployment body's `address` field untouched, so the front door - * and every listing still show it verbatim (compute-main.ts's `svc.address` - * is set from that field, not from the id). + * A service address is dot-separated (`"orders.service"`) but the emulator's + * `` segment must match `/^[a-z0-9][a-z0-9-]*$/`: disallowed characters + * become dashes, runs collapse. The real address still rides the deployment + * body's `address` field untouched, so listings show it verbatim. */ function slugServiceId(address: string): string { const slug = address @@ -155,31 +177,43 @@ async function materializeEnv( } /** - * `ComputeService` → the Compute emulator: reserves (or returns) the - * service's stable port. `delete` is a no-op — instance removal belongs to - * `teardown` (`DELETE /apps/`), not per-resource Alchemy deletes. + * `Prisma.App` → the Compute emulator: reserves (or returns) the service's + * stable port. The app id here IS the service's own address, which + * `Deployment` slugs back into the emulator's id. `delete` is a no-op — + * instance removal belongs to `teardown` (`DELETE /apps/`), not + * per-resource Alchemy deletes. */ -export function LocalComputeServiceProvider( +export function LocalAppProvider( input: LocalTargetProvidersInput, -): Layer.Layer> { - const service: Provider.ProviderService = { +): Layer.Layer> { + const service: Provider.ProviderService = { list: () => Effect.succeed([]), - reconcile: ({ news }) => + reconcile: ({ id, news }) => Effect.tryPromise({ try: async () => { - const app = appNameOf(input.container); - const { url } = await computeClient().ensureService(app, slugServiceId(news.name)); - return { id: news.name, name: news.name, endpointDomain: url }; + const appName = appNameOf(input.container); + const name = news.displayName ?? id; + const { url } = await computeClient().ensureService(appName, slugServiceId(name)); + return { + appId: name, + name, + projectId: projectIdOfInput(news.project), + regionId: news.regionId ?? 'us-east-1', + branchId: news.branchId ?? null, + latestDeploymentId: null, + appEndpointDomain: url, + createdAt: DEV_TIMESTAMP, + } satisfies App['Attributes']; }, catch: (cause) => cause, }), delete: () => Effect.void, read: ({ output }) => Effect.succeed(output), }; - return Provider.effect(ComputeService, Effect.succeed(service)); + return Provider.effect(App, Effect.succeed(service)); } -/** `EnvironmentVariable` → a key/value row in `/env.json`. Parity with deploy: the poison `DATABASE_URL` rows land here like any other. */ +/** `Prisma.EnvironmentVariable` → a key/value row in `/env.json`. Upstream's value is `Redacted`; env.json holds the plain string the child process is given. */ export function LocalEnvironmentVariableProvider( input: LocalTargetProvidersInput, ): Layer.Layer> { @@ -190,9 +224,20 @@ export function LocalEnvironmentVariableProvider( try: async () => { await envStore(input.devDir).update((current) => ({ ...current, - [news.key]: news.value, + [news.key]: Redacted.value(news.value), })); - return { id: news.key, key: news.key }; + return { + environmentVariableId: news.key, + projectId: projectIdOfInput(news.project), + branchId: news.branchId ?? null, + class: news.class, + key: news.key, + value: news.value, + valueKid: '', + isManagedBySystem: false, + createdAt: DEV_TIMESTAMP, + updatedAt: DEV_TIMESTAMP, + } satisfies EnvironmentVariable['Attributes']; }, catch: (cause) => cause, }), @@ -212,10 +257,13 @@ export function LocalEnvironmentVariableProvider( } /** - * `Deployment` → unpacks the artifact once per hash, fetches the emulator's - * assigned port, materializes the child's full env (env store + secrets + - * the port override + `PATH`/`HOME`), and puts the deployment — the emulator - * (re)starts the child only when the hash or env actually changed. + * `Prisma.Deployment` → unpacks the artifact once per hash, fetches the + * emulator's assigned port, materializes the child's full env (env store + + * secrets + the port override + `PATH`/`HOME`), and puts the deployment — the + * emulator (re)starts the child only when the hash or env actually changed. + * `portMapping` is ignored on purpose: the emulator owns port allocation, and + * the child learns its port from `COMPOSER_
_PORT` in the env this + * provider materializes. */ export function LocalDeploymentProvider( input: LocalTargetProvidersInput, @@ -224,12 +272,16 @@ export function LocalDeploymentProvider( list: () => Effect.succeed([]), reconcile: ({ news }) => Effect.tryPromise({ - try: async (): Promise => { + try: async (): Promise => { const app = appNameOf(input.container); - const id = news.computeServiceId; - const emulatorId = slugServiceId(id); + const appId = appIdOfInput(news.app); + const emulatorId = slugServiceId(appId); + if (news.artifactPath === undefined) { + throw new Error('local Deployment requires an artifactPath — nothing to run.'); + } + const artifactHash = await artifactSha256(news.artifactPath); - const artifactDir = path.join(input.devDir, 'artifacts', news.artifactHash); + const artifactDir = path.join(input.devDir, 'artifacts', artifactHash); if (!fs.existsSync(artifactDir)) { extractComputeArtifact(news.artifactPath, artifactDir); } @@ -240,12 +292,25 @@ export function LocalDeploymentProvider( await computeClient().putDeployment(app, emulatorId, { address, artifactDir, - artifactHash: news.artifactHash, + artifactHash, env, port, }); - return { deploymentId: news.artifactHash, deployedUrl: `http://localhost:${port}` }; + const url = `http://localhost:${port}`; + return { + deploymentId: artifactHash, + appId, + // The emulator has no Foundry: the artifact digest is the only + // identity a local deployment has, and it is what upstream's + // recovery-by-version lookup would be given. + foundryVersionId: artifactHash, + status: 'running', + previewDomain: null, + artifactHash, + appEndpointDomain: url, + createdAt: DEV_TIMESTAMP, + } satisfies Deployment['Attributes']; }, catch: (cause) => cause, }), @@ -257,16 +322,31 @@ export function LocalDeploymentProvider( } /** - * `Project` — identity only; present so the provider collection stays total. - * No lowering yields a `Project` resource today (mirrors the hosted - * `Project` provider, which is also never exercised — see postgres.ts). + * `Prisma.Project` — identity only; present so the provider collection stays + * total. No lowering yields a `Project` resource today (mirrors the hosted + * wiring, where the container resolves the project pre-alchemy). */ export function LocalProjectProvider( _input: LocalTargetProvidersInput, ): Layer.Layer> { const service: Provider.ProviderService = { list: () => Effect.succeed([]), - reconcile: ({ news }) => Effect.succeed({ id: 'local', name: news.name }), + reconcile: ({ id, news }) => + Effect.succeed({ + projectId: 'local', + projectName: news.name ?? id, + workspaceId: 'local', + createdAt: DEV_TIMESTAMP, + defaultRegion: null, + databaseId: undefined, + defaultConnectionId: undefined, + directConnectionString: undefined, + pooledConnectionString: undefined, + accelerateConnectionString: undefined, + host: undefined, + user: undefined, + password: undefined, + } satisfies Project['Attributes']), delete: () => Effect.void, }; return Provider.effect(Project, Effect.succeed(service)); diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/postgres.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/postgres.ts index 757346c2b..1c1d642e6 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/postgres.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/postgres.ts @@ -1,34 +1,34 @@ /** - * Local postgres-cluster providers (local-dev spec § 4, REVISED — operator - * review of #162): `Database` and `Connection` become clients of the - * `postgres-main` emulator daemon, which hosts `@prisma/dev`'s programmatic - * `startPrismaDevServer` — one named, persistent server per `Database` - * resource. The CLI shell-out is gone: no bin walk-up, no stdout URL - * parsing, no `prisma dev stop/rm` glob teardown. `PgWarm` and - * `PnMigration` are NOT here; the hosted ones run unchanged against - * whichever URL they are handed. - * - * Instance-name derivation is NOT duplicated here (delta review finding A, - * #160): a locally re-derived slug drifted from the daemon's own - * `instanceNameFor` (no leading/trailing-dash trim), so a database id or - * app name with a leading/trailing non-alphanumeric character (e.g. - * `_orders`) produced a DIFFERENT name here than the one the daemon - * actually created the server under — `Connection`'s lookup by that - * drifted name then threw `noRecordedInstanceError` even though the - * server existed. `instanceNameFor` is imported directly from - * `@internal/dev-emulators` instead, so there is exactly one - * implementation. + * Local postgres-cluster providers: upstream alchemy's `Prisma.Database` and + * `Prisma.Connection` become clients of the `postgres-main` emulator daemon + * (one named, persistent `@prisma/dev` server per `Database` resource). + * `PgWarm`/`PnMigration` are not here — the hosted ones run against whatever + * URL they are handed. Attributes match upstream's shapes; the daemon's + * DIRECT connection string maps to `directConnectionString` and + * `databaseUrl`, everything else is left absent. Instance names come from + * `@internal/dev-emulators`' own `instanceNameFor` — a locally re-derived + * slug drifted from the daemon's and broke `Connection`'s lookup. */ import { createRequire } from 'node:module'; import * as path from 'node:path'; import type { LocalTargetProvidersInput } from '@internal/core/config'; import { instanceNameFor, postgresClient, slug } from '@internal/dev-emulators'; -import { Connection, Database } from '@internal/lowering/postgres'; +import * as Prisma from 'alchemy/Prisma'; import * as Provider from 'alchemy/Provider'; import * as Effect from 'effect/Effect'; import type * as Layer from 'effect/Layer'; +import * as Predicate from 'effect/Predicate'; import * as Redacted from 'effect/Redacted'; import { appNameOf } from './app-name.ts'; +import { DEV_TIMESTAMP, projectIdOfInput } from './upstream-attributes.ts'; + +/** Reads a database id from upstream's `database` input: a plain string or a resolved `Prisma.Database` attributes record. */ +function databaseIdOfInput(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if (Predicate.isObject(value) && typeof value['databaseId'] === 'string') + return value['databaseId']; + return undefined; +} function noPrismaDevError(): Error { return new Error( @@ -62,18 +62,23 @@ export function resolvePrismaDevModulePath(cwd: string): string { } /** - * `Database` → an ensured `postgres-main` server, one per resource. Stores - * the daemon's returned `url` on its own attributes. + * `Prisma.Database` → an ensured `postgres-main` server, one per resource. + * Stores the daemon's returned url as the `directConnectionString` attribute. */ export function LocalDatabaseProvider( input: LocalTargetProvidersInput, -): Layer.Layer> { - const service: Provider.ProviderService = { +): Layer.Layer> { + const service: Provider.ProviderService = { list: () => Effect.succeed([]), - reconcile: ({ news }) => + reconcile: ({ id, news }) => Effect.tryPromise({ try: async () => { const app = appNameOf(input.container); + // Hosted branch-stage deploys omit the display name (see + // descriptors/postgres.ts); local dev never has a branch, so + // `news.name` is normally present — the resource's logical id is + // only a defensive fallback. + const name = news.name ?? id; const prismaDevModulePath = resolvePrismaDevModulePath(process.cwd()); // The daemon's `` path segment must match // /^[a-z0-9][a-z0-9-]*$/ (spec § 2's API hygiene rule) — but a @@ -86,17 +91,33 @@ export function LocalDatabaseProvider( // below record and `Connection` looks up. const { url } = await postgresClient().ensureDatabase( app, - slug(news.name), + slug(name), prismaDevModulePath, ); - const attributes = { id: instanceNameFor(app, news.name), name: news.name, url }; - return attributes; + const direct = Redacted.make(url); + return { + databaseId: instanceNameFor(app, name), + databaseName: name, + projectId: projectIdOfInput(news.project), + status: 'ready', + region: news.region ?? 'us-east-1', + isDefault: false, + branchId: null, + defaultConnectionId: null, + createdAt: DEV_TIMESTAMP, + directConnectionString: direct, + pooledConnectionString: undefined, + accelerateConnectionString: undefined, + host: undefined, + user: undefined, + password: undefined, + } satisfies Prisma.Database['Attributes']; }, catch: (cause) => cause, }), delete: () => Effect.void, }; - return Provider.effect(Database, Effect.succeed(service)); + return Provider.effect(Prisma.Database, Effect.succeed(service)); } function noRecordedInstanceError(databaseId: string): Error { @@ -106,24 +127,45 @@ function noRecordedInstanceError(databaseId: string): Error { ); } -/** `Connection` → the daemon's live listing, matched by instance name (the Database attributes' `id` IS the instance name). */ +/** `Prisma.Connection` → the daemon's live listing, matched by instance name (the Database attributes' `databaseId` IS the instance name). */ export function LocalConnectionProvider( input: LocalTargetProvidersInput, -): Layer.Layer> { - const service: Provider.ProviderService = { +): Layer.Layer> { + const service: Provider.ProviderService = { list: () => Effect.succeed([]), - reconcile: ({ news }) => + reconcile: ({ id, news }) => Effect.tryPromise({ try: async () => { const app = appNameOf(input.container); + const databaseId = databaseIdOfInput(news.database); + if (databaseId === undefined) throw noRecordedInstanceError(String(news.database)); const databases = await postgresClient().listDatabases(app); - const found = databases.find((entry) => entry.instanceName === news.databaseId); - if (found === undefined) throw noRecordedInstanceError(news.databaseId); - return { id: found.instanceName, connectionString: Redacted.make(found.url) }; + const found = databases.find((entry) => entry.instanceName === databaseId); + if (found === undefined) throw noRecordedInstanceError(databaseId); + const direct = Redacted.make(found.url); + return { + connectionId: found.instanceName, + connectionName: news.name ?? id, + databaseId: found.instanceName, + kind: 'postgres', + createdAt: DEV_TIMESTAMP, + directConnectionString: direct, + pooledConnectionString: undefined, + accelerateConnectionString: undefined, + host: undefined, + user: undefined, + password: undefined, + // Local dev has only the direct endpoint, so it is also the + // conventional application URL. The parsed origins stay unset; + // nothing local consumes them. + databaseUrl: direct, + origin: undefined, + pooledOrigin: undefined, + } satisfies Prisma.Connection['Attributes']; }, catch: (cause) => cause, }), delete: () => Effect.void, }; - return Provider.effect(Connection, Effect.succeed(service)); + return Provider.effect(Prisma.Connection, Effect.succeed(service)); } diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/providers.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/providers.ts index 9db09ab0b..777d76bd1 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/providers.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/providers.ts @@ -8,13 +8,19 @@ import type { LocalTargetProvidersInput } from '@internal/core/config'; import { Providers } from '@internal/lowering'; import { Bucket, BucketKey } from '@internal/lowering/buckets'; -import { ComputeService, Deployment, EnvironmentVariable } from '@internal/lowering/compute'; -import { Connection, Database, Project } from '@internal/lowering/postgres'; +import { + App, + Connection, + Database, + Deployment, + EnvironmentVariable, + Project, +} from 'alchemy/Prisma'; import * as Provider from 'alchemy/Provider'; import * as Layer from 'effect/Layer'; import { LocalBucketKeyProvider, LocalBucketProvider } from './bucket.ts'; import { - LocalComputeServiceProvider, + LocalAppProvider, LocalDeploymentProvider, LocalEnvironmentVariableProvider, LocalProjectProvider, @@ -28,7 +34,7 @@ export const localTargetProviders = (input: LocalTargetProvidersInput): Layer.La Project, Database, Connection, - ComputeService, + App, Deployment, EnvironmentVariable, Bucket, @@ -40,7 +46,7 @@ export const localTargetProviders = (input: LocalTargetProvidersInput): Layer.La LocalProjectProvider(input), LocalDatabaseProvider(input), LocalConnectionProvider(input), - LocalComputeServiceProvider(input), + LocalAppProvider(input), LocalDeploymentProvider(input), LocalEnvironmentVariableProvider(input), LocalBucketProvider(input), diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/upstream-attributes.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/upstream-attributes.ts new file mode 100644 index 000000000..402d05f8e --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/upstream-attributes.ts @@ -0,0 +1,19 @@ +/** + * Shared helpers for the upstream-attribute records every local provider + * emits — one implementation, so the compute and postgres families cannot + * drift on the `'local'` project fallback or the timestamp. + */ + +import * as Predicate from 'effect/Predicate'; + +/** The fixed `createdAt`/`updatedAt` local providers stamp: local dev has no meaningful creation time. */ +export const DEV_TIMESTAMP = '1970-01-01T00:00:00.000Z'; + +/** Reads a project id from upstream's `project` input: a plain string or a resolved `Prisma.Project` attributes record. */ +export function projectIdOfInput(value: unknown): string { + if (typeof value === 'string') return value; + if (Predicate.isObject(value) && typeof value['projectId'] === 'string') { + return value['projectId']; + } + return 'local'; +} diff --git a/packages/1-prisma-cloud/0-lowering/lowering/package.json b/packages/1-prisma-cloud/0-lowering/lowering/package.json index ca02a0cf7..6e35af19a 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/package.json +++ b/packages/1-prisma-cloud/0-lowering/lowering/package.json @@ -7,7 +7,6 @@ "./buckets": "./dist/buckets.mjs", "./builds": "./dist/builds.mjs", "./compute": "./dist/compute.mjs", - "./postgres": "./dist/postgres.mjs", "./state": "./dist/state.mjs", "./package.json": "./package.json" }, @@ -18,6 +17,7 @@ "clean": "rm -rf dist" }, "dependencies": { + "@effect/platform-node": "4.0.0-beta.103", "@internal/bundle-paths": "workspace:0.10.0", "@internal/core": "workspace:0.10.0", "@internal/foundation": "workspace:0.10.0", diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/ComputeService.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/ComputeService.test.ts deleted file mode 100644 index 05038c405..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/ComputeService.test.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { beforeEach, describe, expect, test } from 'bun:test'; -import * as Effect from 'effect/Effect'; -import * as Schedule from 'effect/Schedule'; -import { type ManagementApiClient, ManagementClient } from '../client.ts'; -import { - ComputeService, - ComputeServiceProvider, - deleteSafeRetrySchedule, - isDeleteNotSafeYet, -} from '../compute/ComputeService.ts'; -import { PrismaApiError } from '../http.ts'; - -const deleteNotSafeError = new PrismaApiError({ - status: 409, - message: JSON.stringify({ - error: { - code: 'client-error', - message: 'The deployment did not reach a delete-safe state after stop', - hint: 'The resource already exists or is in a conflicting state.', - }, - }), -}); - -describe('isDeleteNotSafeYet', () => { - test('classifies the delete-safe-after-stop error as retryable', () => { - expect(isDeleteNotSafeYet(deleteNotSafeError)).toBe(true); - }); - - test('does not classify an unrelated API error as retryable', () => { - const unauthorized = new PrismaApiError({ status: 401, message: '{"error":"unauthorized"}' }); - const notFound = new PrismaApiError({ status: 404, message: '{"error":"not found"}' }); - const serverError = new PrismaApiError({ status: 500, message: '{"error":"internal error"}' }); - - expect(isDeleteNotSafeYet(unauthorized)).toBe(false); - expect(isDeleteNotSafeYet(notFound)).toBe(false); - expect(isDeleteNotSafeYet(serverError)).toBe(false); - }); -}); - -describe('delete retry wiring (Effect.retry({ schedule, while }))', () => { - // Exercises the same `{ schedule, while: isDeleteNotSafeYet }` composition - // ComputeService's delete uses, swapping in a millisecond-scale schedule so - // the test doesn't wait on the real 2s-to-5min production backoff. - const fastSchedule = Schedule.spaced('1 millis'); - - test('retries a delete-not-safe-yet failure until it succeeds', async () => { - let attempts = 0; - const flaky = Effect.gen(function* () { - attempts++; - if (attempts < 3) return yield* Effect.fail(deleteNotSafeError); - return 'deleted'; - }); - - const result = await Effect.runPromise( - flaky.pipe(Effect.retry({ schedule: fastSchedule, while: isDeleteNotSafeYet })), - ); - - expect(result).toBe('deleted'); - expect(attempts).toBe(3); - }); - - test('does not retry a different error — it fails on the first attempt', async () => { - let attempts = 0; - const alwaysUnauthorized = Effect.gen(function* () { - attempts++; - return yield* Effect.fail( - new PrismaApiError({ status: 401, message: '{"error":"unauthorized"}' }), - ); - }); - - const outcome = await Effect.runPromiseExit( - alwaysUnauthorized.pipe(Effect.retry({ schedule: fastSchedule, while: isDeleteNotSafeYet })), - ); - - expect(outcome._tag).toBe('Failure'); - expect(attempts).toBe(1); - }); - - test('gives up once the delete-safe error persists past the overall timeout', async () => { - // A near-zero overall cap makes the "generous timeout" boundary itself - // fast to test: it should retry a couple of times and then still fail. - let attempts = 0; - const alwaysNotSafe = Effect.gen(function* () { - attempts++; - return yield* Effect.fail(deleteNotSafeError); - }); - - const shortCappedSchedule = Schedule.spaced('1 millis').pipe( - Schedule.upTo({ duration: '20 millis' }), - ); - - const outcome = await Effect.runPromiseExit( - alwaysNotSafe.pipe( - Effect.retry({ schedule: shortCappedSchedule, while: isDeleteNotSafeYet }), - ), - ); - - expect(outcome._tag).toBe('Failure'); - expect(attempts).toBeGreaterThan(1); - }); -}); - -describe('deleteSafeRetrySchedule', () => { - test('is a Schedule value wired into the delete provider', () => { - expect(Schedule.isSchedule(deleteSafeRetrySchedule)).toBe(true); - }); -}); - -interface RecordedCall { - method: 'GET' | 'POST' | 'PATCH'; - path: string; - body?: unknown; -} - -interface FakeState { - calls: RecordedCall[]; - /** When set, GET /v1/apps/{appId} resolves to this — the observed path. */ - observed?: { id: string; name: string; appEndpointDomain?: string }; -} - -const okResponse = (data: T, status = 200) => ({ - data, - error: undefined, - response: new Response(null, { status }), -}); - -const notFoundResponse = () => ({ - data: undefined, - error: undefined, - response: new Response(null, { status: 404 }), -}); - -/** - * A stubbed `ManagementApiClient` covering the ComputeService provider's - * endpoints (GET/POST for observe-or-create; PATCH is stubbed but should - * never be hit — reconcile no longer PATCHes), recording every call it - * receives — the container.test.ts fake-client idiom. `as unknown as - * ManagementApiClient` is acceptable here (test file — exempt from the - * no-bare-cast rule). - */ -const fakeClient = (state: FakeState): ManagementApiClient => { - const GET = (path: string) => { - state.calls.push({ method: 'GET', path }); - if (path === '/v1/apps/{appId}') { - return Promise.resolve( - state.observed ? okResponse({ data: state.observed }) : notFoundResponse(), - ); - } - throw new Error(`fakeClient: unexpected GET ${path}`); - }; - - const POST = (path: string, init: { body?: Record } = {}) => { - state.calls.push({ method: 'POST', path, body: init.body }); - if (path === '/v1/apps') { - return Promise.resolve( - okResponse({ data: { id: 'cs-created', name: String(init.body?.['displayName']) } }, 201), - ); - } - throw new Error(`fakeClient: unexpected POST ${path}`); - }; - - const PATCH = (path: string, init: { body?: Record } = {}) => { - state.calls.push({ method: 'PATCH', path, body: init.body }); - if (path === '/v1/apps/{appId}') { - return Promise.resolve(okResponse({ data: { id: 'cs-created', name: 'compute' } })); - } - throw new Error(`fakeClient: unexpected PATCH ${path}`); - }; - - return { GET, POST, PATCH } as unknown as ManagementApiClient; -}; - -const getService = (state: FakeState) => - Effect.runPromise( - ComputeService.Provider.pipe( - Effect.provide(ComputeServiceProvider()), - Effect.provideService(ManagementClient, fakeClient(state)), - ), - ); - -const reconcile = async ( - state: FakeState, - input: { news: Record; output?: { id: string; name: string } | undefined }, -) => { - const svc = await getService(state); - return Effect.runPromise(svc.reconcile(input as unknown as Parameters[0])); -}; - -describe('ComputeService reconcile — Branch via the create body', () => { - let state: FakeState; - - beforeEach(() => { - state = { calls: [] }; - }); - - test('branchId set, no prior output: creates on the Branch, no PATCH', async () => { - const result = await reconcile(state, { - news: { projectId: 'proj-1', name: 'compute', branchId: 'br-1' }, - output: undefined, - }); - - expect(result).toEqual({ id: 'cs-created', name: 'compute' }); - expect(state.calls.map((c) => c.method)).toEqual(['POST']); - expect(state.calls[0]?.body).toEqual({ - displayName: 'compute', - projectId: 'proj-1', - branchId: 'br-1', - }); - expect(state.calls.filter((c) => c.method === 'PATCH')).toHaveLength(0); - }); - - test('branchId set, prior output exists: observes only, no POST, no PATCH', async () => { - state.observed = { id: 'cs-existing', name: 'compute' }; - - const result = await reconcile(state, { - news: { projectId: 'proj-1', name: 'compute', branchId: 'br-1' }, - output: { id: 'cs-existing', name: 'compute' }, - }); - - expect(result).toEqual({ id: 'cs-existing', name: 'compute' }); - expect(state.calls.map((c) => c.method)).toEqual(['GET']); - expect(state.calls.filter((c) => c.method === 'POST')).toHaveLength(0); - expect(state.calls.filter((c) => c.method === 'PATCH')).toHaveLength(0); - }); - - test('branchId unset, no prior output: creates without a branchId key, no PATCH', async () => { - const result = await reconcile(state, { - news: { projectId: 'proj-1', name: 'compute' }, - output: undefined, - }); - - expect(result).toEqual({ id: 'cs-created', name: 'compute' }); - expect(state.calls.map((c) => c.method)).toEqual(['POST']); - expect(state.calls[0]?.body).toEqual({ displayName: 'compute', projectId: 'proj-1' }); - expect(state.calls.filter((c) => c.method === 'PATCH')).toHaveLength(0); - }); - - test('branchId unset, prior output exists: observes only, no POST, no PATCH', async () => { - state.observed = { id: 'cs-existing', name: 'compute' }; - - const result = await reconcile(state, { - news: { projectId: 'proj-1', name: 'compute' }, - output: { id: 'cs-existing', name: 'compute' }, - }); - - expect(result).toEqual({ id: 'cs-existing', name: 'compute' }); - expect(state.calls.map((c) => c.method)).toEqual(['GET']); - expect(state.calls.filter((c) => c.method === 'POST')).toHaveLength(0); - expect(state.calls.filter((c) => c.method === 'PATCH')).toHaveLength(0); - }); -}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/Database.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/Database.test.ts deleted file mode 100644 index 0be9b4631..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/Database.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { beforeEach, describe, expect, test } from 'bun:test'; -import * as Effect from 'effect/Effect'; -import { type ManagementApiClient, ManagementClient } from '../client.ts'; -import { Database, DatabaseProvider } from '../postgres/Database.ts'; - -interface RecordedCall { - method: 'GET' | 'POST' | 'PATCH'; - path: string; - body?: unknown; -} - -interface FakeState { - calls: RecordedCall[]; - /** When set, GET /v1/databases/{databaseId} resolves to this — the observed path. */ - observed?: { id: string; name: string }; -} - -const okResponse = (data: T, status = 200) => ({ - data, - error: undefined, - response: new Response(null, { status }), -}); - -const notFoundResponse = () => ({ - data: undefined, - error: undefined, - response: new Response(null, { status: 404 }), -}); - -/** - * A stubbed `ManagementApiClient` covering only the Database provider's - * endpoints (GET/POST for observe-or-create, PATCH for Branch attachment), - * recording every call it receives — the container.test.ts fake-client - * idiom. `as unknown as ManagementApiClient` is acceptable here (test file - * — exempt from the no-bare-cast rule). - * - * The project-scoped create route is absent on purpose — it can't carry a - * branchId, so reaching for it throws instead of silently passing. - */ -const fakeClient = (state: FakeState): ManagementApiClient => { - const GET = (path: string) => { - state.calls.push({ method: 'GET', path }); - if (path === '/v1/databases/{databaseId}') { - return Promise.resolve( - state.observed ? okResponse({ data: state.observed }) : notFoundResponse(), - ); - } - throw new Error(`fakeClient: unexpected GET ${path}`); - }; - - const POST = (path: string, init: { body?: Record } = {}) => { - state.calls.push({ method: 'POST', path, body: init.body }); - if (path === '/v1/databases') { - return Promise.resolve( - okResponse({ data: { id: 'db-created', name: String(init.body?.['name']) } }, 201), - ); - } - throw new Error(`fakeClient: unexpected POST ${path}`); - }; - - const PATCH = (path: string, init: { body?: Record } = {}) => { - state.calls.push({ method: 'PATCH', path, body: init.body }); - if (path === '/v1/databases/{databaseId}') { - return Promise.resolve(okResponse({ data: { id: 'db-created', name: 'db' } })); - } - throw new Error(`fakeClient: unexpected PATCH ${path}`); - }; - - return { GET, POST, PATCH } as unknown as ManagementApiClient; -}; - -const getService = (state: FakeState) => - Effect.runPromise( - Database.Provider.pipe( - Effect.provide(DatabaseProvider()), - Effect.provideService(ManagementClient, fakeClient(state)), - ), - ); - -const reconcile = async ( - state: FakeState, - input: { news: Record; output?: { id: string; name: string } | undefined }, -) => { - const svc = await getService(state); - return Effect.runPromise(svc.reconcile(input as unknown as Parameters[0])); -}; - -describe('Database reconcile — Branch attachment', () => { - let state: FakeState; - - beforeEach(() => { - state = { calls: [] }; - }); - - test('branchId set, no prior output: names the Branch in the create, and issues no PATCH', async () => { - const result = await reconcile(state, { - news: { projectId: 'proj-1', name: 'db', region: 'us-east-1', branchId: 'br-1' }, - output: undefined, - }); - - expect(result).toEqual({ id: 'db-created', name: 'db' }); - expect(state.calls).toEqual([ - { - method: 'POST', - path: '/v1/databases', - body: { projectId: 'proj-1', name: 'db', region: 'us-east-1', branchId: 'br-1' }, - }, - ]); - }); - - test('isDefault set: rides the same create body', async () => { - await reconcile(state, { - news: { - projectId: 'proj-1', - name: 'db', - region: 'us-east-1', - branchId: 'br-1', - isDefault: true, - }, - output: undefined, - }); - - expect(state.calls[0]?.body).toEqual({ - projectId: 'proj-1', - name: 'db', - region: 'us-east-1', - isDefault: true, - branchId: 'br-1', - }); - }); - - // The PATCH survives here only: nothing was created, so nothing can be - // stranded, and it's what moves a drifted database back onto its Branch. - test('branchId set, prior output exists: observes, and still PATCHes (idempotent/self-healing)', async () => { - state.observed = { id: 'db-existing', name: 'db' }; - - const result = await reconcile(state, { - news: { projectId: 'proj-1', name: 'db', region: 'us-east-1', branchId: 'br-1' }, - output: { id: 'db-existing', name: 'db' }, - }); - - expect(result).toEqual({ id: 'db-existing', name: 'db' }); - expect(state.calls.map((c) => c.method)).toEqual(['GET', 'PATCH']); - expect(state.calls[1]).toEqual({ - method: 'PATCH', - path: '/v1/databases/{databaseId}', - body: { branchId: 'br-1' }, - }); - }); - - test('branchId unset, no prior output: creates without one and issues no PATCH', async () => { - const result = await reconcile(state, { - news: { projectId: 'proj-1', name: 'db', region: 'us-east-1' }, - output: undefined, - }); - - expect(result).toEqual({ id: 'db-created', name: 'db' }); - expect(state.calls).toEqual([ - { - method: 'POST', - path: '/v1/databases', - body: { projectId: 'proj-1', name: 'db', region: 'us-east-1' }, - }, - ]); - }); - - test('branchId unset, prior output exists: observes and issues no PATCH', async () => { - state.observed = { id: 'db-existing', name: 'db' }; - - const result = await reconcile(state, { - news: { projectId: 'proj-1', name: 'db', region: 'us-east-1' }, - output: { id: 'db-existing', name: 'db' }, - }); - - expect(result).toEqual({ id: 'db-existing', name: 'db' }); - expect(state.calls.map((c) => c.method)).toEqual(['GET']); - expect(state.calls.filter((c) => c.method === 'PATCH')).toHaveLength(0); - }); -}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/EnvironmentVariable.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/EnvironmentVariable.test.ts deleted file mode 100644 index d374a1d05..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/EnvironmentVariable.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { beforeEach, describe, expect, test } from 'bun:test'; -import * as Cause from 'effect/Cause'; -import * as Effect from 'effect/Effect'; -import { type ManagementApiClient, ManagementClient } from '../client.ts'; -import { - EnvironmentVariable, - EnvironmentVariableProvider, -} from '../compute/EnvironmentVariable.ts'; - -interface RecordedCall { - method: 'GET' | 'POST' | 'PATCH'; - path: string; - body?: unknown; - query?: unknown; -} - -interface FakeState { - calls: RecordedCall[]; - /** Rows the own-row GET /{envVarId} resolves (keyed by id); absent → 404. */ - byId: Record; - /** What the list GET (project, class, key[, branchId]) returns as its `data` array. */ - listMatch: { id: string; branchId?: string | null }[]; -} - -const okResponse = (data: T, status = 200) => ({ - data, - error: undefined, - response: new Response(null, { status }), -}); - -const notFoundResponse = () => ({ - data: undefined, - error: undefined, - response: new Response(null, { status: 404 }), -}); - -/** - * A stubbed `ManagementApiClient` covering the EnvironmentVariable provider's - * endpoints, recording every call — the ComputeService.test.ts idiom. `as - * unknown as ManagementApiClient` is acceptable here (test file — exempt from - * the no-bare-cast rule). - */ -const fakeClient = (state: FakeState): ManagementApiClient => { - const GET = ( - path: string, - init: { params?: { path?: { envVarId?: string }; query?: Record } } = {}, - ) => { - state.calls.push({ method: 'GET', path, query: init.params?.query }); - if (path === '/v1/environment-variables/{envVarId}') { - const id = init.params?.path?.envVarId ?? ''; - const row = state.byId[id]; - return Promise.resolve(row ? okResponse(row) : notFoundResponse()); - } - if (path === '/v1/environment-variables') { - return Promise.resolve(okResponse({ data: state.listMatch })); - } - throw new Error(`fakeClient: unexpected GET ${path}`); - }; - - const POST = (path: string, init: { body?: Record } = {}) => { - state.calls.push({ method: 'POST', path, body: init.body }); - return Promise.resolve( - okResponse({ data: { id: 'ev-created', key: String(init.body?.['key']) } }, 201), - ); - }; - - const PATCH = (path: string, init: { body?: Record } = {}) => { - state.calls.push({ method: 'PATCH', path, body: init.body }); - return Promise.resolve(okResponse({ ok: true })); - }; - - return { GET, POST, PATCH } as unknown as ManagementApiClient; -}; - -const getService = (state: FakeState) => - Effect.runPromise( - EnvironmentVariable.Provider.pipe( - Effect.provide(EnvironmentVariableProvider()), - Effect.provideService(ManagementClient, fakeClient(state)), - ), - ); - -const reconcile = async ( - state: FakeState, - input: { - news: Record; - output?: { id: string; key: string } | undefined; - }, -) => { - const svc = await getService(state); - return Effect.runPromise(svc.reconcile(input as unknown as Parameters[0])); -}; - -const reconcileExit = async ( - state: FakeState, - input: { news: Record; output?: { id: string; key: string } | undefined }, -) => { - const svc = await getService(state); - return Effect.runPromiseExit( - svc.reconcile(input as unknown as Parameters[0]), - ); -}; - -describe('EnvironmentVariable reconcile — restricted adoption (ADR-0029)', () => { - let state: FakeState; - - beforeEach(() => { - state = { calls: [], byId: {}, listMatch: [] }; - }); - - test('own prior row (output.id still exists): PATCHes it, no adoption GET-list', async () => { - state.byId['ev-mine'] = { id: 'ev-mine', key: 'COMPOSER_INGEST_STRIPEKEY' }; - - const result = await reconcile(state, { - news: { projectId: 'proj-1', key: 'COMPOSER_INGEST_STRIPEKEY', value: 'STRIPE_SECRET_KEY' }, - output: { id: 'ev-mine', key: 'COMPOSER_INGEST_STRIPEKEY' }, - }); - - expect(result).toEqual({ id: 'ev-mine', key: 'COMPOSER_INGEST_STRIPEKEY' }); - // GET the own row, then PATCH it — never the (project,class,key) adoption list. - expect(state.calls.map((c) => c.method)).toEqual(['GET', 'PATCH']); - expect(state.calls.filter((c) => c.path === '/v1/environment-variables')).toHaveLength(0); - }); - - test('a poison key with a pre-existing platform row is adopted and PATCHed', async () => { - state.listMatch = [{ id: 'ev-poison' }]; - - const result = await reconcile(state, { - news: { projectId: 'proj-1', key: 'DATABASE_URL', value: '-' }, - output: undefined, - }); - - expect(result).toEqual({ id: 'ev-poison', key: 'DATABASE_URL' }); - expect(state.calls.map((c) => c.method)).toEqual(['GET', 'PATCH']); - expect(state.calls.filter((c) => c.method === 'POST')).toHaveLength(0); - }); - - test('a COMPOSER_ key with a pre-existing row it has no state for fails loudly, never overwrites', async () => { - state.listMatch = [{ id: 'ev-foreign' }]; - - const exit = await reconcileExit(state, { - news: { projectId: 'proj-1', key: 'COMPOSER_INGEST_STRIPEKEY', value: 'STRIPE_SECRET_KEY' }, - output: undefined, - }); - - expect(exit._tag).toBe('Failure'); - if (exit._tag === 'Failure') { - expect(Cause.pretty(exit.cause)).toContain('reserved COMPOSER_ key'); - } - // It observed the collision, then refused — no PATCH, no POST. - expect(state.calls.filter((c) => c.method === 'PATCH')).toHaveLength(0); - expect(state.calls.filter((c) => c.method === 'POST')).toHaveLength(0); - }); - - test('a COMPOSER_ key with no pre-existing row creates it', async () => { - state.listMatch = []; - - const result = await reconcile(state, { - news: { projectId: 'proj-1', key: 'COMPOSER_INGEST_STRIPEKEY', value: 'STRIPE_SECRET_KEY' }, - output: undefined, - }); - - expect(result).toEqual({ id: 'ev-created', key: 'COMPOSER_INGEST_STRIPEKEY' }); - const post = state.calls.find((c) => c.method === 'POST'); - expect(post?.body).toMatchObject({ - projectId: 'proj-1', - key: 'COMPOSER_INGEST_STRIPEKEY', - value: 'STRIPE_SECRET_KEY', - }); - expect(state.calls.filter((c) => c.method === 'PATCH')).toHaveLength(0); - }); -}); - -describe('EnvironmentVariable reconcile — branch-scoped collision check', () => { - let state: FakeState; - - beforeEach(() => { - state = { calls: [], byId: {}, listMatch: [] }; - }); - - const previewNews = { - projectId: 'proj-1', - key: 'COMPOSER_WEB_PORT', - value: '3000', - class: 'preview', - branchId: 'br-mine', - }; - - test("a sibling preview branch's row with the same key is not a collision — this branch's row is created", async () => { - // The fake ignores query filters, simulating a server that returned the - // sibling row anyway — the client-side scope comparison must exclude it. - state.listMatch = [{ id: 'ev-sibling', branchId: 'br-other' }]; - - const result = await reconcile(state, { news: previewNews, output: undefined }); - - expect(result).toEqual({ id: 'ev-created', key: 'COMPOSER_WEB_PORT' }); - const post = state.calls.find((c) => c.method === 'POST'); - expect(post?.body).toMatchObject({ - projectId: 'proj-1', - class: 'preview', - key: 'COMPOSER_WEB_PORT', - branchId: 'br-mine', - }); - expect(state.calls.filter((c) => c.method === 'PATCH')).toHaveLength(0); - }); - - test('the adoption list query narrows server-side to the target branch', async () => { - await reconcile(state, { news: previewNews, output: undefined }); - - const list = state.calls.find((c) => c.path === '/v1/environment-variables'); - expect(list?.query).toEqual({ - projectId: 'proj-1', - class: 'preview', - key: 'COMPOSER_WEB_PORT', - branchId: 'br-mine', - }); - }); - - test('a project-level preview template (branchId null) is not a collision for a branch write', async () => { - state.listMatch = [{ id: 'ev-template', branchId: null }]; - - const result = await reconcile(state, { news: previewNews, output: undefined }); - - expect(result).toEqual({ id: 'ev-created', key: 'COMPOSER_WEB_PORT' }); - expect(state.calls.filter((c) => c.method === 'PATCH')).toHaveLength(0); - }); - - test("an untracked row on this deploy's own branch still fails loudly", async () => { - state.listMatch = [{ id: 'ev-foreign', branchId: 'br-mine' }]; - - const exit = await reconcileExit(state, { news: previewNews, output: undefined }); - - expect(exit._tag).toBe('Failure'); - if (exit._tag === 'Failure') { - expect(Cause.pretty(exit.cause)).toContain('reserved COMPOSER_ key'); - expect(Cause.pretty(exit.cause)).toContain('branch "br-mine"'); - } - expect(state.calls.filter((c) => c.method === 'PATCH')).toHaveLength(0); - expect(state.calls.filter((c) => c.method === 'POST')).toHaveLength(0); - }); - - test("a poison key adopts this branch's own row, never a sibling branch's", async () => { - state.listMatch = [ - { id: 'ev-db-sibling', branchId: 'br-other' }, - { id: 'ev-db-mine', branchId: 'br-mine' }, - ]; - - const result = await reconcile(state, { - news: { ...previewNews, key: 'DATABASE_URL', value: '-' }, - output: undefined, - }); - - expect(result).toEqual({ id: 'ev-db-mine', key: 'DATABASE_URL' }); - const patch = state.calls.find((c) => c.method === 'PATCH'); - expect(patch).toBeDefined(); - expect(state.calls.filter((c) => c.method === 'POST')).toHaveLength(0); - }); -}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/database-url-claim.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/database-url-claim.test.ts new file mode 100644 index 000000000..56b202b3c --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/database-url-claim.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from 'bun:test'; +import * as Effect from 'effect/Effect'; +import type { ManagementApiClient } from '../client.ts'; +import { ManagementClient } from '../client.ts'; +import { claimDatabaseUrlKeys } from '../database-url-claim.ts'; +import { PrismaApiError } from '../http.ts'; + +interface PostCall { + readonly path: string; + readonly body: Record; +} + +interface FakeState { + readonly posts: PostCall[]; + /** `${key}:${class}` combinations the platform already holds — a create for one 409s. */ + readonly existing: ReadonlySet; + /** When set, every create returns this status instead of 201. */ + readonly failWith?: number; +} + +const newFakeState = (overrides: Partial = {}): FakeState => ({ + posts: [], + existing: new Set(), + ...overrides, +}); + +/** + * A stubbed `ManagementApiClient` covering only `POST + * /v1/environment-variables`. `as any as ManagementApiClient` is acceptable + * here (test file — exempt from the no-bare-cast rule): the fake's shape + * already guarantees the safety a hand-written openapi-fetch signature would. + */ +const fakeClient = (state: FakeState): ManagementApiClient => { + const POST = (path: string, init: { body?: Record } = {}) => { + if (path !== '/v1/environment-variables') { + throw new Error(`fakeClient: unexpected POST ${path}`); + } + const body = init.body ?? {}; + state.posts.push({ path, body }); + + if (state.failWith !== undefined) { + return Promise.resolve({ + data: undefined, + error: { message: 'stubbed failure' }, + response: new Response(null, { status: state.failWith }), + }); + } + if (state.existing.has(`${String(body['key'])}:${String(body['class'])}`)) { + return Promise.resolve({ + data: undefined, + error: { message: 'A variable with this key already exists in this environment.' }, + response: new Response(null, { status: 409 }), + }); + } + return Promise.resolve({ + data: { data: { id: `env-${state.posts.length}` } }, + error: undefined, + response: new Response(null, { status: 201 }), + }); + }; + + // biome-ignore lint/suspicious/noExplicitAny: test stub — see the doc comment above. + return { POST } as any as ManagementApiClient; +}; + +const run = (projectId: string, state: FakeState) => + Effect.runPromise( + claimDatabaseUrlKeys(projectId).pipe( + Effect.provideService(ManagementClient, fakeClient(state)), + ), + ); + +describe('claimDatabaseUrlKeys', () => { + test('creates both keys in both classes at project level, with a value that cannot connect', async () => { + const state = newFakeState(); + + await run('proj_1', state); + + expect(state.posts.map((p) => p.body)).toEqual([ + { projectId: 'proj_1', class: 'production', key: 'DATABASE_URL', value: '-' }, + { projectId: 'proj_1', class: 'preview', key: 'DATABASE_URL', value: '-' }, + { projectId: 'proj_1', class: 'production', key: 'DATABASE_URL_POOLED', value: '-' }, + { projectId: 'proj_1', class: 'preview', key: 'DATABASE_URL_POOLED', value: '-' }, + ]); + // Project-level rows only: a branch id would scope the claim to one branch + // and leave every other stage's preview unclaimed. + for (const post of state.posts) expect(post.body['branchId']).toBeUndefined(); + }); + + // A row already on the platform is the platform's own system-managed one, or + // one an earlier deploy claimed. Either way it stays exactly as it is: the + // 409 is swallowed and no PATCH or DELETE follows. + test('a 409 on one key is skipped, and the remaining claims still run', async () => { + const state = newFakeState({ existing: new Set(['DATABASE_URL:production']) }); + + await run('proj_1', state); + + expect(state.posts).toHaveLength(4); + expect(state.posts.every((p) => p.path === '/v1/environment-variables')).toBe(true); + }); + + test('every key already present is a complete no-op — four creates, four 409s, nothing else', async () => { + const state = newFakeState({ + existing: new Set([ + 'DATABASE_URL:production', + 'DATABASE_URL:preview', + 'DATABASE_URL_POOLED:production', + 'DATABASE_URL_POOLED:preview', + ]), + }); + + await expect(run('proj_1', state)).resolves.toBeUndefined(); + expect(state.posts).toHaveLength(4); + }); + + test('any other API error fails, carrying the status — the deploy must not proceed unclaimed', async () => { + const state = newFakeState({ failWith: 422 }); + + const exit = await Effect.runPromise( + claimDatabaseUrlKeys('proj_1').pipe( + Effect.provideService(ManagementClient, fakeClient(state)), + Effect.flip, + ), + ); + + expect(exit).toBeInstanceOf(PrismaApiError); + expect(exit.status).toBe(422); + // Failed on the first claim: the rest are never attempted. + expect(state.posts).toHaveLength(1); + }); + + test('no Management API client in context — the local target — claims nothing', async () => { + await expect(Effect.runPromise(claimDatabaseUrlKeys('local'))).resolves.toBeUndefined(); + }); +}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/buckets/Bucket.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/buckets/Bucket.ts index e688eb045..e30af5718 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/buckets/Bucket.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/buckets/Bucket.ts @@ -17,10 +17,14 @@ export interface BucketAttributes { name: string; } -export type Bucket = Resource<'Prisma.Bucket', BucketProps, BucketAttributes>; +export const BUCKET_TYPE_ID = 'PrismaComposer.Bucket'; +/** The type-id Composer's own bucket resource persisted rows under before upstream adoption claimed the `Prisma.*` namespace. */ +export const BUCKET_LEGACY_TYPE_ID = 'Prisma.Bucket'; + +export type Bucket = Resource; /** A Prisma **Object Store bucket** inside a project. */ -export const Bucket = Resource('Prisma.Bucket'); +export const Bucket = Resource(BUCKET_TYPE_ID, { aliases: [BUCKET_LEGACY_TYPE_ID] }); export const BucketProvider = () => Provider.effect( diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/buckets/BucketKey.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/buckets/BucketKey.ts index 546ef16d9..9a48e29b0 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/buckets/BucketKey.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/buckets/BucketKey.ts @@ -37,10 +37,16 @@ export interface BucketKeyAttributes { bucketName: string; } -export type BucketKey = Resource<'Prisma.BucketKey', BucketKeyProps, BucketKeyAttributes>; +export const BUCKET_KEY_TYPE_ID = 'PrismaComposer.BucketKey'; +/** The type-id Composer's own bucket-key resource persisted rows under before upstream adoption claimed the `Prisma.*` namespace. */ +export const BUCKET_KEY_LEGACY_TYPE_ID = 'Prisma.BucketKey'; + +export type BucketKey = Resource; /** A **bucket access key** for a Prisma Object Store bucket — yields the S3 credentials. */ -export const BucketKey = Resource('Prisma.BucketKey'); +export const BucketKey = Resource(BUCKET_KEY_TYPE_ID, { + aliases: [BUCKET_KEY_LEGACY_TYPE_ID], +}); export const BucketKeyProvider = () => Provider.effect( diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/builds/reporter.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/builds/reporter.ts index bedee97fc..52652971d 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/builds/reporter.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/builds/reporter.ts @@ -33,7 +33,9 @@ import type { RunReporter, } from '@internal/core/config'; import { createManagementApiClient } from '@prisma/management-api-sdk'; -import { MANAGEMENT_API_ORIGIN, type ManagementApiClient } from '../client.ts'; +import * as Effect from 'effect/Effect'; +import type { ManagementApiClient } from '../client.ts'; +import { managementApiBaseUrl } from '../credentials.ts'; import { type BuildsApi, buildsApi, type UpdateBuildBody } from './api.ts'; import { BUILD_ID_ENV } from './resources.ts'; import { resolveRunIdentity } from './run-identity.ts'; @@ -123,7 +125,7 @@ async function beginSession( injected ?? createManagementApiClient({ token: token ?? '', - baseUrl: options.origin ?? MANAGEMENT_API_ORIGIN, + baseUrl: options.origin ?? Effect.runSync(managementApiBaseUrl(options.env)), }), warn, }); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/client.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/client.ts index 57488a29b..ac6f94905 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/client.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/client.ts @@ -1,15 +1,13 @@ import { createManagementApiClient } from '@prisma/management-api-sdk'; +import type * as Config from 'effect/Config'; import * as Context from 'effect/Context'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import * as Redacted from 'effect/Redacted'; -import { PrismaCredentials } from './credentials.ts'; +import { managementApiBaseUrl, PrismaCredentials } from './credentials.ts'; export type ManagementApiClient = ReturnType; -/** The origin every Management API call targets — also the origin the hosted Alchemy state API lives under. */ -export const MANAGEMENT_API_ORIGIN = 'https://api.prisma.io'; - /** * The typed Prisma Management API client, built once from the resolved * credentials. Providers yield this in their outer Effect and call it inside @@ -19,16 +17,20 @@ export class ManagementClient extends Context.Service => +}): Layer.Layer => Layer.effect( ManagementClient, Effect.gen(function* () { const { token } = yield* PrismaCredentials; - return createManagementApiClient({ - token: Redacted.value(token), - baseUrl: options?.apiOrigin ?? MANAGEMENT_API_ORIGIN, - }); + const baseUrl = options?.apiOrigin ?? (yield* managementApiBaseUrl()); + return createManagementApiClient({ token: Redacted.value(token), baseUrl }); }), ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/ComputeService.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/ComputeService.ts deleted file mode 100644 index d07cb6ac2..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/ComputeService.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { Resource } from 'alchemy'; -import * as Provider from 'alchemy/Provider'; -import * as Effect from 'effect/Effect'; -import * as Schedule from 'effect/Schedule'; -import { ManagementClient } from '../client.ts'; -import { call, callOptional, callVoid, type PrismaApiError } from '../http.ts'; - -/** - * Stopping a deployment before the app that owns it can be - * deleted is asynchronous on the platform's side: DELETE can 409 with this - * message while the deployment is still winding down. Retrying blindly on - * every API error would mask real failures (bad auth, a genuinely conflicting - * state, etc.), so this only matches the platform's specific "not delete-safe - * yet" wording — everything else fails immediately, as before. - */ -export const isDeleteNotSafeYet = (error: PrismaApiError): boolean => - error.message.includes('did not reach a delete-safe state'); - -/** - * Backs off exponentially from 2s, capped at 5 minutes total — long enough - * for the platform to finish stopping the deployment, short enough to still - * fail loudly (rather than hang forever) if it never does. - */ -export const deleteSafeRetrySchedule = Schedule.exponential('2 seconds', 2).pipe( - Schedule.upTo({ duration: '5 minutes' }), -); - -/** Every region Prisma Compute serves — the runtime source of truth; `ComputeRegion` is derived from it so the two can never drift. */ -export const COMPUTE_REGIONS = [ - 'us-east-1', - 'us-west-1', - 'eu-west-3', - 'eu-central-1', - 'ap-northeast-1', - 'ap-southeast-1', -] as const; - -export type ComputeRegion = (typeof COMPUTE_REGIONS)[number]; - -export interface ComputeServiceProps { - /** The project that will own this compute service. */ - projectId: string; - name: string; - region?: ComputeRegion; - /** When set, the Branch this compute service is attached to (named-stage deploys). */ - branchId?: string; -} - -export interface ComputeServiceAttributes { - id: string; - name: string; - endpointDomain?: string; -} - -export type ComputeService = Resource< - 'Prisma.ComputeService', - ComputeServiceProps, - ComputeServiceAttributes ->; - -/** A Prisma **Compute service** — the stable app identity behind a project. */ -export const ComputeService = Resource('Prisma.ComputeService'); - -export const ComputeServiceProvider = () => - Provider.effect( - ComputeService, - Effect.gen(function* () { - const client = yield* ManagementClient; - - return { - stables: ['id'], - list: () => Effect.succeed([] as ComputeServiceAttributes[]), - reconcile: Effect.fn(function* ({ news, output }) { - // Observe — an app is only findable by its saved id. - const observed = output?.id - ? yield* callOptional(() => - client.GET('/v1/apps/{appId}', { - params: { path: { appId: output.id } }, - }), - ) - : undefined; - if (observed) { - return { - id: observed.data.id, - name: observed.data.name, - endpointDomain: observed.data.appEndpointDomain, - }; - } - - // Create on the target Branch via the create body — NOT a later PATCH. - // App names are unique per Branch, so a create without a branchId - // lands on the default Branch and collides with the same-named - // production app there (a live-deploy find). - const created = yield* call(() => - client.POST('/v1/apps', { - body: { - displayName: news.name, - projectId: news.projectId, - ...(news.region && { regionId: news.region }), - ...(news.branchId !== undefined && { branchId: news.branchId }), - }, - }), - ); - return { - id: created.data.id, - name: created.data.name, - endpointDomain: created.data.appEndpointDomain, - }; - }), - delete: Effect.fn(function* ({ output }) { - yield* callVoid(() => - client.DELETE('/v1/apps/{appId}', { - params: { path: { appId: output.id } }, - }), - ).pipe(Effect.retry({ schedule: deleteSafeRetrySchedule, while: isDeleteNotSafeYet })); - }), - read: Effect.fn(function* ({ output }) { - if (!output?.id) return undefined; - const s = yield* callOptional(() => - client.GET('/v1/apps/{appId}', { - params: { path: { appId: output.id } }, - }), - ); - return s - ? { id: s.data.id, name: s.data.name, endpointDomain: s.data.appEndpointDomain } - : undefined; - }), - }; - }), - ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/Deployment.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/Deployment.ts deleted file mode 100644 index 2be4895f9..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/Deployment.ts +++ /dev/null @@ -1,163 +0,0 @@ -import * as fs from 'node:fs'; -import { Resource } from 'alchemy'; -import * as Provider from 'alchemy/Provider'; -import * as Effect from 'effect/Effect'; -import * as Schedule from 'effect/Schedule'; -import { ManagementClient } from '../client.ts'; -import { call, callOptional, PrismaApiError } from '../http.ts'; -import type { EnvironmentVariable } from './EnvironmentVariable.ts'; - -export interface DeploymentProps { - /** The app this deployment targets. */ - computeServiceId: string; - /** Path to a PREBUILT artifact (tar.gz) to upload. */ - artifactPath: string; - /** - * sha256 of the artifact. Part of the props so a new build (new hash) - * registers as a change and forces a fresh deployment; a byte-identical - * `artifactPath` alone would diff as a no-op. - */ - artifactHash: string; - /** - * HTTP port the app listens on. Compute routes external HTTP to it - * (`portMapping.http`); without it the endpoint has no route and 404s. - */ - port?: number; - /** - * The env-var records this deployment boots with. The provider never reads - * this — PDP materializes the branch's ConfigVariables into the deployment - * itself at deployment-create. Its only job is the Alchemy dependency edge: - * order this Deployment after those writes, and force a new deployment when - * any upstream value changes (the environment edge that kills PRO-211 — - * see docs/design/05-prisma-cloud/alchemy-lowering.md). - */ - environment?: readonly EnvironmentVariable[]; -} - -export interface DeploymentAttributes { - deploymentId: string; - deployedUrl?: string; -} - -export type Deployment = Resource<'Prisma.Deployment', DeploymentProps, DeploymentAttributes>; - -/** - * A **deployment** of a Prisma app — creates a deployment, uploads - * its artifact, starts the VM, waits for it to run, then promotes it to the - * app's stable endpoint. - */ -export const Deployment = Resource('Prisma.Deployment'); - -export const DeploymentProvider = () => - Provider.effect( - Deployment, - Effect.gen(function* () { - const client = yield* ManagementClient; - - // `start` is asynchronous — the VM is not running when it returns. Poll - // the deployment until its status is `running` before promoting, or the - // promote call fails with 409 "not running". - const waitForRunning = (deploymentId: string) => - call(() => - client.GET('/v1/deployments/{deploymentId}', { - params: { path: { deploymentId } }, - }), - ).pipe( - Effect.flatMap((v) => - v.data.status === 'running' - ? Effect.void - : Effect.fail( - new PrismaApiError({ - status: 409, - message: `deployment ${deploymentId} is ${v.data.status}, not running`, - }), - ), - ), - Effect.retry(Schedule.spaced('2 seconds').pipe(Schedule.upTo({ duration: '2 minutes' }))), - ); - - return { - stables: [], - list: () => Effect.succeed([] as DeploymentAttributes[]), - reconcile: Effect.fn(function* ({ news }) { - // Every reconcile ships a new deployment: create → upload → start → - // wait-until-running → promote. There is no observe short-circuit — - // a props change (a new artifactHash) is what brought us here, so - // returning the previous deployment would strand the new build. - const created = yield* call(() => - client.POST('/v1/apps/{appId}/deployments', { - params: { path: { appId: news.computeServiceId } }, - body: news.port !== undefined ? { portMapping: { http: news.port } } : {}, - }), - ); - const deploymentId = created.data.id; - - if (created.data.uploadUrl) { - const uploadUrl = created.data.uploadUrl; - const artifact = yield* Effect.try({ - try: () => fs.readFileSync(news.artifactPath), - catch: (cause) => - new PrismaApiError({ - status: 0, - message: `failed to read artifact ${news.artifactPath}: ${String(cause)}`, - }), - }); - yield* Effect.tryPromise({ - try: async () => { - const res = await fetch(uploadUrl, { method: 'PUT', body: artifact }); - if (!res.ok) { - throw new PrismaApiError({ - status: res.status, - message: `artifact upload failed: ${res.status} ${res.statusText}`, - }); - } - }, - catch: (cause) => - cause instanceof PrismaApiError - ? cause - : new PrismaApiError({ status: 0, message: String(cause) }), - }); - } - - yield* call(() => - client.POST('/v1/deployments/{deploymentId}/start', { - params: { path: { deploymentId } }, - }), - ); - - yield* waitForRunning(deploymentId); - - // The serving domain only resolves to the running deployment's - // region once promoted; the app's create-time `appEndpointDomain` - // is a placeholder. Promote returns the live one. - const promoted = yield* call(() => - client.POST('/v1/apps/{appId}/promote', { - params: { path: { appId: news.computeServiceId } }, - body: { deploymentId }, - }), - ); - - const deployedUrl = promoted.data.appEndpointDomain; - return { deploymentId, ...(deployedUrl !== undefined && { deployedUrl }) }; - }), - delete: Effect.fn(function* () { - // A promoted deployment is retained as the app's deploy history; - // deleting the ComputeService itself tears down its deployments. - }), - read: Effect.fn(function* ({ output }) { - if (!output?.deploymentId) return undefined; - const v = yield* callOptional(() => - client.GET('/v1/deployments/{deploymentId}', { - params: { path: { deploymentId: output.deploymentId } }, - }), - ); - return v - ? { - deploymentId: v.data.id, - ...(v.data.previewDomain && { deployedUrl: v.data.previewDomain }), - } - : undefined; - }), - }; - }), - ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/EnvironmentVariable.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/EnvironmentVariable.ts deleted file mode 100644 index a66f3429a..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/EnvironmentVariable.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { blindCast } from '@internal/foundation/casts'; -import { Resource } from 'alchemy'; -import * as Provider from 'alchemy/Provider'; -import * as Effect from 'effect/Effect'; -import { ManagementClient } from '../client.ts'; -import { call, callOptional, callVoid } from '../http.ts'; - -export type EnvironmentClass = 'production' | 'preview'; - -export interface EnvironmentVariableProps { - /** The project this variable belongs to. */ - projectId: string; - /** Variable name, e.g. `AUTH_URL`. */ - key: string; - /** Variable value. Stored encrypted; not readable back. */ - value: string; - /** Which environment the value applies to. Defaults to `production`. */ - class?: EnvironmentClass; - /** Set only for a preview-branch override. */ - branchId?: string; -} - -export interface EnvironmentVariableAttributes { - id: string; - key: string; -} - -export type EnvironmentVariable = Resource< - 'Prisma.EnvironmentVariable', - EnvironmentVariableProps, - EnvironmentVariableAttributes ->; - -/** - * A project-scoped **environment variable** that Compute injects into the - * project's services from their attached branch (e.g. wiring one module's URL into - * another). - */ -export const EnvironmentVariable = Resource('Prisma.EnvironmentVariable'); - -export const EnvironmentVariableProvider = () => - Provider.effect( - EnvironmentVariable, - Effect.gen(function* () { - const client = yield* ManagementClient; - - return { - stables: ['id'], - list: () => Effect.succeed([]), - reconcile: Effect.fn(function* ({ news, output }) { - const cls = news.class ?? 'production'; - // Value is write-only, so we PATCH, never diff. Adopt our own prior - // row (output.id), or a pre-existing poison-key row (DATABASE_URL(_POOLED), - // platform-seeded). Any other untracked match is a COMPOSER_ collision we - // refuse to overwrite (see the throw below). - let id = output?.id; - if (id !== undefined) { - const priorId = id; - const mine = yield* callOptional(() => - client.GET('/v1/environment-variables/{envVarId}', { - params: { path: { envVarId: priorId } }, - }), - ); - if (!mine) id = undefined; - } - if (id === undefined) { - const match = yield* call(() => - client.GET('/v1/environment-variables', { - params: { - query: blindCast< - never, - 'openapi-fetch mistypes this query as never; the endpoint accepts projectId/class/key/branchId' - >({ - projectId: news.projectId, - class: cls, - key: news.key, - ...(news.branchId !== undefined ? { branchId: news.branchId } : {}), - }), - }, - }), - ); - // Only the exact write scope is a collision; "branchId is null" is not expressible as a query filter, hence the local compare. - const rows = blindCast< - { data?: readonly { id: string; branchId?: string | null }[] }, - 'query-never defeats response inference; project to the fields reconcile reads' - >(match).data; - const matchId = rows?.find( - (row) => (row.branchId ?? null) === (news.branchId ?? null), - )?.id; - if (matchId !== undefined) { - const isPoison = news.key === 'DATABASE_URL' || news.key === 'DATABASE_URL_POOLED'; - if (!isPoison) { - const scope = - news.branchId !== undefined - ? `class "${cls}", branch "${news.branchId}"` - : `class "${cls}"`; - throw new Error( - `EnvironmentVariable "${news.key}" (project "${news.projectId}", ${scope}) ` + - 'exists but is untracked in this deploy state — refusing to overwrite a reserved ' + - "COMPOSER_ key. Restore this deploy's hosted state, or remove the variable to let " + - 'this deploy recreate it.', - ); - } - id = matchId; - } - } - if (id !== undefined) { - const targetId = id; - yield* call(() => - client.PATCH('/v1/environment-variables/{envVarId}', { - params: { path: { envVarId: targetId } }, - body: { value: news.value }, - }), - ); - return { id, key: news.key }; - } - - const created = yield* call(() => - client.POST('/v1/environment-variables', { - body: { - projectId: news.projectId, - class: cls, - key: news.key, - value: news.value, - ...(news.branchId ? { branchId: news.branchId } : {}), - }, - }), - ); - return { id: created.data.id, key: created.data.key }; - }), - delete: Effect.fn(function* ({ output }) { - yield* callVoid(() => - client.DELETE('/v1/environment-variables/{envVarId}', { - params: { path: { envVarId: output.id } }, - }), - ); - }), - read: Effect.fn(function* ({ output }) { - if (!output?.id) return undefined; - const v = yield* callOptional(() => - client.GET('/v1/environment-variables/{envVarId}', { - params: { path: { envVarId: output.id } }, - }), - ); - return v ? { id: v.data.id, key: v.data.key } : undefined; - }), - }; - }), - ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deploy-fingerprint.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deploy-fingerprint.test.ts new file mode 100644 index 000000000..48bc80deb --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deploy-fingerprint.test.ts @@ -0,0 +1,200 @@ +/** + * The environment fingerprint: the path a deploy hands upstream moves when the + * environment moved and stands still when it did not — and no secret byte, and + * no hash of one, is ever part of it. Which plan action a moved path actually + * produces is proven against upstream's real diff in `deployment-edge.test.ts`; + * this file pins what the fingerprint covers and what the path looks like. + */ + +import { afterAll, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + deployEnvFingerprint, + deployEnvFingerprintMaterial, + type EnvFingerprintEntry, + fingerprintedArtifactPath, + type PointerUpdatedAt, +} from '../deploy-fingerprint.ts'; + +const digestDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deploy-fingerprint-')); +const canonicalPath = path.join(digestDir, 'auth.tar.gz'); +fs.writeFileSync(canonicalPath, 'artifact-bytes'); + +const otherDigestDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deploy-fingerprint-')); +const otherArtifactPath = path.join(otherDigestDir, 'auth.tar.gz'); +fs.writeFileSync(otherArtifactPath, 'other-artifact-bytes'); + +afterAll(() => { + fs.rmSync(digestDir, { recursive: true, force: true }); + fs.rmSync(otherDigestDir, { recursive: true, force: true }); +}); + +/** A service's rows: a config literal, a pointer row, the input document, a generated row, a provider param. */ +const environment: readonly EnvFingerprintEntry[] = [ + { key: 'COMPOSER_AUTH_PORT', value: '3000' }, + { + key: 'COMPOSER_AUTH_TIER', + value: '"@composer-param-pointer:AUTH_TIER"', + pointers: ['AUTH_TIER'], + }, + { + key: 'COMPOSER_AUTH_INPUT', + value: '{"apiKey":{"$secret":"STRIPE_KEY"}}', + pointers: ['STRIPE_KEY'], + }, + { key: 'COMPOSER_AUTH_SESSION_GENERATED', withheld: 'generated:32:true' }, + { key: 'COMPOSER_AUTH_ORIGIN', withheld: 'provider.ORIGIN:auth-svc' }, +]; + +const rotations = + (entries: Record): PointerUpdatedAt => + (name) => + entries[name]; + +const platform = rotations({ + AUTH_TIER: '2026-01-01T00:00:00.000Z', + STRIPE_KEY: '2026-01-02T00:00:00.000Z', +}); + +const pathFor = ( + entries: readonly EnvFingerprintEntry[], + updatedAt: PointerUpdatedAt = platform, + artifactPath: string = canonicalPath, +): string => fingerprintedArtifactPath(artifactPath, deployEnvFingerprint(entries, updatedAt)); + +/** The rows with one entry swapped for `replacement`, matched by key. */ +const withRow = (replacement: EnvFingerprintEntry): readonly EnvFingerprintEntry[] => + environment.map((entry) => (entry.key === replacement.key ? replacement : entry)); + +describe('the fingerprint moves exactly when the environment moved', () => { + test('the same environment and the same artifact give the same path — the deployment is reused', () => { + expect(pathFor(environment)).toBe(pathFor(environment)); + }); + + test('the row ORDER does not move it — the serializer may emit rows in any order', () => { + expect(pathFor([...environment].reverse())).toBe(pathFor(environment)); + }); + + test('a changed config value gives a new path', () => { + expect(pathFor(withRow({ key: 'COMPOSER_AUTH_PORT', value: '8080' }))).not.toBe( + pathFor(environment), + ); + }); + + test('an added row gives a new path', () => { + expect(pathFor([...environment, { key: 'COMPOSER_AUTH_DEBUG', value: 'true' }])).not.toBe( + pathFor(environment), + ); + }); + + test('a removed row gives a new path', () => { + expect(pathFor(environment.slice(1))).not.toBe(pathFor(environment)); + }); + + test('a rotated POINTED variable gives a new path, though every row is byte-identical', () => { + const rotated = rotations({ + AUTH_TIER: '2026-01-01T00:00:00.000Z', + STRIPE_KEY: '2026-06-30T09:15:00.000Z', + }); + expect(pathFor(environment, rotated)).not.toBe(pathFor(environment)); + }); + + test('a re-pointed row (same value shape, different platform variable) gives a new path', () => { + expect( + pathFor( + withRow({ + key: 'COMPOSER_AUTH_INPUT', + value: '{"apiKey":{"$secret":"STRIPE_KEY_2"}}', + pointers: ['STRIPE_KEY_2'], + }), + ), + ).not.toBe(pathFor(environment)); + }); + + test('a rewired withheld row (different producing resources) gives a new path', () => { + expect( + pathFor(withRow({ key: 'COMPOSER_AUTH_ORIGIN', withheld: 'provider.ORIGIN:billing-svc' })), + ).not.toBe(pathFor(environment)); + }); + + test('a changed artifact gives a new path — the canonical path is already content-addressed', () => { + expect(pathFor(environment, platform, otherArtifactPath)).not.toBe(pathFor(environment)); + }); +}); + +describe('no secret value can reach the fingerprint', () => { + // The row set a real service produces for its secret-bearing channels: a + // minted generated value, a dependency connection string, a minted service + // key. The sentinel is what each of those values would be. + const SENTINEL = 'hunter2-correct-horse-battery-staple'; + + const secretBearing: readonly EnvFingerprintEntry[] = [ + { key: 'COMPOSER_AUTH_SESSION_GENERATED', withheld: 'generated:32:true' }, + { key: 'COMPOSER_AUTH_DB_URL', withheld: 'input.db:db-postgres' }, + { key: 'COMPOSER_AUTH_STREAMS_API_KEY', withheld: 'provider.STREAMS_API_KEY:streamskey-auth' }, + { + key: 'COMPOSER_AUTH_INPUT', + value: '{"apiKey":{"$secret":"STRIPE_KEY"}}', + pointers: ['STRIPE_KEY'], + }, + ]; + + // The pointed variable HOLDS the sentinel on the platform; what the deploy + // learns about it is a timestamp, and that is all the lookup can return. + const platformHoldingTheSentinel = rotations({ STRIPE_KEY: '2026-01-02T00:00:00.000Z' }); + + test('the hashed material contains the sentinel nowhere', () => { + const material = deployEnvFingerprintMaterial(secretBearing, platformHoldingTheSentinel); + expect(material).not.toContain(SENTINEL); + // What it DOES contain: the row keys, the pointer NAME, and the timestamp. + expect(material).toContain('COMPOSER_AUTH_DB_URL'); + expect(material).toContain('STRIPE_KEY'); + expect(material).toContain('2026-01-02T00:00:00.000Z'); + }); + + test('the path contains the sentinel nowhere', () => { + expect(pathFor(secretBearing, platformHoldingTheSentinel)).not.toContain(SENTINEL); + }); + + test('a withheld entry has no field a value could be passed in', () => { + const entry: EnvFingerprintEntry = { + key: 'COMPOSER_AUTH_DB_URL', + withheld: 'input.db:db-postgres', + }; + // @ts-expect-error a withheld row cannot also carry a value — the union forbids it. + const rejected: EnvFingerprintEntry = { ...entry, value: SENTINEL }; + expect(rejected.key).toBe('COMPOSER_AUTH_DB_URL'); + }); +}); + +describe('the fingerprinted path', () => { + test('lives beside the canonical artifact, named by the fingerprint, same bytes', () => { + const linked = pathFor(environment); + expect(path.dirname(path.dirname(linked))).toBe(digestDir); + expect(path.basename(path.dirname(linked))).toMatch(/^deploy-env-[0-9a-f]{12}$/); + expect(path.basename(linked)).toBe('auth.tar.gz'); + expect(fs.readFileSync(linked, 'utf8')).toBe('artifact-bytes'); + }); + + test("is a plain string, never an Output — upstream's replacement block must stay resolved", () => { + expect(typeof pathFor(environment)).toBe('string'); + }); + + test("the destroy-run placeholder ('' — no build) passes through untouched", () => { + expect(fingerprintedArtifactPath('', deployEnvFingerprint(environment, platform))).toBe(''); + }); +}); + +describe('dev, where no platform timestamps exist', () => { + const noPlatform: PointerUpdatedAt = () => undefined; + + test('every pointer reads as unknown and the fingerprint is still stable', () => { + expect(pathFor(environment, noPlatform)).toBe(pathFor(environment, noPlatform)); + }); + + test('an unknown timestamp is not the same as a known one — dev never collides with a deploy', () => { + expect(pathFor(environment, noPlatform)).not.toBe(pathFor(environment, platform)); + }); +}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deployment-edge.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deployment-edge.test.ts new file mode 100644 index 000000000..45acfe610 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deployment-edge.test.ts @@ -0,0 +1,284 @@ +/** + * The environment→deployment ordering edge, against the REAL Output machinery + * and upstream's REAL `Prisma.Deployment` provider (an eager-collapse stub + * cannot represent the unresolved half of planning). Two properties on the + * deploy that adds a variable and changes code in one run: every variable + * write and the app are upstream of the deployment, and the artifact + * comparison still runs while the new variable is unresolved (see + * deployment-edge.ts for why only the `app` prop keeps that true). + */ + +import { afterAll, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as Output from 'alchemy/Output'; +import * as Prisma from 'alchemy/Prisma'; +import type * as Provider from 'alchemy/Provider'; +import { Stack } from 'alchemy/Stack'; +import { PlatformServices } from 'alchemy/Util/PlatformServices'; +import { sha256, sha256Object } from 'alchemy/Util/sha256'; +import * as Effect from 'effect/Effect'; +import * as Layer from 'effect/Layer'; +import * as Redacted from 'effect/Redacted'; +import { + deployEnvFingerprint, + type EnvFingerprintEntry, + fingerprintedArtifactPath, +} from '../deploy-fingerprint.ts'; +import { appAfterEnvironment } from '../deployment-edge.ts'; + +/** A stack the resource constructors register into; nothing ever applies it. */ +const stack = { name: 'shop', stage: 'prod', resources: {}, bindings: {}, actions: {} }; + +const registered = (effect: Effect.Effect): A => + Effect.runSync( + effect.pipe(Effect.provideService(Stack, stack as never)) as Effect.Effect, + ); + +const app = registered( + Prisma.App('auth-svc', { project: 'proj-1', displayName: 'auth', regionId: 'us-east-1' }), +); + +/** Two variables: one this deploy already had, one it is adding. */ +const persistedVariable = registered( + Prisma.EnvironmentVariable('COMPOSER_AUTH_PORT-var', { + project: 'proj-1', + class: 'production', + key: 'COMPOSER_AUTH_PORT', + value: Redacted.make('3000'), + }), +); + +const newVariable = registered( + Prisma.EnvironmentVariable('COMPOSER_AUTH_DB_URL-var', { + project: 'proj-1', + class: 'production', + key: 'COMPOSER_AUTH_DB_URL', + value: Redacted.make('postgres://db'), + }), +); + +const environment = [persistedVariable, newVariable]; + +const artifactDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deployment-edge-')); +const artifactPath = path.join(artifactDir, 'auth.tar.gz'); +fs.writeFileSync(artifactPath, 'artifact-generation-2'); + +afterAll(() => { + fs.rmSync(artifactDir, { recursive: true, force: true }); +}); + +/** The deploy hook's props, built by the same helper the descriptor uses. */ +const deploymentProps = (propArtifactPath: string = artifactPath) => ({ + app: appAfterEnvironment(app.appId, environment), + artifactPath: propArtifactPath, + artifactContentType: 'application/gzip', + portMapping: { http: 8080 }, + start: true, + promote: true, +}); + +/** Upstream's own fingerprint for the bytes on disk — its formula, its hashes. */ +const artifactFingerprint = () => + Effect.runPromise( + Effect.gen(function* () { + const digest = yield* sha256(fs.readFileSync(artifactPath)); + return yield* sha256Object({ artifact: digest, contentType: 'application/gzip' }); + }), + ); + +const apiDeployment = { + id: 'dep-1', + type: 'deployment', + url: 'https://api.prisma.io/v1/deployments/dep-1', + foundryVersionId: 'fv-1', + status: 'running', + previewDomain: 'dep-1.preview.prisma.app', + createdAt: '2025-01-01T00:00:00.000Z', +}; + +/** Only the endpoints a diff may touch; a create would be a test failure. */ +const stubClient = { + getDeployment: (id: string) => + id === 'dep-1' ? Effect.succeed(apiDeployment) : Effect.die(`unexpected getDeployment ${id}`), + listAppDeployments: () => Effect.succeed([apiDeployment]), + createAppDeployment: () => Effect.die('a diff must not create a deployment'), +} as unknown as Prisma.PrismaManagementClient; + +// Same `any` leak through Provider.effect's typing the state tests document: +// the stubbed PrismaClient is the only real requirement and it IS provided. +const deploymentService = () => + Effect.runPromise( + Prisma.Deployment.Provider.pipe( + Effect.provide( + Prisma.DeploymentProvider().pipe( + Layer.provide(Layer.succeed(Prisma.PrismaClient, stubClient)), + ), + ), + ) as Effect.Effect, never, never>, + ); + +const persistedOutput = (artifactHash: string) => ({ + deploymentId: 'dep-1', + appId: 'app-1', + foundryVersionId: 'fv-1', + status: 'running', + previewDomain: null, + artifactHash, + appEndpointDomain: 'auth.prisma.app', + createdAt: '2025-01-01T00:00:00.000Z', +}); + +/** + * `news` as the planner hands it over on the deploy that adds a variable: the + * combined `app` expression cannot resolve (the new variable has no state to + * resolve to), every other prop is a value. `olds` are the previous deploy's + * persisted props, where `app` had resolved to the app id. + */ +const diffAgainst = async ( + output: Record, + paths: { oldPath?: string; newPath?: string } = {}, +) => { + const service = await deploymentService(); + if (service.diff === undefined) throw new Error('provider must expose diff'); + return Effect.runPromise( + service + .diff({ + id: 'auth-deploy', + fqn: 'auth-deploy', + instanceId: 'inst-deploy', + olds: { ...deploymentProps(paths.oldPath), app: 'app-1' }, + news: deploymentProps(paths.newPath), + output, + session: undefined, + bindings: [], + } as never) + .pipe(Effect.provide(PlatformServices)) as Effect.Effect, + ); +}; + +describe('appAfterEnvironment — the edge Alchemy actually plans on', () => { + test('every variable AND the app are upstream of the deployment', () => { + const upstream = Output.upstreamAny(deploymentProps()); + expect(Object.keys(upstream).sort()).toEqual( + ['COMPOSER_AUTH_DB_URL-var', 'COMPOSER_AUTH_PORT-var', 'auth-svc'].sort(), + ); + }); + + test('the combined expression still resolves to the app id — not a variable id or tuple', () => { + const resolved = Effect.runSync( + Output.evaluate(appAfterEnvironment(app.appId, environment), { + 'auth-svc': { appId: 'app-123' }, + 'COMPOSER_AUTH_PORT-var': { environmentVariableId: 'var-1' }, + 'COMPOSER_AUTH_DB_URL-var': { environmentVariableId: 'var-2' }, + }) as Effect.Effect, + ); + expect(resolved).toBe('app-123'); + }); + + test('a service with no variables passes the app id straight through', () => { + expect(Object.keys(Output.upstreamAny({ app: appAfterEnvironment(app.appId, []) }))).toEqual([ + 'auth-svc', + ]); + }); + + test('the artifact props are plain values, never expressions', () => { + const props = deploymentProps(); + expect(Output.isOutput(props.artifactPath)).toBe(false); + expect(Output.isOutput(props.artifactContentType)).toBe(false); + expect(Output.isOutput(props.portMapping)).toBe(false); + // The unresolved half is confined to `app`, which is the point. + expect(Output.isOutput(props.app)).toBe(true); + }); +}); + +describe('upstream Deployment.diff while the new variable is still unresolved', () => { + test('a changed artifact plans a REPLACE — the code change is not dropped', async () => { + const diff = await diffAgainst(persistedOutput('the-previous-generations-fingerprint')); + expect(diff).toEqual({ action: 'replace' }); + }); + + test('an identical artifactPath plans no replacement', async () => { + const diff = await diffAgainst(persistedOutput(await artifactFingerprint())); + // An update, not a replace: with the SAME path and bytes, upstream reuses + // the deployment and only re-asserts start/promote. The platform never + // re-reads environment rows into a reused deployment, so a real deploy may + // only reach this plan when its environment is unchanged too — which is + // what folding the environment into the path enforces. + expect(diff).toEqual({ action: 'update' }); + }); +}); + +/** + * What each environment produces as a path, and what upstream plans for it. + * The environment-value-only change is the case that has no other signal at + * all: no Deployment prop moves, and the platform never returns a value — so + * the fingerprint is the ONLY thing that can tell upstream to ship a fresh + * deployment, which the platform requires because it materializes env rows + * only at deployment create (PRO-211). + */ +const envRows = (port: string): readonly EnvFingerprintEntry[] => [ + { key: 'COMPOSER_AUTH_PORT', value: port }, + { + key: 'COMPOSER_AUTH_INPUT', + value: '{"apiKey":{"$secret":"STRIPE_KEY"}}', + pointers: ['STRIPE_KEY'], + }, +]; + +const rotatedAt = (updatedAt: string) => () => updatedAt; + +const pathForEnvironment = ( + entries: readonly EnvFingerprintEntry[], + updatedAt = '2026-01-02T00:00:00.000Z', +): string => + fingerprintedArtifactPath(artifactPath, deployEnvFingerprint(entries, rotatedAt(updatedAt))); + +describe('the environment fingerprint, against upstream diff', () => { + test('nothing changed — upstream reuses the deployment rather than replacing it', async () => { + const unchanged = pathForEnvironment(envRows('3000')); + const diff = await diffAgainst(persistedOutput(await artifactFingerprint()), { + oldPath: unchanged, + newPath: unchanged, + }); + // An update, not a replace: same path, same bytes, so upstream keeps the + // running deployment and only re-asserts start/promote. This is the reuse + // the per-deploy-generation path gave up and the fingerprint restores. + expect(diff).toEqual({ action: 'update' }); + }); + + test('a changed environment VALUE plans a replace, though the bytes are identical', async () => { + const diff = await diffAgainst(persistedOutput(await artifactFingerprint()), { + oldPath: pathForEnvironment(envRows('3000')), + newPath: pathForEnvironment(envRows('8080')), + }); + expect(diff).toEqual({ action: 'replace' }); + }); + + test('a rotated POINTED platform variable plans a replace, though every row is identical', async () => { + const diff = await diffAgainst(persistedOutput(await artifactFingerprint()), { + oldPath: pathForEnvironment(envRows('3000'), '2026-01-02T00:00:00.000Z'), + newPath: pathForEnvironment(envRows('3000'), '2026-06-30T09:15:00.000Z'), + }); + expect(diff).toEqual({ action: 'replace' }); + }); + + test('a changed artifact plans a replace under an unchanged environment', async () => { + const unchanged = pathForEnvironment(envRows('3000')); + const diff = await diffAgainst(persistedOutput('the-previous-artifacts-fingerprint'), { + oldPath: unchanged, + newPath: unchanged, + }); + expect(diff).toEqual({ action: 'replace' }); + }); + + test('the fingerprinted path stays a plain value — the diff never degrades to update', () => { + // The replacement block {portMapping, skipCodeUpload, artifactPath, + // artifactContentType} must be RESOLVED at plan time or upstream returns + // no opinion and the engine falls back to a plain update — the silent + // artifact skip all over again. The fingerprinted path is a string + // computed before lowering, never an Output. + expect(Output.isOutput(pathForEnvironment(envRows('3000')))).toBe(false); + }); +}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts index 82c1dc368..25b9db78e 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts @@ -13,6 +13,9 @@ import * as path from 'node:path'; import * as zlib from 'node:zlib'; import { isWithin } from '@internal/bundle-paths'; +/** The content type every Composer compute artifact is uploaded with (a deterministic tar.gz). */ +export const ARTIFACT_CONTENT_TYPE = 'application/gzip'; + export interface PackageComputeArtifactOptions { /** The service's provision id — namespaces the temp output path. */ readonly id: string; @@ -370,12 +373,12 @@ await main.run(boot.address, () => import(boot.appEntrypoint)); const sha256 = crypto.createHash('sha256').update(gz).digest('hex'); // The output path must be content-addressed AND per-user. Content-addressed - // because `artifactPath` is a Deployment prop: a path that varies per call - // (e.g. mkdtemp) makes every redeploy diff as an update even when the bytes - // are identical, breaking the redeploy-noop guarantee. Per-user because a - // fixed shared dir under os.tmpdir() is owned by whichever OS user creates - // it first — everyone else's writes fail EACCES. Same content → same path - // (noop); new build → new hash → new path (update, as designed). uid is -1 + // so the local dev loop can memoize on it (a converge re-hashes and + // re-extracts nothing when the bytes didn't move) — this is the CANONICAL + // path; the deploy hook derives the environment-fingerprinted path the + // hosted Deployment is handed from it (`fingerprintedArtifactPath`). Per-user + // because a fixed shared dir under os.tmpdir() is owned by whichever OS + // user creates it first — everyone else's writes fail EACCES. uid is -1 // on Windows — still a valid, deterministic directory name. const outDir = path.join( os.tmpdir(), diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/deploy-fingerprint.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/deploy-fingerprint.ts new file mode 100644 index 000000000..c344ba890 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/deploy-fingerprint.ts @@ -0,0 +1,122 @@ +/** + * The environment is folded into `artifactPath`: the artifact is hard-linked + * into a sibling directory named by a hash of the environment. The platform + * materializes env rows into a deployment at create time and never re-reads + * them (PRO-211), and nothing an `EnvironmentVariable` exposes can ride + * upstream `Prisma.Deployment`'s replacement block — so a changed environment + * must move `artifactPath` to ship a new deployment, and an unchanged one + * must not, so the deployment is reused. + * + * No secret ever enters the hash: rows that are secret-free by construction + * (ADR-0042 literals and pointers) contribute their text; every other row is + * `withheld` and contributes only its key and what produces its value. + * Platform variables a row points at (and Composer never writes) contribute + * their `updatedAt`, so an out-of-band rotation redeploys. Accepted blind + * spot: a value re-minted in place by the SAME resources does not move the + * fingerprint — there is no leak-free signal for it at plan time. + * + * Replace this with upstream `Prisma.Deployment`'s `redeployOn` once the + * pinned alchemy has it; the one call site is `descriptors/compute.ts`. + */ +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +/** + * One environment row's contribution to the fingerprint. + * + * `value` is the row's stored text, for rows that are secret-free by + * construction (a JSON config literal, a pointer row naming a platform + * variable, the input document — whose secret and generated leaves are + * pointers). `withheld` is for every other row: it names what produces the + * value (e.g. the upstream resources of an unresolved reference) and there is + * no field a secret could be passed in, which is the point. + * + * `pointers` lists platform variables the row POINTS at and Composer never + * writes — each contributes its `updatedAt` timestamp so an out-of-band + * rotation forces a redeploy. + */ +export type EnvFingerprintEntry = { + readonly key: string; + readonly pointers?: readonly string[]; +} & ( + | { readonly value: string; readonly withheld?: never } + | { readonly withheld: string; readonly value?: never } +); + +/** The pointed platform variable's `updatedAt`, or undefined when it is unknown (dev, or a name the deploy just provisioned). */ +export type PointerUpdatedAt = (name: string) => string | undefined; + +/** + * The exact text the fingerprint hashes — exported so a test can assert what + * is, and is not, in it. Entries are sorted by key so the row order the + * serializer happens to produce cannot move the fingerprint. + */ +export function deployEnvFingerprintMaterial( + entries: readonly EnvFingerprintEntry[], + pointerUpdatedAt: PointerUpdatedAt, +): string { + const rows = entries + .map((entry) => ({ + key: entry.key, + row: [ + entry.key, + entry.value !== undefined ? ['value', entry.value] : ['withheld', entry.withheld], + [...(entry.pointers ?? [])].sort().map((name) => [name, pointerUpdatedAt(name) ?? '?']), + ], + })) + // Keys are unique per row (one env var each), so they are the whole order. + .sort((a, b) => (a.key < b.key ? -1 : 1)) + .map((r) => r.row); + return JSON.stringify(rows); +} + +/** The environment fingerprint: a sha256 hex digest of `deployEnvFingerprintMaterial`. */ +export function deployEnvFingerprint( + entries: readonly EnvFingerprintEntry[], + pointerUpdatedAt: PointerUpdatedAt, +): string { + return crypto + .createHash('sha256') + .update(deployEnvFingerprintMaterial(entries, pointerUpdatedAt)) + .digest('hex'); +} + +/** How much of the digest names the directory — enough that two environments never collide in practice, short enough to read in a log line. */ +const FINGERPRINT_PATH_LENGTH = 12; + +/** + * Hard-links `artifactPath` into a sibling `deploy-env-` + * directory and returns the link's path: same bytes, a path that moves if and + * only if the environment moved. The canonical path is already + * content-addressed, so a code change moves the parent directory and a + * fingerprint change moves the child — either one is a new path, which is what + * upstream plans a replace on. The empty path + * (`packageComputeArtifact`'s destroy-run placeholder) passes through untouched. + */ +export function fingerprintedArtifactPath(artifactPath: string, fingerprint: string): string { + if (artifactPath === '') return artifactPath; + const dir = path.join( + path.dirname(artifactPath), + `deploy-env-${fingerprint.slice(0, FINGERPRINT_PATH_LENGTH)}`, + ); + fs.mkdirSync(dir, { recursive: true }); + const linked = path.join(dir, path.basename(artifactPath)); + if (!fs.existsSync(linked)) { + try { + fs.linkSync(artifactPath, linked); + } catch (error) { + // A concurrent run linked it between the existsSync and here — same + // bytes, nothing to do. + if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) { + // A filesystem without hard links still gets the fingerprinted path. + // Copy through a temp file and rename (same pattern as + // packageComputeArtifact) so no reader ever sees a partial artifact. + const tmp = `${linked}.tmp-${String(process.pid)}`; + fs.copyFileSync(artifactPath, tmp); + fs.renameSync(tmp, linked); + } + } + } + return linked; +} diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/deployment-edge.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/deployment-edge.ts new file mode 100644 index 000000000..e10bc1573 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/deployment-edge.ts @@ -0,0 +1,30 @@ +/** + * Orders a deployment AFTER the environment rows it boots with: the platform + * materializes rows into a deployment at create time and never re-reads them + * (PRO-211). Alchemy schedules only on resource references inside prop + * values, and upstream's `Prisma.Deployment` has no environment prop, so the + * edge rides `app`: every variable's id threads through it, and the platform + * still receives the app id. `app` is the ONLY safe prop — upstream's diff + * treats `{portMapping, skipCodeUpload, artifactPath, artifactContentType}` + * as one block and returns "no opinion" if any is unresolved (a brand-new + * variable always is), which would silently skip the artifact comparison. + * Ordering only: shipping a CHANGED value is deploy-fingerprint.ts's job. + */ + +import * as Output from 'alchemy/Output'; +import type { EnvironmentVariable } from 'alchemy/Prisma'; + +export const appAfterEnvironment = ( + app: Output.Output, + environment: readonly EnvironmentVariable[], +): Output.Output => + environment.length === 0 + ? app + : // The app id is inside the combined expression as well as returned from + // it: Alchemy's dependency walker looks only at what an expression is + // built FROM, never inside the function, so an app referenced only by + // the closure would leave the deployment with no edge to its own app. + Output.flatMap( + Output.all(app, ...environment.map((variable) => variable.environmentVariableId)), + () => app, + ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/container.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/container.ts index 4c13c0e58..755a87612 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/container.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/container.ts @@ -92,7 +92,7 @@ const resolveProject = ( if (!ensure) return yield* Effect.fail(new ContainerNotFoundError({ appName })); // createDatabase: false — the platform default database is never used - // (composer poisons DATABASE_URL at provision), so don't create it. The + // (composer claims DATABASE_URL with a placeholder at provision), so don't create it. The // API 403s this for user actors, but deploys authenticate as workspace // actors (service tokens), which are allowed. const created = yield* call(() => diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/credentials.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/credentials.ts index 442ae5326..e462d9817 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/credentials.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/credentials.ts @@ -22,3 +22,67 @@ export const fromEnv = (): Layer.Layer => return { token }; }), ); + +const DEFAULT_BASE_URL = 'https://api.prisma.io'; + +const isLoopbackHost = (hostname: string) => + hostname === 'localhost' || + hostname.endsWith('.localhost') || + hostname === '127.0.0.1' || + hostname === '[::1]'; + +/** Same validation as upstream alchemy's `PrismaEnvironment`: an HTTP(S) origin, HTTPS unless loopback, no credentials, no path/query/fragment. */ +const normalizeBaseUrl = (value: string): Effect.Effect => + Effect.try({ + try: () => { + const url = new URL(value); + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new Error('Prisma Management API URL must use HTTP or HTTPS.'); + } + if (url.username.length > 0 || url.password.length > 0) { + throw new Error('Prisma Management API URL must not contain credentials.'); + } + if (url.protocol === 'http:' && !isLoopbackHost(url.hostname)) { + throw new Error( + 'Prisma Management API URL must use HTTPS unless it targets a loopback host.', + ); + } + if ( + (url.pathname !== '/' && url.pathname !== '') || + url.search.length > 0 || + url.hash.length > 0 + ) { + throw new Error( + 'Prisma Management API URL must be an origin without a path, query, or fragment.', + ); + } + return url.origin; + }, + catch: (cause) => + cause instanceof Error + ? cause + : new Error(`Invalid Prisma Management API URL: ${String(cause)}`), + }); + +/** + * The Management API origin every Prisma-Cloud client in this package uses — + * Composer's own SDK client AND upstream alchemy's postgres providers resolve + * it through this one function, so `PRISMA_API_URL` can never point them at + * different hosts. Mirrors upstream alchemy's `PrismaEnvironment` resolution: + * `PRISMA_API_URL`, then `PRISMA_MANAGEMENT_API_URL`, then the public origin, + * normalized and validated identically. + */ +export const managementApiBaseUrl = ( + env?: Readonly>, +): Effect.Effect => + env !== undefined + ? // `||`, not `??`: an empty string means unset, exactly as Effect's + // Config provider treats an empty env var in the branch below. + normalizeBaseUrl( + env['PRISMA_API_URL'] || env['PRISMA_MANAGEMENT_API_URL'] || DEFAULT_BASE_URL, + ) + : Config.string('PRISMA_API_URL').pipe( + Config.orElse(() => Config.string('PRISMA_MANAGEMENT_API_URL')), + Config.withDefault(DEFAULT_BASE_URL), + Effect.flatMap(normalizeBaseUrl), + ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/database-url-claim.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/database-url-claim.ts new file mode 100644 index 000000000..4156952fb --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/database-url-claim.ts @@ -0,0 +1,60 @@ +/** + * Claims the platform's `DATABASE_URL` / `DATABASE_URL_POOLED` variables for + * the app's project with the placeholder `"-"`, before the platform can seed + * them: on a project with no production `DATABASE_URL`, Prisma Cloud fills + * one in on the next compute deploy, handing live credentials to any service + * that reads `process.env.DATABASE_URL` behind the framework's back. The + * claim is create-only, so whoever writes first wins; any connect attempt + * against `"-"` fails loudly (the API rejects an empty value). + * + * Deliberately NOT alchemy resources: Composer must never patch or delete + * these variables, and a state row would plan exactly those calls. + */ + +import * as Effect from 'effect/Effect'; +import * as Option from 'effect/Option'; +import { type ManagementApiClient, ManagementClient } from './client.ts'; +import { callCreateOnly, type PrismaApiError } from './http.ts'; + +const PLACEHOLDER_VALUE = '-'; + +/** The two names Prisma Cloud fills in for itself, and that no Composer service may bind. */ +export const RESERVED_DATABASE_URL_KEYS = ['DATABASE_URL', 'DATABASE_URL_POOLED'] as const; + +/** + * Both environment classes, each at PROJECT level (no branch id). A preview + * branch with no override of its own reads the project-level preview row, so + * these two rows cover every stage the app will ever deploy — including + * stages that do not exist yet. + */ +const ENVIRONMENT_CLASSES = ['production', 'preview'] as const; + +const claim = ( + client: ManagementApiClient, + projectId: string, + key: string, + environmentClass: (typeof ENVIRONMENT_CLASSES)[number], +) => + callCreateOnly(() => + client.POST('/v1/environment-variables', { + body: { projectId, class: environmentClass, key, value: PLACEHOLDER_VALUE }, + }), + ); + +/** + * Claims both keys in both classes for `projectId`, create-only: a 409 means + * the variable already exists — whether Prisma Cloud seeded it or an earlier + * deploy claimed it — and is skipped, never overwritten and never removed. + * Repeating it is always safe. Does nothing when no {@link ManagementClient} + * is in context (the local target has no Management API). + */ +export const claimDatabaseUrlKeys = (projectId: string): Effect.Effect => + Effect.gen(function* () { + const client = yield* Effect.serviceOption(ManagementClient); + if (Option.isNone(client)) return; + for (const key of RESERVED_DATABASE_URL_KEYS) { + for (const environmentClass of ENVIRONMENT_CLASSES) { + yield* claim(client.value, projectId, key, environmentClass); + } + } + }); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/compute.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/compute.ts index 8a365d5da..ea3708b8b 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/compute.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/compute.ts @@ -1,5 +1,4 @@ export * from '../compute/artifact.ts'; -export * from '../compute/ComputeService.ts'; -export * from '../compute/Deployment.ts'; -export * from '../compute/EnvironmentVariable.ts'; +export * from '../compute/deploy-fingerprint.ts'; +export * from '../compute/deployment-edge.ts'; export * from '../compute/ServiceKey.ts'; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/index.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/index.ts index 0118c6f54..3f5353ce1 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/index.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/index.ts @@ -1,19 +1,22 @@ /** * `@internal/lowering`'s public surface: the Prisma resource providers plus the * Management API client, container, and credential helpers. Implementation - * lives in `../providers.ts` and the modules it re-exports; the compute, - * postgres, and bucket surfaces are their own entrypoints. + * lives in `../providers.ts` and the modules it re-exports; the compute and + * bucket surfaces are their own entrypoints. The postgres family (Project, + * Database, Connection) and the compute family (App, Deployment, + * EnvironmentVariable) are upstream alchemy's — consumers import them via + * `import * as Prisma from 'alchemy/Prisma'`. What stays here is Composer's + * own: the artifact packager, `ServiceKey`, and the bucket resources. */ export { layer as managementClientLayer, - MANAGEMENT_API_ORIGIN, type ManagementApiClient, ManagementClient, } from '../client.ts'; export * from '../container.ts'; export * from '../credentials.ts'; +export * from '../database-url-claim.ts'; export * from '../pagination.ts'; export * from '../providers.ts'; export * from './buckets.ts'; export * from './compute.ts'; -export * from './postgres.ts'; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/postgres.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/postgres.ts deleted file mode 100644 index 888159e54..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/postgres.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from '../postgres/Connection.ts'; -export * from '../postgres/Database.ts'; -export * from '../postgres/Project.ts'; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/http.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/http.ts index 4420e7109..15703a078 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/http.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/http.ts @@ -54,3 +54,17 @@ export const callVoid = ( r.response.status === 404 || r.error === undefined ? Effect.void : fail(r), ), ); + +/** + * Fire a CREATE call, tolerating a 409 (it already exists). Gives the caller + * create-only semantics: the thing is created when absent, and an existing one + * — whoever created it — is left exactly as it is. + */ +export const callCreateOnly = ( + f: () => Promise, +): Effect.Effect => + attempt(f).pipe( + Effect.flatMap((r) => + r.response.status === 409 || r.error === undefined ? Effect.void : fail(r), + ), + ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Connection.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Connection.ts deleted file mode 100644 index 2de9ffd3c..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Connection.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { Resource } from 'alchemy'; -import * as Provider from 'alchemy/Provider'; -import * as Effect from 'effect/Effect'; -import * as Redacted from 'effect/Redacted'; -import { ManagementClient } from '../client.ts'; -import { call, callVoid, PrismaApiError } from '../http.ts'; - -export interface ConnectionProps { - /** The database this connection targets. */ - databaseId: string; - name: string; -} - -export interface ConnectionAttributes { - id: string; - /** - * The Postgres connection string. Returned only at creation and never - * echoed back, so it is captured here (Redacted) and persisted in state. - */ - connectionString: Redacted.Redacted; -} - -export type Connection = Resource<'Prisma.Connection', ConnectionProps, ConnectionAttributes>; - -/** A **connection** to a Prisma Postgres database — yields the connection string. */ -export const Connection = Resource('Prisma.Connection'); - -export const ConnectionProvider = () => - Provider.effect( - Connection, - Effect.gen(function* () { - const client = yield* ManagementClient; - - return { - stables: ['id', 'connectionString'], - list: () => Effect.succeed([] as ConnectionAttributes[]), - reconcile: Effect.fn(function* ({ news, output }) { - // The secret is only returned at creation; cached state is authoritative. - if (output?.id) return output; - - const created = yield* call(() => - client.POST('/v1/databases/{databaseId}/connections', { - params: { path: { databaseId: news.databaseId } }, - body: { name: news.name }, - }), - ); - // `data.url` is the API self-link, NOT a Postgres DSN. The real - // connection strings live under endpoints.{direct,pooled}; the - // top-level `connectionString` is deprecated. Prefer the direct - // endpoint, fall back to pooled. - const endpoints = created.data.endpoints; - const dsn = endpoints?.direct?.connectionString ?? endpoints?.pooled?.connectionString; - if (dsn === undefined) { - return yield* Effect.fail( - new PrismaApiError({ - status: 0, - message: `connection ${created.data.id} returned no direct/pooled connection string`, - }), - ); - } - return { - id: created.data.id, - connectionString: Redacted.make(dsn), - }; - }), - delete: Effect.fn(function* ({ output }) { - yield* callVoid(() => - client.DELETE('/v1/connections/{id}', { - params: { path: { id: output.id } }, - }), - ); - }), - }; - }), - ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Database.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Database.ts deleted file mode 100644 index 2c3957e8f..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Database.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { Resource } from 'alchemy'; -import * as Provider from 'alchemy/Provider'; -import * as Effect from 'effect/Effect'; -import { ManagementClient } from '../client.ts'; -import { call, callOptional, callVoid } from '../http.ts'; - -export type Region = - | 'us-east-1' - | 'us-west-1' - | 'eu-west-3' - | 'eu-central-1' - | 'ap-northeast-1' - | 'ap-southeast-1'; - -export interface DatabaseProps { - /** The project that will own this database. */ - projectId: string; - name: string; - region: Region; - isDefault?: boolean; - /** When set, the Branch this database is attached to (named-stage deploys). */ - branchId?: string; -} - -export interface DatabaseAttributes { - id: string; - name: string; -} - -export type Database = Resource<'Prisma.Database', DatabaseProps, DatabaseAttributes>; - -/** A Prisma **Postgres database** inside a project. */ -export const Database = Resource('Prisma.Database'); - -export const DatabaseProvider = () => - Provider.effect( - Database, - Effect.gen(function* () { - const client = yield* ManagementClient; - - return { - stables: ['id'], - list: () => Effect.succeed([] as DatabaseAttributes[]), - reconcile: Effect.fn(function* ({ news, output }) { - const observed = output?.id - ? yield* callOptional(() => - client.GET('/v1/databases/{databaseId}', { - params: { path: { databaseId: output.id } }, - }), - ) - : undefined; - if (!observed) { - // branchId goes in the create body: a database created without one - // is born on the project's default Branch — production's, on a - // named stage. The platform still attaches in a second step of its - // own, so this narrows that window rather than closing it. - const created = yield* call(() => - client.POST('/v1/databases', { - body: { - projectId: news.projectId, - name: news.name, - region: news.region, - ...(news.isDefault !== undefined && { isDefault: news.isDefault }), - ...(news.branchId !== undefined && { branchId: news.branchId }), - }, - }), - ); - return { id: created.data.id, name: created.data.name }; - } - - const result: DatabaseAttributes = { id: observed.data.id, name: observed.data.name }; - if (news.branchId !== undefined) { - const branchId = news.branchId; - yield* call(() => - client.PATCH('/v1/databases/{databaseId}', { - params: { path: { databaseId: result.id } }, - body: { branchId }, - }), - ); - } - - return result; - }), - delete: Effect.fn(function* ({ output }) { - yield* callVoid(() => - client.DELETE('/v1/databases/{databaseId}', { - params: { path: { databaseId: output.id } }, - }), - ); - }), - read: Effect.fn(function* ({ output }) { - if (!output?.id) return undefined; - const d = yield* callOptional(() => - client.GET('/v1/databases/{databaseId}', { - params: { path: { databaseId: output.id } }, - }), - ); - return d ? { id: d.data.id, name: d.data.name } : undefined; - }), - }; - }), - ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Project.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Project.ts deleted file mode 100644 index fe3afcf80..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Project.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Resource } from 'alchemy'; -import * as Provider from 'alchemy/Provider'; -import * as Effect from 'effect/Effect'; -import { ManagementClient } from '../client.ts'; -import { call, callOptional, callVoid } from '../http.ts'; - -export interface ProjectProps { - /** The workspace that will own this project. */ - workspaceId: string; - /** Human-readable project name. */ - name: string; -} - -export interface ProjectAttributes { - id: string; - name: string; -} - -export type Project = Resource<'Prisma.Project', ProjectProps, ProjectAttributes>; - -/** A Prisma Developer Platform **Project** — the container for databases and compute services. */ -export const Project = Resource('Prisma.Project'); - -export const ProjectProvider = () => - Provider.effect( - Project, - Effect.gen(function* () { - const client = yield* ManagementClient; - - return { - stables: ['id'], - list: () => Effect.succeed([] as ProjectAttributes[]), - reconcile: Effect.fn(function* ({ news, output }) { - // Observe — a project is only findable by its saved id. - const observed = output?.id - ? yield* callOptional(() => - client.GET('/v1/projects/{id}', { - params: { path: { id: output.id } }, - }), - ) - : undefined; - if (observed) return { id: observed.data.id, name: observed.data.name }; - - // Ensure — create it in the target workspace. - const created = yield* call(() => - client.POST('/v1/projects', { - body: { name: news.name, workspaceId: news.workspaceId }, - }), - ); - return { id: created.data.id, name: created.data.name }; - }), - delete: Effect.fn(function* ({ output }) { - yield* callVoid(() => - client.DELETE('/v1/projects/{id}', { - params: { path: { id: output.id } }, - }), - ); - }), - read: Effect.fn(function* ({ output }) { - if (!output?.id) return undefined; - const p = yield* callOptional(() => - client.GET('/v1/projects/{id}', { - params: { path: { id: output.id } }, - }), - ); - return p ? { id: p.data.id, name: p.data.name } : undefined; - }), - }; - }), - ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts index 2746d1870..df9efd713 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts @@ -1,50 +1,95 @@ +import * as NodeHttpClient from '@effect/platform-node/NodeHttpClient'; +import * as Prisma from 'alchemy/Prisma'; import * as Provider from 'alchemy/Provider'; +import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import { Bucket, BucketProvider } from './buckets/Bucket.ts'; import { BucketKey, BucketKeyProvider } from './buckets/BucketKey.ts'; import * as client from './client.ts'; -import { ComputeService, ComputeServiceProvider } from './compute/ComputeService.ts'; -import { Deployment, DeploymentProvider } from './compute/Deployment.ts'; -import { EnvironmentVariable, EnvironmentVariableProvider } from './compute/EnvironmentVariable.ts'; -import { fromEnv } from './credentials.ts'; -import { Connection, ConnectionProvider } from './postgres/Connection.ts'; -import { Database, DatabaseProvider } from './postgres/Database.ts'; -import { Project, ProjectProvider } from './postgres/Project.ts'; +import { fromEnv, managementApiBaseUrl, PrismaCredentials } from './credentials.ts'; /** The collection of Prisma resource providers. */ -export class Providers extends Provider.ProviderCollection()('Prisma') {} +export class Providers extends Provider.ProviderCollection()('PrismaComposer') {} + +/** + * Upstream's `PrismaEnvironment`, built from Composer's own env credentials — + * no profile store, so no TTY prompt and no non-interactive hard-fail: + * `PRISMA_SERVICE_TOKEN` (redacted, via `PrismaCredentials`) plus the base + * URL from `managementApiBaseUrl()` — the SAME resolver `client.ts` uses, + * so `PRISMA_API_URL` moves the postgres family and the compute/bucket/state + * clients together, never one without the other. + */ +const prismaEnvironment = () => + Layer.effect( + Prisma.PrismaEnvironment, + Effect.gen(function* () { + const { token } = yield* PrismaCredentials; + const baseUrl = yield* managementApiBaseUrl(); + return { + type: 'serviceToken' as const, + serviceToken: token, + source: { type: 'env' as const, details: 'PRISMA_SERVICE_TOKEN' }, + baseUrl, + }; + }), + ); + +/** + * Upstream alchemy's live providers for the postgres family (Project, + * Database, Connection) and the compute family (App, Deployment, + * EnvironmentVariable), over upstream's management client, authenticated by + * {@link prismaEnvironment}. + * + * alchemy 2.0.0-beta.67 exports only the per-resource provider layers, so + * they are composed by hand here. TODO: switch to upstream's + * `liveProviderLayer` in the alchemy release that exports it. + */ +const upstreamPrismaProviders = () => + Layer.mergeAll( + Prisma.ProjectProvider(), + Prisma.DatabaseProvider(), + Prisma.ConnectionProvider(), + Prisma.AppProvider(), + Prisma.DeploymentProvider(), + Prisma.EnvironmentVariableProvider(), + ).pipe( + Layer.provideMerge(Prisma.PrismaClientLive), + // Provide (NOT provideMerge) the node transport privately — mirrors + // upstream's Providers.ts: it must serve only the Prisma management + // client, never override the ambient HttpClient of other providers. + Layer.provide(NodeHttpClient.layerNodeHttp), + Layer.provideMerge(prismaEnvironment()), + ); /** * The Prisma provider bundle: every resource provider, the Management API * client, and env-based credentials. Plug into a stack with * `{ providers: Prisma.providers() }`. + * + * The node transport is also the bundle's ambient `HttpClient`: upstream's + * `Deployment` artifact upload needs node's explicit Content-Length (fetch + * streams chunked), and upstream documents the ambient client as the + * supported way to provide it. Invariant: no Composer provider may resolve + * the ambient `HttpClient` — each carries its own client — or it would + * silently get this override. Filed upstream: export the scoped upload + * client, after which this becomes a private layer. */ export const providers = () => Layer.effect( Providers, Provider.collection([ - Project, - Database, - Connection, - ComputeService, - Deployment, - EnvironmentVariable, + Prisma.Project, + Prisma.Database, + Prisma.Connection, + Prisma.App, + Prisma.Deployment, + Prisma.EnvironmentVariable, Bucket, BucketKey, ]), ).pipe( - Layer.provide( - Layer.mergeAll( - ProjectProvider(), - DatabaseProvider(), - ConnectionProvider(), - ComputeServiceProvider(), - DeploymentProvider(), - EnvironmentVariableProvider(), - BucketProvider(), - BucketKeyProvider(), - ), - ), + Layer.provide(Layer.mergeAll(upstreamPrismaProviders(), BucketProvider(), BucketKeyProvider())), + Layer.provideMerge(NodeHttpClient.layerNodeHttp), Layer.provideMerge(client.layer()), Layer.provideMerge(fromEnv()), Layer.orDie, diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-resources.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-resources.test.ts new file mode 100644 index 000000000..817485a6a --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-resources.test.ts @@ -0,0 +1,874 @@ +/** + * Legacy state rows — written by Composer's own deleted `Prisma.Database` / + * `Prisma.Connection` / `Prisma.ComputeService` / `Prisma.Deployment` / + * `Prisma.EnvironmentVariable` resources — must round-trip through the hosted + * state store into shapes upstream alchemy's providers ACCEPT. On the + * unchanged path that means: the provider's `diff` plans no action (so no + * create and no replace), its `read` finds the physical resource instead of + * returning `undefined` (which would plan a create), and `reconcile` keeps the + * persisted secret without rotating anything. Where migration cannot avoid a + * mutating plan (a production App's unrecorded branch, a deployment's + * unrecoverable artifact fingerprint), the test pins WHICH action is planned, + * so the one-time cost stays visible. + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { InstanceId } from 'alchemy/InstanceId'; +import * as Prisma from 'alchemy/Prisma'; +import type * as Provider from 'alchemy/Provider'; +import { Stack } from 'alchemy/Stack'; +import { Stage } from 'alchemy/Stage'; +import { + type CreatedResourceState, + type ReplacedResourceState, + State, + type StateService, +} from 'alchemy/State'; +import { PlatformServices } from 'alchemy/Util/PlatformServices'; +import * as Effect from 'effect/Effect'; +import * as Layer from 'effect/Layer'; +import * as Redacted from 'effect/Redacted'; +import { stateLayerAgainst } from '../layer.ts'; +import { migrateLegacyResourceState } from '../legacy-resources.ts'; +import { FakeStateApi } from './fake-state-api.ts'; + +process.env['PRISMA_SERVICE_TOKEN'] ??= 'test-service-token'; + +const DIRECT_URL = 'postgres://user:pass@db.prisma.io:5432/postgres'; + +const legacyDatabaseRow = (): CreatedResourceState => ({ + resourceType: 'Prisma.Database', + namespace: undefined, + fqn: 'data-db', + logicalId: 'data-db', + instanceId: 'inst-db', + providerVersion: 1, + status: 'created', + downstream: [], + bindings: [], + props: { projectId: 'proj-1', name: 'data', region: 'us-east-1' }, + attr: { id: 'db-1', name: 'data' }, +}); + +const legacyConnectionRow = (): CreatedResourceState => ({ + resourceType: 'Prisma.Connection', + namespace: undefined, + fqn: 'data-conn', + logicalId: 'data-conn', + instanceId: 'inst-conn', + providerVersion: 1, + status: 'created', + downstream: [], + bindings: [], + props: { databaseId: 'db-1', name: 'data' }, + attr: { id: 'conn-1', connectionString: Redacted.make(DIRECT_URL) }, +}); + +const apiDatabase = { + id: 'db-1', + name: 'data', + project: { id: 'proj-1' }, + status: 'ready', + region: { id: 'us-east-1' }, + isDefault: false, + branchId: null, + defaultConnectionId: 'conn-default', + createdAt: '2025-01-01T00:00:00.000Z', + source: { type: 'empty' }, + connections: [], +}; + +const apiConnection = { + id: 'conn-1', + name: 'data', + database: { id: 'db-1' }, + kind: 'postgres', + createdAt: '2025-01-01T00:00:00.000Z', +}; + +/** Only the endpoints the adoption paths under test actually hit; anything else throws loudly. */ +const stubClient = { + getDatabase: (id: string) => + id === 'db-1' ? Effect.succeed(apiDatabase) : Effect.die(`unexpected getDatabase ${id}`), + getConnection: (id: string) => + id === 'conn-1' ? Effect.succeed(apiConnection) : Effect.die(`unexpected getConnection ${id}`), + rotateConnection: (id: string) => + Effect.die(`rotateConnection(${id}) must not be called for an adopted legacy row`), +} as unknown as Prisma.PrismaManagementClient; + +// The provider layers' inferred environment leaks an `any` through +// Provider.effect's typing; the stubbed PrismaClient is the only real +// requirement and it IS provided, so the runtime environment is complete. +const databaseService = () => + Effect.runPromise( + Prisma.Database.Provider.pipe( + Effect.provide( + Prisma.DatabaseProvider().pipe( + Layer.provide(Layer.succeed(Prisma.PrismaClient, stubClient)), + ), + ), + ) as Effect.Effect, never, never>, + ); + +const connectionService = () => + Effect.runPromise( + Prisma.Connection.Provider.pipe( + Effect.provide( + Prisma.ConnectionProvider().pipe( + Layer.provide(Layer.succeed(Prisma.PrismaClient, stubClient)), + ), + ), + ) as Effect.Effect, never, never>, + ); + +type MigratedRow = CreatedResourceState & { + props: Record; + attr: Record; +}; + +describe('migrateLegacyResourceState (pure mapping)', () => { + test('maps a legacy Database row to upstream field names, idempotently', () => { + const migrated = migrateLegacyResourceState(legacyDatabaseRow()) as MigratedRow; + expect(migrated.resourceType).toBe('Prisma.Database'); + expect(migrated.props).toEqual({ project: 'proj-1', name: 'data', region: 'us-east-1' }); + expect(migrated.attr).toMatchObject({ + databaseId: 'db-1', + databaseName: 'data', + projectId: 'proj-1', + region: 'us-east-1', + isDefault: false, + branchId: null, + }); + expect(migrateLegacyResourceState(migrated)).toEqual(migrated); + }); + + test('maps a legacy Connection row, carrying the Redacted secret into directConnectionString', () => { + const migrated = migrateLegacyResourceState(legacyConnectionRow()) as MigratedRow; + expect(migrated.resourceType).toBe('Prisma.Connection'); + expect(migrated.props).toEqual({ database: 'db-1', name: 'data' }); + expect(migrated.attr).toMatchObject({ + connectionId: 'conn-1', + connectionName: 'data', + databaseId: 'db-1', + kind: 'postgres', + }); + const direct = migrated.attr['directConnectionString']; + expect(Redacted.isRedacted(direct)).toBe(true); + expect(Redacted.value(direct as Redacted.Redacted)).toBe(DIRECT_URL); + expect(migrateLegacyResourceState(migrated)).toEqual(migrated); + }); + + test('migrates the nested old-generation chain of a replaced row', () => { + const replaced: ReplacedResourceState = { + ...legacyDatabaseRow(), + status: 'replaced', + old: legacyDatabaseRow(), + deleteFirst: false, + } as ReplacedResourceState; + const migrated = migrateLegacyResourceState(replaced) as ReplacedResourceState & { + old: MigratedRow; + }; + expect(migrated.old.resourceType).toBe('Prisma.Database'); + expect(migrated.old.attr).toMatchObject({ databaseId: 'db-1', databaseName: 'data' }); + expect(migrated.old.props).toEqual({ project: 'proj-1', name: 'data', region: 'us-east-1' }); + }); + + test('maps the unreleased PrismaComposer.* type-ids too, and passes foreign rows through', () => { + const composerEra = { ...legacyDatabaseRow(), resourceType: 'PrismaComposer.Database' }; + expect((migrateLegacyResourceState(composerEra) as MigratedRow).resourceType).toBe( + 'Prisma.Database', + ); + const foreign = { ...legacyDatabaseRow(), resourceType: 'Cloudflare.Worker' }; + expect(migrateLegacyResourceState(foreign)).toEqual(foreign); + }); +}); + +describe('upstream provider acceptance of migrated rows (stubbed management client)', () => { + const migratedDb = migrateLegacyResourceState(legacyDatabaseRow()) as MigratedRow; + const migratedConn = migrateLegacyResourceState(legacyConnectionRow()) as MigratedRow; + + test('Database: diff plans NO action, and read finds the database (no create)', async () => { + const service = await databaseService(); + if (service.diff === undefined || service.read === undefined) { + throw new Error('upstream provider must expose diff and read'); + } + const diff = await Effect.runPromise( + service.diff({ + id: 'data-db', + fqn: 'data-db', + instanceId: 'inst-db', + olds: migratedDb.props, + news: { project: 'proj-1', name: 'data', region: 'us-east-1' }, + output: migratedDb.attr, + session: undefined, + bindings: [], + } as never), + ); + expect(diff).toBeUndefined(); + + const read = await Effect.runPromise( + service.read({ + id: 'data-db', + fqn: 'data-db', + instanceId: 'inst-db', + olds: migratedDb.props, + output: migratedDb.attr, + session: undefined, + bindings: [], + } as never), + ); + expect(read).toMatchObject({ databaseId: 'db-1', databaseName: 'data', projectId: 'proj-1' }); + }); + + test('Connection: diff plans NO action, read finds it, reconcile keeps the secret WITHOUT rotating', async () => { + const service = await connectionService(); + if (service.diff === undefined || service.read === undefined) { + throw new Error('upstream provider must expose diff and read'); + } + const diff = await Effect.runPromise( + service.diff({ + id: 'data-conn', + fqn: 'data-conn', + instanceId: 'inst-conn', + olds: migratedConn.props, + news: { database: 'db-1', name: 'data' }, + output: migratedConn.attr, + session: undefined, + bindings: [], + } as never), + ); + expect(diff).toBeUndefined(); + + const read = await Effect.runPromise( + service.read({ + id: 'data-conn', + fqn: 'data-conn', + instanceId: 'inst-conn', + olds: migratedConn.props, + output: migratedConn.attr, + session: undefined, + bindings: [], + } as never), + ); + expect(read).toMatchObject({ connectionId: 'conn-1', databaseId: 'db-1' }); + + // The stub's rotateConnection dies, so this passing proves reconcile + // never touched the live credentials. + const reconciled = (await Effect.runPromise( + service.reconcile({ + id: 'data-conn', + fqn: 'data-conn', + instanceId: 'inst-conn', + olds: migratedConn.props, + news: { database: 'db-1', name: 'data' }, + output: migratedConn.attr, + session: undefined, + bindings: [], + } as never), + )) as Record; + const direct = reconciled['directConnectionString']; + expect(Redacted.isRedacted(direct)).toBe(true); + expect(Redacted.value(direct as Redacted.Redacted)).toBe(DIRECT_URL); + }); +}); + +describe('branch-stage migrated rows against upstream Database provider', () => { + // A branch-stage row: the legacy descriptor passed an explicit name AND a + // branchId; the replacement descriptor omits the name when branchId is set. + const legacyBranchRow = (): CreatedResourceState => ({ + ...legacyDatabaseRow(), + props: { projectId: 'proj-1', name: 'data', region: 'us-east-1', branchId: 'branch_1' }, + }); + + test('diff plans an UPDATE (the one-time rename + credential-recovery path), never a replace or create', async () => { + const migrated = migrateLegacyResourceState(legacyBranchRow()) as MigratedRow; + expect(migrated.props).toEqual({ + project: 'proj-1', + name: 'data', + region: 'us-east-1', + branchId: 'branch_1', + }); + expect(migrated.attr).toMatchObject({ branchId: 'branch_1' }); + + const service = await databaseService(); + if (service.diff === undefined) throw new Error('upstream provider must expose diff'); + const diff = await Effect.runPromise( + service + .diff({ + id: 'data-db', + fqn: 'data-db', + instanceId: 'inst-db', + olds: migrated.props, + // Branch-stage news shape from descriptors/postgres.ts: NO name. + news: { project: 'proj-1', region: 'us-east-1', branchId: 'branch_1' }, + output: migrated.attr, + session: undefined, + bindings: [], + } as never) + .pipe( + // The omitted name makes upstream derive a generated physical name, + // which reads the engine's Stack/Stage/InstanceId context. + Effect.provideService(Stack, { name: 'app' } as never), + Effect.provideService(Stage, 'stage1'), + Effect.provideService(InstanceId, 'abcd1234abcd1234abcd1234abcd1234'), + ), + ); + expect(diff).toEqual({ action: 'update' }); + }); +}); + +describe('legacy compute-family rows against upstream providers', () => { + const legacyAppRow = (branchId?: string): CreatedResourceState => ({ + resourceType: 'Prisma.ComputeService', + namespace: undefined, + fqn: 'auth-svc', + logicalId: 'auth-svc', + instanceId: 'inst-app', + providerVersion: 1, + status: 'created', + downstream: [], + bindings: [], + props: { + projectId: 'proj-1', + name: 'auth', + region: 'us-east-1', + ...(branchId !== undefined ? { branchId } : {}), + }, + attr: { id: 'app-1', name: 'auth', endpointDomain: 'auth.prisma.app' }, + }); + + const legacyDeploymentRow = (artifactPath: string): CreatedResourceState => ({ + resourceType: 'Prisma.Deployment', + namespace: undefined, + fqn: 'auth-deploy', + logicalId: 'auth-deploy', + instanceId: 'inst-deploy', + providerVersion: 1, + status: 'created', + downstream: [], + bindings: [], + props: { + computeServiceId: 'app-1', + artifactPath, + artifactHash: 'sha-auth', + port: 8080, + environment: [{ id: 'var-1', key: 'COMPOSER_AUTH_PORT' }], + }, + attr: { deploymentId: 'dep-1', deployedUrl: 'auth.prisma.app' }, + }); + + const legacyEnvRow = (key: string): CreatedResourceState => ({ + resourceType: 'Prisma.EnvironmentVariable', + namespace: undefined, + fqn: `${key}-var`, + logicalId: `${key}-var`, + instanceId: 'inst-var', + providerVersion: 1, + status: 'created', + downstream: [], + bindings: [], + props: { projectId: 'proj-1', key, value: 'plain-secret', class: 'production' }, + attr: { id: 'var-1', key }, + }); + + const apiApp = { + id: 'app-1', + name: 'auth', + projectId: 'proj-1', + region: { id: 'us-east-1' }, + branchId: 'branch_1', + latestDeploymentId: 'dep-1', + appEndpointDomain: 'auth.prisma.app', + createdAt: '2025-01-01T00:00:00.000Z', + }; + + const apiDeployment = { + id: 'dep-1', + type: 'deployment', + url: 'https://api.prisma.io/v1/deployments/dep-1', + foundryVersionId: 'fv-1', + status: 'running', + previewDomain: 'dep-1.preview.prisma.app', + createdAt: '2025-01-01T00:00:00.000Z', + }; + + const apiVariable = { + id: 'var-1', + projectId: 'proj-1', + branchId: null, + class: 'production', + key: 'COMPOSER_AUTH_PORT', + valueKid: 'kid-1', + isManagedBySystem: false, + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', + }; + + /** Only the endpoints these adoption paths hit; anything else throws loudly. */ + const computeClient = { + getApp: (id: string) => + id === 'app-1' ? Effect.succeed(apiApp) : Effect.die(`unexpected getApp ${id}`), + listBranches: () => Effect.succeed([{ id: 'branch_1', isDefault: true }]), + getDeployment: (id: string) => + id === 'dep-1' ? Effect.succeed(apiDeployment) : Effect.die(`unexpected getDeployment ${id}`), + listAppDeployments: () => Effect.succeed([apiDeployment]), + getEnvironmentVariable: (id: string) => + id === 'var-1' + ? Effect.succeed(apiVariable) + : Effect.die(`unexpected getEnvironmentVariable ${id}`), + deleteEnvironmentVariable: (id: string) => + Effect.die(`deleteEnvironmentVariable(${id}) must not be called for a platform-owned key`), + createAppDeployment: () => Effect.die('createAppDeployment must not be called by a diff'), + } as unknown as Prisma.PrismaManagementClient; + + // Same `any` leak through Provider.effect's typing as the postgres services + // above: the stubbed PrismaClient is the only real requirement and it IS + // provided, so the runtime environment is complete. + const appService = () => + Effect.runPromise( + Prisma.App.Provider.pipe( + Effect.provide( + Prisma.AppProvider().pipe( + Layer.provide(Layer.succeed(Prisma.PrismaClient, computeClient)), + ), + ), + ) as Effect.Effect, never, never>, + ); + + const deploymentService = () => + Effect.runPromise( + Prisma.Deployment.Provider.pipe( + Effect.provide( + Prisma.DeploymentProvider().pipe( + Layer.provide(Layer.succeed(Prisma.PrismaClient, computeClient)), + ), + ), + ) as Effect.Effect, never, never>, + ); + + const environmentVariableService = () => + Effect.runPromise( + Prisma.EnvironmentVariable.Provider.pipe( + Effect.provide( + Prisma.EnvironmentVariableProvider().pipe( + Layer.provide(Layer.succeed(Prisma.PrismaClient, computeClient)), + ), + ), + ) as Effect.Effect, never, never>, + ); + + test('maps a legacy ComputeService row onto Prisma.App, idempotently', () => { + const migrated = migrateLegacyResourceState(legacyAppRow('branch_1')) as MigratedRow; + expect(migrated.resourceType).toBe('Prisma.App'); + expect(migrated.props).toEqual({ + project: 'proj-1', + displayName: 'auth', + regionId: 'us-east-1', + branchId: 'branch_1', + }); + expect(migrated.attr).toMatchObject({ + appId: 'app-1', + name: 'auth', + projectId: 'proj-1', + regionId: 'us-east-1', + branchId: 'branch_1', + appEndpointDomain: 'auth.prisma.app', + }); + expect(migrateLegacyResourceState(migrated)).toEqual(migrated); + }); + + test('App on a branch stage: diff plans NO action, read finds the app (no create)', async () => { + const migrated = migrateLegacyResourceState(legacyAppRow('branch_1')) as MigratedRow; + const service = await appService(); + if (service.diff === undefined || service.read === undefined) { + throw new Error('upstream provider must expose diff and read'); + } + const diff = await Effect.runPromise( + service.diff({ + id: 'auth-svc', + fqn: 'auth-svc', + instanceId: 'inst-app', + olds: migrated.props, + news: migrated.props, + output: migrated.attr, + session: undefined, + bindings: [], + } as never), + ); + expect(diff).toBeUndefined(); + + const read = await Effect.runPromise( + service.read({ + id: 'auth-svc', + fqn: 'auth-svc', + instanceId: 'inst-app', + olds: migrated.props, + output: migrated.attr, + session: undefined, + bindings: [], + } as never), + ); + expect(read).toMatchObject({ appId: 'app-1', projectId: 'proj-1' }); + }); + + test('App on production: diff plans an UPDATE — the one-time branch-id repair, never a replace', async () => { + // A production row recorded no branch, and the project's default branch id + // is not derivable from the row, so upstream re-reads the App once. + const migrated = migrateLegacyResourceState(legacyAppRow()) as MigratedRow; + expect(migrated.attr).toMatchObject({ branchId: null }); + const service = await appService(); + if (service.diff === undefined) throw new Error('upstream provider must expose diff'); + const diff = await Effect.runPromise( + service.diff({ + id: 'auth-svc', + fqn: 'auth-svc', + instanceId: 'inst-app', + olds: migrated.props, + news: migrated.props, + output: migrated.attr, + session: undefined, + bindings: [], + } as never), + ); + expect(diff).toEqual({ action: 'update' }); + }); + + test('maps a legacy Deployment row onto upstream props/attrs, idempotently', () => { + const migrated = migrateLegacyResourceState( + legacyDeploymentRow('/tmp/auth.tar.gz'), + ) as MigratedRow; + expect(migrated.resourceType).toBe('Prisma.Deployment'); + expect(migrated.props).toEqual({ + app: 'app-1', + artifactPath: '/tmp/auth.tar.gz', + artifactContentType: 'application/gzip', + portMapping: { http: 8080 }, + start: true, + promote: true, + }); + expect(migrated.attr).toMatchObject({ + deploymentId: 'dep-1', + appId: 'app-1', + appEndpointDomain: 'auth.prisma.app', + }); + // Absent, not invented: upstream recovers a lost deployment by Foundry + // version id, and a made-up one could claim a stranger's deployment. + expect(migrated.attr['foundryVersionId']).toBeUndefined(); + expect(migrateLegacyResourceState(migrated)).toEqual(migrated); + }); + + test('Deployment: read finds the deployment; diff plans the one-time REPLACE (unrecoverable artifact fingerprint)', async () => { + const artifactPath = path.join(os.tmpdir(), `legacy-artifact-${process.pid}.tar.gz`); + fs.writeFileSync(artifactPath, 'artifact-bytes'); + try { + const migrated = migrateLegacyResourceState(legacyDeploymentRow(artifactPath)) as MigratedRow; + const service = await deploymentService(); + if (service.diff === undefined || service.read === undefined) { + throw new Error('upstream provider must expose diff and read'); + } + + const read = await Effect.runPromise( + service + .read({ + id: 'auth-deploy', + fqn: 'auth-deploy', + instanceId: 'inst-deploy', + olds: migrated.props, + output: migrated.attr, + session: undefined, + bindings: [], + } as never) + .pipe(Effect.provide(PlatformServices)) as Effect.Effect, + ); + // Read adopts the live deployment — no create planned for it. + expect(read).toMatchObject({ deploymentId: 'dep-1', appId: 'app-1', status: 'running' }); + + const diff = await Effect.runPromise( + service + .diff({ + id: 'auth-deploy', + fqn: 'auth-deploy', + instanceId: 'inst-deploy', + olds: migrated.props, + news: migrated.props, + output: migrated.attr, + session: undefined, + bindings: [], + } as never) + .pipe(Effect.provide(PlatformServices)) as Effect.Effect, + ); + // Pinned, not tolerated silently: upstream's fingerprint hashes the + // artifact digest with the content type, which a legacy row cannot + // reproduce, so the first deploy after migration ships one fresh + // deployment per service (create-before-delete, artifact unchanged). + expect(diff).toEqual({ action: 'replace' }); + } finally { + fs.rmSync(artifactPath, { force: true }); + } + }); + + test('maps a legacy EnvironmentVariable row onto upstream field names, redacting the stored value', () => { + const migrated = migrateLegacyResourceState(legacyEnvRow('COMPOSER_AUTH_PORT')) as MigratedRow; + expect(migrated.resourceType).toBe('Prisma.EnvironmentVariable'); + expect(migrated.attr).toMatchObject({ + environmentVariableId: 'var-1', + projectId: 'proj-1', + branchId: null, + class: 'production', + key: 'COMPOSER_AUTH_PORT', + isManagedBySystem: false, + }); + // The legacy row kept the value in PLAIN TEXT in state; the migrated prop + // carries it wrapped, which is what keeps it out of the next state write. + const value = (migrated.props as { value: unknown })['value']; + expect(Redacted.isRedacted(value)).toBe(true); + expect(Redacted.value(value as Redacted.Redacted)).toBe('plain-secret'); + expect(migrateLegacyResourceState(migrated)).toEqual(migrated); + }); + + test('EnvironmentVariable: read finds the variable (no create); diff plans the value re-apply', async () => { + const migrated = migrateLegacyResourceState(legacyEnvRow('COMPOSER_AUTH_PORT')) as MigratedRow; + const service = await environmentVariableService(); + if (service.diff === undefined || service.read === undefined) { + throw new Error('upstream provider must expose diff and read'); + } + const read = await Effect.runPromise( + service.read({ + id: 'COMPOSER_AUTH_PORT-var', + fqn: 'COMPOSER_AUTH_PORT-var', + instanceId: 'inst-var', + olds: migrated.props, + output: migrated.attr, + session: undefined, + bindings: [], + } as never), + ); + expect(read).toMatchObject({ environmentVariableId: 'var-1', key: 'COMPOSER_AUTH_PORT' }); + + const diff = await Effect.runPromise( + service.diff({ + id: 'COMPOSER_AUTH_PORT-var', + fqn: 'COMPOSER_AUTH_PORT-var', + instanceId: 'inst-var', + olds: migrated.props, + news: migrated.props, + output: migrated.attr, + session: undefined, + bindings: [], + } as never), + ); + // Values are write-only, so upstream re-applies the desired one on every + // deploy — an update, never a replace or a create. + expect(diff).toEqual({ action: 'update' }); + }); + + test('a legacy DATABASE_URL claim row is RETAINED, not deleted: state row retired, platform variable untouched', async () => { + const migrated = migrateLegacyResourceState(legacyEnvRow('DATABASE_URL')) as MigratedRow; + // `retain` is what makes the engine drop the state row, skip the provider + // entirely, and report the resource as `retained` — the truthful verb for + // "we let go of it and called no API". Reporting `deleted` would tell an + // operator the platform variable is gone when it is still there. + expect(migrated['removalPolicy']).toBe('retain'); + expect(migrated.attr).toEqual({ + environmentVariableId: 'dev:legacy-claim-DATABASE_URL', + key: 'DATABASE_URL', + }); + expect(migrateLegacyResourceState(migrated)).toEqual(migrated); + const service = await environmentVariableService(); + // The stub's deleteEnvironmentVariable/getEnvironmentVariable die on this + // id, so completing proves the platform's own variable is never touched. + await Effect.runPromise( + service.delete({ + id: 'DATABASE_URL-var', + fqn: 'DATABASE_URL-var', + instanceId: 'inst-var', + olds: migrated.props, + output: migrated.attr, + session: undefined, + bindings: [], + } as never), + ); + }); + + test('an UPSTREAM-shaped DATABASE_URL row is left alone: the props shape, not the key, decides', () => { + // Upstream's own EnvironmentVariable rows carry the same type-id as the + // legacy ones, so only the props shape tells them apart. A live variable + // upstream manages must survive every state read untouched — retiring it + // would drop a real resource from state on each deploy. + const upstreamRow: CreatedResourceState = { + ...legacyEnvRow('DATABASE_URL'), + props: { + project: 'proj-1', + key: 'DATABASE_URL', + class: 'production', + value: Redacted.make('postgres://live'), + }, + attr: { + environmentVariableId: 'var-9', + projectId: 'proj-1', + branchId: null, + class: 'production', + key: 'DATABASE_URL', + isManagedBySystem: false, + }, + } as CreatedResourceState; + const migrated = migrateLegacyResourceState(upstreamRow) as MigratedRow; + expect(migrated['removalPolicy']).toBeUndefined(); + expect(migrated.attr).toEqual({ + environmentVariableId: 'var-9', + projectId: 'proj-1', + branchId: null, + class: 'production', + key: 'DATABASE_URL', + isManagedBySystem: false, + }); + expect(migrated).toEqual(upstreamRow as unknown as MigratedRow); + }); + + test('a REPLACED claim row migrates its displaced old generation before retiring itself', () => { + // The row on top is retired, but the generation it displaced still rides + // along under `old` and the engine reads it. It must arrive in the + // upstream shape, so the old chain is rewritten before the retirement. + const legacy = legacyEnvRow('DATABASE_URL'); + const replaced = { + ...legacy, + status: 'replaced', + old: { props: legacy.props, attr: legacy.attr, bindings: [] }, + deleteFirst: false, + } as unknown as ReplacedResourceState; + const migrated = migrateLegacyResourceState(replaced) as ReplacedResourceState & { + removalPolicy?: string; + attr: Record; + old: { props: Record; attr: Record }; + }; + expect(migrated.removalPolicy).toBe('retain'); + expect(migrated.attr).toEqual({ + environmentVariableId: 'dev:legacy-claim-DATABASE_URL', + key: 'DATABASE_URL', + }); + expect(migrated.old.attr).toMatchObject({ + environmentVariableId: 'var-1', + projectId: 'proj-1', + key: 'DATABASE_URL', + isManagedBySystem: false, + }); + expect(migrated.old.props).toMatchObject({ project: 'proj-1', key: 'DATABASE_URL' }); + expect(Redacted.isRedacted(migrated.old.props['value'])).toBe(true); + }); + + test('maps the unreleased PrismaComposer.* compute type-ids too', () => { + const composerEra = { + ...legacyAppRow('branch_1'), + resourceType: 'PrismaComposer.ComputeService', + }; + expect((migrateLegacyResourceState(composerEra) as MigratedRow).resourceType).toBe( + 'Prisma.App', + ); + const composerEnv = { + ...legacyEnvRow('COMPOSER_AUTH_PORT'), + resourceType: 'PrismaComposer.EnvironmentVariable', + }; + expect((migrateLegacyResourceState(composerEnv) as MigratedRow).resourceType).toBe( + 'Prisma.EnvironmentVariable', + ); + const composerClaimRow = { + ...legacyEnvRow('DATABASE_URL_POOLED'), + resourceType: 'PrismaComposer.EnvironmentVariable', + }; + const migratedClaimRow = migrateLegacyResourceState(composerClaimRow) as MigratedRow; + expect(migratedClaimRow.resourceType).toBe('Prisma.EnvironmentVariable'); + expect(migratedClaimRow['removalPolicy']).toBe('retain'); + }); +}); + +describe('state round-trip of legacy rows through the hosted state layer', () => { + // The REAL layer (stateLayerAgainst → stock HTTP client → on-read + // migration) against an in-process fake of the platform state API — the + // same wiring a deploy uses, so this proves the layer applies the + // migration, not just that the pure function works. + const fake = new FakeStateApi(); + const stack = 'legacy-state-stack'; + const stage = 'br_legacy'; + + beforeAll(async () => { + await fake.start(); + }); + + afterAll(async () => { + await fake.stop(); + }); + + const stackContext = Layer.succeed(Stack, { + name: stack, + stage, + resources: {}, + bindings: {}, + actions: {}, + }); + + const runLayer = (use: (service: StateService) => Effect.Effect): Promise => { + const layer = stateLayerAgainst(fake.origin, { + projectId: 'proj-legacy', + branchId: 'br-legacy', + }).pipe(Layer.provide(stackContext)) as unknown as Layer.Layer; + return Effect.runPromise( + Effect.gen(function* () { + const service = yield* yield* State; + return yield* use(service).pipe(Effect.orDie); + }).pipe(Effect.provide(layer)) as Effect.Effect, + ); + }; + + test('an old-shape Database row persisted as-is is read back in the upstream shape', async () => { + const row = (await runLayer((service) => + Effect.gen(function* () { + yield* service.set({ stack, stage, fqn: 'data-db', value: legacyDatabaseRow() }); + return yield* service.get({ stack, stage, fqn: 'data-db' }); + }), + )) as MigratedRow; + expect(row.resourceType).toBe('Prisma.Database'); + expect(row.attr).toMatchObject({ databaseId: 'db-1', databaseName: 'data' }); + expect(row.props).toEqual({ project: 'proj-1', name: 'data', region: 'us-east-1' }); + }); + + test('an old-shape Connection row round-trips with the Redacted secret intact', async () => { + const row = (await runLayer((service) => + Effect.gen(function* () { + yield* service.set({ stack, stage, fqn: 'data-conn', value: legacyConnectionRow() }); + return yield* service.get({ stack, stage, fqn: 'data-conn' }); + }), + )) as MigratedRow; + expect(row.resourceType).toBe('Prisma.Connection'); + expect(row.attr).toMatchObject({ connectionId: 'conn-1', databaseId: 'db-1' }); + const direct = row.attr['directConnectionString']; + expect(Redacted.isRedacted(direct)).toBe(true); + expect(Redacted.value(direct as Redacted.Redacted)).toBe(DIRECT_URL); + // The databaseUrl mirror keeps the value usable where the conventional + // application URL is read. + expect(Redacted.value(row.attr['databaseUrl'] as Redacted.Redacted)).toBe(DIRECT_URL); + }); + + test('a replaced legacy row read through getReplacedResources comes back migrated', async () => { + const replaced = { + ...legacyDatabaseRow(), + fqn: 'data-db-replaced', + status: 'replaced', + old: legacyDatabaseRow(), + deleteFirst: false, + } as unknown as CreatedResourceState; + const rows = (await runLayer((service) => + Effect.gen(function* () { + yield* service.set({ stack, stage, fqn: 'data-db-replaced', value: replaced }); + return yield* service.getReplacedResources({ stack, stage }); + }), + )) as unknown as (MigratedRow & { old: MigratedRow })[]; + const row = rows.find((r) => r['fqn'] === 'data-db-replaced'); + expect(row).toBeDefined(); + expect(row?.resourceType).toBe('Prisma.Database'); + expect(row?.old.resourceType).toBe('Prisma.Database'); + expect(row?.old.attr).toMatchObject({ databaseId: 'db-1', databaseName: 'data' }); + }); +}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts index 5e5f945e0..28219487a 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts @@ -1,5 +1,12 @@ +import { blindCast } from '@internal/foundation/casts'; import { Stack, type StackServices } from 'alchemy'; -import { makeHttpStateStore, State } from 'alchemy/State'; +import { + makeHttpStateStore, + type PersistedState, + type ReplacedResourceState, + State, + type StateService, +} from 'alchemy/State'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import * as Redacted from 'effect/Redacted'; @@ -20,6 +27,7 @@ import { redactLeaseHeader, releaseDeployLease, } from './lease.ts'; +import { migrateLegacyResourceState } from './legacy-resources.ts'; /** * The hosted Alchemy state store: alchemy's stock HTTP state client pointed @@ -48,7 +56,39 @@ export const prismaStateLayer = (ids: { /** The project's default Branch id, when the deploy targets the default stage — skips re-resolving it. */ readonly defaultBranchId?: string; }): Layer.Layer => - stateLayerAgainst(client.MANAGEMENT_API_ORIGIN, ids); + // The origin comes from the same resolver upstream's providers use + // (credentials.managementApiBaseUrl), so PRISMA_API_URL moves the state + // client and the resource providers together, never one without the other. + Layer.unwrap( + credentials.managementApiBaseUrl().pipe(Effect.map((origin) => stateLayerAgainst(origin, ids))), + ).pipe(Layer.orDie); + +/** + * The stock service with legacy Composer resource rows rewritten to the + * upstream providers' shapes as they are read (see legacy-resources.ts) — + * reads only; rows written by this version are already upstream-shaped. + */ +const migrateRowsOnRead = (service: StateService): StateService => ({ + ...service, + get: (request) => + Effect.map(service.get(request), (value) => + value === undefined + ? undefined + : blindCast< + PersistedState, + 'migrateLegacyResourceState only rewrites legacy Composer resource rows to the upstream field names; every other value passes through unchanged, so the PersistedState shape is preserved' + >(migrateLegacyResourceState(value)), + ), + getReplacedResources: (request) => + Effect.map(service.getReplacedResources(request), (rows) => + rows.map((row) => + blindCast< + ReplacedResourceState, + 'migrateLegacyResourceState only rewrites legacy Composer resource rows to the upstream field names; the replaced status and envelope shape are preserved' + >(migrateLegacyResourceState(row)), + ), + ), +}); /** `prismaStateLayer` with the API origin injectable — split out so tests can point it at a fake state API. */ export const stateLayerAgainst = ( @@ -117,6 +157,8 @@ export const stateLayerAgainst = ( id: 'prisma-postgres', }).pipe(Effect.provide(FetchHttpClient.layer)); + const migrated = migrateRowsOnRead(service); + // Report what this run touches to the build it belongs to, when there // is one. There is none when nothing created a build — a direct // `alchemy deploy` of the generated stack file, which runs with no CLI @@ -124,11 +166,11 @@ export const stateLayerAgainst = ( // the store is used unwrapped and the deploy is unaffected. const buildId = process.env[BUILD_ID_ENV]; if (buildId === undefined || buildId.length === 0) { - return Effect.succeed(service); + return Effect.succeed(migrated); } const { store, reporter } = withResourceReporting( - service, + migrated, buildsApi({ // The same client the lease and the scope probe already use. client: mgmt, diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/legacy-resources.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/legacy-resources.ts new file mode 100644 index 000000000..587c7cc9f --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/legacy-resources.ts @@ -0,0 +1,323 @@ +/** + * One-time, on-read rewrite of legacy Composer state rows into the shapes + * upstream alchemy's `Prisma.*` providers expect, so their `read`/`diff` + * adopts the deployed resources instead of planning a create. Old rows carry + * hand-rolled attributes (`{id, name}`, `{id, connectionString}`, …); upstream + * expects `{projectId, …}` / `{databaseId, …}` / etc. Fields old rows never + * carried are left absent — upstream recomputes them from observed API state. + * Legacy `DATABASE_URL` claim rows are retired ({@link retireDatabaseUrlClaimRow}). + * + * Operator-visible one-time effects of the first migrated deploy (branch-stage + * database rename + default-connection rotation, one fresh deployment per + * service) are documented in docs/guides/deploying.md. Hosted state only: + * local dev state is cleared with `prisma-composer dev --fresh` instead. + */ + +import * as Redacted from 'effect/Redacted'; +import { ARTIFACT_CONTENT_TYPE } from '../compute/artifact.ts'; +import { RESERVED_DATABASE_URL_KEYS } from '../database-url-claim.ts'; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const EPOCH = '1970-01-01T00:00:00.000Z'; + +/** Where a legacy row recorded no region: the only region Composer's descriptors ever defaulted to. */ +const DEFAULT_REGION = 'us-east-1'; + +/** + * The reserved keys older Composer versions claimed through tracked + * EnvironmentVariable resources. Today the claim is a create-only API call + * outside deploy state (database-url-claim.ts, whose key set this is), so the + * tracked rows those versions left behind are disposed of here. + */ +const CLAIMED_DATABASE_URL_KEYS: ReadonlySet = new Set(RESERVED_DATABASE_URL_KEYS); + +type Family = 'Project' | 'Database' | 'Connection' | 'App' | 'Deployment' | 'EnvironmentVariable'; + +const FAMILY_BY_LEGACY_TYPE: Readonly> = { + 'Prisma.Project': 'Project', + 'Prisma.Database': 'Database', + 'Prisma.Connection': 'Connection', + 'Prisma.ComputeService': 'App', + 'Prisma.Deployment': 'Deployment', + 'Prisma.EnvironmentVariable': 'EnvironmentVariable', + 'PrismaComposer.Project': 'Project', + 'PrismaComposer.Database': 'Database', + 'PrismaComposer.Connection': 'Connection', + 'PrismaComposer.ComputeService': 'App', + 'PrismaComposer.Deployment': 'Deployment', + 'PrismaComposer.EnvironmentVariable': 'EnvironmentVariable', +}; + +/** The type-id upstream registers each family under. */ +const UPSTREAM_TYPE: Readonly> = { + Project: 'Prisma.Project', + Database: 'Prisma.Database', + Connection: 'Prisma.Connection', + App: 'Prisma.App', + Deployment: 'Prisma.Deployment', + EnvironmentVariable: 'Prisma.EnvironmentVariable', +}; + +/** + * Four of the six families keep the type-id they always had, so the type-id + * alone cannot say whether a row is legacy or already upstream's. Each shape + * is told apart by a props field only the legacy one has, which is also what + * makes the whole rewrite idempotent. + */ +const isLegacyProps = (family: Family, props: Record): boolean => { + switch (family) { + case 'Project': + return 'workspaceId' in props; + case 'Database': + return 'projectId' in props && !('project' in props); + case 'Connection': + return 'databaseId' in props && !('database' in props); + case 'App': + return 'projectId' in props && !('project' in props); + case 'Deployment': + return 'computeServiceId' in props; + case 'EnvironmentVariable': + return 'projectId' in props && !('project' in props); + } +}; + +const migrateProps = (family: Family, props: unknown): unknown => { + if (!isRecord(props) || !isLegacyProps(family, props)) return props; + switch (family) { + case 'Project': + // Upstream ProjectProps carry no workspaceId; keep only the name. + return { name: props['name'] }; + case 'Database': + return { + project: props['projectId'], + name: props['name'], + region: props['region'], + ...(props['branchId'] !== undefined ? { branchId: props['branchId'] } : {}), + }; + case 'Connection': + return { database: props['databaseId'], name: props['name'] }; + case 'App': + return { + project: props['projectId'], + displayName: props['name'], + regionId: props['region'] ?? DEFAULT_REGION, + ...(props['branchId'] !== undefined ? { branchId: props['branchId'] } : {}), + }; + case 'Deployment': + // The legacy `environment` prop is dropped: upstream's Deployment has no + // such prop, and the ordering edge it carried rides `app` instead (see + // compute/deployment-edge.ts). `start`/`promote` are what the legacy + // provider always did unconditionally. + return { + app: props['computeServiceId'], + artifactPath: props['artifactPath'], + artifactContentType: ARTIFACT_CONTENT_TYPE, + ...(props['port'] !== undefined ? { portMapping: { http: props['port'] } } : {}), + start: true, + promote: true, + }; + case 'EnvironmentVariable': { + const value = props['value']; + return { + project: props['projectId'], + key: props['key'], + class: props['class'] ?? 'production', + // Legacy rows persisted the value as PLAIN TEXT. Upstream types it + // `Redacted`, which is also what keeps it out of the next state write. + value: Redacted.isRedacted(value) ? value : Redacted.make(String(value ?? '')), + ...(props['branchId'] !== undefined ? { branchId: props['branchId'] } : {}), + }; + } + } +}; + +/** + * A legacy claim row (an EnvironmentVariable resource an older Composer + * persisted for a reserved DATABASE_URL key) names a variable Composer must + * stop managing. Deleting one + * for real is not safe: the legacy adoption matched on `{projectId, class, + * key}` with no branch id, so the recorded scope may not equal the live + * variable's, and upstream's delete refuses — loudly, mid-deploy — on a scope + * mismatch. Whether the live variable is the platform's own system-managed + * template or the `"-"` placeholder Composer wrote over it depends on the + * stage, and neither is Composer's to remove. + * + * Two halves, doing different jobs: + * + * · `removalPolicy: "retain"` on the ROW. Alchemy's engine honors it before + * the provider is ever consulted: it drops the state row, makes no API + * call, and reports the resource as `retained` rather than `deleted` — + * which is the truthful verb, and the one an operator reading the deploy + * log needs to see. + * · An `environmentVariableId` the engine reads as "not a cloud resource" + * (`isPrismaDevId`). This governs what the PROVIDER would do if it were + * ever handed these attributes on some other path: nothing. + */ +const retireDatabaseUrlClaimRow = (row: Record, key: string) => ({ + ...row, + removalPolicy: 'retain', + attr: { environmentVariableId: `dev:legacy-claim-${key}`, key }, +}); + +/** + * The reserved key a legacy claim row named, from whichever half of the row still carries it + * — for LEGACY rows only. Upstream's own `EnvironmentVariable` rows share this + * type-id, so without the props-shape check a live, upstream-managed + * `DATABASE_URL` variable would be retired from state on every read. + */ +const claimedKeyOf = (family: Family, props: unknown, attr: unknown): string | undefined => { + if (family !== 'EnvironmentVariable') return undefined; + if (!isRecord(props) || !isLegacyProps(family, props)) return undefined; + const fromAttr = isRecord(attr) ? attr['key'] : undefined; + const fromProps = isRecord(props) ? props['key'] : undefined; + const key = typeof fromAttr === 'string' ? fromAttr : fromProps; + return typeof key === 'string' && CLAIMED_DATABASE_URL_KEYS.has(key) ? key : undefined; +}; + +const migrateAttr = (family: Family, attr: unknown, props: unknown): unknown => { + if (!isRecord(attr)) return attr; + const oldProps = isRecord(props) ? props : {}; + switch (family) { + case 'Project': { + if (typeof attr['id'] !== 'string' || 'projectId' in attr) return attr; + return { + projectId: attr['id'], + projectName: attr['name'], + workspaceId: oldProps['workspaceId'] ?? '', + createdAt: EPOCH, + defaultRegion: null, + }; + } + case 'Database': { + if (typeof attr['id'] !== 'string' || 'databaseId' in attr) return attr; + return { + databaseId: attr['id'], + databaseName: attr['name'] ?? oldProps['name'], + projectId: oldProps['projectId'], + status: 'ready', + region: oldProps['region'] ?? null, + isDefault: oldProps['isDefault'] ?? false, + branchId: oldProps['branchId'] ?? null, + defaultConnectionId: null, + createdAt: EPOCH, + }; + } + case 'Connection': { + if (typeof attr['id'] !== 'string' || 'connectionId' in attr) return attr; + return { + connectionId: attr['id'], + connectionName: oldProps['name'], + databaseId: oldProps['databaseId'], + kind: 'postgres', + createdAt: EPOCH, + directConnectionString: attr['connectionString'], + databaseUrl: attr['connectionString'], + }; + } + case 'App': { + if (typeof attr['id'] !== 'string' || 'appId' in attr) return attr; + return { + appId: attr['id'], + name: attr['name'] ?? oldProps['name'], + projectId: oldProps['projectId'], + regionId: oldProps['region'] ?? DEFAULT_REGION, + // A production row recorded no branch: null makes upstream's diff plan + // an update, whose reconcile re-reads the App and records the real + // default-branch id. A branch stage recorded its own and converges. + branchId: oldProps['branchId'] ?? null, + latestDeploymentId: null, + // Absent only on a row written before the platform returned a domain. + // Left unset rather than faked: a service's own origin is read from + // this attribute, and an empty string would wire a broken origin + // silently where an absent one fails loudly. + ...(attr['endpointDomain'] !== undefined + ? { appEndpointDomain: attr['endpointDomain'] } + : {}), + createdAt: EPOCH, + }; + } + case 'Deployment': { + if (typeof attr['deploymentId'] !== 'string' || 'appId' in attr) return attr; + return { + deploymentId: attr['deploymentId'], + appId: oldProps['computeServiceId'], + // `foundryVersionId` is deliberately absent, not invented: upstream + // uses it to recover a deployment whose id was lost, and a made-up + // one would either match nothing or claim a stranger's deployment. + // Absent means "recover by deployment id only". + status: undefined, + previewDomain: undefined, + appEndpointDomain: attr['deployedUrl'], + createdAt: undefined, + }; + } + case 'EnvironmentVariable': { + if (typeof attr['id'] !== 'string' || 'environmentVariableId' in attr) return attr; + return { + environmentVariableId: attr['id'], + projectId: oldProps['projectId'], + branchId: oldProps['branchId'] ?? null, + class: oldProps['class'] ?? 'production', + key: attr['key'] ?? oldProps['key'], + // The API never returns plaintext, so upstream's own cold-read + // placeholder is what belongs here — the desired value arrives from + // props on every reconcile. + value: Redacted.make(''), + valueKid: '', + isManagedBySystem: false, + createdAt: EPOCH, + updatedAt: EPOCH, + }; + } + } +}; + +const migrateResourceRow = (row: Record): Record => { + const resourceType = row['resourceType']; + if (typeof resourceType !== 'string') return row; + const family = FAMILY_BY_LEGACY_TYPE[resourceType]; + if (family === undefined) return row; + + const migrated: Record = { + ...row, + resourceType: UPSTREAM_TYPE[family], + ...('props' in row ? { props: migrateProps(family, row['props']) } : {}), + ...('attr' in row ? { attr: migrateAttr(family, row['attr'], row['props']) } : {}), + }; + + // Replacement rows nest the displaced generation under `old` (a full row); + // updating rows nest `{props, attr, bindings}`. Migrate both forms so no + // stale shape survives anywhere in the chain. + const old = row['old']; + if (isRecord(old)) { + migrated['old'] = + typeof old['resourceType'] === 'string' + ? migrateResourceRow(old) + : { + ...old, + ...('props' in old ? { props: migrateProps(family, old['props']) } : {}), + ...('attr' in old ? { attr: migrateAttr(family, old['attr'], old['props']) } : {}), + }; + } + + // Retiring the row happens AFTER the `old` chain is rewritten: a replaced + // claim row still carries the displaced generation, and it must reach the + // engine in the upstream shape even though this row is on its way out. + const claimedKey = claimedKeyOf(family, row['props'], row['attr']); + if (claimedKey !== undefined) return retireDatabaseUrlClaimRow(migrated, claimedKey); + + return migrated; +}; + +/** + * Maps a revived state value from a legacy Composer resource shape to the + * upstream shape. Rows of other resource types (and action rows) pass through + * untouched; the function is idempotent, so already-migrated rows pass + * through too. + */ +export const migrateLegacyResourceState = (value: unknown): unknown => { + if (!isRecord(value) || value['kind'] === 'action') return value; + return migrateResourceRow(value); +}; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/tsdown.config.ts b/packages/1-prisma-cloud/0-lowering/lowering/tsdown.config.ts index a2095f4a9..bc770c692 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/tsdown.config.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/tsdown.config.ts @@ -6,7 +6,6 @@ export default defineConfig({ buckets: 'src/exports/buckets.ts', builds: 'src/exports/builds.ts', compute: 'src/exports/compute.ts', - postgres: 'src/exports/postgres.ts', state: 'src/exports/state.ts', }, }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts index 44bd6ecfe..ebcb1ffbf 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts @@ -14,6 +14,7 @@ import { secretString } from '@internal/foundation/arktype'; // mode regardless of the (filesystem-dependent) test-file order. import * as RealPrismaAlchemy from '@internal/lowering'; import * as RealOutput from 'alchemy/Output'; +import * as RealAlchemyPrisma from 'alchemy/Prisma'; import { type } from 'arktype'; import * as Effect from 'effect/Effect'; import * as Redacted from 'effect/Redacted'; @@ -40,6 +41,7 @@ import * as RealS3Credentials from '../s3-credentials-resource.ts'; // the resolved value the mock resource returned). const recorded: { envVar: Array<[string, unknown]>; + envVarProps: Array<[string, { value: unknown }]>; db: Array<[string, unknown]>; conn: Array<[string, unknown]>; warm: Array<[string, unknown]>; @@ -51,8 +53,10 @@ const recorded: { bucket: Array<[string, unknown]>; bucketKey: Array<[string, unknown]>; generated: Array<[string, unknown]>; + databaseUrlClaims: string[]; } = { envVar: [], + envVarProps: [], db: [], conn: [], warm: [], @@ -64,6 +68,7 @@ const recorded: { bucket: [], bucketKey: [], generated: [], + databaseUrlClaims: [], }; mock.module('alchemy/Output', () => ({ @@ -72,14 +77,73 @@ mock.module('alchemy/Output', () => ({ // Mirrors `map` above: every "output" here is already the resolved value a // mock resource returned, so combining them is just collecting the array. all: (...outs: unknown[]) => outs, + // Same collapse for `flatMap`, whose function returns an "output" that is + // already a resolved value here. What flatMap is FOR — keeping a dependency + // edge visible to Alchemy's planner while resolving to a value — cannot be + // observed through these mocks at all; it is proven against the real Output + // machinery in @internal/lowering's deployment-edge test. + flatMap: (output: unknown, fn: (v: unknown) => unknown) => fn(output), +})); + +// The postgres family (Database/Connection) and the compute family +// (App/Deployment/EnvironmentVariable) are upstream alchemy's — the descriptors +// import them from 'alchemy/Prisma', so the stubs live there. The returned +// attributes use upstream's field names (`databaseId`, `appId`, +// `appEndpointDomain`, `environmentVariableId`). +mock.module('alchemy/Prisma', () => ({ + ...RealAlchemyPrisma, + App: (id: string, props: unknown) => { + recorded.svc.push([id, props]); + return Effect.succeed({ + appId: `${id}#cloud-id`, + name: id, + appEndpointDomain: `https://${id}.example`, + }); + }, + Deployment: (id: string, props: unknown) => { + recorded.deploy.push([id, props]); + return Effect.succeed({ + deploymentId: 'v1', + appEndpointDomain: `https://${id}.example`, + }); + }, + EnvironmentVariable: (id: string, props: { key: string; value: unknown }) => { + // Upstream's prop is `Redacted`. Recorded UNWRAPPED so every row + // assertion below can name the value it expects in plain text; the + // wrapper itself is pinned by its own test, off `recorded.envVarProps`. + recorded.envVarProps.push([id, props]); + recorded.envVar.push([ + id, + { + ...props, + value: Redacted.isRedacted(props.value) ? Redacted.value(props.value) : props.value, + }, + ]); + return Effect.succeed({ environmentVariableId: `${id}#cloud-id`, key: props.key }); + }, + Database: (id: string, props: unknown) => { + recorded.db.push([id, props]); + return Effect.succeed({ databaseId: `${id}#cloud-id`, databaseName: id }); + }, + Connection: (id: string, props: unknown) => { + recorded.conn.push([id, props]); + return Effect.succeed({ + connectionId: `${id}#cloud-id`, + directConnectionString: Redacted.make(`postgres://${id}`), + }); + }, })); mock.module('@internal/lowering', () => ({ ...RealPrismaAlchemy, providers: () => ({ stub: 'providers' }), - EnvironmentVariable: (id: string, props: { key: string }) => { - recorded.envVar.push([id, props]); - return Effect.succeed({ id: `${id}#cloud-id`, key: props.key }); + // Talks to the Management API directly (no Alchemy resource, by design); + // stubbed so the application hook runs purely. The projectId it claims for + // is recorded — what the claim POSTs is pinned by its own test in + // @internal/lowering. + claimDatabaseUrlKeys: (projectId: string) => { + recorded.databaseUrlClaims.push(projectId); + return Effect.void; }, // A real Alchemy Resource (needs the Stack service); stubbed so // application.provision's mint runs purely. The returned "value" is @@ -89,17 +153,6 @@ mock.module('@internal/lowering', () => ({ recorded.serviceKey.push([id, props]); return Effect.succeed({ value: `key-for-${id}` }); }, - Database: (id: string, props: unknown) => { - recorded.db.push([id, props]); - return Effect.succeed({ id: `${id}#cloud-id`, name: id }); - }, - Connection: (id: string, props: unknown) => { - recorded.conn.push([id, props]); - return Effect.succeed({ - id: `${id}#cloud-id`, - connectionString: Redacted.make(`postgres://${id}`), - }); - }, Bucket: (id: string, props: unknown) => { recorded.bucket.push([id, props]); return Effect.succeed({ id: `${id}#cloud-id`, name: id }); @@ -115,22 +168,18 @@ mock.module('@internal/lowering', () => ({ bucketName: 'user-bucket-stub', }); }, - ComputeService: (id: string, props: unknown) => { - recorded.svc.push([id, props]); - return Effect.succeed({ - id: `${id}#cloud-id`, - name: id, - endpointDomain: `https://${id}.example`, - }); - }, - Deployment: (id: string, props: unknown) => { - recorded.deploy.push([id, props]); - return Effect.succeed({ deploymentId: 'v1', deployedUrl: `https://${id}.example` }); - }, packageComputeArtifact: (opts: { id: string }) => { recorded.pkg.push([opts]); return { path: `/tmp/${opts.id}.tar.gz`, sha256: `sha-${opts.id}` }; }, + // The real one hard-links the artifact on disk; a pass-through that appends + // the fingerprint keeps the data flow pure while letting deploy assertions + // pin BOTH that the hook routes the path through the fingerprint seam and + // what it fingerprinted. The real hashing is pinned in @internal/lowering's + // own `deploy-fingerprint.test.ts`, so the stub hashes nothing. + deployEnvFingerprint: (entries: unknown) => JSON.stringify(entries), + fingerprintedArtifactPath: (artifactPath: string, fingerprint: string) => + `${artifactPath}#${fingerprint}`, })); // PgWarm is a real Alchemy Resource (needs the Stack service); stub it so the @@ -207,7 +256,7 @@ const run = (eff: Effect.Effect): A => type Resolved = T extends RealOutput.Output ? U : T; type Mirror = { readonly [K in keyof T]: Resolved }; /** The mock EnvironmentVariable, standing in for the real resource. */ -type MockedEnvironment = ReadonlyArray<{ id: string; key: string }>; +type MockedEnvironment = ReadonlyArray<{ environmentVariableId: string; key: string }>; type MockedProvisioned = Mirror; type MockedSerialized = Omit, 'environment'> & { @@ -306,9 +355,10 @@ describe("projectIdOf — narrowing ctx.application to this extension's own prod }); describe('prismaCloud().application.provision (once-per-lowering hook)', () => { - test('default stage: references the resolved container project (no Project minted), poisons DATABASE_URL + DATABASE_URL_POOLED with "-", class production, no branchId', () => { + test('default stage: references the resolved container project (no Project minted) and claims the DATABASE_URL keys', () => { const target = prismaCloud({ workspaceId: 'ws_1' }); - const before = recorded.envVar.length; + const beforeEnv = recorded.envVar.length; + const beforeClaims = recorded.databaseUrlClaims.length; const container = new PrismaCloudContainer( { appName: 'shop', stage: undefined }, 'shop-project-id', @@ -323,32 +373,18 @@ describe('prismaCloud().application.provision (once-per-lowering hook)', () => { ); expect(result).toEqual({ projectId: 'shop-project-id', branchId: undefined }); - // "-", not "": the API rejects empty env-var values (verified at the R4 deploy proof). - expect(recorded.envVar.slice(before)).toEqual([ - [ - 'DATABASE_URL-poison', - { - projectId: 'shop-project-id', - key: 'DATABASE_URL', - value: '-', - class: 'production', - }, - ], - [ - 'DATABASE_URL_POOLED-poison', - { - projectId: 'shop-project-id', - key: 'DATABASE_URL_POOLED', - value: '-', - class: 'production', - }, - ], - ]); + expect(recorded.databaseUrlClaims.slice(beforeClaims)).toEqual(['shop-project-id']); + // The claim is a direct Management API create, NOT an alchemy resource: + // Composer must never plan a write or a delete for either variable, and a + // state row would do exactly that. So no EnvironmentVariable is declared + // here — for the DATABASE_URL keys or anything else. + expect(recorded.envVar.slice(beforeEnv)).toEqual([]); }); - test('named stage: poison env vars carry class "preview" and branchId', () => { + test('named stage: claims the same project-level keys, and still declares no environment variable', () => { const target = prismaCloud({ workspaceId: 'ws_1' }); - const before = recorded.envVar.length; + const beforeEnv = recorded.envVar.length; + const beforeClaims = recorded.databaseUrlClaims.length; const container = new PrismaCloudContainer( { appName: 'shop', stage: 'staging' }, 'shop-project-id', @@ -363,28 +399,10 @@ describe('prismaCloud().application.provision (once-per-lowering hook)', () => { ); expect(result).toEqual({ projectId: 'shop-project-id', branchId: 'branch_1' }); - expect(recorded.envVar.slice(before)).toEqual([ - [ - 'DATABASE_URL-poison', - { - projectId: 'shop-project-id', - key: 'DATABASE_URL', - value: '-', - class: 'preview', - branchId: 'branch_1', - }, - ], - [ - 'DATABASE_URL_POOLED-poison', - { - projectId: 'shop-project-id', - key: 'DATABASE_URL_POOLED', - value: '-', - class: 'preview', - branchId: 'branch_1', - }, - ], - ]); + // The branch id never reaches the claim: the rows are project-level, so + // one claim covers every stage of the project. + expect(recorded.databaseUrlClaims.slice(beforeClaims)).toEqual(['shop-project-id']); + expect(recorded.envVar.slice(beforeEnv)).toEqual([]); }); test('fails with the container-missing error when the CLI parent never resolved one', () => { @@ -419,10 +437,16 @@ describe("prismaCloud().nodes['postgres'] — the resource descriptor", () => { // meaning, which is exactly why only the descriptor can decide. expect(result.entities).toEqual([{ kind: 'postgres-database', id: 'data-db#cloud-id' }]); expect(recorded.db).toEqual([ - ['data-db', { projectId: 'shop-project#cloud-id', name: 'data', region: 'us-east-1' }], + ['data-db', { project: 'shop-project#cloud-id', name: 'data', region: 'us-east-1' }], ]); expect(recorded.conn).toEqual([ - ['data-conn', { databaseId: 'data-db#cloud-id', name: 'data' }], + [ + 'data-conn', + { + database: { databaseId: 'data-db#cloud-id', databaseName: 'data-db' }, + name: 'data', + }, + ], ]); }); }); @@ -438,12 +462,13 @@ describe("prismaCloud().nodes['postgres'] — the resource descriptor", () => { run(resourceDescriptorOf(target, 'postgres')(ctx)); + // A named stage attaches the branch at create, which upstream only + // permits WITHOUT an explicit display name — so `name` is absent here. expect(recorded.db.slice(before)).toEqual([ [ 'data2-db', { - projectId: 'shop-project#cloud-id', - name: 'data2', + project: 'shop-project#cloud-id', region: 'us-east-1', branchId: 'branch_1', }, @@ -540,7 +565,10 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { endpointDomain: 'https://auth-svc.example', }); expect(recorded.svc).toEqual([ - ['auth-svc', { projectId: 'shop-project#cloud-id', name: 'auth', region: 'us-east-1' }], + [ + 'auth-svc', + { project: 'shop-project#cloud-id', displayName: 'auth', regionId: 'us-east-1' }, + ], ]); }); }); @@ -560,9 +588,9 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { [ 'auth2-svc', { - projectId: 'shop-project#cloud-id', - name: 'auth2', - region: 'us-east-1', + project: 'shop-project#cloud-id', + displayName: 'auth2', + regionId: 'us-east-1', branchId: 'branch_1', }, ], @@ -606,7 +634,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { [ 'COMPOSER_AUTH_DB_URL-var', { - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_AUTH_DB_URL', value: 'postgres://real-db', class: 'production', @@ -618,7 +646,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { [ 'COMPOSER_AUTH_PORT-var', { - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_AUTH_PORT', value: '3000', class: 'production', @@ -629,7 +657,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { [ 'COMPOSER_AUTH_ORIGIN-var', { - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_AUTH_ORIGIN', value: '"https://auth-svc.example"', class: 'production', @@ -637,9 +665,28 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { ], ]); expect(result.environment).toEqual([ - { id: 'COMPOSER_AUTH_DB_URL-var#cloud-id', key: 'COMPOSER_AUTH_DB_URL' }, - { id: 'COMPOSER_AUTH_PORT-var#cloud-id', key: 'COMPOSER_AUTH_PORT' }, - { id: 'COMPOSER_AUTH_ORIGIN-var#cloud-id', key: 'COMPOSER_AUTH_ORIGIN' }, + { + environmentVariableId: 'COMPOSER_AUTH_DB_URL-var#cloud-id', + key: 'COMPOSER_AUTH_DB_URL', + }, + { environmentVariableId: 'COMPOSER_AUTH_PORT-var#cloud-id', key: 'COMPOSER_AUTH_PORT' }, + { + environmentVariableId: 'COMPOSER_AUTH_ORIGIN-var#cloud-id', + key: 'COMPOSER_AUTH_ORIGIN', + }, + ]); + // The same three rows as the deploy hook fingerprints them: the + // service's OWN literal param is config and is hashed as text; the + // provider param's may be a minted key, so it is withheld and named by + // the resources it is built from. The dependency input's value is + // RESOLVED under these mocks (no upstream resources), so it is treated + // as authored config and hashed as text — in a real deploy a + // resource-built connection string is still an Output and stays + // withheld with the resource names. + expect(result.envFingerprint).toEqual([ + { key: 'COMPOSER_AUTH_DB_URL', value: 'postgres://real-db' }, + { key: 'COMPOSER_AUTH_PORT', value: '3000' }, + { key: 'COMPOSER_AUTH_ORIGIN', withheld: 'provider.ORIGIN:' }, ]); // serialize also surfaces the resolved listen port for deploy() — the // Deployment must route to whatever the app binds, not a constant. @@ -647,6 +694,49 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { }); }); + test('every env-var value reaches the platform as a Redacted value, never a bare string', async () => { + await withEnv({}, () => { + const target = prismaCloud({ workspaceId: 'ws_1' }); + const node = compute({ + name: 'test-service', + deps: { + db: postgres(), + }, + build: { + extension: '@prisma/composer/node', + type: 'node', + module: 'file:///test/service.ts', + entry: 'server.js', + }, + }); + const ctx = { + address: 'auth', + node, + graph: { inputBindings: [], edges: [] }, + application: { projectId: 'shop-project#cloud-id', branchId: undefined }, + } as unknown as LowerContext; + const provisioned = { + serviceId: 'auth-svc#cloud-id', + projectId: 'shop-project#cloud-id', + endpointDomain: 'https://auth-svc.example', + }; + const before = recorded.envVarProps.length; + + run( + serviceDescriptorOf(target, 'compute').serialize(ctx, provisioned, { + service: { port: 3000 }, + inputs: { db: { url: 'postgres://real-db' } }, + }), + ); + + const written = recorded.envVarProps.slice(before); + expect(written.length).toBeGreaterThan(0); + // A bare string here would put the value in Alchemy's state file in + // plain text — the wrapper is what keeps it out. + expect(written.every(([, props]) => Redacted.isRedacted(props.value))).toBe(true); + }); + }); + test('an optional connection param with no provisioned value writes NO env-var row; a provided one still does', async () => { await withEnv({}, () => { const target = prismaCloud({ workspaceId: 'ws_1' }); @@ -694,7 +784,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { const writes = recorded.envVar.slice(before).map(([, props]) => props); // The provided url still writes its row... expect(writes).toContainEqual({ - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_CONSUMER_AUTH_URL', value: 'http://auth.internal', class: 'production', @@ -754,13 +844,23 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { // One self-describing document; the secret leaf is a pointer naming // the platform var, never a value. expect(writes).toContainEqual({ - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_INGEST_INPUT', value: '{"stripeEnabled":true,"stripeKey":{"$secret":"STRIPE_SECRET_KEY"}}', class: 'production', }); // No serialized EnvironmentVariable output carries the secret's value. expect(JSON.stringify(writes)).not.toContain('sk_live'); + // Neither does what the deploy hook fingerprints: the document is + // hashed as text (it is secret-free by construction) and the platform + // variable it points at is named, so its rotation timestamp can join + // the hash — the VALUE is nowhere near it. + expect(result.envFingerprint).toContainEqual({ + key: 'COMPOSER_INGEST_INPUT', + value: '{"stripeEnabled":true,"stripeKey":{"$secret":"STRIPE_SECRET_KEY"}}', + pointers: ['STRIPE_SECRET_KEY'], + }); + expect(JSON.stringify(result.envFingerprint)).not.toContain('sk_live'); // The row also rides the serialize → deploy handoff, so deploy() can // put the document (secret-free by construction) on the report entity. expect(result.input).toEqual({ @@ -768,6 +868,9 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { value: '{"stripeEnabled":true,"stripeKey":{"$secret":"STRIPE_SECRET_KEY"}}', absent: [], generated: [], + // The pointed platform variable, so the deploy hook can fold its + // rotation timestamp into the environment fingerprint. + secrets: ['STRIPE_SECRET_KEY'], }); }, ); @@ -819,7 +922,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { // document's $generated pointer names. const writes = recorded.envVar.slice(beforeEnv).map(([, props]) => props); expect(writes).toContainEqual({ - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_INGEST_SECRET_GENERATED', value: 'generated-for-COMPOSER_INGEST_INPUT:secret-generated', class: 'production', @@ -832,6 +935,17 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { expect(result.input?.generated).toEqual([ { varName: 'COMPOSER_INGEST_SECRET_GENERATED', bytes: 48, redacted: true, path: 'secret' }, ]); + // The generated row holds a minted random value, so the deploy hook + // fingerprints it as withheld — and NOT by its platform `updatedAt` + // either: Composer rewrites this row every deploy, so that timestamp + // would move every deploy and the fingerprint would never settle. + expect(result.envFingerprint).toContainEqual({ + key: 'COMPOSER_INGEST_SECRET_GENERATED', + withheld: 'generated:48:true', + }); + expect(JSON.stringify(result.envFingerprint)).not.toContain( + 'generated-for-COMPOSER_INGEST_INPUT', + ); }); }); @@ -876,6 +990,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { value: '{}', absent: ['greeting → NOT_SET_GREETING_VAR'], generated: [], + secrets: [], }); }); }); @@ -918,20 +1033,29 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { const config = { service: { port: envParam('PLATFORM_PORT') }, inputs: {} }; const before = recorded.envVar.length; - run( + const result = run( serviceDescriptorOf(target, 'compute').serialize(ctx, provisioned, config), ); const writes = recorded.envVar.slice(before).map(([, props]) => props); // The pointer row holds the bound platform NAME, never a value. expect(writes).toContainEqual({ - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_WEB_PORT', value: '@composer-param-pointer:PLATFORM_PORT', class: 'production', }); // No serialized EnvironmentVariable output carries the actual value. expect(JSON.stringify(writes)).not.toContain('8443'); + // The deploy hook fingerprints the pointer row by its text AND by the + // platform variable it names, so rotating PLATFORM_PORT out of band + // ships a new deployment even though the row itself never moves. + expect(result.envFingerprint).toContainEqual({ + key: 'COMPOSER_WEB_PORT', + value: '@composer-param-pointer:PLATFORM_PORT', + pointers: ['PLATFORM_PORT'], + }); + expect(JSON.stringify(result.envFingerprint)).not.toContain('8443'); }, ); }); @@ -972,7 +1096,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { const writes = recorded.envVar.slice(before).map(([, props]) => props); expect(writes).toContainEqual({ - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_WEB_PORT', value: '4100', class: 'production', @@ -1014,7 +1138,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { [ 'COMPOSER_AUTH3_PORT-var', { - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_AUTH3_PORT', value: '3000', class: 'preview', @@ -1024,7 +1148,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { [ 'COMPOSER_AUTH3_ORIGIN-var', { - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_AUTH3_ORIGIN', value: '"https://svc.example"', class: 'preview', @@ -1094,13 +1218,21 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { expect(result).toEqual({ path: '/tmp/auth.tar.gz', sha256: 'sha-auth' }); }); - test("deploy's environment prop IS serialize's returned records — the edge that kills PRO-211", () => { + test("deploy's artifactPath carries serialize's env records — the ordering edge that kills PRO-211", () => { const target = prismaCloud({ workspaceId: 'ws_1' }); const ctx = { id: 'auth' } as unknown as LowerContext; const provisioned = { serviceId: 'auth-svc#cloud-id', projectId: 'shop-project#cloud-id' }; const artifact = { path: '/tmp/auth.tar.gz', sha256: 'sha-auth' }; const serialized = { - environment: [{ id: 'COMPOSER_AUTH_DB_URL-var#cloud-id', key: 'COMPOSER_AUTH_DB_URL' }], + environment: [ + { + environmentVariableId: 'COMPOSER_AUTH_DB_URL-var#cloud-id', + key: 'COMPOSER_AUTH_DB_URL', + }, + ], + // A dependency-input row: its value is a provisioning ref (a connection + // string), so serialize withholds the text and names what produces it. + envFingerprint: [{ key: 'COMPOSER_AUTH_DB_URL', withheld: 'input.db:db-postgres' }], // A non-default port from serialize must reach the Deployment verbatim. port: 8080, }; @@ -1109,15 +1241,22 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { serviceDescriptorOf(target, 'compute').deploy(ctx, provisioned, artifact, serialized), ); + // The mocked `Output.all`/`Output.map` collapse to "apply the function to + // the collected values", so the recorded artifactPath is the resolved + // path — what the real Output resolves to as well. What the assertion + // pins is that the path is built FROM the env rows' ids, which is the + // dependency Alchemy schedules the writes on. expect(recorded.deploy).toEqual([ [ 'auth-deploy', { - computeServiceId: 'auth-svc#cloud-id', - artifactPath: '/tmp/auth.tar.gz', - artifactHash: 'sha-auth', - environment: serialized.environment, - port: 8080, + app: 'auth-svc#cloud-id', + artifactPath: + '/tmp/auth.tar.gz#[{"key":"COMPOSER_AUTH_DB_URL","withheld":"input.db:db-postgres"}]', + artifactContentType: 'application/gzip', + portMapping: { http: 8080 }, + start: true, + promote: true, }, ], ]); @@ -1427,21 +1566,27 @@ describe('sharing: one module-provisioned postgres, two compute consumers — th ); expect(recorded.db.slice(before.db)).toEqual([ - ['data-db', { projectId: 'shop-project#cloud-id', name: 'data', region: 'us-east-1' }], + ['data-db', { project: 'shop-project#cloud-id', name: 'data', region: 'us-east-1' }], ]); expect(recorded.conn.slice(before.conn)).toEqual([ - ['data-conn', { databaseId: 'data-db#cloud-id', name: 'data' }], + [ + 'data-conn', + { + database: { databaseId: 'data-db#cloud-id', databaseName: 'data-db' }, + name: 'data', + }, + ], ]); const writes = recorded.envVar.slice(before.envVar).map(([, props]) => props); expect(writes).toContainEqual({ - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_AUTH_MAIN_URL', value: 'postgres://data-conn', class: 'production', }); expect(writes).toContainEqual({ - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_BILLING_STORE_URL', value: 'postgres://data-conn', class: 'production', @@ -1512,13 +1657,13 @@ describe('ADR-0030: per-binding RPC service keys — mint (control.ts) + wire (d const writes = recorded.envVar.slice(before.envVar).map(([, props]) => props); expect(writes).toContainEqual({ - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_WEB_AUTH_SERVICEKEY', value: 'key-for-servicekey-web.auth', class: 'production', }); expect(writes).toContainEqual({ - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_AUTH_RPC_ACCEPTED_KEYS', value: '["key-for-servicekey-web.auth"]', class: 'production', @@ -1596,7 +1741,7 @@ describe('ADR-0030: per-binding RPC service keys — mint (control.ts) + wire (d const writes = recorded.envVar.slice(before.envVar).map(([, props]) => props); expect(writes).toContainEqual({ - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_AUTH3_RPC_ACCEPTED_KEYS', value: '[]', class: 'production', @@ -1718,7 +1863,7 @@ describe("streams' provisioned bearer key — one value per PROVIDER, stored on // validates and re-stashes it), JSON-encoded like any service-own // literal param. expect(writes).toContainEqual({ - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_EVENTS_STREAMS_API_KEY', value: '"key-for-streamskey-events"', class: 'production', @@ -1849,6 +1994,7 @@ describe("descriptors/compute.ts's provider-param loop is generic over the regis const o: ResolvedCloudOptions = { workspaceId: 'ws_1', providerParams, + pointerUpdatedAt: () => undefined, }; const node = compute({ name: 'multi', deps: {}, build, expose: { any: anyContract } }); const ctx = { @@ -1875,13 +2021,13 @@ describe("descriptors/compute.ts's provider-param loop is generic over the regis const writes = recorded.envVar.slice(before).map(([, props]) => props); expect(writes).toContainEqual({ - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_MULTI_PARAM_ONE', value: '"value-one"', class: 'production', }); expect(writes).toContainEqual({ - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_MULTI_PARAM_TWO', value: '"value-two"', class: 'production', diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts index 5da0725c1..04b7ebe3c 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts @@ -122,7 +122,7 @@ describe('invariant 2: authoring imports stay lean (core + pack)', () => { }); describe('invariant 4: environment touches are confined to the config serializer, the control factory, and the container lifecycle', () => { - test("the process-env token appears only in serializer.ts (param read+stash, reserved-provider-param read+stash — the origin row rides that generic pair — the input document's deploy-shell default + boot read/secret lookup/generated-pointer lookup/stash pair, env-sourced param double-lookup, readOrigin's stash read), control/extension.ts's prismaCloud() (ADR-0017 — optional PRISMA_WORKSPACE_ID + optional PRISMA_REGION, neither required — local-dev spec § 5's lazy restructure; the CLI-fed deploy identity now arrives via ctx.container, never env), container.ts (PRISMA_WORKSPACE_ID + PRISMA_SERVICE_TOKEN, ADR-0038's container lifecycle), preflight.ts (shell token + fill-missing lookup), local-target/preflight.ts (dev's own shell-token read — the local-dev value-sourcing policy, ADR-0041), compute.ts (exposes the resolved port as PORT), and testing.ts (bootstrapService's input-row + PORT writes, mirroring a deployed boot)", () => { + test("the process-env token appears only in serializer.ts (param read+stash, reserved-provider-param read+stash — the origin row rides that generic pair — the input document's deploy-shell default + boot read/secret lookup/generated-pointer lookup/stash pair, env-sourced param double-lookup, readOrigin's stash read), control/extension.ts's prismaCloud() (ADR-0017 — optional PRISMA_WORKSPACE_ID + optional PRISMA_REGION, neither required — local-dev spec § 5's lazy restructure; the CLI-fed deploy identity now arrives via ctx.container, never env; plus the env the preflight transport is read from, which pointer-timestamps.ts takes as an argument rather than reaching for), container.ts (PRISMA_WORKSPACE_ID + PRISMA_SERVICE_TOKEN, ADR-0038's container lifecycle), preflight.ts (shell token + fill-missing lookup), local-target/preflight.ts (dev's own shell-token read — the local-dev value-sourcing policy, ADR-0041), compute.ts (exposes the resolved port as PORT), and testing.ts (bootstrapService's input-row + PORT writes, mirroring a deployed boot)", () => { const sources = shippedSources(); expect(sources.length).toBeGreaterThan(0); @@ -135,7 +135,7 @@ describe('invariant 4: environment touches are confined to the config serializer expect(hits.sort((a, b) => a.file.localeCompare(b.file))).toEqual([ { file: 'compute.ts', count: 2 }, { file: 'container.ts', count: 3 }, - { file: 'control/extension.ts', count: 2 }, + { file: 'control/extension.ts', count: 3 }, { file: 'local-target/preflight.ts', count: 2 }, { file: 'preflight.ts', count: 2 }, { file: 'serializer.ts', count: 12 }, diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/param.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/param.test.ts index 412c5f424..6ad0b62af 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/param.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/param.test.ts @@ -12,7 +12,7 @@ describe('envParam (Prisma Cloud param source)', () => { ); }); - test('rejects empty, COMPOSER_-prefixed, and poisoned names — parity with envSecret', () => { + test('rejects empty, COMPOSER_-prefixed, and reserved DATABASE_URL names — parity with envSecret', () => { expect(() => envParam('')).toThrow(/non-empty/); expect(() => envParam('COMPOSER_X')).toThrow(/COMPOSER_/); expect(() => envParam('DATABASE_URL')).toThrow(/reserved/); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/pointer-timestamps.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/pointer-timestamps.test.ts new file mode 100644 index 000000000..5061bf3f6 --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/pointer-timestamps.test.ts @@ -0,0 +1,181 @@ +/** + * Runs BOTH halves of the rotation-timestamp transport: the CLI half + * produces a payload from a real `runPreflight`, the framework transport + * carries it as env vars, and the alchemy half rebuilds the lookup from + * those vars alone — injecting a lookup directly would pass even if nothing + * were transported. Assertions are on `deployEnvFingerprintMaterial` (the + * exact hashed text); the digest itself is stubbed process-globally by a + * sibling test via `mock.module`. + */ +import { describe, expect, test } from 'bun:test'; +import { Load, module } from '@internal/core'; +import { preflightEnv } from '@internal/core/config'; +import { + deployEnvFingerprintMaterial, + type EnvFingerprintEntry, + type ManagementApiClient, +} from '@internal/lowering'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import { PRISMA_CLOUD_EXTENSION_ID, PrismaCloudContainer } from '../container.ts'; +import { + deserializePointerUpdatedAt, + pointerUpdatedAtLookup, + serializePointerUpdatedAt, +} from '../control/pointer-timestamps.ts'; +import { compute } from '../exports/index.ts'; +import { runPreflight } from '../preflight.ts'; +import { envSecret } from '../secret.ts'; + +const build = { + extension: '@prisma/composer/node', + type: 'node', + module: 'file:///test/service.ts', + entry: 'server.js', +}; + +/** Load never validates a binding, so a pass-anything schema is enough here. */ +const anySchema: StandardSchemaV1 = { + '~standard': { version: 1, vendor: 'test', validate: (value) => ({ value }) }, +}; + +const graph = () => + Load( + module('app', ({ provision }) => { + provision(compute({ name: 'ingest', deps: {}, input: anySchema, build }), { + id: 'ingest', + input: { stripeKey: envSecret('STRIPE_SECRET_KEY') }, + }); + }), + ); + +/** A platform holding STRIPE_SECRET_KEY, last written at `updatedAt`. Its VALUE is never returned — the API returns none. */ +const fakePlatform = (updatedAt: string): ManagementApiClient => + ({ + GET: async () => ({ + data: { + data: [{ branchId: null, updatedAt }], + pagination: { nextCursor: null, hasMore: false }, + }, + error: undefined, + response: new Response(null, { status: 200 }), + }), + POST: async () => { + throw new Error('this fake platform already has every name; nothing should be created'); + }, + }) as unknown as ManagementApiClient; + +/** The service's rows as the deploy hook fingerprints them — the input document points at the rotating secret. */ +const envRows: readonly EnvFingerprintEntry[] = [ + { key: 'COMPOSER_INGEST_PORT', value: '3000' }, + { + key: 'COMPOSER_INGEST_INPUT', + value: '{"stripeKey":{"$secret":"STRIPE_SECRET_KEY"}}', + pointers: ['STRIPE_SECRET_KEY'], + }, +]; + +/** The CLI process: run the real preflight against a platform that last wrote the secret at `updatedAt`, and hand its findings to the framework transport. */ +async function cliProcess(updatedAt: string): Promise> { + const timestamps = await runPreflight( + { + graph: graph(), + container: new PrismaCloudContainer({ appName: 'app', stage: undefined }, 'proj', undefined), + stage: undefined, + }, + { client: fakePlatform(updatedAt) }, + ); + const payload = serializePointerUpdatedAt(timestamps); + return preflightEnv( + payload === undefined ? new Map() : new Map([[PRISMA_CLOUD_EXTENSION_ID, payload]]), + ); +} + +/** + * The alchemy process: it never ran a preflight, so its own map is empty and + * everything it knows comes from the transported env — exactly the state a + * fresh `prismaCloud()` is in there. Returns the text the deployment's + * fingerprint is the hash of. + */ +function alchemyProcessMaterial(env: Record): string { + return deployEnvFingerprintMaterial(envRows, pointerUpdatedAtLookup(new Map(), env)); +} + +describe('the rotation signal across the CLI → alchemy process boundary', () => { + test('a secret rotated on the platform moves the fingerprint in the alchemy process', async () => { + const before = alchemyProcessMaterial(await cliProcess('2026-05-05T12:00:00.000Z')); + const after = alchemyProcessMaterial(await cliProcess('2026-07-07T09:15:00.000Z')); + + expect(after).not.toBe(before); + }); + + test('an unchanged secret leaves it standing still — the deployment is reused', async () => { + const first = alchemyProcessMaterial(await cliProcess('2026-05-05T12:00:00.000Z')); + const second = alchemyProcessMaterial(await cliProcess('2026-05-05T12:00:00.000Z')); + + expect(second).toBe(first); + }); + + test('what the transport carries is the timestamp preflight read, under the pointed name', async () => { + const env = await cliProcess('2026-05-05T12:00:00.000Z'); + + expect(env).toEqual({ + PRISMA_COMPOSER_PREFLIGHT_PRISMA_COMPOSER_PRISMA_CLOUD: + '{"STRIPE_SECRET_KEY":"2026-05-05T12:00:00.000Z"}', + }); + }); + + test('without the transport the alchemy process learns nothing — the fingerprint cannot move', async () => { + // What the child saw before the timestamps were transported at all: every + // name unknown, so a rotation is invisible. This is the failure the + // transport exists to prevent. + const untransported = alchemyProcessMaterial({}); + const rotated = alchemyProcessMaterial(await cliProcess('2026-07-07T09:15:00.000Z')); + + expect(alchemyProcessMaterial({})).toBe(untransported); + expect(rotated).not.toBe(untransported); + }); +}); + +describe('what the payload may contain', () => { + test('timestamps only — a name, an ISO time, and nothing else', () => { + expect( + serializePointerUpdatedAt(new Map([['STRIPE_SECRET_KEY', '2026-05-05T12:00:00.000Z']])), + ).toBe('{"STRIPE_SECRET_KEY":"2026-05-05T12:00:00.000Z"}'); + }); + + test('the same times in a different order serialize identically — sorted by name', () => { + const one = new Map([ + ['A_KEY', '2026-01-01T00:00:00.000Z'], + ['B_KEY', '2026-02-02T00:00:00.000Z'], + ]); + const other = new Map([...one].reverse()); + + expect(serializePointerUpdatedAt(other)).toBe(serializePointerUpdatedAt(one)); + }); + + test('a deploy with no pointed variable carries no payload at all', () => { + expect(serializePointerUpdatedAt(new Map())).toBeUndefined(); + }); + + test('a round trip preserves every name', () => { + const timestamps = new Map([ + ['A_KEY', '2026-01-01T00:00:00.000Z'], + ['B_KEY', '2026-02-02T00:00:00.000Z'], + ]); + const payload = serializePointerUpdatedAt(timestamps); + + expect([...deserializePointerUpdatedAt(payload)]).toEqual([...timestamps]); + }); + + test('an absent payload reads as an empty map — dev, and any run with nothing to carry', () => { + expect(deserializePointerUpdatedAt(undefined).size).toBe(0); + }); + + test('a payload that is present but unreadable fails loudly rather than losing the signal', () => { + expect(() => deserializePointerUpdatedAt('not json')).toThrow(/did not survive the transport/); + expect(() => deserializePointerUpdatedAt('"a string"')).toThrow(/not a JSON object/); + expect(() => deserializePointerUpdatedAt('{"A_KEY":42}')).toThrow( + /"A_KEY" is not a string timestamp/, + ); + }); +}); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts index b97acc02d..0acdf4951 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts @@ -24,8 +24,16 @@ interface Row { class: 'production' | 'preview'; key: string; branchId: string | null; + /** Defaulted by the fake client — only the rotation tests below care what it is. */ + updatedAt?: string; } +/** What a row the test did not date reads as. */ +const DEFAULT_UPDATED_AT = '2026-01-01T00:00:00.000Z'; + +/** What the fake platform stamps on a row preflight creates from the deploy shell. */ +const CREATED_UPDATED_AT = '2026-03-03T00:00:00.000Z'; + interface FakeState { gets: Record[]; posts: Record[]; @@ -66,7 +74,10 @@ const fakeClient = (state: FakeState): ManagementApiClient => hasMore: offset + data.length < rows.length, }; return { - data: { data, pagination }, + data: { + data: data.map((r) => ({ ...r, updatedAt: r.updatedAt ?? DEFAULT_UPDATED_AT })), + pagination, + }, error: undefined, response: new Response(null, { status: 200 }), }; @@ -81,7 +92,9 @@ const fakeClient = (state: FakeState): ManagementApiClient => }; } return { - data: { data: { id: 'ev-new', key: init.body['key'] } }, + data: { + data: { id: 'ev-new', key: init.body['key'], updatedAt: CREATED_UPDATED_AT }, + }, error: undefined, response: new Response(null, { status: 201 }), }; @@ -511,4 +524,154 @@ describe('runPreflight — secret manifest verification (ADR-0029)', () => { ).rejects.toThrow(/reported more pages but returned no cursor/); }); }); + + describe('the rotation timestamps it hands back', () => { + test('a name present on the platform reports when it was last written', async () => { + state.rows = [ + { + projectId: 'proj', + class: 'production', + key: 'STRIPE_SECRET_KEY', + branchId: null, + updatedAt: '2026-05-05T12:00:00.000Z', + }, + ]; + + const timestamps = await runPreflight( + { graph: secretGraph(), container: fakeContainer('proj', undefined), stage: undefined }, + { client: fakeClient(state) }, + ); + + expect([...timestamps]).toEqual([['STRIPE_SECRET_KEY', '2026-05-05T12:00:00.000Z']]); + }); + + test('with a template and a branch override in scope, the NEWER row wins', async () => { + state.rows = [ + { + projectId: 'proj', + class: 'preview', + key: 'STRIPE_SECRET_KEY', + branchId: null, + updatedAt: '2026-05-05T12:00:00.000Z', + }, + { + projectId: 'proj', + class: 'preview', + key: 'STRIPE_SECRET_KEY', + branchId: 'br-1', + updatedAt: '2026-07-07T12:00:00.000Z', + }, + ]; + + const timestamps = await runPreflight( + { graph: secretGraph(), container: fakeContainer('proj', 'br-1'), stage: 'feature' }, + { client: fakeClient(state) }, + ); + + expect(timestamps.get('STRIPE_SECRET_KEY')).toBe('2026-07-07T12:00:00.000Z'); + }); + + test('the NEWER row wins even when it is the template, not the branch override', async () => { + state.rows = [ + { + projectId: 'proj', + class: 'preview', + key: 'STRIPE_SECRET_KEY', + branchId: null, + updatedAt: '2026-08-08T12:00:00.000Z', + }, + { + projectId: 'proj', + class: 'preview', + key: 'STRIPE_SECRET_KEY', + branchId: 'br-1', + updatedAt: '2026-07-07T12:00:00.000Z', + }, + ]; + + const timestamps = await runPreflight( + { graph: secretGraph(), container: fakeContainer('proj', 'br-1'), stage: 'feature' }, + { client: fakeClient(state) }, + ); + + // Recency, not scope precedence: the rotation signal is the newest write + // among every row visible to this stage. + expect(timestamps.get('STRIPE_SECRET_KEY')).toBe('2026-08-08T12:00:00.000Z'); + }); + + test('a row belonging to ANOTHER branch is not in scope and does not date this one', async () => { + state.rows = [ + { + projectId: 'proj', + class: 'preview', + key: 'STRIPE_SECRET_KEY', + branchId: null, + updatedAt: '2026-05-05T12:00:00.000Z', + }, + { + projectId: 'proj', + class: 'preview', + key: 'STRIPE_SECRET_KEY', + branchId: 'br-other', + updatedAt: '2026-09-09T12:00:00.000Z', + }, + ]; + + const timestamps = await runPreflight( + { graph: secretGraph(), container: fakeContainer('proj', 'br-1'), stage: 'feature' }, + { client: fakeClient(state) }, + ); + + expect(timestamps.get('STRIPE_SECRET_KEY')).toBe('2026-05-05T12:00:00.000Z'); + }); + + test('a name preflight fills from the shell reports the created row\u2019s time', async () => { + state.rows = []; + + const timestamps = await withEnv({ STRIPE_SECRET_KEY: 'sk_live_fill' }, () => + runPreflight( + { graph: secretGraph(), container: fakeContainer('proj', undefined), stage: undefined }, + { client: fakeClient(state) }, + ), + ); + + expect(timestamps.get('STRIPE_SECRET_KEY')).toBe(CREATED_UPDATED_AT); + }); + + test('a fill that lost the race (409) reports no time, so the next deploy redeploys once', async () => { + state.rows = []; + state.postStatus = 409; + + const timestamps = await withEnv({ STRIPE_SECRET_KEY: 'sk_live_fill' }, () => + runPreflight( + { graph: secretGraph(), container: fakeContainer('proj', undefined), stage: undefined }, + { client: fakeClient(state) }, + ), + ); + + expect(timestamps.has('STRIPE_SECRET_KEY')).toBe(false); + }); + + test('a graph with nothing to check hands back an empty map', async () => { + const timestamps = await runPreflight( + { graph: noSecretGraph(), container: fakeContainer('proj', undefined), stage: undefined }, + { client: fakeClient(state) }, + ); + + expect(timestamps.size).toBe(0); + }); + + test('no VALUE is ever handed back — the API returns none and preflight asks for none', async () => { + state.rows = []; + + const timestamps = await withEnv({ STRIPE_SECRET_KEY: 'sk_live_sentinel' }, () => + runPreflight( + { graph: secretGraph(), container: fakeContainer('proj', undefined), stage: undefined }, + { client: fakeClient(state) }, + ), + ); + + expect(JSON.stringify([...timestamps])).not.toContain('sk_live_sentinel'); + }); + }); }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/secret.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/secret.test.ts index 6cae6a81f..468117699 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/secret.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/secret.test.ts @@ -11,7 +11,7 @@ describe('envSecret (Prisma Cloud secret source)', () => { ); }); - test('rejects empty, COMPOSER_-prefixed, and poisoned names', () => { + test('rejects empty, COMPOSER_-prefixed, and reserved DATABASE_URL names', () => { expect(() => envSecret('')).toThrow(/non-empty/); expect(() => envSecret('COMPOSER_X')).toThrow(/COMPOSER_/); expect(() => envSecret('DATABASE_URL')).toThrow(/reserved/); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts b/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts index b2960f972..18909347d 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts @@ -11,6 +11,7 @@ import * as Prisma from '@internal/lowering'; import { prismaStateLayer } from '@internal/lowering/state'; import { RPC_PEER_KEY } from '@internal/service-rpc'; import * as Output from 'alchemy/Output'; +import * as AlchemyPrisma from 'alchemy/Prisma'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import { @@ -40,6 +41,7 @@ import { prismaCloudReporter } from '../reporting/reporter.ts'; import { S3CredentialsProvider } from '../s3-credentials-resource.ts'; import type { ProviderParamEntry } from '../serializer.ts'; import { STREAMS_API_KEY } from '../streams-keys.ts'; +import { pointerUpdatedAtLookup, serializePointerUpdatedAt } from './pointer-timestamps.ts'; /** * ADR-0031's registered provisioner for RPC_PEER_KEY: mints one `ServiceKey` @@ -152,7 +154,7 @@ const selfOriginValue: ServiceProviderParam['valueForService'] = (provisioned, a Output.map(provisioned.endpointDomain, (v) => { if (v === undefined) { throw new Error( - `ComputeService for "${address}" reported no endpointDomain at provision — cannot resolve the service's own origin (Management API predates the PRO-200 fix?)`, + `the App for "${address}" reported no endpoint domain at provision — cannot resolve the service's own origin (Management API predates the PRO-200 fix?)`, ); } return v; @@ -175,15 +177,15 @@ export interface PrismaCloudOptions { /** Defaults to the PRISMA_WORKSPACE_ID environment variable. */ workspaceId?: string; /** Defaults to the PRISMA_REGION environment variable when set. */ - region?: Prisma.ComputeRegion; + region?: AlchemyPrisma.Types.PrismaRegionId; } -// Prisma.COMPUTE_REGIONS is the runtime source of truth ComputeRegion is +// Upstream's KNOWN_REGION_IDS is the runtime source of truth PrismaRegionId is // derived from, so this can never fall behind — no hand-maintained list, no // exhaustiveness gymnastics to keep it honest. -const KNOWN_REGION_SET: ReadonlySet = new Set(Prisma.COMPUTE_REGIONS); +const KNOWN_REGION_SET: ReadonlySet = new Set(AlchemyPrisma.KNOWN_REGION_IDS); -function isComputeRegion(value: string): value is Prisma.ComputeRegion { +function isComputeRegion(value: string): value is AlchemyPrisma.Types.PrismaRegionId { return KNOWN_REGION_SET.has(value); } @@ -282,7 +284,7 @@ export const PROVIDER_PARAMS: ReadonlyMap { const workspaceId = opts.workspaceId ?? process.env['PRISMA_WORKSPACE_ID'] ?? ''; if (opts.region !== undefined) { @@ -296,7 +298,7 @@ function resolveOptions(opts: PrismaCloudOptions): ResolvedCloudOptions { if (!isComputeRegion(region)) { throw new Error( `prismaCloud(): environment variable PRISMA_REGION="${region}" is not a known region ` + - `(expected one of: ${Prisma.COMPUTE_REGIONS.join(', ')}).`, + `(expected one of: ${AlchemyPrisma.KNOWN_REGION_IDS.join(', ')}).`, ); } return { workspaceId, region, providerParams: PROVIDER_PARAMS }; @@ -310,17 +312,26 @@ function resolveOptions(opts: PrismaCloudOptions): ResolvedCloudOptions { * environment present, since it also builds the `localTarget` descriptor, which must * never require `PRISMA_WORKSPACE_ID`/`PRISMA_REGION`/`PRISMA_SERVICE_TOKEN`. */ -function lazyOptions(opts: PrismaCloudOptions): () => ResolvedCloudOptions { +function lazyOptions( + opts: PrismaCloudOptions, + pointerUpdatedAt: Prisma.PointerUpdatedAt, +): () => ResolvedCloudOptions { let cached: ResolvedCloudOptions | undefined; return () => { - cached ??= resolveOptions(opts); + cached ??= { ...resolveOptions(opts), pointerUpdatedAt }; return cached; }; } /** The Prisma Cloud extension descriptor — `prisma-composer.config.ts` lists it under `extensions`. */ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor => { - const o = lazyOptions(opts); + // When each pointed-at platform variable was last written — filled by the + // deploy preflight, read by the environment fingerprint. A closure, not a + // module variable, so two `prismaCloud()` extensions cannot share it. In + // the alchemy process (no preflight) the lookup falls back to what the CLI + // transported (pointer-timestamps.ts). + const preflightTimestamps = new Map(); + const o = lazyOptions(opts, pointerUpdatedAtLookup(preflightTimestamps, process.env)); return { id: PRISMA_CLOUD_EXTENSION_ID, @@ -342,10 +353,15 @@ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor // Deploy-time prerequisite check (ADR-0029): verify every pointer secret in // the provision manifest exists for the resolved stage, filling absent-but- // in-shell names via a direct API POST — before any stack file or Alchemy. - // The parameter annotation is what recovers this extension's own client - // type from the framework's erased one; without it `input.credentials` - // arrives as `unknown` and the call below stops compiling. - preflight: (input: PrismaCloudPreflightInput) => runPreflight(input), + // Timestamps are kept for this process AND serialized onto the preflight + // transport for the alchemy process. The parameter annotation is what + // recovers this extension's own client type from the framework's erased + // one; without it `input.credentials` arrives as `unknown`. + preflight: (input: PrismaCloudPreflightInput) => + runPreflight(input).then((timestamps) => { + for (const [name, updatedAt] of timestamps) preflightTimestamps.set(name, updatedAt); + return serializePointerUpdatedAt(timestamps); + }), // Records the deploy as a Build so it appears in the Console, and passes // the build's id into the apply so the state store can report what the @@ -356,28 +372,16 @@ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor // to the stage's Branch — deleting the Branch/Project deletes it // platform-side. - // Runs once per lowering, before any service: references the CLI-ensured - // Project, with the poison DATABASE_URL variables written immediately so - // nothing can ever rely on the platform default. Per-binding service keys - // are no longer minted here (ADR-0031): core's provision phase invokes - // `provisions` below, graph-wide, before any service lowers. + // Runs once per lowering, before any service: resolves the CLI-ensured + // Project into the application handle, and claims the project's + // `DATABASE_URL`/`DATABASE_URL_POOLED` with a placeholder + // (`claimDatabaseUrlKeys` explains why). Create-only and outside the + // resource graph — alchemy never plans a write or delete for them. application: { provision: (ctx) => Effect.gen(function* () { const { projectId, branchId } = prismaCloudContainerOf(ctx.container); - for (const key of ['DATABASE_URL', 'DATABASE_URL_POOLED']) { - yield* Prisma.EnvironmentVariable(`${key}-poison`, { - projectId, - key, - // "-", not "": the API rejects empty env-var values with - // "String must contain at least 1 character" (verified at the R4 - // deploy proof). Any garbage value fails a real connect loudly. - value: '-', - class: branchId ? 'preview' : 'production', - ...(branchId !== undefined ? { branchId } : {}), - }); - } - + yield* Prisma.claimDatabaseUrlKeys(projectId); return { projectId, branchId } satisfies CloudApplication; }), }, diff --git a/packages/1-prisma-cloud/1-extensions/target/src/control/pointer-timestamps.ts b/packages/1-prisma-cloud/1-extensions/target/src/control/pointer-timestamps.ts new file mode 100644 index 000000000..97454b73d --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/control/pointer-timestamps.ts @@ -0,0 +1,80 @@ +/** + * Carries, on the framework's preflight transport, when each platform + * variable a Composer row points at was last written — read by preflight in + * the CLI process, needed by the environment fingerprint in the alchemy + * process (which re-imports the config from scratch). ISO timestamps only, + * never values: the Management API returns none and the child's environment + * is not a place to put one. + */ +import { readPreflightPayload } from '@internal/core/config'; +import type { PointerUpdatedAt } from '@internal/lowering'; +import { PRISMA_CLOUD_EXTENSION_ID } from '../container.ts'; + +/** The CLI-process side: what `preflight` hands the framework, or undefined when the deploy read no pointed variable at all. */ +export function serializePointerUpdatedAt( + timestamps: ReadonlyMap, +): string | undefined { + if (timestamps.size === 0) return undefined; + const sorted = [...timestamps].sort(([a], [b]) => (a < b ? -1 : 1)); + return JSON.stringify(Object.fromEntries(sorted)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * The alchemy-process side: the timestamps the CLI process transported. An + * absent payload is the normal case for `prisma-composer dev` and for any run + * with no pointed variables, and reads as an empty map; a payload that is + * present but unreadable is a framework bug and throws rather than silently + * costing the deploy its rotation signal. + */ +export function deserializePointerUpdatedAt( + payload: string | undefined, +): ReadonlyMap { + const timestamps = new Map(); + if (payload === undefined) return timestamps; + + let parsed: unknown; + try { + parsed = JSON.parse(payload); + } catch (error) { + throw payloadError(error instanceof Error ? error.message : String(error)); + } + if (!isRecord(parsed)) throw payloadError('it is not a JSON object'); + for (const [name, updatedAt] of Object.entries(parsed)) { + if (typeof updatedAt !== 'string') throw payloadError(`"${name}" is not a string timestamp`); + timestamps.set(name, updatedAt); + } + return timestamps; +} + +const payloadError = (reason: string): Error => + new Error( + "prisma-cloud: the deploy preflight's rotation timestamps did not survive the transport " + + `into the alchemy process — ${reason}. This is a framework bug; re-running the deploy ` + + 'will not fix it.', + ); + +/** + * The pointer lookup the node descriptors close over: `own` — filled by + * `preflight` — in the CLI process, and the transported payload in the alchemy + * process, where `own` is empty because that process never runs a preflight. + * A name in neither reads as unknown, which is every name under + * `prisma-composer dev`. + */ +export function pointerUpdatedAtLookup( + own: ReadonlyMap, + env: Readonly>, +): PointerUpdatedAt { + let transported: ReadonlyMap | undefined; + return (name) => { + const mine = own.get(name); + if (mine !== undefined) return mine; + transported ??= deserializePointerUpdatedAt( + readPreflightPayload(PRISMA_CLOUD_EXTENSION_ID, env), + ); + return transported.get(name); + }; +} diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts index 47c2900a6..690ef600d 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts @@ -2,17 +2,28 @@ import { isParamSource, type ServiceNode } from '@internal/core'; import type { ServiceLowering } from '@internal/core/deploy'; -import * as Prisma from '@internal/lowering'; +import { + ARTIFACT_CONTENT_TYPE, + appAfterEnvironment, + deployEnvFingerprint, + type EnvFingerprintEntry, + fingerprintedArtifactPath, + packageComputeArtifact, +} from '@internal/lowering'; import * as Output from 'alchemy/Output'; +import * as Prisma from 'alchemy/Prisma'; import * as Effect from 'effect/Effect'; +import * as Redacted from 'effect/Redacted'; import { GeneratedParam } from '../generated-param-resource.ts'; import { paramBindingFor, paramName } from '../param.ts'; import { provisionedEdges } from '../provisioned-edges.ts'; import { configKey, + decodeParamPointer, encode, encodeParamPointer, type InputDocumentRow, + isParamPointerRow, paramEntries, serializeInput, } from '../serializer.ts'; @@ -28,10 +39,10 @@ import { * compute's provision → serialize/deploy handoff. `serviceId` is an * `Output`, not a `string`: the whole stack effect runs before Alchemy * applies anything, so a yielded resource's attributes are lazy references - * that only resolve at apply time. It reaches `Deployment`'s - * `computeServiceId` unchanged — that prop takes `Input`, which - * accepts the reference. `projectId` really is a `string`: it comes from the - * CLI's environment, not from a resource attribute. + * that only resolve at apply time. It reaches `Deployment`'s `app` prop + * unchanged — that prop takes `Input`, which accepts the + * reference. `projectId` really is a `string`: it comes from the CLI's + * environment, not from a resource attribute. */ export interface ComputeProvisioned { readonly serviceId: Output.Output; @@ -43,13 +54,38 @@ export interface ComputeProvisioned { readonly endpointDomain: Output.Output; } -/** compute's serialize → deploy handoff: the env-var rows deploy must depend on, the resolved port it routes to, and the serialized input document (when the service declares one) for the deploy report. */ +/** compute's serialize → deploy handoff: the env-var rows deploy must depend on, one fingerprint entry per row, the resolved port it routes to, and the serialized input document (when the service declares one) for the deploy report. */ export interface ComputeSerialized { readonly environment: readonly Prisma.EnvironmentVariable[]; + /** What the deploy hook fingerprints the environment by — one entry per row of `environment`, in the same order. */ + readonly envFingerprint: readonly EnvFingerprintEntry[]; readonly port: number; readonly input?: InputDocumentRow; } +/** + * Every env-var value goes to the platform wrapped in `Redacted`: the + * Management API never reads a value back, so alchemy persists the desired one + * in state to repair drift, and `Redacted` is what keeps it out of the + * serialized state row. A value that is still an unresolved deploy-time + * reference is wrapped inside the map, at the same point it becomes a string. + */ +const envValue = ( + value: string | Output.Output, +): Redacted.Redacted | Output.Output> => + Output.isOutput(value) ? Output.map(value, Redacted.make) : Redacted.make(value); + +/** + * The fingerprint stand-in for a row whose text this descriptor must NOT hash: + * `kind` says which channel the row came from, and the sorted names of the + * resources the value is built from say what produces it, so rewiring the row + * to a different resource moves the fingerprint. `Output.upstreamAny` is the + * same walker Alchemy builds its dependency graph with, so the names are the + * planner's own — no guessing at what a reference points to. + */ +const withheldSource = (kind: string, upstream: Record): string => + `${kind}:${Object.keys(upstream).sort().join(',')}`; + /** * Returns the PRECISE descriptor type, not the erased `NodeDescriptor`: the * registry in control.ts erases it on assignment anyway (method bivariance), @@ -68,13 +104,13 @@ export function computeDescriptor( validateName(id, 'service name (from provision id)'); const projectId = projectIdOf(application); const branchId = cloudApplicationOf(application).branchId; - const svc = yield* Prisma.ComputeService(`${id}-svc`, { - projectId, - name: id, - region: o().region ?? DEFAULT_REGION, + const svc = yield* Prisma.App(`${id}-svc`, { + project: projectId, + displayName: id, + regionId: o().region ?? DEFAULT_REGION, ...(branchId !== undefined ? { branchId } : {}), }); - return { serviceId: svc.id, projectId, endpointDomain: svc.endpointDomain }; + return { serviceId: svc.appId, projectId, endpointDomain: svc.appEndpointDomain }; }), // Two channels of rows: PARAMS (reserved-param literals JSON-encoded; @@ -90,7 +126,14 @@ export function computeDescriptor( const branch = branchId !== undefined ? { branchId } : {}; const projectId = provisioned.projectId; const svc = node as ServiceNode; - const records = []; + // One element per env row: the resource AND its fingerprint entry + // together, so a row cannot exist without one. An entry that carries + // text carries text that is secret-free BY CONSTRUCTION; the rest are + // `withheld` and have nowhere to put a value — see deploy-fingerprint.ts. + const rows: { + readonly record: Prisma.EnvironmentVariable; + readonly entry: EnvFingerprintEntry; + }[] = []; for (const d of paramEntries(svc)) { const value = @@ -108,15 +151,45 @@ export function computeDescriptor( d.owner === 'service' && isParamSource(value) ? encodeParamPointer(paramName(paramBindingFor(graph.params, address, d.name))) : encode(d.owner, value); - records.push( - yield* Prisma.EnvironmentVariable(`${key}-var`, { - projectId, - key, - value: rowValue, - class: cls, - ...branch, - }), - ); + const record = yield* Prisma.EnvironmentVariable(`${key}-var`, { + project: projectId, + key, + value: envValue(rowValue), + class: cls, + ...branch, + }); + // An own param is config, never a secret (ADR-0042): hash its text, + // and a pointer row's platform name joins `pointers`. A dependency + // input may carry a connection string or minted token: withheld. + if (d.owner === 'service') { + const pointer = isParamPointerRow(rowValue) ? decodeParamPointer(rowValue) : undefined; + rows.push({ + record, + entry: { + key, + value: rowValue, + ...(pointer !== undefined ? { pointers: [pointer] } : {}), + }, + }); + } else if (Output.isOutput(value) || Object.keys(Output.upstreamAny(value)).length > 0) { + // Any Output stays withheld — `upstreamAny` alone is not the test, + // because an Output with no Resource ancestry (a literal or + // effect-built expression) would otherwise be pushed as an object. + rows.push({ + record, + entry: { + key, + withheld: withheldSource(`input.${d.owner.input}`, Output.upstreamAny(value)), + }, + }); + } else { + // A dependency value already RESOLVED at plan time traces back to + // authored config (ADR-0042 routes secret values through pointers + // and resources, which are still Outputs here), so its text is + // hashed like a literal — a changed producer setting (e.g. a + // store's bucket) must move the consumer's fingerprint. + rows.push({ record, entry: { key, value: rowValue } }); + } } const inputRow = serializeInput( @@ -125,18 +198,23 @@ export function computeDescriptor( graph.inputBindings.find((b) => b.serviceAddress === address)?.binding, ); if (inputRow !== undefined) { - records.push( - yield* Prisma.EnvironmentVariable(`${inputRow.key}-var`, { - projectId, + // The document itself is secret-free by construction, so it is + // hashed verbatim; each `$secret` pointer names an OPERATOR-owned + // platform variable Composer never writes, so its rotation shows up + // only as that variable's `updatedAt`. + rows.push({ + record: yield* Prisma.EnvironmentVariable(`${inputRow.key}-var`, { + project: projectId, key: inputRow.key, // The defaults-applied document — secret leaves are `$secret` // pointers, generated leaves are `$generated` pointers, naming // platform vars, never values (ADR-0042). - value: inputRow.value, + value: envValue(inputRow.value), class: cls, ...branch, }), - ); + entry: { key: inputRow.key, value: inputRow.value, pointers: inputRow.secrets }, + }); // Each generated leaf: generate its value ONCE (the resource keeps it // stable across redeploys via its persisted output) and provision it // under the framework var the document's `$generated` pointer names. @@ -146,15 +224,22 @@ export function computeDescriptor( const resource = yield* GeneratedParam(`${inputRow.key}:${leaf.path}-generated`, { bytes: leaf.bytes, }); - records.push( - yield* Prisma.EnvironmentVariable(`${leaf.varName}-var`, { - projectId, + // A minted random value — withheld, and no `updatedAt` either: + // Composer rewrites this row every deploy, so the timestamp + // would churn the fingerprint. + rows.push({ + record: yield* Prisma.EnvironmentVariable(`${leaf.varName}-var`, { + project: projectId, key: leaf.varName, - value: resource.value, + value: envValue(resource.value), class: cls, ...branch, }), - ); + entry: { + key: leaf.varName, + withheld: `generated:${String(leaf.bytes)}:${String(leaf.redacted)}`, + }, + }); } } @@ -211,15 +296,22 @@ export function computeDescriptor( const value = Output.isOutput(raw) ? Output.map(raw, (v) => encode('service', v)) : encode('service', raw); - records.push( - yield* Prisma.EnvironmentVariable(`${key}-var`, { - projectId, + // May be a minted key (rpc, streams), so withheld regardless of + // brand; rewiring changes the producing resources, which is what + // moves the fingerprint. + rows.push({ + record: yield* Prisma.EnvironmentVariable(`${key}-var`, { + project: projectId, key, - value, + value: envValue(value), class: cls, ...branch, }), - ); + entry: { + key, + withheld: withheldSource(`provider.${entry.name}`, Output.upstreamAny(raw)), + }, + }); } // Carries the resolved port to deploy(); falls back to 3000 if unset. @@ -227,7 +319,8 @@ export function computeDescriptor( // only place the fallback belongs — from here on `port` is a number. const port = typeof config.service['port'] === 'number' ? config.service['port'] : 3000; return { - environment: records, + environment: rows.map((r) => r.record), + envFingerprint: rows.map((r) => r.entry), port, ...(inputRow !== undefined ? { input: inputRow } : {}), }; @@ -237,7 +330,7 @@ export function computeDescriptor( // identically; the fs/tar work itself lives in @internal/lowering. package: ({ id }, { assembled, address }) => Effect.try(() => - Prisma.packageComputeArtifact({ + packageComputeArtifact({ id, bundleDir: assembled.dir, appEntry: assembled.entry, @@ -245,17 +338,45 @@ export function computeDescriptor( }), ), - // The environment prop references serialize's env-var records, so the deploy depends on them. deploy: ({ id }, provisioned, artifact, serialized) => Effect.gen(function* () { + // Answers "unknown" for every name under `prisma-composer dev`: dev runs + // no platform preflight, so no rotation timestamps exist. That costs + // nothing — the local Deployment provider reconciles unconditionally. + const pointerUpdatedAt = o().pointerUpdatedAt; + // Effect.try, like the package hook: the hard-link/copy is filesystem + // work whose failure is a deploy error, not a defect. + const artifactPath = yield* Effect.try(() => + fingerprintedArtifactPath( + artifact.path, + deployEnvFingerprint(serialized.envFingerprint, pointerUpdatedAt), + ), + ); const deployment = yield* Prisma.Deployment(`${id}-deploy`, { - computeServiceId: provisioned.serviceId, - artifactPath: artifact.path, - artifactHash: artifact.sha256, - environment: serialized.environment, + // `app` carries the ordering edge on serialize's variable writes as + // well as the app id — see `appAfterEnvironment` for why it is the + // only prop that can (PRO-211). + app: appAfterEnvironment(provisioned.serviceId, serialized.environment), + // The SAME bytes under a path named by a hash of this service's + // environment, so upstream plans a replace exactly when the + // environment (or the code) moved and reuses the deployment + // otherwise — see `deploy-fingerprint.ts` for what the hash covers, + // why no secret reaches it, and the hand-off to upstream's + // `redeployOn`. + artifactPath, + // The artifact IS a gzipped tar (see @internal/lowering's packager); + // upstream sends this as the upload's Content-Type and folds it into + // the fingerprint that decides whether a new deployment is needed. + artifactContentType: ARTIFACT_CONTENT_TYPE, // Route to the port the app actually binds (the service's `port` // param, resolved by serialize) — not a hardcoded constant. - port: serialized.port, + portMapping: { http: serialized.port }, + // A Composer deploy always ships: upload the artifact, wait for it + // to run, then move the app's stable endpoint onto it. Neither is + // configurable — "deployed but not serving" is not a state Composer + // expresses. + start: true, + promote: true, }); // `url` IS published here: a Compute service's deployed URL is a // public endpoint, and this descriptor is the only party that knows @@ -279,12 +400,12 @@ export function computeDescriptor( } : {}; return { - outputs: { url: deployment.deployedUrl, projectId: provisioned.projectId }, + outputs: { url: deployment.appEndpointDomain, projectId: provisioned.projectId }, entities: [ { kind: 'compute-service', id: provisioned.serviceId, - url: deployment.deployedUrl, + url: deployment.appEndpointDomain, ...inputDetails, }, ], diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/postgres.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/postgres.ts index dc734ae2d..2a514c7ab 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/postgres.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/postgres.ts @@ -2,8 +2,8 @@ import type { NodeDescriptor } from '@internal/core/config'; import type { Lowering } from '@internal/core/deploy'; -import * as Prisma from '@internal/lowering'; import * as Output from 'alchemy/Output'; +import * as Prisma from 'alchemy/Prisma'; import * as Effect from 'effect/Effect'; import * as Redacted from 'effect/Redacted'; import { PgWarm } from '../pg-warm-resource.ts'; @@ -25,14 +25,30 @@ export function postgresDescriptor(o: () => ResolvedCloudOptions): NodeDescripto Effect.gen(function* () { validateName(id, 'resource name (from provision id)'); const branchId = cloudApplicationOf(application).branchId; + // Upstream refuses an explicit display name combined with branch + // attachment at create (the Management API creates the database before + // attaching the branch and exposes no idempotency key). On a named + // stage the attachment wins: the name is omitted so upstream creates + // under its recoverable generated physical name WITH the branchId in + // the create call, and `branchId` staying in props keeps the + // attachment reconciled on every later deploy. const db = yield* Prisma.Database(`${id}-db`, { - projectId: projectIdOf(application), - name: id, + project: projectIdOf(application), region: o().region ?? DEFAULT_REGION, - ...(branchId !== undefined ? { branchId } : {}), + ...(branchId !== undefined ? { branchId } : { name: id }), + }); + const conn = yield* Prisma.Connection(`${id}-conn`, { database: db, name: id }); + // Composer's semantics stay DIRECT: PgWarm and the migration flows + // depend on a direct connection, and upstream's `databaseUrl` is + // pooled-first — so bind `directConnectionString` explicitly. + const url = Output.map(conn.directConnectionString, (value) => { + if (value === undefined) { + throw new Error( + `prisma-cloud: connection "${id}-conn" returned no direct connection string.`, + ); + } + return Redacted.value(value); }); - const conn = yield* Prisma.Connection(`${id}-conn`, { databaseId: db.id, name: id }); - const url = Output.map(conn.connectionString, (value) => Redacted.value(value)); // Warm the DB so a consumer's first connect doesn't eat PPG's cold-start // (FT-5226). `warm.url` is the same url, so consumers depend on the warm. const warm = yield* PgWarm(`${id}-warm`, { url }); @@ -42,7 +58,7 @@ export function postgresDescriptor(o: () => ResolvedCloudOptions): NodeDescripto // same key means the opposite thing here as it does on compute. return { outputs: { url: warm.url }, - entities: [{ kind: 'postgres-database', id: db.id }], + entities: [{ kind: 'postgres-database', id: db.databaseId }], }; }); return Object.assign(lowering, { kind: 'resource' as const }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/prisma-next.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/prisma-next.ts index 8f7b36a02..a73f6d3f1 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/prisma-next.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/prisma-next.ts @@ -2,8 +2,8 @@ import type { NodeDescriptor } from '@internal/core/config'; import type { Lowering } from '@internal/core/deploy'; -import * as Prisma from '@internal/lowering'; import * as Output from 'alchemy/Output'; +import * as Prisma from 'alchemy/Prisma'; import * as Effect from 'effect/Effect'; import * as Redacted from 'effect/Redacted'; import { PgWarm } from '../pg-warm-resource.ts'; @@ -30,14 +30,26 @@ export function prismaNextDescriptor(o: () => ResolvedCloudOptions): NodeDescrip Effect.gen(function* () { validateName(id, 'resource name (from provision id)'); const branchId = cloudApplicationOf(application).branchId; + // Same create rule as descriptors/postgres.ts: an explicit name cannot + // combine with branch attachment at create, so a named stage omits the + // name and carries the branchId in props (created attached, reconciled + // attached). const db = yield* Prisma.Database(`${id}-db`, { - projectId: projectIdOf(application), - name: id, + project: projectIdOf(application), region: o().region ?? DEFAULT_REGION, - ...(branchId !== undefined ? { branchId } : {}), + ...(branchId !== undefined ? { branchId } : { name: id }), + }); + const conn = yield* Prisma.Connection(`${id}-conn`, { database: db, name: id }); + // Direct, not pooled — PgWarm and PnMigration below depend on it, and + // upstream's `databaseUrl` is pooled-first. + const url = Output.map(conn.directConnectionString, (value) => { + if (value === undefined) { + throw new Error( + `prisma-cloud: connection "${id}-conn" returned no direct connection string.`, + ); + } + return Redacted.value(value); }); - const conn = yield* Prisma.Connection(`${id}-conn`, { databaseId: db.id, name: id }); - const url = Output.map(conn.connectionString, (value) => Redacted.value(value)); if (!isPnPostgresResourceNode(node)) { // The registry routes 'prisma-next'-typed resource nodes here, so this @@ -81,7 +93,7 @@ export function prismaNextDescriptor(o: () => ResolvedCloudOptions): NodeDescrip // not a public endpoint, and only the descriptor can know that. return { outputs: { url: warm.url }, - entities: [{ kind: 'postgres-database', id: db.id }], + entities: [{ kind: 'postgres-database', id: db.databaseId }], }; }); return Object.assign(lowering, { kind: 'resource' as const }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/shared.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/shared.ts index da98ba839..395ff1c67 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/shared.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/shared.ts @@ -1,7 +1,8 @@ /** Helpers shared by the per-node-kind descriptors under `src/descriptors/` and the extension factory in `control.ts`. */ -import type * as Prisma from '@internal/lowering'; +import type { PointerUpdatedAt } from '@internal/lowering'; import type * as Output from 'alchemy/Output'; +import type * as Prisma from 'alchemy/Prisma'; import type { ProviderParamEntry } from '../serializer.ts'; /** @@ -64,7 +65,7 @@ export interface ServiceProviderParam extends ProviderParamEntry { */ export interface ResolvedCloudOptions { readonly workspaceId: string; - readonly region?: Prisma.ComputeRegion; + readonly region?: Prisma.Types.PrismaRegionId; /** * This extension's reserved provider params, keyed by need brand — * edge-derived (`ProviderParam`) or service-derived (`ServiceProviderParam`). @@ -74,10 +75,20 @@ export interface ResolvedCloudOptions { * place a brand is named). */ readonly providerParams: ReadonlyMap; + /** + * When a platform variable a row POINTS at was last written, by name — the + * out-of-band rotation signal the compute deploy hook folds into its + * environment fingerprint. The deploy preflight supplies the times (it + * already reads exactly these names off the platform) and transports them to + * the alchemy process. Always present: a run with no times to offer — every + * `prisma-composer dev` run, which talks to no platform — supplies a lookup + * that answers "unknown" for every name, so no caller has to. + */ + readonly pointerUpdatedAt: PointerUpdatedAt; } /** Where a resource lands when the deploy names no region. */ -export const DEFAULT_REGION: Prisma.ComputeRegion = 'us-east-1'; +export const DEFAULT_REGION: Prisma.Types.PrismaRegionId = 'us-east-1'; // Prisma's Connection create constrains `name` to 3–65 chars (Management API: // POST /v1/connections); applied here to every id-derived resource name as the diff --git a/packages/1-prisma-cloud/1-extensions/target/src/param.ts b/packages/1-prisma-cloud/1-extensions/target/src/param.ts index db96a8636..69add7d34 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/param.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/param.ts @@ -19,14 +19,18 @@ export interface EnvParamPayload { } const RESERVED_PARAM_PREFIX = 'COMPOSER_'; -const POISONED_PARAM_NAMES: ReadonlySet = new Set(['DATABASE_URL', 'DATABASE_URL_POOLED']); +/** The names Prisma Cloud owns: it seeds and manages them, so Composer never binds one. */ +const PLATFORM_OWNED_PARAM_NAMES: ReadonlySet = new Set([ + 'DATABASE_URL', + 'DATABASE_URL_POOLED', +]); /** * Binds a param slot to a named Prisma Cloud platform env var — the non-secret * sibling of `envSecret` (spec: env-sourced config params). The platform * injects the value into the running instance per stage; the param's own * schema validates it at boot, unredacted. The name may not use the - * framework's reserved `COMPOSER_` prefix or the poisoned + * framework's reserved `COMPOSER_` prefix or the platform-owned * `DATABASE_URL(_POOLED)` keys — same parity as `envSecret`. */ export function envParam(name: string): ParamSource { @@ -41,10 +45,11 @@ export function envParam(name: string): ParamSource { "reserved for the framework's own generated config keys.", ); } - if (POISONED_PARAM_NAMES.has(name)) { + if (PLATFORM_OWNED_PARAM_NAMES.has(name)) { throw new Error( - `envParam name "${name}" is reserved — ${[...POISONED_PARAM_NAMES].join(' and ')} are ` + - 'poisoned at project provision and cannot back a param.', + `envParam name "${name}" is reserved — ${[...PLATFORM_OWNED_PARAM_NAMES].join(' and ')} ` + + 'are seeded and managed by Prisma Cloud itself, so the framework refuses to bind them. ' + + 'Declare the database your service uses and read its url from that connection.', ); } return paramSource({ [PRISMA_CLOUD_PARAM_SOURCE]: true, name }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts b/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts index 901bca99a..a3d4442ff 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts @@ -13,6 +13,13 @@ * in the CLI parent, on the caller's Management API client when it passed one, * and otherwise on a client built from env — the same credential path * `container.ts`'s `ensure`/`locate` use. + * + * It also returns WHEN each of those names was last written. This is the only + * place in a deploy that reads those rows off the platform, so it is where the + * reading belongs; the compute deploy hook folds the timestamps into its + * environment fingerprint so that rotating a secret or an env-sourced param + * out of band ships a new deployment. A timestamp, never a value: env-var + * values are write-only and the API never returns one. */ import type { Graph } from '@internal/core'; import type { PreflightInput } from '@internal/core/config'; @@ -37,15 +44,12 @@ type EnvClass = 'production' | 'preview'; const classFor = (branchId: string | undefined): EnvClass => branchId === undefined ? 'production' : 'preview'; -/** - * Does `key` exist for the target stage's scope? Default stage → any - * production-class template. Named stage → a preview template (branchId null) - * OR this branch's own override — the platform's preview materialization - * (pdp-data-model.md). Metadata read only; env-var values are write-only. - */ /** The fields of one env-var list page that preflight consumes (metadata only; values are write-only). */ interface EnvVarListPage { - readonly data: readonly { readonly branchId: string | null }[]; + readonly data: readonly { + readonly branchId: string | null; + readonly updatedAt: string; + }[]; readonly pagination: { readonly nextCursor: string | null; readonly hasMore: boolean }; } interface EnvVarListResult { @@ -77,24 +81,45 @@ async function listEnvVars( >(res); } -async function existsOnPlatform( +/** What the platform holds for one name: whether it is there at all, and when it last changed. */ +interface PlatformVariable { + readonly exists: boolean; + /** The latest `updatedAt` across every row visible to this stage — undefined when the name is absent. */ + readonly updatedAt?: string; +} + +/** + * What the platform holds for `key` in the target stage's scope. Default stage + * → any production-class template. Named stage → a preview template (branchId + * null) OR this branch's own override — the platform's preview materialization + * (pdp-data-model.md). Metadata read only; env-var values are write-only. + * + * The whole list is walked rather than short-circuiting on the first visible + * row, because the newest `updatedAt` across every visible row is the rotation + * signal the compute deploy hook fingerprints on: stopping early would make + * that timestamp depend on where the page boundary happened to fall, and a + * fingerprint that moves for that reason would redeploy for no reason. A key + * with more rows than one page (a template plus many per-branch overrides) is + * rare, so this costs one request in practice. + */ +async function readPlatformVariable( client: ManagementApiClient, projectId: string, branchId: string | undefined, key: string, -): Promise { +): Promise { const cls = classFor(branchId); - // Default stage → any production template counts; named stage → a preview - // template (branchId null) OR this branch's own override. const visible = (row: { branchId: string | null }): boolean => branchId === undefined || row.branchId === null || row.branchId === branchId; // The list is paginated: a key with more preview rows (template + many // per-branch overrides) than one page must be followed to the end, or a - // present name is falsely reported missing. Short-circuits as soon as a - // visible row is seen; bounded (drivePagesAsync) so broken pagination + // present name is falsely reported missing. Walked to the end (no + // short-circuit) because the LATEST updatedAt across visible rows feeds + // the deploy fingerprint; bounded (drivePagesAsync) so broken pagination // fails loudly instead of looping. - let found = false; + let exists = false; + let latest: string | undefined; await drivePagesAsync( `environment variables named "${key}"`, async (cursor) => { @@ -108,18 +133,27 @@ async function existsOnPlatform( return res.data ?? { data: [], pagination: { nextCursor: null, hasMore: false } }; }, (data) => { - found = data.some(visible); - return found; + for (const row of data) { + if (!visible(row)) continue; + exists = true; + // Parsed, not string-compared: lexicographic order breaks the moment + // two rows serialize with different precision or UTC designators. + if (latest === undefined || Date.parse(row.updatedAt) > Date.parse(latest)) { + latest = row.updatedAt; + } + } + return false; }, ); - return found; + return latest === undefined ? { exists } : { exists, updatedAt: latest }; } /** * Provision `key`=`value` directly via the Management API for the target * stage's scope (a production template for the default stage; a preview branch - * override for a named stage — the same scope the pack writes config rows to, - * EnvironmentVariable.ts). A 409 means a concurrent deploy already provisioned + * override for a named stage — the same scope the pack's config rows are + * written to, through alchemy's `Prisma.EnvironmentVariable`). A 409 means a + * concurrent deploy already provisioned * it — tolerated. The value is never logged. */ async function fillMissing( @@ -128,7 +162,7 @@ async function fillMissing( branchId: string | undefined, key: string, value: string, -): Promise { +): Promise { const res = await client.POST('/v1/environment-variables', { body: { projectId, @@ -141,6 +175,11 @@ async function fillMissing( if (res.error !== undefined && res.response.status !== 409) { throw fillFailedError(key, res.error); } + // The created row's timestamp, so this deploy fingerprints on the same value + // the next one will read back. A 409 (a concurrent deploy won the race) + // returns no row: the name reads as unknown for this run and the next deploy + // picks its timestamp up, which redeploys once — the safe direction. + return res.data?.data.updatedAt; } interface MissingBinding { @@ -208,7 +247,7 @@ async function managementClient(): Promise { export async function runPreflight( input: PrismaCloudPreflightInput, deps?: { readonly client?: ManagementApiClient }, -): Promise { +): Promise> { const { projectId, branchId } = prismaCloudContainerOf(input.container); // One check per platform NAME (many leaves/services, secret or param, may @@ -222,20 +261,27 @@ export async function runPreflight( for (const meta of [...collected.secrets, ...collected.envParams]) { if (!names.has(meta.name)) names.set(meta.name, meta); } - if (names.size === 0) return; + if (names.size === 0) return new Map(); const client = input.credentials?.client ?? deps?.client ?? (await managementClient()); const missing: MissingBinding[] = []; + const updatedAt = new Map(); for (const meta of names.values()) { - if (await existsOnPlatform(client, projectId, branchId, meta.name)) continue; + const platform = await readPlatformVariable(client, projectId, branchId, meta.name); + if (platform.exists) { + if (platform.updatedAt !== undefined) updatedAt.set(meta.name, platform.updatedAt); + continue; + } const shellValue = process.env[meta.name]; if (shellValue !== undefined && shellValue.length > 0) { - await fillMissing(client, projectId, branchId, meta.name, shellValue); + const filled = await fillMissing(client, projectId, branchId, meta.name, shellValue); + if (filled !== undefined) updatedAt.set(meta.name, filled); continue; } missing.push(meta); } if (missing.length > 0) throw missingError(missing, branchId, input.stage); + return updatedAt; } /** diff --git a/packages/1-prisma-cloud/1-extensions/target/src/secret.ts b/packages/1-prisma-cloud/1-extensions/target/src/secret.ts index f9a9a0293..6efab2eea 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/secret.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/secret.ts @@ -19,12 +19,16 @@ export interface EnvSecretPayload { } const RESERVED_SECRET_PREFIX = 'COMPOSER_'; -const POISONED_SECRET_NAMES: ReadonlySet = new Set(['DATABASE_URL', 'DATABASE_URL_POOLED']); +/** The names Prisma Cloud owns: it seeds and manages them, so Composer never binds one. */ +const PLATFORM_OWNED_SECRET_NAMES: ReadonlySet = new Set([ + 'DATABASE_URL', + 'DATABASE_URL_POOLED', +]); /** * Binds a secret slot to a named Prisma Cloud platform env var (ADR-0029). The * value is provisioned out-of-band; only the name is carried. The name may not - * use the framework's reserved `COMPOSER_` prefix or the poisoned + * use the framework's reserved `COMPOSER_` prefix or the platform-owned * `DATABASE_URL(_POOLED)` keys. */ export function envSecret(name: string): SecretSource { @@ -39,10 +43,11 @@ export function envSecret(name: string): SecretSource { "reserved for the framework's own generated config keys.", ); } - if (POISONED_SECRET_NAMES.has(name)) { + if (PLATFORM_OWNED_SECRET_NAMES.has(name)) { throw new Error( - `envSecret name "${name}" is reserved — ${[...POISONED_SECRET_NAMES].join(' and ')} are ` + - 'poisoned at project provision and cannot back a secret.', + `envSecret name "${name}" is reserved — ${[...PLATFORM_OWNED_SECRET_NAMES].join(' and ')} ` + + 'are seeded and managed by Prisma Cloud itself, so the framework refuses to bind them. ' + + 'Declare the database your service uses and read its url from that connection.', ); } return secretSource({ [PRISMA_CLOUD_SECRET_SOURCE]: true, name }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts b/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts index ff2deb92f..01a048235 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts @@ -8,8 +8,9 @@ * root — empty for a lone-service deploy, the "unprefixed" case), then the * owner (the input name, dropped for the service's own params), then the * param name. auth's db.url ↔ AUTH_DB_URL; a lone service's db.url ↔ DB_URL. - * The platform's DATABASE_URL is never among them — forbidden and poisoned - * at project provision (see docs/design/05-prisma-cloud/alchemy-lowering.md). + * The platform's DATABASE_URL is never among them: Prisma Cloud owns that name + * and the framework refuses to bind it (see + * docs/design/05-prisma-cloud/alchemy-lowering.md). * * This module works off the node's RAW params (`node.params` and each * `node.inputs[k].connection.params`) rather than `configOf`'s pure-data @@ -76,9 +77,10 @@ export const configKey = ( const owner = d.owner === 'service' ? [] : [d.owner.input]; // Every generated key lives in the framework's reserved COMPOSER_ namespace // (ADR-0029), so it can never collide with — and silently overwrite — a - // user-provisioned platform var (e.g. a secret's external name). The poison - // keys DATABASE_URL(_POOLED) are written directly in control.ts, not here, so - // they stay unprefixed (they are the platform's own names). + // user-provisioned platform var (e.g. a secret's external name). Composer + // writes no unprefixed variable at all: DATABASE_URL(_POOLED) are the + // platform's own names, banned in param.ts/secret.ts and owned by the + // platform (control/extension.ts). return ['COMPOSER', ...segments, ...owner, d.name].join('_').toUpperCase(); }; @@ -460,6 +462,13 @@ export interface InputDocumentRow { readonly absent: readonly string[]; /** Generated leaves the descriptor must provision (a `GeneratedParam` resource + env row each). */ readonly generated: readonly GeneratedLeaf[]; + /** + * The platform variable each `$secret` pointer in the document names — + * operator-provisioned, never written by Composer. The deploy hook folds + * each one's `updatedAt` into the environment fingerprint, so rotating a + * secret out of band ships a new deployment. + */ + readonly secrets: readonly string[]; } /** @@ -505,8 +514,19 @@ export function serializeInput( 'secretness, or the schema refines on secret content — which ADR-0042 forbids.)', ); } - const document = substitutePointers(validated, sentinels, generated, address); - return { key: inputKey(address), value: JSON.stringify(document), absent, generated }; + const emittedSecrets = new Set(); + const document = substitutePointers(validated, sentinels, generated, address, emittedSecrets); + return { + key: inputKey(address), + value: JSON.stringify(document), + absent, + generated, + // The names the DOCUMENT actually carries (a schema transform can drop a + // bound leaf), sorted and unique: the fingerprint depends only on the set + // of referenced variables, so a binding refactor that reorders or + // duplicates leaves cannot move it. + secrets: [...emittedSecrets].sort(), + }; } /** @@ -526,6 +546,7 @@ function substitutePointers( sentinels: ReadonlyMap, generated: readonly GeneratedLeaf[], address: string, + emittedSecrets: Set, ): unknown { const generatedByPath = new Map(generated.map((leaf) => [leaf.path, leaf])); const walk = (v: unknown, path: string): unknown => { @@ -540,6 +561,7 @@ function substitutePointers( "platform variable); bind the field with envSecret('NAME') instead.", ); } + emittedSecrets.add(name); return { [SECRET_MARKER]: name }; } if (Array.isArray(v)) return v.map((m, i) => walk(m, path === '' ? String(i) : `${path}.${i}`)); diff --git a/patches/alchemy@2.0.0-beta.67.patch b/patches/alchemy@2.0.0-beta.67.patch index f16a8f40c..4c2bd1187 100644 --- a/patches/alchemy@2.0.0-beta.67.patch +++ b/patches/alchemy@2.0.0-beta.67.patch @@ -1,5 +1,5 @@ diff --git a/lib/Resource.d.ts b/lib/Resource.d.ts -index 4ddd84f22ded4d39f6690da7bffe19de527a2db8..e7b23ba27bf4c3837690acb9b866f56c0264a772 100644 +index 4ddd84f..e7b23ba 100644 --- a/lib/Resource.d.ts +++ b/lib/Resource.d.ts @@ -35,7 +35,7 @@ export interface ResourceClassLike { @@ -11,3 +11,16 @@ index 4ddd84f22ded4d39f6690da7bffe19de527a2db8..e7b23ba27bf4c3837690acb9b866f56c } export type ResourceClass = ResourceConstructor : R["Providers"]> & Effect.Effect> & { Self: Self; +diff --git a/src/Resource.ts b/src/Resource.ts +index 8ab04a8..72bfec6 100644 +--- a/src/Resource.ts ++++ b/src/Resource.ts +@@ -56,7 +56,7 @@ export interface ResourceClassLike { + * `ProviderService` by `Provider.succeed`/`Provider.effect` so provider + * lookup can resolve state persisted under a pre-rename type. + */ +- Aliases?: readonly string[]; ++ Aliases?: readonly string[] | undefined; + } + + export type ResourceClass = ResourceConstructor< diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d9b70329..1cfafde08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,7 +9,7 @@ patchedDependencies: hash: 6faf37cc2077b96dea5bf0d1906285c3f32e99f34af71943256985056678b266 path: patches/@alchemy.run__node-utils@0.0.5.patch alchemy@2.0.0-beta.67: - hash: b4f53c9c951c28d72ce7373ac88b38bd85718e114e5001fa7b2a8f7f869d6fac + hash: 2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44 path: patches/alchemy@2.0.0-beta.67.patch importers: @@ -94,7 +94,7 @@ importers: version: link:../../packages/9-public/composer-prisma-cloud alchemy: specifier: 2.0.0-beta.67 - version: 2.0.0-beta.67(patch_hash=b4f53c9c951c28d72ce7373ac88b38bd85718e114e5001fa7b2a8f7f869d6fac)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) + version: 2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) effect: specifier: 4.0.0-beta.103 version: 4.0.0-beta.103 @@ -119,7 +119,7 @@ importers: version: link:../../packages/9-public/composer-prisma-cloud alchemy: specifier: 2.0.0-beta.67 - version: 2.0.0-beta.67(patch_hash=b4f53c9c951c28d72ce7373ac88b38bd85718e114e5001fa7b2a8f7f869d6fac)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) + version: 2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) arktype: specifier: ^2.2.3 version: 2.2.3 @@ -246,7 +246,7 @@ importers: version: link:../../packages/9-public/composer-prisma-cloud alchemy: specifier: 2.0.0-beta.67 - version: 2.0.0-beta.67(patch_hash=b4f53c9c951c28d72ce7373ac88b38bd85718e114e5001fa7b2a8f7f869d6fac)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) + version: 2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) effect: specifier: 4.0.0-beta.103 version: 4.0.0-beta.103 @@ -289,7 +289,7 @@ importers: version: link:modules/storefront alchemy: specifier: 2.0.0-beta.67 - version: 2.0.0-beta.67(patch_hash=b4f53c9c951c28d72ce7373ac88b38bd85718e114e5001fa7b2a8f7f869d6fac)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) + version: 2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) arktype: specifier: ^2.2.3 version: 2.2.3 @@ -450,7 +450,7 @@ importers: version: link:modules/storefront alchemy: specifier: 2.0.0-beta.67 - version: 2.0.0-beta.67(patch_hash=b4f53c9c951c28d72ce7373ac88b38bd85718e114e5001fa7b2a8f7f869d6fac)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) + version: 2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) arktype: specifier: ^2.2.3 version: 2.2.3 @@ -587,7 +587,7 @@ importers: version: 1.1.0 alchemy: specifier: 2.0.0-beta.67 - version: 2.0.0-beta.67(patch_hash=b4f53c9c951c28d72ce7373ac88b38bd85718e114e5001fa7b2a8f7f869d6fac)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) + version: 2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) effect: specifier: 4.0.0-beta.103 version: 4.0.0-beta.103 @@ -837,7 +837,7 @@ importers: version: link:../s3-protocol alchemy: specifier: 2.0.0-beta.67 - version: 2.0.0-beta.67(patch_hash=b4f53c9c951c28d72ce7373ac88b38bd85718e114e5001fa7b2a8f7f869d6fac)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) + version: 2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) effect: specifier: 4.0.0-beta.103 version: 4.0.0-beta.103 @@ -866,6 +866,9 @@ importers: packages/1-prisma-cloud/0-lowering/lowering: dependencies: + '@effect/platform-node': + specifier: 4.0.0-beta.103 + version: 4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1) '@internal/bundle-paths': specifier: workspace:0.10.0 version: link:../../../0-framework/2-authoring/bundle-paths @@ -880,7 +883,7 @@ importers: version: 1.60.0 alchemy: specifier: 2.0.0-beta.67 - version: 2.0.0-beta.67(patch_hash=b4f53c9c951c28d72ce7373ac88b38bd85718e114e5001fa7b2a8f7f869d6fac)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) + version: 2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) effect: specifier: 4.0.0-beta.103 version: 4.0.0-beta.103 @@ -962,7 +965,7 @@ importers: version: 1.1.0 alchemy: specifier: 2.0.0-beta.67 - version: 2.0.0-beta.67(patch_hash=b4f53c9c951c28d72ce7373ac88b38bd85718e114e5001fa7b2a8f7f869d6fac)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) + version: 2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) arktype: specifier: ^2.2.3 version: 2.2.3 @@ -1220,7 +1223,7 @@ importers: version: 1.1.0 alchemy: specifier: 2.0.0-beta.67 - version: 2.0.0-beta.67(patch_hash=b4f53c9c951c28d72ce7373ac88b38bd85718e114e5001fa7b2a8f7f869d6fac)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) + version: 2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) arktype: specifier: ^2.2.3 version: 2.2.3 @@ -1281,7 +1284,7 @@ importers: version: link:../composer alchemy: specifier: 2.0.0-beta.67 - version: 2.0.0-beta.67(patch_hash=b4f53c9c951c28d72ce7373ac88b38bd85718e114e5001fa7b2a8f7f869d6fac)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) + version: 2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) c12: specifier: ^3.3.4 version: 3.3.4 @@ -1336,7 +1339,7 @@ importers: version: 1.1.0 alchemy: specifier: 2.0.0-beta.67 - version: 2.0.0-beta.67(patch_hash=b4f53c9c951c28d72ce7373ac88b38bd85718e114e5001fa7b2a8f7f869d6fac)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) + version: 2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) arktype: specifier: ^2.2.3 version: 2.2.3 @@ -1439,7 +1442,7 @@ importers: version: 26.1.1 alchemy: specifier: 2.0.0-beta.67 - version: 2.0.0-beta.67(patch_hash=b4f53c9c951c28d72ce7373ac88b38bd85718e114e5001fa7b2a8f7f869d6fac)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) + version: 2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) prisma: specifier: 7.9.0 version: 7.9.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) @@ -1457,7 +1460,7 @@ importers: version: link:../packages/9-public/composer-prisma-cloud alchemy: specifier: 2.0.0-beta.67 - version: 2.0.0-beta.67(patch_hash=b4f53c9c951c28d72ce7373ac88b38bd85718e114e5001fa7b2a8f7f869d6fac)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) + version: 2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) devDependencies: '@prisma/composer-cli': specifier: workspace:0.10.0 @@ -7896,7 +7899,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.67(patch_hash=b4f53c9c951c28d72ce7373ac88b38bd85718e114e5001fa7b2a8f7f869d6fac)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1): + alchemy@2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1): dependencies: '@alchemy.run/node-utils': 0.0.5(patch_hash=6faf37cc2077b96dea5bf0d1906285c3f32e99f34af71943256985056678b266) '@aws-sdk/credential-providers': 3.1077.0 @@ -7963,7 +7966,7 @@ snapshots: - vitest - workerd - alchemy@2.0.0-beta.67(patch_hash=b4f53c9c951c28d72ce7373ac88b38bd85718e114e5001fa7b2a8f7f869d6fac)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1): + alchemy@2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1): dependencies: '@alchemy.run/node-utils': 0.0.5(patch_hash=6faf37cc2077b96dea5bf0d1906285c3f32e99f34af71943256985056678b266) '@aws-sdk/credential-providers': 3.1077.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0dd249913..0dd6d3a64 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,3 +4,6 @@ packages: - examples/*/modules/* - test/** - website + +patchedDependencies: + "alchemy@2.0.0-beta.67": patches/alchemy@2.0.0-beta.67.patch diff --git a/scripts/ci-cleanup-utils.ts b/scripts/ci-cleanup-utils.ts index 577b6f436..5d51f07d5 100644 --- a/scripts/ci-cleanup-utils.ts +++ b/scripts/ci-cleanup-utils.ts @@ -77,10 +77,11 @@ const isActiveDeployment409 = (r: HttpResponse): boolean => r.status === 409 && r.body.includes('active deployment'); /** - * The app DELETE 409s with this exact wording while its - * deployment is still winding down — the same "not delete-safe yet" match - * alchemy's ComputeService provider retries on (everything else is a real - * failure and must surface, not be retried). + * The app DELETE 409s with this exact wording while its deployment is still + * winding down. This cleanup retries on this wording alone; everything else is + * a real failure and must surface, not be retried. Alchemy's own App delete + * retries any conflict, but only about four seconds' worth — which is why this + * script keeps its own, longer, wording-specific budget. */ const isDeleteNotSafeYet409 = (r: HttpResponse): boolean => r.status === 409 && r.body.includes('did not reach a delete-safe state'); diff --git a/test/integration/test/local-dev.integration.ts b/test/integration/test/local-dev.integration.ts index 57addca0d..4385503a0 100644 --- a/test/integration/test/local-dev.integration.ts +++ b/test/integration/test/local-dev.integration.ts @@ -539,7 +539,10 @@ async function main(): Promise { assert(typeof webInfo.pid === 'number', 'web service must report a pid'); assert(typeof bkgInfo.pid === 'number', 'bkg service must report a pid'); - // 10. env store correct: poison DATABASE_URL rows. The port-override row + // 10. env store correct: every row lives in the COMPOSER_ namespace. + // Composer writes no unprefixed variable — an unprefixed name is a + // platform-owned one (DATABASE_URL and friends), and the platform manages + // those itself. The port-override row // (COMPOSER_
_PORT) is deliberately NEVER persisted to env.json // (local-dev spec § 4: "Ports live nowhere here" — the Deployment // provider materializes it fresh into each deployment's own env, in @@ -547,9 +550,21 @@ async function main(): Promise { // fixture's own /health response (captured above, before webInfo existed // to compare against) rather than by reading env.json for a key it never // receives. - const env = readJson(path.join(devDir, 'env.json')) as Record; - assertEqual(env['DATABASE_URL'], '-', 'env.json DATABASE_URL is poisoned'); - assertEqual(env['DATABASE_URL_POOLED'], '-', 'env.json DATABASE_URL_POOLED is poisoned'); + const envRaw = readJson(path.join(devDir, 'env.json')); + assert( + typeof envRaw === 'object' && envRaw !== null, + 'env.json exists and parses to an object', + ); + const env = envRaw as Record; + // Non-empty first: an empty object would vacuously pass every check below. + assert(Object.keys(env).length > 0, 'env.json carries the deploy-written rows'); + assertEqual(env['DATABASE_URL'], undefined, 'env.json holds no DATABASE_URL row'); + assertEqual(env['DATABASE_URL_POOLED'], undefined, 'env.json holds no DATABASE_URL_POOLED row'); + assertEqual( + Object.keys(env).every((key) => key.startsWith('COMPOSER_')), + true, + 'every env.json row lives in the COMPOSER_ namespace', + ); assertEqual( health.portEnv, JSON.stringify(webInfo.port),