@prisma/compute-sdk 0.22.0 → 0.23.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,569 @@
1
+ import path from "node:path";
2
+
3
+ import { Result, TaggedError } from "better-result";
4
+
5
+ import { frameworkByKey, isConfigBackedBuildType } from "./frameworks.ts";
6
+ import { COMPUTE_FRAMEWORKS, type ComputeFramework } from "./types.ts";
7
+
8
+ export interface ComputeDeployTargetBuild {
9
+ /** Build command, null to skip the build step, undefined when not configured. */
10
+ command: string | null | undefined;
11
+ /** Normalized output path relative to the app root, undefined when not configured. */
12
+ outputDirectory: string | undefined;
13
+ }
14
+
15
+ export interface ComputeDeployTarget {
16
+ /** `apps` map key, or null for a single-app `app` config. */
17
+ key: string | null;
18
+ name: string | null;
19
+ /** Normalized app directory relative to the config file, or null for the config directory. */
20
+ root: string | null;
21
+ framework: ComputeFramework | null;
22
+ entry: string | null;
23
+ httpPort: number | null;
24
+ /** Env inputs in deploy order: dotenv file paths first, then NAME=VALUE assignments. */
25
+ envInputs: string[];
26
+ /** Build settings; non-null means the config owns build configuration. */
27
+ build: ComputeDeployTargetBuild | null;
28
+ }
29
+
30
+ export interface LoadedComputeConfig {
31
+ configPath: string;
32
+ /** Directory containing the config file. Config-relative paths resolve from here. */
33
+ configDir: string;
34
+ relativeConfigPath: string;
35
+ kind: "single" | "multi";
36
+ targets: ComputeDeployTarget[];
37
+ }
38
+
39
+ export class ComputeConfigInvalidError extends TaggedError(
40
+ "ComputeConfigInvalidError",
41
+ )<{
42
+ message: string;
43
+ configPath: string;
44
+ issues: string[];
45
+ }>() {
46
+ constructor(configPath: string, issues: string[]) {
47
+ super({
48
+ message: `${path.basename(configPath)} is invalid: ${issues.join(" ")}`,
49
+ configPath,
50
+ issues,
51
+ });
52
+ }
53
+ }
54
+
55
+ export class ComputeConfigTargetRequiredError extends TaggedError(
56
+ "ComputeConfigTargetRequiredError",
57
+ )<{
58
+ message: string;
59
+ configPath: string;
60
+ availableTargets: string[];
61
+ }>() {
62
+ constructor(configPath: string, availableTargets: string[]) {
63
+ super({
64
+ message: `${path.basename(configPath)} defines multiple apps. Pass a target: ${availableTargets.join(", ")}.`,
65
+ configPath,
66
+ availableTargets,
67
+ });
68
+ }
69
+ }
70
+
71
+ export class ComputeConfigTargetUnknownError extends TaggedError(
72
+ "ComputeConfigTargetUnknownError",
73
+ )<{
74
+ message: string;
75
+ configPath: string;
76
+ requestedTarget: string;
77
+ availableTargets: string[];
78
+ }>() {
79
+ constructor(
80
+ configPath: string,
81
+ requestedTarget: string,
82
+ availableTargets: string[],
83
+ ) {
84
+ super({
85
+ message: `${path.basename(configPath)} does not define an app named "${requestedTarget}". Available: ${availableTargets.join(", ")}.`,
86
+ configPath,
87
+ requestedTarget,
88
+ availableTargets,
89
+ });
90
+ }
91
+ }
92
+
93
+ export type ComputeConfigTargetError =
94
+ | ComputeConfigTargetRequiredError
95
+ | ComputeConfigTargetUnknownError;
96
+
97
+ const KNOWN_APP_KEYS = [
98
+ "name",
99
+ "root",
100
+ "framework",
101
+ "entry",
102
+ "httpPort",
103
+ "env",
104
+ "build",
105
+ ] as const;
106
+ const KNOWN_ENV_KEYS = ["file", "vars"] as const;
107
+ const KNOWN_BUILD_KEYS = ["command", "outputDirectory"] as const;
108
+
109
+ /**
110
+ * Validates and normalizes a config module's default export. Reports every
111
+ * issue at once so authors fix the file in one pass.
112
+ */
113
+ export function normalizeComputeConfig(
114
+ exported: unknown,
115
+ configPath: string,
116
+ ): Result<LoadedComputeConfig, ComputeConfigInvalidError> {
117
+ const issues: string[] = [];
118
+ const targets: ComputeDeployTarget[] = [];
119
+ let kind: LoadedComputeConfig["kind"] = "single";
120
+
121
+ if (!isPlainObject(exported)) {
122
+ issues.push(
123
+ "The config must `export default defineComputeConfig({ ... })` with an object value.",
124
+ );
125
+ } else {
126
+ const hasApp = exported.app !== undefined;
127
+ const hasApps = exported.apps !== undefined;
128
+
129
+ for (const key of Object.keys(exported)) {
130
+ if (key !== "app" && key !== "apps") {
131
+ issues.push(
132
+ `Unknown top-level key "${key}". Expected "app" or "apps".`,
133
+ );
134
+ }
135
+ }
136
+
137
+ if (hasApp && hasApps) {
138
+ issues.push(
139
+ "Use either `app` (single app) or `apps` (multi-app), not both.",
140
+ );
141
+ } else if (hasApp) {
142
+ const target = normalizeAppEntry(exported.app, "app", null, issues);
143
+ if (target) {
144
+ targets.push(target);
145
+ }
146
+ } else if (hasApps) {
147
+ kind = "multi";
148
+ if (!isPlainObject(exported.apps)) {
149
+ issues.push("`apps` must be an object keyed by deploy target name.");
150
+ } else {
151
+ const entries = Object.entries(exported.apps);
152
+ if (entries.length === 0) {
153
+ issues.push("`apps` must define at least one app.");
154
+ }
155
+ for (const [key, value] of entries) {
156
+ if (key.trim().length === 0) {
157
+ issues.push("`apps` keys must be non-empty target names.");
158
+ continue;
159
+ }
160
+ const target = normalizeAppEntry(value, `apps.${key}`, key, issues);
161
+ if (target) {
162
+ targets.push(target);
163
+ }
164
+ }
165
+ }
166
+ } else {
167
+ issues.push(
168
+ "Define `app` for a single-app repository or `apps` for a multi-app repository.",
169
+ );
170
+ }
171
+ }
172
+
173
+ if (issues.length > 0) {
174
+ return Result.err(new ComputeConfigInvalidError(configPath, issues));
175
+ }
176
+
177
+ return Result.ok({
178
+ configPath,
179
+ configDir: path.dirname(configPath),
180
+ relativeConfigPath: path.basename(configPath),
181
+ kind,
182
+ targets,
183
+ });
184
+ }
185
+
186
+ /** Absolute app directory of a config target. */
187
+ export function computeTargetAppDir(
188
+ config: LoadedComputeConfig,
189
+ target: ComputeDeployTarget,
190
+ ): string {
191
+ return path.resolve(config.configDir, target.root ?? ".");
192
+ }
193
+
194
+ /**
195
+ * Selects a deploy target. Single-app configs return their app (a requested
196
+ * target must then match the configured `name`); multi-app configs require a
197
+ * matching key unless they hold exactly one target.
198
+ */
199
+ export function selectComputeDeployTarget(
200
+ config: LoadedComputeConfig,
201
+ requestedTarget: string | undefined,
202
+ ): Result<ComputeDeployTarget, ComputeConfigTargetError> {
203
+ if (config.kind === "single") {
204
+ const target = config.targets[0];
205
+ if (!target) {
206
+ return Result.err(
207
+ new ComputeConfigTargetRequiredError(config.configPath, []),
208
+ );
209
+ }
210
+ if (requestedTarget && requestedTarget !== target.name) {
211
+ return Result.err(
212
+ new ComputeConfigTargetUnknownError(
213
+ config.configPath,
214
+ requestedTarget,
215
+ target.name ? [target.name] : [],
216
+ ),
217
+ );
218
+ }
219
+ return Result.ok(target);
220
+ }
221
+
222
+ const availableTargets = config.targets.map((target) => target.key ?? "");
223
+ if (!requestedTarget) {
224
+ const only = config.targets[0];
225
+ if (config.targets.length === 1 && only) {
226
+ return Result.ok(only);
227
+ }
228
+ return Result.err(
229
+ new ComputeConfigTargetRequiredError(config.configPath, availableTargets),
230
+ );
231
+ }
232
+
233
+ const matched = config.targets.find(
234
+ (target) => target.key === requestedTarget,
235
+ );
236
+ if (!matched) {
237
+ return Result.err(
238
+ new ComputeConfigTargetUnknownError(
239
+ config.configPath,
240
+ requestedTarget,
241
+ availableTargets,
242
+ ),
243
+ );
244
+ }
245
+
246
+ return Result.ok(matched);
247
+ }
248
+
249
+ /**
250
+ * Infers the deploy target whose app directory contains `cwd`, so commands
251
+ * run from inside a target's root select it without a target argument. The
252
+ * deepest matching root wins; an ambiguous tie infers nothing.
253
+ */
254
+ export function inferComputeTargetFromCwd(
255
+ config: LoadedComputeConfig,
256
+ cwd: string,
257
+ ): string | undefined {
258
+ if (config.kind !== "multi") {
259
+ return undefined;
260
+ }
261
+
262
+ const resolvedCwd = path.resolve(cwd);
263
+ let bestKey: string | undefined;
264
+ let bestDepth = -1;
265
+ let bestIsTied = false;
266
+
267
+ for (const target of config.targets) {
268
+ const appDir = computeTargetAppDir(config, target);
269
+ const relative = path.relative(appDir, resolvedCwd);
270
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
271
+ continue;
272
+ }
273
+
274
+ const depth = appDir.split(path.sep).length;
275
+ if (depth > bestDepth) {
276
+ bestKey = target.key ?? undefined;
277
+ bestDepth = depth;
278
+ bestIsTied = false;
279
+ } else if (depth === bestDepth) {
280
+ bestIsTied = true;
281
+ }
282
+ }
283
+
284
+ return bestIsTied ? undefined : bestKey;
285
+ }
286
+
287
+ function normalizeAppEntry(
288
+ value: unknown,
289
+ label: string,
290
+ key: string | null,
291
+ issues: string[],
292
+ ): ComputeDeployTarget | null {
293
+ if (!isPlainObject(value)) {
294
+ issues.push(`\`${label}\` must be an object.`);
295
+ return null;
296
+ }
297
+
298
+ for (const entryKey of Object.keys(value)) {
299
+ if (!(KNOWN_APP_KEYS as readonly string[]).includes(entryKey)) {
300
+ issues.push(
301
+ `Unknown key "${entryKey}" in \`${label}\`. Expected one of: ${KNOWN_APP_KEYS.join(", ")}.`,
302
+ );
303
+ }
304
+ }
305
+
306
+ const name = readOptionalNonEmptyString(value.name, `${label}.name`, issues);
307
+ const entry = readOptionalNonEmptyString(
308
+ value.entry,
309
+ `${label}.entry`,
310
+ issues,
311
+ );
312
+
313
+ let root: string | null = null;
314
+ if (value.root !== undefined) {
315
+ const rawRoot = readOptionalNonEmptyString(
316
+ value.root,
317
+ `${label}.root`,
318
+ issues,
319
+ );
320
+ if (rawRoot) {
321
+ const normalized = normalizeRelativePath(rawRoot)?.replace(/\/+$/, "");
322
+ if (!normalized) {
323
+ issues.push(
324
+ `\`${label}.root\` must be a relative path inside the repository.`,
325
+ );
326
+ } else if (normalized !== ".") {
327
+ root = normalized;
328
+ }
329
+ }
330
+ }
331
+
332
+ let framework: ComputeFramework | null = null;
333
+ if (value.framework !== undefined) {
334
+ if (
335
+ typeof value.framework === "string" &&
336
+ (COMPUTE_FRAMEWORKS as readonly string[]).includes(value.framework)
337
+ ) {
338
+ framework = value.framework as ComputeFramework;
339
+ } else {
340
+ issues.push(
341
+ `\`${label}.framework\` must be one of: ${COMPUTE_FRAMEWORKS.join(", ")}.`,
342
+ );
343
+ }
344
+ }
345
+
346
+ if (entry && framework && !frameworkByKey(framework).usesEntrypoint) {
347
+ issues.push(
348
+ `\`${label}.entry\` is not supported with the ${framework} framework; it derives its entrypoint from build output.`,
349
+ );
350
+ }
351
+
352
+ let httpPort: number | null = null;
353
+ if (value.httpPort !== undefined) {
354
+ if (
355
+ typeof value.httpPort === "number" &&
356
+ Number.isInteger(value.httpPort) &&
357
+ value.httpPort > 0 &&
358
+ value.httpPort <= 65535
359
+ ) {
360
+ httpPort = value.httpPort;
361
+ } else {
362
+ issues.push(
363
+ `\`${label}.httpPort\` must be an integer between 1 and 65535.`,
364
+ );
365
+ }
366
+ }
367
+
368
+ const envInputs = normalizeEnvConfig(value.env, `${label}.env`, issues);
369
+ const build = normalizeBuildConfig(value.build, `${label}.build`, issues);
370
+ if (
371
+ build &&
372
+ framework &&
373
+ !isConfigBackedBuildType(frameworkByKey(framework).buildType)
374
+ ) {
375
+ issues.push(
376
+ `\`${label}.build\` is not supported with the ${framework} framework; its build runs automatically during deploy.`,
377
+ );
378
+ }
379
+
380
+ return {
381
+ key,
382
+ name,
383
+ root,
384
+ framework,
385
+ entry,
386
+ httpPort,
387
+ envInputs,
388
+ build,
389
+ };
390
+ }
391
+
392
+ function normalizeBuildConfig(
393
+ value: unknown,
394
+ label: string,
395
+ issues: string[],
396
+ ): ComputeDeployTargetBuild | null {
397
+ if (value === undefined) {
398
+ return null;
399
+ }
400
+
401
+ if (!isPlainObject(value)) {
402
+ issues.push(
403
+ `\`${label}\` must be an object with \`command\` and/or \`outputDirectory\`.`,
404
+ );
405
+ return null;
406
+ }
407
+
408
+ for (const buildKey of Object.keys(value)) {
409
+ if (!(KNOWN_BUILD_KEYS as readonly string[]).includes(buildKey)) {
410
+ issues.push(
411
+ `Unknown key "${buildKey}" in \`${label}\`. Expected one of: ${KNOWN_BUILD_KEYS.join(", ")}.`,
412
+ );
413
+ }
414
+ }
415
+
416
+ let command: string | null | undefined;
417
+ if (value.command !== undefined) {
418
+ if (value.command === null) {
419
+ command = null;
420
+ } else if (
421
+ typeof value.command === "string" &&
422
+ value.command.trim().length > 0
423
+ ) {
424
+ command = value.command.trim();
425
+ } else {
426
+ issues.push(
427
+ `\`${label}.command\` must be a non-empty string, or null to skip the build step.`,
428
+ );
429
+ }
430
+ }
431
+
432
+ let outputDirectory: string | undefined;
433
+ if (value.outputDirectory !== undefined) {
434
+ const normalized =
435
+ typeof value.outputDirectory === "string"
436
+ ? normalizeRelativePath(value.outputDirectory)?.replace(/\/+$/, "")
437
+ : undefined;
438
+ if (!normalized) {
439
+ issues.push(
440
+ `\`${label}.outputDirectory\` must be a relative path inside the app root.`,
441
+ );
442
+ } else {
443
+ outputDirectory = normalized;
444
+ }
445
+ }
446
+
447
+ if (command === undefined && outputDirectory === undefined) {
448
+ issues.push(
449
+ `\`${label}\` must set \`command\` and/or \`outputDirectory\`.`,
450
+ );
451
+ return null;
452
+ }
453
+
454
+ return { command, outputDirectory };
455
+ }
456
+
457
+ function normalizeEnvConfig(
458
+ value: unknown,
459
+ label: string,
460
+ issues: string[],
461
+ ): string[] {
462
+ if (value === undefined) {
463
+ return [];
464
+ }
465
+
466
+ if (typeof value === "string") {
467
+ const file = value.trim();
468
+ if (file.length === 0) {
469
+ issues.push(
470
+ `\`${label}\` must be a non-empty dotenv file path when given as a string.`,
471
+ );
472
+ return [];
473
+ }
474
+ return [file];
475
+ }
476
+
477
+ if (!isPlainObject(value)) {
478
+ issues.push(
479
+ `\`${label}\` must be a dotenv file path or an object with \`file\` and/or \`vars\`.`,
480
+ );
481
+ return [];
482
+ }
483
+
484
+ for (const envKey of Object.keys(value)) {
485
+ if (!(KNOWN_ENV_KEYS as readonly string[]).includes(envKey)) {
486
+ issues.push(
487
+ `Unknown key "${envKey}" in \`${label}\`. Expected one of: ${KNOWN_ENV_KEYS.join(", ")}.`,
488
+ );
489
+ }
490
+ }
491
+
492
+ const envInputs: string[] = [];
493
+
494
+ if (value.file !== undefined) {
495
+ const files = Array.isArray(value.file) ? value.file : [value.file];
496
+ for (const file of files) {
497
+ if (typeof file !== "string" || file.trim().length === 0) {
498
+ issues.push(
499
+ `\`${label}.file\` must be a non-empty dotenv file path or an array of them.`,
500
+ );
501
+ continue;
502
+ }
503
+ envInputs.push(file.trim());
504
+ }
505
+ }
506
+
507
+ if (value.vars !== undefined) {
508
+ if (!isPlainObject(value.vars)) {
509
+ issues.push(`\`${label}.vars\` must be an object of NAME: value pairs.`);
510
+ } else {
511
+ for (const [varName, varValue] of Object.entries(value.vars)) {
512
+ if (typeof varValue !== "string" || varValue.length === 0) {
513
+ issues.push(
514
+ `\`${label}.vars.${varName}\` must be a non-empty string.`,
515
+ );
516
+ continue;
517
+ }
518
+ envInputs.push(`${varName}=${varValue}`);
519
+ }
520
+ }
521
+ }
522
+
523
+ return envInputs;
524
+ }
525
+
526
+ function readOptionalNonEmptyString(
527
+ value: unknown,
528
+ label: string,
529
+ issues: string[],
530
+ ): string | null {
531
+ if (value === undefined) {
532
+ return null;
533
+ }
534
+
535
+ if (typeof value !== "string" || value.trim().length === 0) {
536
+ issues.push(`\`${label}\` must be a non-empty string.`);
537
+ return null;
538
+ }
539
+
540
+ return value.trim();
541
+ }
542
+
543
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
544
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
545
+ }
546
+
547
+ function normalizeRelativePath(value: string): string | undefined {
548
+ const raw = value.trim().replace(/\\/g, "/");
549
+ if (raw.length === 0 || raw.split("/").includes("..")) {
550
+ return undefined;
551
+ }
552
+ // Windows drive-relative paths ("C:dir") escape the config directory but
553
+ // are not absolute under either path.win32 or path.posix.
554
+ if (/^[A-Za-z]:/.test(raw)) {
555
+ return undefined;
556
+ }
557
+
558
+ const normalized = path.posix.normalize(raw);
559
+ const segments = normalized.split("/");
560
+ if (
561
+ path.win32.isAbsolute(value) ||
562
+ path.posix.isAbsolute(normalized) ||
563
+ segments.includes("..")
564
+ ) {
565
+ return undefined;
566
+ }
567
+
568
+ return normalized === "." ? "." : normalized;
569
+ }
@@ -0,0 +1,76 @@
1
+ import type { ComputeAppConfig, ComputeConfig } from "./types.ts";
2
+
3
+ /**
4
+ * Renders a `prisma.compute.ts` source file for a config — the scaffolding
5
+ * counterpart of `loadComputeConfig`. Output round-trips through
6
+ * `normalizeComputeConfig` and is stable for committing to a repository.
7
+ */
8
+ export function serializeComputeConfig(
9
+ config: ComputeConfig,
10
+ options?: {
11
+ /** Import specifier for the typed helper. Defaults to this SDK. */
12
+ importFrom?: string;
13
+ },
14
+ ): string {
15
+ const importFrom = options?.importFrom ?? "@prisma/compute-sdk/config";
16
+ const body =
17
+ "app" in config && config.app !== undefined
18
+ ? `{\n app: ${renderValue(config.app, 1)},\n}`
19
+ : `{\n apps: ${renderValue((config as { apps: Record<string, ComputeAppConfig> }).apps, 1)},\n}`;
20
+
21
+ return [
22
+ `import { defineComputeConfig } from ${JSON.stringify(importFrom)};`,
23
+ "",
24
+ `export default defineComputeConfig(${body});`,
25
+ "",
26
+ ].join("\n");
27
+ }
28
+
29
+ const IDENTIFIER_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
30
+
31
+ function renderValue(value: unknown, depth: number): string {
32
+ if (value === null) {
33
+ return "null";
34
+ }
35
+
36
+ switch (typeof value) {
37
+ case "string":
38
+ return JSON.stringify(value);
39
+ case "number":
40
+ case "boolean":
41
+ return String(value);
42
+ case "object":
43
+ break;
44
+ default:
45
+ throw new Error(
46
+ `Cannot serialize a ${typeof value} value in a compute config.`,
47
+ );
48
+ }
49
+
50
+ if (Array.isArray(value)) {
51
+ return `[${value.map((entry) => renderValue(entry, depth)).join(", ")}]`;
52
+ }
53
+
54
+ const entries = Object.entries(value as Record<string, unknown>).filter(
55
+ ([, entry]) => entry !== undefined,
56
+ );
57
+ if (entries.length === 0) {
58
+ return "{}";
59
+ }
60
+
61
+ const indent = " ".repeat(depth + 1);
62
+ const closingIndent = " ".repeat(depth);
63
+ const rendered = entries.map(([key, entry]) => {
64
+ // "__proto__" needs a computed key: in an object literal both the bare
65
+ // and the quoted form set the prototype instead of defining a property.
66
+ const renderedKey =
67
+ key === "__proto__"
68
+ ? '["__proto__"]'
69
+ : IDENTIFIER_KEY.test(key)
70
+ ? key
71
+ : JSON.stringify(key);
72
+ return `${indent}${renderedKey}: ${renderValue(entry, depth + 1)},`;
73
+ });
74
+
75
+ return `{\n${rendered.join("\n")}\n${closingIndent}}`;
76
+ }