diff --git a/.drive/projects/spi-inversion-and-deploy-results/design-notes.md b/.drive/projects/spi-inversion-and-deploy-results/design-notes.md new file mode 100644 index 00000000..379f7d64 --- /dev/null +++ b/.drive/projects/spi-inversion-and-deploy-results/design-notes.md @@ -0,0 +1,188 @@ +# Design notes — SPI inversion & deployment results + +The settled argument from the design session (2026-07-17), recorded so +slices don't re-derive it. The ADR (S1) distills the durable decisions; +this file keeps the full reasoning and the dead ends. + +## Principles inherited + +- ADR-0005: users build, the framework assembles — deterministic steps, no + guessing. A renderer that walks our own graph (not alchemy's output) is + the reporting expression of this. +- `architecture.config.json` layering: the CLI (framework/tooling) must not + import prisma-cloud; core stays presentation-free. +- Repo cast rules: no bare `as`; the SPI must not force descriptors to cast. + +## The corrected execution model (verified, alchemy 2.0.0-beta.59) + +> **Citation drift, corrected 2026-07-17.** S1-D3 re-derived every line +> number below against the installed source rather than trusting this file, +> and most had drifted a few lines. **ADR-0033 carries the corrected +> citations and is the authority**; the ones in this section are indicative. +> Corrections: `Deploy.ts:25-30`; `Apply.ts:191-193` (short-circuit); +> `Apply.ts:198` + `:203` (evaluate, then `setOutput`); `Resource.ts:275-283` +> (unchanged); and the status-change emit exists at **two** sites — +> `Apply.ts:415-421` (per-resource `report()`) and `Apply.ts:184-185` +> (terminal flush) — where this file cited only the first. The *facts* below +> all re-verified; only the line numbers moved. Lesson for S2/S3: cite +> against the installed source at write time, not against this file. + +The design initially assumed each `yield*` in a descriptor returns after +alchemy applies that resource, resolved values in hand. **Wrong.** Verified +against alchemy source: + +- `deploy = evalStack(stackEffect) → Plan.make → Apply.apply` + (`src/Deploy.ts:25–32`). Our entire `lowering()` generator runs to + completion before any platform call. +- Yielding a resource returns a Proxy whose property reads produce lazy + `Output.PropExpr` references (`src/Resource.ts:275–283`). + `deployment.deployedUrl` in a descriptor is symbolic, not a URL. +- Resolved values reach the program in exactly one place: apply evaluates + **whatever the stack effect returned** against its internal tracker and + returns that resolved structure (`src/Apply.ts:195–205`). It also + unconditionally persists that value via `state.setOutput` + (`src/Apply.ts:203`) — alchemy's cross-stack-reference mechanism. +- Per-resource phase outcomes (created/updated/noop/skipped) are **not** + returned to the program. They live in apply's tracker and are emitted as + status-change events to alchemy's CLI session service + (`src/Apply.ts:415–421`). Capturing them means wrapping that service or + an upstream change. + +Consequences: + +- The association (node → primitives) forms at full context inside the + lowering loop, but as symbolic Output references; values materialize when + the stack's return value is evaluated. +- Returning address-keyed primitives from the stack effect is **alchemy's + designed mechanism** for getting resolved values out — not a transport + hack. `lowering()` returning hardcoded `{ outputs: {} }` is us opting out + of it. +- Because alchemy persists the stack output, the value crossing the stack + boundary must be plain data. That is a rule about *alchemy's channel*, + not about our domain types: `DeploymentResult` (node-bearing) is + assembled after `deploy()` returns, in the same child process, by joining + the resolved primitives to the graph. + +## The three roles of `LoweredNode` + +One type (`{ outputs: Record }`, `deploy.ts:116–118`) +serves: + +1. **Intra-descriptor phase handoffs** (`provision` → `serialize`/`deploy`). + Same-party producer and consumer; core threads them opaquely. Forcing + them through the bag makes descriptors cast to recover their own types + (`compute.ts:155–161`). +2. **Inter-node wiring** (`deploy` → `lowered` map → `buildConfig`). The + only role core reads — by param name, per the consumer's connection + declaration. +3. **Reporting** — the reverted `NodeReport`, possible only because a + shared bag accepts new fields without any consumer's signature changing. + +## Key decisions + +- **Dependency inversion at every seam; interfaces live with consumers.** + Phase-handoff types: descriptor-owned, generic in the SPI + (`ServiceLowering` in spirit), opaque to core. + Wiring: the connection declaration is the interface (consumer side, in + core's graph model); across the extension seam it is necessarily + runtime-checked (schemas), not TypeScript-checked — core cannot know + extension types and the producer/consumer pairing is decided by the + user's graph. Primitives/results: declared by the deploy-result subsystem + in core. Formatting: declared by the CLI beside the renderer. +- **The lowering loop is the sole router.** deploy's product splits into + wiring (→ `lowered`, for dependents) and primitives (→ results). + Descriptors don't know `buildConfig` exists; neither knows reporting + exists. A future consumer must declare an interface and appear as a + visible routing edit in the loop — the dumping-ground regression becomes + structurally impossible. +- **`DeploymentResult` per node** — graph node + typed primitives + (`kind`, platform `id`, `url` only when the descriptor declares it + public) + a diagnostics slot populated later. No aggregate noun; the run + yields a collection. "Deployment" alone is banned — it collides with the + `Prisma.Deployment` alchemy resource. +- **Descriptor names what is publishable** (the allowlist lesson): `url` + means a public endpoint on compute and would mean a connection string on + postgres, so no core-level rule is safe. +- **Renderer runs in the deploy child**, wired through the generated stack + file; it walks our module tree and prints authored names with ids/URLs. + The stack's own output shrinks to primitives (or nothing beyond them), + killing the raw alchemy dump. +- **Wiring enforcement** (pending operator confirmation): after a producer + lowers, core checks its wiring outputs satisfy every param the consumer's + connection declares (except provisioned ones); a gap is a `LowerError` + naming edge, param, and both nodes — at deploy time, where the mistake + is, instead of `undefined` in a booted service's env. + +## Shaping addendum (2026-07-17, slice grounding) + +Facts established while pinning the slice specs, superseding two earlier +leanings: + +- **The render vehicle is an alchemy Action, not a post-apply step.** The + bin (`src/Cli/commands/deploy.ts`) has no post-apply hook: it imports the + stack module's default export, plans, applies, `Console.log`s a non- + undefined output, done. Actions (`src/Action.ts`) are alchemy's "run + during apply with resolved inputs" primitive: recorded on the stack, + given upstream edges from their input's Output references + (`src/Plan.ts:539-546`), executed by apply with the resolved input, with + their output tracked like a resource (`src/Apply.ts:1103-1232`). An + action noops when its input hash matches prior state + (`src/Plan.ts:1069-1082`) — a `Date.now()` nonce in the input forces the + report to run every deploy. +- **Parent-process readback via `getOutput` was rejected on three verified + grounds**: our pg state layer requires alchemy's `Stack` service + (`state/layer.ts:47` does `yield* Stack`) so the parent would fake it; + the layer acquires the deploy lock on build (`state/layer.ts:76`); and + the parent would have to replicate alchemy's stage derivation + (`--stage` absent → `STAGE` config → `dev_${USER}`, + `src/Cli/commands/_shared.ts:85-110`) to key the read. +- **The stack effect returns `undefined` from S1 on.** `Apply.apply` + short-circuits on a falsy plan output (`src/Apply.ts:189-191`): no + `setOutput` write, and the bin skips its `Console.log`. This kills the + `{ outputs: {} }` dump one slice early and makes S3's action the only + output channel. +- **Action inputs must be plain data + alchemy Inputs.** Plan-time + `hashInput(resolvedInput)` serializes the input — graph nodes (functions, + Standard Schemas) must never ride in it. Addresses + primitives in the + input; the graph joins in the runner via closure. +- **`ctx.application` is typed `unknown`, narrowed by an extension-owned + guard** (`CloudApplication`/`projectIdOf`). Full generic threading of the + application type through `ExtensionDescriptor`/`NodeDescriptor` was + rejected: the registry is heterogeneous, and the assignment would lean on + method bivariance in contravariant positions across packages. The + `ServiceLowering` generics are fine because producer and consumer + are the same descriptor; the registry stores them at the `unknown` + defaults through method bivariance (kept deliberately — noted in S1). + +## Alternatives considered + +- **`NodeReport` on `LoweredNode`** (PR #101): rejected — reporting data on + the wiring contract, untyped, meaningless on two of three phases. +- **A separate `describe()` SPI hook**: rejected — the resource handles + live inside `deploy()`'s effect; `describe()` would need them handed back + out, which is just returning them with extra plumbing. +- **Transporting results to the CLI parent** (stdout parsing → JSON file → + state-store `getOutput` readback): all rejected — the child already holds + both the Graph and the results; every transport was solving a problem + that doesn't exist. The state-store version also let a serialization + concern dictate the domain model ("can't hold a Node because Postgres"). +- **Making the whole `DeploymentResult` the stack output**: rejected — + alchemy persists stack output unconditionally, and nodes carry functions/ + schemas. Plain primitives cross; the join to nodes happens on our side. + +## Open questions + +- Operator confirmation on wiring enforcement (S2 contingent). +- `Output.evaluate` behavior on plain values mixed into the returned + structure — verify early in S3. +- Whether `ApplicationDescriptor`/provisioner surfaces get full generic + treatment or a minimal rename (S1 decides from their actual consumers). + +## References + +- `packages/0-framework/1-core/core/src/deploy.ts` — SPI + loop. +- `packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts` + — the cast-recovery example (lines 155–161). +- alchemy `2.0.0-beta.59` `src/Deploy.ts`, `src/Resource.ts`, + `src/Apply.ts` — execution-model evidence. +- ADR-0005, ADR-0031; `architecture.config.json`. diff --git a/.drive/projects/spi-inversion-and-deploy-results/learnings.md b/.drive/projects/spi-inversion-and-deploy-results/learnings.md new file mode 100644 index 00000000..e245190e --- /dev/null +++ b/.drive/projects/spi-inversion-and-deploy-results/learnings.md @@ -0,0 +1,159 @@ +# Learnings — SPI inversion & deployment results + +Working ledger. Reviewed with the operator at close-out; cross-cutting +lessons migrate to durable docs, project-local ones drop with this folder. + +## A cast in the code you are specifying against is evidence that someone +## made a claim — NOT evidence that the claim was true + +**The pattern.** Across S1's three dispatches the implementer pushed back on +pinned spec text three times, and was right all three times: + +1. `ComputeProvisioned.serviceId: string` — actually `Output`. I read + the existing `provisioned.outputs['serviceId'] as string` as evidence of + the real type. +2. `computeDescriptor`'s return type `NodeDescriptor` — contradicted the + spec's own "compose-over-base stays", because the erased type would have + forced s3-store to cast `P`/`S` back in. +3. My pinned `isCloudApplication` body — contained a bare `as`, which would + have *relocated* a cast in a cast-removal slice. `'projectId' in value` + narrows without one. + +**The common root, in the reviewer's words:** each pushback surfaced +something the spec got wrong by reading an existing cast as evidence of a +real type, when the cast was what made the wrong type compile. + +**Why it bit here specifically.** This project's whole subject is a seam +where casts hid type lies. So the code I was specifying *against* was +maximally untrustworthy as a source of type facts — the casts were the +disease, and I used them as the diagnosis. + +**How to apply it to S2 and S3.** When pinning a type, derive it from the +producing expression's real type (compile a probe), never from an adjacent +cast or a declared prop type that accepts a union. This is already why S3's +spec was pre-emptively amended: `DeployedPrimitive.id: string` had the +identical defect, caught by applying this lesson rather than by paying a +second halt. + +**Candidate for migration:** this generalizes past this project. Any +refactor that removes casts is specified against code whose casts are lying. +Worth a line in the repo's cast rules (`.agents/rules/no-bare-casts.mdc`) at +close-out — the rule currently governs *writing* casts, not *reading* them. + +## Halts earn their cost when the spec claims to be zero-freedom + +The "zero creative freedom" framing put all the risk on the spec being +right. That only worked because the halt conditions were explicit and the +implementer used them instead of improvising. The `serviceId` halt cost one +round-trip and prevented a laundered type; it also taught me the class of +bug, which I then found pre-emptively in S3's spec. A dispatch that had +"just made it compile" would have buried both. + +**Corollary:** zero-freedom specs need *more* halt discipline, not less. The +freedom removed from implementation has to reappear as permission to stop. + +## A green check is a claim, and claims get controlled + +Four times in this project an agent refused to accept that a check passing +meant the check worked. Each time the control was cheap and the finding was +real: + +| Control | What it proved | +| --- | --- | +| S2-D2 mutated the real postgres descriptor to under-deliver | The guard reaches real descriptor pairs → the "no pair under-delivers" null result means something | +| S3-D1 added a third deploy with the **same** nonce | The action noop is real → the nonce is precisely what defeats it, not "actions always run" | +| S3-D2 made the Action unconditional | The sync test genuinely catches alchemy's `Stack` leaking into core's requirements | +| S3-D3 introduced a deliberate plane violation into `report.ts` | **`lint:deps` was passing blind** — see below | + +The generalization: **a passing check and an absent check are indistinguishable +from the outside.** Before a green gate is allowed to support a claim, make it +go red once on purpose. This is the same epistemics as the project's central +thesis — a claim nobody was asked to defend is not a claim that was checked. + +## Silence is not success — the false-green, and why the obvious diagnosis was wrong + +S3-D3 reported `pnpm lint → exit 0`. It was exit 1, two branch-introduced +errors. The reviewer caught it by running the gate instead of reading the +report. + +**My diagnosis was wrong and the implementer corrected it.** I assumed a +stale run — lint executed before the late JSON edits. It hadn't been: the +command was + +```sh +pnpm lint >/dev/null 2>&1 && echo "exit 0 clean" +``` + +The `&&` swallowed the failure, nothing printed, and **the absence of the +success line was read as success.** The transcript shows `--- lint ---` +followed straight by `--- lint:deps ---`, with `exit 0 clean` conspicuously +missing. The gate reported red, in its own terminal, and the report said +green. + +This matters because **the two diagnoses imply different fixes**. "Re-run +gates last" would not have helped — it *was* run last. The fix is: never +infer success from the absence of a failure signal; capture `$?` explicitly. +A command shape that can only ever emit on success is indistinguishable from +one that didn't run. + +Same family as the blind `lint:deps` ✔, one level up: **a check that cannot +announce its own failure is not a check.** The dispatch that built a control +to prove `lint:deps` fires accepted `pnpm lint`'s silence without turning the +same suspicion on it. + +**The reviewer's sharpening, which is the version to keep:** a gate that +reports by *printing on success* is silent in **two different worlds** — +not-reached, and reached-but-failed — and those must never be conflated. The +shape of the command, not the diligence of the operator, is what makes the +two indistinguishable. + +**And the unifying generalization, from both halves of this dispatch: the +check you ran wasn't the check you thought you ran.** The `&&` swallow ran a +gate whose output channel only existed on success. My `--stdin-file-path` +adjudication asked biome to judge *content* when the real gate judges *a file +at a path* — and biome's config resolution keys on the path, so the stdin form +can disagree, and disagree in the **permissive** direction. The only test that +settles a gate dispute is running the gate the way CI runs it. This bit twice +in one dispatch, in both directions (an agent's false green, and my false +refutation of a true finding). + +**My own verification was also unsound**, and worth recording: checking main's +files via `--stdin-file-path` reported them dirty too, which would have let me +dismiss a true finding. Stdin mode doesn't resolve the same config. Swapping +the files in at their real paths is what settled it. When adjudicating a +factual dispute between two agents, the method has to be one whose failure +mode you've thought about. + +## `lint:deps` does not guard new public files (repo-wide, beyond this project) + +Surfaced by S3-D3's control. Two independent mechanisms each sufficient to +let a layering violation land unnoticed: + +1. **`architecture.config.json` lists every `packages/9-public/composer/src/*` + file individually.** A *new* file matches no glob, joins no module group, + and therefore **no rule applies to it**. New public files are unguarded by + default — the config's per-file listing makes that the default failure mode, + not an oversight in any one PR. +2. **`tsconfig.depcruise.json`'s paths must name each entry**, or the cruiser + cannot resolve the edge to source and cannot check it at all. + +S3-D3 fixed both *for its own file* and correctly did not change the +mechanism — that's an audit, not a slice's work. **Filed as a follow-up.** + +Worth stating plainly: the architecture rules are the thing this whole project +leans on to keep the seams honest (ADR-0033's consequences point at them), and +for new public files they were decorative. + +## Verification beats relay — twice, in both directions + +- The reviewer refuted the implementer's ergonomics claim with a compiled + probe (D1). Had I relayed it unchecked, D2 would have hand-annotated five + descriptors for no reason. +- The implementer re-derived every alchemy citation against installed source + rather than copying `design-notes.md`, and most had drifted (D3). The + reviewer then independently re-verified all eight. + +Both times the cheap move was to trust the upstream artifact. Neither agent +took it. **`design-notes.md` is now known to be a lossy source for line +numbers** — it carries a correction header pointing at ADR-0033 as the +authority. diff --git a/.drive/projects/spi-inversion-and-deploy-results/plan.md b/.drive/projects/spi-inversion-and-deploy-results/plan.md new file mode 100644 index 00000000..856576a6 --- /dev/null +++ b/.drive/projects/spi-inversion-and-deploy-results/plan.md @@ -0,0 +1,148 @@ +# SPI inversion & deployment results — Project Plan + +## Summary + +Three slices: the DI refactor first (operator-directed ordering), then +wiring enforcement and deployment results on the clean seams. + +**Spec:** [spec.md](spec.md) · **Design notes:** [design-notes.md](design-notes.md) +· **Learnings:** [learnings.md](learnings.md) + +## Delivery shape — ONE PR (operator decision, 2026-07-17) + +All three slices land on **one branch, `claude/spi-inversion`**, as +[PR #117](https://github.com/prisma/composer/pull/117) — draft until S3 +merges. This overrides the default of one-PR-per-slice. + +**Two real consequences, recorded so they aren't rediscovered:** + +1. **S2 and S3 no longer run in parallel.** The plan sequenced them as a + parallel group because they were to be separate PRs touching different + parts of the loop. One branch plus one persistent implementer means they + **serialize**: S2, then S3. Parallelism was the only thing the split + bought, and the operator traded it deliberately. +2. **Slice-INVEST's _Independent_ ("ships as one PR") no longer holds + literally**, and _Small_ ("manageable in a single code review") is under + real pressure — one reviewer now faces all three slices at once. + Mitigation, not a cure: each slice is independently reviewed inside the + build loop before the next starts, and the PR body separates them so a + reader can take them one at a time. If the final review strains, that is + the predicted cost of this decision, not a surprise. + +**Branch-rename note:** the branch was created as `claude/spi-inversion-s1` +and renamed once the one-PR decision landed. GitHub **closed** the original +PR (#115) on the rename rather than retargeting it, and it could not be +reopened — the old head ref no longer resolves. #117 carries the identical +branch at the same SHA; #115 has a pointer comment. No work was lost, but +rename-after-PR is a trap worth avoiding next time: name the branch for the +delivery shape before opening the PR. + +## Tracker + +Slices are identified by S-number here; this plan is the source of truth. +Linear issues are created per-slice when the slice starts, under +[Prisma Composer: SPI inversion & deployment results](https://linear.app/prisma-company/project/prisma-composer-spi-inversion-and-deployment-results-f87bb6d9de12) +(Terminal/TML). + +## External dependencies + +- **alchemy `2.0.0-beta.59`** — execution-model facts in design-notes are + verified against this version; a version bump mid-project re-opens them. +- **Composer PR #101** — the superseded `NodeReport` PR; disposed of in S3. +- No dependency on other in-flight projects. + +## Slices + +### S1 — Invert the lowering SPI (+ ADR) + +**Spec:** [slices/s1-invert-spi/spec.md](slices/s1-invert-spi/spec.md) +· **Plan:** [slices/s1-invert-spi/plan.md](slices/s1-invert-spi/plan.md) + +Retire `LoweredNode`'s triple duty. Phase handoffs become descriptor-owned +types carried generically by the SPI (opaque to core); the inter-node +wiring record becomes its own named type, still name-keyed because +`buildConfig` reads it by the consumer's declared params. Every descriptor +(compute, postgres, prisma-next, s3-store, s3-credentials) migrates to its +own typed handoffs; the casts that recover a descriptor's own values go +away. Decide and apply the treatment for `ApplicationDescriptor` and +provisioner surfaces from their actual consumers. Author the ADR in +`docs/design/90-decisions/` covering the seam design: consumer-declared +interfaces, the loop as sole router, results assembled at full context, no +transport before a cross-process consumer exists. + +**Builds on:** nothing. +**Hands to:** S2, S3 — a `deploy.ts` SPI whose three contracts are distinct +types with distinct consumers, all descriptors compiling cleanly against it, +behavior unchanged (deploys identical to today). + +### S2 — Enforce the wiring contract *(operator-confirmed 2026-07-17)* + +**Spec:** [slices/s2-enforce-wiring-contract/spec.md](slices/s2-enforce-wiring-contract/spec.md) +· **Plan:** [slices/s2-enforce-wiring-contract/plan.md](slices/s2-enforce-wiring-contract/plan.md) + +After a producer lowers, verify its wiring outputs satisfy every param the +consumer's connection declares (skipping provisioned params, which the mint +supplies). A gap fails the deploy with a `LowerError` naming the edge, the +param, and both nodes. Tests cover the loud path and the provisioned-param +exemption. Behavior change: descriptor pairs silently under-delivering +today start failing — that is the point. + +**Builds on:** S1 (the named wiring type and its single consumer path). +**Hands to:** nothing downstream; independently mergeable. + +### S3 — DeploymentResult and rendered deploys (supersedes #101) + +**Spec:** [slices/s3-deployment-results/spec.md](slices/s3-deployment-results/spec.md) +· **Plan:** [slices/s3-deployment-results/plan.md](slices/s3-deployment-results/plan.md) + +The deploy phase returns wiring and primitives as distinct values +(`LoweredResult`); the lowering loop routes wiring to `lowered` and hands +the address-keyed primitives to an alchemy **Action** declared at the end +of the stack effect. The action runs during apply with the primitives +resolved, joins them to the graph by closure into per-node +`DeploymentResult`s, and calls the CLI's renderer (wired through the +generated stack file): the app's own topology, authored names, platform +ids, public URLs. The stack returns `undefined`, so the raw alchemy +stack-output dump stays gone. First dispatch is a probe of the Action +mechanism on a fresh stack. Close PR #101 with a supersession comment. + +**Builds on:** S1. +**Hands to:** nothing downstream; the project-DoD demo rides on it. + +## Sequencing + +- **Stack:** S1 → S2 → S3, all on `claude/spi-inversion`. +- Originally `S1 → (S2 ∥ S3)`. The one-PR decision serializes S2 and S3 — + see § Delivery shape. Neither waits on a merge now; each starts when the + previous is reviewed. + +## Open items + +- **`buildConfig`'s `edge === undefined` branch is defensive against + something the authoring API already prevents** (surfaced by S2-D1). A + service declaring an input cannot be provisioned without wiring it — it + does not type-check — so the branch is only reachable by mutating the + graph after `Load`. Not acted on in S2: defensive coding in core's loop is + cheap and the pinned guard is correct either way. Worth revisiting if + someone audits core for dead branches; the question is whether `Load` + should assert the invariant the type system implies, which would let the + branch go. + +- **`core-model.md`'s model section is stale beyond this project's reach** + (surfaced by S1-D3, deliberately not fixed there). It still describes a + `Target` with separate `resources`/`services` maps; the code has + `ExtensionDescriptor` with a single `nodes` registry (ADR-0017/0031). + S1-D3 transcribed only the SPI signatures it owned — correctly, since + fixing the surrounding model is its own change with its own review. + **Not a finding; needs a follow-up ticket.** (`Record` + at ~line 447 is *not* part of this: it stays correct under the new + generics, storing at the `unknown` defaults — the erasure ADR-0033 + describes.) + +## Close-out (required) + +- [ ] Verify all acceptance criteria in [spec.md](spec.md) +- [ ] Migrate long-lived docs into `docs/` (the ADR lands in S1; check + design-notes for anything else durable) +- [ ] Strip repo-wide references to `.drive/projects/spi-inversion-and-deploy-results/**` +- [ ] Delete `.drive/projects/spi-inversion-and-deploy-results/` diff --git a/.drive/projects/spi-inversion-and-deploy-results/slices/s1-invert-spi/plan.md b/.drive/projects/spi-inversion-and-deploy-results/slices/s1-invert-spi/plan.md new file mode 100644 index 00000000..cd9f9068 --- /dev/null +++ b/.drive/projects/spi-inversion-and-deploy-results/slices/s1-invert-spi/plan.md @@ -0,0 +1,31 @@ +# S1 — Dispatch plan + +## D1 — Core SPI + loop + core tests + +**Outcome:** `deploy.ts` carries the new type set (spec § Core verbatim); +`lowering()`/`lower()`/`buildConfig` compile against it; `lowering.test.ts` +updated and green, including the new resolves-to-`undefined` test. +**Builds on:** nothing. +**Hands to:** D2 — a compiling core whose SPI the extension migrates onto. +**Completed when:** `pnpm turbo run test --filter @internal/core` green; +no `LoweredNode` reference remains under `packages/0-framework/`. + +## D2 — Extension migration + target tests + +**Outcome:** the five descriptors, `shared.ts` (`CloudApplication` + +guarded `projectIdOf`), and `control.ts` implement the typed SPI per spec; +compute's two `as` casts and shared's `blindCast` deleted; target tests +(incl. the new `projectIdOf` seam-error test) green. +**Builds on:** D1's SPI. +**Hands to:** D3 — a fully compiling workspace. +**Completed when:** repo-wide typecheck + target package tests green; +cast ratchet net-negative. + +## D3 — ADR + doc sweep + full CI + +**Outcome:** ADR-0033 (spec § Docs content contract) + decisions index +updated; `core-model.md` SPI quotes transcribed to the new types; full CI +green. +**Builds on:** D2 (documents what now exists). +**Hands to:** slice PR open; S2/S3 unblocked. +**Completed when:** `git grep LoweredNode` empty repo-wide; CI green. diff --git a/.drive/projects/spi-inversion-and-deploy-results/slices/s1-invert-spi/spec.md b/.drive/projects/spi-inversion-and-deploy-results/slices/s1-invert-spi/spec.md new file mode 100644 index 00000000..51b2056b --- /dev/null +++ b/.drive/projects/spi-inversion-and-deploy-results/slices/s1-invert-spi/spec.md @@ -0,0 +1,371 @@ +# S1 — Invert the lowering SPI (+ ADR) + +## At a glance + +Retire `LoweredNode` and give each of its three roles its own +consumer-declared type. Behavior-preserving except one deliberate change: +the stack effect returns `undefined`, which kills the `{ outputs: {} }` +dump alchemy prints after every deploy. All type decisions below are +settled — the implementer's freedom is limited to mechanical execution and +test-fixture naming. + +## Chosen design + +### Core: `packages/0-framework/1-core/core/src/deploy.ts` + +**Delete** `LoweredNode`. **Add**: + +```ts +/** + * A node's inter-node wiring outputs — the values downstream nodes' declared + * connection params resolve against (buildConfig reads them by param name). + * Name-keyed and unknown-valued of necessity: core cannot know extension + * types, and which producer feeds which consumer is decided by the user's + * graph at runtime. The connection declaration is the contract. + */ +export type WiringOutputs = Readonly>; +``` + +**Change the SPI signatures** (method syntax must be kept — heterogeneous +descriptors assign to the registry through TS method bivariance; a +property-arrow form would break the assignment, do not "improve" it): + +```ts +/** One node's realization. Runs inside the Alchemy stack effect. */ +export type Lowering = (ctx: LowerContext) => Effect.Effect; + +export interface ApplicationDescriptor { + provision(ctx: LowerContext): Effect.Effect; +} + +/** + * The phased service SPI. `P` and `S` are the descriptor's OWN intra-node + * handoff types — provision's product consumed by serialize/deploy, and + * serialize's product consumed by deploy. Core threads them through without + * inspection; only the descriptor that writes them reads them. + */ +export interface ServiceLowering

{ + provision(ctx: LowerContext): Effect.Effect; + serialize(ctx: LowerContext, provisioned: P, config: Config): Effect.Effect; + package(ctx: LowerContext, input: PackageInput): Effect.Effect; + deploy( + ctx: LowerContext, + provisioned: P, + artifact: Artifact, + serialized: S, + ): Effect.Effect; +} +``` + +**`LowerContext` changes** (doc comments updated to match): + +- `application: LoweredNode` → `application: unknown` — the owning + extension's application hook product; `undefined` when the extension + declares no hook. Core never reads it; the extension narrows it with its + own type guard. +- `lowered: ReadonlyMap` → `ReadonlyMap`. + +**Loop changes in `lowering()`**: + +- `noApplication` constant deleted; `applications` becomes + `Map`; `applications.get(node.extension)` (no `??` + fallback — absent means `undefined`). +- `lowered` becomes `Map`; `lowered.set(id, …)` + stores the descriptor's return directly. +- Final `return { outputs: {} }` → `return undefined`, and the function's + return type becomes `Effect.Effect` (the + trailing `as` assertion on the gen block updates to match; keep the + existing assertion idiom, do not introduce new casts). +- `lower()`: `stackEffect` typed `Effect.Effect`. + +**`buildConfig`**: parameter `lowered: ReadonlyMap`; +`const producedOutputs = edge !== undefined ? (lowered.get(edge.from) ?? {}) : {};` +(the `?.outputs` hop disappears). + +**`app-config.ts`**: no structural change — `NodeDescriptor`'s service arm +stays `{ kind: 'service' } & ServiceLowering` (defaults ``). + +### Extension: `packages/1-prisma-cloud/1-extensions/target/src/` + +**`descriptors/shared.ts`** — replace `projectIdOf`'s `blindCast` with an +extension-owned application contract and a real narrow: + +```ts +/** What prisma-cloud's application hook produces; its own descriptors are the only consumers. */ +export interface CloudApplication { + readonly projectId: string; +} + +export function isCloudApplication(value: unknown): value is CloudApplication { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { projectId?: unknown }).projectId === 'string' + ); +} + +/** Narrows ctx.application at the extension seam; throws naming the seam when the hook didn't run. */ +export function projectIdOf(application: unknown): string { + if (!isCloudApplication(application)) { + throw new Error( + 'prisma-cloud: ctx.application is not this extension\'s application product — ' + + 'the prismaCloud() application hook must run before any node lowers.', + ); + } + return application.projectId; +} +``` + +(`projectIdOf` call sites pass `application` / `provisioned` as before — +see per-file notes. The `blindCast` import goes away here.) + +**`control.ts`** — the application hook returns `{ projectId }` satisfying +`CloudApplication` (drop the `{ outputs: { … } }` wrapper). The +`serviceKeyProvisioner` is untouched — provisioner refs are opaque +`unknown` by design (ADR-0031), and that stays. + +**`descriptors/compute.ts`** — export the descriptor's own handoff types and +type the descriptor against them: + +```ts +export interface ComputeProvisioned { + /** The yielded resource's attribute — an unresolved reference until apply. */ + readonly serviceId: Output.Output; + /** Not a resource attribute: the CLI-supplied project id, a plain string. */ + readonly projectId: string; +} +export interface ComputeSerialized { + readonly environment: readonly Prisma.EnvironmentVariable[]; + readonly port: number; +} +``` + +**Amended 2026-07-17 (D2 halt, evidence accepted).** `serviceId` was +originally pinned as `string`. It cannot be: alchemy maps every resource +attribute through `Output` (`Resource.d.ts:95-100`), so `svc.id` is +`Output` — the lazy-proxy fact design-notes already records, +which the original pin failed to carry into the type. Probe: +`error TS2322: Type 'Output' is not assignable to type 'string'`. + +The accurate type serves this slice's goal *better than the original text +did*: `provisioned.serviceId` flows into `Prisma.Deployment`'s +`computeServiceId: Input` position with **no cast**, because +`Input = T | Output | …`. Pinning `string` would have forced the +`as string` cast to be reintroduced to keep the write site compiling. +`projectId` stays `string` — it comes from the CLI's env, not from a +resource. + +**Why the old code hid this:** `provisioned.outputs['serviceId'] as string` +laundered `Output` into `string`, and the lie was invisible because +the consuming prop accepts both. That is the bag's cost made concrete — an +untyped record let a producer's real type be misdescribed at the read site, +and the cast made it compile. Retiring the bag surfaced it. This is +ADR-0033 evidence, not an incidental fix (see § Docs). + +- Factory return type: **the precise type** + `{ kind: 'service' } & ServiceLowering`, + not `NodeDescriptor`. + + **Amended 2026-07-17 (D2 deviation, reviewer-endorsed).** The original + pin said `NodeDescriptor` *and* told s3-store to keep composing over + compute's base descriptor. Those contradict: `NodeDescriptor` erases + `P`/`S`, so `base.provision` returns `Effect` and s3-store would + have to cast the types back in — adding casts to a cast-removal slice and + **re-creating in s3-store the exact seam this slice exists to kill** (a + producer-side shape a consumer must cast to use). The precise type only + publishes what the erased type discarded; it adds no unsoundness, and the + registry assignment in `control.ts` still goes through by method + bivariance (`prismaCloud` is annotated `ExtensionDescriptor`, assigning + into `Record`). + + Consequence: s3-store's `base.kind !== 'service'` runtime check is + **deleted**, not kept. It was unreachable — the literal is + `kind: 'service' as const` — and existed solely to narrow the erased + union for the compiler. +- `provision` returns `{ serviceId: svc.id, projectId: projectIdOf(application) }` + (no wrapper; `application.outputs['projectId']` read is replaced by + `projectIdOf(ctx.application)`). +- `serialize`: `projectIdOf(provisioned)` → `provisioned.projectId`; returns + `{ environment: records, port }` (no wrapper). The `port` fallback logic + (`typeof config.service['port'] === 'number' ? … : 3000`) stays in + serialize; `port` is a plain `number` from here on. +- `deploy`: `provisioned.serviceId` (cast deleted), `serialized.environment` + (cast deleted), `port: serialized.port` (typeof-fallback deleted — the + type carries it now). Returns + `{ url: deployment.deployedUrl, projectId: provisioned.projectId }` (no + wrapper). +- The `keyOuts` `blindCast` in serialize stays — a provisioner ref is + `unknown` by ADR-0031 and `Output` is not runtime-guardable; the + cast's justification comment already says exactly this. + +**`descriptors/s3-store.ts`**: + +```ts +export interface S3StoreSerialized extends ComputeSerialized { + readonly bucket: unknown; + readonly accessKeyId: unknown; + readonly secretAccessKey: unknown; +} +``` + +- Descriptor typed `ServiceLowering`. +- The compose-over-base pattern stays; `base` narrows via the same + `satisfies`/typed-literal approach used in compute (the current + `base.kind !== 'service'` runtime check may stay for the discriminant). +- serialize spreads `…serialized` (the typed base product) plus the three + fields; the existing D4a↔D4b missing-field error check is unchanged. +- deploy returns `{ …deployed, bucket: serialized.bucket, … }` — `deployed` + is now `WiringOutputs` (a bare record), so the `.outputs` hops disappear. + +**`descriptors/postgres.ts`, `prisma-next.ts`, `s3-credentials.ts`** — each +`Lowering` returns the bare record (drop the `{ outputs: … }` wrapper); +`projectIdOf(application)` call sites unchanged in shape (the helper's +parameter is now `unknown`). + +### Tests + +The compiler finds every remaining site; the known ones: + +- `packages/0-framework/1-core/core/src/__tests__/lowering.test.ts` — + `LoweredNode` import → `WiringOutputs`; `{ outputs: { url: … } }` map + literals → bare `{ url: … }`; `run()`'s effect type parameter → + `undefined`; fake descriptors' returns unwrap. +- `packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts` + — same treatment; application-hook assertions check the bare + `{ projectId }` product. +- Add one new test in `lowering.test.ts`: the lowering effect resolves to + `undefined` (pins the no-stack-output behavior). +- Add one new test near `shared.ts`'s tests (or in + `control-lowering.test.ts`): `projectIdOf` throws its seam error on a + non-conforming value. + +### Docs + +- `docs/design/10-domains/core-model.md` lines ~455–525 quote the old SPI + verbatim — update the quoted signatures to the new ones (mechanical + transcription of the types above). +- **ADR-0033** (next free number; re-check the index at write time) in + `docs/design/90-decisions/`, titled + `ADR-0033-lowering-spi-seams-are-consumer-declared.md`. Content contract: + - Decision: each lowering-SPI seam's type is declared by its consumer — + descriptor-owned generic phase handoffs; the connection declaration as + the wiring contract (runtime-checked across the extension seam, and + silent under-delivery becomes an error — S2); core-declared deployment + results assembled by the lowering loop at full context (S3); the loop + as the only router. No shared producer-side bag. + - Context: the three-roles analysis of `LoweredNode` and the `NodeReport` + failure (PR #101). Include the **`serviceId` finding as concrete + evidence for the thesis**: the shared untyped record let compute's + provision product be *misdescribed* — `serviceId` is an unresolved + `Output`, and `provisioned.outputs['serviceId'] as string` + laundered it into a `string` at the read site, compiling only because + the consuming prop accepts both. Retiring the bag surfaced it on the + first migration. + + **State the thesis precisely — deliberate-and-audited vs. accidental, + NOT bag-vs-no-bag.** The blunt version ("a bag lets you lie, a typed + handoff doesn't") is refuted by our own code: `keyOuts`' surviving + `blindCast` claims `Output` through a different `unknown`-typed + seam (provisioner refs, ADR-0031) and we are keeping it. The difference + that matters is not whether an unchecked claim exists but whether it is + **named, justified, and singular**: the provisioner-ref cast is a + single site carrying its own written justification, which a reviewer + can evaluate and a grep can find. The bag made the same kind of claim + **anonymously, at every read site, with nothing recording that a claim + was being made at all.** That is the version that survives contact with + the cast that stays. + - **The seam taxonomy** — three seams, three different mechanisms: + 1. **Phase handoffs** (same party writes and reads): precise types, + defended by the **compiler**. + 2. **The application seam** (crosses core's `unknown` into an + extension): a precise claim — `CloudApplication.projectId: string` — + defended by a **runtime guard** (`isCloudApplication`), because the + compiler cannot reach across it. + 3. **The wiring seam**: claims **nothing** (`unknown` values, resolved + by the consumer's declared params). `unknown` cannot lie — which is + why `warm.url` and `creds.accessKeyId`, also unresolved + `Output`s, were never mis-typed the way `serviceId` was. + The bug could only ever have lived where a precise claim was made + without a mechanism defending it. + - Consequence to fold into the alchemy-facts appendix: **phase handoff + types legitimately carry unresolved `Output` references**, because + the whole stack effect runs before apply. A handoff type that promises + resolved values is lying — the same lazy-Output truth as the execution + model, showing up in the SPI's types. + - Consequences, three parts: + 1. The alchemy-facts appendix (stack effect runs pre-apply; resolved + values exist only in the stack's evaluated return / action inputs; + per-resource change status is CLI-event-only), so S2/S3 build on + recorded facts. Cite alchemy `2.0.0-beta.59` file:line as in + design-notes. + 2. **The heterogeneous registry's type safety rests on the lowering + loop, not the compiler.** Method bivariance is what lets descriptors + with different `P`/`S` assign into one `Record`, + and it is unsound by construction: core calls + `descriptor.serialize(ctx, provisionedNode, config)` with + `provisionedNode: unknown`, so the compiler would not object if the + loop ever threaded the wrong node's provisioned value. The loop is + correct today; this is the accepted price of a registry core cannot + type. State it, so anyone editing the loop (S2 and S3 both do) knows + what it is holding. + 3. **The one producer-side shape that crosses a module boundary is + `ComputeSerialized`** (s3-store's handoff type extends it). That is + legitimate because s3-store composes compute's own descriptor — same + party, not an unrelated consumer. Record the tripwire: a third + descriptor importing `ComputeSerialized` *without* composing compute + is the shared bag reforming, and the answer is its own handoff type, + not a widened shared one. + - Update `docs/design/90-decisions/README.md` index. + +## Coherence rationale + +One reviewer, one sitting: a single mechanical seam change radiating from +one file (`deploy.ts`) through five descriptors and two test files, plus an +ADR that documents exactly that change. No behavior changes to hold in +one's head beyond the deleted stack dump. + +## Scope + +**In:** everything above. +**Deliberately out:** wiring enforcement (S2); primitives/results/rendering +(S3); any change to `buildConfig`'s resolution semantics; the cron shared +module and examples (they author via `module()`/`compute()`, not the SPI — +verified no `LoweredNode` references outside the files listed). + +## Pre-investigated edge cases + +| Case | Ruling | +| --- | --- | +| `ServiceLowering` registry assignability | Works only through method-syntax bivariance. Keep method syntax; add a one-line comment on the interface saying so. | +| Stack effect returning `undefined` | Verified against alchemy source: `Apply.apply` short-circuits (`if (!plan.output) return undefined`) — no `setOutput` write, and the bin's `Console.log(outputs)` is skipped. Existing `alchemy_stack_output` rows become stale but harmless. | +| `keyOuts` blindCast in compute.serialize | Stays. Provisioner refs are opaque by ADR-0031; do not attempt a guard on `Output`. | +| `applications` map default | Absent application hook now yields `ctx.application === undefined` (was `{ outputs: {} }`). Only prisma-cloud's own descriptors read it, and they now go through `projectIdOf`'s guard, which throws its seam error on `undefined` — correct, since those descriptors require the hook. | + +## Slice-DoD + +- **No `LoweredNode` reference in live code, or in docs describing the + *current* system** — i.e. `git grep LoweredNode -- packages/ docs/design/10-domains/` + returns nothing. + + **Amended 2026-07-17 (D3 flag, accepted).** This was originally written + as "returns nothing repo-wide (docs included)", which is incoherent: an + ADR recording the retirement of `LoweredNode` **must name it** — that is + what the record is for, and it is the house pattern (ADR-0025 names + ADR-0014's superseded noun). The index entry must name it too, or a + reader asking "what happened to `LoweredNode`?" can't find ADR-0033. The + literal condition would have traded a durable record for a passing string + check. Deliberate surviving references: ADR-0033, the decisions index, + and this project's own `.drive/` working docs. + +- `pnpm lint:casts` shows a net decrease, with every delta accounted for. + **Note the accounting rule** (established in D2): the ratchet counts bare + `as` tokens only, so removing a `blindCast` call — as `shared.ts` does — + correctly does **not** move it. The −2 is exactly compute's two `as`. + +## References + +- Project spec: [../../spec.md](../../spec.md) · design notes: + [../../design-notes.md](../../design-notes.md) +- `packages/0-framework/1-core/core/src/deploy.ts:54-118` (current SPI), + `:392-536` (loop) +- `packages/1-prisma-cloud/1-extensions/target/src/control.ts:100-160` diff --git a/.drive/projects/spi-inversion-and-deploy-results/slices/s2-enforce-wiring-contract/plan.md b/.drive/projects/spi-inversion-and-deploy-results/slices/s2-enforce-wiring-contract/plan.md new file mode 100644 index 00000000..4e1bca3b --- /dev/null +++ b/.drive/projects/spi-inversion-and-deploy-results/slices/s2-enforce-wiring-contract/plan.md @@ -0,0 +1,36 @@ +# S2 — Dispatch plan + +## D1 — Failing tests + +**Outcome:** the four Slice-DoD cases exist in `lowering.test.ts`; cases +1 fails (no guard yet), 2–4 pass (pinning the exemptions before the guard +lands). +**Builds on:** S1 merged. +**Hands to:** D2 — an executable contract for the guard. +**Completed when:** test run shows exactly case 1 red. + +## D2 — Implement the guard + +**Outcome:** the spec's guard clause + proxy-fact comment in `buildConfig`; +all four cases green; dogfood/example lowering tests still green (any +newly-exposed under-delivery fixed as its own commit, named in the PR body). +**Builds on:** D1. +**Hands to:** D3 — a live guard whose user-visible consequence needs writing +down. +**Completed when:** full CI green. **DONE** (`ded15f4`, `49240bd`) — no +existing pair under-delivers; reach proven by mutating the real postgres +descriptor (3 e2e tests red, restored). + +## D3 — Document the new failure mode (F3) + +**Outcome:** `docs/guides/**` and `skills/prisma-composer/SKILL.md` both +explain the new deploy-time failure — added because +`.agents/rules/user-facing-surface-changes.mdc` (`alwaysApply: true`) +requires it and no slice owned the debt. +**Builds on:** D2 (documents behaviour that now exists). +**Hands to:** S3 — the branch's docs obligation for S2 is closed, so S3's +own surface changes are the only remaining debt. +**Completed when:** both surfaces updated; guides and skill do not disagree; +`website` content tests green; the guide explains the *consequence* (a +previously-green deploy now fails, and what to do) rather than the +mechanism. diff --git a/.drive/projects/spi-inversion-and-deploy-results/slices/s2-enforce-wiring-contract/spec.md b/.drive/projects/spi-inversion-and-deploy-results/slices/s2-enforce-wiring-contract/spec.md new file mode 100644 index 00000000..045eba60 --- /dev/null +++ b/.drive/projects/spi-inversion-and-deploy-results/slices/s2-enforce-wiring-contract/spec.md @@ -0,0 +1,177 @@ +# S2 — Enforce the wiring contract + +## At a glance + +A producer that fails to supply a consumer's declared connection param +today yields a silent `undefined` serialized into the consumer's +environment — the failure surfaces at the consumer's boot, far from the +mistake. Under the inverted seam (ADR-0033) the consumer's connection +declaration is the contract; this slice makes under-delivery a loud +`LowerError` at deploy time, naming the edge. Operator-confirmed behavior +change (2026-07-17). + +## Chosen design + +All changes in `packages/0-framework/1-core/core/src/deploy.ts`, +`buildConfig`'s inputs loop — the one place the contract is resolved. + +Current loop body (post-S1 shape): + +```ts +const producedOutputs = edge !== undefined ? (lowered.get(edge.from) ?? {}) : {}; +const values: Record = {}; +for (const [name, param] of Object.entries(inputNode.connection.params)) { + values[name] = + param.provision !== undefined + ? provisioned.get(`${id}.${inputName}`) + : producedOutputs[name]; +} +``` + +New behavior — inside the same `for` loop, for the non-provisioned branch +only, when **an edge exists**: + +```ts +const value = producedOutputs[name]; +if (value === undefined && param.optional !== true && edge !== undefined) { + throw new LowerError( + `Connection input "${id}.${inputName}" declares param "${name}", but its producer ` + + `"${edge.from}" did not supply it — the producer's wiring outputs carry ` + + `[${Object.keys(producedOutputs).join(', ') || 'nothing'}]. Add "${name}" to the ` + + `producer's returned wiring outputs, or declare the param optional on the connection.`, + ); +} +values[name] = value; +``` + +Pinned rulings: + +- **`throw`, not `Effect.fail`** — matches `resolveParam`'s existing idiom + in the same file; consistency wins over channel purity here. +- **`value === undefined` is the test** (not `name in producedOutputs`): a + producer explicitly setting a key to `undefined` counts as missing — + matches `resolveParam`'s bound-detection idiom. +- **Presence check only, no schema validation.** Wiring values at lowering + time are routinely alchemy `Output` proxies (e.g. `deployment.deployedUrl`) + — symbolic references that no Standard Schema can validate before apply + resolves them. Record this as a code comment on the check, so nobody + "completes" the enforcement later without noticing the proxy fact. +- **`edge === undefined` keeps today's behavior** (all params resolve + `undefined`, no error). An unwired input is a graph-construction concern, + out of this slice's scope; the check must not change that path. +- **Provisioned params are exempt** — the mint supplies them (ADR-0031); + the `param.provision !== undefined` branch is untouched. +- **`param.optional === true` is exempt** — the consumer said absent is + legal; boot-side `coerce()` already reads a missing var as `undefined`. + +## Coherence rationale + +One guard clause, one error message, a handful of tests — a reviewer holds +the entire semantic change (silent → loud) in one screen of diff. + +## Scope + +**In:** the guard, its comment, its tests. +**Deliberately out:** schema validation of wiring values (impossible +pre-resolution — see ruling); unwired-input handling; any descriptor +change (none should be needed — if a descriptor pair fails the new check +in tests or dogfood, that is a real latent bug, fixed as its own commit in +this slice with the failure named in the PR body). + +## Pre-investigated edge cases + +| Case | Ruling | +| --- | --- | +| s3-store's D4a↔D4b check | Its own serialize-time error stays — it guards `config` fields, not wiring presence; no overlap, no removal. | +| Optional connection params in existing modules | `coerce()`'s missing-var-as-absent contract (compute.ts serialize comment) is exactly the exempted path — unchanged. | + +## Two facts D1 established (2026-07-17) — carry into the PR narrative + +**1. The old behaviour was written down as a test, and this slice deletes +that assertion.** `lowering.test.ts` carried +*"a param the graph declares but the lowered outputs never produced +resolves to undefined"* — `db` wired, producer supplying nothing, `url` +declared required, asserting `{ url: undefined }`. That is precisely the +silent failure S2 retires, so DoD case 1 is that test **inverted**, and it +replaces rather than joins it. + +This is the right call and the operator has confirmed the behaviour change +— but the diff will show a deleted assertion, and a reviewer must see that +as *the point*, not an oversight. Note the honest reading: the old +behaviour was characterized, not designed. The test recorded what +`buildConfig` did; nothing argued it was correct that a missing producer +output should reach a booting service as `undefined`. + +**2. `buildConfig`'s `edge === undefined` branch is unreachable through +authoring.** `h.provision(auth, { id: 'auth' })` on a service declaring a +`db` input does not type-check — the authoring API requires declared inputs +be wired. So the branch is defensive only; D1's case 4 reaches it by +dropping the edge after `Load`, with a comment saying so. + +**Ruling: implement the pinned condition as written.** `edge !== undefined` +inside the per-param check is correct either way, and defensive coding in +core's loop is cheap. The observation is recorded, not acted on — see the +project plan's § Open items. + +## The blast radius is USER apps, not just our descriptors (established in review) + +`dependency` is exported from `@internal/core`'s index (`index.ts:53`), and +`packages/9-public/composer/src/index.ts` is `export * from '@internal/core'`. +**App authors declare their own connections.** So the population this change +affects is not "our five descriptors" — it is every user-authored connection +declaration whose producer doesn't supply a declared required param. + +This corrects how the null result must be framed. "No existing descriptor +pair under-delivers" is true and measured, but it covers **our** pairs and +says nothing about user apps, which is where the real exposure is. State it +that way in the PR; don't let the null result read as "nothing breaks." + +Two further precisions for the PR narrative, from review: + +- The postgres mutation proves the guard **can** fire through the real + pipeline. The green suite **with the guard live** is what proves nothing + under-delivers. The honest claim is therefore: *no descriptor pair the + suite exercises under-delivers.* +- The residual is inverted by the slice itself — an untested + under-delivering pair now fails **loudly at deploy** instead of silently + at boot. The uncovered case is handled by the mechanism under review, + which is why the qualified claim suffices. + +## Slice-DoD + +**Docs + skill (D3) — required, `.agents/rules/user-facing-surface-changes.mdc` +is `alwaysApply: true`:** + +- [ ] `docs/guides/**` and `skills/prisma-composer/SKILL.md` both updated in + this PR. + +**Amended 2026-07-17 (F3, review).** My original DoD listed only test cases +— a spec-authoring miss, not an implementation one. S2 is precisely the +rule's named most-missed case ("behaviour a user hits without changing a +line of their code — a new default, **failure mode**, or status code"), and +the rule's own example is the same shape (RPC endpoints began returning 401 +to unwired callers and neither surface said so). The one-PR decision is why +this is still catchable rather than already shipped: the "same PR" window is +open, but **no slice owned the debt** — S2's DoD listed tests, S3's spec is +primitives/rendering. It is now owned here, explicitly. + +Write the consequence, not the mechanism: the line worth writing is the one +that stops a bug report from a user whose previously-green deploy now fails. + +New `lowering.test.ts` cases, all green: + +1. Producer omits a declared required param → deploy fails with a + `LowerError` whose message contains the edge id (`consumer.input`), the + param name, the producer id, and the producer's actual key list. +2. Producer omits a declared `optional` param → lowering succeeds; the + consumer's config carries `undefined` for it. +3. A provisioned param with no producer-supplied value → untouched by the + guard (mint path). +4. Unwired input (no edge) → today's behavior, no error. + +## References + +- Project spec: [../../spec.md](../../spec.md) · builds on S1's + `WiringOutputs` seam. +- `packages/0-framework/1-core/core/src/deploy.ts:237-250` (the loop), + `:178-218` (`resolveParam`'s idioms this mirrors). diff --git a/.drive/projects/spi-inversion-and-deploy-results/slices/s3-deployment-results/plan.md b/.drive/projects/spi-inversion-and-deploy-results/slices/s3-deployment-results/plan.md new file mode 100644 index 00000000..b22ec446 --- /dev/null +++ b/.drive/projects/spi-inversion-and-deploy-results/slices/s3-deployment-results/plan.md @@ -0,0 +1,52 @@ +# S3 — Dispatch plan + +## D1 — Action-mechanism probe + +**Outcome:** a throwaway stack (scratch, not committed) proves: an +`Action` whose input references a fresh resource's attribute plans and +runs on a first deploy; the nonce forces re-run on an unchanged redeploy; +the runner receives resolved values. It must also settle the two typing +questions the spec's edge-case table names: that `In`'s **mutable** arrays +map correctly through `Input<>` (a `readonly T[]` does not satisfy its +array branch), and that nested `Output` fields inside +`entries[].primitives[]` are accepted at the call site and arrive +**resolved** in the runner. Probe torn down after. +**Builds on:** S1 merged. +**Hands to:** D2 — the mechanism confirmed, or a STOP → discussion-mode +signal per the spec's edge-case table. +**Completed when:** both probe deploys observed; findings noted in the +dispatch return. + +## D2 — Core: LoweredResult + loop + action + joinDeployment + +**Outcome:** spec § Core types + § SPI change + § Loop change implemented; +`joinDeployment` exported and unit-tested (missing-address skip included); +existing lowering tests updated to `LoweredResult` returns; sync tests +still run without alchemy context. +**Builds on:** D1's confirmation. +**Hands to:** D3 — core compiles; descriptors don't (their returns are now +type errors), which is the migration worklist. +**Completed when:** core package tests green. + +## D3 — Descriptors + renderer + generated stack + +**Outcome:** the five descriptors return the pinned primitives table; +`render-deployment.ts` implements the pinned format (pure, unit-tested +against a fixture covering nested addresses, no-primitive nodes, url and +no-url primitives); `@prisma/composer` gains the `./report` export; +`generate-stack.ts` template emits the import + `report:` option; snapshot +tests updated. +**Builds on:** D2's types. +**Hands to:** D4 — a fully wired build. +**Completed when:** full repo CI green. + +## D4 — Live verification + #101 closure + +**Outcome:** deploy of the example/dogfood app shows the rendered tree on +a changed AND an unchanged redeploy, no stack-output blob; output-ordering +cosmetics assessed (upstream ask filed if ugly); PR #101 closed with the +supersession comment; slice PR opened. +**Builds on:** D3. +**Hands to:** project close-out. +**Completed when:** Slice-DoD checked off with deploy transcript excerpts +in the PR body. diff --git a/.drive/projects/spi-inversion-and-deploy-results/slices/s3-deployment-results/spec.md b/.drive/projects/spi-inversion-and-deploy-results/slices/s3-deployment-results/spec.md new file mode 100644 index 00000000..3f50f8a2 --- /dev/null +++ b/.drive/projects/spi-inversion-and-deploy-results/slices/s3-deployment-results/spec.md @@ -0,0 +1,360 @@ +# S3 — DeploymentResult and rendered deploys + +## At a glance + +A node's final lowering phase reports the platform primitives it became, +distinctly from its wiring outputs. The lowering loop assembles them; an +alchemy **Action** — declared last in the stack effect, forced to run every +deploy by a nonce — receives the *resolved* primitives during apply, joins +them to the graph it holds by closure, and invokes a CLI-supplied renderer. +The user sees their own topology with ids and public URLs; alchemy's raw +stack-output dump stays dead (the stack still returns `undefined`). +Supersedes PR #101. + +**Mechanism rationale (recorded so it isn't re-litigated):** the stack +effect runs before apply, so program code can't see resolved values after +the fact; the bin has no post-apply hook; parent-side state readback needs +a faked `Stack` service, re-takes the deploy lock, and must replicate +alchemy's `dev_${USER}` stage derivation. Actions are alchemy's designed +"run during apply with resolved inputs" primitive — verified in +`src/Action.ts` / `src/Plan.ts:1040-1100` / `src/Apply.ts:1103-1232` +(2.0.0-beta.59). Actions noop when their input hash is unchanged, hence +the nonce. + +## Chosen design + +### Core types — `packages/0-framework/1-core/core/src/deploy.ts` + +**Amended 2026-07-17 (F5, review) — `primitives` is required, not optional.** +The original pin had `primitives?`, with the loop reading +`result.primitives ?? []`. That lets a descriptor assert *"this node became +no platform primitives"* **by staying silent** — no error, no type +complaint, no test failure. + +That is the project's own thesis turned against its own code. ADR-0033 says a +claim must be **named, justified, and singular**, and that the bag's sin was +letting claims be made *anonymously, with nothing recording that a claim was +being made at all*. An omitted optional field is exactly that. The live +evidence: commit `0fe22f2`'s message records that rebuilding s3-store's +result by hand "would have silently dropped them" — the author's care +prevented a drop the **type** should have made impossible, which is the +substitution this project exists to reverse. `s3-credentials` already models +the honest form: `primitives: []`, deliberately, with a comment. + +Zero churn: all seven construction sites (five descriptors + two core test +fakes) already supply it explicitly. Making it required breaks nothing and +constrains only what comes next — D4's work and any third-party extension. + +**Amended 2026-07-17, before implementation** — S1-D2 established that +resource attributes are `Output`, not `T` (alchemy maps every attribute +through `Output`; `Resource.d.ts:95-100`). The originally pinned +`DeployedPrimitive.id: string` had the same defect S1's +`ComputeProvisioned.serviceId: string` did: a descriptor constructing a +primitive holds `svc.id`/`deployment.deployedUrl`, which are **unresolved +references**. So the primitive needs two shapes — the resolved one the +report consumer sees, and the construction-side one a descriptor returns. +This is not a workaround; it is the same lazy-Output truth the execution +model already records. + +```ts +/** + * One platform thing a node became, RESOLVED — what the report consumer + * sees. The descriptor names it; core never infers. `url` is present ONLY + * when the descriptor declares the address publicly reachable — a + * connection string is never a `url`. + */ +export interface DeployedPrimitive { + readonly kind: string; + readonly id: string; + readonly url?: string; + readonly details?: Readonly>; +} + +/** + * What a descriptor RETURNS: the same shape, but every field may still be + * an unresolved reference, because the stack effect runs before apply. + * Alchemy's `Input` mapping is deep and recursive (`Input.ts:11-29`: + * the object branch is `{ [K in keyof T]: Input }`), so this is + * exactly what the Action's input position accepts — and `Output.upstreamAny` + * walks it to give the action its upstream edges on every referenced + * resource, which is what makes apply run it last. + */ +export type ReportedPrimitive = Input; + +/** + * What a node's final lowering phase produces: wiring for dependents, + * primitives for reporting. `primitives` is REQUIRED — a node that became no + * platform primitives says so out loud with `[]`. + */ +export interface LoweredResult { + readonly wiring: WiringOutputs; + readonly primitives: readonly ReportedPrimitive[]; +} + +/** What one graph node became — the deploy subsystem's own result type. In-process only. */ +export interface DeploymentResult { + readonly address: string; + readonly node: ServiceNode | ResourceNode; + readonly primitives: readonly DeployedPrimitive[]; +} +``` + +`LowerOptions` gains: + +```ts +/** Invoked once per deploy, during apply, with every node's resolved results in topo order. Presentation belongs to the caller (the CLI wires its renderer here); core never formats. */ +readonly report?: (results: readonly DeploymentResult[]) => void; +``` + +### SPI change + +- `Lowering` (resources): returns `LoweredResult` (was `WiringOutputs`). +- `ServiceLowering.deploy`: returns `LoweredResult`. `provision`/`serialize`/ + `package` unchanged. + +### Loop change — `lowering()` + +- Per node: `const result = yield* …; lowered.set(id, result.wiring);` and + collect `entries.push({ address: id, primitives: result.primitives ?? [] })` + in topo order. +- After the loop, **only when `opts.report !== undefined`**, declare the + action (inline form, `Action('composer-deployment-report', runner)`, + imported from `alchemy`): + - **Input** (plain data + alchemy Inputs ONLY — never graph nodes: the + plan hashes the resolved input, and nodes carry functions/schemas). + Declare the action's `In` in **resolved** terms — that is what the + runner receives: + + ```ts + interface ReportEntry { + address: string; + primitives: DeployedPrimitive[]; // resolved — the runner's view + } + // In = { nonce: number; entries: ReportEntry[] } + ``` + + The call site passes `{ nonce: Date.now(), entries }` carrying + `ReportedPrimitive`s; the deep `Input<>` mapping on the input position + accepts their unresolved fields. The nonce defeats the input-hash noop + so the report runs on unchanged redeploys. + + **Declare `In`'s arrays `readonly`** (`readonly ReportEntry[]`, + `readonly DeployedPrimitive[]`), matching `LoweredResult` / + `DeploymentResult` and the codebase's `readonly`-throughout style. + + **Amended 2026-07-17 (D1 probe — the earlier pin was wrong).** The spec + previously required mutable arrays, reasoning that `Input`'s array + branch tests `T extends any[]`, which `readonly T[]` fails, so it would + fall to the object branch and map over array *members* (`length`, `map`) + rather than elements. **The premise is right; the conclusion is wrong.** + It does fall to the object branch — but that branch is + `{ [K in keyof T]: Input }`, a *homomorphic* mapped type over a + naked type parameter, and TypeScript special-cases those over arrays: it + maps over **elements**, preserves array-ness, and preserves the + `readonly` modifier. It never touches `length`/`map`/`filter`. + + Probed, and probed for *teeth* rather than mere compilation: both forms + accept a nested `Output`, both reject `id: 42`, and both reject + an excess key at the same nested position. `Input` stays + array-like and stays readonly (assigning it to `P[]` is correctly + rejected — which also rules out a collapse to `any`). + + Same shape as S1's `serviceId` defect: **a claim derived by reading + types instead of compiling them.** + - **Runner**: receives the resolved input; closes over `graph` and + `opts`; joins via the pure helper below; calls `opts.report(results)`; + returns `undefined`. + - `yield*` the action call so it lands in the stack's plan; its input + referencing every primitive gives it upstream edges on every reporting + resource, so apply runs it after them. +- The stack effect still returns `undefined` (no `setOutput`, no bin dump). +- Extract the join as an exported pure function so it is unit-testable + without alchemy: + +```ts +/** Joins resolved report entries back to their graph nodes. Skips addresses the graph no longer holds (defensive: entries are data, the graph is truth). */ +export function joinDeployment( + graph: Graph, + entries: readonly { address: string; primitives: readonly DeployedPrimitive[] }[], +): readonly DeploymentResult[] +``` + +- The gen-block's closing type assertion updates for the yield of the + action effect (whose requirements include alchemy's `Stack` context) — + keep the existing single-assertion idiom. +- Tests that run `lowering()` without `opts.report` never construct the + action and stay sync-runnable — this conditionality is REQUIRED, not an + optimization. + +### Descriptor primitives — pinned per descriptor + +| Descriptor | `primitives` | +| --- | --- | +| compute `deploy` | `[{ kind: 'compute-service', id: provisioned.serviceId, url: deployment.deployedUrl }]` | +| s3-store `deploy` | base compute's primitives, unchanged (delegation passes them through with the wiring spread) | +| postgres | `[{ kind: 'postgres-database', id: db.id }]` — **no `url`** (connection string is not public) | +| prisma-next | `[{ kind: 'postgres-database', id: db.id }]` | +| s3-credentials | `[]` — a minted keypair has nothing publishable; secret material must never appear in a primitive | + +Wiring returns wrap accordingly: e.g. compute deploy returns +`{ wiring: { url: deployment.deployedUrl, projectId: provisioned.projectId }, primitives: […] }`. + +### Renderer — `packages/0-framework/3-tooling/cli/src/render-deployment.ts` + +```ts +/** Renders a deploy's results as the app's own topology. Pure — returns the string; the caller prints. */ +export function renderDeployment(appName: string, results: readonly DeploymentResult[]): string +``` + +Pinned format — tree by dot-address segments, box-drawing guides, one line +per primitive `kind id`, URL indented on its own line when present, nodes +without primitives listed with `(no primitives reported)`: + +``` +storefront-auth +├─ auth +│ └─ api compute-service cps_abc123 +│ https://xyz.ewr.prisma.build +├─ db postgres-database pdb_def456 +└─ web compute-service cps_ghi789 + https://uvw.ewr.prisma.build +``` + +The default report callback (same file): + +```ts +/** The report hook the generated stack file wires into LowerOptions. */ +export function deploymentReport(appName: string): (results: readonly DeploymentResult[]) => void +``` + +— prints a leading blank line then `renderDeployment` output via +`console.log`. + +### Wiring it through — generated stack + public package + +- `packages/9-public/composer/package.json` gains an export path + `"./report"` mapping to a new build entry that re-exports + `deploymentReport` (and `renderDeployment`) from `@internal/cli`'s + module. Follow the existing per-entry build convention in that package + (mirror how `./deploy` is produced). +- `generate-stack.ts`'s template adds + `import { deploymentReport } from '@prisma/composer/report';` and, inside + the options literal, `report: deploymentReport(),` (the same + `name` already rendered into options). Snapshot tests in + `generate-stack.test.ts` update. +- `run-alchemy.ts` and `main.ts` are untouched — rendering happens in the + child, inside apply. + +### PR #101 + +Close with a comment linking this slice's PR and one sentence: superseded +by the consumer-declared-seams design (ADR-0033); `NodeReport` is +withdrawn. + +## A bound S2's review established — the line rendering must not cross + +**S2's guard enforces that a producer *declared* a key. It does NOT enforce +that the key resolves to anything real.** Presence-only against lazy proxies +catches a missing key but not a mis-named attribute read: `{ url: svc.typoAttr }` +returns a `PropExpr` — the resource proxy's `get` trap fabricates one for any +absent property (`Resource.ts:283`) — so it is non-`undefined`, passes the +guard, and fails at apply. That is inherent to the seam and consistent with +the guard's own comment; it is not a defect. + +**The consequence for S3: rendering must never present a wiring value as +verified.** S2's enforcement is not evidence a wiring value is trustworthy. +This is another reason the reported primitives come from the descriptor +naming them deliberately (`ReportedPrimitive`), resolved by apply, rather +than from anything scraped out of `WiringOutputs`. + +## ADR-0033's alchemy appendix gets S3's verified facts + +ADR-0033's appendix exists so S2/S3 build on recorded facts rather than +re-deriving them. S3 establishes four more, all probe-verified in D1 — +**append them** (don't restate the seam design; the ADR's decision is +unchanged): + +1. An `Action` whose input references a not-yet-created resource **plans** + and **runs during apply**, after the resources it references. +2. Its runner receives **resolved** values, arbitrarily deep — + `entries[].primitives[].id` arrives as a real `string`. +3. Alchemy persists the **resolved** input snapshot and hashes *that* + (`State/ActionState.ts`), noop-ing the action when the hash is unchanged. + A nonce evaluated at stack-effect time therefore defeats the noop. +4. `Input` maps `readonly T[]` correctly — the object branch is + homomorphic over a naked type parameter, so elements are mapped and both + array-ness and `readonly` survive. + +Cite against installed source at write time, not against these notes — +S1-D3 found most of `design-notes.md`'s line numbers had drifted. + +## Docs debt — S3 owns its own + +`.agents/rules/user-facing-surface-changes.mdc` is `alwaysApply: true`. +S3 changes what a user observes on every deploy (a rendered topology +replacing alchemy's dump) and adds a public export path +(`@prisma/composer/report`). **Both `docs/guides/**` and +`skills/prisma-composer/SKILL.md` must be updated in this PR** — a named +condition here because S2's review caught the project silently carrying this +debt with no slice owning it. + +## Specification warning inherited from S1 — read before trusting any type here + +**A cast in the code this spec is written against is evidence that someone +made a claim, not evidence that the claim was true.** S1 pinned three types +by reading existing casts as type facts and was wrong all three times (see +[../../learnings.md](../../learnings.md)). `DeployedPrimitive`'s split into +resolved + `ReportedPrimitive` already applies the lesson once. + +So: **derive every type here from the producing expression, with a probe, +before writing code against it.** Specifically distrust: `deployment.deployedUrl` +and `db.id` (both `Output`, not `string`); anything a declared prop +type appears to promise, since `Input` accepts `T | Output` and will +swallow both truth and lie. If a pinned type here is contradicted, **halt** — +same as S1-D2 did. That halt paid for itself twice. + +## Coherence rationale + +One reviewer can hold it: one SPI return-type change, five mechanical +descriptor edits, one new pure renderer, one action declaration, one +template line. The alchemy-facing novelty (the Action) is isolated to ~15 +lines in `lowering()` with its mechanism documented in the ADR appendix. + +## Scope + +**In:** everything above. +**Deliberately out:** per-node `ok`/diagnostics (fail-fast decision +pending — the `DeploymentResult` shape deliberately leaves room, adding a +field later is non-breaking); created/updated/noop change status +(CLI-event-only in alchemy — recorded non-goal); any `--json`/parent-process +consumer (future transport, own design); destroy-path reporting (the bin +zeroes actions on destroy — nothing renders, correct). + +## Pre-investigated edge cases + +| Case | Ruling | +| --- | --- | +| Action noops on unchanged input | Verified `Plan.ts:1069-1082` — hash-compared against prior state. The `Date.now()` nonce forces `run` every deploy. (`Date.now()` is fine here — it's a report trigger, not artifact input; determinism rules govern artifacts.) | +| Graph nodes in action input | FORBIDDEN — plan-time `hashInput` serializes the resolved input; nodes carry functions/Standard Schemas. Addresses + primitives only; the join uses the closure. | +| Output ordering vs alchemy's TUI | The action runs inside the apply session, so the summary may print before alchemy's final status flush. Verify visually in D4; if interleaving is ugly, accepted for this slice and recorded as an upstream ask — do NOT reach for stdout piping. | +| Plan-time `resolveInput` on first deploy | Action inputs referencing not-yet-created resources must still plan (alchemy's own feature contract for actions). D1 includes a probe verifying an action with resource-referencing input plans+runs on a fresh stack; if it fails, STOP → discussion mode (spec amendment), do not improvise a fallback. | +| `Input` vs `readonly` arrays | **Settled by D1's probe: `readonly` works — use it.** `readonly T[]` does fall to `Input`'s object branch, but that branch is homomorphic over a naked type parameter, which TypeScript special-cases over arrays: elements are mapped, array-ness and `readonly` survive. Verified to still reject `id: 42` and excess nested keys. The earlier mutable-array pin was wrong. | +| Deep `Input<>` resolution at runtime | **Settled by D1's probe.** A nested `Output` at `entries[].primitives[].id` — two levels deep — arrives in the runner as a real `string`. This is the claim the whole `ReportedPrimitive`/`DeployedPrimitive` split rests on, and it holds at runtime, not merely at the type level. | +| Nonce vs the action's input-hash noop | **Settled by D1's probe, with a control.** Fresh deploy → ran. Unchanged redeploy + new nonce → ran. Unchanged redeploy + **same** nonce → **did not run**. The third case is what proves the noop is real and the nonce is precisely what defeats it. Alchemy persists the **resolved** input snapshot and hashes that (`State/ActionState.ts`), which is why a `Date.now()` evaluated at stack-effect time is sufficient. | +| `lowering()` unit tests | Sync tests run without `report` and never touch alchemy context; the report path is covered by `joinDeployment` (pure), renderer (pure), generate-stack snapshots, and D4's live deploy. | + +## Slice-DoD + +- A real deploy of `examples/storefront-auth` (or the dogfood app) prints + the pinned tree with real ids/URLs after apply, on BOTH a changed and an + unchanged (all-noop) redeploy. +- No alchemy stack-output blob in the deploy output. +- PR #101 closed with the supersession comment. + +## References + +- Project spec: [../../spec.md](../../spec.md) · design notes § "The + corrected execution model". +- alchemy `2.0.0-beta.59`: `src/Action.ts:85-135`, `src/Plan.ts:1040-1100`, + `src/Apply.ts:1103-1232`, `src/Cli/commands/deploy.ts:171-175`. +- `packages/0-framework/3-tooling/cli/src/generate-stack.ts:50-70`. diff --git a/.drive/projects/spi-inversion-and-deploy-results/slices/s4-glossary-alignment/spec.md b/.drive/projects/spi-inversion-and-deploy-results/slices/s4-glossary-alignment/spec.md new file mode 100644 index 00000000..d0f5a14d --- /dev/null +++ b/.drive/projects/spi-inversion-and-deploy-results/slices/s4-glossary-alignment/spec.md @@ -0,0 +1,111 @@ +# S4 — Align the new names with the repo's ubiquitous language + +## At a glance + +Post-review rework on PR #117, operator-directed (2026-07-17). The branch +coined names for concepts the repo's glossary already covers. This slice +renames them to the established vocabulary and records the one genuinely +new noun ("Deployment entity") in the docs. **Pure rename + wording — zero +behaviour change.** Every gate that is green before this slice must be +green after, with identical counts. + +Grounding (operator-ratified): + +- The glossary (`docs/design/03-domain-model/glossary.md`) names what a + node provides to its dependents **Outputs** (:136, :365-370). "Wiring" was + a coinage. +- The check S2 added enforces the **connection** contract (glossary + :121-127; the domain doc is literally `connection-contracts.md`). + "Wiring contract" was a second name for it. +- The planes are authoring / provisioning / hosting (`layering.md:16-29`). + Naked "primitive" carries no meaning without a plane qualifier + (ADR-0014 "authoring primitive" vs layering.md "hosting primitives"), and + "Deployed" is not a plane — so `DeployedPrimitive` said nothing. The + operator's chosen noun for a thing on the deployment target: + **Deployment entity**. +- `DeploymentResult` is legitimate **only** as the result of the Deploy + operation. Today it names a per-node record; the actual operation result + is an unnamed array. Fixed by promoting it. + +## The rename set (complete — nothing else changes name) + +| Current | New | Notes | +| --- | --- | --- | +| `WiringOutputs` | `Outputs` | Verified collision-free in core + public. Doc comment: "The values a node provides to its dependents — what a consumer's declared connection params resolve against. Name-keyed and unknown-valued of necessity…" (keep the existing rationale text, minus the word "wiring"). | +| `LoweredResult.wiring` | `LoweredResult.outputs` | Type `Outputs`. | +| `LoweredResult.primitives` | `LoweredResult.entities` | Type `readonly Input[]`. | +| `DeployedPrimitive` | `DeployedEntity` | Same shape: `kind`, `id`, `url?`, `details?`. `kind` string values (`'compute-service'`, `'postgres-database'`) are hosting-plane nouns and DO NOT change. | +| `ReportedPrimitive` | **deleted** | Two use sites become `Input` written literally — Alchemy's own idiom for "this shape, fields possibly unresolved". No replacement alias. | +| `DeploymentResult` (per-node) | `DeployedNode` | `{ address, node, entities }` (field `primitives` → `entities`). | +| — (new) | `DeploymentResult` | **The result of the Deploy operation**: `{ readonly app: string; readonly nodes: readonly DeployedNode[] }`. Core's Action runner constructs it with `app = opts.name`. | +| `LowerOptions.report` | signature change | `(result: DeploymentResult) => void`. | +| `joinDeployment(graph, entries)` | returns `readonly DeployedNode[]` | Same join semantics (skip unknown addresses). The runner wraps it into `DeploymentResult`. Keep it pure and exported. | +| `renderDeployment(appName, results)` | `renderDeployment(result: DeploymentResult)` | App name now rides in the result. | +| `deploymentReport(appName)` factory | `deploymentReport(result: DeploymentResult): void` | No longer a factory — it IS the callback. Generated stack template passes `report: deploymentReport` (no call, no name argument). `generate-stack.ts` template + snapshot tests update; the name option the template previously threaded to the factory is no longer emitted there. | +| Action input `entries[].primitives` | `entries[].entities` | Nonce mechanics unchanged. Action name `composer-deployment-report` unchanged. | + +## Wording sweeps (exact) + +**S2 error text** (`deploy.ts`, `buildConfig`) — new pinned message: + +``` +Connection input "auth.main" declares param "url", but its producer "data" +did not supply it — the producer's outputs carry [nothing]. Add "url" to +the outputs the producer returns from its lowering, or declare the param +optional on the connection. +``` + +(Substitute the real interpolations; "[nothing]" fallback behaviour +unchanged.) The S2 tests asserting message fragments update to match. + +**Renderer empty case**: `(no primitives reported)` → `(no entities reported)`. +Renderer tests update; tree format otherwise byte-identical. + +**Guides + skill** (`docs/guides/deploying.md`, `skills/prisma-composer/SKILL.md`): +"wiring contract" → "connection contract"; "wiring outputs" → "outputs"; +the deploying.md section currently titled around a "wiring gap" retitles to +name the actual event, e.g. "When a deploy stops on a missing connection +value"; SKILL.md heading "The wiring contract is checked at deploy" → +"The connection contract is checked at deploy". Quoted error text in both +updates to the new message. "primitive(s)" referring to our report types → +"entities". Guide and skill must not disagree; skill rules +(`skills/README.md`) still bind — re-verify the quoted error text is a true +prefix of the live template after the reword. + +**ADR-0033** (`ADR-0033-lowering-types-are-defined-by-their-readers.md`): +sweep coined-noun "wiring" → Outputs/connection vocabulary; our-type +"primitive(s)" → "entities"/type names. **Keep** "hosting primitives" where +it cites layering.md's own vocabulary. Substance, taxonomy, citations +untouched. + +**Docs record the new noun** — one addition, pinned: in +`docs/design/03-domain-model/layering.md`, hosting-plane bullet (:26-29), +append: *"The framework's deploy report calls a thing on this plane a +**Deployment entity** (`DeployedEntity`): its kind, platform id, and — only +when the target says it is publicly reachable — its URL."* Mirror one line +in the glossary's Planes section if it reads naturally; skip if forced. + +**Code comments/tests** added by this branch that say "wiring" or use +"primitive" for our types: sweep on branch-added lines only. Pre-existing +files' vocabulary (e.g. `core-model.md`'s broader text, `testing.md`) stays +— except `core-model.md` lines this branch already edited, which follow the +rename (and note: the half-migrated `deploy` signature there — reads +`WiringOutputs`, code returns `LoweredResult` — gets fixed to the RENAMED +truth in the same pass; that closes the review's B5 defect). + +## Out of scope + +Behaviour of any kind. The review's F01 (report runner try/catch) — separate +decision, not bundled into a rename. The `.drive/` transient docs and the +`reviews/pr-117/` artifacts (snapshots of a round; they keep the old names). +The PR body (orchestrator owns it). + +## Completed when + +- `git grep -in "wiring" -- packages/ docs/guides/ skills/` shows no + coined-noun uses on branch-added lines (plain verb "wire up" in + pre-existing text is fine). +- `git grep -n "Primitive" -- packages/` returns nothing. +- Full suite green with **identical counts** (typecheck 58, test 48, lint 0, + lint:deps clean, lint:casts ≤ 30) — a rename that changes a count changed + behaviour. `pnpm lint` last, exit code read. diff --git a/.drive/projects/spi-inversion-and-deploy-results/spec.md b/.drive/projects/spi-inversion-and-deploy-results/spec.md new file mode 100644 index 00000000..71ead33a --- /dev/null +++ b/.drive/projects/spi-inversion-and-deploy-results/spec.md @@ -0,0 +1,159 @@ +# Purpose + +Make the lowering SPI's seams point the right way — each consumer declares +the interface it relies on, producers implement it, and only the lowering +loop knows the routing — so that deployment results can be captured and +rendered as a first-class product of a deploy instead of being bolted onto +the wiring contract. The `NodeReport` attempt (composer PR #101) failed +because one shared producer-side bag (`LoweredNode`) serves three unrelated +contracts; this project replaces the bag with consumer-declared interfaces +and builds reporting on the clean seams. + +# At a glance + +Today `LoweredNode` (`{ outputs: Record }`) plays three +roles at once: + +1. **Intra-descriptor phase handoffs** — `provision` → `serialize`/`deploy`. + Private to one descriptor; core never reads them; the descriptor casts to + recover types it produced itself two phases earlier. +2. **Inter-node wiring** — `deploy`'s return, stored in the `lowered` map, + read by `buildConfig` by param name for downstream nodes. The only role + core genuinely consumes. +3. **Reporting** (the reverted `NodeReport`) — presentation data smuggled + onto the wiring type because the shared bag accepts anything. + +The project separates them: + +- **Phase handoffs** become descriptor-owned types carried generically by + the SPI — typed to the one party that writes and reads them, opaque to core. +- **Wiring** becomes its own named, name-keyed record type. The consumer-side + declaration already exists (a node input's connection params); enforcement + makes a producer that under-delivers a loud `LowerError` naming the edge + (operator-confirmed 2026-07-17). +- **Reporting** becomes `DeploymentResult`: per node, holding the graph node + itself plus typed platform primitives (kind, platform id, url only when + the descriptor declares it public). Assembled by the lowering loop at the + moment it holds full context; the whole-run value is a plain collection, + no aggregate noun. +- **Rendering** runs in the deploy child process, which holds both the Graph + and the results — no cross-process transport. The vehicle is an alchemy + **Action** declared at the end of the stack effect: actions run *during + apply* with resolved inputs, so the action's runner receives the resolved + primitives, joins them to the graph it holds by closure, and calls the + renderer the CLI wired in through the generated stack file. Core stays + presentation-free. + +Grounding facts that shape everything (verified against alchemy +2.0.0-beta.59 source): the stack effect runs entirely **before** apply; +resource yields return lazy Output proxies; resolved values reach program +code only through apply-time evaluation (the stack's return value, or an +Action's input). The stack effect returns `undefined` from S1 on, which +kills both alchemy's raw stack-output dump and the `setOutput` state write. +Parent-process readback via the state store was rejected on verified +grounds: it needs a faked `Stack` service, re-takes the deploy lock, and +must replicate alchemy's `dev_${USER}` stage derivation. + +# Non-goals + +- **Per-node `ok`/failure diagnostics.** `lower()` is `orDie`: one failure + kills the run, so `ok` would never vary. `DeploymentResult` may carry a + diagnostics slot, but populating it waits on a deliberate + collect-and-continue decision (deploy semantics, partly alchemy's) that + this project does not make. +- **Per-resource change status (created/updated/noop).** Verified not + exposed to the program — it flows only to alchemy's CLI event session. + Capturing it means wrapping alchemy's Cli service or an upstream change; + deferred. +- **Any cross-process transport for results.** The renderer runs where the + results are. A `--json`/CI consumer in the CLI parent process, if ever + wanted, is its own future design with its own serialized projection. +- **Collect-and-continue lowering.** Fail-fast semantics stay as they are. + +# Place in the larger world + +- Supersedes composer **PR #101** (`NodeReport` on `LoweredNode`) — that + approach is withdrawn; the PR is reworked or closed in favor of this + project's slices. +- Constrained by **ADR-0005** (users build, the framework assembles — + deterministic, no guessing) and the layering rules in + `architecture.config.json` (`framework.mayImportFrom: []`; the CLI must + not import prisma-cloud). The renderer-in-child design respects both. +- The wiring seam builds on the connection-params model (ADR-0031 for + provisioned params) — the consumer-side declaration this project starts + enforcing. +- Alchemy behavior grounded in `alchemy@2.0.0-beta.59` + (`Deploy.ts`/`Resource.ts`/`Apply.ts`): evalStack → Plan → Apply; Output + proxies; stack output evaluated and persisted via `setOutput`. + +# Cross-cutting requirements + +- **Dependency inversion at every seam.** Interfaces live with their + consumers: phase-handoff types with the descriptor, the wiring contract + with the connection declaration (core's graph model), the primitive/result + types with the deploy-result subsystem in core, formatting interfaces with + the CLI. The lowering loop is the only party that knows which producer + output feeds which consumer. +- **No shared mutable bag survives.** `LoweredNode` is retired; nothing + reintroduces a producer-side record that multiple subsystems read. A new + consumer requires a new declared interface and a visible routing edit in + the lowering loop. +- **The descriptor names what is publishable.** Core never infers meaning + from output keys; `url` on a primitive means publicly reachable because + the descriptor said so (the allowlist lesson from #101). +- **Whatever crosses the stack boundary is plain data** (address-keyed + primitives) because alchemy unconditionally persists the stack output. + Node-bearing types stay in-process, on our side of the boundary. +- **No new casts.** The refactor must reduce, not relocate, the + `blindCast`/`as` surface in descriptors (repo cast-ratchet rules apply). + +# Transitional-shape constraints + +- The DI refactor (S1) lands before the reporting slices; reporting builds + on the separated seams, never on `LoweredNode`. +- Between S1 and the reporting slice, deploys behave exactly as today + (hardcoded empty stack output); no intermediate state may change deploy + behavior except as its slice specifies. + +# Project-DoD + +- [ ] `LoweredNode` no longer exists; each of its three roles has its own + consumer-declared type, and every descriptor compiles against the new + SPI without casting to recover its own phase values. +- [ ] An ADR records the seam design (consumer-declared interfaces, results + assembled at full context, no transport before a cross-process + consumer exists) in `docs/design/90-decisions/`. +- [ ] A real deploy of an example app prints a rendered deployment summary: + the app's own topology with authored names, platform ids, and public + URLs — produced from `DeploymentResult`s, not parsed from alchemy + output — and alchemy's raw stack-output dump is gone or trivially + empty. +- [ ] Wiring-contract enforcement (if confirmed): a producer that fails to + supply a consumer's declared connection param fails the deploy with a + `LowerError` naming the edge and param, covered by a test. +- [ ] PR #101 is superseded: reworked to this design or closed with a + pointer. + +# Open questions + +- **Action plan-time input resolution on a fresh stack** — alchemy's own + feature contract says an action's resource-referencing input plans before + those resources exist; S3's D1 probe verifies it before anything builds + on it (STOP → discussion mode if it fails). + +Settled during shaping (see slice specs): wiring enforcement confirmed by +operator (S2); `ctx.application` becomes `unknown` narrowed by an +extension-owned type guard, and provisioner refs stay opaque `unknown` +(S1); the stack-output readback mechanism was rejected in favor of the +Action (S3). + +# References + +- Design notes: [design-notes.md](design-notes.md) — the argument, the + corrected alchemy execution model, alternatives rejected. +- `packages/0-framework/1-core/core/src/deploy.ts` — the SPI and lowering + loop this project rebuilds. +- `packages/1-prisma-cloud/1-extensions/target/src/descriptors/` — the + descriptors that migrate. +- Composer PR #101 — the superseded `NodeReport` attempt. +- Tracker: [Prisma Composer: SPI inversion & deployment results](https://linear.app/prisma-company/project/prisma-composer-spi-inversion-and-deployment-results-f87bb6d9de12) diff --git a/architecture.config.json b/architecture.config.json index 5f07ee40..7f2adb8a 100644 --- a/architecture.config.json +++ b/architecture.config.json @@ -276,6 +276,12 @@ "layer": "public", "plane": "control" }, + { + "glob": "packages/9-public/composer/src/report.ts", + "domain": "public", + "layer": "public", + "plane": "control" + }, { "glob": "packages/9-public/composer/src/config.ts", "domain": "public", diff --git a/docs/design/03-domain-model/glossary.md b/docs/design/03-domain-model/glossary.md index 6a0700fe..f4b8f966 100644 --- a/docs/design/03-domain-model/glossary.md +++ b/docs/design/03-domain-model/glossary.md @@ -198,6 +198,8 @@ aggregate via the marker/ledger. The three layers the framework spans: what you write (the framework), how it's wired and provisioned (Alchemy/Effect), what runs (Prisma Cloud). See `layering.md`. +The deploy report calls a thing on the hosting plane a **Deployment entity** +(`DeployedEntity`). ### Lowering diff --git a/docs/design/03-domain-model/layering.md b/docs/design/03-domain-model/layering.md index de2514f9..a675cd5a 100644 --- a/docs/design/03-domain-model/layering.md +++ b/docs/design/03-domain-model/layering.md @@ -26,7 +26,9 @@ resource graph, which deploys to the cloud. - **Hosting plane (Prisma Cloud)** — what actually runs. Nouns: ComputeService / ComputeVersion, 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. + its own hosting primitives. The framework's deploy report calls a thing on + this plane a **Deployment entity** (`DeployedEntity`): its kind, platform id, + and — only when the target says it is publicly reachable — its URL. ## The mapping diff --git a/docs/design/10-domains/core-model.md b/docs/design/10-domains/core-model.md index f563d404..70e24e37 100644 --- a/docs/design/10-domains/core-model.md +++ b/docs/design/10-domains/core-model.md @@ -451,18 +451,24 @@ interface Target { // The application's shared infrastructure: on Prisma Cloud, the one Project // (the config namespace and lifecycle boundary) plus the poison DATABASE_URL -// variables. Its outputs (projectId) reach every later SPI call via -// LowerContext.application. +// variables. 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 ApplicationLowering { - provision(ctx: LowerContext): Effect.Effect + provision(ctx: LowerContext): Effect.Effect } -// The phased service SPI — the seam between the phases belongs to CORE. -interface ServiceLowering { +// The phased service SPI. P and S are the DESCRIPTOR's own handoff types — +// provision's product consumed by serialize/deploy, and serialize's product +// consumed by deploy. Core threads them through without inspection; only the +// descriptor that writes them reads them (ADR-0033). Method syntax is required: +// the heterogeneous registry assigns through TypeScript's method bivariance. +interface ServiceLowering

{ // provision: make the target-specific thing that will host the service — // identity-bearing infrastructure only (the App), inside the application's - // Project (ctx.application); no code runs. - provision(ctx: LowerContext): Effect.Effect + // Project (ctx.application); no code runs. P may hold unresolved Output + // references — the whole stack effect runs before Alchemy applies anything. + provision(ctx: LowerContext): Effect.Effect // serialize: encode the typed Config core built into the service's runtime // environment (on Prisma Cloud: EnvironmentVariables on the project), keyed by // the deployment address. The pack owns the encoding; run()'s deserialize @@ -470,8 +476,8 @@ interface ServiceLowering { // Leaf values are provisioning refs → the env writes depend on the // resources/producer (the ordering edges). Returns the env-var records so // `deploy` can reference them (the environment edge — see alchemy-lowering.md). - serialize(ctx: LowerContext, provisioned: LoweredNode, config: Config): - Effect.Effect + serialize(ctx: LowerContext, provisioned: P, config: Config): + Effect.Effect // package: assemble the deployable artifact from the build adapter's normalized // bundle dir and print the bootstrap (address + the boot import baked in). The // envelope is target vocabulary and the pack's business (Compute: bootstrap.js + @@ -482,9 +488,18 @@ interface ServiceLowering { 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 trustworthy URL. - deploy(ctx: LowerContext, provisioned: LoweredNode, artifact: Artifact, - serialized: LoweredNode): Effect.Effect + // via the Deployment's environment prop (the 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, + serialized: S): Effect.Effect +} + +// What a node's final lowering phase produces: outputs for dependents, +// entities for the deploy report (ADR-0033). +interface LoweredResult { + readonly outputs: Outputs + readonly entities: readonly Input[] } // package input: the build adapter's assembled output plus the address. The @@ -504,7 +519,7 @@ interface PackageInput { // One node's realization. Runs inside the Alchemy stack effect; yields the // pack's Alchemy resources. Core never looks inside. -type Lowering = (ctx: LowerContext) => Effect.Effect +type Lowering = (ctx: LowerContext) => Effect.Effect interface LowerContext { readonly id: NodeId @@ -513,13 +528,19 @@ interface LowerContext { readonly node: ServiceNode | ResourceNode readonly graph: Graph readonly opts: LowerOptions - readonly application: LoweredNode // the application provision's outputs - readonly lowered: ReadonlyMap // already-lowered deps (topo order) + readonly application: unknown // the owning extension's application product; + // undefined when it declares no hook. Core never + // reads it; the extension narrows it (ADR-0033) + readonly lowered: ReadonlyMap // already-lowered deps (topo order) } -// What a lowering hands downstream — e.g. a deployed URL a later node's env -// wiring consumes. The inter-node config-wiring hook for Connections. -interface LoweredNode { readonly outputs: Readonly> } +// The values a node provides to its dependents — e.g. a deployed URL a later +// node's env consumes; what a consumer's declared connection params +// resolve against. Name-keyed and unknown-valued of necessity: core cannot +// know extension types, and which producer feeds which consumer is decided by +// the user's graph at runtime. The connection declaration is the contract +// (ADR-0033). +type Outputs = Readonly> interface LowerOptions { readonly name: string // stack name (+ Load's root id override) @@ -545,13 +566,15 @@ class LowerError extends Error {} // Composable form — for MIXED topologies: framework-authored nodes beside // hand-wired Alchemy resources in one stack. Runs the same Load → route walk -// inside the caller's stack effect and returns the root's LoweredNode, whose -// outputs (e.g. the deployed URL) hand-wired resources may consume. +// inside the caller's stack effect. Resolves to `undefined`: the root module +// has no outputs of its own (boundary ports are future work), and returning +// nothing keeps Alchemy from printing and persisting a stack-output dump — +// Apply.apply short-circuits on a falsy plan output (ADR-0033). // Error channel: LowerError from routing, PLUS whatever a pack lowering fails // with (their error type is open) — a mixed-stack caller treats failures as // deploy-fatal or inspects; it must not assume LowerError is the only inhabitant. function lowering(root: ModuleNode, target: Target, opts: LowerOptions): - Effect.Effect + Effect.Effect ``` `lower()` is nothing but the whole-stack wrapper: diff --git a/docs/design/90-decisions/ADR-0033-lowering-types-are-defined-by-their-readers.md b/docs/design/90-decisions/ADR-0033-lowering-types-are-defined-by-their-readers.md new file mode 100644 index 00000000..45ca1259 --- /dev/null +++ b/docs/design/90-decisions/ADR-0033-lowering-types-are-defined-by-their-readers.md @@ -0,0 +1,304 @@ +# ADR-0033: Every value in the lowering pipeline is typed by the code that reads it + +## Decision + +The lowering SPI has several places where one piece of code hands a value to +another. **At each of them, the type is defined by the code that reads the +value, not by the code that produces it.** No single shared record is used in +more than one of those places. + +Concretely, replacing `LoweredNode` (`{ outputs: Record }`): + +```ts +// 1 — a descriptor hands values between its own phases. Typed by the descriptor +// that writes AND reads them; core passes P and S through without inspecting either. +export interface ServiceLowering

{ + provision(ctx: LowerContext): Effect.Effect; + serialize(ctx: LowerContext, provisioned: P, config: Config): Effect.Effect; + package(ctx: LowerContext, input: PackageInput): Effect.Effect; + deploy(ctx: LowerContext, provisioned: P, artifact: Artifact, serialized: S): + Effect.Effect; +} + +// 2 — the extension's application hook hands its product to that extension's +// own descriptors. Core types nothing here; the extension defines its own +// product type and narrows to it with its own guard. +readonly application: unknown; + +// 3 — one node hands values to the nodes wired downstream of it. Name-keyed and +// unknown-valued: the consumer's connection declaration is the contract, +// resolved by param name at runtime. +export type Outputs = Readonly>; +``` + +The **lowering loop is the only router**. It alone knows that `deploy`'s return +feeds `buildConfig` for dependent nodes. Descriptors do not know `buildConfig` +exists. A future consumer of a descriptor's output must declare its own +interface and appear as a visible routing edit in the loop. + +Two of these are decided here and implemented in later slices: the connection +contract becomes **enforced** — a producer that fails to supply a param the +consumer's connection declares fails the deploy naming the edge (S2) — and +**deployment results** become core-declared types the loop assembles at full +context (S3). Neither is implemented by this ADR. + +## Reasoning + +### What went wrong + +`LoweredNode` was one type serving three unrelated contracts: + +1. **Intra-descriptor phase handoffs** — `provision` → `serialize`/`deploy`. + Same party writes and reads; core never looks inside. Forcing them through + the shared record made descriptors cast to recover types they had produced + themselves two phases earlier. +2. **A node's outputs for its dependents** — `deploy`'s return, stored in the + `lowered` map, read by `buildConfig` by param name. The only role core + genuinely consumes. +3. **Reporting** — the reverted `NodeReport` (PR #101), possible only because a + shared untyped record accepts a new field without any consumer's signature + changing. + +A shared bag is a *producer-side* type: it describes what someone hands over, +not what anyone needs. That is what let one type serve three consumers — and +what let reporting data be added to the type dependents' connections resolve +against, without any reviewer seeing an interface change. + +### The evidence: a type lie the bag hid indefinitely + +Migrating the first descriptor off `LoweredNode` immediately surfaced a bug +that had been live and invisible. + +Compute's `provision` yields an Alchemy `ComputeService` and stores `svc.id`. +`ComputeServiceAttributes` declares `id: string`, so `serviceId` looked like a +`string` — and `deploy` read it back as one: + +```ts +computeServiceId: provisioned.outputs['serviceId'] as string, // the lie +``` + +`svc.id` is **not** a `string`. Alchemy's `Resource` type maps every attribute +through `Output` (`Resource.d.ts:95-100`), so `svc.id` is an `Output` — +an unresolved, lazy reference (see the appendix). The cast laundered that +reference into a `string` at the read site, and it compiled for one reason: +the consuming prop is `Input`, which accepts **both** a `string` and an +`Output`. Nothing in the pipeline ever objected. + +Typing the handoff honestly deletes the cast rather than relocating it: + +```ts +export interface ComputeProvisioned { + readonly serviceId: Output.Output; // what it actually is + readonly projectId: string; // CLI env, genuinely a string +} +// deploy, now: +computeServiceId: provisioned.serviceId, // no cast; Input accepts it +``` + +### The thesis: deliberate and audited, not absence of claims + +The tempting conclusion — "a shared bag lets you lie; a typed handoff doesn't" +— is refuted by our own code. `compute.serialize` keeps a `blindCast` that +claims `Output` for a provisioner ref — a value that arrives through a +different `unknown`-typed channel, which +[ADR-0031](ADR-0031-provisioned-param-values-are-a-need-resolved-through-a-target-registry.md) +keeps deliberately opaque. It is the same kind of unchecked claim +that `as string` was. **We are keeping it.** + +The distinction that matters is not whether an unchecked claim exists. It is +whether the claim is **named, justified, and singular**: + +- The provisioner-ref cast is **one site**, carrying a written justification of + why no guard is possible. A reviewer can evaluate that argument. A grep finds + every instance. It is a decision on the record. +- The bag made the same kind of claim **anonymously, at every read site, with + nothing recording that a claim was being made at all.** `outputs['serviceId']` + reads as ordinary field access. Nothing marks it as the moment someone decided + an `unknown` was a `string`. No reviewer was ever asked. + +That is why `serviceId` survived code review and `keyOuts` is fine. The goal is +not zero unchecked claims — it is that every one is deliberate and auditable. + +### Three handovers, three mechanisms + +The three places differ in what the type claims, and in what stops the claim +from being wrong: + +| Where a value is handed over | The type claims | What checks it | +| --- | --- | --- | +| **Between a descriptor's phases** (`P`, `S`) | Precise types | The **compiler** — the same descriptor writes and reads them, so it can check both ends | +| **Application hook → its extension's descriptors** (`ctx.application`) | Precise: `CloudApplication.projectId: string` | A **runtime guard** (`isCloudApplication`) — the value passes through core as `unknown`, so the compiler cannot follow it | +| **A node → the nodes wired downstream of it** (`Outputs`) | **Nothing** — the values are `unknown` | Nothing needed. `unknown` cannot lie | + +The third row is where the argument actually lands. `warm.url` (postgres) and +`creds.accessKeyId` (s3-credentials) are *also* unresolved `Output`s — +exactly like `serviceId`. They were never mis-typed, and could not have been: +they flow into `Outputs`, which claims nothing about them. Core cannot know +extension types, and which producer feeds which consumer is decided by the +user's graph at runtime, so `Outputs` says it does not know, and the value is +resolved by name against the consumer's declared params instead. + +**The bug could only ever have lived where a precise claim was made with no +mechanism checking it.** The application hook's product is a precise claim too +— checked by a guard rather than by the compiler, which is why there are three +mechanisms and not two. + +## Consequences + +### Alchemy execution facts this rests on + +Verified against `alchemy@2.0.0-beta.59`; S2 and S3 build on these rather than +re-deriving them. + +- **The whole stack effect runs before anything is applied.** + `deploy = evalStack(stackEffect) → Plan.make → Apply.apply` + (`src/Deploy.ts:25-30`). Our entire `lowering()` generator completes before + the first platform call. +- **Yielding a resource returns a lazy proxy**, whose property reads produce + `Output.PropExpr` references (`src/Resource.ts:275-283`). + `deployment.deployedUrl` inside a descriptor is symbolic, not a URL. +- **Therefore phase-handoff types legitimately carry unresolved `Output` + references.** A handoff type promising resolved values is lying — this is the + same lazy-Output truth as the execution model, surfacing in the SPI's types. + `ComputeProvisioned.serviceId: Output` is the correct type, not a + compromise. +- **Resolved values reach program code in exactly one place**: apply evaluates + whatever the stack effect returned and returns that resolved structure + (`src/Apply.ts:198`). It also unconditionally persists it via `state.setOutput` + (`src/Apply.ts:203`) — alchemy's cross-stack-reference mechanism — so whatever + crosses that boundary must be plain data. +- **The stack effect now returns `undefined`.** `Apply.apply` short-circuits on + a falsy plan output (`src/Apply.ts:191-193`): no `setOutput` write, and the + CLI skips its `Console.log`. This removes the `{ outputs: {} }` dump printed + after every deploy. Existing `alchemy_stack_output` rows go stale but harmless. +- **Per-resource change status (created/updated/noop/skipped) is not returned to + the program.** It lives in apply's tracker and is emitted as status-change + events to alchemy's CLI session service (`src/Apply.ts:184-185`, `:415-421`). + Capturing it requires wrapping that service or an upstream change. + +#### Actions — how resolved values reach program code after all + +The facts above say resolved values are unreachable from the stack effect. An +**Action** is the exception alchemy provides, and S3's deploy report is built on +it. Each of these was verified by a throwaway probe that applied a real stack +against alchemy's in-memory state, not by reading the types: + +- **An Action whose input references a not-yet-created resource plans, and runs + during apply after the resources it references.** An Action is a graph node + that runs an Effect with its resolved input during plan/apply + (`src/Action.ts:12-13`); its upstream edges are derived from the Output + references found in its input (`Output.upstreamAny(action.Input)`, + `src/Plan.ts:543`), which is what orders it after them. Probed on a fresh + stack where the referenced resource did not yet exist: it planned and ran. +- **The runner receives resolved values, arbitrarily deep.** Apply evaluates the + action's input against its tracker (`src/Apply.ts:1190`) and invokes the body + with the result (`src/Apply.ts:1205`); the runner's parameter is typed `In`, + the resolved shape, while the call site takes + `{ [k in keyof In]: Input }` (`src/Action.ts:39` and `:133`). Probed + two levels deep — `entries[].entities[].id`, handed over as an + `Output`, arrived in the runner as a real `string`. +- **Alchemy hashes the RESOLVED input and noops on an unchanged hash.** Plan + resolves the input, then hashes that (`src/Plan.ts:1043-1044`), and an Action + whose prior run has the same hash is planned as a noop unless `--force` + (`src/Plan.ts:1069-1071`); apply persists the resolved snapshot alongside the + hash (`src/Apply.ts:1199-1200`, `src/State/ActionState.ts:28-30`). **This is why a + nonce evaluated at stack-effect time works**: it changes the resolved input, + so the hash differs and the body runs on an otherwise unchanged redeploy. + Established with a control, not by observing a re-run: three deploys — fresh + (ran), unchanged with a new nonce (ran), unchanged with the *same* nonce (did + **not** run). The third is what proves the noop is real and the nonce is what + defeats it; without it, "it ran" would be consistent with actions always + running. +- **`Input` maps `readonly T[]` correctly**, so an Action's `In` may use + `readonly` arrays and match the rest of the codebase. The reasoning that says + otherwise is a trap worth recording: `readonly T[]` genuinely fails the array + branch's `T extends any[]` test (`src/Input.ts:23`) and falls through to the + object branch — but that branch is `{ [K in keyof T]: Input }` + (`src/Input.ts:28`), a *homomorphic* mapped type over a naked type parameter, + and TypeScript special-cases those over arrays: elements are mapped, and both + array-ness and the `readonly` modifier survive. It never maps over `length` or + `map`. Probed for teeth rather than compilation: the `readonly` form accepts a + nested `Output`, rejects a wrong type and an excess key at that same + nested position, and stays unassignable to a mutable array — which also rules + out a silent collapse to `any`. + +### The registry's type safety rests on the loop, not the compiler + +Descriptors with different `P`/`S` assign into one +`Record` only through TypeScript's **method +bivariance** — which is why `ServiceLowering` must use method syntax; a +property-arrow form is checked contravariantly and the assignment fails. + +This is **unsound by construction**, and deliberately accepted. Core calls +`descriptor.serialize(ctx, provisionedNode, config)` with `provisionedNode` +typed `unknown`, so the compiler would not object if the loop ever threaded the +wrong node's provisioned value into a descriptor. The loop is correct today; +nothing but the loop makes it correct. + +This is the price of a heterogeneous registry core cannot type. It is recorded +here so that anyone editing the lowering loop — S2 and S3 both do — knows what +they are holding. + +### `ComputeSerialized` crosses a module boundary — and that is a tripwire + +One producer-side shape is imported across modules: `s3-store`'s +`S3StoreSerialized extends ComputeSerialized`. This is legitimate because +`s3-store` **composes compute's own descriptor** — it delegates to compute's +hooks and extends their product. Same party, not an unrelated consumer. + +**The tripwire:** a third descriptor importing `ComputeSerialized` *without* +composing compute is the shared bag reforming. The answer then is its own +handoff type, not a widened shared one. + +For the same reason, `computeDescriptor` returns its precise descriptor type +rather than the erased `NodeDescriptor`. The registry erases `P`/`S` on +assignment anyway, but `s3-store` needs them visible; annotating +`NodeDescriptor` would force `s3-store` to cast them straight back — +putting back into s3-store the exact cast-to-recover-your-own-type problem this +ADR exists to remove. + +### General + +- Each of `LoweredNode`'s three roles now has its own consumer-declared type; + no descriptor casts to recover a value it produced itself. +- A new consumer of a descriptor's output requires a declared interface and a + visible routing edit in the loop. The dumping-ground regression that produced + `NodeReport` becomes structurally impossible. +- **The descriptor names what is publishable.** Core never infers meaning from + output keys: `url` means a public endpoint because the descriptor said so. + No core-level rule is safe — `url` on compute is an endpoint, on postgres it + would be a connection string. +- An extension whose application hook does not run yields `ctx.application === + undefined` (previously `{ outputs: {} }`). Prisma-cloud's descriptors go + through `projectIdOf`'s guard, which fails and names the hook that did not run + — correct, since + those descriptors require the hook. + +## Alternatives considered + +- **`NodeReport` on `LoweredNode`** (PR #101) — rejected: reporting data on the + type dependents' connections resolve against, untyped, and meaningless on + two of the three phases. This + ADR supersedes that approach. +- **A separate `describe()` SPI hook** — rejected: the resource handles live + inside `deploy()`'s effect; `describe()` would need them handed back out, + which is returning them with extra plumbing. +- **Full generic threading of the application type** through + `ExtensionDescriptor`/`NodeDescriptor` — rejected: the registry is + heterogeneous, and the assignment would lean on method bivariance in + contravariant positions across packages. `ServiceLowering` is fine + because producer and consumer are the same descriptor; the application type + has no such guarantee. Hence `unknown` plus an extension-owned guard. +- **Keeping `serviceId: string` and casting** — rejected: it would have forced + the deleted cast's reintroduction. The accurate type removes it. + +## References + +- `packages/0-framework/1-core/core/src/deploy.ts` — the SPI and the lowering loop. +- `packages/1-prisma-cloud/1-extensions/target/src/descriptors/` — the migrated descriptors. +- [ADR-0005](ADR-0005-users-build-the-framework-assembles.md) — users build, the framework assembles. +- [ADR-0031](ADR-0031-provisioned-param-values-are-a-need-resolved-through-a-target-registry.md) — provisioner refs are opaque by design. +- alchemy `2.0.0-beta.59`: `src/Deploy.ts`, `src/Resource.ts`, `src/Apply.ts`; `lib/Resource.d.ts`. +- Composer PR #101 — the superseded `NodeReport` attempt. + + diff --git a/docs/design/90-decisions/README.md b/docs/design/90-decisions/README.md index 10a543b2..36d25f4d 100644 --- a/docs/design/90-decisions/README.md +++ b/docs/design/90-decisions/README.md @@ -54,3 +54,4 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [ADR-0030](ADR-0030-rpc-callers-verified-with-an-auto-provisioned-service-key.md) — Every RPC binding carries a distinct, framework-minted **service key**: the client sends it, `serve()` rejects a caller without one (401). Auto-provisioned per edge at deploy, carried on the binding's own `COMPOSER_*` env-var rail; its value is minted and kept in the workspace deploy state (a capability token, deliberately not an ADR-0029 name-only secret). *(Proposed)* - [ADR-0031](ADR-0031-provisioned-param-values-are-a-need-resolved-through-a-target-registry.md) — A framework-minted param value is an opaque, branded **provisioning need** (not a named facet on the param): core forwards it and resolves its brand against the deploy target's `provisions` registry, failing loudly on a miss. The provisioner owns all mint/size/stability/rotation policy; core's surface stays one field. Resolves against the consumer's extension; cross-extension edges fail closed. *(Proposed)* - [ADR-0032](ADR-0032-params-bind-at-provision-env-sourcing-is-a-target-source.md) — A param can be bound at `provision()` — a schema-validated literal (beats `default`) or a target-owned source (`envParam('NAME')`, mirroring ADR-0029's need/source split): a pointer row on the wire, boot double-lookup, the raw string handed to the param's own schema, read through `config()` unredacted; preflight covers the names like secrets. +- [ADR-0033](ADR-0033-lowering-types-are-defined-by-their-readers.md) — Every value in the lowering pipeline is typed by the code that reads it, not the code that writes it, retiring the shared `LoweredNode` record: a descriptor types the values it passes between its own phases (`ServiceLowering`); the application hook's product reaches core as `unknown` and its own extension narrows it with a guard; a node's values for the nodes downstream are name-keyed `WiringOutputs`, resolved against the consumer's connection declaration. The lowering loop is the only party that knows which output feeds which input. Records the alchemy execution facts (a value passed between phases legitimately holds an unresolved `Output`), that the heterogeneous registry's type safety rests on the loop rather than the compiler, and that an unchecked claim is acceptable only when it is named, justified, and singular. diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index fd2d1b26..6847b560 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -63,7 +63,43 @@ place. A stage name must be a valid git ref name (`git check-ref-format`); an invalid name is a hard error, never a silent rename. After a deploy, each service is a Compute service in the Project; its public -URL is its service endpoint domain, shown in the Console. +URL is its service endpoint domain — printed when the deploy finishes, and +also shown in the Console. + +## What a deploy prints + +A deploy ends by printing your app's own topology — the names you authored, +what each one became on the platform, and the public URLs: + +``` +storefront-auth +├─ auth +│ └─ api compute-service cps_abc123 +│ https://xyz.ewr.prisma.build +├─ db postgres-database db_def456 +└─ web compute-service cps_ghi789 + https://uvw.ewr.prisma.build +``` + +The tree is your module structure: `auth.api` is the `api` service inside the +`auth` module. Under each name is the platform resource it became and its id +— the thing to search for in the Console when you need it. + +**A URL appears only where the address is genuinely public.** A Compute +service prints one because its endpoint is reachable. A database never does: +it has a connection string, not a public endpoint, and printing it in a +terminal would be the wrong thing in both directions. Nothing that reports a +URL here is a secret, and nothing secret is reported here at all — an +`s3-credentials` node, whose whole product is a key pair, prints no resource +line for that reason. + +A node that deployed but published nothing reportable is still listed, marked +`(no entities reported)`, so a node is never silently missing from the tree. + +**If you're wondering where the JSON went:** deploys used to end with a raw +`{ outputs: {} }` blob from the underlying deploy engine — always empty, never +about your app. It's gone, replaced by the tree above. Nothing you can +configure printed it and nothing depended on it. ## Destroying @@ -107,6 +143,41 @@ preflight copies missing ones up on that first deploy. A name absent from both the platform and the shell fails the deploy early, naming the missing variable. +## When a deploy stops on a missing connection value + +A dependency's connection declares the values it needs, by name. The node on +the other end of the wire has to supply them. When one doesn't, the deploy +stops and names the edge rather than standing the app up: + +``` +Connection input "auth.db" declares param "url", but its producer "db" did not +supply it — the producer's outputs carry [host]. Add "url" to the outputs the +producer returns from its lowering, or declare the param optional on the +connection. +``` + +Two fixes, and which is right depends on whether the value is genuinely +required: + +- **The producer should be supplying it** — add the name to what the producer + returns. This is the common case: the two sides drifted, usually a rename on + one end only. +- **Absent is legitimate** — declare the param `optional` on the connection. + The consumer then reads it as `undefined`, which is what it was already + receiving. + +**Why an app you didn't touch can start failing this.** It used to deploy. The +missing value reached the consumer as `undefined`, was written into its +environment, and broke at *that service's* boot — so the crash surfaced in the +service that read the value, not the one that failed to supply it, and the +stack trace pointed at the wrong end of the wire. The deploy now refuses up +front. Nothing about your app got worse; the same mistake now reports itself +where it was made, before anything is provisioned. + +You'll only meet this if you wrote the connection or the extension on one side +of the wire — every block that ships with the framework supplies what it +declares. + ## Production behavior What deployed apps actually run into, and what to do about it: diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 0057f096..9ba0983c 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -304,9 +304,21 @@ pnpm exec prisma-composer deploy module.ts The CLI creates a Project named `my-app` in your workspace, provisions both services on Prisma Compute, points the gateway's `quotes` dependency at the -deployed quotes service, and starts everything. Each service's public URL is -its Compute service endpoint, shown in the Console. Open the gateway's URL: -you get a quote, served over one typed RPC hop. +deployed quotes service, and starts everything. + +The deploy finishes by printing what it made — your own module names, the +platform resource each became, and the public URLs: + +``` +my-app +├─ quotes compute-service cps_abc123 +│ https://xyz.ewr.prisma.build +└─ gateway compute-service cps_def456 + https://uvw.ewr.prisma.build +``` + +Open the gateway's URL from that output: you get a quote, served over one +typed RPC hop. Now try the *quotes* service directly — `curl /rpc/random` — and you'll get `401`. That's deliberate. Deploying also gave the gateway a diff --git a/packages/0-framework/1-core/core/src/__tests__/lowering.test.ts b/packages/0-framework/1-core/core/src/__tests__/lowering.test.ts index 5eed61fb..bd751317 100644 --- a/packages/0-framework/1-core/core/src/__tests__/lowering.test.ts +++ b/packages/0-framework/1-core/core/src/__tests__/lowering.test.ts @@ -10,16 +10,19 @@ import { type Artifact, type Bundle, buildConfig, + joinDeployment, type LowerContext, LowerError, - type LoweredNode, + type LoweredResult, type LowerOptions, lower, lowering, mergedProviders, + type Outputs, type ProvisionEdge, type ProvisionerDescriptor, resolveStateLayer, + type ServiceLowering, } from '../deploy.ts'; import { Load } from '../graph.ts'; import { @@ -113,6 +116,16 @@ type Call = readonly environment: unknown; }; +// The fake 'fake/compute' descriptor's own intra-node handoff types — stand- +// ins for a real extension's descriptor-owned provision/serialize products. +interface FakeProvisioned { + readonly serviceId: string; + readonly projectId: string; +} +interface FakeSerialized { + readonly environment: ReadonlyArray<{ input: string; name: string; value: unknown }>; +} + function fakeExtension(opts: { provisions?: ReadonlyMap } = {}) { const calls: Call[] = []; const descriptor: ExtensionDescriptor = { @@ -124,14 +137,17 @@ function fakeExtension(opts: { provisions?: ReadonlyMap { calls.push({ phase: 'application', id: ctx.id }); - return Effect.succeed({ outputs: { projectId: `${ctx.id}#project` } }); + return Effect.succeed({ projectId: `${ctx.id}#project` }); }, }, nodes: { 'fake/db': Object.assign( - (ctx: LowerContext) => { + (ctx: LowerContext): Effect.Effect => { calls.push({ phase: 'resource', id: ctx.id, type: ctx.node.type }); - return Effect.succeed({ outputs: { url: `db://${ctx.id}` } }); + return Effect.succeed({ + outputs: { url: `db://${ctx.id}` }, + entities: [{ kind: 'fake-db', id: `${ctx.id}#db` }], + }); }, { kind: 'resource' as const }, ), @@ -139,11 +155,13 @@ function fakeExtension(opts: { provisions?: ReadonlyMap { calls.push({ phase: 'provision', id: ctx.id, address: ctx.address }); + // ctx.application is `unknown` by design — core never reads the + // application hook's product. A real extension narrows it with its + // own type guard; this fake asserts its own hook's shape. + const application = ctx.application as { projectId: string }; return Effect.succeed({ - outputs: { - serviceId: `${ctx.id}#svc`, - projectId: ctx.application.outputs['projectId'], - }, + serviceId: `${ctx.id}#svc`, + projectId: application.projectId, }); }, serialize: (ctx, _provisioned, config) => { @@ -153,7 +171,7 @@ function fakeExtension(opts: { provisions?: ReadonlyMap Object.entries(values).map(([name, value]) => ({ input, name, value })), ); - return Effect.succeed({ outputs: { environment: records } }); + return Effect.succeed({ environment: records }); }, package: (ctx, input) => { calls.push({ @@ -169,16 +187,16 @@ function fakeExtension(opts: { provisions?: ReadonlyMap, }, }; const config: PrismaAppConfig = { @@ -188,10 +206,10 @@ function fakeExtension(opts: { provisions?: ReadonlyMap): LoweredNode => - Effect.runSync(eff as Effect.Effect); +const run = (eff: ReturnType): undefined => + Effect.runSync(eff as Effect.Effect); const runError = (eff: ReturnType): LowerError => - Effect.runSync(Effect.flip(eff as Effect.Effect)); + Effect.runSync(Effect.flip(eff as Effect.Effect)); describe('buildConfig', () => { test("matches a dependency input's params by name to the wired producer's lowered outputs, via its dependency edge", () => { @@ -202,7 +220,7 @@ describe('buildConfig', () => { return {}; }); const graph = Load(root); - const lowered = new Map([['db', { outputs: { url: 'db://db' } }]]); + const lowered = new Map([['db', { url: 'db://db' }]]); expect(buildConfig(auth, 'auth', graph, lowered, new Map())).toEqual({ service: { port: 3000 }, @@ -210,7 +228,35 @@ describe('buildConfig', () => { }); }); - test('a param the graph declares but the lowered outputs never produced resolves to undefined', () => { + // ——— The connection contract (S2). The consumer's connection declaration IS the + // contract (ADR-0033): core resolves each declared param by name against the + // producer's outputs. A producer that under-delivers used to hand the + // consumer a silent `undefined`, which serialized into its environment and + // failed at the consumer's boot — far from the mistake. It now fails the + // deploy, naming the edge. + + test('a producer that omits a declared required param fails the deploy, naming the edge, the param, the producer, and what the producer DID supply', () => { + const auth = app('fake/compute', { db: dbEnd() }); + const root = module('shop', {}, (h) => { + const db = h.provision(dbResource(), { id: 'db' }); + h.provision(auth, { id: 'auth', deps: { db } }); + return {}; + }); + const graph = Load(root); + // The producer lowered, but its outputs carry no `url` — the exact + // param `dbEnd()`'s connection declares and does not mark optional. + const lowered = new Map([['db', { host: 'db.internal' }]]); + + const build = () => buildConfig(auth, 'auth', graph, lowered, new Map()); + + expect(build).toThrow(LowerError); + expect(build).toThrow(/"auth\.db"/); // the edge id + expect(build).toThrow(/"url"/); // the param + expect(build).toThrow(/"db"/); // the producer + expect(build).toThrow(/host/); // the producer's actual key list + }); + + test('a producer supplying nothing at all names an empty key list, not a confusing blank', () => { const auth = app('fake/compute', { db: dbEnd() }); const root = module('shop', {}, (h) => { const db = h.provision(dbResource(), { id: 'db' }); @@ -219,12 +265,56 @@ describe('buildConfig', () => { }); const graph = Load(root); + // `lowered` empty: the producer is wired but produced no outputs. + expect(() => buildConfig(auth, 'auth', graph, new Map(), new Map())).toThrow(/nothing/); + }); + + test('a declared param the consumer marked optional is exempt — absent stays undefined, no error', () => { + const optionalDbEnd = () => + dependency({ + name: 'db', + type: 'fake/db', + connection: conn({ url: string({ optional: true }) }, () => ({})), + required: providerContract('fake/db', { url: '' }), + }); + const auth = app('fake/compute', { db: optionalDbEnd() }); + const root = module('shop', {}, (h) => { + const db = h.provision(dbResource(), { id: 'db' }); + h.provision(auth, { id: 'auth', deps: { db } }); + return {}; + }); + const graph = Load(root); + + // The consumer said absent is legal, so the producer under-delivering is + // not a contract breach — boot's coerce() reads a missing var as absent. expect(buildConfig(auth, 'auth', graph, new Map(), new Map())).toEqual({ service: {}, inputs: { db: { url: undefined } }, }); }); + test('an unwired input (no dependency edge) keeps resolving to undefined — the guard only judges a producer that exists', () => { + const auth = app('fake/compute', { db: dbEnd() }); + const root = module('shop', {}, (h) => { + const db = h.provision(dbResource(), { id: 'db' }); + h.provision(auth, { id: 'auth', deps: { db } }); + return {}; + }); + // The authoring API will not let a declared input go unwired — + // `h.provision(auth, { id: 'auth' })` does not type-check — so `db` is + // wired here and its edge then dropped. That reaches buildConfig's + // `edge === undefined` branch, which is defensive rather than authorable. + // With no edge there is no producer to hold to the contract, so the guard + // must stay out of it: an unwired input is a graph-construction concern. + const graph = Load(root); + const withoutEdges = { ...graph, edges: graph.edges.filter((e) => e.kind !== 'dependency') }; + + expect(buildConfig(auth, 'auth', withoutEdges, new Map(), new Map())).toEqual({ + service: {}, + inputs: { db: { url: undefined } }, + }); + }); + test("a param carrying a provision need sources its value from `provisioned` (keyed by edge id), not the producer's outputs", () => { const BRAND = Symbol('test-provision-brand'); const tokenEnd = () => @@ -245,7 +335,7 @@ describe('buildConfig', () => { const graph = Load(root); // The producer IS lowered (with some unrelated output), but a provisioned // param must ignore it entirely — its value comes only from `provisioned`. - const lowered = new Map([['auth', { outputs: { token: 'wrong-value' } }]]); + const lowered = new Map([['auth', { token: 'wrong-value' }]]); const provisioned = new Map([['consumer.auth', 'minted-value']]); expect(buildConfig(consumer, 'consumer', graph, lowered, provisioned)).toEqual({ @@ -253,6 +343,33 @@ describe('buildConfig', () => { inputs: { auth: { token: 'minted-value' } }, }); }); + + test('a REQUIRED provisioned param is exempt from the connection contract — the mint supplies it, the producer hands nothing over (ADR-0031)', () => { + const BRAND = Symbol('test-provision-brand'); + // Deliberately NOT optional: this pins that the provision branch is exempt + // on its own, rather than incidentally passing because it was optional. + const tokenEnd = () => + dependency({ + name: 'auth', + type: 'fake/rpc', + connection: conn({ token: string({ provision: provisionNeed(BRAND) }) }, () => ({})), + }); + const consumer = app('fake/compute', { auth: tokenEnd() }); + const root = module('shop', {}, (h) => { + const authRef = h.provision(app('fake/compute', {}), { id: 'auth' }); + h.provision(consumer, { id: 'consumer', deps: { auth: authRef } }); + return {}; + }); + const graph = Load(root); + // The producer supplies NOTHING — which for a non-provisioned required + // param would now be a LowerError. Here the mint is the source. + const provisioned = new Map([['consumer.auth', 'minted-value']]); + + expect(buildConfig(consumer, 'consumer', graph, new Map(), provisioned)).toEqual({ + service: {}, + inputs: { auth: { token: 'minted-value' } }, + }); + }); }); describe('buildConfig — provision-time param binding', () => { @@ -401,7 +518,18 @@ describe('lowering a module root — a single service', () => { ]); // The root is always a module — its own lowering has no outputs yet // (boundary ports are future work); see the two-service suite below. - expect(result).toEqual({ outputs: {} }); + expect(result).toBeUndefined(); + }); + + test('the lowering effect resolves to undefined — S1 kills the stack-output dump for good', () => { + const { config } = fakeExtension(); + const root = singleServiceModule('fake/compute'); + + const result = Effect.runSync( + lowering(root, config, opts(svcBundles)) as Effect.Effect, + ); + + expect(result).toBe(undefined); }); test('a module-provisioned resource lowers before the service that consumes it', () => { @@ -477,7 +605,7 @@ describe('lowering a module root — a single service', () => { const result = run(lowering(root, config, opts(svcBundles))); - expect(result).toEqual({ outputs: {} }); + expect(result).toBeUndefined(); }); test('missing a bundle for a single-service module is a LowerError naming it', () => { @@ -739,7 +867,7 @@ describe('lowering a module root — a provisioned resource and two connected se }), ); - expect(result).toEqual({ outputs: {} }); + expect(result).toBeUndefined(); }); test('fails with LowerError naming the type and the known types on an unknown resource type', () => { @@ -958,6 +1086,74 @@ describe('provision phase (ADR-0031): resolving a provisioned param against the }); }); +describe('joinDeployment', () => { + // The Action's input carries addresses + plain entities only — never graph + // nodes, because the plan hashes the resolved input and a node holds + // functions and Standard Schemas. This join is what puts the node back, + // reading it from the graph the runner holds by closure. + const twoNodeGraph = () => { + const root = module('shop', {}, (h) => { + const db = h.provision(dbResource(), { id: 'db' }); + h.provision(app('fake/compute', { db: dbEnd() }), { id: 'auth', deps: { db } }); + return {}; + }); + return Load(root); + }; + + test('puts each entry back together with its graph node, preserving entry order', () => { + const graph = twoNodeGraph(); + const entries = [ + { address: 'db', entities: [{ kind: 'fake-db', id: 'db#1' }] }, + { address: 'auth', entities: [{ kind: 'fake-compute', id: 'svc#1', url: 'https://a' }] }, + ]; + + const results = joinDeployment(graph, entries); + + expect(results).toHaveLength(2); + expect(results.map((r) => r.address)).toEqual(['db', 'auth']); + expect(results[0]?.node.name).toBe('db'); + expect(results[1]?.node.name).toBe('test-service'); + expect(results[1]?.entities).toEqual([{ kind: 'fake-compute', id: 'svc#1', url: 'https://a' }]); + }); + + test('skips an address the graph no longer holds — entries are data, the graph is truth', () => { + const graph = twoNodeGraph(); + const entries = [ + { address: 'db', entities: [] }, + { address: 'ghost', entities: [{ kind: 'fake-compute', id: 'gone#1' }] }, + ]; + + const results = joinDeployment(graph, entries); + + expect(results.map((r) => r.address)).toEqual(['db']); + }); + + test('a node that reported no entities still yields a result — it deployed, it just published nothing', () => { + const graph = twoNodeGraph(); + + const results = joinDeployment(graph, [{ address: 'auth', entities: [] }]); + + expect(results).toHaveLength(1); + expect(results[0]?.entities).toEqual([]); + }); + + test('no entries yields no results', () => { + expect(joinDeployment(twoNodeGraph(), [])).toEqual([]); + }); +}); + +describe('lowering() — the report path', () => { + test('without opts.report, lowering declares no action and stays sync-runnable', () => { + const { config } = fakeExtension(); + const root = singleServiceWithDbModule(); + + // The assertion IS that this runs synchronously: constructing the Action + // would drag alchemy's Stack context into the requirements and runSync + // would die. `run()` is Effect.runSync. + expect(run(lowering(root, config, opts(svcBundles)))).toBeUndefined(); + }); +}); + describe('lower()', () => { test('builds an Alchemy Stack wrapping the same lowering', () => { // Unlike lowering(), lower() DOES merge the extensions' providers eagerly @@ -1003,7 +1199,7 @@ describe('lowering a nested module — dotted addresses (H1: module-composition) ), ); - expect(result).toEqual({ outputs: {} }); + expect(result).toBeUndefined(); }); }); diff --git a/packages/0-framework/1-core/core/src/deploy.ts b/packages/0-framework/1-core/core/src/deploy.ts index d3458c45..c0540231 100644 --- a/packages/0-framework/1-core/core/src/deploy.ts +++ b/packages/0-framework/1-core/core/src/deploy.ts @@ -1,6 +1,6 @@ /** Routes each graph node to its extension descriptor (by node.extension/type) and runs provision → serialize → package → deploy in dependency order. */ -import type { StackServices } from 'alchemy'; +import type { Input, StackServices } from 'alchemy'; import * as Alchemy from 'alchemy'; import type { State } from 'alchemy/State/State'; import * as Effect from 'effect/Effect'; @@ -26,7 +26,7 @@ export type AlchemyStateLayer = Layer.Layer; * SPI calls via LowerContext.application. */ export interface ApplicationDescriptor { - provision(ctx: LowerContext): Effect.Effect; + provision(ctx: LowerContext): Effect.Effect; } /** @@ -50,33 +50,40 @@ export interface ProvisionerDescriptor { provision(edge: ProvisionEdge): Effect.Effect; } -/** The phased service SPI — the seam between the phases belongs to CORE. */ -export interface ServiceLowering { +/** + * The phased service SPI. `P` and `S` are the descriptor's OWN intra-node + * handoff types — provision's product consumed by serialize/deploy, and + * serialize's product consumed by deploy. Core threads them through without + * inspection; only the descriptor that writes them reads them. + * + * Method syntax (not property-arrow) is required: the heterogeneous + * descriptor registry (`NodeDescriptor`) assigns concrete descriptors to + * this interface's `unknown` defaults through TypeScript's method + * bivariance — a property-arrow form is checked contravariantly and breaks + * that assignment. + */ +export interface ServiceLowering

{ /** Makes the platform-specific thing that will host the service — identity-bearing infrastructure only, no code runs. */ - provision(ctx: LowerContext): Effect.Effect; + provision(ctx: LowerContext): Effect.Effect; /** * Encodes the typed Config into the service's runtime environment. Boot-side * deserialize reverses it through the same serializer. Returns the env-var * records so `deploy` can reference them. */ - serialize( - ctx: LowerContext, - provisioned: LoweredNode, - config: Config, - ): Effect.Effect; + serialize(ctx: LowerContext, provisioned: P, config: Config): Effect.Effect; /** * Prints the bootstrap and assembles the deployable artifact from the * app-built bundle. Must be byte-deterministic: an unchanged service noops * on redeploy. */ package(ctx: LowerContext, input: PackageInput): Effect.Effect; - /** Ships the packaged artifact into the provisioned thing and runs it. Returns the trustworthy URL. */ + /** Ships the packaged artifact into the provisioned thing and runs it. Returns the node's outputs for dependents, plus the entities it became on the deployment target. */ deploy( ctx: LowerContext, - provisioned: LoweredNode, + provisioned: P, artifact: Artifact, - serialized: LoweredNode, - ): Effect.Effect; + serialized: S, + ): Effect.Effect; } /** Input to an extension's package() step: the built bundle and the node's graph address. */ @@ -88,7 +95,7 @@ export interface PackageInput { } /** One node's realization. Runs inside the Alchemy stack effect. */ -export type Lowering = (ctx: LowerContext) => Effect.Effect; +export type Lowering = (ctx: LowerContext) => Effect.Effect; export interface LowerContext { readonly id: NodeId; @@ -101,20 +108,74 @@ export interface LowerContext { readonly node: ServiceNode | ResourceNode; readonly graph: Graph; readonly opts: LowerOptions; - /** The owning extension's application hook outputs (`{ outputs: {} }` when it declares none). */ - readonly application: LoweredNode; + /** + * The owning extension's application hook product; `undefined` when the + * extension declares no hook. Core never reads it; the extension narrows + * it with its own type guard. + */ + readonly application: unknown; /** Already-lowered deps (topo order). */ - readonly lowered: ReadonlyMap; + readonly lowered: ReadonlyMap; /** Every provisioned param value minted this lowering, keyed by edge id (ADR-0031). */ readonly provisioned: ReadonlyMap; } /** - * What a lowering hands downstream — e.g. a deployed URL a later node's env - * wiring consumes. The inter-node config-wiring hook for Connections. + * The values a node provides to its dependents — what a consumer's declared + * connection params resolve against (buildConfig reads them by param name). + * Name-keyed and unknown-valued of necessity: core cannot know extension + * types, and which producer feeds which consumer is decided by the user's + * graph at runtime. The connection declaration is the contract. */ -export interface LoweredNode { - readonly outputs: Readonly>; +export type Outputs = Readonly>; + +/** + * One thing a node became on the deployment target, RESOLVED — what the report + * consumer sees. The descriptor names it; core never infers meaning from it. + * `url` is present ONLY when the descriptor declares the address publicly + * reachable — a connection string is never a `url`. + * + * A descriptor constructing one holds `svc.id` / `deployment.deployedUrl` — + * `Output` references, not values, because the stack effect runs before + * Alchemy applies anything. So construction sites traffic in + * `Input` (Alchemy's own idiom for "this shape, fields possibly + * unresolved"); apply resolves it before any reader sees it. + */ +export interface DeployedEntity { + readonly kind: string; + readonly id: string; + readonly url?: string; + readonly details?: Readonly>; +} + +/** + * What a node's final lowering phase produces: outputs for dependents, + * entities for reporting. + * + * `entities` is REQUIRED, not optional. "This node became nothing reportable + * on the deployment target" is a claim, and an optional field lets a + * descriptor make it by saying nothing at all — no error, no type complaint, + * no failing test. That is the shared bag's sin in miniature (ADR-0033): a + * claim made anonymously, with nothing recording that a claim was made. `[]` + * costs one token and puts the assertion on the record where a reviewer can + * see it. + */ +export interface LoweredResult { + readonly outputs: Outputs; + readonly entities: readonly Input[]; +} + +/** What one graph node became — in-process only (it holds the node itself, so it never crosses the stack boundary). */ +export interface DeployedNode { + readonly address: string; + readonly node: ServiceNode | ResourceNode; + readonly entities: readonly DeployedEntity[]; +} + +/** The result of the Deploy operation: the app and every node it deployed, in topo order. */ +export interface DeploymentResult { + readonly app: string; + readonly nodes: readonly DeployedNode[]; } export interface LowerOptions { @@ -126,6 +187,14 @@ export interface LowerOptions { readonly stage?: string; /** Alchemy state store for the stack. Defaults to the config's own state layer. */ readonly state?: AlchemyStateLayer; + /** + * Invoked once per deploy, during apply, with the Deploy operation's result + * — the app and every node it deployed, resolved, in topo order. + * Presentation belongs to the caller (the CLI wires its renderer here); + * core never formats. Absent means no report is assembled and no Action is + * declared at all. + */ + readonly report?: (result: DeploymentResult) => void; } /** A build descriptor's normalized output: the produced bundle dir plus the app's runnable entry within it. */ @@ -222,12 +291,16 @@ function resolveParam( * it, the producer hands nothing over. The service's own params resolve via * `resolveParam` (provision-time binding, then default, then loud * unbound-required failure). + * + * This is also where the connection contract is enforced: a producer that fails to + * supply a required param its consumer's connection declares fails the deploy + * here, naming the edge, rather than reaching the consumer as `undefined`. */ export function buildConfig( node: ServiceNode, id: NodeId, graph: Graph, - lowered: ReadonlyMap, + lowered: ReadonlyMap, provisioned: ReadonlyMap, ): Config { const inputs: Record> = {}; @@ -236,13 +309,42 @@ export function buildConfig( const edge = graph.edges.find( (e) => e.to === id && e.input === inputName && e.kind === 'dependency', ); - const producedOutputs = edge !== undefined ? (lowered.get(edge.from)?.outputs ?? {}) : {}; + const producedOutputs = edge !== undefined ? (lowered.get(edge.from) ?? {}) : {}; const values: Record = {}; for (const [name, param] of Object.entries(inputNode.connection.params)) { - values[name] = - param.provision !== undefined - ? provisioned.get(`${id}.${inputName}`) - : producedOutputs[name]; + // ADR-0031: the framework mints this value; the producer hands nothing + // over, so the connection contract below doesn't apply to it. + if (param.provision !== undefined) { + values[name] = provisioned.get(`${id}.${inputName}`); + continue; + } + + const value = producedOutputs[name]; + // The connection contract: the consumer's connection declaration says what it + // needs, and the producer must supply it (ADR-0033). Under-delivery used + // to reach the consumer as a silent `undefined`, serialized into its + // environment and failing at ITS boot — far from the mistake. + // + // PRESENCE ONLY — deliberately not schema-validated, and it cannot be. + // At lowering time these values are routinely alchemy `Output` proxies + // (e.g. `deployment.deployedUrl`): lazy symbolic references that only + // resolve when Alchemy applies the stack, which is strictly after this + // whole effect runs. No Standard Schema can validate one. Checking that + // a value EXISTS is the most that can honestly be checked here. + // + // `=== undefined` (not `name in producedOutputs`): a producer explicitly + // writing `undefined` has supplied nothing, matching resolveParam. + // `edge === undefined` is left alone — with no producer there is nobody + // to hold to the contract; an unwired input is a graph concern. + if (value === undefined && param.optional !== true && edge !== undefined) { + throw new LowerError( + `Connection input "${id}.${inputName}" declares param "${name}", but its producer ` + + `"${edge.from}" did not supply it — the producer's outputs carry ` + + `[${Object.keys(producedOutputs).join(', ') || 'nothing'}]. Add "${name}" to the ` + + 'outputs the producer returns from its lowering, or declare the param optional on the connection.', + ); + } + values[name] = value; } inputs[inputName] = values; } @@ -259,6 +361,33 @@ export function buildConfig( return { service, inputs }; } +/** + * Joins resolved report entries back to their graph nodes — the last step of a + * deploy report, run inside the Action with apply's resolved values. + * + * The entries cross Alchemy's action-input boundary, so they carry addresses + * and plain entities only; the graph is held by closure on this side. That + * split is why this join exists at all, and it is what keeps functions and + * Standard Schemas (which a node carries, and which the plan's input hash + * would have to serialize) out of the input. + * + * Skips an address the graph no longer holds: entries are data, the graph is + * truth. + */ +export function joinDeployment( + graph: Graph, + entries: readonly { address: string; entities: readonly DeployedEntity[] }[], +): readonly DeployedNode[] { + const nodes: DeployedNode[] = []; + for (const entry of entries) { + const found = graph.nodes.find((n) => n.id === entry.address); + const node = found?.node; + if (node === undefined || (node.kind !== 'service' && node.kind !== 'resource')) continue; + nodes.push({ address: entry.address, node, entities: entry.entities }); + } + return nodes; +} + function missingBundleError(id: NodeId): LowerError { return new LowerError( `No bundle provided for service "${id}" (opts.bundles["${id}"] is required).`, @@ -383,6 +512,23 @@ export function mergedProviders(config: PrismaAppConfig): Layer.Layer { return first === undefined ? Layer.empty : Layer.mergeAll(first, ...rest); } +/** + * The deployment-report Action's input, declared in RESOLVED terms — this is + * what the runner receives. The call site passes `Input`s, + * whose fields may still be `Output` references; alchemy's deep `Input<>` + * mapping on the input position accepts them and apply resolves them before + * the runner runs. Addresses and entities only — never graph nodes (see + * joinDeployment). + */ +interface ReportEntry { + readonly address: string; + readonly entities: readonly DeployedEntity[]; +} +interface ReportInput { + readonly nonce: number; + readonly entries: readonly ReportEntry[]; +} + /** * Composable form for mixed stacks: hand-wired Alchemy resources alongside Prisma App nodes in one stack effect. * Fails with LowerError or whatever an extension's lowering raises — the error type is open. @@ -391,11 +537,16 @@ export function lowering( root: ModuleNode, config: PrismaAppConfig, opts: LowerOptions, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { const graph = Load(root, { id: opts.name }); const extensions = yield* extensionsById(config); - const lowered = new Map(); + const lowered = new Map(); + // Each node's reported entities, in topo order — the loop is the only + // party that holds both the node's identity and what it became. Collected + // unconditionally (it is a cheap array); only the Action below is + // conditional. + const entries: { address: string; entities: readonly Input[] }[] = []; // ADR-0031: every provisioned param value minted this lowering, keyed by // edge id. Populated by the provision phase below (after the application // hooks, before any node), then threaded read-only through every ctx and @@ -405,8 +556,7 @@ export function lowering( // Each extension's application hook runs ONCE, before any node, in config // order — its outputs reach that extension's own nodes via ctx.application // (the same threading the one-target model had). - const noApplication: LoweredNode = { outputs: {} }; - const applications = new Map(); + const applications = new Map(); for (const descriptor of config.extensions) { if (descriptor.application === undefined) continue; const appCtx: LowerContext = { @@ -416,7 +566,7 @@ export function lowering( node: graph.root.node as never, graph, opts, - application: noApplication, + application: undefined, lowered, provisioned, }; @@ -492,7 +642,7 @@ export function lowering( node: node as ServiceNode | ResourceNode, graph, opts, - application: applications.get(node.extension) ?? noApplication, + application: applications.get(node.extension), lowered, provisioned, }; @@ -500,7 +650,9 @@ export function lowering( const descriptor = yield* descriptorFor(extensions, node, id); if (descriptor.kind === 'resource') { - lowered.set(id, yield* descriptor(ctx)); + const result = yield* descriptor(ctx); + lowered.set(id, result.outputs); + entries.push({ address: id, entities: result.entities }); continue; } if (descriptor.kind !== 'service') { @@ -526,11 +678,38 @@ export function lowering( assembled: { dir: bundle.dir, entry: bundle.entry }, address: id, }); - lowered.set(id, yield* descriptor.deploy(ctx, provisionedNode, artifact, serialized)); + const result = yield* descriptor.deploy(ctx, provisionedNode, artifact, serialized); + lowered.set(id, result.outputs); + entries.push({ address: id, entities: result.entities }); + } + + // The report is assembled ONLY when a caller asked for one. This + // conditionality is required, not an optimization: without it every + // `lowering()` call would declare an Action and drag alchemy's Stack + // context into core's sync unit tests. + if (opts.report !== undefined) { + const report = opts.report; + const ReportAction = Alchemy.Action('composer-deployment-report', (input: ReportInput) => + Effect.sync(() => { + // `input` arrives RESOLVED — apply evaluates the action's input + // against its tracker before invoking the runner, so the ids and + // URLs the descriptors handed over as Output references are real + // strings here. The graph rides in on the closure, never in the + // input: the plan hashes the resolved input, and a node carries + // functions and Standard Schemas. + report({ app: opts.name, nodes: joinDeployment(graph, input.entries) }); + }), + ); + // `Date.now()` forces the report to run on an otherwise unchanged + // redeploy: alchemy noops an action whose resolved input hashes to what + // the last run persisted. A nonce is legitimate here because this input + // triggers a report — it is not artifact input, which determinism rules + // govern. + yield* ReportAction({ nonce: Date.now(), entries }); } - return { outputs: {} }; - }) as Effect.Effect; + return undefined; + }) as Effect.Effect; } /** @@ -541,10 +720,7 @@ export function lowering( export function lower(root: ModuleNode, config: PrismaAppConfig, opts: LowerOptions) { // A LowerError at deploy is fatal; orDie moves it off the error channel to // match what Alchemy.Stack accepts. - const stackEffect = Effect.orDie(lowering(root, config, opts)) as Effect.Effect< - LoweredNode, - never - >; + const stackEffect = Effect.orDie(lowering(root, config, opts)) as Effect.Effect; return Alchemy.Stack( opts.name, diff --git a/packages/0-framework/3-tooling/cli/package.json b/packages/0-framework/3-tooling/cli/package.json index 2c54cfca..558f1b77 100644 --- a/packages/0-framework/3-tooling/cli/package.json +++ b/packages/0-framework/3-tooling/cli/package.json @@ -8,6 +8,10 @@ "types": "./dist/index.d.mts", "default": "./dist/index.mjs" }, + "./report": { + "types": "./dist/render-deployment.d.mts", + "default": "./dist/render-deployment.mjs" + }, "./package.json": "./package.json" }, "scripts": { diff --git a/packages/0-framework/3-tooling/cli/src/__tests__/generate-stack.test.ts b/packages/0-framework/3-tooling/cli/src/__tests__/generate-stack.test.ts index 5cecaebf..4b5321b2 100644 --- a/packages/0-framework/3-tooling/cli/src/__tests__/generate-stack.test.ts +++ b/packages/0-framework/3-tooling/cli/src/__tests__/generate-stack.test.ts @@ -36,6 +36,23 @@ describe('renderStackFile() — a module root', () => { expect(content).not.toContain('stage:'); }); + test('passes the deploy report through, so the renderer runs in the alchemy child where the resolved result is', () => { + const content = renderStackFile({ + entryPath: '/repo/app/module.ts', + cwd: '/repo/app', + configPath: '/repo/app/prisma-composer.config.ts', + name: 'storefront-auth', + assembled: { bundles: { app: { dir: '/repo/app/dist', entry: 'server.js' } } }, + }); + + expect(content).toContain("import { deploymentReport } from '@prisma/composer/report';"); + // The callback, not a call: the app name rides inside the DeploymentResult + // core assembles, so the template threads nothing to the report. + expect(content).toContain('report: deploymentReport,'); + expect(content).not.toContain('deploymentReport('); + expect(content).toContain('name: "storefront-auth"'); + }); + test('a config discovered ABOVE the app dir renders with the deeper relative path', () => { const content = renderStackFile({ entryPath: '/repo/apps/shop/module.ts', diff --git a/packages/0-framework/3-tooling/cli/src/__tests__/render-deployment.test.ts b/packages/0-framework/3-tooling/cli/src/__tests__/render-deployment.test.ts new file mode 100644 index 00000000..18694175 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/__tests__/render-deployment.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from 'bun:test'; +import { service } from '@internal/core'; +import type { DeployedNode, DeploymentResult } from '@internal/core/deploy'; +import { deploymentReport, renderDeployment } from '../render-deployment.ts'; + +/** + * The renderer reads only `address` and `entities` — `node` is along for the + * ride (it is what makes a deployed node joinable to the graph). A real + * ServiceNode rather than a stub, so these fixtures cannot drift from the type. + */ +const deployed = (address: string, entities: DeployedNode['entities']): DeployedNode => ({ + address, + node: service({ + name: address, + extension: 'test/pack', + type: 'compute', + inputs: {}, + params: {}, + build: { + extension: '@prisma/composer/node', + type: 'node', + module: 'file:///test/service.ts', + entry: 'server.js', + }, + }), + entities, +}); + +const result = (app: string, nodes: readonly DeployedNode[]): DeploymentResult => ({ app, nodes }); + +describe('renderDeployment', () => { + test('renders the pinned tree: nested addresses, aligned entities, urls on their own line', () => { + const results = [ + deployed('auth.api', [ + { kind: 'compute-service', id: 'cps_abc123', url: 'https://xyz.ewr.prisma.build' }, + ]), + deployed('db', [{ kind: 'postgres-database', id: 'pdb_def456' }]), + deployed('web', [ + { kind: 'compute-service', id: 'cps_ghi789', url: 'https://uvw.ewr.prisma.build' }, + ]), + ]; + + expect(renderDeployment(result('storefront-auth', results))).toBe( + [ + 'storefront-auth', + '├─ auth', + '│ └─ api compute-service cps_abc123', + '│ https://xyz.ewr.prisma.build', + '├─ db postgres-database pdb_def456', + '└─ web compute-service cps_ghi789', + ' https://uvw.ewr.prisma.build', + ].join('\n'), + ); + }); + + test('a node that reported no entities is listed, not silently dropped — it deployed, it just published nothing', () => { + const results = [ + deployed('creds', []), + deployed('store', [{ kind: 'compute-service', id: 'cps_1' }]), + ]; + + expect(renderDeployment(result('app', results))).toBe( + ['app', '├─ creds (no entities reported)', '└─ store compute-service cps_1'].join('\n'), + ); + }); + + test('an intermediate address segment is structure, not a deployed node — it carries no entity column', () => { + // Only `auth.api` deployed; `auth` exists solely to hold it. + const results = [deployed('auth.api', [{ kind: 'compute-service', id: 'cps_1' }])]; + + expect(renderDeployment(result('app', results))).toBe( + ['app', '└─ auth', ' └─ api compute-service cps_1'].join('\n'), + ); + }); + + test('a node with several entities puts each on its own line, aligned under the first', () => { + const results = [ + deployed('svc', [ + { kind: 'compute-service', id: 'cps_1', url: 'https://a.example' }, + { kind: 'postgres-database', id: 'pdb_1' }, + ]), + ]; + + expect(renderDeployment(result('app', results))).toBe( + [ + 'app', + '└─ svc compute-service cps_1', + ' https://a.example', + ' postgres-database pdb_1', + ].join('\n'), + ); + }); + + test('the app name alone when nothing deployed', () => { + expect(renderDeployment(result('app', []))).toBe('app'); + }); + + test('deep nesting keeps every entity in one column', () => { + const results = [ + deployed('a.b.c', [{ kind: 'compute-service', id: 'cps_1' }]), + deployed('z', [{ kind: 'postgres-database', id: 'pdb_1' }]), + ]; + + expect(renderDeployment(result('app', results))).toBe( + [ + 'app', + '├─ a', + '│ └─ b', + '│ └─ c compute-service cps_1', + '└─ z postgres-database pdb_1', + ].join('\n'), + ); + }); +}); + +describe('deploymentReport', () => { + test('prints a leading blank line then the rendered tree', () => { + const lines: unknown[] = []; + const original = console.log; + console.log = (value?: unknown) => { + lines.push(value); + }; + try { + deploymentReport( + result('app', [deployed('db', [{ kind: 'postgres-database', id: 'pdb_1' }])]), + ); + } finally { + console.log = original; + } + + expect(lines).toEqual(['', 'app\n└─ db postgres-database pdb_1']); + }); +}); 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 b3ceface..51247cda 100644 --- a/packages/0-framework/3-tooling/cli/src/generate-stack.ts +++ b/packages/0-framework/3-tooling/cli/src/generate-stack.ts @@ -44,6 +44,14 @@ function renderOptions(input: StackFileInput): string { } lines.push(' },'); + // Presentation is the CLI's, but it has to run where the result is: the + // report is invoked inside apply, in the alchemy child, which is this file's + // process — not the CLI's. Passing the hook through the generated file is + // what lets core stay presentation-free while the renderer still sees + // resolved values (ADR-0033). The callback takes the DeploymentResult + // directly — the app name rides inside it, so nothing else is threaded here. + lines.push(' report: deploymentReport,'); + return lines.join('\n'); } @@ -60,6 +68,7 @@ export function renderStackFile(input: StackFileInput): string { // // bisects a CLI bug from an Alchemy bug (deploy-cli.md § Implementation decisions). import { lower } from '@prisma/composer/deploy'; +import { deploymentReport } from '@prisma/composer/report'; import config from ${quote(configImport)}; import app from ${quote(appImport)}; diff --git a/packages/0-framework/3-tooling/cli/src/render-deployment.ts b/packages/0-framework/3-tooling/cli/src/render-deployment.ts new file mode 100644 index 00000000..e588c2ce --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/render-deployment.ts @@ -0,0 +1,116 @@ +/** + * Pipeline step 8: renders a deploy's result as the app's own topology. + * + * Presentation lives here, beside the CLI that owns the terminal — core + * assembles the `DeploymentResult` and never formats (ADR-0033). The rendered + * values are the entities each descriptor deliberately NAMED and apply + * resolved; nothing here is scraped from a node's outputs, which are checked + * for presence but never for truth. + */ +import type { DeployedEntity, DeployedNode, DeploymentResult } from '@internal/core/deploy'; + +/** Gap between the deepest tree label and the entity column. */ +const LABEL_GAP = 3; + +interface TreeNode { + readonly segment: string; + readonly children: Map; + /** The deployed node at exactly this address, if one deployed here. Absent for a pure path segment (`auth` when only `auth.api` deployed). */ + deployed?: DeployedNode; +} + +const emptyNode = (segment: string): TreeNode => ({ segment, children: new Map() }); + +/** Builds the address tree, splitting each dot-address into its segments. */ +function buildTree(nodes: readonly DeployedNode[]): TreeNode { + const root = emptyNode(''); + for (const deployed of nodes) { + let node = root; + for (const segment of deployed.address.split('.')) { + let child = node.children.get(segment); + if (child === undefined) { + child = emptyNode(segment); + node.children.set(segment, child); + } + node = child; + } + node.deployed = deployed; + } + return root; +} + +interface Row { + /** Tree guides + connector + segment name — what occupies the left column. */ + readonly label: string; + /** Guides only, with this row's own connector blanked — the prefix a wrapped line carries. */ + readonly continuation: string; + readonly deployed: DeployedNode | undefined; +} + +/** Flattens the tree to rows in address order, drawing the box guides. */ +function toRows(node: TreeNode, guides: string, rows: Row[]): void { + const children = Array.from(node.children.values()); + children.forEach((child, index) => { + const isLast = index === children.length - 1; + rows.push({ + label: `${guides}${isLast ? '└─ ' : '├─ '}${child.segment}`, + // A wrapped line under this row keeps the ancestors' guides but not the + // connector: the branch has already been drawn. + continuation: `${guides}${isLast ? ' ' : '│ '}`, + deployed: child.deployed, + }); + toRows(child, `${guides}${isLast ? ' ' : '│ '}`, rows); + }); +} + +/** `kind id` — the one line an entity gets. */ +const entityLine = (entity: DeployedEntity): string => `${entity.kind} ${entity.id}`; + +/** Pads `prefix` out to `width`, so every entity starts in the same column. */ +const pad = (prefix: string, width: number): string => prefix.padEnd(width, ' '); + +/** + * Renders a deploy's result as the app's own topology. Pure — returns the + * string; the caller prints. + */ +export function renderDeployment(result: DeploymentResult): string { + const rows: Row[] = []; + toRows(buildTree(result.nodes), '', rows); + + // One column for every entity in the tree, set by the widest label — so + // the ids line up regardless of nesting depth. + const column = Math.max(0, ...rows.map((row) => row.label.length)) + LABEL_GAP; + + const lines = [result.app]; + for (const row of rows) { + if (row.deployed === undefined) { + // A pure path segment (`auth` when only `auth.api` deployed) — structure, + // not a deployed node. Nothing to report against it. + lines.push(row.label); + continue; + } + if (row.deployed.entities.length === 0) { + lines.push(`${pad(row.label, column)}(no entities reported)`); + continue; + } + // The first entity shares the label's line; the rest wrap into the + // same column, as does a url. + row.deployed.entities.forEach((entity, index) => { + const prefix = index === 0 ? row.label : row.continuation; + lines.push(`${pad(prefix, column)}${entityLine(entity)}`); + if (entity.url !== undefined) { + lines.push(`${pad(row.continuation, column)}${entity.url}`); + } + }); + } + return lines.join('\n'); +} + +/** + * The report hook the generated stack file wires into `LowerOptions`. Prints a + * leading blank line so the summary separates from alchemy's own apply output. + */ +export function deploymentReport(result: DeploymentResult): void { + console.log(''); + console.log(renderDeployment(result)); +} diff --git a/packages/0-framework/3-tooling/cli/tsdown.config.ts b/packages/0-framework/3-tooling/cli/tsdown.config.ts index 37c78e32..085f5345 100644 --- a/packages/0-framework/3-tooling/cli/tsdown.config.ts +++ b/packages/0-framework/3-tooling/cli/tsdown.config.ts @@ -1,8 +1,13 @@ import { defineConfig } from '@internal/tsdown-config'; export default defineConfig({ - entry: ['src/index.ts', 'src/bin.ts'], - // The CLI declares its `.` export and `bin` by hand; don't auto-generate a - // `./bin` export (the executable must not be importable). + // `render-deployment.ts` is its own entry, not part of the `.` barrel: the + // generated stack file imports the renderer into the ALCHEMY CHILD, and the + // barrel would drag the whole CLI (clipanion, c12, and — via main.ts — + // @internal/lowering) in with it. The renderer imports nothing but core's + // types, so its own entry stays that way. + entry: ['src/index.ts', 'src/bin.ts', 'src/render-deployment.ts'], + // The CLI declares its `.` and `./report` exports and `bin` by hand; don't + // auto-generate a `./bin` export (the executable must not be importable). exports: false, }); 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 5fd70b50..3247fa5c 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 @@ -1,6 +1,6 @@ import { describe, expect, mock, test } from 'bun:test'; import type { Contract } from '@internal/core'; -import type { LowerContext, LoweredNode } from '@internal/core/deploy'; +import type { LowerContext, LoweredResult, Outputs } from '@internal/core/deploy'; // Import the REAL modules the mocks below stub, so each mock can spread them. // This matters beyond convenience: `bun test` runs every test file in ONE // process and `mock.module` is process-global. When the real module is already @@ -13,7 +13,13 @@ import * as RealPrismaAlchemy from '@internal/lowering'; import * as RealOutput from 'alchemy/Output'; import * as Effect from 'effect/Effect'; import * as Redacted from 'effect/Redacted'; +import type { ComputeProvisioned, ComputeSerialized } from '../descriptors/compute.ts'; +import type { S3StoreSerialized } from '../descriptors/s3-store.ts'; +// shared.ts's only @internal/lowering import is type-only, so pulling +// projectIdOf in statically doesn't drag the mocked runtime module in early. +import { type CloudApplication, projectIdOf } from '../descriptors/shared.ts'; import * as RealPgWarm from '../pg-warm-resource.ts'; +import * as RealS3Credentials from '../s3-credentials-resource.ts'; // Stub the provider layer AND alchemy/Output so the compute target's data // flow (id derivation, props threading, outputs shape) runs purely — no @@ -29,6 +35,7 @@ const recorded: { deploy: Array<[string, unknown]>; pkg: Array<[unknown]>; serviceKey: Array<[string, unknown]>; + creds: Array<[string, unknown]>; } = { envVar: [], db: [], @@ -38,6 +45,7 @@ const recorded: { deploy: [], pkg: [], serviceKey: [], + creds: [], }; mock.module('alchemy/Output', () => ({ @@ -100,6 +108,18 @@ mock.module('../pg-warm-resource.ts', () => ({ PgWarmProvider: () => ({ stub: 'pg-warm-provider' }), })); +// S3Credentials is a real Alchemy Resource (needs the Stack service); stub it +// so the credentials lowering's data flow runs purely. The real provider mints +// a random pair — fixed values here so assertions can name them. +mock.module('../s3-credentials-resource.ts', () => ({ + ...RealS3Credentials, + S3Credentials: (id: string, props: unknown) => { + recorded.creds.push([id, props]); + return Effect.succeed({ accessKeyId: 'AKIA-STUB', secretAccessKey: 'secret-stub' }); + }, + S3CredentialsProvider: () => ({ stub: 's3-credentials-provider' }), +})); + const { prismaCloud } = await import('../control.ts'); const { compute, envParam, envSecret, postgres, postgresContract, s3StoreService } = await import( '../index.ts' @@ -108,9 +128,46 @@ const { dependency, module, provisionNeed, secret, string } = await import('@int const { lowering } = await import('@internal/core/deploy'); const { RPC_PEER_KEY } = await import('@internal/rpc'); -const run = (eff: Effect.Effect): A => +// The node registry erases each descriptor's P/S to `unknown`, so every hook +// hands back Effect. `A` is the caller's claim about what the hook +// under test returns — asserted here, checked by the descriptor's own +// `satisfies` at its definition. +const run = (eff: Effect.Effect): A => Effect.runSync(eff as Effect.Effect); +// ——— The handoff shapes AS THE MOCKS ABOVE PRODUCE THEM. +// +// The real types describe the real world: `ComputeProvisioned.serviceId` is an +// `Output` (a lazy reference that only resolves when Alchemy applies +// the stack), and `ComputeSerialized.environment` holds real +// `EnvironmentVariable` resources. The mocks at the top of this file collapse +// that laziness on purpose — `Output.map` applies its function directly, and +// each mock resource returns a plain object — so the hooks hand back resolved +// values here and nothing else. +// +// These mirror the real types with that collapse applied. Reusing the real +// types would re-assert `Output` over a plain string, which is the +// exact type lie this slice removed from compute.ts. +// +// They are DERIVED from the real types rather than restated, so a renamed or +// retyped handoff field breaks here instead of leaving these asserting a shape +// that no longer exists. `run` cannot catch that drift — it takes +// Effect, so `A` is an unchecked caller assertion — which makes these +// definitions the only compile-time link back to the real types. +/** The mocks collapse Output to T; nothing else about the real types changes. */ +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 MockedProvisioned = Mirror; +type MockedSerialized = Omit, 'environment'> & { + readonly environment: MockedEnvironment; +}; +type MockedS3StoreSerialized = Omit, 'environment'> & { + readonly environment: MockedEnvironment; +}; + /** Sets env vars for the duration of `fn`, restoring whatever was there before. */ async function withEnv(values: Record, fn: () => T): Promise { const previous = new Map(Object.keys(values).map((k) => [k, process.env[k]])); @@ -154,17 +211,40 @@ const configFor = (descriptor: Descriptor) => ({ }, }); +describe("projectIdOf — narrowing ctx.application to this extension's own product", () => { + test("accepts this extension's own application product", () => { + expect(projectIdOf({ projectId: 'shop-project-id' })).toBe('shop-project-id'); + }); + + // ctx.application is `unknown`: core never reads the application hook's + // product and cannot type it. Anything that isn't prisma-cloud's own product + // — most importantly `undefined`, which is what core hands a node whose + // extension declares no application hook — must fail here, naming the hook + // that didn't run, rather than surfacing as `undefined` inside a deployed + // service's env. + test.each([ + ['undefined (the extension declared no application hook)', undefined], + ['null', null], + ['a non-object', 'shop-project-id'], + ['an object without projectId', { branchId: 'b_1' }], + ['an object whose projectId is not a string', { projectId: 42 }], + ])('throws, naming the hook that must run, on %s', (_label, value) => { + expect(() => projectIdOf(value)).toThrow(/prisma-cloud: ctx\.application/); + expect(() => projectIdOf(value)).toThrow(/application hook must run before any node lowers/); + }); +}); + describe('prismaCloud().application.provision (once-per-lowering hook)', () => { test('default stage: references PRISMA_PROJECT_ID (no Project minted), poisons DATABASE_URL + DATABASE_URL_POOLED with "-", class production, no branchId', async () => { await withEnv({ PRISMA_PROJECT_ID: 'shop-project-id', PRISMA_BRANCH_ID: undefined }, () => { const target = prismaCloud({ workspaceId: 'ws_1' }); const before = recorded.envVar.length; - const result = run( + const result = run( applicationOf(target).provision({ graph: { edges: [] } } as unknown as LowerContext), ); - expect(result.outputs).toEqual({ projectId: 'shop-project-id' }); + expect(result).toEqual({ projectId: 'shop-project-id' }); // "-", not "": the API rejects empty env-var values (verified at the R4 deploy proof). expect(recorded.envVar.slice(before)).toEqual([ [ @@ -194,11 +274,11 @@ describe('prismaCloud().application.provision (once-per-lowering hook)', () => { const target = prismaCloud({ workspaceId: 'ws_1' }); const before = recorded.envVar.length; - const result = run( + const result = run( applicationOf(target).provision({ graph: { edges: [] } } as unknown as LowerContext), ); - expect(result.outputs).toEqual({ projectId: 'shop-project-id' }); + expect(result).toEqual({ projectId: 'shop-project-id' }); expect(recorded.envVar.slice(before)).toEqual([ [ 'DATABASE_URL-poison', @@ -229,7 +309,7 @@ describe('prismaCloud().application.provision (once-per-lowering hook)', () => { const target = prismaCloud({ workspaceId: 'ws_1' }); expect(() => - run(applicationOf(target).provision({} as unknown as LowerContext)), + run(applicationOf(target).provision({} as unknown as LowerContext)), ).toThrow(/PRISMA_PROJECT_ID/); }); }); @@ -242,12 +322,16 @@ describe("prismaCloud().nodes['postgres'] — the resource descriptor", () => { // ctx.id is the module provision id — one Database per provisioned resource. const ctx = { id: 'data', - application: { outputs: { projectId: 'shop-project#cloud-id' } }, + application: { projectId: 'shop-project#cloud-id' }, } as unknown as LowerContext; - const result = run(resourceDescriptorOf(target, 'postgres')(ctx)); + const result = run(resourceDescriptorOf(target, 'postgres')(ctx)); expect(result.outputs).toEqual({ url: 'postgres://data-conn' }); + // The entity carries NO `url`: a connection string is not a public + // endpoint. The outputs above still carry one — same key, opposite + // 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' }], ]); @@ -262,11 +346,11 @@ describe("prismaCloud().nodes['postgres'] — the resource descriptor", () => { const target = prismaCloud({ workspaceId: 'ws_1' }); const ctx = { id: 'data2', - application: { outputs: { projectId: 'shop-project#cloud-id' } }, + application: { projectId: 'shop-project#cloud-id' }, } as unknown as LowerContext; const before = recorded.db.length; - run(resourceDescriptorOf(target, 'postgres')(ctx)); + run(resourceDescriptorOf(target, 'postgres')(ctx)); expect(recorded.db.slice(before)).toEqual([ [ @@ -283,18 +367,36 @@ describe("prismaCloud().nodes['postgres'] — the resource descriptor", () => { }); }); +describe("prismaCloud().nodes['credentials'] — the resource descriptor", () => { + test('reports NO entities — a minted keypair is secret material, and an entity is built to be printed', () => { + const target = prismaCloud({ workspaceId: 'ws_1' }); + const ctx = { id: 'creds' } as unknown as LowerContext; + + const result = run(resourceDescriptorOf(target, 'credentials')(ctx)); + + // The pair reaches consumers through the OUTPUTS — that is what they are for. + expect(result.outputs).toEqual({ + accessKeyId: 'AKIA-STUB', + secretAccessKey: 'secret-stub', + }); + // It must never reach an entity. Entities get rendered to a terminal + // and are the one channel with nothing publishable to say here. + expect(result.entities).toEqual([]); + }); +}); + describe("prismaCloud().nodes['compute'] — the service descriptor", () => { test("provision creates a ComputeService inside the application's project", async () => { await withEnv({ PRISMA_BRANCH_ID: undefined }, () => { const target = prismaCloud({ workspaceId: 'ws_1' }); const ctx = { id: 'auth', - application: { outputs: { projectId: 'shop-project#cloud-id' } }, + application: { projectId: 'shop-project#cloud-id' }, } as unknown as LowerContext; - const result = run(serviceDescriptorOf(target, 'compute').provision(ctx)); + const result = run(serviceDescriptorOf(target, 'compute').provision(ctx)); - expect(result.outputs).toEqual({ + expect(result).toEqual({ serviceId: 'auth-svc#cloud-id', projectId: 'shop-project#cloud-id', }); @@ -309,11 +411,11 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { const target = prismaCloud({ workspaceId: 'ws_1' }); const ctx = { id: 'auth2', - application: { outputs: { projectId: 'shop-project#cloud-id' } }, + application: { projectId: 'shop-project#cloud-id' }, } as unknown as LowerContext; const before = recorded.svc.length; - run(serviceDescriptorOf(target, 'compute').provision(ctx)); + run(serviceDescriptorOf(target, 'compute').provision(ctx)); expect(recorded.svc.slice(before)).toEqual([ [ @@ -348,14 +450,12 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { address: 'auth', node, graph: { secrets: [], edges: [] }, - application: { outputs: {} }, + application: {}, } as unknown as LowerContext; - const provisioned: LoweredNode = { - outputs: { serviceId: 'auth-svc#cloud-id', projectId: 'shop-project#cloud-id' }, - }; + const provisioned = { serviceId: 'auth-svc#cloud-id', projectId: 'shop-project#cloud-id' }; const config = { service: { port: 3000 }, inputs: { db: { url: 'postgres://real-db' } } }; - const result = run( + const result = run( serviceDescriptorOf(target, 'compute').serialize(ctx, provisioned, config), ); @@ -382,13 +482,13 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { }, ], ]); - expect(result.outputs['environment']).toEqual([ + 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' }, ]); // serialize also surfaces the resolved listen port for deploy() — the // Deployment must route to whatever the app binds, not a constant. - expect(result.outputs['port']).toBe(3000); + expect(result.port).toBe(3000); }); }); @@ -418,10 +518,11 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { address: 'consumer', node, graph: { secrets: [], edges: [] }, - application: { outputs: {} }, + application: {}, } as unknown as LowerContext; - const provisioned: LoweredNode = { - outputs: { serviceId: 'consumer-svc#cloud-id', projectId: 'shop-project#cloud-id' }, + const provisioned = { + serviceId: 'consumer-svc#cloud-id', + projectId: 'shop-project#cloud-id', }; // buildConfig resolves url from the wired provider; serviceKey has no value yet. const config = { @@ -430,7 +531,9 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { }; const before = recorded.envVar.length; - run(serviceDescriptorOf(target, 'compute').serialize(ctx, provisioned, config)); + run( + serviceDescriptorOf(target, 'compute').serialize(ctx, provisioned, config), + ); const writes = recorded.envVar.slice(before).map(([, props]) => props); // The provided url still writes its row... @@ -475,13 +578,13 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { address: 'ingest', node, graph, - application: { outputs: {} }, + application: {}, } as unknown as LowerContext; - const provisioned: LoweredNode = { outputs: { projectId: 'shop-project#cloud-id' } }; + const provisioned = { projectId: 'shop-project#cloud-id' }; const config = { service: { port: 3000 }, inputs: {} }; const before = recorded.envVar.length; - run( + run( serviceDescriptorOf(target, 'compute').serialize(ctx, provisioned, config), ); @@ -527,15 +630,15 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { address: 'web', node, graph, - application: { outputs: {} }, + application: {}, } as unknown as LowerContext; - const provisioned: LoweredNode = { outputs: { projectId: 'shop-project#cloud-id' } }; + const provisioned = { projectId: 'shop-project#cloud-id' }; // buildConfig resolved the param to the opaque ParamSource, unvalidated — exactly what // deploy.ts's resolveParam does for a source-bound param. const config = { service: { port: 3000, appOrigin: envParam('APP_ORIGIN') }, inputs: {} }; const before = recorded.envVar.length; - run( + run( serviceDescriptorOf(target, 'compute').serialize(ctx, provisioned, config), ); @@ -572,16 +675,18 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { address: 'web', node, graph, - application: { outputs: {} }, + application: {}, } as unknown as LowerContext; - const provisioned: LoweredNode = { outputs: { projectId: 'shop-project#cloud-id' } }; + const provisioned = { projectId: 'shop-project#cloud-id' }; const config = { service: { port: 3000, appOrigin: 'https://literal.example.com' }, inputs: {}, }; const before = recorded.envVar.length; - run(serviceDescriptorOf(target, 'compute').serialize(ctx, provisioned, config)); + run( + serviceDescriptorOf(target, 'compute').serialize(ctx, provisioned, config), + ); const writes = recorded.envVar.slice(before).map(([, props]) => props); expect(writes).toContainEqual({ @@ -610,13 +715,15 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { address: 'auth3', node, graph: { secrets: [], edges: [] }, - application: { outputs: {} }, + application: {}, } as unknown as LowerContext; - const provisioned: LoweredNode = { outputs: { projectId: 'shop-project#cloud-id' } }; + const provisioned = { projectId: 'shop-project#cloud-id' }; const config = { service: { port: 3000 }, inputs: {} }; const before = recorded.envVar.length; - run(serviceDescriptorOf(target, 'compute').serialize(ctx, provisioned, config)); + run( + serviceDescriptorOf(target, 'compute').serialize(ctx, provisioned, config), + ); expect(recorded.envVar.slice(before)).toEqual([ [ @@ -650,18 +757,18 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { address: 'auth', node, graph: { secrets: [], edges: [] }, - application: { outputs: {} }, + application: {}, } as unknown as LowerContext; - const provisioned: LoweredNode = { outputs: { projectId: 'shop-project#cloud-id' } }; + const provisioned = { projectId: 'shop-project#cloud-id' }; // A port other than the pack default: serialize must carry 8080 through, // not silently normalize it back to 3000. const config = { service: { port: 8080 }, inputs: {} }; - const result = run( + const result = run( serviceDescriptorOf(target, 'compute').serialize(ctx, provisioned, config), ); - expect(result.outputs['port']).toBe(8080); + expect(result.port).toBe(8080); }); }); @@ -692,19 +799,15 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { test("deploy's environment prop IS serialize's returned records — the edge that kills PRO-211", () => { const target = prismaCloud({ workspaceId: 'ws_1' }); const ctx = { id: 'auth' } as unknown as LowerContext; - const provisioned: LoweredNode = { - outputs: { serviceId: 'auth-svc#cloud-id', projectId: 'shop-project#cloud-id' }, - }; + const provisioned = { serviceId: 'auth-svc#cloud-id', projectId: 'shop-project#cloud-id' }; const artifact = { path: '/tmp/auth.tar.gz', sha256: 'sha-auth' }; - const serialized: LoweredNode = { - outputs: { - environment: [{ id: 'COMPOSER_AUTH_DB_URL-var#cloud-id', key: 'COMPOSER_AUTH_DB_URL' }], - // A non-default port from serialize must reach the Deployment verbatim. - port: 8080, - }, + const serialized = { + environment: [{ id: 'COMPOSER_AUTH_DB_URL-var#cloud-id', key: 'COMPOSER_AUTH_DB_URL' }], + // A non-default port from serialize must reach the Deployment verbatim. + port: 8080, }; - const result = run( + const result = run( serviceDescriptorOf(target, 'compute').deploy(ctx, provisioned, artifact, serialized), ); @@ -715,7 +818,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { computeServiceId: 'auth-svc#cloud-id', artifactPath: '/tmp/auth.tar.gz', artifactHash: 'sha-auth', - environment: serialized.outputs['environment'], + environment: serialized.environment, port: 8080, }, ], @@ -724,6 +827,15 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { url: 'https://auth-deploy.example', projectId: 'shop-project#cloud-id', }); + // compute publishes its URL deliberately — a Compute service's deployed + // endpoint IS public, and this descriptor is the only party that knows it. + expect(result.entities).toEqual([ + { + kind: 'compute-service', + id: 'auth-svc#cloud-id', + url: 'https://auth-deploy.example', + }, + ]); }); }); @@ -743,11 +855,9 @@ describe("prismaCloud().nodes['s3-store'] — the service descriptor with extend address: 'store', node, graph: { secrets: [], edges: [] }, - application: { outputs: {} }, + application: {}, } as unknown as LowerContext; - const provisioned: LoweredNode = { - outputs: { serviceId: 'store-svc#cloud-id', projectId: 'shop-project#cloud-id' }, - }; + const provisioned = { serviceId: 'store-svc#cloud-id', projectId: 'shop-project#cloud-id' }; // buildConfig would populate inputs.credentials from the wired resource's // lowered outputs; bucket is the service's own param. const config = { @@ -755,16 +865,16 @@ describe("prismaCloud().nodes['s3-store'] — the service descriptor with extend inputs: { credentials: { accessKeyId: 'AKIA123', secretAccessKey: 'sekret' } }, }; - const result = run( + const result = run( serviceDescriptorOf(target, 's3-store').serialize(ctx, provisioned, config), ); - expect(result.outputs['bucket']).toBe('streams'); - expect(result.outputs['accessKeyId']).toBe('AKIA123'); - expect(result.outputs['secretAccessKey']).toBe('sekret'); - // compute's own outputs survive. - expect(result.outputs['port']).toBe(3000); - expect(Array.isArray(result.outputs['environment'])).toBe(true); + expect(result.bucket).toBe('streams'); + expect(result.accessKeyId).toBe('AKIA123'); + expect(result.secretAccessKey).toBe('sekret'); + // compute's own serialize product survives the extension. + expect(result.port).toBe(3000); + expect(Array.isArray(result.environment)).toBe(true); }); }); @@ -776,11 +886,11 @@ describe("prismaCloud().nodes['s3-store'] — the service descriptor with extend address: 'store', node, graph: { secrets: [], edges: [] }, - application: { outputs: {} }, + application: {}, } as unknown as LowerContext; - const provisioned: LoweredNode = { outputs: { projectId: 'shop-project#cloud-id' } }; + const provisioned = { projectId: 'shop-project#cloud-id' }; const serialize = (config: unknown) => - run( + run( serviceDescriptorOf(target, 's3-store').serialize( ctx, provisioned, @@ -805,21 +915,17 @@ describe("prismaCloud().nodes['s3-store'] — the service descriptor with extend test('deploy outputs carry all four S3Config field names for a consumer s3() slot', async () => { const target = prismaCloud({ workspaceId: 'ws_1' }); const ctx = { id: 'store' } as unknown as LowerContext; - const provisioned: LoweredNode = { - outputs: { serviceId: 'store-svc#cloud-id', projectId: 'shop-project#cloud-id' }, - }; + const provisioned = { serviceId: 'store-svc#cloud-id', projectId: 'shop-project#cloud-id' }; const artifact = { path: '/tmp/store.tar.gz', sha256: 'sha-store' }; - const serialized: LoweredNode = { - outputs: { - environment: [{ id: 'STORE_PORT-var#cloud-id', key: 'STORE_PORT' }], - port: 3000, - bucket: 'streams', - accessKeyId: 'AKIA123', - secretAccessKey: 'sekret', - }, + const serialized = { + environment: [{ id: 'STORE_PORT-var#cloud-id', key: 'STORE_PORT' }], + port: 3000, + bucket: 'streams', + accessKeyId: 'AKIA123', + secretAccessKey: 'sekret', }; - const result = run( + const result = run( serviceDescriptorOf(target, 's3-store').deploy(ctx, provisioned, artifact, serialized), ); @@ -831,18 +937,29 @@ describe("prismaCloud().nodes['s3-store'] — the service descriptor with extend accessKeyId: 'AKIA123', secretAccessKey: 'sekret', }); + // The entities are compute's, passed through untouched — an s3-store IS + // a compute service and became nothing else. Note what is NOT here: the + // credentials ride in the outputs (the consumer needs them) but never reach + // an entity, which exists to be printed to a terminal. + expect(result.entities).toEqual([ + { + kind: 'compute-service', + id: 'store-svc#cloud-id', + url: 'https://store-deploy.example', + }, + ]); }); test('provision + package delegate to compute unchanged', () => { const target = prismaCloud({ workspaceId: 'ws_1' }); const ctx = { id: 'store', - application: { outputs: { projectId: 'p#cloud-id' } }, + application: { projectId: 'p#cloud-id' }, } as unknown as LowerContext; - const provisionResult = run( + const provisionResult = run( serviceDescriptorOf(target, 's3-store').provision(ctx), ); - expect(provisionResult.outputs['serviceId']).toBe('store-svc#cloud-id'); + expect(provisionResult.serviceId).toBe('store-svc#cloud-id'); const pkg = run( serviceDescriptorOf(target, 's3-store').package(ctx, { @@ -914,7 +1031,7 @@ describe('sharing: one module-provisioned postgres, two compute consumers — th envVar: recorded.envVar.length, }; - run( + run( lowering(root, configFor(target), { name: 'shop', bundles: { @@ -996,7 +1113,7 @@ describe('ADR-0030: per-binding RPC service keys — mint (control.ts) + wire (d }); const before = { envVar: recorded.envVar.length, serviceKey: recorded.serviceKey.length }; - run( + run( lowering(root, configFor(target), { name: 'shop', bundles: { @@ -1050,7 +1167,7 @@ describe('ADR-0030: per-binding RPC service keys — mint (control.ts) + wire (d }); const before = recorded.envVar.length; - run( + run( lowering(root, configFor(target), { name: 'shop', bundles: { @@ -1091,7 +1208,7 @@ describe('ADR-0030: per-binding RPC service keys — mint (control.ts) + wire (d }); const before = { envVar: recorded.envVar.length, serviceKey: recorded.serviceKey.length }; - run( + run( lowering(root, configFor(target), { name: 'shop', bundles: { auth3: { dir: 'modules/auth3/dist/bundle', entry: 'server.js' } }, @@ -1131,7 +1248,7 @@ describe('ADR-0030: per-binding RPC service keys — mint (control.ts) + wire (d }); const before = recorded.envVar.length; - run( + run( lowering(root, configFor(target), { name: 'shop', bundles: { @@ -1233,7 +1350,7 @@ describe('name validation — fail fast on Prisma name constraints, before creat const before = recorded.db.length; expect(() => - run(lowering(root, configFor(target), { name: 'shop', bundles })), + run(lowering(root, configFor(target), { name: 'shop', bundles })), ).not.toThrow(); expect(recorded.db.length).toBe(before + 1); }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/control.ts b/packages/1-prisma-cloud/1-extensions/target/src/control.ts index 995910dc..fad1b69c 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/control.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/control.ts @@ -16,7 +16,7 @@ import { postgresDescriptor } from './descriptors/postgres.ts'; import { prismaNextDescriptor } from './descriptors/prisma-next.ts'; import { s3CredentialsDescriptor } from './descriptors/s3-credentials.ts'; import { s3StoreDescriptor } from './descriptors/s3-store.ts'; -import type { ResolvedCloudOptions } from './descriptors/shared.ts'; +import type { CloudApplication, ResolvedCloudOptions } from './descriptors/shared.ts'; import { PgWarmProvider } from './pg-warm-resource.ts'; import { PnMigrationProvider } from './pn-migration-resource.ts'; import { runPreflight } from './preflight.ts'; @@ -141,7 +141,7 @@ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor }); } - return { outputs: { projectId } }; + return { projectId } satisfies CloudApplication; }), }, 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 dfed1ae5..3be4242e 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 @@ -1,7 +1,7 @@ /** The `compute` node kind's descriptor: the four service hooks — provision, serialize, package, deploy. */ import { isParamSource, type ServiceNode } from '@internal/core'; -import type { NodeDescriptor } from '@internal/core/config'; +import type { ServiceLowering } from '@internal/core/deploy'; import { blindCast } from '@internal/foundation/casts'; import * as Prisma from '@internal/lowering'; import * as Output from 'alchemy/Output'; @@ -17,7 +17,35 @@ import { import { serviceKeyEdges, serviceKeyEnvName } from '../service-keys.ts'; import { DEFAULT_REGION, projectIdOf, type ResolvedCloudOptions, validateName } from './shared.ts'; -export function computeDescriptor(o: ResolvedCloudOptions): NodeDescriptor { +/** + * 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. + */ +export interface ComputeProvisioned { + readonly serviceId: Output.Output; + readonly projectId: string; +} + +/** compute's serialize → deploy handoff: the env-var rows deploy must depend on, and the resolved port it routes to. */ +export interface ComputeSerialized { + readonly environment: readonly Prisma.EnvironmentVariable[]; + readonly port: number; +} + +/** + * Returns the PRECISE descriptor type, not the erased `NodeDescriptor`: the + * registry in control.ts erases it on assignment anyway (method bivariance), + * but s3-store composes over these hooks and needs their P/S to stay visible. + * Annotating this `NodeDescriptor` would force s3-store to cast them back. + */ +export function computeDescriptor( + o: ResolvedCloudOptions, +): { readonly kind: 'service' } & ServiceLowering { return { kind: 'service' as const, // The service as a PLACE inside the application's Project: the App, @@ -25,15 +53,14 @@ export function computeDescriptor(o: ResolvedCloudOptions): NodeDescriptor { provision: ({ id, application }) => Effect.gen(function* () { validateName(id, 'service name (from provision id)'); + const projectId = projectIdOf(application); const svc = yield* Prisma.ComputeService(`${id}-svc`, { - projectId: projectIdOf(application), + projectId, name: id, region: o.region ?? DEFAULT_REGION, ...(o.branchId !== undefined ? { branchId: o.branchId } : {}), }); - return { - outputs: { serviceId: svc.id, projectId: application.outputs['projectId'] }, - }; + return { serviceId: svc.id, projectId }; }), // Two channels of rows: PARAMS (service-own literals JSON-encoded; dependency @@ -45,7 +72,7 @@ export function computeDescriptor(o: ResolvedCloudOptions): NodeDescriptor { const { address, node, graph } = ctx; const cls = o.branchId ? ('preview' as const) : ('production' as const); const branch = o.branchId !== undefined ? { branchId: o.branchId } : {}; - const projectId = projectIdOf(provisioned); + const projectId = provisioned.projectId; const svc = node as ServiceNode; const records = []; @@ -131,9 +158,11 @@ export function computeDescriptor(o: ResolvedCloudOptions): NodeDescriptor { ); } - // Carries the resolved port to deploy() via serialize's outputs; falls back to 3000 if unset. + // Carries the resolved port to deploy(); falls back to 3000 if unset. + // This is the only place the raw, untyped config is read, so it is the + // only place the fallback belongs — from here on `port` is a number. const port = typeof config.service['port'] === 'number' ? config.service['port'] : 3000; - return { outputs: { environment: records, port } }; + return { environment: records, port }; }), // Deterministic tar.gz (fixed mtimes/ordering) so unchanged inputs hash @@ -152,17 +181,28 @@ export function computeDescriptor(o: ResolvedCloudOptions): NodeDescriptor { deploy: ({ id }, provisioned, artifact, serialized) => Effect.gen(function* () { const deployment = yield* Prisma.Deployment(`${id}-deploy`, { - computeServiceId: provisioned.outputs['serviceId'] as string, + computeServiceId: provisioned.serviceId, artifactPath: artifact.path, artifactHash: artifact.sha256, - environment: serialized.outputs['environment'] as readonly Prisma.EnvironmentVariable[], + environment: serialized.environment, // Route to the port the app actually binds (the service's `port` // param, resolved by serialize) — not a hardcoded constant. - port: typeof serialized.outputs['port'] === 'number' ? serialized.outputs['port'] : 3000, + port: serialized.port, }); + // `url` IS published here: a Compute service's deployed URL is a + // public endpoint, and this descriptor is the only party that knows + // that. Both fields are still unresolved Output references at this + // point — apply resolves them before the report's runner sees them. return { - outputs: { url: deployment.deployedUrl, projectId: provisioned.outputs['projectId'] }, + outputs: { url: deployment.deployedUrl, projectId: provisioned.projectId }, + entities: [ + { + kind: 'compute-service', + id: provisioned.serviceId, + url: deployment.deployedUrl, + }, + ], }; }), - }; + } satisfies { readonly kind: 'service' } & ServiceLowering; } 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 09f73f38..20e002c2 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 @@ -29,7 +29,14 @@ export function postgresDescriptor(o: ResolvedCloudOptions): NodeDescriptor { // 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 }); - return { outputs: { url: warm.url } }; + // No `url` on the entity: a Postgres connection string is not a public + // endpoint. `url` on an entity means publicly reachable BECAUSE the + // descriptor said so — core has no rule that could infer it, and the + // same key means the opposite thing here as it does on compute. + return { + outputs: { url: warm.url }, + entities: [{ kind: 'postgres-database', id: db.id }], + }; }); 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 c3bc6224..bae65374 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 @@ -59,7 +59,12 @@ export function prismaNextDescriptor(o: ResolvedCloudOptions): NodeDescriptor { ...(node.targetRef !== undefined ? { refName: node.targetRef } : {}), }); - return { outputs: { url: warm.url } }; + // No `url` entity field — same reason as postgres: a connection string is + // not a public endpoint, and only the descriptor can know that. + return { + outputs: { url: warm.url }, + entities: [{ kind: 'postgres-database', id: db.id }], + }; }); return Object.assign(lowering, { kind: 'resource' as const }); } diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-credentials.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-credentials.ts index 94281206..0af35275 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-credentials.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-credentials.ts @@ -17,8 +17,12 @@ export function s3CredentialsDescriptor(_o: ResolvedCloudOptions): NodeDescripto const lowering: Lowering = ({ id }) => Effect.gen(function* () { const creds = yield* S3Credentials(`${id}-creds`, {}); + // No entities: a minted keypair has nothing publishable. Both fields + // are secret material, and secret material must never reach an entity + // — entities are built to be rendered to a terminal. return { outputs: { accessKeyId: creds.accessKeyId, secretAccessKey: creds.secretAccessKey }, + entities: [], }; }); return Object.assign(lowering, { kind: 'resource' as const }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-store.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-store.ts index 96d8f21d..cc9706e3 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-store.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-store.ts @@ -9,15 +9,30 @@ */ import type { NodeDescriptor } from '@internal/core/config'; +import type { ServiceLowering } from '@internal/core/deploy'; import * as Effect from 'effect/Effect'; -import { computeDescriptor } from './compute.ts'; +import { type ComputeProvisioned, type ComputeSerialized, computeDescriptor } from './compute.ts'; import type { ResolvedCloudOptions } from './shared.ts'; +/** + * s3-store's serialize → deploy handoff: compute's, plus the four + * consumer-facing S3Config fields. Extending `ComputeSerialized` is legitimate + * because this descriptor COMPOSES compute's own hooks — same party, not an + * unrelated consumer reaching for a shared bag. The three added fields are + * `unknown` because that is what they honestly are: they come out of the + * untyped `Config`, whose values core cannot type. + */ +export interface S3StoreSerialized extends ComputeSerialized { + readonly bucket: unknown; + readonly accessKeyId: unknown; + readonly secretAccessKey: unknown; +} + export function s3StoreDescriptor(o: ResolvedCloudOptions): NodeDescriptor { + // No `base.kind !== 'service'` check any more: computeDescriptor's return + // type says `kind: 'service'`, so the discriminant is a compile-time fact + // rather than something to re-test at runtime. const base = computeDescriptor(o); - if (base.kind !== 'service') { - throw new Error('computeDescriptor must be a service descriptor'); - } return { kind: 'service' as const, @@ -46,26 +61,30 @@ export function s3StoreDescriptor(o: ResolvedCloudOptions): NodeDescriptor { ); } return { - outputs: { - ...serialized.outputs, - bucket, - accessKeyId: credentials['accessKeyId'], - secretAccessKey: credentials['secretAccessKey'], - }, + ...serialized, + bucket, + accessKeyId: credentials['accessKeyId'], + secretAccessKey: credentials['secretAccessKey'], }; }), deploy: (ctx, provisioned, artifact, serialized) => Effect.gen(function* () { const deployed = yield* base.deploy(ctx, provisioned, artifact, serialized); + // Compute's entities pass through untouched — an s3-store IS a + // compute service, and it became nothing else. Only the OUTPUTS gain + // the four S3Config fields a consumer's s3() slot resolves by name. + // Spreading `deployed` and overriding `outputs` (rather than rebuilding + // the result) is what keeps the entities' pass-through exact. return { + ...deployed, outputs: { ...deployed.outputs, - bucket: serialized.outputs['bucket'], - accessKeyId: serialized.outputs['accessKeyId'], - secretAccessKey: serialized.outputs['secretAccessKey'], + bucket: serialized.bucket, + accessKeyId: serialized.accessKeyId, + secretAccessKey: serialized.secretAccessKey, }, }; }), - }; + } satisfies { readonly kind: 'service' } & ServiceLowering; } 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 1c87dde6..64a68e09 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,6 +1,5 @@ /** Helpers shared by the per-node-kind descriptors under `src/descriptors/` and the extension factory in `control.ts`. */ -import { blindCast } from '@internal/foundation/casts'; import type * as Prisma from '@internal/lowering'; /** @@ -34,11 +33,28 @@ export function validateName(value: string, source: string): void { } } -/** The application/provisioned hook's `projectId` output — `LoweredNode.outputs` is typed `unknown`, so this is the one asserted read. */ -export const projectIdOf = (hook: { - readonly outputs: Readonly>; -}): string => - blindCast< - string, - 'the projectId output is a provisioning string ref the application hook produced; LoweredNode.outputs is typed unknown' - >(hook.outputs['projectId']); +/** What prisma-cloud's application hook produces; its own descriptors are the only consumers. */ +export interface CloudApplication { + readonly projectId: string; +} + +export function isCloudApplication(value: unknown): value is CloudApplication { + // `in` narrows without a cast — TS carries the key through to the read. + return ( + typeof value === 'object' && + value !== null && + 'projectId' in value && + typeof value.projectId === 'string' + ); +} + +/** Narrows `ctx.application`, which core hands over as `unknown`, to this extension's own product; throws naming the hook when it hasn't run. */ +export function projectIdOf(application: unknown): string { + if (!isCloudApplication(application)) { + throw new Error( + "prisma-cloud: ctx.application is not this extension's application product — " + + 'the prismaCloud() application hook must run before any node lowers.', + ); + } + return application.projectId; +} diff --git a/packages/9-public/composer/package.json b/packages/9-public/composer/package.json index 758c3e29..d2bf91a3 100644 --- a/packages/9-public/composer/package.json +++ b/packages/9-public/composer/package.json @@ -10,6 +10,7 @@ ".": "./dist/index.mjs", "./config": "./dist/config.mjs", "./deploy": "./dist/deploy.mjs", + "./report": "./dist/report.mjs", "./testing": "./dist/testing.mjs", "./casts": "./dist/casts.mjs", "./assertions": "./dist/assertions.mjs", diff --git a/packages/9-public/composer/src/report.ts b/packages/9-public/composer/src/report.ts new file mode 100644 index 00000000..732bee8c --- /dev/null +++ b/packages/9-public/composer/src/report.ts @@ -0,0 +1 @@ +export * from '@internal/cli/report'; diff --git a/packages/9-public/composer/tsdown.config.ts b/packages/9-public/composer/tsdown.config.ts index 1fd46ddb..34df428f 100644 --- a/packages/9-public/composer/tsdown.config.ts +++ b/packages/9-public/composer/tsdown.config.ts @@ -12,6 +12,7 @@ export default defineConfig([ index: 'src/index.ts', config: 'src/config.ts', deploy: 'src/deploy.ts', + report: 'src/report.ts', testing: 'src/testing.ts', casts: 'src/casts.ts', assertions: 'src/assertions.ts', diff --git a/skills/prisma-composer/SKILL.md b/skills/prisma-composer/SKILL.md index 562b5d5b..1b43d469 100644 --- a/skills/prisma-composer/SKILL.md +++ b/skills/prisma-composer/SKILL.md @@ -526,6 +526,58 @@ fails rather than standing one up. turbo run build && prisma-composer deploy module.ts --stage pr-42 ``` +### What a deploy prints + +A deploy ends by printing the app's own topology — authored names, the +platform resource each became, and public URLs. The tree is the module +structure (`auth.api` is the `api` service inside the `auth` module): + +``` +storefront-auth +├─ auth +│ └─ api compute-service cps_abc123 +│ https://xyz.ewr.prisma.build +├─ db postgres-database db_def456 +└─ web compute-service cps_ghi789 + https://uvw.ewr.prisma.build +``` + +Read ids out of this rather than telling the user to go hunting in the +Console. A URL appears only where the address is genuinely public — a compute +service prints one, a database never does (it has a connection string, not a +public endpoint), and a node whose product is secret material (an +`s3-credentials` keypair) reports no resource line at all. A node that +published nothing reportable still appears, marked `(no entities reported)`. + +Older deploys ended with a raw `{ outputs: {} }` blob from the deploy engine — +always empty, never about the app. It is gone; nothing configured it and +nothing consumed it. + +### The connection contract is checked at deploy + +A connection declares the values it needs by name, and the producer on the +other end must supply them. A producer that omits one fails the deploy, naming +the edge, the param, and what the producer did supply: + +``` +Connection input "auth.db" declares param "url", but its producer "db" did not +supply it — the producer's outputs carry [host]. +``` + +Fix it at whichever end is wrong: add the name to the outputs the producer +returns from its lowering, or mark the param `optional` on the connection if absent is +genuinely legal (the consumer then reads `undefined`). + +This is a deploy-time refusal, not a broken deploy — and it can appear on an +app whose code didn't change. The gap used to pass silently: the value reached +the consumer as `undefined`, went into its environment, and crashed *that* +service at boot, blaming the reader instead of the supplier. Don't route around +it by making the param optional unless absent really is valid; that reinstates +the silent `undefined`. + +Only reachable if you authored the connection or the extension on one side — +every shipped block supplies what it declares. + ## Production pitfalls - **Scale-to-zero closes idle database connections.** A persistent client diff --git a/tsconfig.depcruise.json b/tsconfig.depcruise.json index 885e887c..ffa82a69 100644 --- a/tsconfig.depcruise.json +++ b/tsconfig.depcruise.json @@ -19,6 +19,7 @@ "@internal/nextjs": ["./packages/0-framework/2-authoring/nextjs/src/index.ts"], "@internal/assemble": ["./packages/0-framework/3-tooling/assemble/src/index.ts"], "@internal/cli/bin": ["./packages/0-framework/3-tooling/cli/src/bin.ts"], + "@internal/cli/report": ["./packages/0-framework/3-tooling/cli/src/render-deployment.ts"], "@internal/cli": ["./packages/0-framework/3-tooling/cli/src/index.ts"], "@internal/lowering/postgres": [ "./packages/1-prisma-cloud/0-lowering/lowering/src/postgres/index.ts"