| 1 | // Shared scaffolding for the Prisma ORM v8 deploy-time resources. |
| 2 | // NOT exported from the ORM index — service-internal only. |
| 3 | import * as Data from "effect/Data"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Path from "effect/Path"; |
| 7 | import { ChildProcess } from "effect/process"; |
| 8 | import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; |
| 9 | import { 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 | */ |
| 21 | export 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 | */ |
| 36 | export 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 | */ |
| 63 | export 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). */ |
| 121 | interface 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 | */ |
| 136 | interface 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 | */ |
| 150 | const 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 | */ |
| 174 | export 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. */ |
| 234 | export 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. */ |
| 246 | export 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. */ |
| 259 | export 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). */ |
| 275 | export 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 | */ |
| 294 | const 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 | */ |
| 308 | export 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 | */ |
| 344 | export 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 | */ |
| 356 | export 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 | */ |
| 404 | export 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 | }; |