import { existsSync } from "node:fs"; import { access } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { Result, TaggedError } from "better-result"; import { createJiti } from "jiti"; import { type ComputeConfigInvalidError, type LoadedComputeConfig, normalizeComputeConfig, } from "./normalize.ts"; import { sourceRootLineage } from "./source-root.ts"; export const COMPUTE_CONFIG_FILENAME = "prisma.compute.ts"; // Highest priority first. TypeScript is the canonical format; the rest exist // so plain JavaScript projects are not forced into TypeScript. export const COMPUTE_CONFIG_FILENAMES = [ "prisma.compute.ts", "prisma.compute.mts", "prisma.compute.js", "prisma.compute.mjs", "prisma.compute.cjs", ] as const; // Config files import the typed helper through this specifier; it aliases // to this module so configs load without a local install. const CONFIG_MODULE_SPECIFIERS = ["@prisma/compute-sdk/config"] as const; export class ComputeConfigAmbiguousError extends TaggedError( "ComputeConfigAmbiguousError", )<{ message: string; configPaths: string[]; }>() { constructor(configPaths: string[]) { super({ message: `Multiple compute config files exist: ${configPaths.map((configPath) => path.basename(configPath)).join(", ")}. Keep exactly one.`, configPaths, }); } } export class ComputeConfigLoadError extends TaggedError( "ComputeConfigLoadError", )<{ message: string; cause: unknown; configPath: string; }>() { constructor(configPath: string, cause: unknown) { super({ message: `Could not load ${path.basename(configPath)}: ${cause instanceof Error ? cause.message : String(cause)}`, cause, configPath, }); } } export type ComputeConfigError = | ComputeConfigAmbiguousError | ComputeConfigLoadError | ComputeConfigInvalidError; /** * Compute config files present in one directory, in filename priority order. */ export async function findComputeConfigCandidates( directory: string, signal?: AbortSignal, ): Promise { const candidates: string[] = []; for (const filename of COMPUTE_CONFIG_FILENAMES) { const configPath = path.join(directory, filename); signal?.throwIfAborted(); try { await access(configPath); candidates.push(configPath); } catch (error) { if (signal?.aborted) throw error; } } signal?.throwIfAborted(); return candidates; } /** * Locates the nearest directory holding a compute config file, searching from * `cwd` up to the source root. This is location-only discovery — the config * is not loaded or validated — so it is safe to run in hot paths. * Returns null when no config exists inside the repository boundary. */ export async function findComputeConfigDir( cwd: string, signal?: AbortSignal, ): Promise { for (const directory of await sourceRootLineage(cwd, signal)) { const candidates = await findComputeConfigCandidates(directory, signal); if (candidates.length > 0) { return directory; } } return null; } /** * Loads the nearest compute config, searching from `cwd` up to the source * root (repository or workspace boundary). Without such a boundary only * `cwd` itself is checked, so discovery never escapes into unrelated * directories. */ export async function loadComputeConfig( cwd: string, options?: { signal?: AbortSignal; /** * Module path the config-helper import specifiers alias to. Defaults to * this SDK's own config module; pass a path when the consumer ships its * own copy of the contract (e.g. a bundled CLI). */ configModuleAlias?: string; }, ): Promise> { const signal = options?.signal; for (const directory of await sourceRootLineage(cwd, signal)) { const candidates = await findComputeConfigCandidates(directory, signal); if (candidates.length === 0) { continue; } if (candidates.length > 1) { return Result.err(new ComputeConfigAmbiguousError(candidates)); } const configPath = candidates[0] as string; signal?.throwIfAborted(); const imported = await importComputeConfigModule( configPath, options?.configModuleAlias, ); if (imported.isErr()) { return Result.err(imported.error); } return normalizeComputeConfig(imported.value, configPath); } return Result.ok(null); } async function importComputeConfigModule( configPath: string, configModuleAlias: string | undefined, ): Promise> { return Result.tryPromise({ try: async () => { const aliasTarget = configModuleAlias ?? resolveOwnConfigModulePath(); const jiti = createJiti(import.meta.url, { // Keep Node's standard interop so `.default` exists only for a real // default export (ESM) or module.exports (CJS). interopDefault: false, // Re-import fresh so repeated loads in one process observe file edits. moduleCache: false, ...(aliasTarget ? { alias: Object.fromEntries( CONFIG_MODULE_SPECIFIERS.map((specifier) => [ specifier, aliasTarget, ]), ), } : {}), }); const moduleNamespace = await jiti.import>(configPath); // Require an explicit default export instead of jiti's namespace // interop so named-export configs fail with a clear message. return moduleNamespace?.default; }, catch: (cause) => new ComputeConfigLoadError(configPath, cause), }); } function resolveOwnConfigModulePath(): string | null { // dist layout: dist/config/load.js -> dist/config/index.js // src layout (bun/tests): src/config/load.ts -> src/config/index.ts for (const candidate of ["./index.js", "./index.ts"]) { const candidatePath = fileURLToPath(new URL(candidate, import.meta.url)); if (existsSync(candidatePath)) { return candidatePath; } } return null; }