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/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..976ba01db517 --- /dev/null +++ b/docs/architecture docs/adrs/ADR 253 - Config paths resolve against the file that wrote them.md @@ -0,0 +1,114 @@ +# 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 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 +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 `baseDir`, for anything that needs the project's location rather than one of its files: + +```ts +{ + orm: { + 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' }, + }, + $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. + +## How the file learns where it is + +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 + participant L as loader + participant F as prisma.config.ts + participant O as ormConfig + L->>L: publish baseDir = dirname(file) + L->>F: evaluate + F->>O: ormConfig({ contract: './contract.prisma' }) + O->>O: read baseDir, resolve paths, record baseDir + O-->>F: section with absolute paths + F-->>L: config + L->>L: clear baseDir +``` + +Three properties follow. + +- **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. + +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 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 + +**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 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. + +**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. 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 + +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 + +- Config files are written as before. No argument is added and no path is written differently. +- 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 + +**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 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. + +**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. + +**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. + +**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 6af9e8a929f5..942c2c2e72b1 100644 --- a/packages/1-framework/1-core/config/package.json +++ b/packages/1-framework/1-core/config/package.json @@ -42,6 +42,8 @@ "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", "./package.json": "./package.json" 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..fdacfdcb4976 --- /dev/null +++ b/packages/1-framework/1-core/config/src/config-base-dir.ts @@ -0,0 +1,20 @@ +/** + * The directory relative paths in a config file resolve against: the + * 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'); + +/** What a loader publishes: the shape of an AsyncLocalStorage. */ +export interface BaseDirStore { + getStore(): string | undefined; +} + +type BaseDirSlot = { [BASE_DIR_KEY]?: BaseDirStore }; + +export function baseDir(): string | undefined { + return (globalThis as BaseDirSlot)[BASE_DIR_KEY]?.getStore(); +} 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..c0badb6508c2 --- /dev/null +++ b/packages/1-framework/1-core/config/src/config-resolve.ts @@ -0,0 +1,98 @@ +import { resolve } from 'pathe'; +import type { ContractConfig, PrismaNextConfig } from './config-types'; + +type ContractSourceProvider = NonNullable['source']; + +/** A value that is not a string is left for validation to report. */ +function resolveAuthored(baseDir: string, value: string): string { + return typeof value === 'string' ? resolve(baseDir, value) : value; +} + +function resolveContractSource( + source: ContractSourceProvider, + baseDir: string, +): ContractSourceProvider { + const inputs = source.inputs; + return Array.isArray(inputs) + ? { ...source, inputs: inputs.map((input) => resolveAuthored(baseDir, 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, baseDir: string): ContractConfig { + const normalized = normalizeContractConfig(contract); + return { + ...normalized, + ...(normalized.source ? { source: resolveContractSource(normalized.source, baseDir) } : {}), + output: resolveAuthored(baseDir, normalized.output), + }; +} + +const DEFAULT_MIGRATIONS_DIR = 'migrations'; + +type MigrationsConfig = NonNullable; + +export function resolveMigrationsConfig( + migrations: PrismaNextConfig['migrations'], + baseDir: string, +): PrismaNextConfig['migrations'] { + return migrations?.dir === undefined + ? migrations + : { ...migrations, dir: resolveAuthored(baseDir, migrations.dir) }; +} + +/** + * 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 baseDir: string }, +): TConfig & { readonly migrations: MigrationsConfig & { readonly dir: string } } { + return { + ...config, + migrations: { + ...config.migrations, + dir: config.migrations?.dir ?? resolve(config.baseDir, DEFAULT_MIGRATIONS_DIR), + }, + }; +} + +/** + * 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, + baseDir: string, +): TConfig & { readonly baseDir: string } { + return { + ...config, + baseDir, + ...(config.contract ? { contract: resolveContractConfig(config.contract, baseDir) } : {}), + ...(config.migrations + ? { migrations: resolveMigrationsConfig(config.migrations, baseDir) } + : {}), + }; +} 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..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,6 +6,8 @@ import type { ControlFamilyDescriptor, ControlTargetDescriptor, } from '@internal/framework-components/control'; +import { baseDir } from './config-base-dir'; +import { normalizeContractConfig, resolveConfigPaths } from './config-resolve'; import type { ContractSourceProvider } from './contract-source-types'; /** @@ -24,10 +26,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; } @@ -37,26 +40,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. @@ -107,10 +90,17 @@ 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; + /** + * 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 baseDir?: string; } /** @@ -123,19 +113,20 @@ export interface PrismaNextConfig< * - contract.output defaults to a path colocated with DEFAULT_CONTRACT_SOURCE_DIR * when missing (in-memory-only providers) * + * 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 */ 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; + const dir = baseDir(); + return dir === undefined ? normalized : resolveConfigPaths(normalized, dir); } 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/src/exports/config-base-dir.ts b/packages/1-framework/1-core/config/src/exports/config-base-dir.ts new file mode 100644 index 000000000000..bf5e3baa1f79 --- /dev/null +++ b/packages/1-framework/1-core/config/src/exports/config-base-dir.ts @@ -0,0 +1,2 @@ +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/src/exports/config-resolve.ts b/packages/1-framework/1-core/config/src/exports/config-resolve.ts new file mode 100644 index 000000000000..1439bc41dc82 --- /dev/null +++ b/packages/1-framework/1-core/config/src/exports/config-resolve.ts @@ -0,0 +1,8 @@ +export { + DEFAULT_CONTRACT_SOURCE_DIR, + normalizeContractConfig, + resolveConfigPaths, + resolveContractConfig, + resolveMigrationsConfig, + withConfigDefaults, +} 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-base-dir.test.ts b/packages/1-framework/1-core/config/test/config-base-dir.test.ts new file mode 100644 index 000000000000..ef6809ac168a --- /dev/null +++ b/packages/1-framework/1-core/config/test/config-base-dir.test.ts @@ -0,0 +1,34 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { afterEach, describe, expect, it } from 'vitest'; +import { BASE_DIR_KEY, baseDir } from '../src/config-base-dir'; + +type Slot = { [BASE_DIR_KEY]?: AsyncLocalStorage }; + +afterEach(() => { + delete (globalThis as Slot)[BASE_DIR_KEY]; +}); + +describe('baseDir', () => { + it('is undefined when no loader has published a store', () => { + expect(baseDir()).toBeUndefined(); + }); + + it('reads the directory a loader published through the shared store', async () => { + const store = new AsyncLocalStorage(); + (globalThis as Slot)[BASE_DIR_KEY] = store; + + await store.run('/app', async () => { + expect(baseDir()).toBe('/app'); + }); + expect(baseDir()).toBeUndefined(); + }); + + 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 store.run('/elsewhere', async () => { + expect(baseDir()).toBe('/elsewhere'); + }); + }); +}); 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..fba9bb46cd0e --- /dev/null +++ b/packages/1-framework/1-core/config/test/config-resolve.test.ts @@ -0,0 +1,162 @@ +import { ok } from '@internal/utils/result'; +import { describe, expect, it } from 'vitest'; +import { resolveConfigPaths, withConfigDefaults } 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 base directory on the section', () => { + expect(resolveConfigPaths(createConfig(), '/project').baseDir).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, + baseDir: '/somewhere/else', + }); + }); +}); + +describe('withConfigDefaults', () => { + it('supplies the migrations dir under the root so no caller re-derives it', () => { + const config = resolveConfigPaths(createConfig(), '/project'); + + expect(withConfigDefaults({ ...config, baseDir: '/project' }).migrations.dir).toBe( + '/project/migrations', + ); + }); + + it('keeps an authored migrations dir', () => { + const config = resolveConfigPaths(createConfig(undefined, { migrations: { dir: 'db' } }), '/p'); + + expect(withConfigDefaults({ ...config, baseDir: '/p' }).migrations.dir).toBe('/p/db'); + }); +}); 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/1-core/config/test/define-config.test.ts b/packages/1-framework/1-core/config/test/define-config.test.ts index 37d51a8eab83..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,6 +6,7 @@ import type { } from '@internal/framework-components/control'; import { ok } from '@internal/utils/result'; import { describe, expect, it } from 'vitest'; +import { BASE_DIR_KEY } from '../src/config-base-dir'; import { defineConfig, type PrismaNextConfig } from '../src/config-types'; const mockHook = { @@ -81,6 +83,7 @@ function createValidConfig(overrides: Record = {}): PrismaNextC describe('defineConfig', () => { it('returns the same object when contract is absent', () => { const config = createValidConfig(); + expect(defineConfig(config)).toBe(config); }); @@ -116,3 +119,35 @@ describe('defineConfig', () => { expect(() => defineConfig(invalidConfig)).not.toThrow(); }); }); + +describe('defineConfig path resolution', () => { + const authored = () => + createValidConfig({ + contract: { + source: createSourceProvider({ inputs: ['./schema.prisma'] }), + output: './out/contract.json', + }, + migrations: { dir: './db' }, + }); + + it('resolves every relative path against the base directory the loader published', async () => { + 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', + 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 fa2737cff6d3..909febea9646 100644 --- a/packages/1-framework/1-core/config/tsdown.config.ts +++ b/packages/1-framework/1-core/config/tsdown.config.ts @@ -1,5 +1,10 @@ import { defineConfig } from '@repo/tsdown'; export default defineConfig({ - entry: ['src/exports/config-types.ts', 'src/exports/config-validation.ts'], + 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/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..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,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 `baseDir`; 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..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,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 `baseDir`; 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..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,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 `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. */ - 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..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 @@ -73,6 +73,28 @@ function unreadableDiagnostic(error: unknown): Diagnostic { }; } +/** + * `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 noBaseDirDiagnostic(): 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 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 if the section already uses defineConfig.', + }, + ], + }; +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -135,7 +157,6 @@ function validate(raw: unknown): SectionValidation { if (!isRecord(raw)) { return { ok: false as const, diagnostics: [notAnObjectDiagnostic()] }; } - let issues: readonly ConfigValidationIssue[]; try { issues = [...collectConfigIssues(raw), ...collectArtifactCollisionIssues(raw)]; @@ -146,6 +167,9 @@ function validate(raw: unknown): SectionValidation { if (issues.length > 0) { return { ok: false as const, diagnostics: issues.map(issueDiagnostic) }; } + if (typeof raw['baseDir'] !== 'string') { + return { ok: false as const, diagnostics: [noBaseDirDiagnostic()] }; + } 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 de6ce6aada35..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 { projectConfigPathFor } 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, - configPath: projectConfigPathFor(ctx.cwd), + projectDir: baseDirFor(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 7088fb1f3313..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,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, baseDirFor, displayPath, migrationsDirFor } 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.cwd), + projectDir: baseDirFor(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 fdc1bcdc04f9..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 { projectConfigPathFor } 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, - configPath: projectConfigPathFor(ctx.cwd), + projectDir: baseDirFor(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 391292999705..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, @@ -30,38 +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. - * - * 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. - */ -function finalizedConfigContext( - 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); - return { ...ctx, config: finalizeConfig(config, ctx.cwd) }; -} export function defineOrmCommand< TFlags extends Record> = Record>, @@ -86,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 f5ce94124c29..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, projectConfigPathFor } from './migration/paths'; +import { baseDirFor, displayPath, migrationsDirFor } 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.cwd), + projectDir: baseDirFor(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 52a1ebc63bc6..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, projectConfigPathFor } 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, - configPath: projectConfigPathFor(ctx.cwd), + projectDir: baseDirFor(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 3dc2b98b0fd3..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 @@ -1,49 +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. Resolved against the invocation - * directory, which is also the config file's directory for every default - * invocation. + * 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 baseDirFor(config: PrismaNextConfig): string { + if (config.baseDir === undefined) { + throw new InternalError('the orm config section reached a command without baseDir'); + } + return config.baseDir; } -/** 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(baseDirFor(config), 'migrations'); } -/** - * 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. - */ -export function projectConfigPathFor(cwd: string): string { - return resolve(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 f531ae53b285..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, - projectConfigPathFor, } 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.cwd), + projectDir: baseDirFor(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..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,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: { baseDir?: 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 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/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..ecfffd5ffd93 --- /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 `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 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 37d40fe70b12..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 @@ -25,6 +25,7 @@ function validDescriptor(kind: string) { function validConfig() { return { + baseDir: '/project', family: validFamily(), target: { ...validDescriptor('target'), targetId: 'postgres' }, adapter: validDescriptor('adapter'), @@ -47,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' }, }; @@ -56,6 +57,30 @@ describe('ormConfigSection', () => { }); }); + 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); + 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 baseDir', () => { + const { baseDir: _baseDir, ...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); @@ -135,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', }, }); @@ -150,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', }, }); @@ -217,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/cli/test/orm/contract-emit.test.ts b/packages/1-framework/3-tooling/cli/test/orm/contract-emit.test.ts index b97b790b7b40..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: [], }); }, @@ -141,6 +144,44 @@ 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) => { + 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'], + { 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/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 7b09f6771f2b..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 @@ -144,57 +144,3 @@ 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', - }, - migrations: { dir: './migrations' }, - }, - }, - }); - - const cwd = process.cwd(); - const run = await cli.run(['probe', '--json'], { cwd }); - - expect(run.exitCode).toBe(0); - expect(seen).toEqual({ - output: `${cwd}/src/prisma/contract.json`, - dir: `${cwd}/migrations`, - }); - }); -}); 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 05817747d6c1..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,12 +1,16 @@ 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'; +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 { createOrmTestCli } from '../helpers/orm-test-cli'; import { ADDITIVE_OP, contractJson, @@ -38,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, }, }); } @@ -82,6 +84,66 @@ async function upToDateProject(): Promise { return project; } +/** + * 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', + }, + 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', () => { + 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: resolvingLoader(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(); 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..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 @@ -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' } }, + { baseDir: '/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 baseDir when the config names none', () => { + const paths = resolveMigrationPaths({ baseDir: '/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 baseDir', () => { + 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..138a42053bb8 100644 --- a/packages/1-framework/3-tooling/config-loader/README.md +++ b/packages/1-framework/3-tooling/config-loader/README.md @@ -6,8 +6,11 @@ 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`. 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`). It exposes a single `loadConfig(configPath?)` that maps failures to the CLI's structured 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 e13af5fc6e29..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,5 +1,5 @@ export type { PrismaNextConfig } from '@internal/config/config-types'; -export { finalizeConfig } from '../finalize-config'; +export { withBaseDir } from '../base-dir'; 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..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,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 { dirname, join, resolve } from 'pathe'; -import { finalizeContractConfig, finalizeMigrationsConfig } from './finalize-config'; +import { dirname, isAbsolute, join, resolve } from 'pathe'; +import { withBaseDir } from './base-dir'; const CONFIG_FILENAME = 'prisma.config.ts'; @@ -100,29 +101,36 @@ function collectArtifactCollisionDiagnostics( } function buildLoadedConfig(rawConfig: Record, configDir: string): LoadedConfig { - const issues = collectConfigIssues(rawConfig); + const authored = blindCast< + PrismaNextConfig, + '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. 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 raw = blindCast< - PrismaNextConfig, - '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) }; + ? resolved + : withConfigDefaults(resolved); 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 { @@ -185,11 +193,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)); } 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(); + }); +}); 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..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 @@ -455,6 +455,57 @@ describe('loadConfig', () => { timeouts.typeScriptCompilation, ); + it( + 'publishes the base directory to the file while it is evaluated', + async () => { + const readsBaseDir = `${CONFIG_BODY} +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); + process.chdir(tempDir); + + const { config } = (await loadConfig()).assertOk(); + + 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')].getStore(); +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 baseDir', + async () => { + writeFileSync(join(tempDir, 'prisma.config.ts'), VALID_CONFIG_SOURCE); + process.chdir(tempDir); + + const { config } = (await loadConfig()).assertOk(); + + expect(config.baseDir).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..ca953e33beaf 100644 --- a/packages/9-public/@prisma/orm-framework/package.json +++ b/packages/9-public/@prisma/orm-framework/package.json @@ -60,6 +60,8 @@ "./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", "./contract": "./dist/contract.mjs", 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); }