Skip to content
32 changes: 16 additions & 16 deletions .drive/projects/prisma-cli-v8/deferred.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand Down Expand Up @@ -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

Expand Down
24 changes: 18 additions & 6 deletions packages/cli/e2e/deployed-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
]);
Expand Down
196 changes: 194 additions & 2 deletions packages/cli/e2e/service-deployment.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -45,6 +55,9 @@ interface DeploymentRow {
}

afterAll(async () => {
if (secondDeployment !== undefined) {
await deleteDeployment(scratch, secondDeployment);
}
if (deployed !== undefined) {
await deleteDeployment(scratch, {
id: deployed.deploymentId,
Expand Down Expand Up @@ -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();
Expand All @@ -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<void> {
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<void> {
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<string[]> {
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();
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/e2e/service.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { describeCommand } from "./suite";

const scratch = useScratchProject("service");

const SERVICE_ID = /^cps_/;

let serviceName: string | undefined;
let serviceId: string | undefined;

Expand Down Expand Up @@ -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.
Expand Down
13 changes: 0 additions & 13 deletions packages/cli/tests/e2e-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,6 @@ const EXCLUSIONS: Readonly<Record<string, string>> = {
* 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
Expand All @@ -127,16 +122,8 @@ const EXCLUSIONS: Readonly<Record<string, string>> = {
*
* `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",
Expand Down
Loading