Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,47 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
windows-smoke:
name: Windows creation smoke
if: >-
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository &&
(github.event.action != 'labeled' || github.event.label.name == 'release:next')
runs-on: windows-latest
timeout-minutes: 20
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false

- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "22.18.0"

- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: latest

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Scaffold and build a Prisma app
env:
CREATE_PRISMA_E2E_TIMEOUT_MS: "600000"
run: >-
bun test --timeout 600000
Comment thread
coderabbitai[bot] marked this conversation as resolved.
--test-name-pattern "builds a Next.js app with a TypeScript-authored contract"
./tests/e2e/create-prisma.e2e.test.ts

preview:
name: Publish PR preview
needs: windows-smoke
if: >-
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository &&
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ yarn dlx create-prisma@latest my-app
bunx create-prisma@latest my-app
```

The CLI initializes Prisma 8 with `prisma@next`, installs dependencies, emits the contract, and generates a deployable Composer app. PostgreSQL projects use Composer's native Prisma Postgres provider, including migrations and a typed runtime client.
The CLI initializes Prisma 8 with `prisma@latest`, installs dependencies, emits the contract, and generates a deployable Composer app. PostgreSQL projects use Composer's native Prisma Postgres provider, including migrations and a typed runtime client.

The deployment prompt is:

Expand Down
18 changes: 6 additions & 12 deletions src/constants/dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,19 @@ export const dependencyVersionMap = {
mongodb: "^7.1.0",
"mongodb-memory-server": "^11.1.0",
nitro: "^3.0.260610-beta",
prisma: "8.0.0-rc.12",
prisma: "latest",
// The ORM runtime's timestamp columns need a global Temporal, which no
// stable Node or Bun ships yet.
"temporal-polyfill": "^1.0.4",
tsx: "^4.21.0",
typescript: "^5.9.3",
} as const;

// Pinned, not `prisma@next`: the scaffold's own invocations must not float
// with the dist-tag — the rc line ships breaking changes between releases
// (rc.10 broke every create). The pin must move in lockstep with the pins
// above: the CLI bundles its own copies of @prisma/composer-cli and
// @prisma/orm-toolchain, and those must match the @prisma/composer* and
// @prisma/orm-* versions this map installs into the project.
export const PRISMA_PLATFORM_CLI_PACKAGE = "prisma@8.0.0-rc.12";
// Deno runs the same pinned consolidated CLI. The former `prisma-next`
// fallback is dead: under Deno the bare `npm:prisma-next` specifier resolves
// to the highest non-prerelease version (0.12.0, frozen), which cannot emit
// against the ORM releases pinned above.
// `prisma@next` is a compatibility tag that may intentionally lag behind the
// current release. New scaffolds and every delegated command use `latest`.
export const PRISMA_PLATFORM_CLI_PACKAGE = "prisma@latest";
// Deno runs the same consolidated CLI. The former `prisma-next` fallback is
// frozen and cannot emit against the current ORM releases.
export const PRISMA_DENO_CLI_PACKAGE = PRISMA_PLATFORM_CLI_PACKAGE;

export type AvailableDependency = keyof typeof dependencyVersionMap;
Expand Down
27 changes: 23 additions & 4 deletions src/tasks/deploy-with-composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,24 @@ import { runSetupCommand } from "../utils/run-command";

type PrismaCliEnvelope<Result = unknown> = {
ok: boolean;
command?: string;
commandId?: string;
result?: Result;
error?: { summary?: string; message?: string; why?: string };
error?: { code?: string; summary?: string; message?: string; why?: string };
};

export class PrismaCliCommandError extends Error {
readonly prismaCliCommand?: string;
readonly prismaCliErrorCode?: string;

constructor(options: { message: string; command?: string; code?: string }) {
super(options.message);
this.name = "PrismaCliCommandError";
this.prismaCliCommand = options.command;
this.prismaCliErrorCode = options.code;
}
}

type PrismaWorkspace = {
id: string;
name: string | null;
Expand Down Expand Up @@ -175,11 +189,16 @@ async function runPrismaJsonCommand<Result>(options: {

if (result.exitCode !== 0 || !envelope.ok || envelope.result === undefined) {
const summary = envelope.error?.summary ?? envelope.error?.message;
throw new Error(
[summary, envelope.error?.why].filter(Boolean).join(": ") ||
throw new PrismaCliCommandError({
message:
[summary, envelope.error?.why].filter(Boolean).join(": ") ||
result.stderr.trim() ||
"Prisma CLI command failed.",
);
...(envelope.commandId || envelope.command
? { command: envelope.commandId ?? envelope.command }
: {}),
...(envelope.error?.code ? { code: envelope.error.code } : {}),
});
}
return envelope.result;
}
Expand Down
32 changes: 32 additions & 0 deletions src/telemetry/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,23 @@ export const CREATE_PRISMA_NEXT_CANCELLED_EVENT = "cli:create_prisma_next_comman

export type CreateTelemetryFailureStage = CreateFailureStage;

const expectedRejectionReasons = new Set<CreateFailureReason>([
"invalid_input",
"unsupported_node_version",
"invalid_project_name",
"target_path_not_directory",
"target_directory_not_empty",
"unsupported_configuration",
"not_authenticated",
"workspace_missing",
"workspace_mismatch",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"project_name_collision",
]);

function getFailureClass(reason: CreateFailureReason): "expected_rejection" | "technical_failure" {
return expectedRejectionReasons.has(reason) ? "expected_rejection" : "technical_failure";
}

function getTargetDirectoryState(context: CreatePromptContext): string {
if (!context.targetPathState.exists) {
return "new";
Expand Down Expand Up @@ -68,6 +85,18 @@ function getErrorCode(error: unknown): number | string | null {
return typeof code === "number" || typeof code === "string" ? code : null;
}

function getPrismaCliFailureProperty(
error: unknown,
property: "prismaCliCommand" | "prismaCliErrorCode",
): string | null {
if (typeof error !== "object" || error === null) {
return null;
}

const value = Reflect.get(error, property);
return typeof value === "string" && value.length > 0 ? value : null;
}

export async function trackCreateCompleted(params: {
input: CreateCommandInput;
context: CreatePromptContext;
Expand All @@ -90,10 +119,13 @@ export async function trackCreateFailed(params: {
await trackCliTelemetry(CREATE_PRISMA_NEXT_FAILED_EVENT, {
...getBaseCreateProperties(params.input, params.context),
"duration-ms": params.durationMs,
"failure-class": getFailureClass(params.reason),
"failure-stage": params.stage,
"failure-reason": params.reason,
"error-name": getErrorName(params.error),
"error-code": getErrorCode(params.error),
"prisma-cli-command": getPrismaCliFailureProperty(params.error, "prismaCliCommand"),
"prisma-cli-error-code": getPrismaCliFailureProperty(params.error, "prismaCliErrorCode"),
});
}

Expand Down
2 changes: 1 addition & 1 deletion src/utils/node-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function supportsPrisma(nodeVersion = process.versions.node): boolean {

export function getUnsupportedNodeMessage(nodeVersion = process.versions.node): string {
return [
`Node.js ${nodeVersion} is unsupported by create-prisma@next.`,
`Node.js ${nodeVersion} is unsupported by create-prisma@latest.`,
"Required: Node.js 22.18 or newer.",
"Update Node.js and run the command again.",
].join("\n");
Expand Down
2 changes: 1 addition & 1 deletion tests/dependencies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ describe("Prisma 8 dependency versions", () => {
expect(getDependencyVersion("@prisma/orm-mongo")).toBe("8.0.0-rc.8");
expect(getDependencyVersion("@prisma/composer")).toBe("0.16.0");
expect(getDependencyVersion("@prisma/composer-prisma-cloud")).toBe("0.16.0");
expect(getDependencyVersion("prisma")).toBe("8.0.0-rc.12");
expect(getDependencyVersion("prisma")).toBe("latest");
expect(getDependencyVersion("alchemy")).toBe("2.0.0-beta.74");
expect(getDependencyVersion("effect")).toBe("4.0.0-rc.112");
});
Expand Down
41 changes: 41 additions & 0 deletions tests/deploy-with-composer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getConsoleProjectUrl,
parseComposerDeployResult,
parsePrismaCliEnvelope,
PrismaCliCommandError,
} from "../src/tasks/deploy-with-composer";
import { getErrorMessage, redactSecrets } from "../src/utils/errors";

Expand Down Expand Up @@ -91,6 +92,46 @@ describe("parsePrismaCliEnvelope", () => {
),
).toEqual({ ok: true, result: { summary: null } });
});

test("preserves stable command and error codes from a failure envelope", () => {
const envelope = parsePrismaCliEnvelope(
JSON.stringify({
kind: "result",
envelope: {
ok: false,
commandId: "app.deploy",
error: {
code: "APP.DEPLOY_FAILED",
summary: "Deployment failed",
why: "The compute service was not created",
},
},
}),
);

expect(envelope).toMatchObject({
ok: false,
commandId: "app.deploy",
error: { code: "APP.DEPLOY_FAILED" },
});
});
});

describe("PrismaCliCommandError", () => {
test("exposes only stable structured fields for telemetry", () => {
const error = new PrismaCliCommandError({
message: "Deployment failed",
command: "app.deploy",
code: "APP.DEPLOY_FAILED",
});

expect(error).toMatchObject({
name: "PrismaCliCommandError",
message: "Deployment failed",
prismaCliCommand: "app.deploy",
prismaCliErrorCode: "APP.DEPLOY_FAILED",
});
});
});

describe("parseComposerDeployResult", () => {
Expand Down
10 changes: 9 additions & 1 deletion tests/e2e/create-prisma.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ describe("create-prisma e2e", () => {
expect(
await pathExists(path.join(projectDir, ".claude/skills/prisma-composer/SKILL.md")),
).toBe(true);
expect(packageJson.devDependencies.prisma).toBe("8.0.0-rc.12");
expect(packageJson.devDependencies.prisma).toBe("latest");
expect(packageJson.scripts.postinstall).toBe("prisma skills sync || exit 0");
expect(packageJson.scripts.deploy).toContain("bun run composer:deploy");
expect(packageJson.overrides.effect).toBe("4.0.0-rc.112");
Expand Down Expand Up @@ -420,6 +420,14 @@ describe("create-prisma e2e", () => {
expect(await pathExists(path.join(projectDir, "src/prisma/generated/contract.d.ts"))).toBe(
true,
);
expect(await pathExists(path.join(projectDir, "prisma.config.ts"))).toBe(true);
expect(await pathExists(path.join(projectDir, "migrations/app"))).toBe(true);
expect(
await pathExists(path.join(projectDir, ".agents/skills/prisma-composer/SKILL.md")),
).toBe(true);
expect(
await pathExists(path.join(projectDir, ".claude/skills/prisma-composer/SKILL.md")),
).toBe(true);

await runCommand(projectDir, ["bun", "run", "build"]);
await runCommand(projectDir, ["bunx", "tsc", "--noEmit"]);
Expand Down
44 changes: 44 additions & 0 deletions tests/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ describe("create telemetry", () => {
expect(properties).toEqual(
expect.objectContaining({
"duration-ms": 456,
"failure-class": "technical_failure",
"error-code": "ERR_TEST",
"failure-stage": "plan_migration",
"failure-reason": "migration_plan_failed",
Expand All @@ -78,6 +79,49 @@ describe("create telemetry", () => {
expect(JSON.stringify(properties)).not.toContain("secret");
});

test("separates expected input and environment rejections from technical failures", async () => {
for (const reason of ["target_directory_not_empty", "workspace_missing"] as const) {
await trackCreateFailed({
input: createInput,
context: createContext,
durationMs: 10,
stage: reason === "workspace_missing" ? "select_workspace" : "collect_context",
reason,
});
}
const calls = trackCliTelemetry.mock.calls as Array<[string, Record<string, unknown>]>;
expect(calls).toHaveLength(2);
expect(calls.map(([, properties]) => properties["failure-reason"])).toEqual([
"target_directory_not_empty",
"workspace_missing",
]);
for (const [, properties] of calls) {
expect(properties["failure-class"]).toBe("expected_rejection");
}
});

test("tracks stable Prisma CLI failure fields without raw output", async () => {
await trackCreateFailed({
input: createInput,
context: createContext,
durationMs: 456,
error: Object.assign(new Error("token=secret"), {
prismaCliCommand: "app.deploy",
prismaCliErrorCode: "APP.DEPLOY_FAILED",
}),
stage: "composer_deploy",
reason: "composer_deploy_failed",
});
const [, properties] = trackCliTelemetry.mock.calls[0] as [string, Record<string, unknown>];
expect(properties).toEqual(
expect.objectContaining({
"prisma-cli-command": "app.deploy",
"prisma-cli-error-code": "APP.DEPLOY_FAILED",
}),
);
expect(JSON.stringify(properties)).not.toContain("secret");
});

test("tracks prompt cancellation as a separate outcome", async () => {
await trackCreateCancelled({
input: createInput,
Expand Down
Loading