diff --git a/.drive/projects/prisma-cli-v8/deferred.md b/.drive/projects/prisma-cli-v8/deferred.md index a8032062..2b2943c3 100644 --- a/.drive/projects/prisma-cli-v8/deferred.md +++ b/.drive/projects/prisma-cli-v8/deferred.md @@ -100,9 +100,13 @@ composer can use it. Running the shipped `prisma` binary against a scratch directory, rather than the ORM family through the test harness, turns up three things. The first is a defect a user hits on their first command. - **`prisma orm init` scaffolds a project the `prisma` binary cannot read.** It writes `prisma-next.config.ts` — the standalone `prisma-next` bin's config file — and then fails its own last step, `Emit the contract`, with exit 5 and `Config is not a defineConfig result`. Nine files are already on disk at that point. Running any ORM command afterwards fails again, differently: the mounted family reads its configuration from an `orm` section of `prisma.config.ts` (`ormConfigSection`, `packages/1-framework/3-tooling/cli/src/orm/config-section.ts` in prisma/prisma), so it reports `CLI.CONFIG_SECTION_INVALID` and `CONFIG.FILE_NOT_FOUND` — "The orm config section is absent, so prisma-next.config.ts was never evaluated." So `prisma orm init && prisma contract emit` cannot work, and the two config surfaces have different shapes: the section nests the whole config under `orm`, while the scaffolded file exports a `defineConfig` result. Which side moves is the ORM's call; that it is broken today is not in question. -- **Loading a hand-written `prisma.config.ts` failed with `Cannot find package 'pathe'`**, imported by `c12` from `packages/cli-engine/node_modules/c12`. `c12` declares `pathe` and the package is in the workspace store, so this is probably a pnpm layout artifact of running the built binary from inside the monorepo rather than a shipping defect — **but it is unverified**, and if it does reproduce from a packed tarball then every command that reads a config file is broken on install. Worth one run of the S6 tarball check with a config file present. +- **Loading a hand-written `prisma.config.ts` failed with `Cannot find package 'pathe'`**, imported by `c12` from `packages/cli-engine/node_modules/c12`. **Settled 2026-08-18: it does not reproduce from a packed tarball.** The conformance sandbox (`.conformance/cli/sandbox`, the packed `@prisma/cli` + engine tarballs installed with npm) evaluates a hand-written `prisma.config.ts` correctly: an unknown top-level key answers `CLI.CONFIG_UNKNOWN_SECTION` naming the key, and an `orm` section reaches that section's validator — both require c12 to have evaluated the file. So the pathe failure is what it looked like: a pnpm layout artifact of running the built binary from inside the monorepo, not a shipping defect. (Observed while checking: jiti evaluates a config whose relative import cannot resolve rather than failing — c12/jiti behavior, noted, not chased.) - **The e2e coverage convention excludes all 22 ORM commands on reasoning #171 disproved.** `tests/e2e-coverage.test.ts` excuses them with "Real e2e lives in prisma/prisma (R7); the shell proves composition in orm-mount.test.ts (R8)." prisma/prisma's suite passed throughout the presentations change while the assembled binary exited 2, and `orm-mount.test.ts` proves composition for exactly one command, `migration list`, not per family. The operator's ruling (2026-08-13) is that every mounted command needs a happy path in this repo, precisely because the product repos cannot reproduce the assembled CLI. The exclusion should become a backlog entry once the first item above is fixed and the commands can run at all. +## The domain commands' branch default is a name no project has (found writing the e2e happy paths, verified against the API 2026-08-18) + +- **`service domain add` without `--branch` reports `SERVICE.SELECTION_INVALID` for a service that exists.** Root cause pinned: `resolveServiceDomainTarget` (`packages/cli/src/commands/service/target.ts:650`) defaults the branch to the literal name `"production"` and passes it as a filter to `listServices`; a project's production branch is actually named `main`, so the filtered listing is empty and the explicitly named service is judged missing. Verified against the real API on a fresh service: no `--branch` and `--branch production` both answer `SELECTION_INVALID` — "Selected service does not exist in the resolved project" — while `--branch main` proceeds to the genuine next refusal (`SERVICE.NO_DEPLOYMENTS` undeployed, the DNS check when deployed). The error also misreports the cause: the service exists, the branch doesn't. Same family as the name-vs-role bug below (`toBranchKind`); the fix is to resolve the production branch by role from the API's branch records rather than by literal name, which would repair both. + ## A live bug carried out of the port (found closing PR #92, 2026-08-12) - **The production branch is still resolved by name, not role.** @@ -347,21 +351,17 @@ CLI does not do, and each restarts as engine work if wanted: that later date, not deleted** — R-S8-5's "provided live streaming can be added at a later date" is still the standing commitment, and this slice is what it was traded against. -- **The e2e suite should assert the real service-id prefix.** D2 wrote - `e2e/service.e2e.ts` without credentials to run it, so it asserts only - that `service create` reports a non-empty id. The sibling suites assert - real prefixes (`bkt_`, `db_`) because their authors could see one. - Whoever first runs this suite green should read the id the API actually - returns and tighten the assertion to match, as `bucket.e2e.ts` does. -- **`service show` can have a real e2e now, and should.** It sat on the - `AWAITING_COVERAGE` backlog because the whole `service` family was - assumed to need a deployed service. `service create` falsified that: - `service show` works against a service that has never been promoted — - D1's own unit test asserts that case. Adding it to `e2e/service.e2e.ts` - alongside `create`/`list`/`remove` is a small job and removes the entry - rather than re-explaining it. The `service domain *` entries look like - the same case (they attach a domain to a service, not to a deployment) - and are worth checking at the same time. +- **The e2e suite should assert the real service-id prefix.** DONE + (2026-08-18): the API returns `cps_`-prefixed ids, and + `e2e/service.e2e.ts` now asserts `/^cps_/` the way `bucket.e2e.ts` + asserts `bkt_`. +- **`service show` can have a real e2e now, and should.** DONE: it runs + in `e2e/service.e2e.ts` alongside `create`/`list`/`remove` and is off + the `AWAITING_COVERAGE` backlog. The `service domain *` prediction in + this entry turned out wrong twice over: the domain commands DO need a + promoted deployment (`SERVICE.NO_DEPLOYMENTS` without one), and their + DNS check needs a hostname the test account controls, which is why + they remain on the backlog. ## Composer's public surface — ruled, closed diff --git a/packages/cli/e2e/deployed-service.ts b/packages/cli/e2e/deployed-service.ts index cd0fe5f8..c1913cdf 100644 --- a/packages/cli/e2e/deployed-service.ts +++ b/packages/cli/e2e/deployed-service.ts @@ -64,19 +64,31 @@ function tarEntry(name: string, contents: string): Buffer { } /** The smallest thing the platform will run: an HTTP server that - * answers, so a started deployment reaches `running` rather than - * crash-looping. */ + * answers, so a started deployment actually boots and serves rather + * than crash-looping. The layout is what Composer's archiver produces + * and the runner requires — files under `bundle/`, and a root + * `compute.manifest.json` naming the entrypoint; without the manifest + * the runner exits before ever starting the app. The server logs on + * startup and per request, so `service logs` has lines to read; the + * "e2e-fixture" markers are what the logs test looks for. */ function artifact(): Buffer { const tar = Buffer.concat([ tarEntry( - "package.json", + "compute.manifest.json", + '{"manifestVersion":"1","entrypoint":"bundle/index.js"}', + ), + tarEntry( + "bundle/package.json", '{"name":"e2e-fixture","version":"1.0.0","type":"module","main":"index.js"}', ), tarEntry( - "index.js", + "bundle/index.js", 'import{createServer}from"node:http";' + - 'createServer((_,response)=>{response.writeHead(200);response.end("ok")})' + - ".listen(process.env.PORT||3000);", + "createServer((request,response)=>{" + + 'console.log("e2e-fixture served "+request.url);' + + 'response.writeHead(200);response.end("ok")})' + + ".listen(process.env.PORT||3000," + + '()=>console.log("e2e-fixture listening"));', ), Buffer.alloc(1024), // two zero blocks end the archive ]); diff --git a/packages/cli/e2e/service-deployment.e2e.ts b/packages/cli/e2e/service-deployment.e2e.ts index b1206b0e..4ff1ea08 100644 --- a/packages/cli/e2e/service-deployment.e2e.ts +++ b/packages/cli/e2e/service-deployment.e2e.ts @@ -8,11 +8,19 @@ * * The blocks run in file order and share one service: it is deployed * once, read by the middle blocks, then stopped and deleted at the end. - * Teardown must delete the deployment before the scratch project can go. + * The rollback block adds a second deployment, promotes it, and rolls + * back to the first, so the later blocks still act on a live first + * deployment. Teardown must delete every deployment before the scratch + * project can go. */ import { afterAll, expect, it } from "vitest"; -import { deleteDeployment, deployService } from "./deployed-service"; +import { + createDeployment, + deleteDeployment, + deployService, +} from "./deployed-service"; +import type { CliRun } from "./harness"; import { scratchName } from "./harness"; import { useScratchProject } from "./scratch"; import { describeCommand } from "./suite"; @@ -25,6 +33,8 @@ let deployed: | { serviceId: string; serviceName: string; deploymentId: string } | undefined; +let secondDeployment: { id: string; serviceName: string } | undefined; + function requireDeployed(): { serviceId: string; serviceName: string; @@ -45,6 +55,9 @@ interface DeploymentRow { } afterAll(async () => { + if (secondDeployment !== undefined) { + await deleteDeployment(scratch, secondDeployment); + } if (deployed !== undefined) { await deleteDeployment(scratch, { id: deployed.deploymentId, @@ -145,6 +158,65 @@ describeCommand("service deployment show", () => { }); }); +describeCommand("service deployment rollback", () => { + it("rolls production back to the previously live deployment", async () => { + const existing = requireDeployed(); + // Rolling back needs somewhere to roll back from: a second + // deployment, promoted over the first. It is tracked for teardown + // before anything can throw, because `project remove` refuses while + // it exists. + const secondId = await createDeployment(existing.serviceId); + secondDeployment = { id: secondId, serviceName: existing.serviceName }; + await scratch.run([ + "service", + "deployment", + "start", + secondId, + "--service", + existing.serviceName, + ]); + await scratch.run([ + "service", + "deployment", + "promote", + secondId, + "--service", + existing.serviceName, + ]); + + // No --to: the default target is the deployment before the live + // one, which is the first. --confirm must name that target. + const run = await scratch.run([ + "service", + "deployment", + "rollback", + "--service", + existing.serviceName, + "--confirm", + existing.deploymentId, + ]); + const rolledBack = run.envelope.result as { + readonly service: { readonly id: string }; + readonly deployment: DeploymentRow; + readonly previousLiveDeploymentId: string | null; + }; + + expect(rolledBack.service.id).toBe(existing.serviceId); + expect(rolledBack.deployment.id).toBe(existing.deploymentId); + expect(rolledBack.deployment.live).toBe(true); + expect(rolledBack.previousLiveDeploymentId).toBe(secondId); + + const shown = await scratch.run([ + "service", + "deployment", + "show", + existing.deploymentId, + ]); + const after = shown.envelope.result as { deployment: DeploymentRow }; + expect(after.deployment.live).toBe(true); + }); +}); + describeCommand("service open", () => { it("answers with the service's URL rather than opening one", async () => { const existing = requireDeployed(); @@ -169,6 +241,126 @@ describeCommand("service open", () => { }); }); +/** The log lines of a `--json` run: `output` frames on the `logs` + * source's data channel, which is where the command reports each line + * the platform captured from the app. */ +function logLines(run: CliRun): string[] { + return run.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("{")) + .flatMap((line) => { + try { + return [ + JSON.parse(line) as { + kind?: string; + source?: string; + channel?: string; + line?: string; + }, + ]; + } catch { + return []; + } + }) + .filter( + (frame) => + frame.kind === "output" && + frame.source === "logs" && + frame.channel === "data" && + typeof frame.line === "string", + ) + .map((frame) => frame.line as string); +} + +function sleep(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +/** A fresh hostname does not serve on the first try — the edge is + * still setting up routing and TLS for it — so the request retries + * until the app answers. */ +async function serveProbeRequest(url: string, path: string): Promise { + const deadline = Date.now() + 60_000; + let lastAnswer: number | string = "never reached"; + for (;;) { + try { + // biome-ignore lint/performance/noAwaitInLoops: each retry decides from the previous answer; waiting between requests is the point. + const served = await fetch(`${url}${path}`); + lastAnswer = served.status; + if (served.ok) { + return; + } + } catch (failure) { + lastAnswer = failure instanceof Error ? failure.message : "error"; + } + if (Date.now() > deadline) { + throw new Error( + `the deployment at ${url} never served the probe request; ` + + `last answer: ${lastAnswer}`, + ); + } + await sleep(3000); + } +} + +/** Ingestion lags a request by some unspecified amount, so `service + * logs` is polled until `wantedLine` arrives (or the deadline passes, + * leaving the assertions to report what the last read held). */ +async function pollLogsForLine( + serviceName: string, + wantedLine: string, +): Promise { + const deadline = Date.now() + 90_000; + for (;;) { + // biome-ignore lint/performance/noAwaitInLoops: polling one page at a time is the point, as in the command's own --follow loop. + const run = await scratch.run([ + "service", + "logs", + "--service", + serviceName, + ]); + const lines = logLines(run); + if ( + lines.some((line) => line.includes(wantedLine)) || + Date.now() > deadline + ) { + return lines; + } + await sleep(5000); + } +} + +describeCommand("service logs", () => { + it("reads back what the deployment wrote while serving a request", async () => { + const existing = requireDeployed(); + // Rollback made the first deployment live again, so it is what + // `service logs` reads by default. Serve one request against it so + // there is a line whose ingestion this run can be pinned to. + const shown = await scratch.run([ + "service", + "deployment", + "show", + existing.deploymentId, + ]); + const url = (shown.envelope.result as { deployment: DeploymentRow }) + .deployment.url; + expect(url).toMatch(HTTPS_URL); + await serveProbeRequest(url as string, "/e2e-logs-probe"); + + const lines = await pollLogsForLine( + existing.serviceName, + "e2e-fixture served /e2e-logs-probe", + ); + expect(lines.some((line) => line.includes("e2e-fixture listening"))).toBe( + true, + ); + expect( + lines.some((line) => line.includes("e2e-fixture served /e2e-logs-probe")), + ).toBe(true); + }); +}); + describeCommand("service deployment stop", () => { it("stops the running deployment", async () => { const existing = requireDeployed(); diff --git a/packages/cli/e2e/service.e2e.ts b/packages/cli/e2e/service.e2e.ts index 0e48faff..5d2919f2 100644 --- a/packages/cli/e2e/service.e2e.ts +++ b/packages/cli/e2e/service.e2e.ts @@ -17,6 +17,8 @@ import { describeCommand } from "./suite"; const scratch = useScratchProject("service"); +const SERVICE_ID = /^cps_/; + let serviceName: string | undefined; let serviceId: string | undefined; @@ -47,7 +49,7 @@ describeCommand("service create", () => { }; expect(created.projectId).toBe(scratch.project().id); - expect(created.service.id).toBeTruthy(); + expect(created.service.id).toMatch(SERVICE_ID); expect(created.service.name).toBe(name); expect(created.existing).toBe(false); // No --region: the API picks the default and reports which one. diff --git a/packages/cli/tests/e2e-coverage.test.ts b/packages/cli/tests/e2e-coverage.test.ts index 07d96611..7f80142f 100644 --- a/packages/cli/tests/e2e-coverage.test.ts +++ b/packages/cli/tests/e2e-coverage.test.ts @@ -113,11 +113,6 @@ const EXCLUSIONS: Readonly> = { * starts and promotes it through the CLI. That covered seven commands, * and what is left needs something the deployment alone does not give. * - * `service deployment rollback` needs a SECOND promoted deployment to - * roll back from. The fixture makes one; making two and promoting them - * in order is more run time and more teardown, and is the next thing to - * write. - * * The five `service domain *` commands need a hostname whose DNS we * control. With a promoted deployment in place, `service domain add` * gets all the way to `SERVICE.DOMAIN_DNS_NOT_CONFIGURED` — "DNS @@ -127,16 +122,8 @@ const EXCLUSIONS: Readonly> = { * * `build logs` needs a build, which comes from a git push or a Console * action, not from anything the CLI can do. - * - * `service logs` arrived while this was being written, excluded because - * "only `composer deploy` produces" a deployment to read logs from. That - * is no longer true, so it is owed rather than excused — it needs a - * deployment that has actually served traffic, which is a little more - * than the fixture does today. */ const AWAITING_COVERAGE: readonly string[] = [ - "service deployment rollback", - "service logs", "service domain add", "service domain show", "service domain remove",