From 3a4a1edeb8ad0d8294062252a644562c1c7fb7a2 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 17 Sep 2026 10:32:26 +0200 Subject: [PATCH 1/5] fix(cli): relative paths in prisma.config.ts resolve against the config file, not cwd Under the unified `prisma` CLI, `contract emit --config ./sub/prisma.config.ts` run from the parent directory failed with CONTRACT.SOURCE_LOAD_FAILED: the command wrapper finalized the loaded config against the working directory, so `./contract.prisma` inside `sub/prisma.config.ts` was looked for in the parent. The same file meant different things depending on where the command ran, and the ORM's own loader, Composer, and the migrations.dir docs all anchor on the config file instead. The engine now hands every command `ctx.configFile`, the absolute path of the file it loaded (prisma/prisma-cli#274). The wrapper anchors on that file's directory and falls back to cwd only when no file was loaded. The migration and db commands had the same defect a second time in projectConfigPathFor, which named `/prisma.config.ts` for the project manifest walk; it now names the loaded file. Depends on a @prisma/cli-engine release carrying ctx.configFile and the matching version bump here. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/CLI Style Guide.md | 3 +- .../1-core/config/src/config-types.ts | 11 +- .../3-tooling/cli/src/orm/db/init.ts | 2 +- .../3-tooling/cli/src/orm/db/sign.ts | 2 +- .../3-tooling/cli/src/orm/db/update.ts | 2 +- .../3-tooling/cli/src/orm/define-command.ts | 26 ++-- .../3-tooling/cli/src/orm/migrate.ts | 2 +- .../3-tooling/cli/src/orm/migration/new.ts | 2 +- .../3-tooling/cli/src/orm/migration/paths.ts | 21 +-- .../3-tooling/cli/src/orm/migration/plan.ts | 2 +- .../cli/test/orm/contract-emit.test.ts | 36 +++++ .../cli/test/orm/define-command.test.ts | 146 ++++++++++++------ .../cli/test/orm/migration-plan.test.ts | 57 ++++++- 13 files changed, 235 insertions(+), 77 deletions(-) diff --git a/docs/CLI Style Guide.md b/docs/CLI Style Guide.md index 3f74dbd2a39e..b4edcc04f3a4 100644 --- a/docs/CLI Style Guide.md +++ b/docs/CLI Style Guide.md @@ -140,10 +140,11 @@ This is a deliberate divergence from clig.dev §Arguments §Confirmation. AI age ## Config & Environment - Config file names: `prisma.config.ts|.mjs|.js` (ESM); optional CJS fallback. - Discovery precedence: `--config ` > `PRISMA_CONFIG` > nearest `prisma.config.*` in CWD (no upward search). +- Relative paths inside the config file (`contract` source inputs, `contract.output`, `migrations.dir`) are relative to the config file's directory, not to CWD, so `--config ./sub/prisma.config.ts` reads and writes under `sub/`. - Precedence: flags > config > defaults. - Env policy: the CLI does not auto‑load `.env`. Apps may do so in `prisma.config.*` and pass values (e.g., `db.connection`). - Contract source: defined in config; no flag override. -- Contract output directory: `--output-path ` on `contract emit` sets the directory where `contract.json` and `contract.d.ts` are written. The filenames are canonical and not user-controlled. Precedence: `--output-path` flag > `output` in config > derived default (directory of the contract source file). The path is resolved relative to CWD. Extension wrappers (`defineConfig` from `@internal/mongo` and `@internal/postgres`) expose an `output?: string` option that maps directly to this config field. +- Contract output directory: `--output-path ` on `contract emit` sets the directory where `contract.json` and `contract.d.ts` are written. The filenames are canonical and not user-controlled. Precedence: `--output-path` flag > `output` in config > derived default (directory of the contract source file). The flag is resolved relative to CWD; `output` in config, like every relative path in `prisma.config.ts`, is resolved relative to the config file, whichever directory the command runs from. Extension wrappers (`defineConfig` from `@internal/mongo` and `@internal/postgres`) expose an `output?: string` option that maps directly to this config field. - Migration directory: defined in config; no flag override. - DB Connection: `--db=` or `config.db.connection`. diff --git a/packages/1-framework/1-core/config/src/config-types.ts b/packages/1-framework/1-core/config/src/config-types.ts index 965102a26cbb..2e2ae4930f82 100644 --- a/packages/1-framework/1-core/config/src/config-types.ts +++ b/packages/1-framework/1-core/config/src/config-types.ts @@ -24,10 +24,11 @@ export interface ContractConfig { */ readonly source: ContractSourceProvider; /** - * Path to contract.json artifact. Providers that know an input path (PSL, - * `typescriptContractFromPath`) derive an output colocated with that input - * so this rarely needs to be set explicitly. The `.d.ts` types file is - * always emitted next to the JSON (e.g., `contract.json` → `contract.d.ts`). + * Path to contract.json artifact, relative to the config file. Providers + * that know an input path (PSL, `typescriptContractFromPath`) derive an + * output colocated with that input so this rarely needs to be set + * explicitly. The `.d.ts` types file is always emitted next to the JSON + * (e.g., `contract.json` → `contract.d.ts`). */ readonly output?: string; } @@ -107,7 +108,7 @@ export interface PrismaNextConfig< * Migration configuration. Controls where on-disk migration packages are stored. */ readonly migrations?: { - /** Directory for migration packages, relative to config file. Defaults to 'migrations'. */ + /** Directory for migration packages, relative to the config file. Defaults to 'migrations'. */ readonly dir?: string; }; readonly formatter?: FormatterConfig; diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/init.ts b/packages/1-framework/3-tooling/cli/src/orm/db/init.ts index de6ce6aada35..03ef268b7de9 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/init.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/init.ts @@ -156,7 +156,7 @@ export function createDbInitCommand(createClient: CreateControlClient) { name: refName, contractJson, contractJsonPath: contractPath, - configPath: projectConfigPathFor(ctx.cwd), + configPath: projectConfigPathFor(ctx), client, }); if (!preflight.ok) { diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts b/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts index 7088fb1f3313..5702359711e1 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts @@ -307,7 +307,7 @@ export function createDbSignCommand( name: refName, contractJson: signedSource.json, contractJsonPath: signedSource.jsonPath, - configPath: projectConfigPathFor(ctx.cwd), + configPath: projectConfigPathFor(ctx), client, }); if (!preflight.ok) { diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/update.ts b/packages/1-framework/3-tooling/cli/src/orm/db/update.ts index fdc1bcdc04f9..e4e872989a26 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/update.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/update.ts @@ -191,7 +191,7 @@ export function createDbUpdateCommand(createClient: CreateControlClient) { name: refName, contractJson, contractJsonPath: snapshotContractPath, - configPath: projectConfigPathFor(ctx.cwd), + configPath: projectConfigPathFor(ctx), client, }); if (!preflight.ok) { diff --git a/packages/1-framework/3-tooling/cli/src/orm/define-command.ts b/packages/1-framework/3-tooling/cli/src/orm/define-command.ts index 391292999705..78dfbe409f46 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/define-command.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/define-command.ts @@ -14,6 +14,7 @@ import type { } from '@prisma/cli-engine'; import { defineCommand } from '@prisma/cli-engine'; import { notOk } from '@prisma/cli-engine/protocol'; +import { dirname } from 'pathe'; import { normalizeError } from './normalize-error'; /** @@ -42,17 +43,19 @@ function isRecord(value: unknown): value is Record { * bin finalizes them in its loader. Anchoring here, on the section every ORM * command reads, makes both hosts hand handlers the same absolute paths. * - * The anchor is the working directory: the engine discovers the config in the - * working directory only, so that is the file's own directory. (A `--config` - * pointing into another directory is not visible from a command context; a - * relative path inside such a file resolves against the invocation directory, - * which is also what the file's author sees the command run from.) - * Finalization is idempotent — an already-absolute path resolves to itself — - * so a config that arrived finalized passes through unchanged. + * A relative path in the file is relative to the file, so the anchor is the + * directory of the config file the engine loaded; the working directory is the + * anchor only when no file was loaded. Finalization is idempotent — an + * already-absolute path resolves to itself — so a config that arrived + * finalized passes through unchanged. */ -function finalizedConfigContext( - ctx: TCtx, -): TCtx { +function finalizedConfigContext< + TCtx extends { + readonly cwd: string; + readonly configFile: string | null; + readonly config: unknown; + }, +>(ctx: TCtx): TCtx { if (!isRecord(ctx.config)) { return ctx; } @@ -60,7 +63,8 @@ function finalizedConfigContext(ctx.config); - return { ...ctx, config: finalizeConfig(config, ctx.cwd) }; + const configDir = ctx.configFile === null ? ctx.cwd : dirname(ctx.configFile); + return { ...ctx, config: finalizeConfig(config, configDir) }; } export function defineOrmCommand< diff --git a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts index f5ce94124c29..cc8b8ef99b7a 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts @@ -423,7 +423,7 @@ export function createMigrateCommand(createClient: CreateControlClient) { name: args.flags.advanceRef, contractJson: snapshotContractJson, contractJsonPath: snapshotContractPath, - configPath: projectConfigPathFor(ctx.cwd), + configPath: projectConfigPathFor(ctx), client, }); if (!preflight.ok) { diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/new.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/new.ts index 52a1ebc63bc6..625d7a06fb57 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/new.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/new.ts @@ -90,7 +90,7 @@ export function createMigrationNewCommand(createClient: CreateControlClient) { const scaffolded = await executeMigrationNewCommand({ config: ctx.config, cwd: ctx.cwd, - configPath: projectConfigPathFor(ctx.cwd), + configPath: projectConfigPathFor(ctx), ...ifDefined('name', args.flags.name), ...ifDefined('from', args.flags.from), client: createClient({ diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/paths.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/paths.ts index 3dc2b98b0fd3..e2b77d149a8f 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/paths.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/paths.ts @@ -4,9 +4,9 @@ import { spaceMigrationDirectory } from '@internal/migration-tools/spaces'; import { relative, resolve } from 'pathe'; /** - * Where migrations live for this project. Resolved against the invocation - * directory, which is also the config file's directory for every default - * invocation. + * Where migrations live for this project. The command boundary has already + * resolved `migrations.dir` against the config file's directory, so `cwd` + * only anchors a config handed in raw, as tests do. */ export function migrationsDirFor(config: PrismaNextConfig, cwd: string): string { return resolve(cwd, config.migrations?.dir ?? 'migrations'); @@ -18,14 +18,15 @@ export function appMigrationsDirFor(config: PrismaNextConfig, cwd: string): stri } /** - * The config file an operation should anchor its project paths on. The engine - * loads the config and hands a handler the value but not the path, and - * `--config` is an engine flag the handler never sees, so a handler names the - * invocation directory's file. That equals the loaded file for every - * invocation whose config sits in the invocation directory. + * The config file an operation should anchor its project paths on: the file + * the engine loaded, which `--config` may have placed outside the invocation + * directory. With no loaded file it is the invocation directory's own. */ -export function projectConfigPathFor(cwd: string): string { - return resolve(cwd, 'prisma.config.ts'); +export function projectConfigPathFor(ctx: { + readonly cwd: string; + readonly configFile: string | null; +}): string { + return ctx.configFile ?? resolve(ctx.cwd, 'prisma.config.ts'); } /** diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts index f531ae53b285..735da0a3d1d4 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts @@ -256,7 +256,7 @@ export function createMigrationPlanCommand(createClient: CreateControlClient) { { config: ctx.config, cwd: ctx.cwd, - configPath: projectConfigPathFor(ctx.cwd), + configPath: projectConfigPathFor(ctx), ...ifDefined('name', args.flags.name), ...ifDefined('from', args.flags.from), ...ifDefined('to', args.flags.to), diff --git a/packages/1-framework/3-tooling/cli/test/orm/contract-emit.test.ts b/packages/1-framework/3-tooling/cli/test/orm/contract-emit.test.ts index b97b790b7b40..4f7abf55f823 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/contract-emit.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/contract-emit.test.ts @@ -141,6 +141,42 @@ describe('contract emit', () => { }); }); + it('anchors relative config paths on the --config file, not the working directory', async () => { + const configDir = join(PROJECT_DIR, 'sub'); + const config = ormConfig({ + contract: { + source: { + format: 'psl', + inputs: ['./contract.prisma'], + load: () => ({ ok: true, value: {} }), + }, + output: './generated/contract.json', + }, + }); + const loadConfig = (configPath?: string) => + Promise.resolve({ + path: join(PROJECT_DIR, configPath ?? 'prisma.config.ts'), + sections: { orm: config }, + diagnostics: [], + }); + + const run = await createTestCli({ commands, groups, loadConfig }).run( + ['contract', 'emit', '--json', '--config', 'sub/prisma.config.ts'], + { cwd: PROJECT_DIR }, + ); + + expect(run.exitCode).toBe(0); + expect(executeContractEmit.mock.calls[0]?.[0]).toMatchObject({ + config: { + contract: { + source: { inputs: [join(configDir, 'contract.prisma')] }, + output: join(configDir, 'generated', 'contract.json'), + }, + }, + cwd: PROJECT_DIR, + }); + }); + it('loads the config exactly once for a run', async () => { const loader = countingLoader(); diff --git a/packages/1-framework/3-tooling/cli/test/orm/define-command.test.ts b/packages/1-framework/3-tooling/cli/test/orm/define-command.test.ts index 7b09f6771f2b..231096e30d75 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/define-command.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/define-command.test.ts @@ -1,9 +1,10 @@ import { CliStructuredError } from '@internal/errors/control'; import { InternalError } from '@internal/utils/internal-error'; import { structuredError } from '@internal/utils/structured-error'; -import type { ErroredEnvelope, StreamEvent } from '@prisma/cli-engine'; +import type { ErroredEnvelope, LoadedConfig, StreamEvent } from '@prisma/cli-engine'; import { ok } from '@prisma/cli-engine/protocol'; import { createTestCli } from '@prisma/cli-engine/testing'; +import { resolve } from 'pathe'; import { describe, expect, it } from 'vitest'; import { defineOrmCommand } from '../../src/orm/define-command'; @@ -145,56 +146,115 @@ describe('defineOrmCommand', () => { }); }); -describe('config finalization at the command boundary', () => { - it('hands the handler absolute contract and migration paths whatever the loader left relative', async () => { - let seen: { output?: string | undefined; dir?: string | undefined } = {}; - const cli = createTestCli({ - commands: { - probe: defineOrmCommand({ - help: { summary: 'Records the config paths the handler receives' }, - needs: { - config: { - name: 'orm', - validate: (raw) => ({ - ok: true as const, - value: raw as Record, - diagnostics: [], - }), - }, - }, - handler: async (_args, ctx) => { - const config = ctx.config as { - contract?: { output?: string }; - migrations?: { dir?: string }; - }; - seen = { output: config.contract?.output, dir: config.migrations?.dir }; - return ok( - ctx.present( - { data: seen, exitCode: 0 }, - { stdout: () => [], next: () => [], human: () => [], json: () => seen }, - ), - ); - }, - }), - }, - config: { - orm: { - contract: { - source: { load: async () => ({ ok: true, value: {} }) }, - output: './src/prisma/contract.json', +interface SeenPaths { + output?: string | undefined; + inputs?: readonly string[] | undefined; + dir?: string | undefined; +} + +function probeCli(seen: SeenPaths, loadConfig: (configPath?: string) => Promise) { + return createTestCli({ + commands: { + probe: defineOrmCommand({ + help: { summary: 'Records the config paths the handler receives' }, + needs: { + config: { + name: 'orm', + validate: (raw) => ({ + ok: true as const, + value: raw as Record, + diagnostics: [], + }), }, - migrations: { dir: './migrations' }, }, - }, - }); + handler: async (_args, ctx) => { + const config = ctx.config as { + contract?: { source?: { inputs?: readonly string[] }; output?: string }; + migrations?: { dir?: string }; + }; + seen.output = config.contract?.output; + seen.inputs = config.contract?.source?.inputs; + seen.dir = config.migrations?.dir; + return ok( + ctx.present( + { data: seen, exitCode: 0 }, + { stdout: () => [], next: () => [], human: () => [], json: () => seen }, + ), + ); + }, + }), + }, + loadConfig, + }); +} - const cwd = process.cwd(); - const run = await cli.run(['probe', '--json'], { cwd }); +/** The engine's own loader: paths inside the file stay exactly as authored. */ +const relativeOrmSection = { + contract: { + source: { + format: 'psl', + inputs: ['./contract.prisma'], + load: async () => ({ ok: true, value: {} }), + }, + output: './src/prisma/contract.json', + }, + migrations: { dir: './migrations' }, +}; + +function loaderAt(cwd: string): (configPath?: string) => Promise { + return async (configPath) => ({ + path: resolve(cwd, configPath ?? 'prisma.config.ts'), + sections: { orm: relativeOrmSection }, + diagnostics: [], + }); +} + +describe('config finalization at the command boundary', () => { + it('hands the handler absolute contract and migration paths whatever the loader left relative', async () => { + const cwd = '/workspace/app'; + const seen: SeenPaths = {}; + + const run = await probeCli(seen, loaderAt(cwd)).run(['probe', '--json'], { cwd }); expect(run.exitCode).toBe(0); expect(seen).toEqual({ output: `${cwd}/src/prisma/contract.json`, + inputs: [`${cwd}/contract.prisma`], dir: `${cwd}/migrations`, }); }); + + describe('a --config file in a subdirectory of the working directory', () => { + const parent = '/workspace/app'; + const configDir = `${parent}/sub`; + const anchoredOnConfigDir: SeenPaths = { + output: `${configDir}/src/prisma/contract.json`, + inputs: [`${configDir}/contract.prisma`], + dir: `${configDir}/migrations`, + }; + + it('resolves relative paths against the config file, not the working directory', async () => { + const seen: SeenPaths = {}; + + const run = await probeCli(seen, loaderAt(parent)).run( + ['probe', '--json', '--config', 'sub/prisma.config.ts'], + { cwd: parent }, + ); + + expect(run.exitCode).toBe(0); + expect(seen).toEqual(anchoredOnConfigDir); + }); + + it('yields the same absolute paths as running from the config directory', async () => { + const seen: SeenPaths = {}; + + const run = await probeCli(seen, loaderAt(configDir)).run( + ['probe', '--json', '--config', 'prisma.config.ts'], + { cwd: configDir }, + ); + + expect(run.exitCode).toBe(0); + expect(seen).toEqual(anchoredOnConfigDir); + }); + }); }); diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts index 05817747d6c1..992a2024ac68 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts @@ -3,8 +3,9 @@ import { readdir, readFile } from 'node:fs/promises'; import { contractSnapshotDir } from '@internal/migration-tools/contract-snapshot-store'; import { computeMigrationHash } from '@internal/migration-tools/hash'; import { notOk } from '@internal/utils/result'; +import type { LoadedConfig } from '@prisma/cli-engine'; import { createTestCli } from '@prisma/cli-engine/testing'; -import { join } from 'pathe'; +import { basename, dirname, join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { BIN_GROUPS } from '../../src/orm/cli'; import { @@ -82,6 +83,60 @@ async function upToDateProject(): Promise { return project; } +/** The engine's own loader leaves the paths inside the file as authored. */ +function relativeConfigLoader( + project: OfflineProject, +): (configPath?: string) => Promise { + return async (configPath) => ({ + path: join(project.dir, configPath === undefined ? 'prisma.config.ts' : basename(configPath)), + sections: { + orm: { + ...offlineConfig({ project }), + contract: { + source: { format: 'typescript', inputs: [], load: async () => contractJson('unused') }, + output: './output/contract.json', + }, + migrations: { dir: './migrations' }, + }, + }, + diagnostics: [], + }); +} + +describe('migration plan --config naming a file in a subdirectory', () => { + it('reads the contract from and plans into the config file directory', async () => { + const project = await plannableProject(); + const cli = createTestCli({ + commands: OFFLINE_COMMANDS, + groups: BIN_GROUPS, + loadConfig: relativeConfigLoader(project), + }); + + const run = await cli.run( + [ + 'migration', + 'plan', + '--name', + 'add-users', + '--config', + join(basename(project.dir), 'prisma.config.ts'), + ], + { cwd: dirname(project.dir) }, + ); + const dirs = await plannedDirs(project); + + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toMatchObject({ + ok: true, + noOp: false, + from: HASH_FROM, + to: HASH_TO, + }); + expect(dirs).toHaveLength(2); + expect(dirs.at(-1)).toMatch(/_add_users$/); + }); +}); + describe('migration plan', () => { it('settles as a completed envelope carrying the plan document', async () => { const project = await plannableProject(); From eb4e4a1f2e415ff45d220cf9150b8a0435b3ed6c Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 21 Sep 2026 15:13:21 +0200 Subject: [PATCH 2/5] fix(config): relative paths in prisma.config.ts resolve against the file that wrote them (ADR 253) Under the unified prisma CLI, contract emit --config ./sub/prisma.config.ts run from the parent failed with CONTRACT.SOURCE_LOAD_FAILED: the command wrapper resolved the loaded section against the working directory. The engine loader hands the orm section over as written, and nothing told the ORM which file wrote it. ADR 253: a relative path in the file is relative to the file, resolved by the loader per c12 layer before layers merge. ormConfig attaches a resolver under Symbol.for("prisma.config.resolve"); the ORM loader resolves each layer against its own file and merges nearest-first; the resolved section records rootDir. The section validator refuses a section still carrying its resolver (an engine that never called it) or lacking rootDir, so no path is ever silently resolved against cwd. Commands read absolute paths and rootDir. The post-load finalisation in the command wrapper and the cwd anchoring in the migration path helpers are gone; control API operations that located the project through a configPath take projectDir instead, which also fixes migration plan from a parent directory walking to the wrong package.json. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- ...esolve against the file that wrote them.md | 130 ++++++++++ .../1-framework/1-core/config/package.json | 1 + .../1-core/config/src/config-resolve.ts | 136 +++++++++++ .../1-core/config/src/config-types.ts | 44 ++-- .../config/src/exports/config-resolve.ts | 11 + .../1-core/config/src/exports/config-types.ts | 7 +- .../1-core/config/test/config-resolve.test.ts | 222 ++++++++++++++++++ .../1-core/config/test/define-config.test.ts | 46 +++- .../1-core/config/tsdown.config.ts | 6 +- .../control-api/operations/contract-emit.ts | 10 +- .../control-api/operations/migrate-show.ts | 3 - .../control-api/operations/migration-new.ts | 7 +- .../control-api/operations/migration-plan.ts | 8 +- .../control-api/operations/ref-advancement.ts | 4 +- .../cli/src/control-api/operations/ref.ts | 12 +- .../3-tooling/cli/src/control-api/types.ts | 11 +- .../3-tooling/cli/src/orm/config-section.ts | 39 +++ .../3-tooling/cli/src/orm/db/init.ts | 4 +- .../3-tooling/cli/src/orm/db/prepare.ts | 6 +- .../3-tooling/cli/src/orm/db/sign.ts | 13 +- .../3-tooling/cli/src/orm/db/update.ts | 4 +- .../3-tooling/cli/src/orm/db/verification.ts | 2 +- .../3-tooling/cli/src/orm/db/verify.ts | 2 +- .../3-tooling/cli/src/orm/define-command.ts | 41 +--- .../3-tooling/cli/src/orm/migrate.ts | 6 +- .../3-tooling/cli/src/orm/migration/check.ts | 4 +- .../3-tooling/cli/src/orm/migration/graph.ts | 2 +- .../3-tooling/cli/src/orm/migration/list.ts | 2 +- .../3-tooling/cli/src/orm/migration/new.ts | 8 +- .../3-tooling/cli/src/orm/migration/paths.ts | 47 ++-- .../3-tooling/cli/src/orm/migration/plan.ts | 10 +- .../3-tooling/cli/src/orm/migration/show.ts | 6 +- .../3-tooling/cli/src/orm/migration/status.ts | 6 +- .../cli/src/utils/command-helpers.ts | 13 +- .../cli/src/utils/project-import-root.ts | 14 +- .../migration-ref-error-mapping.test.ts | 3 - .../3-tooling/cli/test/config-types.test.ts | 4 +- .../control-api/migrate-show-plan.test.ts | 5 - .../migration-plan-prologue.test.ts | 4 +- .../test/control-api/ref-advancement.test.ts | 10 +- .../cli/test/helpers/orm-test-cli.ts | 23 ++ .../cli/test/orm/config-section.test.ts | 40 ++++ .../cli/test/orm/contract-emit.test.ts | 19 +- .../cli/test/orm/contract-infer.test.ts | 4 +- .../test/orm/cross-consumer-integrity.test.ts | 4 +- .../3-tooling/cli/test/orm/db-init.test.ts | 4 +- .../3-tooling/cli/test/orm/db-schema.test.ts | 4 +- .../cli/test/orm/db-sign-fixtures.ts | 4 +- .../cli/test/orm/db-update-consent.test.ts | 4 +- .../test/orm/db-update-to-resolution.test.ts | 4 +- .../3-tooling/cli/test/orm/db-update.test.ts | 4 +- .../3-tooling/cli/test/orm/db-verify.test.ts | 4 +- .../cli/test/orm/define-command.test.ts | 116 +-------- .../3-tooling/cli/test/orm/format.test.ts | 6 +- .../cli/test/orm/migrate-show.test.ts | 4 +- .../cli/test/orm/migrate-to-contract.test.ts | 4 +- .../3-tooling/cli/test/orm/migrate.test.ts | 4 +- .../orm/migration-check-multi-space.test.ts | 4 +- .../cli/test/orm/migration-check.test.ts | 4 +- .../cli/test/orm/migration-graph.test.ts | 4 +- .../cli/test/orm/migration-invariants.test.ts | 4 +- .../cli/test/orm/migration-list.test.ts | 6 +- .../cli/test/orm/migration-log.test.ts | 4 +- .../cli/test/orm/migration-new.test.ts | 6 +- .../cli/test/orm/migration-plan.test.ts | 55 +++-- .../cli/test/orm/migration-show.test.ts | 6 +- .../cli/test/orm/migration-status.test.ts | 4 +- .../cli/test/orm/migration-tamper.test.ts | 4 +- .../3-tooling/cli/test/orm/ref-fixtures.ts | 6 +- .../orm/ref-format-error-boundary.test.ts | 26 +- .../cli/test/utils/command-helpers.test.ts | 53 ++--- .../3-tooling/config-loader/README.md | 6 +- .../3-tooling/config-loader/package.json | 1 + .../config-loader/src/exports/index.ts | 1 - .../config-loader/src/finalize-config.ts | 65 ----- .../3-tooling/config-loader/src/load.ts | 49 +++- .../test/finalize-config.test.ts | 134 ----------- .../3-tooling/config-loader/test/load.test.ts | 44 ++++ .../vite-plugin-contract-emit/src/plugin.ts | 4 +- .../test/plugin.test.ts | 9 +- .../@prisma/orm-framework/package.json | 1 + .../@prisma/orm-toolchain/package.json | 1 + pnpm-lock.yaml | 6 + .../test/cli.init-templates.e2e.test.ts | 6 +- 84 files changed, 1005 insertions(+), 673 deletions(-) create mode 100644 docs/architecture docs/adrs/ADR 253 - Config paths resolve against the file that wrote them.md create mode 100644 packages/1-framework/1-core/config/src/config-resolve.ts create mode 100644 packages/1-framework/1-core/config/src/exports/config-resolve.ts create mode 100644 packages/1-framework/1-core/config/test/config-resolve.test.ts create mode 100644 packages/1-framework/3-tooling/cli/test/helpers/orm-test-cli.ts delete mode 100644 packages/1-framework/3-tooling/config-loader/src/finalize-config.ts delete mode 100644 packages/1-framework/3-tooling/config-loader/test/finalize-config.test.ts diff --git a/docs/architecture docs/adrs/ADR 253 - Config paths resolve against the file that wrote them.md b/docs/architecture docs/adrs/ADR 253 - Config paths resolve against the file that wrote them.md new file mode 100644 index 000000000000..7005adc531b1 --- /dev/null +++ b/docs/architecture docs/adrs/ADR 253 - Config paths resolve against the file that wrote them.md @@ -0,0 +1,130 @@ +# ADR 253 — Config paths resolve against the file that wrote them + +## Decision + +A relative path written in a `prisma.config.ts` is relative to that file. The loader resolves it against the file's directory as part of loading, one file at a time, before any files are merged and before any command sees the config. Nothing changes in how a config is written: + +```ts +// /home/me/app/prisma.config.ts +import { definePrismaConfig } from '@prisma/cli-engine'; +import { defineConfig as ormConfig } from '@prisma/orm-postgres/config'; + +export default definePrismaConfig({ + orm: ormConfig({ + contract: './contract.prisma', + migrations: { dir: './migrations' }, + }), +}); +``` + +Loading this file yields the same object from any working directory. Every path is absolute, and the section records the directory it was resolved against as `rootDir`, for anything that needs the project's location rather than one of its files: + +```ts +{ + orm: { + rootDir: '/home/me/app', + contract: { /* source read from /home/me/app/contract.prisma */ output: '/home/me/app/contract.json' }, + migrations: { dir: '/home/me/app/migrations' }, + }, + $prismaConfig: 1, +} +``` + +## Why the file is the anchor + +Consider a config that does not sit in the directory a command runs from: + +``` +exp/ + sub/ + prisma.config.ts # contract: './contract.prisma' + contract.prisma +``` + +`prisma contract emit --config ./sub/prisma.config.ts`, run from `exp`, must read `exp/sub/contract.prisma`. The author wrote `./contract.prisma` next to the file, and that is the only reading of it that does not change with the caller's shell. Every documented rule agrees: the ORM's `migrations.dir` is documented as relative to the config file, and Prisma Composer and `prisma dev` read paths the same way. + +## Why resolution happens per file, before merging + +Configs can be layered: a file in a child directory extends a file in a parent, and the loader merges the two. A merged section holds paths written in different files. In the example below the merged `orm` section has `contract` from the child and `migrations.dir` from the parent, both still relative, with no record of which file wrote which: + +``` +repo/ + prisma.config.ts # orm: { migrations: { dir: './migrations' } } + apps/shop/ + prisma.config.ts # extends: '../../prisma.config.ts' + # orm: { contract: './contract.prisma' } +``` + +Once merged, no single directory can anchor that section: `./migrations` belongs to `repo/` and `./contract.prisma` to `repo/apps/shop/`. The anchor belongs to each path, and it is lost the moment the sections are merged. So resolution must happen while the loader still holds each file separately, and merging must operate on absolute paths only. + +The loader has what it needs for this. Both loaders that read `prisma.config.ts`, the CLI engine's and the ORM's, evaluate the file through c12, and c12 reports one layer per file: the file's own path and that file's unmerged config. Each layer is resolved against its own file, then the layers are merged. + +## How the loader resolves a section + +The loader does not know which fields of a section are paths. Only the family that defines the section knows that, so resolution is a small protocol between the family's config helper and the loader. + +```mermaid +sequenceDiagram + participant F as prisma.config.ts + participant O as ormConfig + participant L as loader + F->>O: ormConfig({ contract: './contract.prisma' }) + O-->>F: section with relative paths and a resolver + L->>F: evaluate (c12, one layer per file) + F-->>L: layer: configFile + unmerged config + L->>O: resolver(dirname(configFile)) + O-->>L: section with absolute paths and rootDir + L->>L: merge resolved layers +``` + +- A family's config helper returns its section with relative paths untouched and a resolver function stored under the key `Symbol.for('prisma.config.resolve')`. The resolver takes a directory and returns the section with absolute paths and `rootDir` set. +- After c12 evaluates the file, the loader resolves each layer against `dirname(configFile)` and then merges the resolved layers. The engine's loader, which does not know what any section contains, does this through `resolveConfigSections`, a function it exports that calls the resolver on every section that has one, removes the key, and leaves other sections untouched. A loader that belongs to the family, such as the ORM's own, resolves its section directly with the same function the resolver wraps. +- The key is created with `Symbol.for` rather than exported from the engine, so it is the same symbol across two engine copies in one dependency tree, and a family declares it without importing the engine. Symbol-keyed properties survive c12's evaluation and appear on each layer's config. +- `definePrismaConfig` is unchanged: it stamps the `$prismaConfig` marker and nothing else. + +## An unresolved section is refused + +A section that still carries the resolver key has not been resolved. The family's validator refuses it and names what is missing. This is what makes the design safe: a program that evaluates a config without running the resolution step, whether an older engine, a test that imports the file directly, or a new loading route, gets a loud failure, never a path silently resolved against the working directory. + +## Responsibilities + +**The engine** owns `resolveConfigSections`: finding resolvers on a config object, calling them with a directory, and removing the key. It never knows which fields are paths. The key itself is created with `Symbol.for`, so it belongs to no package. + +**A family** owns its config helper, its resolver, and its validator's refusal of an unresolved section: which fields are paths, how each resolves, and `rootDir`. The ORM's resolver covers the contract source, `contract.output`, and `migrations.dir`. + +**Loaders** evaluate the file through c12, resolve each layer against its own file, and merge. Whichever loader runs, the sections it hands over are resolved. + +**Commands** read absolute paths and `rootDir` from the config. A command that needs the project's location, for instance to find the project's `package.json`, starts from `rootDir`. No command reconstructs the config file's path, and no command resolves a config value against the working directory. The one kind of path that is relative to the working directory is a path typed on the command line, such as `--output-path` on `contract emit`, because the shell is where the user wrote it. + +## Layered configs + +Whatever mechanism layering uses to find the files, the loader ends up with one config per file and each file's path. It resolves each against its own file and then merges. `rootDir` follows the family's merge rule for its section; by default the nearest file wins. + +## Mismatched versions + +A dependency tree can pair an engine and a family from different releases. An engine whose loader does not run `resolveConfigSections` hands the family a section with the resolver key still on it; the family's validator refuses it and names the engine version it needs. A family whose helper attaches no resolver hands the loader a section with nothing to resolve, which passes through unchanged. Neither pairing resolves a path against the wrong directory. + +## Consequences + +- Config files are written as before. No argument is added and no path is written differently. +- A config object holds absolute paths and a `rootDir` per section that has paths. Nothing resolves a config path after loading. +- Family config helpers attach a resolver rather than being identity functions over an object literal. The resolver is the one place a family's path fields are enumerated. +- Loading a config means loading it through a loader. Importing the file directly yields unresolved sections, which the family's validator refuses; a test that wants a resolved config resolves what it imported against the directory it stands for. +- The ORM's resolved section carries `rootDir`; the migrations directory defaults to `migrations` under it, supplied after merging so a layer's default never shadows a directory another layer authored. +- `resolveConfigSections` and the loader's per-layer resolution are engine changes, so the engine's version moves and every family's exact engine peer moves with it. + +## Alternatives considered + +**Resolve against the working directory and document it.** Only the unified CLI would follow that rule; every other reader of the file anchors on the file. Authors who run commands from a monorepo root would write `import.meta.dirname` into every path by hand. + +**The engine tells each command which file it loaded.** Correct for a single file: the command resolves against that file's directory. It needs a field on the command context and post-load resolution code in every family, and it fails under layering, where a merged section holds paths from several files and one path cannot anchor them. + +**The engine resolves paths itself.** The engine hands sections over opaquely and cannot know which fields are paths. That knowledge belongs to each family. + +**Resolve inside the file: `definePrismaConfig(import.meta, {...})`.** The file resolves its own sections before any loader sees them, which is fully independent of how the file is loaded. It costs every config file an argument that the loader already knows the value of, since c12 reports the file it evaluated. The loader-side design keeps the same resolver protocol and the same per-file guarantee without the argument. + +**The family helper takes the location: `ormConfig(import.meta, {...})`.** The same as the previous option with the argument repeated on every helper in every file. + +**Hand each family its per-file sections and let the family merge.** Correct under layering, but it moves merging into every family and turns the loader's output into a list. Resolving per layer inside the loader and merging there needs none of it. + +**The engine sets a global before evaluating each file, so a helper can read the current file.** No visible surface, but hidden state that breaks whenever a config is imported outside the engine's loader. The loader-side design has the same independence from the file's contents without the global. diff --git a/packages/1-framework/1-core/config/package.json b/packages/1-framework/1-core/config/package.json index 6af9e8a929f5..152eb0db8924 100644 --- a/packages/1-framework/1-core/config/package.json +++ b/packages/1-framework/1-core/config/package.json @@ -42,6 +42,7 @@ "src" ], "exports": { + "./config-resolve": "./dist/config-resolve.mjs", "./config-types": "./dist/config-types.mjs", "./config-validation": "./dist/config-validation.mjs", "./package.json": "./package.json" diff --git a/packages/1-framework/1-core/config/src/config-resolve.ts b/packages/1-framework/1-core/config/src/config-resolve.ts new file mode 100644 index 000000000000..3e750e4dc010 --- /dev/null +++ b/packages/1-framework/1-core/config/src/config-resolve.ts @@ -0,0 +1,136 @@ +import { resolve } from 'pathe'; +import type { ContractConfig, PrismaNextConfig } from './config-types'; + +/** + * The key under which a config section carries its path resolver until a loader + * has resolved it against the file that wrote it. `Symbol.for` so the key is the + * same across every copy of this package in a dependency tree. + */ +export const CONFIG_RESOLVE: unique symbol = Symbol.for('prisma.config.resolve'); + +export type ConfigResolver = (rootDir: string) => TConfig; + +export interface UnresolvedConfig { + readonly [CONFIG_RESOLVE]: ConfigResolver; +} + +export function isUnresolvedConfig(value: unknown): value is UnresolvedConfig { + return ( + typeof value === 'object' && + value !== null && + typeof (value as Partial>)[CONFIG_RESOLVE] === 'function' + ); +} + +type ContractSourceProvider = NonNullable['source']; + +/** A value that is not a string is left for validation to report. */ +function resolveAuthored(rootDir: string, value: string): string { + return typeof value === 'string' ? resolve(rootDir, value) : value; +} + +function resolveContractSource( + source: ContractSourceProvider, + rootDir: string, +): ContractSourceProvider { + const inputs = source.inputs; + return Array.isArray(inputs) + ? { ...source, inputs: inputs.map((input) => resolveAuthored(rootDir, input)) } + : source; +} + +/** + * Default *source* directory for the contract file the user authors at `init` + * time. Output artefacts colocate with source per the same rule path-bearing + * providers apply. + */ +export const DEFAULT_CONTRACT_SOURCE_DIR = 'src/prisma'; + +export function normalizeContractConfig( + contract: ContractConfig, +): ContractConfig & { readonly output: string } { + // In-memory-only fallback: `typescriptContract(contract)` has no source path + // to anchor on, so normalization supplies a default output colocated with + // the default source directory. + const inMemoryFallbackOutput = `${DEFAULT_CONTRACT_SOURCE_DIR}/contract.json`; + return { + source: contract.source, + output: contract.output ?? inMemoryFallbackOutput, + }; +} + +export function resolveContractConfig(contract: ContractConfig, rootDir: string): ContractConfig { + const normalized = normalizeContractConfig(contract); + return { + ...normalized, + ...(normalized.source ? { source: resolveContractSource(normalized.source, rootDir) } : {}), + output: resolveAuthored(rootDir, normalized.output), + }; +} + +const DEFAULT_MIGRATIONS_DIR = 'migrations'; + +type MigrationsConfig = NonNullable; + +export function resolveMigrationsConfig( + migrations: PrismaNextConfig['migrations'], + rootDir: string, +): PrismaNextConfig['migrations'] { + return migrations?.dir === undefined + ? migrations + : { ...migrations, dir: resolveAuthored(rootDir, migrations.dir) }; +} + +/** + * Supplies the defaults a section may omit, anchored on its `rootDir`. Applied + * once, after every layer has been resolved and merged, so a layer's default + * never shadows a value another layer authored. + */ +export function withConfigDefaults( + config: TConfig & { readonly rootDir: string }, +): TConfig & { readonly migrations: MigrationsConfig & { readonly dir: string } } { + return { + ...config, + migrations: { + ...config.migrations, + dir: config.migrations?.dir ?? resolve(config.rootDir, DEFAULT_MIGRATIONS_DIR), + }, + }; +} + +/** + * Resolves every authored path in the ORM config section against `rootDir`, + * the directory of the config file that wrote it, and records `rootDir` on the + * section. Defaults are not supplied here; see {@link withConfigDefaults}. + * Idempotent: an absolute path resolves to itself. + */ +export function resolveConfigPaths( + config: TConfig, + rootDir: string, +): TConfig { + const { [CONFIG_RESOLVE]: _resolver, ...authored } = config as TConfig & + Partial>; + return { + ...(authored as TConfig), + rootDir, + ...(config.contract ? { contract: resolveContractConfig(config.contract, rootDir) } : {}), + ...(config.migrations + ? { migrations: resolveMigrationsConfig(config.migrations, rootDir) } + : {}), + }; +} + +/** Attaches the resolver a loader calls once it knows which file wrote the section. */ +export function withPathResolver( + config: TConfig, +): TConfig & UnresolvedConfig { + return { ...config, [CONFIG_RESOLVE]: (rootDir: string) => resolveConfigPaths(config, rootDir) }; +} + +/** + * Resolves the section if it still carries its resolver; a section that was + * resolved already, or never carried one, is returned unchanged. + */ +export function resolveConfigSection(value: unknown, rootDir: string): unknown { + return isUnresolvedConfig(value) ? value[CONFIG_RESOLVE](rootDir) : value; +} diff --git a/packages/1-framework/1-core/config/src/config-types.ts b/packages/1-framework/1-core/config/src/config-types.ts index 2e2ae4930f82..0f719cd80f4f 100644 --- a/packages/1-framework/1-core/config/src/config-types.ts +++ b/packages/1-framework/1-core/config/src/config-types.ts @@ -6,6 +6,7 @@ import type { ControlFamilyDescriptor, ControlTargetDescriptor, } from '@internal/framework-components/control'; +import { normalizeContractConfig, withPathResolver } from './config-resolve'; import type { ContractSourceProvider } from './contract-source-types'; /** @@ -38,26 +39,6 @@ export interface FormatterConfig { readonly newline?: 'LF' | 'CRLF'; } -/** - * Default *source* directory for the contract file the user authors at `init` - * time. Output artefacts colocate with source per the same rule path-bearing - * providers apply. - */ -export const DEFAULT_CONTRACT_SOURCE_DIR = 'src/prisma'; - -export function normalizeContractConfig( - contract: ContractConfig, -): ContractConfig & { readonly output: string } { - // In-memory-only fallback: `typescriptContract(contract)` has no source path - // to anchor on, so normalization supplies a default output colocated with - // the default source directory. - const inMemoryFallbackOutput = `${DEFAULT_CONTRACT_SOURCE_DIR}/contract.json`; - return { - source: contract.source, - output: contract.output ?? inMemoryFallbackOutput, - }; -} - /** * Configuration for Prisma 8 CLI. * Uses Control*Descriptor types for type-safe wiring with compile-time compatibility checks. @@ -112,6 +93,12 @@ export interface PrismaNextConfig< readonly dir?: string; }; readonly formatter?: FormatterConfig; + /** + * The directory of the config file that wrote this section, set when the + * section's paths are resolved. Every relative path above is resolved + * against it; commands that need the project's location start here. + */ + readonly rootDir?: string; } /** @@ -124,19 +111,18 @@ export interface PrismaNextConfig< * - contract.output defaults to a path colocated with DEFAULT_CONTRACT_SOURCE_DIR * when missing (in-memory-only providers) * + * Relative paths stay as written. The section carries a resolver under + * `CONFIG_RESOLVE` that a loader calls with the directory of the file that + * wrote the section; see ADR 253. + * * @param config - Raw config input from user * @returns Normalized config IR with defaults applied */ export function defineConfig( config: PrismaNextConfig, ): PrismaNextConfig { - if (config.contract) { - return { - ...config, - contract: normalizeContractConfig(config.contract), - }; - } - - // Return config as-is if no contract (preserve literal types) - return config; + const normalized = config.contract + ? { ...config, contract: normalizeContractConfig(config.contract) } + : config; + return withPathResolver(normalized); } diff --git a/packages/1-framework/1-core/config/src/exports/config-resolve.ts b/packages/1-framework/1-core/config/src/exports/config-resolve.ts new file mode 100644 index 000000000000..73aad1f96605 --- /dev/null +++ b/packages/1-framework/1-core/config/src/exports/config-resolve.ts @@ -0,0 +1,11 @@ +export type { ConfigResolver, UnresolvedConfig } from '../config-resolve'; +export { + CONFIG_RESOLVE, + isUnresolvedConfig, + resolveConfigPaths, + resolveConfigSection, + resolveContractConfig, + resolveMigrationsConfig, + withConfigDefaults, + withPathResolver, +} from '../config-resolve'; diff --git a/packages/1-framework/1-core/config/src/exports/config-types.ts b/packages/1-framework/1-core/config/src/exports/config-types.ts index 973e772dfe3e..450e2a1cf44b 100644 --- a/packages/1-framework/1-core/config/src/exports/config-types.ts +++ b/packages/1-framework/1-core/config/src/exports/config-types.ts @@ -1,9 +1,6 @@ +export { DEFAULT_CONTRACT_SOURCE_DIR, normalizeContractConfig } from '../config-resolve'; export type { ContractConfig, FormatterConfig, PrismaNextConfig } from '../config-types'; -export { - DEFAULT_CONTRACT_SOURCE_DIR, - defineConfig, - normalizeContractConfig, -} from '../config-types'; +export { defineConfig } from '../config-types'; export type { ContractSourceContext, ContractSourceDiagnostic, diff --git a/packages/1-framework/1-core/config/test/config-resolve.test.ts b/packages/1-framework/1-core/config/test/config-resolve.test.ts new file mode 100644 index 000000000000..dee0c3ae1c9c --- /dev/null +++ b/packages/1-framework/1-core/config/test/config-resolve.test.ts @@ -0,0 +1,222 @@ +import { ok } from '@internal/utils/result'; +import { describe, expect, it } from 'vitest'; +import { + CONFIG_RESOLVE, + isUnresolvedConfig, + resolveConfigPaths, + resolveConfigSection, + withConfigDefaults, + withPathResolver, +} from '../src/config-resolve'; +import type { PrismaNextConfig } from '../src/config-types'; + +function createConfig( + contract?: PrismaNextConfig['contract'], + overrides: Partial = {}, +): PrismaNextConfig { + return { + family: { + kind: 'family', + id: 'sql', + familyId: 'sql', + version: '0.0.1', + manifest: {}, + emission: { id: 'sql' } as never, + create: () => ({ familyId: 'sql' }) as never, + }, + target: { + kind: 'target', + familyId: 'sql', + targetId: 'postgres', + id: 'postgres', + version: '0.0.1', + manifest: {}, + contractSerializer: { + deserializeContract: (json) => json as never, + serializeContract: () => ({}), + }, + create: () => ({ familyId: 'sql', targetId: 'postgres' }), + }, + adapter: { + kind: 'adapter', + familyId: 'sql', + targetId: 'postgres', + id: 'postgres', + version: '0.0.1', + manifest: {}, + create: () => ({ familyId: 'sql', targetId: 'postgres' }), + }, + ...(contract ? { contract } : {}), + ...overrides, + } as PrismaNextConfig; +} + +function createSource(inputs?: readonly string[]) { + return { + ...(inputs ? { inputs } : {}), + load: async () => ok({ targetFamily: 'sql' } as never), + }; +} + +describe('resolveConfigPaths', () => { + it('records the root directory on the section', () => { + expect(resolveConfigPaths(createConfig(), '/project').rootDir).toBe('/project'); + }); + + it('leaves the contract absent when the config declares none', () => { + expect(resolveConfigPaths(createConfig(), '/project').contract).toBeUndefined(); + }); + + describe('the migrations directory', () => { + it('resolves a relative dir against the root, not the invocation directory', () => { + const config = createConfig(undefined, { migrations: { dir: 'db' } }); + + expect(resolveConfigPaths(config, '/project').migrations?.dir).toBe('/project/db'); + }); + + it('leaves an unauthored dir absent, so a layer default never shadows another layer', () => { + expect(resolveConfigPaths(createConfig(), '/project').migrations).toBeUndefined(); + }); + + it('leaves a value that is not a string for validation to report', () => { + const config = createConfig(undefined, { migrations: { dir: 7 as unknown as string } }); + + expect(resolveConfigPaths(config, '/project').migrations?.dir).toBe(7); + }); + + it('leaves an absolute dir alone', () => { + const config = createConfig(undefined, { migrations: { dir: '/elsewhere/db' } }); + + expect(resolveConfigPaths(config, '/project').migrations?.dir).toBe('/elsewhere/db'); + }); + }); + + it('resolves relative inputs and output against the root', () => { + const config = createConfig({ + source: createSource(['./schema.prisma', 'nested/extra.prisma']), + output: './generated/contract.json', + }); + + const result = resolveConfigPaths(config, '/project'); + + expect(result.contract?.source.inputs).toEqual([ + '/project/schema.prisma', + '/project/nested/extra.prisma', + ]); + expect(result.contract?.output).toBe('/project/generated/contract.json'); + }); + + it('leaves inputs that are not a list for validation to report', () => { + const config = createConfig({ + source: { + inputs: 'schema.prisma' as unknown as readonly string[], + load: createSource().load, + }, + output: './contract.json', + }); + + expect(resolveConfigPaths(config, '/project').contract?.source.inputs).toBe('schema.prisma'); + }); + + it('preserves the source when inputs are omitted', () => { + const config = createConfig({ source: createSource(), output: './contract.json' }); + + expect(resolveConfigPaths(config, '/project').contract?.source.inputs).toBeUndefined(); + }); + + it('preserves non-contract authoring fields while resolving the contract', () => { + const driver = { id: 'postgres', familyId: 'sql', create: () => ({}) }; + const config = createConfig( + { source: createSource(['./schema.prisma']), output: './generated/contract.json' }, + { driver } as unknown as Partial, + ); + + const result = resolveConfigPaths(config, '/project'); + + expect(result.family).toBe(config.family); + expect(result.target).toBe(config.target); + expect(result.driver).toBe(driver); + }); + + it('is idempotent: resolving a resolved section changes nothing', () => { + const config = createConfig( + { source: createSource(['./schema.prisma']), output: './contract.json' }, + { migrations: { dir: 'db' } }, + ); + const once = resolveConfigPaths(config, '/project'); + + expect(resolveConfigPaths(once, '/somewhere/else')).toEqual({ + ...once, + rootDir: '/somewhere/else', + }); + }); +}); + +describe('withPathResolver', () => { + it('attaches a resolver under the shared key and leaves the paths as written', () => { + const config = withPathResolver( + createConfig({ source: createSource(['./schema.prisma']), output: './contract.json' }), + ); + + expect(isUnresolvedConfig(config)).toBe(true); + expect(config.contract?.source.inputs).toEqual(['./schema.prisma']); + expect(config.rootDir).toBeUndefined(); + }); + + it('the resolver resolves against the directory it is given and drops itself', () => { + const config = withPathResolver( + createConfig({ source: createSource(['./schema.prisma']), output: './contract.json' }), + ); + + const resolved = config[CONFIG_RESOLVE]('/project'); + + expect(isUnresolvedConfig(resolved)).toBe(false); + expect(resolved).toMatchObject({ + rootDir: '/project', + contract: { + source: { inputs: ['/project/schema.prisma'] }, + output: '/project/contract.json', + }, + }); + }); + + it('survives an object spread, so a config extended by hand still resolves', () => { + const config = { ...withPathResolver(createConfig()), migrations: { dir: 'db' } }; + + expect(isUnresolvedConfig(config)).toBe(true); + }); +}); + +describe('resolveConfigSection', () => { + it('resolves a section that carries a resolver', () => { + const section = withPathResolver(createConfig(undefined, { migrations: { dir: 'db' } })); + + expect(resolveConfigSection(section, '/project')).toMatchObject({ + rootDir: '/project', + migrations: { dir: '/project/db' }, + }); + }); + + it('returns anything else unchanged', () => { + const plain = { migrations: { dir: 'db' } }; + + expect(resolveConfigSection(plain, '/project')).toBe(plain); + expect(resolveConfigSection(undefined, '/project')).toBeUndefined(); + }); +}); + +describe('withConfigDefaults', () => { + it('supplies the migrations dir under the root so no caller re-derives it', () => { + const config = resolveConfigPaths(createConfig(), '/project'); + + expect(withConfigDefaults({ ...config, rootDir: '/project' }).migrations.dir).toBe( + '/project/migrations', + ); + }); + + it('keeps an authored migrations dir', () => { + const config = resolveConfigPaths(createConfig(undefined, { migrations: { dir: 'db' } }), '/p'); + + expect(withConfigDefaults({ ...config, rootDir: '/p' }).migrations.dir).toBe('/p/db'); + }); +}); diff --git a/packages/1-framework/1-core/config/test/define-config.test.ts b/packages/1-framework/1-core/config/test/define-config.test.ts index 37d51a8eab83..d6b715a8a3a3 100644 --- a/packages/1-framework/1-core/config/test/define-config.test.ts +++ b/packages/1-framework/1-core/config/test/define-config.test.ts @@ -5,6 +5,7 @@ import type { } from '@internal/framework-components/control'; import { ok } from '@internal/utils/result'; import { describe, expect, it } from 'vitest'; +import { CONFIG_RESOLVE, isUnresolvedConfig } from '../src/config-resolve'; import { defineConfig, type PrismaNextConfig } from '../src/config-types'; const mockHook = { @@ -79,9 +80,13 @@ function createValidConfig(overrides: Record = {}): PrismaNextC } describe('defineConfig', () => { - it('returns the same object when contract is absent', () => { + it('returns the config unchanged apart from the resolver when contract is absent', () => { const config = createValidConfig(); - expect(defineConfig(config)).toBe(config); + + const result = defineConfig(config); + + expect(result).toMatchObject(config); + expect(result.contract).toBeUndefined(); }); it('applies default output path when contract output is missing', () => { @@ -116,3 +121,40 @@ describe('defineConfig', () => { expect(() => defineConfig(invalidConfig)).not.toThrow(); }); }); + +describe('defineConfig path resolution', () => { + it('attaches a resolver and leaves relative paths as written', () => { + const config = defineConfig( + createValidConfig({ + contract: { + source: createSourceProvider({ inputs: ['./schema.prisma'] }), + output: './out/contract.json', + }, + migrations: { dir: './db' }, + }), + ); + + expect(isUnresolvedConfig(config)).toBe(true); + expect(config.contract?.source.inputs).toEqual(['./schema.prisma']); + expect(config.migrations?.dir).toBe('./db'); + }); + + it('resolves every path against the directory the resolver is given', () => { + const config = defineConfig( + createValidConfig({ + contract: { + source: createSourceProvider({ inputs: ['./schema.prisma'] }), + output: './out/contract.json', + }, + migrations: { dir: './db' }, + }), + ); + if (!isUnresolvedConfig(config)) throw new Error('expected a resolver'); + + expect(config[CONFIG_RESOLVE]('/app')).toMatchObject({ + rootDir: '/app', + contract: { source: { inputs: ['/app/schema.prisma'] }, output: '/app/out/contract.json' }, + migrations: { dir: '/app/db' }, + }); + }); +}); diff --git a/packages/1-framework/1-core/config/tsdown.config.ts b/packages/1-framework/1-core/config/tsdown.config.ts index fa2737cff6d3..9448619fbfb3 100644 --- a/packages/1-framework/1-core/config/tsdown.config.ts +++ b/packages/1-framework/1-core/config/tsdown.config.ts @@ -1,5 +1,9 @@ import { defineConfig } from '@repo/tsdown'; export default defineConfig({ - entry: ['src/exports/config-types.ts', 'src/exports/config-validation.ts'], + entry: [ + 'src/exports/config-resolve.ts', + 'src/exports/config-types.ts', + 'src/exports/config-validation.ts', + ], }); diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts index 5e88a5ef2625..986d3edda318 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts @@ -230,7 +230,7 @@ export async function executeContractEmit( ): Promise { const { config, - configPath, + projectDir, outputPath, signal = new AbortController().signal, onProgress, @@ -349,9 +349,11 @@ export async function executeContractEmit( // Which package names the generated files may import is decided by // the nearest manifest above the file being written — the package // that will import it, and the same directory `validateContractDeps` - // resolves against below. A caller holding the config file's path - // may name it instead. - resolveImportSpecifier: createProjectSpecifierResolver(configPath ?? outputJsonPath), + // resolves against below. A caller that knows the project directory + // names it instead. + resolveImportSpecifier: createProjectSpecifierResolver( + projectDir ?? dirname(outputJsonPath), + ), ...ifDefined('shouldPreserveEmpty', contractSerializer.shouldPreserveEmpty), ...ifDefined('sortStorage', contractSerializer.sortStorage), ...ifDefined('supportsNamespaces', config.target.supportsNamespaces), diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts index eccab209e3d0..b12228102e42 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts @@ -44,8 +44,6 @@ export interface ExecuteMigrateShowPlanOptions { readonly config: PrismaNextConfig; /** Directory the command was invoked from. */ readonly cwd: string; - /** `--config` as the user wrote it, used only to locate the migrations directory and for display. */ - readonly configPath?: string; readonly db?: string; readonly to?: string; readonly from?: string; @@ -90,7 +88,6 @@ export async function executeMigrateShowPlan( ): Promise> { const config = options.config; const { configPath, migrationsDir, migrationsRelative, refsDir } = resolveMigrationPaths( - options.configPath, config, options.cwd, ); diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts index 489c9c00f6cb..256725e44baa 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts @@ -40,8 +40,8 @@ export interface MigrationNewOptions { readonly config: PrismaNextConfig; /** Directory the command was invoked from. */ readonly cwd: string; - /** `--config` as the user wrote it, used only to locate project paths and for display. */ - readonly configPath?: string; + /** The project's directory, normally the resolved config's `rootDir`; locates the project manifest. */ + readonly projectDir?: string; readonly name?: string; readonly from?: string; /** Renders the declarations of the destination snapshot from its `contract.json`. */ @@ -62,7 +62,6 @@ export async function executeMigrationNewCommand( const config = options.config; const cwd = options.cwd; const { migrationsDir, appMigrationsDir, appMigrationsRelative } = resolveMigrationPaths( - options.configPath, config, cwd, ); @@ -199,7 +198,7 @@ export async function executeMigrationNewCommand( // Before any write: an unreadable or contradictory project manifest fails // the command outright rather than after a half-scaffolded migration // directory is already on disk. - const resolveSpecifier = createProjectSpecifierResolver(options.configPath); + const resolveSpecifier = createProjectSpecifierResolver(options.projectDir); const declarations = await renderSnapshotDeclarations({ client: options.client, contractJson: parsedContract, diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts index 48f7bec8a894..d51c84b7ea87 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts @@ -63,8 +63,8 @@ export interface MigrationPlanOptions { readonly config: PrismaNextConfig; /** Directory the command was invoked from. */ readonly cwd: string; - /** `--config` as the user wrote it, used only to locate project paths and for display. */ - readonly configPath?: string; + /** The project's directory, normally the resolved config's `rootDir`; locates the project manifest. */ + readonly projectDir?: string; readonly name?: string; readonly from?: string; readonly to?: string; @@ -264,7 +264,7 @@ async function executeMigrationPlanCommandInner( const config = options.config; const cwd = options.cwd; const { configPath, migrationsDir, appMigrationsDir, appMigrationsRelative } = - resolveMigrationPaths(options.configPath, config, cwd); + resolveMigrationPaths(config, cwd); const contractPathAbsolute = resolveContractPath(config); const contractPath = relative(cwd, contractPathAbsolute); @@ -393,7 +393,7 @@ async function executeMigrationPlanCommandInner( // Before the seed phase, which is the first thing here that writes: an // unreadable or contradictory project manifest fails the command outright // rather than after artifacts are already on disk. - const resolveImportSpecifier = createProjectSpecifierResolver(options.configPath); + const resolveImportSpecifier = createProjectSpecifierResolver(options.projectDir); // Likewise the destination snapshot's declarations: rendered now, written // with the planned package later. A plan whose source already is the diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-advancement.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-advancement.ts index 3a5e1168a9d5..5cced6fa1ff7 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-advancement.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-advancement.ts @@ -48,7 +48,7 @@ export async function preflightRefAdvancement(args: { readonly name: string; readonly contractJson: Record; readonly contractJsonPath: string; - readonly configPath: string; + readonly projectDir: string; readonly client: Pick; }): Promise> { if (!validateRefName(args.name)) { @@ -56,7 +56,7 @@ export async function preflightRefAdvancement(args: { } let resolveImportSpecifier: ReturnType; try { - resolveImportSpecifier = createProjectSpecifierResolver(args.configPath); + resolveImportSpecifier = createProjectSpecifierResolver(args.projectDir); } catch (error) { if (CliStructuredError.is(error)) { return notOk(error); diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/ref.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/ref.ts index 68e8bec8730e..1d73ac3786b5 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/ref.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/ref.ts @@ -63,8 +63,6 @@ export interface RefOperationOptions { readonly config: PrismaNextConfig; /** Directory the command was invoked from. */ readonly cwd: string; - /** `--config` as the user wrote it, used only to locate the migrations directory and for display. */ - readonly configPath?: string; } function cliErrorInvalidRefName(name: string): CliStructuredError { @@ -85,11 +83,7 @@ export async function executeRefSetCommand( const config = options.config; try { - const { migrationsDir, refsDir } = resolveMigrationPaths( - options.configPath, - config, - options.cwd, - ); + const { migrationsDir, refsDir } = resolveMigrationPaths(config, options.cwd); const loaded = await buildReadAggregate(config, { migrationsDir }); if (!loaded.ok) { return notOk(loaded.failure); @@ -157,7 +151,7 @@ export async function executeRefDeleteCommand( options: RefOperationOptions, ): Promise> { try { - const { refsDir } = resolveMigrationPaths(options.configPath, options.config, options.cwd); + const { refsDir } = resolveMigrationPaths(options.config, options.cwd); await deleteRef(refsDir, name); return ok({ ok: true as const, ref: name, deleted: true as const }); } catch (error) { @@ -170,7 +164,7 @@ export async function executeRefListCommand( options: RefOperationOptions, ): Promise> { try { - const { refsDir } = resolveMigrationPaths(options.configPath, options.config, options.cwd); + const { refsDir } = resolveMigrationPaths(options.config, options.cwd); const refs = await readRefs(refsDir); return ok({ ok: true as const, refs }); } catch (error) { diff --git a/packages/1-framework/3-tooling/cli/src/control-api/types.ts b/packages/1-framework/3-tooling/cli/src/control-api/types.ts index 01e92f0fcbb8..099d8d0a05e4 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/types.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/types.ts @@ -788,13 +788,12 @@ export interface ContractEmitOptions { /** Directory the caller was invoked from. */ readonly cwd: string; /** - * Path to the prisma.config.ts file. Used to find the project manifest - * whose dependencies decide the import specifiers in emitted files; the - * config itself is never read from it. Omit it and the manifest is looked up - * from the directory the artifacts are written to, which is the package that - * will import them. + * The project's directory, normally the resolved config's `rootDir`. Used to + * find the project manifest whose dependencies decide the import specifiers + * in emitted files. Omit it and the manifest is looked up from the directory + * the artifacts are written to, which is the package that will import them. */ - readonly configPath?: string; + readonly projectDir?: string; /** * Directory to write contract artifacts into. When set, `contract.json` and * `contract.d.ts` are written inside this directory, taking precedence over diff --git a/packages/1-framework/3-tooling/cli/src/orm/config-section.ts b/packages/1-framework/3-tooling/cli/src/orm/config-section.ts index cb0d885eba06..557c506ed9fc 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/config-section.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/config-section.ts @@ -1,3 +1,4 @@ +import { isUnresolvedConfig } from '@internal/config/config-resolve'; import type { PrismaNextConfig } from '@internal/config/config-types'; import type { ConfigValidationIssue } from '@internal/config/config-validation'; import { collectConfigIssues } from '@internal/config/config-validation'; @@ -73,6 +74,38 @@ function unreadableDiagnostic(error: unknown): Diagnostic { }; } +/** + * The section still carries the resolver a loader calls with the directory of + * the file that wrote it (ADR 253). The engine that loaded this config did not + * call it, so its paths are still as written and cannot be trusted. + */ +function unresolvedDiagnostic(): Diagnostic { + return { + code: 'CONFIG.VALIDATION_FAILED', + severity: 'error', + summary: 'Prisma ORM configuration was loaded without resolving its paths', + why: 'The CLI that loaded prisma.config.ts predates path resolution for config sections, so relative paths in the orm section have no anchor.', + nextActions: [ + { + kind: 'user-choice', + label: 'Update the prisma CLI to a version that resolves config sections.', + }, + ], + }; +} + +function noRootDirDiagnostic(): Diagnostic { + return { + code: 'CONFIG.VALIDATION_FAILED', + severity: 'error', + summary: 'Prisma ORM configuration does not record the directory it was written in', + why: `The ${ORM_CONFIG_SECTION_NAME} section carries no rootDir, so relative paths in it have no anchor. A section built with defineConfig from your database facade package records it when the config is loaded.`, + nextActions: [ + { kind: 'edit-file', label: 'Build the orm section with defineConfig in prisma.config.ts' }, + ], + }; +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -135,6 +168,9 @@ function validate(raw: unknown): SectionValidation { if (!isRecord(raw)) { return { ok: false as const, diagnostics: [notAnObjectDiagnostic()] }; } + if (isUnresolvedConfig(raw)) { + return { ok: false as const, diagnostics: [unresolvedDiagnostic()] }; + } let issues: readonly ConfigValidationIssue[]; try { @@ -146,6 +182,9 @@ function validate(raw: unknown): SectionValidation { if (issues.length > 0) { return { ok: false as const, diagnostics: issues.map(issueDiagnostic) }; } + if (typeof raw['rootDir'] !== 'string') { + return { ok: false as const, diagnostics: [noRootDirDiagnostic()] }; + } return { ok: true as const, diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/init.ts b/packages/1-framework/3-tooling/cli/src/orm/db/init.ts index 03ef268b7de9..95962ac997b7 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/init.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/init.ts @@ -23,7 +23,7 @@ import type { MigrationCommandResult } from '../../utils/formatters/migrations'; import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { dbFlag } from '../flags'; -import { projectConfigPathFor } from '../migration/paths'; +import { projectRootFor } from '../migration/paths'; import { normalizeError } from '../normalize-error'; import { controlProgressReporter } from '../progress'; import { migrationResultBlocks, migrationResultNextActions } from './migration-blocks'; @@ -156,7 +156,7 @@ export function createDbInitCommand(createClient: CreateControlClient) { name: refName, contractJson, contractJsonPath: contractPath, - configPath: projectConfigPathFor(ctx), + projectDir: projectRootFor(ctx.config), client, }); if (!preflight.ok) { diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/prepare.ts b/packages/1-framework/3-tooling/cli/src/orm/db/prepare.ts index 239b8bf96f21..fc1fb5895a6e 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/prepare.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/prepare.ts @@ -77,7 +77,7 @@ export async function prepareMigrationRun(inputs: { readonly createClient: CreateControlClient; }): Promise> { const { config, cwd, commandName } = inputs; - const contractPath = contractPathFor(config, cwd); + const contractPath = contractPathFor(config); if (contractPath === undefined) { return notOk( normalizeError( @@ -134,7 +134,7 @@ export async function prepareMigrationRun(inputs: { contractDisplayPath: displayPath(contractPath, cwd), dbConnection, database: typeof dbConnection === 'string' ? maskConnectionUrl(dbConnection) : undefined, - migrationsDir: migrationsDirFor(config, cwd), - refsDir: appRefsDirFor(config, cwd), + migrationsDir: migrationsDirFor(config), + refsDir: appRefsDirFor(config), }); } diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts b/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts index 5702359711e1..3581075c9613 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts @@ -22,12 +22,7 @@ import { runCommandAction } from '../../utils/next-actions'; import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { dbFlag } from '../flags'; -import { - appRefsDirFor, - displayPath, - migrationsDirFor, - projectConfigPathFor, -} from '../migration/paths'; +import { appRefsDirFor, displayPath, migrationsDirFor, projectRootFor } from '../migration/paths'; import { normalizeError } from '../normalize-error'; import { controlProgressReporter } from '../progress'; import { @@ -267,7 +262,7 @@ export function createDbSignCommand( return notOk(emitted.failure); } - const migrationsDir = migrationsDirFor(ctx.config, ctx.cwd); + const migrationsDir = migrationsDirFor(ctx.config); let contractInput: unknown = emitted.value.contract; let signedSource: SignedContractSource; if (contractRef !== undefined) { @@ -307,7 +302,7 @@ export function createDbSignCommand( name: refName, contractJson: signedSource.json, contractJsonPath: signedSource.jsonPath, - configPath: projectConfigPathFor(ctx), + projectDir: projectRootFor(ctx.config), client, }); if (!preflight.ok) { @@ -389,7 +384,7 @@ export function createDbSignCommand( ); } - const refsDir = appRefsDirFor(ctx.config, ctx.cwd); + const refsDir = appRefsDirFor(ctx.config); const previousHash = await previousRefHash(refsDir, advancement.name); const advanced = await advanceRefSafely({ refsDir, diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/update.ts b/packages/1-framework/3-tooling/cli/src/orm/db/update.ts index e4e872989a26..87bce1c2eb28 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/update.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/update.ts @@ -28,7 +28,7 @@ import type { MigrationCommandResult } from '../../utils/formatters/migrations'; import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { dbFlag } from '../flags'; -import { projectConfigPathFor } from '../migration/paths'; +import { projectRootFor } from '../migration/paths'; import { normalizeError } from '../normalize-error'; import { controlProgressReporter } from '../progress'; import { @@ -191,7 +191,7 @@ export function createDbUpdateCommand(createClient: CreateControlClient) { name: refName, contractJson, contractJsonPath: snapshotContractPath, - configPath: projectConfigPathFor(ctx), + projectDir: projectRootFor(ctx.config), client, }); if (!preflight.ok) { diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/verification.ts b/packages/1-framework/3-tooling/cli/src/orm/db/verification.ts index 43e18a9f8b6c..e080907e7bbc 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/verification.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/verification.ts @@ -46,7 +46,7 @@ export async function readEmittedContract(inputs: { readonly cwd: string; readonly commandName: string; }): Promise> { - const path = contractPathFor(inputs.config, inputs.cwd); + const path = contractPathFor(inputs.config); if (path === undefined) { return notOk( normalizeError( diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/verify.ts b/packages/1-framework/3-tooling/cli/src/orm/db/verify.ts index cc8d5319c5c5..162a37016d97 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/verify.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/verify.ts @@ -438,7 +438,7 @@ export function createDbVerifyCommand( strict, database: maskConnectionUrl(dbConnection), }); - const migrationsDir = migrationsDirFor(ctx.config, ctx.cwd); + const migrationsDir = migrationsDirFor(ctx.config); const client = createClient({ family: ctx.config.family, target: ctx.config.target, diff --git a/packages/1-framework/3-tooling/cli/src/orm/define-command.ts b/packages/1-framework/3-tooling/cli/src/orm/define-command.ts index 78dfbe409f46..1d996d0d5819 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/define-command.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/define-command.ts @@ -1,6 +1,3 @@ -import type { PrismaNextConfig } from '@internal/config/config-types'; -import { finalizeConfig } from '@internal/config-loader'; -import { blindCast } from '@internal/utils/casts'; import { isInternalError } from '@internal/utils/internal-error'; import type { ArgsSpec, @@ -14,7 +11,6 @@ import type { } from '@prisma/cli-engine'; import { defineCommand } from '@prisma/cli-engine'; import { notOk } from '@prisma/cli-engine/protocol'; -import { dirname } from 'pathe'; import { normalizeError } from './normalize-error'; /** @@ -31,41 +27,6 @@ import { normalizeError } from './normalize-error'; * Prisma ORM rather than something the user did. Re-throwing lets the engine settle it as a bug * at exit 1, where converting it would report the same number as a bad connection string. */ -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -/** - * Hands the handler a config whose paths are absolute. The engine's own - * loader evaluates `prisma.config.ts` without touching the paths inside it, so - * a command mounted in the unified host receives `contract.output` and - * `migrations.dir` exactly as authored — usually relative — while this repo's - * bin finalizes them in its loader. Anchoring here, on the section every ORM - * command reads, makes both hosts hand handlers the same absolute paths. - * - * A relative path in the file is relative to the file, so the anchor is the - * directory of the config file the engine loaded; the working directory is the - * anchor only when no file was loaded. Finalization is idempotent — an - * already-absolute path resolves to itself — so a config that arrived - * finalized passes through unchanged. - */ -function finalizedConfigContext< - TCtx extends { - readonly cwd: string; - readonly configFile: string | null; - readonly config: unknown; - }, ->(ctx: TCtx): TCtx { - if (!isRecord(ctx.config)) { - return ctx; - } - const config = blindCast< - PrismaNextConfig, - 'every ORM command that declares needs.config reads the orm section, whose validated value is PrismaNextConfig' - >(ctx.config); - const configDir = ctx.configFile === null ? ctx.cwd : dirname(ctx.configFile); - return { ...ctx, config: finalizeConfig(config, configDir) }; -} export function defineOrmCommand< TFlags extends Record> = Record>, @@ -90,7 +51,7 @@ export function defineOrmCommand< ...def, handler: async (args, ctx) => { try { - return await def.handler(args, finalizedConfigContext(ctx)); + return await def.handler(args, ctx); } catch (error) { if (isInternalError(error)) { throw error; diff --git a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts index cc8b8ef99b7a..a38eea5b1035 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts @@ -51,7 +51,7 @@ import { perSpaceBlocks } from './db/migration-blocks'; import { prepareMigrationRun } from './db/prepare'; import { defineOrmCommand } from './define-command'; import { dbFlag } from './flags'; -import { displayPath, migrationsDirFor, projectConfigPathFor } from './migration/paths'; +import { displayPath, migrationsDirFor, projectRootFor } from './migration/paths'; import { normalizeError } from './normalize-error'; import { controlProgressReporter } from './progress'; @@ -243,7 +243,7 @@ export function createMigrateCommand(createClient: CreateControlClient) { handler: async (args, ctx) => { // `migrate` walks every contract space, so the header names the root they // all live under rather than the app subspace. - const migrationsRelative = displayPath(migrationsDirFor(ctx.config, ctx.cwd), ctx.cwd); + const migrationsRelative = displayPath(migrationsDirFor(ctx.config), ctx.cwd); if (args.flags.show) { const planned = await executeMigrateShowPlan({ @@ -423,7 +423,7 @@ export function createMigrateCommand(createClient: CreateControlClient) { name: args.flags.advanceRef, contractJson: snapshotContractJson, contractJsonPath: snapshotContractPath, - configPath: projectConfigPathFor(ctx), + projectDir: projectRootFor(ctx.config), client, }); if (!preflight.ok) { diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/check.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/check.ts index a36c541ce0b8..b839bb927664 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/check.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/check.ts @@ -125,8 +125,8 @@ export const migrationCheckCommand = defineOrmCommand({ handler: async (args, ctx) => { const { target } = args.positionals; const spaceFilter = args.flags.space; - const migrationsDir = migrationsDirFor(ctx.config, ctx.cwd); - const appMigrationsDir = appMigrationsDirFor(ctx.config, ctx.cwd); + const migrationsDir = migrationsDirFor(ctx.config); + const appMigrationsDir = appMigrationsDirFor(ctx.config); const appMigrationsRelative = displayPath(appMigrationsDir, ctx.cwd); const loaded = await buildReadAggregate(ctx.config, { migrationsDir }); diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/graph.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/graph.ts index 97a25dae44c0..9e46f9507171 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/graph.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/graph.ts @@ -112,7 +112,7 @@ export const migrationGraphCommand = defineOrmCommand({ return notOk(normalizeError(errorLegendHumanOnly('--dot'))); } - const migrationsDir = migrationsDirFor(ctx.config, ctx.cwd); + const migrationsDir = migrationsDirFor(ctx.config); const loaded = await buildReadAggregate(ctx.config, { migrationsDir }); if (!loaded.ok) { return notOk(normalizeError(loaded.failure)); diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/list.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/list.ts index f00cea7cb715..444e5aca02e4 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/list.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/list.ts @@ -73,7 +73,7 @@ export const migrationListCommand = defineOrmCommand({ }, needs: { config: ormConfigSection }, handler: async (args, ctx) => { - const migrationsDir = migrationsDirFor(ctx.config, ctx.cwd); + const migrationsDir = migrationsDirFor(ctx.config); const loaded = await buildReadAggregate(ctx.config, { migrationsDir }); if (!loaded.ok) { diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/new.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/new.ts index 625d7a06fb57..33920715db20 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/new.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/new.ts @@ -12,7 +12,7 @@ import { runCommandAction } from '../../utils/next-actions'; import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { normalizeError } from '../normalize-error'; -import { appMigrationsDirFor, contractPathFor, displayPath, projectConfigPathFor } from './paths'; +import { appMigrationsDirFor, contractPathFor, displayPath, projectRootFor } from './paths'; function newPresentations(inputs: { readonly document: MigrationNewResult; @@ -90,7 +90,7 @@ export function createMigrationNewCommand(createClient: CreateControlClient) { const scaffolded = await executeMigrationNewCommand({ config: ctx.config, cwd: ctx.cwd, - configPath: projectConfigPathFor(ctx), + projectDir: projectRootFor(ctx.config), ...ifDefined('name', args.flags.name), ...ifDefined('from', args.flags.from), client: createClient({ @@ -105,7 +105,7 @@ export function createMigrationNewCommand(createClient: CreateControlClient) { return notOk(normalizeError(scaffolded.failure)); } - const contractPath = contractPathFor(ctx.config, ctx.cwd); + const contractPath = contractPathFor(ctx.config); return ok( ctx.present( { data: scaffolded.value }, @@ -113,7 +113,7 @@ export function createMigrationNewCommand(createClient: CreateControlClient) { document: scaffolded.value, contractPath: contractPath === undefined ? '(unset)' : displayPath(contractPath, ctx.cwd), - appMigrationsRelative: displayPath(appMigrationsDirFor(ctx.config, ctx.cwd), ctx.cwd), + appMigrationsRelative: displayPath(appMigrationsDirFor(ctx.config), ctx.cwd), }), ), ); diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/paths.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/paths.ts index e2b77d149a8f..b07263b14a46 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/paths.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/paths.ts @@ -1,50 +1,41 @@ import type { PrismaNextConfig } from '@internal/config/config-types'; import { APP_SPACE_ID } from '@internal/framework-components/control'; import { spaceMigrationDirectory } from '@internal/migration-tools/spaces'; +import { InternalError } from '@internal/utils/internal-error'; import { relative, resolve } from 'pathe'; /** - * Where migrations live for this project. The command boundary has already - * resolved `migrations.dir` against the config file's directory, so `cwd` - * only anchors a config handed in raw, as tests do. + * The directory of the config file that wrote the section. The section + * validator refuses a config without it, so its absence here is a bug. */ -export function migrationsDirFor(config: PrismaNextConfig, cwd: string): string { - return resolve(cwd, config.migrations?.dir ?? 'migrations'); +export function projectRootFor(config: PrismaNextConfig): string { + if (config.rootDir === undefined) { + throw new InternalError('the orm config section reached a command without rootDir'); + } + return config.rootDir; } -/** The app subspace under {@link migrationsDirFor}. */ -export function appMigrationsDirFor(config: PrismaNextConfig, cwd: string): string { - return spaceMigrationDirectory(migrationsDirFor(config, cwd), APP_SPACE_ID); +/** Where migrations live for this project; the resolved config carries it absolute. */ +export function migrationsDirFor(config: PrismaNextConfig): string { + return config.migrations?.dir ?? resolve(projectRootFor(config), 'migrations'); } -/** - * The config file an operation should anchor its project paths on: the file - * the engine loaded, which `--config` may have placed outside the invocation - * directory. With no loaded file it is the invocation directory's own. - */ -export function projectConfigPathFor(ctx: { - readonly cwd: string; - readonly configFile: string | null; -}): string { - return ctx.configFile ?? resolve(ctx.cwd, 'prisma.config.ts'); +/** The app subspace under {@link migrationsDirFor}. */ +export function appMigrationsDirFor(config: PrismaNextConfig): string { + return spaceMigrationDirectory(migrationsDirFor(config), APP_SPACE_ID); } /** * Where refs live. The framework keeps them under the app subspace rather than * at the migrations root. */ -export function appRefsDirFor(config: PrismaNextConfig, cwd: string): string { - return resolve(appMigrationsDirFor(config, cwd), 'refs'); +export function appRefsDirFor(config: PrismaNextConfig): string { + return resolve(appMigrationsDirFor(config), 'refs'); } -/** - * The emitted contract. The config loader has already resolved - * `contract.output` against the config file's directory, so this only has an - * effect for a config handed in raw, as tests do. - */ -export function contractPathFor(config: PrismaNextConfig, cwd: string): string | undefined { - const output = config.contract?.output; - return output === undefined ? undefined : resolve(cwd, output); +/** The emitted contract; the resolved config carries `contract.output` absolute. */ +export function contractPathFor(config: PrismaNextConfig): string | undefined { + return config.contract?.output; } /** diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts index 735da0a3d1d4..09c482626a43 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts @@ -19,7 +19,7 @@ import { contractPathFor, displayPath, migrationsDirFor, - projectConfigPathFor, + projectRootFor, } from './paths'; function hashRow(label: string, hash: string | null): { label: string; value: Text } { @@ -256,7 +256,7 @@ export function createMigrationPlanCommand(createClient: CreateControlClient) { { config: ctx.config, cwd: ctx.cwd, - configPath: projectConfigPathFor(ctx), + projectDir: projectRootFor(ctx.config), ...ifDefined('name', args.flags.name), ...ifDefined('from', args.flags.from), ...ifDefined('to', args.flags.to), @@ -281,7 +281,7 @@ export function createMigrationPlanCommand(createClient: CreateControlClient) { text: `Total time: ${planned.value.timings.total}ms`, }); - const contractPath = contractPathFor(ctx.config, ctx.cwd); + const contractPath = contractPathFor(ctx.config); return ok( ctx.present( { data: planned.value }, @@ -289,8 +289,8 @@ export function createMigrationPlanCommand(createClient: CreateControlClient) { document: planned.value, contractPath: contractPath === undefined ? '(unset)' : displayPath(contractPath, ctx.cwd), - appMigrationsRelative: displayPath(appMigrationsDirFor(ctx.config, ctx.cwd), ctx.cwd), - migrationsRelative: displayPath(migrationsDirFor(ctx.config, ctx.cwd), ctx.cwd), + appMigrationsRelative: displayPath(appMigrationsDirFor(ctx.config), ctx.cwd), + migrationsRelative: displayPath(migrationsDirFor(ctx.config), ctx.cwd), from: args.flags.from, to: args.flags.to, name: args.flags.name, diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/show.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/show.ts index 39da71366514..a8b789852f17 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/show.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/show.ts @@ -223,7 +223,7 @@ export const migrationShowCommand = defineOrmCommand({ needs: { config: ormConfigSection }, handler: async (args, ctx) => { const { target } = args.positionals; - const contractPath = contractPathFor(ctx.config, ctx.cwd); + const contractPath = contractPathFor(ctx.config); if (contractPath === undefined) { return notOk( normalizeError( @@ -234,8 +234,8 @@ export const migrationShowCommand = defineOrmCommand({ ), ); } - const migrationsDir = migrationsDirFor(ctx.config, ctx.cwd); - const appMigrationsDir = appMigrationsDirFor(ctx.config, ctx.cwd); + const migrationsDir = migrationsDirFor(ctx.config); + const appMigrationsDir = appMigrationsDirFor(ctx.config); const appMigrationsRelative = displayPath(appMigrationsDir, ctx.cwd); let contractJson: string; diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts index 3be733a6656f..8b5fcbdeb7b2 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts @@ -244,7 +244,7 @@ export const migrationStatusCommand = defineOrmCommand({ }, needs: { config: ormConfigSection }, handler: async (args, ctx) => { - const migrationsDir = migrationsDirFor(ctx.config, ctx.cwd); + const migrationsDir = migrationsDirFor(ctx.config); const dbConnection = args.flags.db ?? ctx.config.db?.connection; const hasDriver = ctx.config.driver !== undefined; const usingFromOverride = args.flags.from !== undefined; @@ -261,7 +261,7 @@ export const migrationStatusCommand = defineOrmCommand({ } } - const refsResult = await readMigrationRefs(appRefsDirFor(ctx.config, ctx.cwd)); + const refsResult = await readMigrationRefs(appRefsDirFor(ctx.config)); if (!refsResult.ok) { return notOk(normalizeError(refsResult.failure)); } @@ -276,7 +276,7 @@ export const migrationStatusCommand = defineOrmCommand({ const { aggregate, contractHash } = loaded.value; const contractConfig = { - contract: ifDefined('output', contractPathFor(ctx.config, ctx.cwd)), + contract: ifDefined('output', contractPathFor(ctx.config)), }; try { await readContractEnvelope(contractConfig); diff --git a/packages/1-framework/3-tooling/cli/src/utils/command-helpers.ts b/packages/1-framework/3-tooling/cli/src/utils/command-helpers.ts index b1f672f3ade4..41d7e5cef59b 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/command-helpers.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/command-helpers.ts @@ -43,8 +43,7 @@ export function resolveContractPath(config: { contract?: { output?: string } }): * the result is computed against it. */ export function resolveMigrationPaths( - configOption: string | undefined, - config: { migrations?: { dir?: string } }, + config: { rootDir?: string; migrations?: { dir?: string } }, cwd: string, ): { configPath: string; @@ -54,12 +53,10 @@ export function resolveMigrationPaths( appMigrationsRelative: string; refsDir: string; } { - const resolvedConfigPath = configOption ? resolve(cwd, configOption) : undefined; - const configPath = resolvedConfigPath ? relative(cwd, resolvedConfigPath) : 'prisma.config.ts'; - const migrationsDir = resolve( - resolvedConfigPath ? resolve(resolvedConfigPath, '..') : cwd, - config.migrations?.dir ?? 'migrations', - ); + const configPath = 'prisma.config.ts'; + // A resolved config carries the directory absolute; a config handed in raw + // by a programmatic caller is anchored on its rootDir, else on cwd. + const migrationsDir = resolve(config.rootDir ?? cwd, config.migrations?.dir ?? 'migrations'); const migrationsRelative = relative(cwd, migrationsDir); const appMigrationsDir = spaceMigrationDirectory(migrationsDir, APP_SPACE_ID); const appMigrationsRelative = relative(cwd, appMigrationsDir); diff --git a/packages/1-framework/3-tooling/cli/src/utils/project-import-root.ts b/packages/1-framework/3-tooling/cli/src/utils/project-import-root.ts index 0c161e8bd969..0e7848427351 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/project-import-root.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/project-import-root.ts @@ -99,15 +99,15 @@ function declaredDependencies(manifest: Record): string[] { } /** - * The import root for the project that owns `configPath`, or for the working - * directory when the config was discovered rather than named. + * The import root for the project at `projectDir`, or for the working + * directory when no project directory is known. * * A project with no manifest, or one that names no published package, is on * the internal root — which emits every specifier exactly as authored, so a * project that has not moved to published names is unaffected. */ -export function projectImportRoot(configPath?: string): ImportRoot { - const start = configPath === undefined ? resolve('.') : dirname(resolve(configPath)); +export function projectImportRoot(projectDir?: string): ImportRoot { + const start = projectDir === undefined ? resolve('.') : resolve(projectDir); const nearest = nearestManifest(start); if (nearest === undefined) return internalImportRoot; try { @@ -123,7 +123,7 @@ export function projectImportRoot(configPath?: string): ImportRoot { } } -/** The specifier resolver emission should use for the project owning `configPath`. */ -export function createProjectSpecifierResolver(configPath?: string): ImportSpecifierResolver { - return createImportSpecifierResolver(projectImportRoot(configPath)); +/** The specifier resolver emission should use for the project at `projectDir`. */ +export function createProjectSpecifierResolver(projectDir?: string): ImportSpecifierResolver { + return createImportSpecifierResolver(projectImportRoot(projectDir)); } diff --git a/packages/1-framework/3-tooling/cli/test/commands/migration-ref-error-mapping.test.ts b/packages/1-framework/3-tooling/cli/test/commands/migration-ref-error-mapping.test.ts index b43cd4997880..224a9fdbcee0 100644 --- a/packages/1-framework/3-tooling/cli/test/commands/migration-ref-error-mapping.test.ts +++ b/packages/1-framework/3-tooling/cli/test/commands/migration-ref-error-mapping.test.ts @@ -11,7 +11,6 @@ const config = { migrations: { dir: 'migrations' } } as unknown as PrismaNextCon describe('migration-ref MigrationToolsError envelope passthrough', () => { let tempDir: string; - let configPath: string; beforeEach(async () => { tempDir = join( @@ -26,7 +25,6 @@ describe('migration-ref MigrationToolsError envelope passthrough', () => { `${JSON.stringify({ hash: HASH_A, invariants: [] }, null, 2)}\n`, 'utf-8', ); - configPath = join(tempDir, 'prisma.config.ts'); }); afterEach(async () => { @@ -41,7 +39,6 @@ describe('migration-ref MigrationToolsError envelope passthrough', () => { const result = await executeRefDeleteCommand('does-not-exist', { config, cwd: tempDir, - configPath, }); expect(result.ok).toBe(false); diff --git a/packages/1-framework/3-tooling/cli/test/config-types.test.ts b/packages/1-framework/3-tooling/cli/test/config-types.test.ts index b3cd5aa48849..dbbf13dddce7 100644 --- a/packages/1-framework/3-tooling/cli/test/config-types.test.ts +++ b/packages/1-framework/3-tooling/cli/test/config-types.test.ts @@ -99,9 +99,9 @@ describe('defineConfig', () => { extensions: [], }; - it('returns the config object unchanged when no contract', () => { + it('returns the config unchanged apart from its path resolver when no contract', () => { const result = defineConfig(baseConfig); - expect(result).toBe(baseConfig); + expect(result).toMatchObject(baseConfig); expect(result.family.familyId).toBe('sql'); expect(result.target.id).toBe('postgres'); expect(result.adapter.id).toBe('postgres'); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/migrate-show-plan.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/migrate-show-plan.test.ts index e4696317c7f9..18e7fb9b2329 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/migrate-show-plan.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/migrate-show-plan.test.ts @@ -35,7 +35,6 @@ describe('executeMigrateShowPlan', () => { let tempDir: string; let migrationsDir: string; let appMigrationsDir: string; - let configPath: string; let config: PrismaNextConfig; let firstDirName: string; let firstMigrationHash: string; @@ -77,7 +76,6 @@ describe('executeMigrateShowPlan', () => { migrationsDir = join(tempDir, 'migrations'); appMigrationsDir = join(migrationsDir, 'app'); await mkdir(join(appMigrationsDir, 'refs'), { recursive: true }); - configPath = join(tempDir, 'prisma.config.ts'); await writeFile(join(tempDir, 'contract.json'), JSON.stringify(contractJsonForHash(HASH_B))); const first = await writeAttestedMigration({ @@ -124,7 +122,6 @@ describe('executeMigrateShowPlan', () => { const result = await executeMigrateShowPlan({ config, cwd: tempDir, - configPath, from: HASH_A, }); expect(result.ok).toBe(true); @@ -149,7 +146,6 @@ describe('executeMigrateShowPlan', () => { const result = await executeMigrateShowPlan({ config, cwd: tempDir, - configPath, from: EMPTY_CONTRACT_HASH, }); expect(result.ok).toBe(true); @@ -168,7 +164,6 @@ describe('executeMigrateShowPlan', () => { const result = await executeMigrateShowPlan({ config, cwd: tempDir, - configPath, from: HASH_A, onPreflightComplete, }); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/migration-plan-prologue.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/migration-plan-prologue.test.ts index d418dacc0a3d..963d6fd11be6 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/migration-plan-prologue.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/migration-plan-prologue.test.ts @@ -37,7 +37,7 @@ describe('executeMigrationPlanCommand — mutation-prologue guard', () => { ); const result = await executeMigrationPlanCommand( - { config, cwd: '/project', configPath: '/project/prisma.config.ts', client: stubClient }, + { config, cwd: '/project', projectDir: '/project', client: stubClient }, Date.now(), ); @@ -59,7 +59,7 @@ describe('executeMigrationPlanCommand — mutation-prologue guard', () => { ); const result = await executeMigrationPlanCommand( - { config, cwd: '/project', configPath: '/project/prisma.config.ts', client: stubClient }, + { config, cwd: '/project', projectDir: '/project', client: stubClient }, Date.now(), ); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/ref-advancement.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/ref-advancement.test.ts index 17b434259001..24798adc140f 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/ref-advancement.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/ref-advancement.test.ts @@ -146,13 +146,11 @@ describe('preflightRefAdvancement', () => { const contractJson = sampleContractIR().contract as Record; let projectDir: string; let contractJsonPath: string; - let configPath: string; beforeEach(() => { projectDir = createTestProjectDir('preflight-ref-advancement'); writeProjectManifest(projectDir); contractJsonPath = join(projectDir, 'output', 'contract.json'); - configPath = join(projectDir, 'prisma.config.ts'); }); afterEach(async () => { @@ -171,7 +169,7 @@ describe('preflightRefAdvancement', () => { name: 'db', contractJson, contractJsonPath, - configPath, + projectDir, client, }); @@ -189,7 +187,7 @@ describe('preflightRefAdvancement', () => { name: 'Invalid Name', contractJson, contractJsonPath, - configPath, + projectDir, client, }); @@ -213,7 +211,7 @@ describe('preflightRefAdvancement', () => { name: 'db', contractJson, contractJsonPath, - configPath, + projectDir, client, }); @@ -240,7 +238,7 @@ describe('preflightRefAdvancement', () => { name: 'db', contractJson, contractJsonPath, - configPath, + projectDir, client, }); diff --git a/packages/1-framework/3-tooling/cli/test/helpers/orm-test-cli.ts b/packages/1-framework/3-tooling/cli/test/helpers/orm-test-cli.ts new file mode 100644 index 000000000000..c61fb7d6ce9a --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/helpers/orm-test-cli.ts @@ -0,0 +1,23 @@ +import { resolveConfigPaths } from '@internal/config/config-resolve'; +import type { PrismaNextConfig } from '@internal/config/config-types'; +import { createTestCli } from '@prisma/cli-engine/testing'; + +type TestCliSpec = Parameters[0]; +type TestCli = ReturnType; +type RunArgs = Parameters; + +/** + * A test CLI seeded with an `orm` section as the engine's loader hands it + * over for a config discovered in the run's working directory: resolved + * against that directory, with `rootDir` recorded (ADR 253). + */ +export function createOrmTestCli(spec: Omit & { readonly orm: unknown }) { + const { orm, ...rest } = spec; + return { + run: (argv: RunArgs[0], opts?: RunArgs[1]): ReturnType => { + const rootDir = opts?.cwd ?? process.cwd(); + const section = resolveConfigPaths(orm as PrismaNextConfig, rootDir); + return createTestCli({ ...rest, config: { orm: section } }).run(argv, opts); + }, + }; +} diff --git a/packages/1-framework/3-tooling/cli/test/orm/config-section.test.ts b/packages/1-framework/3-tooling/cli/test/orm/config-section.test.ts index 37d40fe70b12..835954606cb8 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/config-section.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/config-section.test.ts @@ -1,3 +1,4 @@ +import { CONFIG_RESOLVE } from '@internal/config/config-resolve'; import { describe, expect, it } from 'vitest'; import { ormConfigSection } from '../../src/orm/config-section'; @@ -25,6 +26,7 @@ function validDescriptor(kind: string) { function validConfig() { return { + rootDir: '/project', family: validFamily(), target: { ...validDescriptor('target'), targetId: 'postgres' }, adapter: validDescriptor('adapter'), @@ -56,6 +58,44 @@ describe('ormConfigSection', () => { }); }); + describe('a section whose paths were never resolved', () => { + it('refuses a section still carrying its resolver and asks for a newer CLI', () => { + const raw = { ...validConfig(), [CONFIG_RESOLVE]: () => validConfig() }; + const result = ormConfigSection.validate(raw); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toMatchObject([ + { + code: 'CONFIG.VALIDATION_FAILED', + summary: 'Prisma ORM configuration was loaded without resolving its paths', + nextActions: [{ kind: 'user-choice' }], + }, + ]); + }); + + it('refuses a section that records no rootDir', () => { + const { rootDir: _rootDir, ...raw } = validConfig(); + const result = ormConfigSection.validate(raw); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toMatchObject([ + { + code: 'CONFIG.VALIDATION_FAILED', + summary: 'Prisma ORM configuration does not record the directory it was written in', + }, + ]); + }); + + it('reports structural problems before the missing rootDir', () => { + const { rootDir: _rootDir, ...raw } = { ...validConfig(), migrations: { dir: 42 } }; + const result = ormConfigSection.validate(raw); + + expect(result.diagnostics.map((diagnostic) => diagnostic.summary)).toEqual([ + 'Config.migrations.dir must be a string', + ]); + }); + }); + describe('absence', () => { it('reports the missing config file rather than throwing', () => { const result = ormConfigSection.validate(undefined); diff --git a/packages/1-framework/3-tooling/cli/test/orm/contract-emit.test.ts b/packages/1-framework/3-tooling/cli/test/orm/contract-emit.test.ts index 4f7abf55f823..df7cac27f6c7 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/contract-emit.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/contract-emit.test.ts @@ -1,13 +1,16 @@ +import { resolveConfigPaths } from '@internal/config/config-resolve'; +import type { PrismaNextConfig } from '@internal/config/config-types'; import { CliStructuredError } from '@internal/errors/control'; import type { ErroredEnvelope, LoadedConfig, MountedTree, StreamEvent } from '@prisma/cli-engine'; import { createTestCli } from '@prisma/cli-engine/testing'; -import { join } from 'pathe'; +import { dirname, join } from 'pathe'; import stripAnsi from 'strip-ansi'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ContractEmitResult } from '../../src/control-api/types'; import { BIN_GROUPS } from '../../src/orm/cli'; import type { ContractEmitCommandDeps } from '../../src/orm/contract/emit'; import { createContractEmitCommand } from '../../src/orm/contract/emit'; +import { createOrmTestCli } from '../helpers/orm-test-cli'; /** * The command is mounted from the factory with the operation injected, so no @@ -79,7 +82,7 @@ function ormConfig(overrides: Record = {}): Record = ormConfig()) { - return createTestCli({ commands, groups, config: { orm: config } }); + return createOrmTestCli({ commands, groups, orm: config }); } function erroredEnvelope(run: { readonly json: readonly StreamEvent[] }): ErroredEnvelope { @@ -102,7 +105,7 @@ function countingLoader(config: Record = ormConfig()): { calls.push(configPath ?? '(none)'); return Promise.resolve({ path: join(PROJECT_DIR, 'prisma.config.ts'), - sections: { orm: config }, + sections: { orm: resolveConfigPaths(config as unknown as PrismaNextConfig, PROJECT_DIR) }, diagnostics: [], }); }, @@ -153,12 +156,14 @@ describe('contract emit', () => { output: './generated/contract.json', }, }); - const loadConfig = (configPath?: string) => - Promise.resolve({ - path: join(PROJECT_DIR, configPath ?? 'prisma.config.ts'), - sections: { orm: config }, + const loadConfig = (configPath?: string) => { + const path = join(PROJECT_DIR, configPath ?? 'prisma.config.ts'); + return Promise.resolve({ + path, + sections: { orm: resolveConfigPaths(config as unknown as PrismaNextConfig, dirname(path)) }, diagnostics: [], }); + }; const run = await createTestCli({ commands, groups, loadConfig }).run( ['contract', 'emit', '--json', '--config', 'sub/prisma.config.ts'], diff --git a/packages/1-framework/3-tooling/cli/test/orm/contract-infer.test.ts b/packages/1-framework/3-tooling/cli/test/orm/contract-infer.test.ts index ef0aec5d26e7..d3a574947aad 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/contract-infer.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/contract-infer.test.ts @@ -1,12 +1,12 @@ import { readdir, readFile, rm, writeFile } from 'node:fs/promises'; import { CliStructuredError } from '@internal/errors/control'; import type { ErroredEnvelope, MountedTree, StreamEvent } from '@prisma/cli-engine'; -import { createTestCli } from '@prisma/cli-engine/testing'; import { join } from 'pathe'; import stripAnsi from 'strip-ansi'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { BIN_GROUPS } from '../../src/orm/cli'; import { createContractInferCommand } from '../../src/orm/contract/infer'; +import { createOrmTestCli } from '../helpers/orm-test-cli'; import { createTestProjectDir } from '../utils/test-project-dir'; const PSL = 'model User {\n id Int @id\n}\n'; @@ -91,7 +91,7 @@ function ormConfig(dir: string, overrides: Record = {}): Record } function harness(config: Record) { - return createTestCli({ commands, groups, config: { orm: config } }); + return createOrmTestCli({ commands, groups, orm: config }); } function erroredEnvelope(run: { readonly json: readonly StreamEvent[] }): ErroredEnvelope { diff --git a/packages/1-framework/3-tooling/cli/test/orm/cross-consumer-integrity.test.ts b/packages/1-framework/3-tooling/cli/test/orm/cross-consumer-integrity.test.ts index b99e56076b97..a85408d46064 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/cross-consumer-integrity.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/cross-consumer-integrity.test.ts @@ -1,10 +1,10 @@ import { writeFile } from 'node:fs/promises'; import { writeRef } from '@internal/migration-tools/refs'; -import { createTestCli } from '@prisma/cli-engine/testing'; import { join } from 'pathe'; import { afterEach, describe, expect, it } from 'vitest'; import type { MigrationCheckResult } from '../../src/commands/json/schemas'; import { BIN_COMMANDS, BIN_GROUPS } from '../../src/orm/cli'; +import { createOrmTestCli } from '../helpers/orm-test-cli'; import { contractJson, createOfflineProject, @@ -83,7 +83,7 @@ function driverConfig(project: OfflineProject): Record { } function harness(config: Record) { - return createTestCli({ commands: BIN_COMMANDS, groups: BIN_GROUPS, config: { orm: config } }); + return createOrmTestCli({ commands: BIN_COMMANDS, groups: BIN_GROUPS, orm: config }); } /** diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-init.test.ts b/packages/1-framework/3-tooling/cli/test/orm/db-init.test.ts index 03efd5ca3ec3..13433438f690 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-init.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-init.test.ts @@ -3,12 +3,12 @@ import { readFile } from 'node:fs/promises'; import { contractSnapshotDir } from '@internal/migration-tools/contract-snapshot-store'; import { notOk, ok } from '@internal/utils/result'; import type { EngineEvent, StreamEvent } from '@prisma/cli-engine'; -import { createTestCli } from '@prisma/cli-engine/testing'; import { join } from 'pathe'; import stripAnsi from 'strip-ansi'; import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ControlClient } from '../../src/control-api/types'; import { BIN_GROUPS, createBinCommands } from '../../src/orm/cli'; +import { createOrmTestCli } from '../helpers/orm-test-cli'; import { createTestProjectDir, writeProjectManifest } from '../utils/test-project-dir'; const mocks = { @@ -128,7 +128,7 @@ function planSuccess(): Record { } function harness(config: Record) { - return createTestCli({ commands, groups: BIN_GROUPS, config: { orm: config } }); + return createOrmTestCli({ commands, groups: BIN_GROUPS, orm: config }); } function envelopeOf(json: readonly StreamEvent[]): unknown { diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-schema.test.ts b/packages/1-framework/3-tooling/cli/test/orm/db-schema.test.ts index bde3cf0a6b45..484395d518f6 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-schema.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-schema.test.ts @@ -1,9 +1,9 @@ import type { StreamEvent } from '@prisma/cli-engine'; -import { createTestCli } from '@prisma/cli-engine/testing'; import stripAnsi from 'strip-ansi'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ControlClient } from '../../src/control-api/types'; import { BIN_GROUPS, createBinCommands } from '../../src/orm/cli'; +import { createOrmTestCli } from '../helpers/orm-test-cli'; const mocks = { introspect: vi.fn(), @@ -83,7 +83,7 @@ function ormConfig(overrides: Record = {}): Record) { - return createTestCli({ commands, groups: BIN_GROUPS, config: { orm: config } }); + return createOrmTestCli({ commands, groups: BIN_GROUPS, orm: config }); } function envelopeOf(json: readonly StreamEvent[]): unknown { diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-sign-fixtures.ts b/packages/1-framework/3-tooling/cli/test/orm/db-sign-fixtures.ts index 5c96f5c31662..bea2ac68689f 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-sign-fixtures.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-sign-fixtures.ts @@ -10,12 +10,12 @@ import { blindCast } from '@internal/utils/casts'; import { ok } from '@internal/utils/result'; import type { MountedTree, PresentedResult } from '@prisma/cli-engine'; import type { Diagnostic } from '@prisma/cli-engine/protocol'; -import { createTestCli } from '@prisma/cli-engine/testing'; import { join } from 'pathe'; import { type Mock, vi } from 'vitest'; import type { ControlClient } from '../../src/control-api/types'; import { BIN_COMMANDS, BIN_GROUPS } from '../../src/orm/cli'; import { createDbSignCommand } from '../../src/orm/db/sign'; +import { createOrmTestCli } from '../helpers/orm-test-cli'; import { createTestProjectDir, writeProjectManifest } from '../utils/test-project-dir'; export const HASH_A = `4cb4256${'0'.repeat(57)}`; @@ -120,7 +120,7 @@ export function ormConfig(overrides: Record = {}): Record) { - return createTestCli({ commands, groups, config: { orm: config } }); + return createOrmTestCli({ commands, groups, orm: config }); } export const MISSING_COLUMN = blindCast< diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts b/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts index cdcb17f997b5..ff33f3b0d04e 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts @@ -1,12 +1,12 @@ import { rmSync, writeFileSync } from 'node:fs'; import { notOk, ok } from '@internal/utils/result'; import type { EngineEvent, StreamEvent } from '@prisma/cli-engine'; -import { createTestCli } from '@prisma/cli-engine/testing'; import { join } from 'pathe'; import stripAnsi from 'strip-ansi'; import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ControlClient } from '../../src/control-api/types'; import { BIN_GROUPS, createBinCommands } from '../../src/orm/cli'; +import { createOrmTestCli } from '../helpers/orm-test-cli'; import { createTestProjectDir, writeProjectManifest } from '../utils/test-project-dir'; const mocks = { @@ -154,7 +154,7 @@ function refuseUntilConsented() { } function harness(config: Record = ormConfig()) { - return createTestCli({ commands, groups, config: { orm: config } }); + return createOrmTestCli({ commands, groups, orm: config }); } function envelopeOf(json: readonly StreamEvent[]): unknown { diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts b/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts index 68f1d9f4c26e..430783c1c6d8 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts @@ -5,11 +5,11 @@ import { computeMigrationHash } from '@internal/migration-tools/hash'; import { formatMigrationDirName, writeMigrationPackage } from '@internal/migration-tools/io'; import type { MigrationMetadata } from '@internal/migration-tools/metadata'; import { ok } from '@internal/utils/result'; -import { createTestCli } from '@prisma/cli-engine/testing'; import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ControlClient } from '../../src/control-api/types'; import { BIN_GROUPS, createBinCommands } from '../../src/orm/cli'; +import { createOrmTestCli } from '../helpers/orm-test-cli'; import { createTestProjectDir, writeProjectManifest } from '../utils/test-project-dir'; /** @@ -135,7 +135,7 @@ function ormConfig(cwd: string): Record { } function harness(cwd: string) { - return createTestCli({ commands, groups: BIN_GROUPS, config: { orm: ormConfig(cwd) } }); + return createOrmTestCli({ commands, groups: BIN_GROUPS, orm: ormConfig(cwd) }); } describe('db update --to bundle resolution', () => { diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-update.test.ts b/packages/1-framework/3-tooling/cli/test/orm/db-update.test.ts index 0aae3c11bdb6..1de2298172dd 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-update.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-update.test.ts @@ -3,12 +3,12 @@ import { readFile } from 'node:fs/promises'; import { contractSnapshotDir } from '@internal/migration-tools/contract-snapshot-store'; import { notOk, ok } from '@internal/utils/result'; import type { EngineEvent, StreamEvent } from '@prisma/cli-engine'; -import { createTestCli } from '@prisma/cli-engine/testing'; import { join } from 'pathe'; import stripAnsi from 'strip-ansi'; import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ControlClient } from '../../src/control-api/types'; import { BIN_GROUPS, createBinCommands } from '../../src/orm/cli'; +import { createOrmTestCli } from '../helpers/orm-test-cli'; import { createTestProjectDir, writeProjectManifest } from '../utils/test-project-dir'; const mocks = { @@ -130,7 +130,7 @@ function planSuccess(): Record { } function harness(config: Record = ormConfig()) { - return createTestCli({ commands, groups, config: { orm: config } }); + return createOrmTestCli({ commands, groups, orm: config }); } function envelopeOf(json: readonly StreamEvent[]): unknown { diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-verify.test.ts b/packages/1-framework/3-tooling/cli/test/orm/db-verify.test.ts index 7f7b7295d8e7..2b29e9ff961a 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-verify.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-verify.test.ts @@ -8,13 +8,13 @@ import { blindCast } from '@internal/utils/casts'; import { notOk, ok } from '@internal/utils/result'; import type { MountedTree, PresentedResult } from '@prisma/cli-engine'; import type { Diagnostic } from '@prisma/cli-engine/protocol'; -import { createTestCli } from '@prisma/cli-engine/testing'; import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ControlClient } from '../../src/control-api/types'; import { BIN_COMMANDS, BIN_GROUPS } from '../../src/orm/cli'; import { createDbVerifyCommand } from '../../src/orm/db/verify'; import { CliStructuredError } from '../../src/utils/cli-errors'; +import { createOrmTestCli } from '../helpers/orm-test-cli'; import { createTestProjectDir } from '../utils/test-project-dir'; const HASH_A = `4cb4256${'0'.repeat(57)}`; @@ -93,7 +93,7 @@ function ormConfig(overrides: Record = {}): Record) { - return createTestCli({ commands, groups, config: { orm: config } }); + return createOrmTestCli({ commands, groups, orm: config }); } function verified(overrides: Partial = {}): VerifyDatabaseResult { diff --git a/packages/1-framework/3-tooling/cli/test/orm/define-command.test.ts b/packages/1-framework/3-tooling/cli/test/orm/define-command.test.ts index 231096e30d75..dcd06c9a8af3 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/define-command.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/define-command.test.ts @@ -1,10 +1,9 @@ import { CliStructuredError } from '@internal/errors/control'; import { InternalError } from '@internal/utils/internal-error'; import { structuredError } from '@internal/utils/structured-error'; -import type { ErroredEnvelope, LoadedConfig, StreamEvent } from '@prisma/cli-engine'; +import type { ErroredEnvelope, StreamEvent } from '@prisma/cli-engine'; import { ok } from '@prisma/cli-engine/protocol'; import { createTestCli } from '@prisma/cli-engine/testing'; -import { resolve } from 'pathe'; import { describe, expect, it } from 'vitest'; import { defineOrmCommand } from '../../src/orm/define-command'; @@ -145,116 +144,3 @@ describe('defineOrmCommand', () => { }); }); }); - -interface SeenPaths { - output?: string | undefined; - inputs?: readonly string[] | undefined; - dir?: string | undefined; -} - -function probeCli(seen: SeenPaths, loadConfig: (configPath?: string) => Promise) { - return createTestCli({ - commands: { - probe: defineOrmCommand({ - help: { summary: 'Records the config paths the handler receives' }, - needs: { - config: { - name: 'orm', - validate: (raw) => ({ - ok: true as const, - value: raw as Record, - diagnostics: [], - }), - }, - }, - handler: async (_args, ctx) => { - const config = ctx.config as { - contract?: { source?: { inputs?: readonly string[] }; output?: string }; - migrations?: { dir?: string }; - }; - seen.output = config.contract?.output; - seen.inputs = config.contract?.source?.inputs; - seen.dir = config.migrations?.dir; - return ok( - ctx.present( - { data: seen, exitCode: 0 }, - { stdout: () => [], next: () => [], human: () => [], json: () => seen }, - ), - ); - }, - }), - }, - loadConfig, - }); -} - -/** The engine's own loader: paths inside the file stay exactly as authored. */ -const relativeOrmSection = { - contract: { - source: { - format: 'psl', - inputs: ['./contract.prisma'], - load: async () => ({ ok: true, value: {} }), - }, - output: './src/prisma/contract.json', - }, - migrations: { dir: './migrations' }, -}; - -function loaderAt(cwd: string): (configPath?: string) => Promise { - return async (configPath) => ({ - path: resolve(cwd, configPath ?? 'prisma.config.ts'), - sections: { orm: relativeOrmSection }, - diagnostics: [], - }); -} - -describe('config finalization at the command boundary', () => { - it('hands the handler absolute contract and migration paths whatever the loader left relative', async () => { - const cwd = '/workspace/app'; - const seen: SeenPaths = {}; - - const run = await probeCli(seen, loaderAt(cwd)).run(['probe', '--json'], { cwd }); - - expect(run.exitCode).toBe(0); - expect(seen).toEqual({ - output: `${cwd}/src/prisma/contract.json`, - inputs: [`${cwd}/contract.prisma`], - dir: `${cwd}/migrations`, - }); - }); - - describe('a --config file in a subdirectory of the working directory', () => { - const parent = '/workspace/app'; - const configDir = `${parent}/sub`; - const anchoredOnConfigDir: SeenPaths = { - output: `${configDir}/src/prisma/contract.json`, - inputs: [`${configDir}/contract.prisma`], - dir: `${configDir}/migrations`, - }; - - it('resolves relative paths against the config file, not the working directory', async () => { - const seen: SeenPaths = {}; - - const run = await probeCli(seen, loaderAt(parent)).run( - ['probe', '--json', '--config', 'sub/prisma.config.ts'], - { cwd: parent }, - ); - - expect(run.exitCode).toBe(0); - expect(seen).toEqual(anchoredOnConfigDir); - }); - - it('yields the same absolute paths as running from the config directory', async () => { - const seen: SeenPaths = {}; - - const run = await probeCli(seen, loaderAt(configDir)).run( - ['probe', '--json', '--config', 'prisma.config.ts'], - { cwd: configDir }, - ); - - expect(run.exitCode).toBe(0); - expect(seen).toEqual(anchoredOnConfigDir); - }); - }); -}); diff --git a/packages/1-framework/3-tooling/cli/test/orm/format.test.ts b/packages/1-framework/3-tooling/cli/test/orm/format.test.ts index e26bc9591fa5..259944cefdb6 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/format.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/format.test.ts @@ -1,9 +1,9 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { createTestCli } from '@prisma/cli-engine/testing'; import { join } from 'pathe'; import stripAnsi from 'strip-ansi'; import { afterEach, describe, expect, it } from 'vitest'; import { BIN_COMMANDS, BIN_GROUPS } from '../../src/orm/cli'; +import { createOrmTestCli } from '../helpers/orm-test-cli'; import { createTestProjectDir } from '../utils/test-project-dir'; const MESSY_PSL = 'model User{id Int @id\nname String}\n'; @@ -63,10 +63,10 @@ function pslConfig(inputPath: string): Record { } function harness(config: Record) { - return createTestCli({ + return createOrmTestCli({ commands: BIN_COMMANDS, groups: BIN_GROUPS, - config: { orm: config }, + orm: config, }); } diff --git a/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts index d02ff9f17389..548176375c5e 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts @@ -6,11 +6,11 @@ import { writeMigrationPackage } from '@internal/migration-tools/io'; import type { MigrationMetadata } from '@internal/migration-tools/metadata'; import { writeRef } from '@internal/migration-tools/refs'; import type { Block } from '@prisma/cli-engine'; -import { createTestCli } from '@prisma/cli-engine/testing'; import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ControlClient } from '../../src/control-api/types'; import { BIN_GROUPS, createBinCommands } from '../../src/orm/cli'; +import { createOrmTestCli } from '../helpers/orm-test-cli'; import { createTestProjectDir } from '../utils/test-project-dir'; const mocks = { @@ -177,7 +177,7 @@ function ormConfig(cwd: string, overrides: Record = {}): Record } function harness(config: Record) { - return createTestCli({ commands, groups: BIN_GROUPS, config: { orm: config } }); + return createOrmTestCli({ commands, groups: BIN_GROUPS, orm: config }); } /** Flattens a drawing block's span lines into plain strings. */ diff --git a/packages/1-framework/3-tooling/cli/test/orm/migrate-to-contract.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migrate-to-contract.test.ts index a2433eecb841..f28fd7342756 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migrate-to-contract.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migrate-to-contract.test.ts @@ -8,11 +8,11 @@ import { computeMigrationHash } from '@internal/migration-tools/hash'; import { writeMigrationPackage } from '@internal/migration-tools/io'; import type { MigrationMetadata } from '@internal/migration-tools/metadata'; import { ok } from '@internal/utils/result'; -import { createTestCli } from '@prisma/cli-engine/testing'; import { join, relative } from 'pathe'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ControlClient } from '../../src/control-api/types'; import { BIN_GROUPS, createBinCommands } from '../../src/orm/cli'; +import { createOrmTestCli } from '../helpers/orm-test-cli'; import { createTestProjectDir } from '../utils/test-project-dir'; /** @@ -164,7 +164,7 @@ function ormConfig(cwd: string): Record { } function harness(config: Record) { - return createTestCli({ commands, groups: BIN_GROUPS, config: { orm: config } }); + return createOrmTestCli({ commands, groups: BIN_GROUPS, orm: config }); } function appliedContractHash(): string { diff --git a/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts index f3b5f0b2afdb..6b137fb91f30 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts @@ -7,12 +7,12 @@ import { writeMigrationPackage } from '@internal/migration-tools/io'; import type { MigrationMetadata } from '@internal/migration-tools/metadata'; import { notOk, ok } from '@internal/utils/result'; import type { EngineEvent, StreamEvent } from '@prisma/cli-engine'; -import { createTestCli } from '@prisma/cli-engine/testing'; import { join } from 'pathe'; import stripAnsi from 'strip-ansi'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ControlClient } from '../../src/control-api/types'; import { BIN_GROUPS, createBinCommands } from '../../src/orm/cli'; +import { createOrmTestCli } from '../helpers/orm-test-cli'; import { createTestProjectDir, writeProjectManifest } from '../utils/test-project-dir'; const mocks = { @@ -178,7 +178,7 @@ beforeEach(() => { }); function harness(config: Record) { - return createTestCli({ commands, groups: BIN_GROUPS, config: { orm: config } }); + return createOrmTestCli({ commands, groups: BIN_GROUPS, orm: config }); } function envelopeOf(json: readonly StreamEvent[]): unknown { diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-check-multi-space.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-check-multi-space.test.ts index 4ec623aeb32a..4e0592aa2c28 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-check-multi-space.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-check-multi-space.test.ts @@ -1,10 +1,10 @@ import { readFile, writeFile } from 'node:fs/promises'; import { writeRef } from '@internal/migration-tools/refs'; -import { createTestCli } from '@prisma/cli-engine/testing'; import { join } from 'pathe'; import { afterEach, describe, expect, it } from 'vitest'; import type { MigrationCheckResult } from '../../src/commands/json/schemas'; import { BIN_COMMANDS, BIN_GROUPS } from '../../src/orm/cli'; +import { createOrmTestCli } from '../helpers/orm-test-cli'; import { contractJson, createOfflineProject, @@ -46,7 +46,7 @@ function postgisExtension(): Record { function harness(project: OfflineProject, options: { readonly declared?: boolean } = {}) { const base = offlineConfig({ project }); const config = options.declared === false ? base : { ...base, extensions: [postgisExtension()] }; - return createTestCli({ commands: BIN_COMMANDS, groups: BIN_GROUPS, config: { orm: config } }); + return createOrmTestCli({ commands: BIN_COMMANDS, groups: BIN_GROUPS, orm: config }); } async function seedHeadRef(spaceDir: string, hash: string): Promise { diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-check.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-check.test.ts index 5a6eda621204..03fb44a59654 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-check.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-check.test.ts @@ -7,12 +7,12 @@ import type { MigrationMetadata } from '@internal/migration-tools/metadata'; import { blindCast } from '@internal/utils/casts'; import type { PresentedResult } from '@prisma/cli-engine'; import type { Diagnostic } from '@prisma/cli-engine/protocol'; -import { createTestCli } from '@prisma/cli-engine/testing'; import { createSqlContract } from '@repo/test-utils'; import { join } from 'pathe'; import { afterEach, describe, expect, it } from 'vitest'; import type { MigrationCheckResult } from '../../src/commands/json/schemas'; import { BIN_COMMANDS, BIN_GROUPS } from '../../src/orm/cli'; +import { createOrmTestCli } from '../helpers/orm-test-cli'; import { createTestProjectDir } from '../utils/test-project-dir'; const HASH_A = `4cb4256${'0'.repeat(57)}`; @@ -136,7 +136,7 @@ async function seedDanglingRef(migrationsDir: string, name: string): Promise) { - return createTestCli({ commands: BIN_COMMANDS, groups: BIN_GROUPS, config: { orm: config } }); + return createOrmTestCli({ commands: BIN_COMMANDS, groups: BIN_GROUPS, orm: config }); } function diagnosticsOf(run: { diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-graph.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-graph.test.ts index eda35dbd0959..e90c4d78bf6a 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-graph.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-graph.test.ts @@ -6,12 +6,12 @@ import { computeMigrationHash } from '@internal/migration-tools/hash'; import { writeMigrationPackage } from '@internal/migration-tools/io'; import type { MigrationMetadata } from '@internal/migration-tools/metadata'; import { blindCast } from '@internal/utils/casts'; -import { createTestCli } from '@prisma/cli-engine/testing'; import { createSqlContract } from '@repo/test-utils'; import { join } from 'pathe'; import stripAnsi from 'strip-ansi'; import { afterEach, describe, expect, it } from 'vitest'; import { BIN_COMMANDS, BIN_GROUPS } from '../../src/orm/cli'; +import { createOrmTestCli } from '../helpers/orm-test-cli'; import { createTestProjectDir } from '../utils/test-project-dir'; const HASH_A = `4cb4256${'0'.repeat(57)}`; @@ -96,7 +96,7 @@ async function seedMigration(migrationsDir: string): Promise { } function harness(config: Record) { - return createTestCli({ commands: BIN_COMMANDS, groups: BIN_GROUPS, config: { orm: config } }); + return createOrmTestCli({ commands: BIN_COMMANDS, groups: BIN_GROUPS, orm: config }); } describe('migration graph', () => { diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-invariants.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-invariants.test.ts index 979b0ed9d66f..f65847347fea 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-invariants.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-invariants.test.ts @@ -1,8 +1,8 @@ import { writeRef } from '@internal/migration-tools/refs'; -import { createTestCli } from '@prisma/cli-engine/testing'; import { join } from 'pathe'; import { afterEach, describe, expect, it } from 'vitest'; import { BIN_COMMANDS, BIN_GROUPS } from '../../src/orm/cli'; +import { createOrmTestCli } from '../helpers/orm-test-cli'; import { createOfflineProject, invariantOp, @@ -58,7 +58,7 @@ function driverConfig(project: OfflineProject, marker: MarkerScript = {}): Recor } function harness(config: Record) { - return createTestCli({ commands: BIN_COMMANDS, groups: BIN_GROUPS, config: { orm: config } }); + return createOrmTestCli({ commands: BIN_COMMANDS, groups: BIN_GROUPS, orm: config }); } /** diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-list.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-list.test.ts index 63736713aadb..6cb2374efc04 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-list.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-list.test.ts @@ -6,12 +6,12 @@ import { writeMigrationPackage } from '@internal/migration-tools/io'; import type { MigrationMetadata } from '@internal/migration-tools/metadata'; import { writeRef } from '@internal/migration-tools/refs'; import { blindCast } from '@internal/utils/casts'; -import { createTestCli } from '@prisma/cli-engine/testing'; import { createSqlContract } from '@repo/test-utils'; import { join } from 'pathe'; import stripAnsi from 'strip-ansi'; import { afterEach, describe, expect, it } from 'vitest'; import { BIN_COMMANDS, BIN_GROUPS } from '../../src/orm/cli'; +import { createOrmTestCli } from '../helpers/orm-test-cli'; import { createTestProjectDir } from '../utils/test-project-dir'; const HASH_A = `4cb4256${'0'.repeat(57)}`; @@ -96,10 +96,10 @@ async function seedMigration(migrationsDir: string): Promise { } function harness(config: Record) { - return createTestCli({ + return createOrmTestCli({ commands: BIN_COMMANDS, groups: BIN_GROUPS, - config: { orm: config }, + orm: config, }); } diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-log.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-log.test.ts index 130508bf7dd1..42fa0c518dce 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-log.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-log.test.ts @@ -1,10 +1,10 @@ import type { LedgerEntryRecord } from '@internal/contract/types'; import type { MountedTree } from '@prisma/cli-engine'; -import { createTestCli } from '@prisma/cli-engine/testing'; import { timeouts } from '@repo/test-utils'; import stripAnsi from 'strip-ansi'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import type { BIN_GROUPS as BinGroups } from '../../src/orm/cli'; +import { createOrmTestCli } from '../helpers/orm-test-cli'; const mocks = vi.hoisted(() => ({ connect: vi.fn(), @@ -88,7 +88,7 @@ function ledgerEntry(overrides: Partial = {}): LedgerEntryRec } function harness(config: Record) { - return createTestCli({ commands, groups, config: { orm: config } }); + return createOrmTestCli({ commands, groups, orm: config }); } describe('migration log', () => { diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-new.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-new.test.ts index b6a649415a98..a879cc25c042 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-new.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-new.test.ts @@ -2,10 +2,10 @@ import { existsSync } from 'node:fs'; import { readdir, readFile } from 'node:fs/promises'; import { contractSnapshotDir } from '@internal/migration-tools/contract-snapshot-store'; import { notOk } from '@internal/utils/result'; -import { createTestCli } from '@prisma/cli-engine/testing'; import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { BIN_GROUPS } from '../../src/orm/cli'; +import { createOrmTestCli } from '../helpers/orm-test-cli'; import { contractJson, createOfflineProject, @@ -26,10 +26,10 @@ beforeEach(resetRenderContractDtsMock); afterEach(removeOfflineProjects); function harness(project: OfflineProject, overrides: Record = {}) { - return createTestCli({ + return createOrmTestCli({ commands: OFFLINE_COMMANDS, groups: BIN_GROUPS, - config: { orm: { ...offlineConfig({ project }), ...overrides } }, + orm: { ...offlineConfig({ project }), ...overrides }, }); } diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts index 992a2024ac68..4b578f88952d 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts @@ -1,5 +1,7 @@ import { existsSync } from 'node:fs'; import { readdir, readFile } from 'node:fs/promises'; +import { resolveConfigPaths } from '@internal/config/config-resolve'; +import type { PrismaNextConfig } from '@internal/config/config-types'; import { contractSnapshotDir } from '@internal/migration-tools/contract-snapshot-store'; import { computeMigrationHash } from '@internal/migration-tools/hash'; import { notOk } from '@internal/utils/result'; @@ -8,6 +10,7 @@ import { createTestCli } from '@prisma/cli-engine/testing'; import { basename, dirname, join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { BIN_GROUPS } from '../../src/orm/cli'; +import { createOrmTestCli } from '../helpers/orm-test-cli'; import { ADDITIVE_OP, contractJson, @@ -39,14 +42,12 @@ function harness( readonly overrides?: Record; } = {}, ) { - return createTestCli({ + return createOrmTestCli({ commands: OFFLINE_COMMANDS, groups: BIN_GROUPS, - config: { - orm: { - ...offlineConfig({ project, ...(options.script ? { script: options.script } : {}) }), - ...options.overrides, - }, + orm: { + ...offlineConfig({ project, ...(options.script ? { script: options.script } : {}) }), + ...options.overrides, }, }); } @@ -83,24 +84,30 @@ async function upToDateProject(): Promise { return project; } -/** The engine's own loader leaves the paths inside the file as authored. */ -function relativeConfigLoader( - project: OfflineProject, -): (configPath?: string) => Promise { - return async (configPath) => ({ - path: join(project.dir, configPath === undefined ? 'prisma.config.ts' : basename(configPath)), - sections: { - orm: { - ...offlineConfig({ project }), - contract: { - source: { format: 'typescript', inputs: [], load: async () => contractJson('unused') }, - output: './output/contract.json', - }, - migrations: { dir: './migrations' }, +/** + * The engine's loader: the section as the file wrote it, resolved against the + * file's own directory before the command sees it (ADR 253). + */ +function resolvingLoader(project: OfflineProject): (configPath?: string) => Promise { + return async (configPath) => { + const path = join( + project.dir, + configPath === undefined ? 'prisma.config.ts' : basename(configPath), + ); + const authored = { + ...offlineConfig({ project }), + contract: { + source: { format: 'typescript', inputs: [], load: async () => contractJson('unused') }, + output: './output/contract.json', }, - }, - diagnostics: [], - }); + migrations: { dir: './migrations' }, + }; + return { + path, + sections: { orm: resolveConfigPaths(authored as unknown as PrismaNextConfig, dirname(path)) }, + diagnostics: [], + }; + }; } describe('migration plan --config naming a file in a subdirectory', () => { @@ -109,7 +116,7 @@ describe('migration plan --config naming a file in a subdirectory', () => { const cli = createTestCli({ commands: OFFLINE_COMMANDS, groups: BIN_GROUPS, - loadConfig: relativeConfigLoader(project), + loadConfig: resolvingLoader(project), }); const run = await cli.run( diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-show.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-show.test.ts index d0894cfc35ac..1a3deb2f2391 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-show.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-show.test.ts @@ -4,11 +4,11 @@ import { computeMigrationHash } from '@internal/migration-tools/hash'; import { writeMigrationPackage } from '@internal/migration-tools/io'; import type { MigrationMetadata } from '@internal/migration-tools/metadata'; import { blindCast } from '@internal/utils/casts'; -import { createTestCli } from '@prisma/cli-engine/testing'; import { join } from 'pathe'; import stripAnsi from 'strip-ansi'; import { afterEach, describe, expect, it } from 'vitest'; import { BIN_COMMANDS, BIN_GROUPS } from '../../src/orm/cli'; +import { createOrmTestCli } from '../helpers/orm-test-cli'; import { createTestProjectDir } from '../utils/test-project-dir'; const HASH_A = `4cb4256${'0'.repeat(57)}`; @@ -91,10 +91,10 @@ async function seedProject(dir: string): Promise { } function harness(config: Record) { - return createTestCli({ + return createOrmTestCli({ commands: BIN_COMMANDS, groups: BIN_GROUPS, - config: { orm: config }, + orm: config, }); } diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts index 2bee13f3b767..82f8c912e0d2 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts @@ -2,11 +2,11 @@ import { rm } from 'node:fs/promises'; import { EMPTY_CONTRACT_HASH } from '@internal/migration-tools/constants'; import { writeRef } from '@internal/migration-tools/refs'; import type { Diagnostic } from '@prisma/cli-engine/protocol'; -import { createTestCli } from '@prisma/cli-engine/testing'; import { join } from 'pathe'; import stripAnsi from 'strip-ansi'; import { afterEach, describe, expect, it } from 'vitest'; import { BIN_COMMANDS, BIN_GROUPS } from '../../src/orm/cli'; +import { createOrmTestCli } from '../helpers/orm-test-cli'; import { createOfflineProject, invariantOp, @@ -88,7 +88,7 @@ function driverConfig( } function harness(config: Record) { - return createTestCli({ commands: BIN_COMMANDS, groups: BIN_GROUPS, config: { orm: config } }); + return createOrmTestCli({ commands: BIN_COMMANDS, groups: BIN_GROUPS, orm: config }); } /** A project whose app space carries one migration ∅ → HASH_HEAD. */ diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-tamper.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-tamper.test.ts index d5d0c3a4cbc7..194541aa1a44 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-tamper.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-tamper.test.ts @@ -1,9 +1,9 @@ import { mkdir, rm, writeFile } from 'node:fs/promises'; import { writeRef } from '@internal/migration-tools/refs'; -import { createTestCli } from '@prisma/cli-engine/testing'; import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { BIN_GROUPS } from '../../src/orm/cli'; +import { createOrmTestCli } from '../helpers/orm-test-cli'; import { ADDITIVE_OP, contractJson, @@ -70,7 +70,7 @@ function driverConfig(project: OfflineProject): { } function harness(config: Record) { - return createTestCli({ commands: OFFLINE_COMMANDS, groups: BIN_GROUPS, config: { orm: config } }); + return createOrmTestCli({ commands: OFFLINE_COMMANDS, groups: BIN_GROUPS, orm: config }); } /** A project whose only migration's ops.json was rewritten after attestation. */ diff --git a/packages/1-framework/3-tooling/cli/test/orm/ref-fixtures.ts b/packages/1-framework/3-tooling/cli/test/orm/ref-fixtures.ts index f0ede7ff3b32..f82a3e231487 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/ref-fixtures.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/ref-fixtures.ts @@ -5,9 +5,9 @@ import { computeMigrationHash } from '@internal/migration-tools/hash'; import { formatMigrationDirName, writeMigrationPackage } from '@internal/migration-tools/io'; import type { MigrationMetadata } from '@internal/migration-tools/metadata'; import { blindCast } from '@internal/utils/casts'; -import { createTestCli } from '@prisma/cli-engine/testing'; import { join } from 'pathe'; import { BIN_COMMANDS, BIN_GROUPS } from '../../src/orm/cli'; +import { createOrmTestCli } from '../helpers/orm-test-cli'; import { createTestProjectDir } from '../utils/test-project-dir'; export const HASH_A = `4cb4256${'0'.repeat(57)}`; @@ -64,10 +64,10 @@ export function ormConfig(): Record { } export function harness(config: Record = ormConfig()) { - return createTestCli({ + return createOrmTestCli({ commands: BIN_COMMANDS, groups: BIN_GROUPS, - config: { orm: config }, + orm: config, }); } diff --git a/packages/1-framework/3-tooling/cli/test/orm/ref-format-error-boundary.test.ts b/packages/1-framework/3-tooling/cli/test/orm/ref-format-error-boundary.test.ts index 1611c4efe1a5..258ac84cfd96 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/ref-format-error-boundary.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/ref-format-error-boundary.test.ts @@ -1,6 +1,5 @@ import { CliStructuredError } from '@internal/errors/control'; import type { ErroredEnvelope, MountedTree, StreamEvent } from '@prisma/cli-engine'; -import { createTestCli } from '@prisma/cli-engine/testing'; import type { Mock } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { executeFormat } from '../../src/control-api/operations/format'; @@ -14,6 +13,7 @@ import { createFormatCommand } from '../../src/orm/format'; import { createRefDeleteCommand } from '../../src/orm/ref/delete'; import { createRefListCommand } from '../../src/orm/ref/list'; import { createRefSetCommand } from '../../src/orm/ref/set'; +import { createOrmTestCli } from '../helpers/orm-test-cli'; const operations = { executeFormat: vi.fn(), @@ -43,22 +43,20 @@ const DESCRIPTOR = { }; function harness() { - return createTestCli({ + return createOrmTestCli({ commands, groups: BIN_GROUPS, - config: { - orm: { - family: { - kind: 'family', - id: 'sql', - familyId: 'sql', - version: '1.0.0', - emission: {}, - create: () => ({}), - }, - target: { ...DESCRIPTOR, kind: 'target', id: 'postgres' }, - adapter: { ...DESCRIPTOR, kind: 'adapter', id: 'pg' }, + orm: { + family: { + kind: 'family', + id: 'sql', + familyId: 'sql', + version: '1.0.0', + emission: {}, + create: () => ({}), }, + target: { ...DESCRIPTOR, kind: 'target', id: 'postgres' }, + adapter: { ...DESCRIPTOR, kind: 'adapter', id: 'pg' }, }, }); } diff --git a/packages/1-framework/3-tooling/cli/test/utils/command-helpers.test.ts b/packages/1-framework/3-tooling/cli/test/utils/command-helpers.test.ts index d09781998046..25d380cdeefe 100644 --- a/packages/1-framework/3-tooling/cli/test/utils/command-helpers.test.ts +++ b/packages/1-framework/3-tooling/cli/test/utils/command-helpers.test.ts @@ -108,56 +108,43 @@ describe('sanitizeErrorMessage', () => { }); describe('resolveMigrationPaths', () => { - describe('a relative --config naming a project other than the invocation directory', () => { - it('resolves the config path against cwd, not the process working directory', () => { - const paths = resolveMigrationPaths('../app/prisma.config.ts', {}, '/work/scratch'); - - expect(paths.migrationsDir).toBe('/work/app/migrations'); - expect(paths.configPath).toBe('../app/prisma.config.ts'); - }); - - it('anchors an explicit migrations dir on the config file directory', () => { + describe('a resolved config', () => { + it('uses the absolute migrations dir the config carries, whatever cwd is', () => { const paths = resolveMigrationPaths( - '../app/prisma.config.ts', - { migrations: { dir: 'db' } }, + { rootDir: '/work/app', migrations: { dir: '/work/app/db' } }, '/work/scratch', ); - expect(paths.migrationsDir).toBe('/work/app/db'); + expect(paths).toMatchObject({ + configPath: 'prisma.config.ts', + migrationsDir: '/work/app/db', + migrationsRelative: '../app/db', + }); }); - }); - describe('an absolute --config', () => { - it('is unaffected by cwd', () => { - const paths = resolveMigrationPaths('/app/prisma.config.ts', {}, '/tmp'); + it('defaults the migrations dir under rootDir when the config names none', () => { + const paths = resolveMigrationPaths({ rootDir: '/work/app' }, '/tmp'); - expect(paths.migrationsDir).toBe('/app/migrations'); + expect(paths.migrationsDir).toBe('/work/app/migrations'); }); }); - describe('no --config', () => { - it('anchors everything on cwd', () => { - const paths = resolveMigrationPaths(undefined, {}, '/work/app'); + describe('a raw config from a programmatic caller', () => { + it('anchors a relative migrations dir on cwd when the config carries no rootDir', () => { + const paths = resolveMigrationPaths({ migrations: { dir: 'db' } }, '/work/app'); + + expect(paths.migrationsDir).toBe('/work/app/db'); + }); + + it('anchors the default on cwd', () => { + const paths = resolveMigrationPaths({}, '/work/app'); expect(paths).toMatchObject({ - configPath: 'prisma.config.ts', migrationsDir: '/work/app/migrations', migrationsRelative: 'migrations', }); }); }); - - describe('a migrations dir the config loader already made absolute', () => { - it('leaves it alone', () => { - const paths = resolveMigrationPaths( - undefined, - { migrations: { dir: '/app/migrations' } }, - '/tmp', - ); - - expect(paths.migrationsDir).toBe('/app/migrations'); - }); - }); }); describe('toStructuralEdge', () => { diff --git a/packages/1-framework/3-tooling/config-loader/README.md b/packages/1-framework/3-tooling/config-loader/README.md index 7e456bac0c1d..90aa5147eb92 100644 --- a/packages/1-framework/3-tooling/config-loader/README.md +++ b/packages/1-framework/3-tooling/config-loader/README.md @@ -6,8 +6,10 @@ Discovers, validates, and finalizes `prisma.config.ts`. ## Overview -This package owns config _loading_ — the file I/O (`c12`), validation, and finalization -that turns a `prisma.config.ts` on disk into a resolved `PrismaNextConfig`. It also +This package owns config _loading_ — the file I/O (`c12`), validation, and path resolution +that turns a `prisma.config.ts` on disk into a resolved `PrismaNextConfig`. Every relative path +in the file is resolved against the file that wrote it, layer by layer before layers merge; see +[ADR 253](../../../../docs/architecture%20docs/adrs/ADR%20253%20-%20Config%20paths%20resolve%20against%20the%20file%20that%20wrote%20them.md). It also performs the emitter-derived artifact-collision check (`getEmittedArtifactPaths`). It exposes a single `loadConfig(configPath?)` that maps failures to the CLI's structured diff --git a/packages/1-framework/3-tooling/config-loader/package.json b/packages/1-framework/3-tooling/config-loader/package.json index 4bbcebee6e97..7aa4f412c50d 100644 --- a/packages/1-framework/3-tooling/config-loader/package.json +++ b/packages/1-framework/3-tooling/config-loader/package.json @@ -21,6 +21,7 @@ "@internal/errors": "workspace:8.0.0-rc.11", "@internal/utils": "workspace:8.0.0-rc.11", "c12": "^3.3.4", + "defu": "^6.1.7", "pathe": "^2.0.3" }, "devDependencies": { diff --git a/packages/1-framework/3-tooling/config-loader/src/exports/index.ts b/packages/1-framework/3-tooling/config-loader/src/exports/index.ts index e13af5fc6e29..7a1bb4d6b5fb 100644 --- a/packages/1-framework/3-tooling/config-loader/src/exports/index.ts +++ b/packages/1-framework/3-tooling/config-loader/src/exports/index.ts @@ -1,5 +1,4 @@ export type { PrismaNextConfig } from '@internal/config/config-types'; -export { finalizeConfig } from '../finalize-config'; export type { ConfigSection, LoadedConfig } from '../load'; export { findNearestConfigPathForFile, diff --git a/packages/1-framework/3-tooling/config-loader/src/finalize-config.ts b/packages/1-framework/3-tooling/config-loader/src/finalize-config.ts deleted file mode 100644 index eedc33bb0229..000000000000 --- a/packages/1-framework/3-tooling/config-loader/src/finalize-config.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { PrismaNextConfig } from '@internal/config/config-types'; -import { normalizeContractConfig } from '@internal/config/config-types'; -import { resolve } from 'pathe'; - -type ContractSourceProvider = NonNullable['source']; - -function finalizeContractSource( - source: ContractSourceProvider, - configDir: string, -): ContractSourceProvider { - const resolvedInputs = source.inputs?.map((input) => resolve(configDir, input)); - if (resolvedInputs === undefined) { - return source; - } - - return { - ...source, - inputs: resolvedInputs, - }; -} - -type ContractConfig = NonNullable; - -/** Normalizes a contract section and resolves its paths against `configDir`. */ -export function finalizeContractConfig( - contract: ContractConfig, - configDir: string, -): ContractConfig { - const normalized = normalizeContractConfig(contract); - return { - ...normalized, - source: finalizeContractSource(normalized.source, configDir), - output: resolve(configDir, normalized.output), - }; -} - -const DEFAULT_MIGRATIONS_DIR = 'migrations'; - -type MigrationsConfig = NonNullable; - -/** - * Resolves the migrations directory against `configDir`, which is what `migrations.dir` is - * documented to be relative to. The default is applied here too, so no caller re-derives it - * against a different base — a command run from one directory with `--config` naming a project in - * another would otherwise read the wrong `migrations/`. - */ -export function finalizeMigrationsConfig( - migrations: PrismaNextConfig['migrations'], - configDir: string, -): MigrationsConfig & { readonly dir: string } { - return { - ...migrations, - dir: resolve(configDir, migrations?.dir ?? DEFAULT_MIGRATIONS_DIR), - }; -} - -export function finalizeConfig(config: PrismaNextConfig, configDir: string): PrismaNextConfig { - return { - ...config, - ...(config.contract - ? { contract: finalizeContractConfig(config.contract, configDir) } - : undefined), - migrations: finalizeMigrationsConfig(config.migrations, configDir), - }; -} diff --git a/packages/1-framework/3-tooling/config-loader/src/load.ts b/packages/1-framework/3-tooling/config-loader/src/load.ts index bb8a64eb7173..b3299dc406f8 100644 --- a/packages/1-framework/3-tooling/config-loader/src/load.ts +++ b/packages/1-framework/3-tooling/config-loader/src/load.ts @@ -2,6 +2,7 @@ import { realpathSync } from 'node:fs'; import { access } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; +import { resolveConfigPaths, withConfigDefaults } from '@internal/config/config-resolve'; import type { PrismaNextConfig } from '@internal/config/config-types'; import type { ConfigSection } from '@internal/config/config-validation'; import { collectConfigIssues } from '@internal/config/config-validation'; @@ -17,8 +18,8 @@ import { blindCast } from '@internal/utils/casts'; import { ifDefined } from '@internal/utils/defined'; import { notOk, ok, type Result } from '@internal/utils/result'; import { isStructuredError } from '@internal/utils/structured-error'; +import { defu } from 'defu'; import { dirname, join, resolve } from 'pathe'; -import { finalizeContractConfig, finalizeMigrationsConfig } from './finalize-config'; const CONFIG_FILENAME = 'prisma.config.ts'; @@ -99,6 +100,40 @@ function collectArtifactCollisionDiagnostics( return []; } +interface ConfigLayer { + readonly config?: unknown; + /** Absolute, or relative to the requested config's directory for an extended layer. */ + readonly configFile?: string; +} + +/** + * Resolves each file's `orm` section against that file, then merges the + * layers nearest-first. Merging after resolution is what keeps a relative + * path relative to the file that wrote it (ADR 253). + */ +function resolveOrmLayers( + layers: readonly ConfigLayer[], + merged: unknown, + rootCwd: string, +): unknown { + const resolved = layers.flatMap((layer) => { + const orm = isRecord(layer.config) ? layer.config['orm'] : undefined; + if (!isRecord(orm) || typeof layer.configFile !== 'string') { + return []; + } + const configFile = resolve(rootCwd, layer.configFile); + const section = blindCast< + PrismaNextConfig, + 'resolution touches only contract and migrations paths and leaves any other value for collectConfigIssues' + >(orm); + return [resolveConfigPaths(section, dirname(configFile))]; + }); + if (resolved.length === 0) { + return merged; + } + return resolved.reduce((nearest, base) => defu(nearest, base)); +} + function buildLoadedConfig(rawConfig: Record, configDir: string): LoadedConfig { const issues = collectConfigIssues(rawConfig); const diagnostics = issues.map((issue) => @@ -110,19 +145,16 @@ function buildLoadedConfig(rawConfig: Record, configDir: string 'Structure was checked by collectConfigIssues; sections carrying diagnostics are guarded by requireConfigSections' >(rawConfig); - // A section that already has a diagnostic is not well-typed enough to - // finalize; it is left exactly as authored for the caller to report. const config = issues.some((issue) => issue.section === 'migrations') ? raw - : { ...raw, migrations: finalizeMigrationsConfig(raw.migrations, configDir) }; + : withConfigDefaults({ ...raw, rootDir: raw.rootDir ?? configDir }); if (config.contract === undefined || issues.some((issue) => issue.section === 'contract')) { return { config, diagnostics }; } - const contract = finalizeContractConfig(config.contract, configDir); - diagnostics.push(...collectArtifactCollisionDiagnostics(contract)); - return { config: { ...config, contract }, diagnostics }; + diagnostics.push(...collectArtifactCollisionDiagnostics(config.contract)); + return { config, diagnostics }; } function toConfigLoadFailure(error: unknown, configPath?: string): CliStructuredError { @@ -235,7 +267,8 @@ export async function loadConfig( ], }); } - return ok(buildLoadedConfig(orm ?? {}, loadedConfigDir)); + const resolvedOrm = resolveOrmLayers(result.layers ?? [], orm, configCwd); + return ok(buildLoadedConfig(isRecord(resolvedOrm) ? resolvedOrm : {}, loadedConfigDir)); } /* v8 ignore next -- a config that evaluated always carries its resolved path */ diff --git a/packages/1-framework/3-tooling/config-loader/test/finalize-config.test.ts b/packages/1-framework/3-tooling/config-loader/test/finalize-config.test.ts deleted file mode 100644 index c92df7762c0b..000000000000 --- a/packages/1-framework/3-tooling/config-loader/test/finalize-config.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import type { PrismaNextConfig } from '@internal/config/config-types'; -import { ok } from '@internal/utils/result'; -import { describe, expect, it } from 'vitest'; -import { finalizeConfig } from '../src/finalize-config'; - -function createConfig( - contract?: PrismaNextConfig['contract'], - overrides: Partial = {}, -): PrismaNextConfig { - return { - family: { - kind: 'family', - id: 'sql', - familyId: 'sql', - version: '0.0.1', - manifest: {}, - emission: { id: 'sql' } as never, - create: () => ({ familyId: 'sql' }) as never, - }, - target: { - kind: 'target', - familyId: 'sql', - targetId: 'postgres', - id: 'postgres', - version: '0.0.1', - manifest: {}, - contractSerializer: { - deserializeContract: (json) => json as never, - serializeContract: () => ({}), - }, - create: () => ({ familyId: 'sql', targetId: 'postgres' }), - }, - adapter: { - kind: 'adapter', - familyId: 'sql', - targetId: 'postgres', - id: 'postgres', - version: '0.0.1', - manifest: {}, - create: () => ({ familyId: 'sql', targetId: 'postgres' }), - }, - ...(contract ? { contract } : {}), - ...overrides, - } as PrismaNextConfig; -} - -function createSource(inputs?: readonly string[]) { - return { - ...(inputs ? { inputs } : {}), - load: async () => ok({ targetFamily: 'sql' } as never), - }; -} - -describe('finalizeConfig', () => { - it('leaves the contract absent when the config declares none', () => { - expect(finalizeConfig(createConfig(), '/project').contract).toBeUndefined(); - }); - - describe('the migrations directory', () => { - it('resolves a relative dir against the config directory, not the invocation directory', () => { - const config = createConfig(undefined, { migrations: { dir: 'db' } }); - - expect(finalizeConfig(config, '/project').migrations?.dir).toBe('/project/db'); - }); - - it('supplies the default dir so callers never re-derive it against another base', () => { - expect(finalizeConfig(createConfig(), '/project').migrations?.dir).toBe( - '/project/migrations', - ); - }); - - it('leaves an absolute dir alone', () => { - const config = createConfig(undefined, { migrations: { dir: '/elsewhere/db' } }); - - expect(finalizeConfig(config, '/project').migrations?.dir).toBe('/elsewhere/db'); - }); - }); - - it('resolves relative inputs and output against the config directory', () => { - const config = createConfig({ - source: createSource(['./schema.prisma', 'nested/extra.prisma']), - output: './generated/contract.json', - }); - - const result = finalizeConfig(config, '/project'); - - expect(result.contract?.source.inputs).toEqual([ - '/project/schema.prisma', - '/project/nested/extra.prisma', - ]); - expect(result.contract?.output).toBe('/project/generated/contract.json'); - }); - - it('preserves the source when inputs are omitted', () => { - const config = createConfig({ - source: createSource(), - output: './contract.json', - }); - - const result = finalizeConfig(config, '/project'); - - expect(result.contract?.source.inputs).toBeUndefined(); - }); - - it('leaves emitted artifact collision checks to tooling config loaders', () => { - const config = createConfig({ - source: createSource(['./generated/contract.json']), - output: './generated/contract.json', - }); - - const result = finalizeConfig(config, '/project'); - - expect(result.contract?.source.inputs).toEqual(['/project/generated/contract.json']); - expect(result.contract?.output).toBe('/project/generated/contract.json'); - }); - - it('preserves non-contract authoring fields while resolving the contract', () => { - const driver = { id: 'postgres', familyId: 'sql', create: () => ({}) }; - const config = createConfig( - { - source: createSource(['./schema.prisma']), - output: './generated/contract.json', - }, - { driver } as unknown as Partial, - ); - - const result = finalizeConfig(config, '/project'); - - expect(result.family).toBe(config.family); - expect(result.target).toBe(config.target); - expect(result.adapter).toBe(config.adapter); - expect(result.driver).toBe(driver); - }); -}); diff --git a/packages/1-framework/3-tooling/config-loader/test/load.test.ts b/packages/1-framework/3-tooling/config-loader/test/load.test.ts index 97f0573ed475..357315cadec1 100644 --- a/packages/1-framework/3-tooling/config-loader/test/load.test.ts +++ b/packages/1-framework/3-tooling/config-loader/test/load.test.ts @@ -455,6 +455,50 @@ describe('loadConfig', () => { timeouts.typeScriptCompilation, ); + it( + 'resolves each layer against its own file before merging', + async () => { + const baseDir = join(tempDir, 'base'); + mkdirSync(baseDir); + writeFileSync( + join(baseDir, 'base.config.ts'), + `${CONFIG_BODY}\nexport default { orm: { ...config, migrations: { dir: './db' } } };\n`, + ); + writeFileSync( + join(tempDir, 'prisma.config.ts'), + "export default { $prismaConfig: 1, extends: './base/base.config.ts', orm: { contract: { output: './out/contract.json' } } };\n", + ); + process.chdir(tempDir); + + const { config, diagnostics } = (await loadConfig()).assertOk(); + + expect(diagnostics).toEqual([]); + expect(config).toMatchObject({ + rootDir: tempDir, + contract: { + source: { inputs: [join(baseDir, 'schema.prisma')] }, + output: join(tempDir, 'out', 'contract.json'), + }, + migrations: { dir: join(baseDir, 'db') }, + }); + }, + timeouts.typeScriptCompilation, + ); + + it( + 'records the config file directory as rootDir', + async () => { + writeFileSync(join(tempDir, 'prisma.config.ts'), VALID_CONFIG_SOURCE); + process.chdir(tempDir); + + const { config } = (await loadConfig()).assertOk(); + + expect(config.rootDir).toBe(tempDir); + expect(config.migrations?.dir).toBe(join(tempDir, 'migrations')); + }, + timeouts.typeScriptCompilation, + ); + it( 'rejects an unmarked config that extends a marked base config', async () => { diff --git a/packages/1-framework/3-tooling/vite-plugin-contract-emit/src/plugin.ts b/packages/1-framework/3-tooling/vite-plugin-contract-emit/src/plugin.ts index 55d6d0d15c24..8e80da517b14 100644 --- a/packages/1-framework/3-tooling/vite-plugin-contract-emit/src/plugin.ts +++ b/packages/1-framework/3-tooling/vite-plugin-contract-emit/src/plugin.ts @@ -2,7 +2,7 @@ import type { ContractEmitResult } from '@internal/cli/control-api'; import { disposeEmitQueue, executeContractEmit } from '@internal/cli/control-api'; import { loadConfigForSections } from '@internal/config-loader'; import { getEmittedArtifactPaths } from '@internal/emitter'; -import { extname, resolve } from 'pathe'; +import { dirname, extname, resolve } from 'pathe'; import type { Plugin, ViteDevServer } from 'vite'; import type { PrismaVitePluginOptions } from './types'; @@ -129,7 +129,7 @@ export function prismaVitePlugin( const result = await executeContractEmit({ config: configResult.value, cwd: process.cwd(), - configPath: absoluteConfigPath, + projectDir: dirname(absoluteConfigPath), signal, }); diff --git a/packages/1-framework/3-tooling/vite-plugin-contract-emit/test/plugin.test.ts b/packages/1-framework/3-tooling/vite-plugin-contract-emit/test/plugin.test.ts index 9407f7f5bb20..4dd199c877a5 100644 --- a/packages/1-framework/3-tooling/vite-plugin-contract-emit/test/plugin.test.ts +++ b/packages/1-framework/3-tooling/vite-plugin-contract-emit/test/plugin.test.ts @@ -32,6 +32,7 @@ vi.mock('@internal/emitter', () => ({ vi.mock('pathe', async () => { const path = await vi.importActual('node:path'); return { + dirname: path.dirname, extname: path.extname, resolve: path.resolve, }; @@ -250,7 +251,7 @@ describe('prismaVitePlugin', () => { expect(mockedExecuteContractEmit).toHaveBeenCalledWith( expect.objectContaining({ - configPath: '/project/prisma.config.ts', + projectDir: '/project', }), ); }); @@ -269,7 +270,7 @@ describe('prismaVitePlugin', () => { expect(mockedExecuteContractEmit).toHaveBeenCalledWith( expect.objectContaining({ - configPath: '/absolute/prisma.config.ts', + projectDir: '/absolute', }), ); }); @@ -420,7 +421,7 @@ describe('prismaVitePlugin', () => { expect(mockedExecuteContractEmit).toHaveBeenCalledWith( expect.objectContaining({ - configPath: expect.stringContaining('prisma.config.ts'), + projectDir: expect.any(String), }), ); }); @@ -541,7 +542,7 @@ describe('prismaVitePlugin', () => { ); expect(mockedExecuteContractEmit).toHaveBeenCalledWith( expect.objectContaining({ - configPath: '/project/prisma.config.ts', + projectDir: '/project', }), ); expect(consoleErrorSpy).not.toHaveBeenCalledWith( diff --git a/packages/9-public/@prisma/orm-framework/package.json b/packages/9-public/@prisma/orm-framework/package.json index b9d4038c6aa8..648d85d5eee9 100644 --- a/packages/9-public/@prisma/orm-framework/package.json +++ b/packages/9-public/@prisma/orm-framework/package.json @@ -60,6 +60,7 @@ "./components/runtime": "./dist/components__runtime.mjs", "./components/utils": "./dist/components__utils.mjs", "./config": "./dist/config.mjs", + "./config/config-resolve": "./dist/config__config-resolve.mjs", "./config/config-types": "./dist/config__config-types.mjs", "./config/config-validation": "./dist/config__config-validation.mjs", "./contract": "./dist/contract.mjs", diff --git a/packages/9-public/@prisma/orm-toolchain/package.json b/packages/9-public/@prisma/orm-toolchain/package.json index cd96195253f1..d71b71656c76 100644 --- a/packages/9-public/@prisma/orm-toolchain/package.json +++ b/packages/9-public/@prisma/orm-toolchain/package.json @@ -22,6 +22,7 @@ "clipanion": "4.0.0-rc.4", "closest-match": "^1.3.3", "colorette": "^2.0.20", + "defu": "^6.1.7", "esbuild": "^0.28.2", "jsonc-parser": "^3.3.1", "package-manager-detector": "^1.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ccf1372bee1e..0772759240df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1524,6 +1524,9 @@ importers: c12: specifier: ^3.3.4 version: 3.3.4(magicast@0.5.4) + defu: + specifier: ^6.1.7 + version: 6.1.7 pathe: specifier: ^2.0.3 version: 2.0.3 @@ -5226,6 +5229,9 @@ importers: colorette: specifier: ^2.0.20 version: 2.0.20 + defu: + specifier: ^6.1.7 + version: 6.1.7 esbuild: specifier: ^0.28.2 version: 0.28.2 diff --git a/test/integration/test/cli.init-templates.e2e.test.ts b/test/integration/test/cli.init-templates.e2e.test.ts index e4d33a180e53..366213a0cfb2 100644 --- a/test/integration/test/cli.init-templates.e2e.test.ts +++ b/test/integration/test/cli.init-templates.e2e.test.ts @@ -72,7 +72,11 @@ async function emitContract(testDir: string, configPath: string): Promise if (!loaded.ok) { throw loaded.failure; } - await executeContractEmit({ config: loaded.value, cwd: testDir, configPath }); + await executeContractEmit({ + config: loaded.value, + cwd: testDir, + projectDir: dirname(configPath), + }); } finally { process.chdir(originalCwd); } From b02ad1872a1a94108476d2fab856496ecd43328e Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 21 Sep 2026 15:42:52 +0200 Subject: [PATCH 3/5] feat(config): the loader tells prisma.config.ts where it is, and ormConfig resolves its own paths Replaces the resolver protocol with the simplest mechanism that gives the same guarantee: the loader publishes the directory of the file it is evaluating in globalThis[Symbol.for("prisma.config.baseDir")] for the duration of the evaluation, and defineConfig reads it while the file runs to resolve the contract source inputs, contract.output and migrations.dir, recording the directory as baseDir on the section. Evaluated outside a loader the paths stay as written and no baseDir is recorded, which the section validator refuses. The per-layer resolve-then-merge pass, the resolver key, and the defu dependency are gone. rootDir is renamed baseDir throughout, the industry term for the directory relative paths resolve against. ADR 253 is rewritten around the decision, with the resolver protocol and definePrismaConfig(import.meta, ...) recorded as alternatives. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- ...esolve against the file that wrote them.md | 87 ++++++++----------- .../1-framework/1-core/config/package.json | 1 + .../1-core/config/src/config-base-dir.ts | 29 +++++++ .../1-core/config/src/config-resolve.ts | 78 +++++------------ .../1-core/config/src/config-types.ts | 22 +++-- .../config/src/exports/config-base-dir.ts | 1 + .../config/src/exports/config-resolve.ts | 7 +- .../config/test/config-base-dir.test.ts | 48 ++++++++++ .../1-core/config/test/config-resolve.test.ts | 72 ++------------- .../1-core/config/test/define-config.test.ts | 59 +++++-------- .../1-core/config/tsdown.config.ts | 1 + .../control-api/operations/migration-new.ts | 2 +- .../control-api/operations/migration-plan.ts | 2 +- .../3-tooling/cli/src/control-api/types.ts | 2 +- .../3-tooling/cli/src/orm/config-section.ts | 37 +++----- .../3-tooling/cli/src/orm/db/init.ts | 4 +- .../3-tooling/cli/src/orm/db/sign.ts | 4 +- .../3-tooling/cli/src/orm/db/update.ts | 4 +- .../3-tooling/cli/src/orm/migrate.ts | 4 +- .../3-tooling/cli/src/orm/migration/new.ts | 4 +- .../3-tooling/cli/src/orm/migration/paths.ts | 10 +-- .../3-tooling/cli/src/orm/migration/plan.ts | 4 +- .../cli/src/utils/command-helpers.ts | 6 +- .../cli/test/helpers/orm-test-cli.ts | 6 +- .../cli/test/orm/config-section.test.ts | 27 ++---- .../cli/test/utils/command-helpers.test.ts | 8 +- .../3-tooling/config-loader/README.md | 5 +- .../3-tooling/config-loader/package.json | 1 - .../3-tooling/config-loader/src/load.ts | 58 ++++--------- .../3-tooling/config-loader/test/load.test.ts | 53 ++++++----- .../@prisma/orm-framework/package.json | 1 + .../@prisma/orm-toolchain/package.json | 1 - pnpm-lock.yaml | 6 -- 33 files changed, 276 insertions(+), 378 deletions(-) create mode 100644 packages/1-framework/1-core/config/src/config-base-dir.ts create mode 100644 packages/1-framework/1-core/config/src/exports/config-base-dir.ts create mode 100644 packages/1-framework/1-core/config/test/config-base-dir.test.ts diff --git a/docs/architecture docs/adrs/ADR 253 - Config paths resolve against the file that wrote them.md b/docs/architecture docs/adrs/ADR 253 - Config paths resolve against the file that wrote them.md index 7005adc531b1..db13f7c14db8 100644 --- a/docs/architecture docs/adrs/ADR 253 - Config paths resolve against the file that wrote them.md +++ b/docs/architecture docs/adrs/ADR 253 - Config paths resolve against the file that wrote them.md @@ -2,7 +2,7 @@ ## Decision -A relative path written in a `prisma.config.ts` is relative to that file. The loader resolves it against the file's directory as part of loading, one file at a time, before any files are merged and before any command sees the config. Nothing changes in how a config is written: +A relative path written in a `prisma.config.ts` is relative to that file. The file resolves the path itself while it is being evaluated: the loader tells the file where it is, and the config helper the file calls resolves its paths on the spot. Nothing changes in how a config is written: ```ts // /home/me/app/prisma.config.ts @@ -17,12 +17,12 @@ export default definePrismaConfig({ }); ``` -Loading this file yields the same object from any working directory. Every path is absolute, and the section records the directory it was resolved against as `rootDir`, for anything that needs the project's location rather than one of its files: +Loading this file yields the same object from any working directory. Every path is absolute, and the section records the directory it was resolved against as `baseDir`, for anything that needs the project's location rather than one of its files: ```ts { orm: { - rootDir: '/home/me/app', + baseDir: '/home/me/app', contract: { /* source read from /home/me/app/contract.prisma */ output: '/home/me/app/contract.json' }, migrations: { dir: '/home/me/app/migrations' }, }, @@ -43,88 +43,71 @@ exp/ `prisma contract emit --config ./sub/prisma.config.ts`, run from `exp`, must read `exp/sub/contract.prisma`. The author wrote `./contract.prisma` next to the file, and that is the only reading of it that does not change with the caller's shell. Every documented rule agrees: the ORM's `migrations.dir` is documented as relative to the config file, and Prisma Composer and `prisma dev` read paths the same way. -## Why resolution happens per file, before merging +## How the file learns where it is -Configs can be layered: a file in a child directory extends a file in a parent, and the loader merges the two. A merged section holds paths written in different files. In the example below the merged `orm` section has `contract` from the child and `migrations.dir` from the parent, both still relative, with no record of which file wrote which: - -``` -repo/ - prisma.config.ts # orm: { migrations: { dir: './migrations' } } - apps/shop/ - prisma.config.ts # extends: '../../prisma.config.ts' - # orm: { contract: './contract.prisma' } -``` - -Once merged, no single directory can anchor that section: `./migrations` belongs to `repo/` and `./contract.prisma` to `repo/apps/shop/`. The anchor belongs to each path, and it is lost the moment the sections are merged. So resolution must happen while the loader still holds each file separately, and merging must operate on absolute paths only. - -The loader has what it needs for this. Both loaders that read `prisma.config.ts`, the CLI engine's and the ORM's, evaluate the file through c12, and c12 reports one layer per file: the file's own path and that file's unmerged config. Each layer is resolved against its own file, then the layers are merged. - -## How the loader resolves a section - -The loader does not know which fields of a section are paths. Only the family that defines the section knows that, so resolution is a small protocol between the family's config helper and the loader. +The loader is the one party that knows which file it is evaluating. Before it evaluates the file it publishes that file's directory in a slot on `globalThis` under `Symbol.for('prisma.config.baseDir')`, and clears it after. While the file runs, `ormConfig` reads the slot and resolves every path in its section against it, recording it as `baseDir`. ```mermaid sequenceDiagram + participant L as loader participant F as prisma.config.ts participant O as ormConfig - participant L as loader + L->>L: publish baseDir = dirname(file) + L->>F: evaluate F->>O: ormConfig({ contract: './contract.prisma' }) - O-->>F: section with relative paths and a resolver - L->>F: evaluate (c12, one layer per file) - F-->>L: layer: configFile + unmerged config - L->>O: resolver(dirname(configFile)) - O-->>L: section with absolute paths and rootDir - L->>L: merge resolved layers + O->>O: read baseDir, resolve paths, record baseDir + O-->>F: section with absolute paths + F-->>L: config + L->>L: clear baseDir ``` -- A family's config helper returns its section with relative paths untouched and a resolver function stored under the key `Symbol.for('prisma.config.resolve')`. The resolver takes a directory and returns the section with absolute paths and `rootDir` set. -- After c12 evaluates the file, the loader resolves each layer against `dirname(configFile)` and then merges the resolved layers. The engine's loader, which does not know what any section contains, does this through `resolveConfigSections`, a function it exports that calls the resolver on every section that has one, removes the key, and leaves other sections untouched. A loader that belongs to the family, such as the ORM's own, resolves its section directly with the same function the resolver wraps. -- The key is created with `Symbol.for` rather than exported from the engine, so it is the same symbol across two engine copies in one dependency tree, and a family declares it without importing the engine. Symbol-keyed properties survive c12's evaluation and appear on each layer's config. -- `definePrismaConfig` is unchanged: it stamps the `$prismaConfig` marker and nothing else. +Three properties follow. -## An unresolved section is refused +- **No party needs to know more than it does.** The loader never learns which fields are paths; the helper never learns which file it is in beyond the one value it needs. There is no protocol between them, only the slot. +- **The user writes nothing.** The file already runs inside a loader; the loader already knows the file. Asking the config author to pass `import.meta` would ask them for a value the system already has. +- **Merging sees absolute paths.** Each file resolves its own paths while it runs, so whatever merges files later never sees a relative path and never needs to know which file wrote which value. -A section that still carries the resolver key has not been resolved. The family's validator refuses it and names what is missing. This is what makes the design safe: a program that evaluates a config without running the resolution step, whether an older engine, a test that imports the file directly, or a new loading route, gets a loud failure, never a path silently resolved against the working directory. +The slot is `Symbol.for`, so every loader and every helper in a dependency tree shares it, and a family package declares it without importing the engine. + +## A section without a base directory is refused + +A file evaluated outside a loader, for instance a test that imports it directly, finds the slot empty. `ormConfig` then leaves the paths as written and records no `baseDir`. The ORM's section validator refuses any section without `baseDir`, naming the two ways that happens: the section was written as a plain object rather than with `defineConfig`, or the loader that evaluated the file predates the base directory. There is no fallback to the working directory: a path silently resolved against the wrong directory is exactly the failure this decision removes, so the only acceptable failure is a loud one. ## Responsibilities -**The engine** owns `resolveConfigSections`: finding resolvers on a config object, calling them with a directory, and removing the key. It never knows which fields are paths. The key itself is created with `Symbol.for`, so it belongs to no package. +**Loaders** publish the base directory around each file they evaluate and clear it after. The CLI engine's loader does this through `withBaseDir`, which it exports for any other loader; the ORM's loader does the same around its own evaluation. -**A family** owns its config helper, its resolver, and its validator's refusal of an unresolved section: which fields are paths, how each resolves, and `rootDir`. The ORM's resolver covers the contract source, `contract.output`, and `migrations.dir`. +**A family's config helper** reads the base directory and resolves its own path fields, recording `baseDir`. The ORM's `defineConfig` covers the contract source, `contract.output`, and `migrations.dir`. Defaults that are themselves paths, such as the migrations directory, are supplied by the reader from `baseDir` rather than written into the section, so a layer that omits a value never shadows a layer that authored one. -**Loaders** evaluate the file through c12, resolve each layer against its own file, and merge. Whichever loader runs, the sections it hands over are resolved. +**Commands** read absolute paths and `baseDir` from the config. A command that needs the project's location, for instance to find the project's `package.json`, starts from `baseDir`. No command reconstructs the config file's path, and no command resolves a config value against the working directory. The one kind of path that is relative to the working directory is a path typed on the command line, such as `--output-path` on `contract emit`, because the shell is where the user wrote it. -**Commands** read absolute paths and `rootDir` from the config. A command that needs the project's location, for instance to find the project's `package.json`, starts from `rootDir`. No command reconstructs the config file's path, and no command resolves a config value against the working directory. The one kind of path that is relative to the working directory is a path typed on the command line, such as `--output-path` on `contract emit`, because the shell is where the user wrote it. +**The validator** refuses a section without `baseDir`. ## Layered configs -Whatever mechanism layering uses to find the files, the loader ends up with one config per file and each file's path. It resolves each against its own file and then merges. `rootDir` follows the family's merge rule for its section; by default the nearest file wins. +Layering holds as long as the loader publishes each file's own directory while that file runs. A loader that evaluates the files itself, one at a time, gets this for free. A loader that delegates the whole chain to c12's `extends` does not: c12 evaluates a base file inside the same call as the file that extends it, so the slot still names the extending file. Whichever mechanism layering adopts, it evaluates each file with its own base directory published; that is the constraint this decision places on it. -## Mismatched versions +## Concurrency -A dependency tree can pair an engine and a family from different releases. An engine whose loader does not run `resolveConfigSections` hands the family a section with the resolver key still on it; the family's validator refuses it and names the engine version it needs. A family whose helper attaches no resolver hands the loader a section with nothing to resolve, which passes through unchanged. Neither pairing resolves a path against the wrong directory. +The slot is process-global, so two config evaluations interleaving in one process would read each other's directory. Nothing loads configs concurrently today, and the loader sets and restores the slot around an awaited evaluation. If concurrent loads ever arrive, the slot moves to an `AsyncLocalStorage` behind the same two functions and nothing else changes. ## Consequences - Config files are written as before. No argument is added and no path is written differently. -- A config object holds absolute paths and a `rootDir` per section that has paths. Nothing resolves a config path after loading. -- Family config helpers attach a resolver rather than being identity functions over an object literal. The resolver is the one place a family's path fields are enumerated. -- Loading a config means loading it through a loader. Importing the file directly yields unresolved sections, which the family's validator refuses; a test that wants a resolved config resolves what it imported against the directory it stands for. -- The ORM's resolved section carries `rootDir`; the migrations directory defaults to `migrations` under it, supplied after merging so a layer's default never shadows a directory another layer authored. -- `resolveConfigSections` and the loader's per-layer resolution are engine changes, so the engine's version moves and every family's exact engine peer moves with it. +- A config object holds absolute paths and a `baseDir` per section that has paths. Nothing resolves a config path after loading. +- Importing a config file directly yields a section without `baseDir`, which the validator refuses; a test that wants a resolved config evaluates it under `withBaseDir`. +- The engine's loader publishes the base directory, which is an engine change, so the engine's version moves and every family's exact engine peer moves with it. ## Alternatives considered **Resolve against the working directory and document it.** Only the unified CLI would follow that rule; every other reader of the file anchors on the file. Authors who run commands from a monorepo root would write `import.meta.dirname` into every path by hand. -**The engine tells each command which file it loaded.** Correct for a single file: the command resolves against that file's directory. It needs a field on the command context and post-load resolution code in every family, and it fails under layering, where a merged section holds paths from several files and one path cannot anchor them. +**The engine tells each command which file it loaded.** Correct for a single file: the command resolves against that file's directory after loading. It needs a field on the command context and post-load resolution code in every family, and it fails under layering, where a merged section holds paths from several files and one path cannot anchor them. **The engine resolves paths itself.** The engine hands sections over opaquely and cannot know which fields are paths. That knowledge belongs to each family. -**Resolve inside the file: `definePrismaConfig(import.meta, {...})`.** The file resolves its own sections before any loader sees them, which is fully independent of how the file is loaded. It costs every config file an argument that the loader already knows the value of, since c12 reports the file it evaluated. The loader-side design keeps the same resolver protocol and the same per-file guarantee without the argument. - -**The family helper takes the location: `ormConfig(import.meta, {...})`.** The same as the previous option with the argument repeated on every helper in every file. +**Pass the file's location in: `definePrismaConfig(import.meta, {...})`.** Fully explicit, and independent of how the file is loaded. It asks every config author for a value the loader already knows, and because the inner helper runs before the outer call, it still needs a deferred-resolution protocol between them to work at all. -**Hand each family its per-file sections and let the family merge.** Correct under layering, but it moves merging into every family and turns the loader's output into a list. Resolving per layer inside the loader and merging there needs none of it. +**A resolver on the section, called by the loader after evaluation.** The helper attaches a function under a shared key, the loader calls it per file with the file's directory and strips the key. Achieves the same result with no user-visible change, but through a protocol two parties must both implement, plus per-layer resolution and merging inside every loader. Publishing the directory to the file removes all of that machinery for the same guarantee. -**The engine sets a global before evaluating each file, so a helper can read the current file.** No visible surface, but hidden state that breaks whenever a config is imported outside the engine's loader. The loader-side design has the same independence from the file's contents without the global. +**Per-layer sections with provenance.** The engine hands each family its section from each file, tagged with the file, and the family resolves each layer and merges. Correct under layering, but it moves merging and provenance tracking into every family and turns the loader's output into a list. diff --git a/packages/1-framework/1-core/config/package.json b/packages/1-framework/1-core/config/package.json index 152eb0db8924..942c2c2e72b1 100644 --- a/packages/1-framework/1-core/config/package.json +++ b/packages/1-framework/1-core/config/package.json @@ -42,6 +42,7 @@ "src" ], "exports": { + "./config-base-dir": "./dist/config-base-dir.mjs", "./config-resolve": "./dist/config-resolve.mjs", "./config-types": "./dist/config-types.mjs", "./config-validation": "./dist/config-validation.mjs", diff --git a/packages/1-framework/1-core/config/src/config-base-dir.ts b/packages/1-framework/1-core/config/src/config-base-dir.ts new file mode 100644 index 000000000000..72012a870858 --- /dev/null +++ b/packages/1-framework/1-core/config/src/config-base-dir.ts @@ -0,0 +1,29 @@ +/** + * The directory relative paths in a config file resolve against: the + * directory of the file being evaluated. The loader sets it around the + * evaluation and the config helpers read it while the file runs. `Symbol.for` + * so every loader and helper in a dependency tree shares one slot. + */ +export const BASE_DIR_KEY: unique symbol = Symbol.for('prisma.config.baseDir'); + +type BaseDirSlot = { [BASE_DIR_KEY]?: string }; + +export function baseDir(): string | undefined { + return (globalThis as BaseDirSlot)[BASE_DIR_KEY]; +} + +/** Runs `evaluate` with `dir` as the base directory, restoring the previous value after. */ +export async function withBaseDir(dir: string, evaluate: () => Promise): Promise { + const slot = globalThis as BaseDirSlot; + const previous = slot[BASE_DIR_KEY]; + slot[BASE_DIR_KEY] = dir; + try { + return await evaluate(); + } finally { + if (previous === undefined) { + delete slot[BASE_DIR_KEY]; + } else { + slot[BASE_DIR_KEY] = previous; + } + } +} diff --git a/packages/1-framework/1-core/config/src/config-resolve.ts b/packages/1-framework/1-core/config/src/config-resolve.ts index 3e750e4dc010..c0badb6508c2 100644 --- a/packages/1-framework/1-core/config/src/config-resolve.ts +++ b/packages/1-framework/1-core/config/src/config-resolve.ts @@ -1,41 +1,20 @@ import { resolve } from 'pathe'; import type { ContractConfig, PrismaNextConfig } from './config-types'; -/** - * The key under which a config section carries its path resolver until a loader - * has resolved it against the file that wrote it. `Symbol.for` so the key is the - * same across every copy of this package in a dependency tree. - */ -export const CONFIG_RESOLVE: unique symbol = Symbol.for('prisma.config.resolve'); - -export type ConfigResolver = (rootDir: string) => TConfig; - -export interface UnresolvedConfig { - readonly [CONFIG_RESOLVE]: ConfigResolver; -} - -export function isUnresolvedConfig(value: unknown): value is UnresolvedConfig { - return ( - typeof value === 'object' && - value !== null && - typeof (value as Partial>)[CONFIG_RESOLVE] === 'function' - ); -} - type ContractSourceProvider = NonNullable['source']; /** A value that is not a string is left for validation to report. */ -function resolveAuthored(rootDir: string, value: string): string { - return typeof value === 'string' ? resolve(rootDir, value) : value; +function resolveAuthored(baseDir: string, value: string): string { + return typeof value === 'string' ? resolve(baseDir, value) : value; } function resolveContractSource( source: ContractSourceProvider, - rootDir: string, + baseDir: string, ): ContractSourceProvider { const inputs = source.inputs; return Array.isArray(inputs) - ? { ...source, inputs: inputs.map((input) => resolveAuthored(rootDir, input)) } + ? { ...source, inputs: inputs.map((input) => resolveAuthored(baseDir, input)) } : source; } @@ -59,12 +38,12 @@ export function normalizeContractConfig( }; } -export function resolveContractConfig(contract: ContractConfig, rootDir: string): ContractConfig { +export function resolveContractConfig(contract: ContractConfig, baseDir: string): ContractConfig { const normalized = normalizeContractConfig(contract); return { ...normalized, - ...(normalized.source ? { source: resolveContractSource(normalized.source, rootDir) } : {}), - output: resolveAuthored(rootDir, normalized.output), + ...(normalized.source ? { source: resolveContractSource(normalized.source, baseDir) } : {}), + output: resolveAuthored(baseDir, normalized.output), }; } @@ -74,63 +53,46 @@ type MigrationsConfig = NonNullable; export function resolveMigrationsConfig( migrations: PrismaNextConfig['migrations'], - rootDir: string, + baseDir: string, ): PrismaNextConfig['migrations'] { return migrations?.dir === undefined ? migrations - : { ...migrations, dir: resolveAuthored(rootDir, migrations.dir) }; + : { ...migrations, dir: resolveAuthored(baseDir, migrations.dir) }; } /** - * Supplies the defaults a section may omit, anchored on its `rootDir`. Applied + * Supplies the defaults a section may omit, anchored on its `baseDir`. Applied * once, after every layer has been resolved and merged, so a layer's default * never shadows a value another layer authored. */ export function withConfigDefaults( - config: TConfig & { readonly rootDir: string }, + config: TConfig & { readonly baseDir: string }, ): TConfig & { readonly migrations: MigrationsConfig & { readonly dir: string } } { return { ...config, migrations: { ...config.migrations, - dir: config.migrations?.dir ?? resolve(config.rootDir, DEFAULT_MIGRATIONS_DIR), + dir: config.migrations?.dir ?? resolve(config.baseDir, DEFAULT_MIGRATIONS_DIR), }, }; } /** - * Resolves every authored path in the ORM config section against `rootDir`, - * the directory of the config file that wrote it, and records `rootDir` on the + * Resolves every authored path in the ORM config section against `baseDir`, + * the directory of the config file that wrote it, and records `baseDir` on the * section. Defaults are not supplied here; see {@link withConfigDefaults}. * Idempotent: an absolute path resolves to itself. */ export function resolveConfigPaths( config: TConfig, - rootDir: string, -): TConfig { - const { [CONFIG_RESOLVE]: _resolver, ...authored } = config as TConfig & - Partial>; + baseDir: string, +): TConfig & { readonly baseDir: string } { return { - ...(authored as TConfig), - rootDir, - ...(config.contract ? { contract: resolveContractConfig(config.contract, rootDir) } : {}), + ...config, + baseDir, + ...(config.contract ? { contract: resolveContractConfig(config.contract, baseDir) } : {}), ...(config.migrations - ? { migrations: resolveMigrationsConfig(config.migrations, rootDir) } + ? { migrations: resolveMigrationsConfig(config.migrations, baseDir) } : {}), }; } - -/** Attaches the resolver a loader calls once it knows which file wrote the section. */ -export function withPathResolver( - config: TConfig, -): TConfig & UnresolvedConfig { - return { ...config, [CONFIG_RESOLVE]: (rootDir: string) => resolveConfigPaths(config, rootDir) }; -} - -/** - * Resolves the section if it still carries its resolver; a section that was - * resolved already, or never carried one, is returned unchanged. - */ -export function resolveConfigSection(value: unknown, rootDir: string): unknown { - return isUnresolvedConfig(value) ? value[CONFIG_RESOLVE](rootDir) : value; -} diff --git a/packages/1-framework/1-core/config/src/config-types.ts b/packages/1-framework/1-core/config/src/config-types.ts index 0f719cd80f4f..dee875d3e589 100644 --- a/packages/1-framework/1-core/config/src/config-types.ts +++ b/packages/1-framework/1-core/config/src/config-types.ts @@ -6,7 +6,8 @@ import type { ControlFamilyDescriptor, ControlTargetDescriptor, } from '@internal/framework-components/control'; -import { normalizeContractConfig, withPathResolver } from './config-resolve'; +import { baseDir } from './config-base-dir'; +import { normalizeContractConfig, resolveConfigPaths } from './config-resolve'; import type { ContractSourceProvider } from './contract-source-types'; /** @@ -94,11 +95,12 @@ export interface PrismaNextConfig< }; readonly formatter?: FormatterConfig; /** - * The directory of the config file that wrote this section, set when the - * section's paths are resolved. Every relative path above is resolved - * against it; commands that need the project's location start here. + * The directory of the config file that wrote this section: the base every + * relative path above was resolved against. Set by `defineConfig` while the + * file is evaluated by a loader; commands that need the project's location + * start here. */ - readonly rootDir?: string; + readonly baseDir?: string; } /** @@ -111,9 +113,10 @@ export interface PrismaNextConfig< * - contract.output defaults to a path colocated with DEFAULT_CONTRACT_SOURCE_DIR * when missing (in-memory-only providers) * - * Relative paths stay as written. The section carries a resolver under - * `CONFIG_RESOLVE` that a loader calls with the directory of the file that - * wrote the section; see ADR 253. + * Relative paths are resolved against the directory of the file being + * evaluated, which the loader publishes while the file runs (see + * `withBaseDir` and ADR 253). Evaluated outside a loader, the paths stay as + * written and no `baseDir` is recorded, which the CLI refuses. * * @param config - Raw config input from user * @returns Normalized config IR with defaults applied @@ -124,5 +127,6 @@ export function defineConfig { + it('publishes the directory while the evaluation runs and clears it after', async () => { + let seen: string | undefined; + + await withBaseDir('/app', async () => { + seen = baseDir(); + }); + + expect(seen).toBe('/app'); + expect(baseDir()).toBeUndefined(); + }); + + it('restores the outer directory for a nested evaluation', async () => { + let inner: string | undefined; + let afterInner: string | undefined; + + await withBaseDir('/outer', async () => { + await withBaseDir('/inner', async () => { + inner = baseDir(); + }); + afterInner = baseDir(); + }); + + expect({ inner, afterInner }).toEqual({ inner: '/inner', afterInner: '/outer' }); + }); + + it('clears the directory when the evaluation throws', async () => { + await expect( + withBaseDir('/app', async () => { + throw new Error('boom'); + }), + ).rejects.toThrow('boom'); + + expect(baseDir()).toBeUndefined(); + }); + + it('is the shared slot any loader can set without importing this package', async () => { + const slot = globalThis as { [BASE_DIR_KEY]?: string }; + slot[Symbol.for('prisma.config.baseDir') as typeof BASE_DIR_KEY] = '/elsewhere'; + + expect(baseDir()).toBe('/elsewhere'); + + delete slot[BASE_DIR_KEY]; + }); +}); diff --git a/packages/1-framework/1-core/config/test/config-resolve.test.ts b/packages/1-framework/1-core/config/test/config-resolve.test.ts index dee0c3ae1c9c..fba9bb46cd0e 100644 --- a/packages/1-framework/1-core/config/test/config-resolve.test.ts +++ b/packages/1-framework/1-core/config/test/config-resolve.test.ts @@ -1,13 +1,6 @@ import { ok } from '@internal/utils/result'; import { describe, expect, it } from 'vitest'; -import { - CONFIG_RESOLVE, - isUnresolvedConfig, - resolveConfigPaths, - resolveConfigSection, - withConfigDefaults, - withPathResolver, -} from '../src/config-resolve'; +import { resolveConfigPaths, withConfigDefaults } from '../src/config-resolve'; import type { PrismaNextConfig } from '../src/config-types'; function createConfig( @@ -59,8 +52,8 @@ function createSource(inputs?: readonly string[]) { } describe('resolveConfigPaths', () => { - it('records the root directory on the section', () => { - expect(resolveConfigPaths(createConfig(), '/project').rootDir).toBe('/project'); + it('records the base directory on the section', () => { + expect(resolveConfigPaths(createConfig(), '/project').baseDir).toBe('/project'); }); it('leaves the contract absent when the config declares none', () => { @@ -147,69 +140,16 @@ describe('resolveConfigPaths', () => { expect(resolveConfigPaths(once, '/somewhere/else')).toEqual({ ...once, - rootDir: '/somewhere/else', + baseDir: '/somewhere/else', }); }); }); -describe('withPathResolver', () => { - it('attaches a resolver under the shared key and leaves the paths as written', () => { - const config = withPathResolver( - createConfig({ source: createSource(['./schema.prisma']), output: './contract.json' }), - ); - - expect(isUnresolvedConfig(config)).toBe(true); - expect(config.contract?.source.inputs).toEqual(['./schema.prisma']); - expect(config.rootDir).toBeUndefined(); - }); - - it('the resolver resolves against the directory it is given and drops itself', () => { - const config = withPathResolver( - createConfig({ source: createSource(['./schema.prisma']), output: './contract.json' }), - ); - - const resolved = config[CONFIG_RESOLVE]('/project'); - - expect(isUnresolvedConfig(resolved)).toBe(false); - expect(resolved).toMatchObject({ - rootDir: '/project', - contract: { - source: { inputs: ['/project/schema.prisma'] }, - output: '/project/contract.json', - }, - }); - }); - - it('survives an object spread, so a config extended by hand still resolves', () => { - const config = { ...withPathResolver(createConfig()), migrations: { dir: 'db' } }; - - expect(isUnresolvedConfig(config)).toBe(true); - }); -}); - -describe('resolveConfigSection', () => { - it('resolves a section that carries a resolver', () => { - const section = withPathResolver(createConfig(undefined, { migrations: { dir: 'db' } })); - - expect(resolveConfigSection(section, '/project')).toMatchObject({ - rootDir: '/project', - migrations: { dir: '/project/db' }, - }); - }); - - it('returns anything else unchanged', () => { - const plain = { migrations: { dir: 'db' } }; - - expect(resolveConfigSection(plain, '/project')).toBe(plain); - expect(resolveConfigSection(undefined, '/project')).toBeUndefined(); - }); -}); - describe('withConfigDefaults', () => { it('supplies the migrations dir under the root so no caller re-derives it', () => { const config = resolveConfigPaths(createConfig(), '/project'); - expect(withConfigDefaults({ ...config, rootDir: '/project' }).migrations.dir).toBe( + expect(withConfigDefaults({ ...config, baseDir: '/project' }).migrations.dir).toBe( '/project/migrations', ); }); @@ -217,6 +157,6 @@ describe('withConfigDefaults', () => { it('keeps an authored migrations dir', () => { const config = resolveConfigPaths(createConfig(undefined, { migrations: { dir: 'db' } }), '/p'); - expect(withConfigDefaults({ ...config, rootDir: '/p' }).migrations.dir).toBe('/p/db'); + expect(withConfigDefaults({ ...config, baseDir: '/p' }).migrations.dir).toBe('/p/db'); }); }); diff --git a/packages/1-framework/1-core/config/test/define-config.test.ts b/packages/1-framework/1-core/config/test/define-config.test.ts index d6b715a8a3a3..7013d1f0106c 100644 --- a/packages/1-framework/1-core/config/test/define-config.test.ts +++ b/packages/1-framework/1-core/config/test/define-config.test.ts @@ -5,7 +5,7 @@ import type { } from '@internal/framework-components/control'; import { ok } from '@internal/utils/result'; import { describe, expect, it } from 'vitest'; -import { CONFIG_RESOLVE, isUnresolvedConfig } from '../src/config-resolve'; +import { withBaseDir } from '../src/config-base-dir'; import { defineConfig, type PrismaNextConfig } from '../src/config-types'; const mockHook = { @@ -80,13 +80,10 @@ function createValidConfig(overrides: Record = {}): PrismaNextC } describe('defineConfig', () => { - it('returns the config unchanged apart from the resolver when contract is absent', () => { + it('returns the same object when contract is absent', () => { const config = createValidConfig(); - const result = defineConfig(config); - - expect(result).toMatchObject(config); - expect(result.contract).toBeUndefined(); + expect(defineConfig(config)).toBe(config); }); it('applies default output path when contract output is missing', () => { @@ -123,38 +120,30 @@ describe('defineConfig', () => { }); describe('defineConfig path resolution', () => { - it('attaches a resolver and leaves relative paths as written', () => { - const config = defineConfig( - createValidConfig({ - contract: { - source: createSourceProvider({ inputs: ['./schema.prisma'] }), - output: './out/contract.json', - }, - migrations: { dir: './db' }, - }), - ); - - expect(isUnresolvedConfig(config)).toBe(true); - expect(config.contract?.source.inputs).toEqual(['./schema.prisma']); - expect(config.migrations?.dir).toBe('./db'); - }); + const authored = () => + createValidConfig({ + contract: { + source: createSourceProvider({ inputs: ['./schema.prisma'] }), + output: './out/contract.json', + }, + migrations: { dir: './db' }, + }); - it('resolves every path against the directory the resolver is given', () => { - const config = defineConfig( - createValidConfig({ - contract: { - source: createSourceProvider({ inputs: ['./schema.prisma'] }), - output: './out/contract.json', - }, - migrations: { dir: './db' }, - }), - ); - if (!isUnresolvedConfig(config)) throw new Error('expected a resolver'); - - expect(config[CONFIG_RESOLVE]('/app')).toMatchObject({ - rootDir: '/app', + it('resolves every relative path against the base directory the loader published', async () => { + const config = await withBaseDir('/app', async () => defineConfig(authored())); + + expect(config).toMatchObject({ + baseDir: '/app', contract: { source: { inputs: ['/app/schema.prisma'] }, output: '/app/out/contract.json' }, migrations: { dir: '/app/db' }, }); }); + + it('evaluated outside a loader, leaves the paths as written and records no baseDir', () => { + const config = defineConfig(authored()); + + expect(config.baseDir).toBeUndefined(); + expect(config.contract?.source.inputs).toEqual(['./schema.prisma']); + expect(config.migrations?.dir).toBe('./db'); + }); }); diff --git a/packages/1-framework/1-core/config/tsdown.config.ts b/packages/1-framework/1-core/config/tsdown.config.ts index 9448619fbfb3..909febea9646 100644 --- a/packages/1-framework/1-core/config/tsdown.config.ts +++ b/packages/1-framework/1-core/config/tsdown.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from '@repo/tsdown'; export default defineConfig({ entry: [ + 'src/exports/config-base-dir.ts', 'src/exports/config-resolve.ts', 'src/exports/config-types.ts', 'src/exports/config-validation.ts', diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts index 256725e44baa..7485c5adbfa5 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts @@ -40,7 +40,7 @@ export interface MigrationNewOptions { readonly config: PrismaNextConfig; /** Directory the command was invoked from. */ readonly cwd: string; - /** The project's directory, normally the resolved config's `rootDir`; locates the project manifest. */ + /** The project's directory, normally the resolved config's `baseDir`; locates the project manifest. */ readonly projectDir?: string; readonly name?: string; readonly from?: string; diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts index d51c84b7ea87..890ac3031630 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts @@ -63,7 +63,7 @@ export interface MigrationPlanOptions { readonly config: PrismaNextConfig; /** Directory the command was invoked from. */ readonly cwd: string; - /** The project's directory, normally the resolved config's `rootDir`; locates the project manifest. */ + /** The project's directory, normally the resolved config's `baseDir`; locates the project manifest. */ readonly projectDir?: string; readonly name?: string; readonly from?: string; diff --git a/packages/1-framework/3-tooling/cli/src/control-api/types.ts b/packages/1-framework/3-tooling/cli/src/control-api/types.ts index 099d8d0a05e4..10f925e12a75 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/types.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/types.ts @@ -788,7 +788,7 @@ export interface ContractEmitOptions { /** Directory the caller was invoked from. */ readonly cwd: string; /** - * The project's directory, normally the resolved config's `rootDir`. Used to + * The project's directory, normally the resolved config's `baseDir`. Used to * find the project manifest whose dependencies decide the import specifiers * in emitted files. Omit it and the manifest is looked up from the directory * the artifacts are written to, which is the package that will import them. diff --git a/packages/1-framework/3-tooling/cli/src/orm/config-section.ts b/packages/1-framework/3-tooling/cli/src/orm/config-section.ts index 557c506ed9fc..7eb1ad011a98 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/config-section.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/config-section.ts @@ -1,4 +1,3 @@ -import { isUnresolvedConfig } from '@internal/config/config-resolve'; import type { PrismaNextConfig } from '@internal/config/config-types'; import type { ConfigValidationIssue } from '@internal/config/config-validation'; import { collectConfigIssues } from '@internal/config/config-validation'; @@ -75,37 +74,27 @@ function unreadableDiagnostic(error: unknown): Diagnostic { } /** - * The section still carries the resolver a loader calls with the directory of - * the file that wrote it (ADR 253). The engine that loaded this config did not - * call it, so its paths are still as written and cannot be trusted. + * `defineConfig` records the directory of the file it runs in when a loader + * publishes it (ADR 253). A section without one was written as a plain object, + * or evaluated by a loader that predates the base directory, and its relative + * paths have no anchor. */ -function unresolvedDiagnostic(): Diagnostic { +function noBaseDirDiagnostic(): Diagnostic { return { code: 'CONFIG.VALIDATION_FAILED', severity: 'error', - summary: 'Prisma ORM configuration was loaded without resolving its paths', - why: 'The CLI that loaded prisma.config.ts predates path resolution for config sections, so relative paths in the orm section have no anchor.', + summary: 'Prisma ORM configuration does not record the directory it was written in', + why: `The ${ORM_CONFIG_SECTION_NAME} section carries no baseDir, so relative paths in it have no anchor. defineConfig from your database facade package records it when a current prisma CLI loads the file.`, nextActions: [ + { kind: 'edit-file', label: 'Build the orm section with defineConfig in prisma.config.ts' }, { kind: 'user-choice', - label: 'Update the prisma CLI to a version that resolves config sections.', + label: 'Update the prisma CLI if the section already uses defineConfig.', }, ], }; } -function noRootDirDiagnostic(): Diagnostic { - return { - code: 'CONFIG.VALIDATION_FAILED', - severity: 'error', - summary: 'Prisma ORM configuration does not record the directory it was written in', - why: `The ${ORM_CONFIG_SECTION_NAME} section carries no rootDir, so relative paths in it have no anchor. A section built with defineConfig from your database facade package records it when the config is loaded.`, - nextActions: [ - { kind: 'edit-file', label: 'Build the orm section with defineConfig in prisma.config.ts' }, - ], - }; -} - function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -168,10 +157,6 @@ function validate(raw: unknown): SectionValidation { if (!isRecord(raw)) { return { ok: false as const, diagnostics: [notAnObjectDiagnostic()] }; } - if (isUnresolvedConfig(raw)) { - return { ok: false as const, diagnostics: [unresolvedDiagnostic()] }; - } - let issues: readonly ConfigValidationIssue[]; try { issues = [...collectConfigIssues(raw), ...collectArtifactCollisionIssues(raw)]; @@ -182,8 +167,8 @@ function validate(raw: unknown): SectionValidation { if (issues.length > 0) { return { ok: false as const, diagnostics: issues.map(issueDiagnostic) }; } - if (typeof raw['rootDir'] !== 'string') { - return { ok: false as const, diagnostics: [noRootDirDiagnostic()] }; + if (typeof raw['baseDir'] !== 'string') { + return { ok: false as const, diagnostics: [noBaseDirDiagnostic()] }; } return { diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/init.ts b/packages/1-framework/3-tooling/cli/src/orm/db/init.ts index 95962ac997b7..8cc0ea09a66e 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/init.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/init.ts @@ -23,7 +23,7 @@ import type { MigrationCommandResult } from '../../utils/formatters/migrations'; import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { dbFlag } from '../flags'; -import { projectRootFor } from '../migration/paths'; +import { baseDirFor } from '../migration/paths'; import { normalizeError } from '../normalize-error'; import { controlProgressReporter } from '../progress'; import { migrationResultBlocks, migrationResultNextActions } from './migration-blocks'; @@ -156,7 +156,7 @@ export function createDbInitCommand(createClient: CreateControlClient) { name: refName, contractJson, contractJsonPath: contractPath, - projectDir: projectRootFor(ctx.config), + projectDir: baseDirFor(ctx.config), client, }); if (!preflight.ok) { diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts b/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts index 3581075c9613..c3123da440d4 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts @@ -22,7 +22,7 @@ import { runCommandAction } from '../../utils/next-actions'; import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { dbFlag } from '../flags'; -import { appRefsDirFor, displayPath, migrationsDirFor, projectRootFor } from '../migration/paths'; +import { appRefsDirFor, baseDirFor, displayPath, migrationsDirFor } from '../migration/paths'; import { normalizeError } from '../normalize-error'; import { controlProgressReporter } from '../progress'; import { @@ -302,7 +302,7 @@ export function createDbSignCommand( name: refName, contractJson: signedSource.json, contractJsonPath: signedSource.jsonPath, - projectDir: projectRootFor(ctx.config), + projectDir: baseDirFor(ctx.config), client, }); if (!preflight.ok) { diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/update.ts b/packages/1-framework/3-tooling/cli/src/orm/db/update.ts index 87bce1c2eb28..4111c4bc7a47 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/update.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/update.ts @@ -28,7 +28,7 @@ import type { MigrationCommandResult } from '../../utils/formatters/migrations'; import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { dbFlag } from '../flags'; -import { projectRootFor } from '../migration/paths'; +import { baseDirFor } from '../migration/paths'; import { normalizeError } from '../normalize-error'; import { controlProgressReporter } from '../progress'; import { @@ -191,7 +191,7 @@ export function createDbUpdateCommand(createClient: CreateControlClient) { name: refName, contractJson, contractJsonPath: snapshotContractPath, - projectDir: projectRootFor(ctx.config), + projectDir: baseDirFor(ctx.config), client, }); if (!preflight.ok) { diff --git a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts index a38eea5b1035..ee6d8c4d2007 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts @@ -51,7 +51,7 @@ import { perSpaceBlocks } from './db/migration-blocks'; import { prepareMigrationRun } from './db/prepare'; import { defineOrmCommand } from './define-command'; import { dbFlag } from './flags'; -import { displayPath, migrationsDirFor, projectRootFor } from './migration/paths'; +import { baseDirFor, displayPath, migrationsDirFor } from './migration/paths'; import { normalizeError } from './normalize-error'; import { controlProgressReporter } from './progress'; @@ -423,7 +423,7 @@ export function createMigrateCommand(createClient: CreateControlClient) { name: args.flags.advanceRef, contractJson: snapshotContractJson, contractJsonPath: snapshotContractPath, - projectDir: projectRootFor(ctx.config), + projectDir: baseDirFor(ctx.config), client, }); if (!preflight.ok) { diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/new.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/new.ts index 33920715db20..8a1917a65b6b 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/new.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/new.ts @@ -12,7 +12,7 @@ import { runCommandAction } from '../../utils/next-actions'; import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { normalizeError } from '../normalize-error'; -import { appMigrationsDirFor, contractPathFor, displayPath, projectRootFor } from './paths'; +import { appMigrationsDirFor, baseDirFor, contractPathFor, displayPath } from './paths'; function newPresentations(inputs: { readonly document: MigrationNewResult; @@ -90,7 +90,7 @@ export function createMigrationNewCommand(createClient: CreateControlClient) { const scaffolded = await executeMigrationNewCommand({ config: ctx.config, cwd: ctx.cwd, - projectDir: projectRootFor(ctx.config), + projectDir: baseDirFor(ctx.config), ...ifDefined('name', args.flags.name), ...ifDefined('from', args.flags.from), client: createClient({ diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/paths.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/paths.ts index b07263b14a46..ecca63403a99 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/paths.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/paths.ts @@ -8,16 +8,16 @@ import { relative, resolve } from 'pathe'; * The directory of the config file that wrote the section. The section * validator refuses a config without it, so its absence here is a bug. */ -export function projectRootFor(config: PrismaNextConfig): string { - if (config.rootDir === undefined) { - throw new InternalError('the orm config section reached a command without rootDir'); +export function baseDirFor(config: PrismaNextConfig): string { + if (config.baseDir === undefined) { + throw new InternalError('the orm config section reached a command without baseDir'); } - return config.rootDir; + return config.baseDir; } /** Where migrations live for this project; the resolved config carries it absolute. */ export function migrationsDirFor(config: PrismaNextConfig): string { - return config.migrations?.dir ?? resolve(projectRootFor(config), 'migrations'); + return config.migrations?.dir ?? resolve(baseDirFor(config), 'migrations'); } /** The app subspace under {@link migrationsDirFor}. */ diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts index 09c482626a43..36d8711e5a58 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts @@ -16,10 +16,10 @@ import { defineOrmCommand } from '../define-command'; import { normalizeError } from '../normalize-error'; import { appMigrationsDirFor, + baseDirFor, contractPathFor, displayPath, migrationsDirFor, - projectRootFor, } from './paths'; function hashRow(label: string, hash: string | null): { label: string; value: Text } { @@ -256,7 +256,7 @@ export function createMigrationPlanCommand(createClient: CreateControlClient) { { config: ctx.config, cwd: ctx.cwd, - projectDir: projectRootFor(ctx.config), + projectDir: baseDirFor(ctx.config), ...ifDefined('name', args.flags.name), ...ifDefined('from', args.flags.from), ...ifDefined('to', args.flags.to), diff --git a/packages/1-framework/3-tooling/cli/src/utils/command-helpers.ts b/packages/1-framework/3-tooling/cli/src/utils/command-helpers.ts index 41d7e5cef59b..e21a93781fd1 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/command-helpers.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/command-helpers.ts @@ -43,7 +43,7 @@ export function resolveContractPath(config: { contract?: { output?: string } }): * the result is computed against it. */ export function resolveMigrationPaths( - config: { rootDir?: string; migrations?: { dir?: string } }, + config: { baseDir?: string; migrations?: { dir?: string } }, cwd: string, ): { configPath: string; @@ -55,8 +55,8 @@ export function resolveMigrationPaths( } { const configPath = 'prisma.config.ts'; // A resolved config carries the directory absolute; a config handed in raw - // by a programmatic caller is anchored on its rootDir, else on cwd. - const migrationsDir = resolve(config.rootDir ?? cwd, config.migrations?.dir ?? 'migrations'); + // by a programmatic caller is anchored on its baseDir, else on cwd. + const migrationsDir = resolve(config.baseDir ?? cwd, config.migrations?.dir ?? 'migrations'); const migrationsRelative = relative(cwd, migrationsDir); const appMigrationsDir = spaceMigrationDirectory(migrationsDir, APP_SPACE_ID); const appMigrationsRelative = relative(cwd, appMigrationsDir); diff --git a/packages/1-framework/3-tooling/cli/test/helpers/orm-test-cli.ts b/packages/1-framework/3-tooling/cli/test/helpers/orm-test-cli.ts index c61fb7d6ce9a..ecfffd5ffd93 100644 --- a/packages/1-framework/3-tooling/cli/test/helpers/orm-test-cli.ts +++ b/packages/1-framework/3-tooling/cli/test/helpers/orm-test-cli.ts @@ -9,14 +9,14 @@ type RunArgs = Parameters; /** * A test CLI seeded with an `orm` section as the engine's loader hands it * over for a config discovered in the run's working directory: resolved - * against that directory, with `rootDir` recorded (ADR 253). + * against that directory, with `baseDir` recorded (ADR 253). */ export function createOrmTestCli(spec: Omit & { readonly orm: unknown }) { const { orm, ...rest } = spec; return { run: (argv: RunArgs[0], opts?: RunArgs[1]): ReturnType => { - const rootDir = opts?.cwd ?? process.cwd(); - const section = resolveConfigPaths(orm as PrismaNextConfig, rootDir); + const baseDir = opts?.cwd ?? process.cwd(); + const section = resolveConfigPaths(orm as PrismaNextConfig, baseDir); return createTestCli({ ...rest, config: { orm: section } }).run(argv, opts); }, }; diff --git a/packages/1-framework/3-tooling/cli/test/orm/config-section.test.ts b/packages/1-framework/3-tooling/cli/test/orm/config-section.test.ts index 835954606cb8..962c517bfd7c 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/config-section.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/config-section.test.ts @@ -1,4 +1,3 @@ -import { CONFIG_RESOLVE } from '@internal/config/config-resolve'; import { describe, expect, it } from 'vitest'; import { ormConfigSection } from '../../src/orm/config-section'; @@ -26,7 +25,7 @@ function validDescriptor(kind: string) { function validConfig() { return { - rootDir: '/project', + baseDir: '/project', family: validFamily(), target: { ...validDescriptor('target'), targetId: 'postgres' }, adapter: validDescriptor('adapter'), @@ -58,23 +57,9 @@ describe('ormConfigSection', () => { }); }); - describe('a section whose paths were never resolved', () => { - it('refuses a section still carrying its resolver and asks for a newer CLI', () => { - const raw = { ...validConfig(), [CONFIG_RESOLVE]: () => validConfig() }; - const result = ormConfigSection.validate(raw); - - expect(result.ok).toBe(false); - expect(result.diagnostics).toMatchObject([ - { - code: 'CONFIG.VALIDATION_FAILED', - summary: 'Prisma ORM configuration was loaded without resolving its paths', - nextActions: [{ kind: 'user-choice' }], - }, - ]); - }); - - it('refuses a section that records no rootDir', () => { - const { rootDir: _rootDir, ...raw } = validConfig(); + describe('a section that records no base directory', () => { + it('refuses a section that records no baseDir', () => { + const { baseDir: _baseDir, ...raw } = validConfig(); const result = ormConfigSection.validate(raw); expect(result.ok).toBe(false); @@ -86,8 +71,8 @@ describe('ormConfigSection', () => { ]); }); - it('reports structural problems before the missing rootDir', () => { - const { rootDir: _rootDir, ...raw } = { ...validConfig(), migrations: { dir: 42 } }; + it('reports structural problems before the missing baseDir', () => { + const { baseDir: _baseDir, ...raw } = { ...validConfig(), migrations: { dir: 42 } }; const result = ormConfigSection.validate(raw); expect(result.diagnostics.map((diagnostic) => diagnostic.summary)).toEqual([ diff --git a/packages/1-framework/3-tooling/cli/test/utils/command-helpers.test.ts b/packages/1-framework/3-tooling/cli/test/utils/command-helpers.test.ts index 25d380cdeefe..4cef0a2fd74a 100644 --- a/packages/1-framework/3-tooling/cli/test/utils/command-helpers.test.ts +++ b/packages/1-framework/3-tooling/cli/test/utils/command-helpers.test.ts @@ -111,7 +111,7 @@ describe('resolveMigrationPaths', () => { describe('a resolved config', () => { it('uses the absolute migrations dir the config carries, whatever cwd is', () => { const paths = resolveMigrationPaths( - { rootDir: '/work/app', migrations: { dir: '/work/app/db' } }, + { baseDir: '/work/app', migrations: { dir: '/work/app/db' } }, '/work/scratch', ); @@ -122,15 +122,15 @@ describe('resolveMigrationPaths', () => { }); }); - it('defaults the migrations dir under rootDir when the config names none', () => { - const paths = resolveMigrationPaths({ rootDir: '/work/app' }, '/tmp'); + it('defaults the migrations dir under baseDir when the config names none', () => { + const paths = resolveMigrationPaths({ baseDir: '/work/app' }, '/tmp'); expect(paths.migrationsDir).toBe('/work/app/migrations'); }); }); describe('a raw config from a programmatic caller', () => { - it('anchors a relative migrations dir on cwd when the config carries no rootDir', () => { + it('anchors a relative migrations dir on cwd when the config carries no baseDir', () => { const paths = resolveMigrationPaths({ migrations: { dir: 'db' } }, '/work/app'); expect(paths.migrationsDir).toBe('/work/app/db'); diff --git a/packages/1-framework/3-tooling/config-loader/README.md b/packages/1-framework/3-tooling/config-loader/README.md index 90aa5147eb92..138a42053bb8 100644 --- a/packages/1-framework/3-tooling/config-loader/README.md +++ b/packages/1-framework/3-tooling/config-loader/README.md @@ -7,8 +7,9 @@ Discovers, validates, and finalizes `prisma.config.ts`. ## Overview This package owns config _loading_ — the file I/O (`c12`), validation, and path resolution -that turns a `prisma.config.ts` on disk into a resolved `PrismaNextConfig`. Every relative path -in the file is resolved against the file that wrote it, layer by layer before layers merge; see +that turns a `prisma.config.ts` on disk into a resolved `PrismaNextConfig`. While the file is +evaluated the loader publishes the file's directory as the base every relative path in it resolves +against; see [ADR 253](../../../../docs/architecture%20docs/adrs/ADR%20253%20-%20Config%20paths%20resolve%20against%20the%20file%20that%20wrote%20them.md). It also performs the emitter-derived artifact-collision check (`getEmittedArtifactPaths`). diff --git a/packages/1-framework/3-tooling/config-loader/package.json b/packages/1-framework/3-tooling/config-loader/package.json index 7aa4f412c50d..4bbcebee6e97 100644 --- a/packages/1-framework/3-tooling/config-loader/package.json +++ b/packages/1-framework/3-tooling/config-loader/package.json @@ -21,7 +21,6 @@ "@internal/errors": "workspace:8.0.0-rc.11", "@internal/utils": "workspace:8.0.0-rc.11", "c12": "^3.3.4", - "defu": "^6.1.7", "pathe": "^2.0.3" }, "devDependencies": { diff --git a/packages/1-framework/3-tooling/config-loader/src/load.ts b/packages/1-framework/3-tooling/config-loader/src/load.ts index b3299dc406f8..67f66bfaf91c 100644 --- a/packages/1-framework/3-tooling/config-loader/src/load.ts +++ b/packages/1-framework/3-tooling/config-loader/src/load.ts @@ -2,6 +2,7 @@ import { realpathSync } from 'node:fs'; import { access } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; +import { withBaseDir } from '@internal/config/config-base-dir'; import { resolveConfigPaths, withConfigDefaults } from '@internal/config/config-resolve'; import type { PrismaNextConfig } from '@internal/config/config-types'; import type { ConfigSection } from '@internal/config/config-validation'; @@ -18,7 +19,6 @@ import { blindCast } from '@internal/utils/casts'; import { ifDefined } from '@internal/utils/defined'; import { notOk, ok, type Result } from '@internal/utils/result'; import { isStructuredError } from '@internal/utils/structured-error'; -import { defu } from 'defu'; import { dirname, join, resolve } from 'pathe'; const CONFIG_FILENAME = 'prisma.config.ts'; @@ -100,40 +100,6 @@ function collectArtifactCollisionDiagnostics( return []; } -interface ConfigLayer { - readonly config?: unknown; - /** Absolute, or relative to the requested config's directory for an extended layer. */ - readonly configFile?: string; -} - -/** - * Resolves each file's `orm` section against that file, then merges the - * layers nearest-first. Merging after resolution is what keeps a relative - * path relative to the file that wrote it (ADR 253). - */ -function resolveOrmLayers( - layers: readonly ConfigLayer[], - merged: unknown, - rootCwd: string, -): unknown { - const resolved = layers.flatMap((layer) => { - const orm = isRecord(layer.config) ? layer.config['orm'] : undefined; - if (!isRecord(orm) || typeof layer.configFile !== 'string') { - return []; - } - const configFile = resolve(rootCwd, layer.configFile); - const section = blindCast< - PrismaNextConfig, - 'resolution touches only contract and migrations paths and leaves any other value for collectConfigIssues' - >(orm); - return [resolveConfigPaths(section, dirname(configFile))]; - }); - if (resolved.length === 0) { - return merged; - } - return resolved.reduce((nearest, base) => defu(nearest, base)); -} - function buildLoadedConfig(rawConfig: Record, configDir: string): LoadedConfig { const issues = collectConfigIssues(rawConfig); const diagnostics = issues.map((issue) => @@ -145,9 +111,12 @@ function buildLoadedConfig(rawConfig: Record, configDir: string 'Structure was checked by collectConfigIssues; sections carrying diagnostics are guarded by requireConfigSections' >(rawConfig); + // A section built with defineConfig while the loader published the base + // directory arrives resolved; one written as a plain object is resolved here + // against the same directory. Resolution is idempotent, so both are one call. const config = issues.some((issue) => issue.section === 'migrations') ? raw - : withConfigDefaults({ ...raw, rootDir: raw.rootDir ?? configDir }); + : withConfigDefaults(resolveConfigPaths(raw, raw.baseDir ?? configDir)); if (config.contract === undefined || issues.some((issue) => issue.section === 'contract')) { return { config, diagnostics }; @@ -217,11 +186,15 @@ export async function loadConfig( let result: Awaited>>>; try { const c12 = await importC12(); - result = await c12.loadConfig>({ - name: 'prisma', - ...ifDefined('configFile', resolvedConfigPath), - cwd: configCwd, - }); + // The file's config helpers read the base directory while the file runs, + // so relative paths inside it resolve against the file (ADR 253). + result = await withBaseDir(configCwd, () => + c12.loadConfig>({ + name: 'prisma', + ...ifDefined('configFile', resolvedConfigPath), + cwd: configCwd, + }), + ); } catch (error) { return notOk(toConfigLoadFailure(error, configPath)); } @@ -267,8 +240,7 @@ export async function loadConfig( ], }); } - const resolvedOrm = resolveOrmLayers(result.layers ?? [], orm, configCwd); - return ok(buildLoadedConfig(isRecord(resolvedOrm) ? resolvedOrm : {}, loadedConfigDir)); + return ok(buildLoadedConfig(orm ?? {}, loadedConfigDir)); } /* v8 ignore next -- a config that evaluated always carries its resolved path */ diff --git a/packages/1-framework/3-tooling/config-loader/test/load.test.ts b/packages/1-framework/3-tooling/config-loader/test/load.test.ts index 357315cadec1..b3ae01e2298a 100644 --- a/packages/1-framework/3-tooling/config-loader/test/load.test.ts +++ b/packages/1-framework/3-tooling/config-loader/test/load.test.ts @@ -456,44 +456,51 @@ describe('loadConfig', () => { ); it( - 'resolves each layer against its own file before merging', + 'publishes the base directory to the file while it is evaluated', async () => { - const baseDir = join(tempDir, 'base'); - mkdirSync(baseDir); - writeFileSync( - join(baseDir, 'base.config.ts'), - `${CONFIG_BODY}\nexport default { orm: { ...config, migrations: { dir: './db' } } };\n`, - ); - writeFileSync( - join(tempDir, 'prisma.config.ts'), - "export default { $prismaConfig: 1, extends: './base/base.config.ts', orm: { contract: { output: './out/contract.json' } } };\n", - ); + const readsBaseDir = `${CONFIG_BODY} +const baseDir = globalThis[Symbol.for('prisma.config.baseDir')]; +export default { $prismaConfig: 1, orm: { ...config, migrations: { dir: baseDir + '/from-file' } } }; +`; + writeFileSync(join(tempDir, 'prisma.config.ts'), readsBaseDir); process.chdir(tempDir); - const { config, diagnostics } = (await loadConfig()).assertOk(); + const { config } = (await loadConfig()).assertOk(); - expect(diagnostics).toEqual([]); - expect(config).toMatchObject({ - rootDir: tempDir, - contract: { - source: { inputs: [join(baseDir, 'schema.prisma')] }, - output: join(tempDir, 'out', 'contract.json'), - }, - migrations: { dir: join(baseDir, 'db') }, - }); + expect(config.migrations?.dir).toBe(join(tempDir, 'from-file')); + }, + timeouts.typeScriptCompilation, + ); + + it( + 'publishes the directory of a --config file elsewhere, not the working directory', + async () => { + const elsewhere = join(tempDir, 'elsewhere'); + mkdirSync(elsewhere); + const readsBaseDir = `${CONFIG_BODY} +const baseDir = globalThis[Symbol.for('prisma.config.baseDir')]; +export default { $prismaConfig: 1, orm: { ...config, migrations: { dir: baseDir + '/from-file' } } }; +`; + writeFileSync(join(elsewhere, 'custom.config.ts'), readsBaseDir); + process.chdir(tempDir); + + const { config } = (await loadConfig(join('elsewhere', 'custom.config.ts'))).assertOk(); + + expect(config.migrations?.dir).toBe(join(elsewhere, 'from-file')); + expect(config.baseDir).toBe(elsewhere); }, timeouts.typeScriptCompilation, ); it( - 'records the config file directory as rootDir', + 'records the config file directory as baseDir', async () => { writeFileSync(join(tempDir, 'prisma.config.ts'), VALID_CONFIG_SOURCE); process.chdir(tempDir); const { config } = (await loadConfig()).assertOk(); - expect(config.rootDir).toBe(tempDir); + expect(config.baseDir).toBe(tempDir); expect(config.migrations?.dir).toBe(join(tempDir, 'migrations')); }, timeouts.typeScriptCompilation, diff --git a/packages/9-public/@prisma/orm-framework/package.json b/packages/9-public/@prisma/orm-framework/package.json index 648d85d5eee9..ca953e33beaf 100644 --- a/packages/9-public/@prisma/orm-framework/package.json +++ b/packages/9-public/@prisma/orm-framework/package.json @@ -60,6 +60,7 @@ "./components/runtime": "./dist/components__runtime.mjs", "./components/utils": "./dist/components__utils.mjs", "./config": "./dist/config.mjs", + "./config/config-base-dir": "./dist/config__config-base-dir.mjs", "./config/config-resolve": "./dist/config__config-resolve.mjs", "./config/config-types": "./dist/config__config-types.mjs", "./config/config-validation": "./dist/config__config-validation.mjs", diff --git a/packages/9-public/@prisma/orm-toolchain/package.json b/packages/9-public/@prisma/orm-toolchain/package.json index d71b71656c76..cd96195253f1 100644 --- a/packages/9-public/@prisma/orm-toolchain/package.json +++ b/packages/9-public/@prisma/orm-toolchain/package.json @@ -22,7 +22,6 @@ "clipanion": "4.0.0-rc.4", "closest-match": "^1.3.3", "colorette": "^2.0.20", - "defu": "^6.1.7", "esbuild": "^0.28.2", "jsonc-parser": "^3.3.1", "package-manager-detector": "^1.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0772759240df..ccf1372bee1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1524,9 +1524,6 @@ importers: c12: specifier: ^3.3.4 version: 3.3.4(magicast@0.5.4) - defu: - specifier: ^6.1.7 - version: 6.1.7 pathe: specifier: ^2.0.3 version: 2.0.3 @@ -5229,9 +5226,6 @@ importers: colorette: specifier: ^2.0.20 version: 2.0.20 - defu: - specifier: ^6.1.7 - version: 6.1.7 esbuild: specifier: ^0.28.2 version: 0.28.2 From f55917b3429bfe4d83275bbb7ca94a438df633ec Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 21 Sep 2026 16:11:26 +0200 Subject: [PATCH 4/5] fix(config): scope the base directory to its evaluation and refuse unresolved paths Review findings on the base-directory design: - The published directory was a plain global, so two config loads that overlap in one process (the language server loading several projects) could read each other's directory. It is now an AsyncLocalStorage kept under the same Symbol.for key, so each evaluation sees its own. - A baseDir written by hand into a plain-object section was trusted: a non-string crashed resolution and a relative value silently anchored every path on the wrong directory. collectConfigIssues now refuses a baseDir that is not an absolute path and any contract input, contract output or migrations dir that is still relative, so the loader and the CLI validator enforce one rule; the loader anchors on configDir when the authored baseDir is unusable and lets validation report it. - ADR 253 records the release order the validator imposes: the engine that publishes the base directory ships before the toolchain release that requires it. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- ...esolve against the file that wrote them.md | 13 ++-- .../1-core/config/src/config-base-dir.ts | 39 ++++++------ .../1-core/config/src/config-validation.ts | 38 ++++++++++++ .../config/test/config-base-dir.test.ts | 35 +++++++++-- .../config/test/config-validation.test.ts | 61 ++++++++++++++++++- .../cli/test/orm/config-section.test.ts | 34 +++++++++-- .../3-tooling/config-loader/src/load.ts | 29 +++++---- .../3-tooling/config-loader/test/load.test.ts | 4 +- 8 files changed, 203 insertions(+), 50 deletions(-) diff --git a/docs/architecture docs/adrs/ADR 253 - Config paths resolve against the file that wrote them.md b/docs/architecture docs/adrs/ADR 253 - Config paths resolve against the file that wrote them.md index db13f7c14db8..edff3743a9ec 100644 --- a/docs/architecture docs/adrs/ADR 253 - Config paths resolve against the file that wrote them.md +++ b/docs/architecture docs/adrs/ADR 253 - Config paths resolve against the file that wrote them.md @@ -45,7 +45,7 @@ exp/ ## How the file learns where it is -The loader is the one party that knows which file it is evaluating. Before it evaluates the file it publishes that file's directory in a slot on `globalThis` under `Symbol.for('prisma.config.baseDir')`, and clears it after. While the file runs, `ormConfig` reads the slot and resolves every path in its section against it, recording it as `baseDir`. +The loader is the one party that knows which file it is evaluating. It evaluates the file inside an `AsyncLocalStorage` run that carries the file's directory, so everything the evaluation awaits sees that directory and nothing else does. The store itself lives on `globalThis` under `Symbol.for('prisma.config.baseDir')`. While the file runs, `ormConfig` reads the store and resolves every path in its section against it, recording it as `baseDir`. ```mermaid sequenceDiagram @@ -67,11 +67,11 @@ Three properties follow. - **The user writes nothing.** The file already runs inside a loader; the loader already knows the file. Asking the config author to pass `import.meta` would ask them for a value the system already has. - **Merging sees absolute paths.** Each file resolves its own paths while it runs, so whatever merges files later never sees a relative path and never needs to know which file wrote which value. -The slot is `Symbol.for`, so every loader and every helper in a dependency tree shares it, and a family package declares it without importing the engine. +The store is published under a `Symbol.for` key, so every loader and every helper in a dependency tree shares one store, and a family package reaches it without importing the engine. ## A section without a base directory is refused -A file evaluated outside a loader, for instance a test that imports it directly, finds the slot empty. `ormConfig` then leaves the paths as written and records no `baseDir`. The ORM's section validator refuses any section without `baseDir`, naming the two ways that happens: the section was written as a plain object rather than with `defineConfig`, or the loader that evaluated the file predates the base directory. There is no fallback to the working directory: a path silently resolved against the wrong directory is exactly the failure this decision removes, so the only acceptable failure is a loud one. +A file evaluated outside a loader, for instance a test that imports it directly, finds no base directory. `ormConfig` then leaves the paths as written and records no `baseDir`. The ORM's section validator refuses any section without `baseDir`, naming the two ways that happens: the section was written as a plain object rather than with `defineConfig`, or the loader that evaluated the file predates the base directory. The same validation also refuses a `baseDir` that is not an absolute path, and any path field that is still relative, so a `baseDir` written by hand into a plain object cannot smuggle relative paths past the check. There is no fallback to the working directory: a path silently resolved against the wrong directory is exactly the failure this decision removes, so the only acceptable failure is a loud one. ## Responsibilities @@ -81,15 +81,15 @@ A file evaluated outside a loader, for instance a test that imports it directly, **Commands** read absolute paths and `baseDir` from the config. A command that needs the project's location, for instance to find the project's `package.json`, starts from `baseDir`. No command reconstructs the config file's path, and no command resolves a config value against the working directory. The one kind of path that is relative to the working directory is a path typed on the command line, such as `--output-path` on `contract emit`, because the shell is where the user wrote it. -**The validator** refuses a section without `baseDir`. +**The validator** refuses a section without an absolute `baseDir` or with any path still relative. The rule lives in the shared `collectConfigIssues`, so the ORM's loader and the CLI's section validator enforce the same thing. ## Layered configs -Layering holds as long as the loader publishes each file's own directory while that file runs. A loader that evaluates the files itself, one at a time, gets this for free. A loader that delegates the whole chain to c12's `extends` does not: c12 evaluates a base file inside the same call as the file that extends it, so the slot still names the extending file. Whichever mechanism layering adopts, it evaluates each file with its own base directory published; that is the constraint this decision places on it. +Layering holds as long as the loader publishes each file's own directory while that file runs. The engine's loader discovers the chain of config files itself and evaluates them one at a time, publishing each file's directory around its evaluation, so a parent's `./migrations` resolves under the parent and a child's under the child before the two are merged. A loader that instead delegates a chain to c12's `extends` does not get this: c12 evaluates a base file inside the same call as the file that extends it, so the published directory still names the extending file. Any loader that layers files must evaluate each with its own base directory published; that is the constraint this decision places on it. ## Concurrency -The slot is process-global, so two config evaluations interleaving in one process would read each other's directory. Nothing loads configs concurrently today, and the loader sets and restores the slot around an awaited evaluation. If concurrent loads ever arrive, the slot moves to an `AsyncLocalStorage` behind the same two functions and nothing else changes. +Config evaluations can overlap in one process: the language server loads one project per config file and a workspace can hold several. With a plain global value, the second evaluation to start would overwrite the directory the first one was about to read, and a file would silently record another project's directory. `AsyncLocalStorage` scopes the directory to the evaluation that published it, so overlapping loads each see their own. The two functions, `withBaseDir` and `baseDir`, are the whole surface; the storage behind them is not. ## Consequences @@ -97,6 +97,7 @@ The slot is process-global, so two config evaluations interleaving in one proces - A config object holds absolute paths and a `baseDir` per section that has paths. Nothing resolves a config path after loading. - Importing a config file directly yields a section without `baseDir`, which the validator refuses; a test that wants a resolved config evaluates it under `withBaseDir`. - The engine's loader publishes the base directory, which is an engine change, so the engine's version moves and every family's exact engine peer moves with it. +- The releases are ordered. A family whose validator requires `baseDir` must not be mounted by a shell whose engine does not yet publish it, or every command of that family fails for every user. The engine ships first; the family's release follows and moves its engine peer in the same release. ## Alternatives considered diff --git a/packages/1-framework/1-core/config/src/config-base-dir.ts b/packages/1-framework/1-core/config/src/config-base-dir.ts index 72012a870858..ca31e1f44b09 100644 --- a/packages/1-framework/1-core/config/src/config-base-dir.ts +++ b/packages/1-framework/1-core/config/src/config-base-dir.ts @@ -1,29 +1,30 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + /** * The directory relative paths in a config file resolve against: the - * directory of the file being evaluated. The loader sets it around the - * evaluation and the config helpers read it while the file runs. `Symbol.for` - * so every loader and helper in a dependency tree shares one slot. + * directory of the file being evaluated. The loader publishes it for the + * duration of the evaluation and the config helpers read it while the file + * runs. The store lives on `globalThis` under a `Symbol.for` key so every + * loader and helper in a dependency tree shares one, and a loader can publish + * without importing this package. It is an AsyncLocalStorage rather than a + * plain value so evaluations that overlap in time, such as a language server + * loading several projects at once, each see their own directory. */ export const BASE_DIR_KEY: unique symbol = Symbol.for('prisma.config.baseDir'); -type BaseDirSlot = { [BASE_DIR_KEY]?: string }; +type BaseDirSlot = { [BASE_DIR_KEY]?: AsyncLocalStorage }; + +function store(): AsyncLocalStorage { + const slot = globalThis as BaseDirSlot; + slot[BASE_DIR_KEY] ??= new AsyncLocalStorage(); + return slot[BASE_DIR_KEY]; +} export function baseDir(): string | undefined { - return (globalThis as BaseDirSlot)[BASE_DIR_KEY]; + return store().getStore(); } -/** Runs `evaluate` with `dir` as the base directory, restoring the previous value after. */ -export async function withBaseDir(dir: string, evaluate: () => Promise): Promise { - const slot = globalThis as BaseDirSlot; - const previous = slot[BASE_DIR_KEY]; - slot[BASE_DIR_KEY] = dir; - try { - return await evaluate(); - } finally { - if (previous === undefined) { - delete slot[BASE_DIR_KEY]; - } else { - slot[BASE_DIR_KEY] = previous; - } - } +/** Runs `evaluate` with `dir` as the base directory for everything it awaits. */ +export function withBaseDir(dir: string, evaluate: () => Promise): Promise { + return store().run(dir, evaluate); } diff --git a/packages/1-framework/1-core/config/src/config-validation.ts b/packages/1-framework/1-core/config/src/config-validation.ts index 402e545202f4..2e740c731f0a 100644 --- a/packages/1-framework/1-core/config/src/config-validation.ts +++ b/packages/1-framework/1-core/config/src/config-validation.ts @@ -1,3 +1,5 @@ +import { isAbsolute } from 'pathe'; + /** * Top-level config sections. Diagnostics carry the section they concern so * commands can fail on the sections they read and ignore the rest. @@ -226,6 +228,12 @@ function validateContract(config: Record, issues: IssueCollecto 'contract.source.inputs[]', 'Config.contract.source.inputs must contain only strings', ); + } else if (inputs.some((input) => !isAbsolute(input))) { + issues.add( + 'contract', + 'contract.source.inputs[]', + 'Config.contract.source.inputs must be absolute once loaded; build the orm section with defineConfig so paths resolve against the config file', + ); } } @@ -253,6 +261,12 @@ function validateContract(config: Record, issues: IssueCollecto 'contract.output', 'Config.contract.output must be a string when provided', ); + } else if (output !== undefined && !isAbsolute(output)) { + issues.add( + 'contract', + 'contract.output', + 'Config.contract.output must be absolute once loaded; build the orm section with defineConfig so paths resolve against the config file', + ); } } @@ -267,7 +281,30 @@ function validateMigrations(config: Record, issues: IssueCollec const dir = config['migrations']['dir']; if (dir !== undefined && typeof dir !== 'string') { issues.add('migrations', 'migrations.dir', 'Config.migrations.dir must be a string'); + } else if (dir !== undefined && !isAbsolute(dir)) { + issues.add( + 'migrations', + 'migrations.dir', + 'Config.migrations.dir must be absolute once loaded; build the orm section with defineConfig so paths resolve against the config file', + ); + } +} + +/** + * `baseDir` is written by `defineConfig` while a loader evaluates the file + * (ADR 253). A value that is present but not an absolute path was written by + * hand, and every path anchored on it would be wrong. Reported against each + * path-bearing section so a command reading either refuses the config. + */ +function validateBaseDir(config: Record, issues: IssueCollector): void { + const baseDir = config['baseDir']; + if (baseDir === undefined || (typeof baseDir === 'string' && isAbsolute(baseDir))) { + return; } + const message = + 'Config.baseDir must be an absolute path; it is recorded by defineConfig, not written by hand'; + issues.add('contract', 'baseDir', message); + issues.add('migrations', 'baseDir', message); } function validateFormatter(config: Record, issues: IssueCollector): void { @@ -339,6 +376,7 @@ export function collectConfigIssues( validateExtensions(config, familyId, targetId, issues); validateContract(config, issues); validateMigrations(config, issues); + validateBaseDir(config, issues); validateFormatter(config, issues); return issues.issues; diff --git a/packages/1-framework/1-core/config/test/config-base-dir.test.ts b/packages/1-framework/1-core/config/test/config-base-dir.test.ts index b3a0682fefd2..056cf06daff8 100644 --- a/packages/1-framework/1-core/config/test/config-base-dir.test.ts +++ b/packages/1-framework/1-core/config/test/config-base-dir.test.ts @@ -1,3 +1,4 @@ +import type { AsyncLocalStorage } from 'node:async_hooks'; import { describe, expect, it } from 'vitest'; import { BASE_DIR_KEY, baseDir, withBaseDir } from '../src/config-base-dir'; @@ -37,12 +38,36 @@ describe('withBaseDir', () => { expect(baseDir()).toBeUndefined(); }); - it('is the shared slot any loader can set without importing this package', async () => { - const slot = globalThis as { [BASE_DIR_KEY]?: string }; - slot[Symbol.for('prisma.config.baseDir') as typeof BASE_DIR_KEY] = '/elsewhere'; + it('keeps two overlapping evaluations apart', async () => { + let seenA: string | undefined; + let seenB: string | undefined; + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); - expect(baseDir()).toBe('/elsewhere'); + await Promise.all([ + withBaseDir('/a', async () => { + await gate; + seenA = baseDir(); + }), + withBaseDir('/b', async () => { + release(); + seenB = baseDir(); + }), + ]); - delete slot[BASE_DIR_KEY]; + expect({ seenA, seenB }).toEqual({ seenA: '/a', seenB: '/b' }); + }); + + it('is one store any loader can publish through without importing this package', async () => { + const shared = (globalThis as { [BASE_DIR_KEY]?: AsyncLocalStorage })[ + Symbol.for('prisma.config.baseDir') as typeof BASE_DIR_KEY + ]; + if (shared === undefined) throw new Error('store not published'); + + await shared.run('/elsewhere', async () => { + expect(baseDir()).toBe('/elsewhere'); + }); }); }); diff --git a/packages/1-framework/1-core/config/test/config-validation.test.ts b/packages/1-framework/1-core/config/test/config-validation.test.ts index 7792f380dfe0..d06c71ac205e 100644 --- a/packages/1-framework/1-core/config/test/config-validation.test.ts +++ b/packages/1-framework/1-core/config/test/config-validation.test.ts @@ -304,7 +304,7 @@ describe('collectConfigIssues', () => { contract: { source: createSourceProvider({ format: 'made-up-format', - inputs: ['./schema.prisma'], + inputs: ['/project/schema.prisma'], interpret: () => [], }), }, @@ -317,7 +317,14 @@ describe('collectConfigIssues', () => { expectIssue(createValidRawConfig({ migrations: 'invalid' }), 'migrations', 'migrations'); expectIssue(createValidRawConfig({ migrations: { dir: 123 } }), 'migrations.dir', 'migrations'); expect(collectConfigIssues(createValidRawConfig({ migrations: {} }))).toEqual([]); - expect(collectConfigIssues(createValidRawConfig({ migrations: { dir: 'moves' } }))).toEqual([]); + expect(collectConfigIssues(createValidRawConfig({ migrations: { dir: '/moves' } }))).toEqual( + [], + ); + expect( + collectConfigIssues(createValidRawConfig({ migrations: { dir: 'moves' } })).map( + (issue) => issue.field, + ), + ).toEqual(['migrations.dir']); }); it('collects formatter issues under the formatter section', () => { @@ -376,3 +383,53 @@ describe('collectConfigIssues', () => { expect(collectConfigIssues(config)).toEqual([]); }); }); + +describe('paths are absolute once loaded', () => { + it('reports a relative contract input, output, and migrations dir', () => { + const issues = collectConfigIssues( + createValidRawConfig({ + contract: { + source: createSourceProvider({ inputs: ['./schema.prisma'] }), + output: 'out/contract.json', + }, + migrations: { dir: './db' }, + }), + ); + + expect(issues.map((issue) => issue.field)).toEqual([ + 'contract.source.inputs[]', + 'contract.output', + 'migrations.dir', + ]); + }); + + it('accepts absolute paths', () => { + expect( + collectConfigIssues( + createValidRawConfig({ + contract: { + source: createSourceProvider({ inputs: ['/p/schema.prisma'] }), + output: '/p/out/contract.json', + }, + migrations: { dir: '/p/db' }, + baseDir: '/p', + }), + ), + ).toEqual([]); + }); + + it('reports a baseDir that is not an absolute path against both path-bearing sections', () => { + const issues = collectConfigIssues(createValidRawConfig({ baseDir: 'app' })); + + expect(issues.map((issue) => [issue.section, issue.field])).toEqual([ + ['contract', 'baseDir'], + ['migrations', 'baseDir'], + ]); + }); + + it('reports a baseDir that is not a string', () => { + expect( + collectConfigIssues(createValidRawConfig({ baseDir: 42 })).map((issue) => issue.field), + ).toEqual(['baseDir', 'baseDir']); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/orm/config-section.test.ts b/packages/1-framework/3-tooling/cli/test/orm/config-section.test.ts index 962c517bfd7c..28a05d62a136 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/config-section.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/config-section.test.ts @@ -48,7 +48,7 @@ describe('ormConfigSection', () => { it('accepts the optional subsections', () => { const raw = { ...validConfig(), - migrations: { dir: 'migrations' }, + migrations: { dir: '/project/migrations' }, formatter: { indent: 2, newline: 'LF' }, db: { connection: 'postgres://localhost/app' }, }; @@ -160,8 +160,8 @@ describe('ormConfigSection', () => { const result = ormConfigSection.validate({ ...validConfig(), contract: { - source: { format: 'psl', inputs: ['./out/./contract.json'], load: () => ({}) }, - output: 'out/contract.json', + source: { format: 'psl', inputs: ['/app/out/./contract.json'], load: () => ({}) }, + output: '/app/out/contract.json', }, }); @@ -175,8 +175,8 @@ describe('ormConfigSection', () => { const result = ormConfigSection.validate({ ...validConfig(), contract: { - source: { format: 'psl', inputs: ['./src/contract.prisma'], load: () => ({}) }, - output: 'out/contract.json', + source: { format: 'psl', inputs: ['/app/src/contract.prisma'], load: () => ({}) }, + output: '/app/out/contract.json', }, }); @@ -242,3 +242,27 @@ describe('ormConfigSection', () => { }); }); }); + +describe('paths that were never resolved', () => { + it('refuses a relative path even when baseDir is present, naming the field', () => { + const result = ormConfigSection.validate({ + ...validConfig(), + migrations: { dir: './migrations' }, + }); + + expect(result.ok).toBe(false); + expect(result.diagnostics.map((diagnostic) => diagnostic.meta?.['field'])).toEqual([ + 'migrations.dir', + ]); + }); + + it('refuses a hand-written relative baseDir', () => { + const result = ormConfigSection.validate({ ...validConfig(), baseDir: 'app' }); + + expect(result.ok).toBe(false); + expect(result.diagnostics.map((diagnostic) => diagnostic.meta?.['field'])).toEqual([ + 'baseDir', + 'baseDir', + ]); + }); +}); diff --git a/packages/1-framework/3-tooling/config-loader/src/load.ts b/packages/1-framework/3-tooling/config-loader/src/load.ts index 67f66bfaf91c..137f0eeaf3b3 100644 --- a/packages/1-framework/3-tooling/config-loader/src/load.ts +++ b/packages/1-framework/3-tooling/config-loader/src/load.ts @@ -19,7 +19,7 @@ import { blindCast } from '@internal/utils/casts'; import { ifDefined } from '@internal/utils/defined'; import { notOk, ok, type Result } from '@internal/utils/result'; import { isStructuredError } from '@internal/utils/structured-error'; -import { dirname, join, resolve } from 'pathe'; +import { dirname, isAbsolute, join, resolve } from 'pathe'; const CONFIG_FILENAME = 'prisma.config.ts'; @@ -101,22 +101,29 @@ function collectArtifactCollisionDiagnostics( } function buildLoadedConfig(rawConfig: Record, configDir: string): LoadedConfig { - const issues = collectConfigIssues(rawConfig); - const diagnostics = issues.map((issue) => - errorConfigValidation(issue.field, { why: issue.message, section: issue.section }), - ); - - const raw = blindCast< + const authored = blindCast< PrismaNextConfig, - 'Structure was checked by collectConfigIssues; sections carrying diagnostics are guarded by requireConfigSections' + 'resolution touches only contract and migrations paths and leaves any other value for collectConfigIssues' >(rawConfig); // A section built with defineConfig while the loader published the base // directory arrives resolved; one written as a plain object is resolved here - // against the same directory. Resolution is idempotent, so both are one call. + // against the same directory. Resolution is idempotent, so both are one + // call. A baseDir written by hand is left for collectConfigIssues to refuse + // rather than anchored on. + const baseDir = + typeof authored.baseDir === 'string' && isAbsolute(authored.baseDir) + ? authored.baseDir + : configDir; + const resolved = resolveConfigPaths(authored, baseDir); + const issues = collectConfigIssues({ ...rawConfig, ...resolved }); + const diagnostics = issues.map((issue) => + errorConfigValidation(issue.field, { why: issue.message, section: issue.section }), + ); + const config = issues.some((issue) => issue.section === 'migrations') - ? raw - : withConfigDefaults(resolveConfigPaths(raw, raw.baseDir ?? configDir)); + ? resolved + : withConfigDefaults(resolved); if (config.contract === undefined || issues.some((issue) => issue.section === 'contract')) { return { config, diagnostics }; diff --git a/packages/1-framework/3-tooling/config-loader/test/load.test.ts b/packages/1-framework/3-tooling/config-loader/test/load.test.ts index b3ae01e2298a..3a61596aa30d 100644 --- a/packages/1-framework/3-tooling/config-loader/test/load.test.ts +++ b/packages/1-framework/3-tooling/config-loader/test/load.test.ts @@ -459,7 +459,7 @@ describe('loadConfig', () => { 'publishes the base directory to the file while it is evaluated', async () => { const readsBaseDir = `${CONFIG_BODY} -const baseDir = globalThis[Symbol.for('prisma.config.baseDir')]; +const baseDir = globalThis[Symbol.for('prisma.config.baseDir')].getStore(); export default { $prismaConfig: 1, orm: { ...config, migrations: { dir: baseDir + '/from-file' } } }; `; writeFileSync(join(tempDir, 'prisma.config.ts'), readsBaseDir); @@ -478,7 +478,7 @@ export default { $prismaConfig: 1, orm: { ...config, migrations: { dir: baseDir const elsewhere = join(tempDir, 'elsewhere'); mkdirSync(elsewhere); const readsBaseDir = `${CONFIG_BODY} -const baseDir = globalThis[Symbol.for('prisma.config.baseDir')]; +const baseDir = globalThis[Symbol.for('prisma.config.baseDir')].getStore(); export default { $prismaConfig: 1, orm: { ...config, migrations: { dir: baseDir + '/from-file' } } }; `; writeFileSync(join(elsewhere, 'custom.config.ts'), readsBaseDir); From 9bc25b886f08ebf7735b266f5bd5eb439b034a04 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 21 Sep 2026 16:19:39 +0200 Subject: [PATCH 5/5] refactor(config): the config helper reads the base directory without importing node:async_hooks Only loaders create the store, so only loaders import AsyncLocalStorage; withBaseDir moves to @internal/config-loader. baseDir() in @internal/config reads whatever store a loader published under the shared key and is undefined otherwise, so defineConfig runs under any runtime. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- ...esolve against the file that wrote them.md | 4 +- .../1-core/config/src/config-base-dir.ts | 32 +++----- .../config/src/exports/config-base-dir.ts | 3 +- .../config/test/config-base-dir.test.ts | 75 +++++-------------- .../1-core/config/test/define-config.test.ts | 8 +- .../3-tooling/config-loader/src/base-dir.ts | 21 ++++++ .../config-loader/src/exports/index.ts | 1 + .../3-tooling/config-loader/src/load.ts | 2 +- .../config-loader/test/base-dir.test.ts | 62 +++++++++++++++ 9 files changed, 124 insertions(+), 84 deletions(-) create mode 100644 packages/1-framework/3-tooling/config-loader/src/base-dir.ts create mode 100644 packages/1-framework/3-tooling/config-loader/test/base-dir.test.ts diff --git a/docs/architecture docs/adrs/ADR 253 - Config paths resolve against the file that wrote them.md b/docs/architecture docs/adrs/ADR 253 - Config paths resolve against the file that wrote them.md index edff3743a9ec..976ba01db517 100644 --- a/docs/architecture docs/adrs/ADR 253 - Config paths resolve against the file that wrote them.md +++ b/docs/architecture docs/adrs/ADR 253 - Config paths resolve against the file that wrote them.md @@ -75,9 +75,9 @@ A file evaluated outside a loader, for instance a test that imports it directly, ## Responsibilities -**Loaders** publish the base directory around each file they evaluate and clear it after. The CLI engine's loader does this through `withBaseDir`, which it exports for any other loader; the ORM's loader does the same around its own evaluation. +**Loaders** publish the base directory around each file they evaluate. The CLI engine's loader and the ORM's loader each do this through their own `withBaseDir`, which creates the shared store when it is first needed. Only loaders import `node:async_hooks`. -**A family's config helper** reads the base directory and resolves its own path fields, recording `baseDir`. The ORM's `defineConfig` covers the contract source, `contract.output`, and `migrations.dir`. Defaults that are themselves paths, such as the migrations directory, are supplied by the reader from `baseDir` rather than written into the section, so a layer that omits a value never shadows a layer that authored one. +**A family's config helper** reads the base directory through `baseDir()`, which imports nothing and returns undefined when no loader has published a store, so the helper runs under any runtime, and resolves its own path fields, recording `baseDir`. The ORM's `defineConfig` covers the contract source, `contract.output`, and `migrations.dir`. Defaults that are themselves paths, such as the migrations directory, are supplied by the reader from `baseDir` rather than written into the section, so a layer that omits a value never shadows a layer that authored one. **Commands** read absolute paths and `baseDir` from the config. A command that needs the project's location, for instance to find the project's `package.json`, starts from `baseDir`. No command reconstructs the config file's path, and no command resolves a config value against the working directory. The one kind of path that is relative to the working directory is a path typed on the command line, such as `--output-path` on `contract emit`, because the shell is where the user wrote it. diff --git a/packages/1-framework/1-core/config/src/config-base-dir.ts b/packages/1-framework/1-core/config/src/config-base-dir.ts index ca31e1f44b09..fdacfdcb4976 100644 --- a/packages/1-framework/1-core/config/src/config-base-dir.ts +++ b/packages/1-framework/1-core/config/src/config-base-dir.ts @@ -1,30 +1,20 @@ -import { AsyncLocalStorage } from 'node:async_hooks'; - /** * The directory relative paths in a config file resolve against: the - * directory of the file being evaluated. The loader publishes it for the - * duration of the evaluation and the config helpers read it while the file - * runs. The store lives on `globalThis` under a `Symbol.for` key so every - * loader and helper in a dependency tree shares one, and a loader can publish - * without importing this package. It is an AsyncLocalStorage rather than a - * plain value so evaluations that overlap in time, such as a language server - * loading several projects at once, each see their own directory. + * directory of the file being evaluated. A loader publishes it for the + * duration of the evaluation through a store it keeps on `globalThis` under + * this `Symbol.for` key, and the config helpers read it while the file runs. + * This side only reads: it imports nothing, so a config helper works under any + * runtime, and outside a loader there is simply no base directory. */ export const BASE_DIR_KEY: unique symbol = Symbol.for('prisma.config.baseDir'); -type BaseDirSlot = { [BASE_DIR_KEY]?: AsyncLocalStorage }; - -function store(): AsyncLocalStorage { - const slot = globalThis as BaseDirSlot; - slot[BASE_DIR_KEY] ??= new AsyncLocalStorage(); - return slot[BASE_DIR_KEY]; +/** What a loader publishes: the shape of an AsyncLocalStorage. */ +export interface BaseDirStore { + getStore(): string | undefined; } -export function baseDir(): string | undefined { - return store().getStore(); -} +type BaseDirSlot = { [BASE_DIR_KEY]?: BaseDirStore }; -/** Runs `evaluate` with `dir` as the base directory for everything it awaits. */ -export function withBaseDir(dir: string, evaluate: () => Promise): Promise { - return store().run(dir, evaluate); +export function baseDir(): string | undefined { + return (globalThis as BaseDirSlot)[BASE_DIR_KEY]?.getStore(); } diff --git a/packages/1-framework/1-core/config/src/exports/config-base-dir.ts b/packages/1-framework/1-core/config/src/exports/config-base-dir.ts index 0abea1f3d334..bf5e3baa1f79 100644 --- a/packages/1-framework/1-core/config/src/exports/config-base-dir.ts +++ b/packages/1-framework/1-core/config/src/exports/config-base-dir.ts @@ -1 +1,2 @@ -export { BASE_DIR_KEY, baseDir, withBaseDir } from '../config-base-dir'; +export type { BaseDirStore } from '../config-base-dir'; +export { BASE_DIR_KEY, baseDir } from '../config-base-dir'; diff --git a/packages/1-framework/1-core/config/test/config-base-dir.test.ts b/packages/1-framework/1-core/config/test/config-base-dir.test.ts index 056cf06daff8..ef6809ac168a 100644 --- a/packages/1-framework/1-core/config/test/config-base-dir.test.ts +++ b/packages/1-framework/1-core/config/test/config-base-dir.test.ts @@ -1,72 +1,33 @@ -import type { AsyncLocalStorage } from 'node:async_hooks'; -import { describe, expect, it } from 'vitest'; -import { BASE_DIR_KEY, baseDir, withBaseDir } from '../src/config-base-dir'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import { afterEach, describe, expect, it } from 'vitest'; +import { BASE_DIR_KEY, baseDir } from '../src/config-base-dir'; -describe('withBaseDir', () => { - it('publishes the directory while the evaluation runs and clears it after', async () => { - let seen: string | undefined; +type Slot = { [BASE_DIR_KEY]?: AsyncLocalStorage }; - await withBaseDir('/app', async () => { - seen = baseDir(); - }); +afterEach(() => { + delete (globalThis as Slot)[BASE_DIR_KEY]; +}); - expect(seen).toBe('/app'); +describe('baseDir', () => { + it('is undefined when no loader has published a store', () => { expect(baseDir()).toBeUndefined(); }); - it('restores the outer directory for a nested evaluation', async () => { - let inner: string | undefined; - let afterInner: string | undefined; + it('reads the directory a loader published through the shared store', async () => { + const store = new AsyncLocalStorage(); + (globalThis as Slot)[BASE_DIR_KEY] = store; - await withBaseDir('/outer', async () => { - await withBaseDir('/inner', async () => { - inner = baseDir(); - }); - afterInner = baseDir(); + await store.run('/app', async () => { + expect(baseDir()).toBe('/app'); }); - - expect({ inner, afterInner }).toEqual({ inner: '/inner', afterInner: '/outer' }); - }); - - it('clears the directory when the evaluation throws', async () => { - await expect( - withBaseDir('/app', async () => { - throw new Error('boom'); - }), - ).rejects.toThrow('boom'); - expect(baseDir()).toBeUndefined(); }); - it('keeps two overlapping evaluations apart', async () => { - let seenA: string | undefined; - let seenB: string | undefined; - let release: () => void = () => {}; - const gate = new Promise((resolve) => { - release = resolve; - }); - - await Promise.all([ - withBaseDir('/a', async () => { - await gate; - seenA = baseDir(); - }), - withBaseDir('/b', async () => { - release(); - seenB = baseDir(); - }), - ]); - - expect({ seenA, seenB }).toEqual({ seenA: '/a', seenB: '/b' }); - }); - - it('is one store any loader can publish through without importing this package', async () => { - const shared = (globalThis as { [BASE_DIR_KEY]?: AsyncLocalStorage })[ - Symbol.for('prisma.config.baseDir') as typeof BASE_DIR_KEY - ]; - if (shared === undefined) throw new Error('store not published'); + it('reaches a store published under the key by a loader that never imported this package', async () => { + const store = new AsyncLocalStorage(); + (globalThis as { [key: symbol]: unknown })[Symbol.for('prisma.config.baseDir')] = store; - await shared.run('/elsewhere', async () => { + await store.run('/elsewhere', async () => { expect(baseDir()).toBe('/elsewhere'); }); }); diff --git a/packages/1-framework/1-core/config/test/define-config.test.ts b/packages/1-framework/1-core/config/test/define-config.test.ts index 7013d1f0106c..6f922fd210d3 100644 --- a/packages/1-framework/1-core/config/test/define-config.test.ts +++ b/packages/1-framework/1-core/config/test/define-config.test.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; import type { Contract } from '@internal/contract/types'; import type { ControlDriverInstance, @@ -5,7 +6,7 @@ import type { } from '@internal/framework-components/control'; import { ok } from '@internal/utils/result'; import { describe, expect, it } from 'vitest'; -import { withBaseDir } from '../src/config-base-dir'; +import { BASE_DIR_KEY } from '../src/config-base-dir'; import { defineConfig, type PrismaNextConfig } from '../src/config-types'; const mockHook = { @@ -130,7 +131,10 @@ describe('defineConfig path resolution', () => { }); it('resolves every relative path against the base directory the loader published', async () => { - const config = await withBaseDir('/app', async () => defineConfig(authored())); + const store = new AsyncLocalStorage(); + (globalThis as { [BASE_DIR_KEY]?: AsyncLocalStorage })[BASE_DIR_KEY] = store; + const config = await store.run('/app', async () => defineConfig(authored())); + delete (globalThis as { [BASE_DIR_KEY]?: unknown })[BASE_DIR_KEY]; expect(config).toMatchObject({ baseDir: '/app', diff --git a/packages/1-framework/3-tooling/config-loader/src/base-dir.ts b/packages/1-framework/3-tooling/config-loader/src/base-dir.ts new file mode 100644 index 000000000000..cde6418f9e9d --- /dev/null +++ b/packages/1-framework/3-tooling/config-loader/src/base-dir.ts @@ -0,0 +1,21 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { BASE_DIR_KEY } from '@internal/config/config-base-dir'; + +type BaseDirSlot = { [BASE_DIR_KEY]?: AsyncLocalStorage }; + +function store(): AsyncLocalStorage { + const slot = globalThis as BaseDirSlot; + slot[BASE_DIR_KEY] ??= new AsyncLocalStorage(); + return slot[BASE_DIR_KEY]; +} + +/** + * Runs `evaluate` with `dir` published as the base directory for everything + * it awaits (ADR 253). The store is an AsyncLocalStorage so evaluations that + * overlap in one process, such as a language server loading several + * projects, each see their own directory. It is created here, by the loader, + * and shared through the `Symbol.for` key the config helpers read. + */ +export function withBaseDir(dir: string, evaluate: () => Promise): Promise { + return store().run(dir, evaluate); +} diff --git a/packages/1-framework/3-tooling/config-loader/src/exports/index.ts b/packages/1-framework/3-tooling/config-loader/src/exports/index.ts index 7a1bb4d6b5fb..d8c1b3de2bbe 100644 --- a/packages/1-framework/3-tooling/config-loader/src/exports/index.ts +++ b/packages/1-framework/3-tooling/config-loader/src/exports/index.ts @@ -1,4 +1,5 @@ export type { PrismaNextConfig } from '@internal/config/config-types'; +export { withBaseDir } from '../base-dir'; export type { ConfigSection, LoadedConfig } from '../load'; export { findNearestConfigPathForFile, diff --git a/packages/1-framework/3-tooling/config-loader/src/load.ts b/packages/1-framework/3-tooling/config-loader/src/load.ts index 137f0eeaf3b3..81b72773a9e9 100644 --- a/packages/1-framework/3-tooling/config-loader/src/load.ts +++ b/packages/1-framework/3-tooling/config-loader/src/load.ts @@ -2,7 +2,6 @@ import { realpathSync } from 'node:fs'; import { access } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; -import { withBaseDir } from '@internal/config/config-base-dir'; import { resolveConfigPaths, withConfigDefaults } from '@internal/config/config-resolve'; import type { PrismaNextConfig } from '@internal/config/config-types'; import type { ConfigSection } from '@internal/config/config-validation'; @@ -20,6 +19,7 @@ import { ifDefined } from '@internal/utils/defined'; import { notOk, ok, type Result } from '@internal/utils/result'; import { isStructuredError } from '@internal/utils/structured-error'; import { dirname, isAbsolute, join, resolve } from 'pathe'; +import { withBaseDir } from './base-dir'; const CONFIG_FILENAME = 'prisma.config.ts'; diff --git a/packages/1-framework/3-tooling/config-loader/test/base-dir.test.ts b/packages/1-framework/3-tooling/config-loader/test/base-dir.test.ts new file mode 100644 index 000000000000..66291007ee82 --- /dev/null +++ b/packages/1-framework/3-tooling/config-loader/test/base-dir.test.ts @@ -0,0 +1,62 @@ +import { baseDir } from '@internal/config/config-base-dir'; +import { describe, expect, it } from 'vitest'; +import { withBaseDir } from '../src/base-dir'; + +describe('withBaseDir', () => { + it('publishes the directory while the evaluation runs and clears it after', async () => { + let seen: string | undefined; + + await withBaseDir('/app', async () => { + seen = baseDir(); + }); + + expect(seen).toBe('/app'); + expect(baseDir()).toBeUndefined(); + }); + + it('restores the outer directory for a nested evaluation', async () => { + let inner: string | undefined; + let afterInner: string | undefined; + + await withBaseDir('/outer', async () => { + await withBaseDir('/inner', async () => { + inner = baseDir(); + }); + afterInner = baseDir(); + }); + + expect({ inner, afterInner }).toEqual({ inner: '/inner', afterInner: '/outer' }); + }); + + it('keeps two overlapping evaluations apart', async () => { + let seenA: string | undefined; + let seenB: string | undefined; + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + + await Promise.all([ + withBaseDir('/a', async () => { + await gate; + seenA = baseDir(); + }), + withBaseDir('/b', async () => { + release(); + seenB = baseDir(); + }), + ]); + + expect({ seenA, seenB }).toEqual({ seenA: '/a', seenB: '/b' }); + }); + + it('clears the directory when the evaluation throws', async () => { + await expect( + withBaseDir('/app', async () => { + throw new Error('boom'); + }), + ).rejects.toThrow('boom'); + + expect(baseDir()).toBeUndefined(); + }); +});