1// Shared scaffolding for the Prisma ORM v8 deploy-time resources.
2// NOT exported from the ORM index — service-internal only.
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import { ChildProcess } from "effect/process";
8import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner";
9import { exec } from "../../Util/exec.ts";
10
11/**
12 * A structured failure from the `prisma` CLI.
13 *
14 * In `--json` mode the CLI streams newline-delimited events and closes with
15 * a `{ kind: "result", envelope }` document. A failed envelope carries a
16 * stable `code` (e.g. `CONFIG.FILE_NOT_FOUND`, `CONTRACT.VERIFY_FAILED`)
17 * plus human guidance in `summary`/`why`/`nextActions`. Those fields are
18 * surfaced verbatim so callers can match on `code` and users get the CLI's
19 * own remediation text.
20 */
21export class CliError extends Data.TaggedError("Prisma.CliError")<{
22 /** Stable machine code reported by the CLI (e.g. `CONTRACT.VERIFY_FAILED`). */
23 code?: string | undefined;
24 message: string;
25 /** The CLI's suggested remediation, when it reported one. */
26 fix?: string | undefined;
27 /** Extra structured detail from the CLI (e.g. host/port on connection errors). */
28 meta?: Record<string, unknown> | undefined;
29}> {}
30
31/**
32 * Connection-flavored failures worth retrying: a database that was
33 * provisioned earlier in the same deploy may not be accepting connections
34 * yet (eventual consistency), so `Prisma.Migrate` retries these briefly.
35 */
36export const isTransientDbError = (error: CliError): boolean => {
37 if (error.code === "DRIVER.CONNECTION_FAILED") return true;
38 const meta = error.meta ?? {};
39 // The CLI reports the driver's errno under `sqlState` for connection
40 // failures (`ECONNREFUSED`, ...) and a real SQLSTATE for statement ones.
41 const errno = [meta.sqlState, meta.code].find(
42 (value) => typeof value === "string",
43 );
44 return (
45 [
46 "ECONNREFUSED",
47 "ECONNRESET",
48 "ENOTFOUND",
49 "ETIMEDOUT",
50 "EAI_AGAIN",
51 ].includes(errno as string) ||
52 /connection|timeout|reachable/i.test(error.message)
53 );
54};
55
56/**
57 * Resolve the unified `prisma` CLI entrypoint (`prisma` v8 mounts the ORM
58 * commands — `contract emit`, `migration plan`, `db migrate` — that the
59 * retired `prisma-next` bin used to serve). The package is an optional peer,
60 * so it resolves out of the *user's* project and their pinned CLI version is
61 * the one that runs.
62 */
63export const resolvePrismaCliBin = Effect.gen(function* () {
64 const fs = yield* FileSystem.FileSystem;
65 const path = yield* Path.Path;
66 const url = yield* Effect.try({
67 try: () => import.meta.resolve("prisma/package.json"),
68 catch: (cause) =>
69 new CliError({
70 message: `Failed to resolve the "prisma" CLI (is it installed? \`npm i -D prisma\`): ${cause}`,
71 }),
72 });
73 const manifestPath = yield* Effect.try({
74 try: () => new URL(url),
75 catch: (cause) =>
76 new CliError({
77 message: `Failed to parse the "prisma" package URL: ${cause}`,
78 }),
79 }).pipe(
80 Effect.flatMap((fileUrl) =>
81 path.fromFileUrl(fileUrl).pipe(
82 Effect.mapError(
83 (cause) =>
84 new CliError({
85 message: `Failed to convert the "prisma" package URL to a path: ${cause}`,
86 }),
87 ),
88 ),
89 ),
90 );
91 const manifest = yield* fs.readFileString(manifestPath).pipe(
92 Effect.flatMap((text) =>
93 Effect.try({
94 try: () =>
95 JSON.parse(text) as { bin?: Record<string, string> | string },
96 catch: (cause) =>
97 new CliError({
98 message: `Failed to parse ${manifestPath}: ${cause}`,
99 }),
100 }),
101 ),
102 Effect.mapError((cause) =>
103 cause instanceof CliError
104 ? cause
105 : new CliError({ message: `Failed to read ${manifestPath}: ${cause}` }),
106 ),
107 );
108 const bin =
109 typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.prisma;
110 if (bin === undefined) {
111 return yield* Effect.fail(
112 new CliError({
113 message: `The installed "prisma" package declares no \`prisma\` bin (${manifestPath}).`,
114 }),
115 );
116 }
117 return path.resolve(path.dirname(manifestPath), bin);
118});
119
120/** The CLI's failure payload (`envelope.error` in `--json` mode). */
121interface CliFailure {
122 code?: string;
123 severity?: string;
124 summary?: string;
125 why?: string;
126 nextActions?: Array<{ kind?: string; label?: string }>;
127 meta?: Record<string, unknown>;
128 docsUrl?: string;
129}
130
131/**
132 * The terminal `{ kind: "result" }` event the CLI closes a `--json` run
133 * with. `ok` discriminates the payload: `result` for success, `error` for
134 * failure.
135 */
136interface CliEnvelope {
137 ok: boolean;
138 commandId?: string;
139 result?: unknown;
140 error?: CliFailure;
141 exitCode?: number;
142 nextActions?: Array<{ kind?: string; label?: string }>;
143}
144
145/**
146 * Scan the CLI's newline-delimited JSON stream for the terminal result
147 * envelope. Progress events (`step-started`, `step-finished`, ...) and any
148 * non-JSON banner lines are skipped.
149 */
150const parseEnvelope = (stdout: string): CliEnvelope | undefined => {
151 let envelope: CliEnvelope | undefined;
152 for (const line of stdout.split("\n")) {
153 const text = line.trim();
154 if (!text.startsWith("{")) continue;
155 let event: { kind?: string; envelope?: CliEnvelope };
156 try {
157 event = JSON.parse(text);
158 } catch {
159 continue;
160 }
161 if (event.kind === "result" && event.envelope !== undefined) {
162 envelope = event.envelope;
163 }
164 }
165 return envelope;
166};
167
168/**
169 * Run a `prisma` subcommand with `--json --no-interactive` from `cwd` (the
170 * directory containing `prisma.config.ts` — all config-relative paths
171 * resolve against it, matching a user running the CLI in their project) and
172 * parse the structured result envelope.
173 */
174export const runPrismaCli = <T>(
175 args: readonly string[],
176 options: { readonly cwd: string },
177): Effect.Effect<
178 T,
179 CliError,
180 FileSystem.FileSystem | Path.Path | ChildProcessSpawner
181> =>
182 Effect.gen(function* () {
183 const bin = yield* resolvePrismaCliBin;
184 const nodeExecPath = yield* Effect.sync(() => process.execPath);
185 const result = yield* exec(
186 ChildProcess.make(
187 nodeExecPath,
188 [bin, ...args, "--json", "--no-interactive"],
189 {
190 cwd: options.cwd,
191 env: { PRISMA_DISABLE_TELEMETRY: "1", DO_NOT_TRACK: "1" },
192 extendEnv: true,
193 },
194 ),
195 ).pipe(
196 // exec fully drains stdout/stderr and the exit code before returning,
197 // so the process handle's scope can close right here.
198 Effect.scoped,
199 Effect.mapError(
200 (cause) =>
201 new CliError({
202 message: `prisma ${args.slice(0, 2).join(" ")} failed to spawn: ${String(cause)}`,
203 }),
204 ),
205 );
206
207 const envelope = yield* Effect.sync(() => parseEnvelope(result.stdout));
208
209 if (envelope?.ok === true) {
210 return envelope.result as T;
211 }
212 if (envelope?.ok === false) {
213 const error = envelope.error ?? {};
214 const fix = (error.nextActions ?? envelope.nextActions ?? []).find(
215 (action) => typeof action.label === "string",
216 )?.label;
217 return yield* Effect.fail(
218 new CliError({
219 code: error.code,
220 message: `prisma ${args.slice(0, 2).join(" ")} failed: ${[error.summary, error.why].filter(Boolean).join(" — ") || "unknown error"}`,
221 fix,
222 meta: error.meta,
223 }),
224 );
225 }
226 return yield* Effect.fail(
227 new CliError({
228 message: `prisma ${args.slice(0, 2).join(" ")} failed (exit ${result.exitCode}): ${result.stdout}\n${result.stderr}`,
229 }),
230 );
231 });
232
233/** `prisma contract emit --json` result. */
234export interface EmitResult {
235 ok: true;
236 /** Canonical hash of the emitted contract's storage shape — the contract identity the migration graph and DB marker track. */
237 storageHash: string;
238 /** Only emitted for TypeScript-authored contracts. */
239 executionHash?: string;
240 profileHash: string;
241 outDir: string;
242 files: { json: string; dts: string };
243}
244
245/** `prisma migration plan --json` result. */
246export interface PlanResult {
247 ok: true;
248 noOp: boolean;
249 from: string | null;
250 to: string;
251 /** cwd-relative directory of the written migration package (absent when `noOp`). */
252 dir?: string;
253 /** True when the plan wrote `placeholder(...)` sentinels that need a human decision. */
254 pendingPlaceholders?: boolean;
255 operations: Array<{ id: string; label: string; operationClass: string }>;
256}
257
258/** `prisma db migrate --json` result. */
259export interface MigrateResult {
260 ok: true;
261 migrationsApplied: number;
262 migrationsTotal: number;
263 /** The DB marker's contract hash after the run. */
264 markerHash: string;
265 applied: Array<{
266 spaceId: string;
267 dirName: string;
268 migrationHash: string;
269 from: string;
270 to: string;
271 }>;
272}
273
274/** `prisma db migrate --show --json` result (read-only preview). */
275export interface MigrateShowResult {
276 ok: true;
277 migrations: Array<{
278 spaceId: string;
279 dirName: string;
280 migrationHash: string;
281 from: string;
282 to: string;
283 }>;
284}
285
286/**
287 * Prisma ORM v8 bug workaround (through 8.0.0-rc.8): `contract emit` writes
288 * the Prisma monorepo's `@internal/*` aliases into `contract.d.ts` instead of
289 * the public `@prisma/orm-*` packages. Those packages are not published, so
290 * the emitted types would not resolve in a user project.
291 * Longest-prefix-first so `sql-contract` is not eaten by `contract`.
292 * `@internal/extension-<name>/` maps to `@prisma/orm-extension-<name>/`.
293 */
294const INTERNAL_SPECIFIER_REWRITES: ReadonlyArray<readonly [string, string]> = [
295 ["@internal/adapter-postgres/", "@prisma/orm-postgres/adapter/"],
296 ["@internal/target-postgres/", "@prisma/orm-postgres/target/"],
297 ["@internal/sql-contract/", "@prisma/orm-postgres/family-contract/"],
298 ["@internal/contract/", "@prisma/orm-postgres/contract/"],
299 ["@internal/extension-", "@prisma/orm-extension-"],
300];
301
302/**
303 * Rewrite leaked `@internal/*` specifiers in an emitted `contract.d.ts` to
304 * their public `@prisma/orm-*` equivalents. A no-op for PSL-authored emits
305 * (which never contain them); fails actionably if an unmapped `@internal/*`
306 * specifier remains.
307 */
308export const rewriteEmittedTypes = (dtsPath: string) =>
309 Effect.gen(function* () {
310 const fs = yield* FileSystem.FileSystem;
311 const exists = yield* fs.exists(dtsPath);
312 if (!exists) return;
313 const original = yield* fs.readFileString(dtsPath);
314 if (!original.includes("@internal/")) return;
315 let rewritten = original;
316 for (const [internal, publicPrefix] of INTERNAL_SPECIFIER_REWRITES) {
317 rewritten = rewritten.replaceAll(internal, publicPrefix);
318 }
319 if (rewritten.includes("@internal/")) {
320 const leftover = rewritten
321 .split("\n")
322 .find((line) => line.includes("@internal/"));
323 return yield* Effect.fail(
324 new CliError({
325 message: [
326 `Emitted ${dtsPath} references an unpublished @internal/* module this`,
327 `integration does not know how to map yet: ${leftover?.trim()}`,
328 "Please report this — the emitted types will not resolve until it is mapped.",
329 ].join("\n"),
330 }),
331 );
332 }
333 yield* fs.writeFileString(dtsPath, rewritten);
334 });
335
336/**
337 * One on-disk migration package under `{migrationsDir}/app/`.
338 *
339 * `opsEmpty` distinguishes a fully-emitted package (`ops.json` holds the
340 * rendered operations) from one still carrying unfilled `placeholder(...)`
341 * closures — `migration plan` writes `ops.json` as `[]` until the user edits
342 * `migration.ts` and self-emits it.
343 */
344export interface MigrationPackage {
345 dirName: string;
346 from: string | null;
347 to: string;
348 migrationHash: string;
349 opsEmpty: boolean;
350}
351
352/**
353 * Read every migration package in the app contract space, sorted by
354 * directory name (timestamp-prefixed).
355 */
356export const readMigrationPackages = (migrationsDirAbs: string) =>
357 Effect.gen(function* () {
358 const fs = yield* FileSystem.FileSystem;
359 const path = yield* Path.Path;
360 const appDir = path.join(migrationsDirAbs, "app");
361 const exists = yield* fs.exists(appDir);
362 if (!exists) return [] as MigrationPackage[];
363 const entries = (yield* fs.readDirectory(appDir)).sort();
364 const packages: MigrationPackage[] = [];
365 for (const dirName of entries) {
366 const metaPath = path.join(appDir, dirName, "migration.json");
367 const hasMeta = yield* fs.exists(metaPath);
368 if (!hasMeta) continue;
369 const meta = yield* fs.readFileString(metaPath).pipe(
370 Effect.flatMap((text) =>
371 Effect.try({
372 try: () =>
373 JSON.parse(text) as {
374 from: string | null;
375 to: string;
376 migrationHash: string;
377 },
378 catch: (cause) =>
379 new CliError({
380 message: `Failed to parse ${metaPath}: ${cause}`,
381 }),
382 }),
383 ),
384 );
385 const opsPath = path.join(appDir, dirName, "ops.json");
386 const hasOps = yield* fs.exists(opsPath);
387 const opsText = hasOps ? yield* fs.readFileString(opsPath) : "[]";
388 packages.push({
389 dirName,
390 from: meta.from,
391 to: meta.to,
392 migrationHash: meta.migrationHash,
393 opsEmpty: opsText.trim() === "[]" || opsText.trim() === "",
394 });
395 }
396 return packages;
397 });
398
399/**
400 * Resolve the head of the migration graph: the `to` hash no other package
401 * plans *from*. Directory-name order breaks ties (branchy graphs from
402 * merges); an empty graph has no head.
403 */
404export const resolveGraphHead = (
405 packages: readonly MigrationPackage[],
406): MigrationPackage | undefined => {
407 const froms = new Set(
408 packages.flatMap((pkg) => (pkg.from === null ? [] : [pkg.from])),
409 );
410 const heads = packages.filter((pkg) => !froms.has(pkg.to));
411 return heads.at(-1);
412};