@prisma/compute-sdk 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Detects how an application's database schema should be applied: which
3
+ * engine owns it (Prisma ORM vs Prisma Next) and which CLI subcommand
4
+ * brings the database in line with it (`migrate deploy`, `db push`, or the
5
+ * Prisma Next init flow).
6
+ *
7
+ * Both deploy front doors share this so a git-push deploy and
8
+ * `prisma app deploy` classify a repository identically.
9
+ */
10
+ import type { Dirent } from "node:fs";
11
+ import { access, readdir, readFile, stat } from "node:fs/promises";
12
+ import path from "node:path";
13
+
14
+ export type SchemaEngine = "prisma-orm" | "prisma-next";
15
+
16
+ export type MigrationCommand =
17
+ | "migrate-deploy"
18
+ | "db-push"
19
+ | "prisma-next-init";
20
+
21
+ export type UnsupportedSchemaTarget =
22
+ | "mongodb"
23
+ | "mysql"
24
+ | "sqlite"
25
+ | "sqlserver"
26
+ | "cockroachdb";
27
+
28
+ export interface DetectedSchema {
29
+ engine: SchemaEngine;
30
+ /** Absolute path to `schema.prisma` (Prisma ORM) or the Prisma Next config. */
31
+ path: string;
32
+ command: MigrationCommand;
33
+ /** Prisma ORM only; always `false` for Prisma Next. */
34
+ hasMigrations: boolean;
35
+ target: "postgresql" | "unknown";
36
+ }
37
+
38
+ export interface UnsupportedSchema {
39
+ engine: SchemaEngine;
40
+ path: string;
41
+ target: UnsupportedSchemaTarget;
42
+ }
43
+
44
+ export interface SchemaDetection {
45
+ /** A schema whose migrations can be applied, or `null` if none was found. */
46
+ schema: DetectedSchema | null;
47
+ /** A schema targeting an engine the migration flow does not support. */
48
+ unsupported: UnsupportedSchema | null;
49
+ }
50
+
51
+ const SKIPPED_DIRECTORIES = new Set([
52
+ ".git",
53
+ ".next",
54
+ ".nuxt",
55
+ ".output",
56
+ ".prisma",
57
+ ".turbo",
58
+ ".vercel",
59
+ ".wrangler",
60
+ "build",
61
+ "coverage",
62
+ "dist",
63
+ "node_modules",
64
+ "out",
65
+ ]);
66
+ const MAX_SCAN_DEPTH = 6;
67
+ const MAX_SCAN_FILES = 1_000;
68
+ const MAX_TEXT_FILE_BYTES = 1024 * 1024;
69
+
70
+ /**
71
+ * Walks `appPath` for a Prisma ORM schema or Prisma Next config and returns
72
+ * the migration command that should run against it. Returns a `null` schema
73
+ * when none is found, and reports a non-Postgres schema under `unsupported`
74
+ * so callers can decide how to surface it.
75
+ */
76
+ export async function detectAppSchema(
77
+ appPath: string,
78
+ signal?: AbortSignal,
79
+ ): Promise<SchemaDetection> {
80
+ const state: ScanState = {
81
+ filesVisited: 0,
82
+ schemaCandidates: [],
83
+ prismaNextConfigCandidates: [],
84
+ };
85
+
86
+ await scanDirectory(appPath, 0, state, signal);
87
+
88
+ const prismaNextConfigs = await Promise.all(
89
+ state.prismaNextConfigCandidates.map((configPath) =>
90
+ classifyPrismaNextConfig(configPath, signal),
91
+ ),
92
+ );
93
+ const supportedPrismaNextConfig = selectPrismaNextConfig(
94
+ appPath,
95
+ prismaNextConfigs,
96
+ "supported",
97
+ );
98
+ const unsupportedPrismaNextConfig = selectPrismaNextConfig(
99
+ appPath,
100
+ prismaNextConfigs,
101
+ "unsupported",
102
+ );
103
+ const selectedPrismaOrmSchema = await selectPrismaOrmSchema(
104
+ appPath,
105
+ state.schemaCandidates,
106
+ signal,
107
+ );
108
+
109
+ const schema: DetectedSchema | null = supportedPrismaNextConfig
110
+ ? {
111
+ engine: "prisma-next",
112
+ path: supportedPrismaNextConfig.path,
113
+ hasMigrations: false,
114
+ command: "prisma-next-init",
115
+ target: supportedPrismaNextConfig.target,
116
+ }
117
+ : selectedPrismaOrmSchema.schema;
118
+ const unsupported: UnsupportedSchema | null = schema
119
+ ? null
120
+ : unsupportedPrismaNextConfig
121
+ ? {
122
+ engine: "prisma-next",
123
+ path: unsupportedPrismaNextConfig.path,
124
+ target: unsupportedPrismaNextConfig.target,
125
+ }
126
+ : selectedPrismaOrmSchema.unsupported;
127
+
128
+ return { schema, unsupported };
129
+ }
130
+
131
+ interface ScanState {
132
+ filesVisited: number;
133
+ schemaCandidates: string[];
134
+ prismaNextConfigCandidates: string[];
135
+ }
136
+
137
+ interface ClassifiedPrismaNextConfig {
138
+ path: string;
139
+ target: "postgresql" | "unknown" | UnsupportedSchemaTarget;
140
+ }
141
+
142
+ interface PrismaOrmSchemaSelection {
143
+ schema: DetectedSchema | null;
144
+ unsupported: UnsupportedSchema | null;
145
+ }
146
+
147
+ async function scanDirectory(
148
+ directory: string,
149
+ depth: number,
150
+ state: ScanState,
151
+ signal?: AbortSignal,
152
+ ): Promise<void> {
153
+ signal?.throwIfAborted();
154
+
155
+ if (depth > MAX_SCAN_DEPTH || state.filesVisited >= MAX_SCAN_FILES) {
156
+ return;
157
+ }
158
+
159
+ let entries: Dirent[];
160
+ try {
161
+ entries = await readdir(directory, { withFileTypes: true });
162
+ } catch (error) {
163
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
164
+ return;
165
+ }
166
+ throw error;
167
+ }
168
+ entries.sort((left, right) => left.name.localeCompare(right.name));
169
+
170
+ for (const entry of entries) {
171
+ signal?.throwIfAborted();
172
+ if (state.filesVisited >= MAX_SCAN_FILES) {
173
+ return;
174
+ }
175
+
176
+ const entryPath = path.join(directory, entry.name);
177
+ if (entry.isDirectory()) {
178
+ if (!SKIPPED_DIRECTORIES.has(entry.name)) {
179
+ await scanDirectory(entryPath, depth + 1, state, signal);
180
+ }
181
+ continue;
182
+ }
183
+
184
+ if (!entry.isFile()) {
185
+ continue;
186
+ }
187
+
188
+ state.filesVisited += 1;
189
+
190
+ if (entry.name === "schema.prisma") {
191
+ state.schemaCandidates.push(entryPath);
192
+ }
193
+
194
+ if (isPrismaNextConfigFile(entry.name)) {
195
+ state.prismaNextConfigCandidates.push(entryPath);
196
+ }
197
+ }
198
+ }
199
+
200
+ async function selectPrismaOrmSchema(
201
+ appPath: string,
202
+ candidates: string[],
203
+ signal?: AbortSignal,
204
+ ): Promise<PrismaOrmSchemaSelection> {
205
+ const sorted = sortByPreferredRelativePath(
206
+ appPath,
207
+ candidates,
208
+ "schema.prisma",
209
+ );
210
+
211
+ for (const schemaPath of sorted) {
212
+ const target = await classifyPrismaOrmSchemaTarget(schemaPath, signal);
213
+ if (target === "postgresql" || target === "unknown") {
214
+ const hasMigrations = await hasMigrationsDirectory(
215
+ path.dirname(schemaPath),
216
+ signal,
217
+ );
218
+ return {
219
+ schema: {
220
+ engine: "prisma-orm",
221
+ path: schemaPath,
222
+ hasMigrations,
223
+ command: hasMigrations ? "migrate-deploy" : "db-push",
224
+ target,
225
+ },
226
+ unsupported: null,
227
+ };
228
+ }
229
+
230
+ return {
231
+ schema: null,
232
+ unsupported: { engine: "prisma-orm", path: schemaPath, target },
233
+ };
234
+ }
235
+
236
+ return { schema: null, unsupported: null };
237
+ }
238
+
239
+ function selectPrismaNextConfig(
240
+ appPath: string,
241
+ candidates: ClassifiedPrismaNextConfig[],
242
+ mode: "supported",
243
+ ): { path: string; target: "postgresql" | "unknown" } | null;
244
+ function selectPrismaNextConfig(
245
+ appPath: string,
246
+ candidates: ClassifiedPrismaNextConfig[],
247
+ mode: "unsupported",
248
+ ): { path: string; target: UnsupportedSchemaTarget } | null;
249
+ function selectPrismaNextConfig(
250
+ appPath: string,
251
+ candidates: ClassifiedPrismaNextConfig[],
252
+ mode: "supported" | "unsupported",
253
+ ): ClassifiedPrismaNextConfig | null {
254
+ const matches = candidates.filter((candidate) => {
255
+ const isSupported =
256
+ candidate.target === "postgresql" || candidate.target === "unknown";
257
+ return mode === "supported" ? isSupported : !isSupported;
258
+ });
259
+
260
+ return (
261
+ sortByPreferredRelativePath(
262
+ appPath,
263
+ matches.map((candidate) => candidate.path),
264
+ "prisma-next.config.ts",
265
+ )
266
+ .map((candidatePath) =>
267
+ matches.find((candidate) => candidate.path === candidatePath),
268
+ )
269
+ .find((candidate): candidate is ClassifiedPrismaNextConfig =>
270
+ Boolean(candidate),
271
+ ) ?? null
272
+ );
273
+ }
274
+
275
+ function sortByPreferredRelativePath(
276
+ appPath: string,
277
+ candidates: string[],
278
+ preferredRootFile: string,
279
+ ): string[] {
280
+ return candidates
281
+ .map((candidate) => ({
282
+ absolute: candidate,
283
+ relative: path.relative(appPath, candidate) || preferredRootFile,
284
+ }))
285
+ .sort((left, right) => {
286
+ if (left.relative === preferredRootFile) return -1;
287
+ if (right.relative === preferredRootFile) return 1;
288
+ return (
289
+ left.relative.length - right.relative.length ||
290
+ left.relative.localeCompare(right.relative)
291
+ );
292
+ })
293
+ .map((candidate) => candidate.absolute);
294
+ }
295
+
296
+ async function hasMigrationsDirectory(
297
+ schemaDirectory: string,
298
+ signal?: AbortSignal,
299
+ ): Promise<boolean> {
300
+ signal?.throwIfAborted();
301
+ const migrationsPath = path.join(schemaDirectory, "migrations");
302
+
303
+ try {
304
+ await access(migrationsPath);
305
+ const entries = await readdir(migrationsPath);
306
+ return entries.length > 0;
307
+ } catch (error) {
308
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
309
+ return false;
310
+ }
311
+ throw error;
312
+ }
313
+ }
314
+
315
+ async function classifyPrismaNextConfig(
316
+ configPath: string,
317
+ signal?: AbortSignal,
318
+ ): Promise<ClassifiedPrismaNextConfig> {
319
+ const content = await readTextFileIfSmall(configPath, signal);
320
+ if (!content) {
321
+ return { path: configPath, target: "unknown" };
322
+ }
323
+
324
+ if (content.includes("@prisma-next/postgres/config")) {
325
+ return { path: configPath, target: "postgresql" };
326
+ }
327
+ if (content.includes("@prisma-next/mongo/config")) {
328
+ return { path: configPath, target: "mongodb" };
329
+ }
330
+ if (content.includes("@prisma-next/sqlite/config")) {
331
+ return { path: configPath, target: "sqlite" };
332
+ }
333
+
334
+ return { path: configPath, target: "unknown" };
335
+ }
336
+
337
+ async function classifyPrismaOrmSchemaTarget(
338
+ schemaPath: string,
339
+ signal?: AbortSignal,
340
+ ): Promise<"postgresql" | "unknown" | UnsupportedSchemaTarget> {
341
+ const content = await readTextFileIfSmall(schemaPath, signal);
342
+ const provider = content?.match(/\bprovider\s*=\s*"([^"]+)"/)?.[1] ?? null;
343
+
344
+ switch (provider) {
345
+ case "postgresql":
346
+ return "postgresql";
347
+ case "mongodb":
348
+ return "mongodb";
349
+ case "mysql":
350
+ return "mysql";
351
+ case "sqlite":
352
+ return "sqlite";
353
+ case "sqlserver":
354
+ return "sqlserver";
355
+ case "cockroachdb":
356
+ return "cockroachdb";
357
+ default:
358
+ return "unknown";
359
+ }
360
+ }
361
+
362
+ function isPrismaNextConfigFile(fileName: string): boolean {
363
+ if (!fileName.startsWith("prisma-next.config.")) {
364
+ return false;
365
+ }
366
+
367
+ return [".cjs", ".cts", ".js", ".mjs", ".mts", ".ts"].some((extension) =>
368
+ fileName.endsWith(extension),
369
+ );
370
+ }
371
+
372
+ async function readTextFileIfSmall(
373
+ filePath: string,
374
+ signal?: AbortSignal,
375
+ ): Promise<string | null> {
376
+ signal?.throwIfAborted();
377
+
378
+ const info = await stat(filePath);
379
+ if (info.size > MAX_TEXT_FILE_BYTES) {
380
+ return null;
381
+ }
382
+
383
+ return readFile(filePath, { encoding: "utf8", signal });
384
+ }
package/src/index.ts CHANGED
@@ -7,6 +7,11 @@ export {
7
7
  type Result,
8
8
  TaggedError,
9
9
  } from "better-result";
10
+ export type {
11
+ ApplyMigrationsOptions,
12
+ ApplyMigrationsResult,
13
+ } from "./apply-migrations.ts";
14
+ export { applyMigrations } from "./apply-migrations.ts";
10
15
  export { createArchive } from "./archive.ts";
11
16
  export { normalizeArtifactSymlinks } from "./artifact-postprocess.ts";
12
17
  export { AstroBuild } from "./astro-build.ts";
@@ -47,6 +52,15 @@ export type {
47
52
  UpdateEnvResult,
48
53
  } from "./compute-client.ts";
49
54
  export { ComputeClient } from "./compute-client.ts";
55
+ export type {
56
+ DetectedSchema,
57
+ MigrationCommand,
58
+ SchemaDetection,
59
+ SchemaEngine,
60
+ UnsupportedSchema,
61
+ UnsupportedSchemaTarget,
62
+ } from "./detect-schema.ts";
63
+ export { detectAppSchema } from "./detect-schema.ts";
50
64
  export type {
51
65
  ApiRequestError,
52
66
  DeployError,