Skip to content
Merged
2 changes: 1 addition & 1 deletion examples/auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"hono": "^4.12.31"
},
"devDependencies": {
"@prisma/cli-engine": "0.4.0",
"@prisma/cli-engine": "0.5.0",
"@types/bun": "^1.3.13",
"typescript": "^6.0.3"
}
Expand Down
2 changes: 1 addition & 1 deletion examples/orm-demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"pg": "8.22.0"
},
"devDependencies": {
"@prisma/cli-engine": "0.4.0",
"@prisma/cli-engine": "0.5.0",
"@prisma/composer": "workspace:0.21.0",
"@prisma/composer-cli": "workspace:0.21.0",
"@prisma/management-api-sdk": "^1.47.0",
Expand Down
2 changes: 1 addition & 1 deletion examples/store/modules/catalog/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"@prisma/composer-prisma-cloud": "workspace:0.21.0"
},
"devDependencies": {
"@prisma/cli-engine": "0.4.0",
"@prisma/cli-engine": "0.5.0",
"@prisma/composer": "workspace:0.21.0",
"@prisma/composer-prisma-cloud": "workspace:0.21.0",
"@types/bun": "^1.3.13",
Expand Down
2 changes: 1 addition & 1 deletion examples/store/modules/orders/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"@prisma/composer-prisma-cloud": "workspace:0.21.0"
},
"devDependencies": {
"@prisma/cli-engine": "0.4.0",
"@prisma/cli-engine": "0.5.0",
"@prisma/composer": "workspace:0.21.0",
"@prisma/composer-prisma-cloud": "workspace:0.21.0",
"@types/bun": "^1.3.13",
Expand Down
2 changes: 1 addition & 1 deletion packages/0-framework/3-tooling/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"@internal/assemble": "workspace:0.21.0",
"@internal/core": "workspace:0.21.0",
"@internal/foundation": "workspace:0.21.0",
"@prisma/cli-engine": "0.4.0",
"@prisma/cli-engine": "0.5.0",
"c12": "^3.3.4",
"chokidar": "^4.0.3",
"cross-spawn": "^7.0.6"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ describe('findConfigPathForEntry() — the walk-up', () => {
});

describe('resolveConfigFile() — which file a load will use', () => {
test('a relative configPath resolves against the cwd the command runs in', () => {
test('the section-supplied absolute configPath is used as given, whatever the cwd', () => {
const dir = makeTree();
const appDir = path.join(dir, 'apps', 'shop');
fs.mkdirSync(appDir, { recursive: true });
Expand All @@ -138,8 +138,8 @@ describe('resolveConfigFile() — which file a load will use', () => {

const resolved = resolveConfigFile({
entryPath: path.join(dir, 'module.ts'),
configPath: `./apps/shop/${CONFIG_FILENAME}`,
cwd: dir,
configPath,
cwd: appDir,
});

expect(resolved.path).toBe(configPath);
Expand All @@ -164,7 +164,7 @@ describe('resolveConfigFile() — which file a load will use', () => {
try {
resolveConfigFile({
entryPath: path.join(dir, 'module.ts'),
configPath: './not-here.config.ts',
configPath: path.join(dir, 'not-here.config.ts'),
cwd: dir,
});
} catch (thrown: unknown) {
Expand All @@ -178,6 +178,28 @@ describe('resolveConfigFile() — which file a load will use', () => {
expect(error.message).toContain(path.join(dir, 'not-here.config.ts'));
});

test('a relative configPath is CONFIG.PATH_NOT_ABSOLUTE — the validator was skipped, never a cwd resolve', () => {
const dir = makeTree();
fs.writeFileSync(path.join(dir, CONFIG_FILENAME), VALID_CONFIG_SOURCE);

const error: unknown = (() => {
try {
resolveConfigFile({
entryPath: path.join(dir, 'module.ts'),
configPath: './prisma-composer.config.ts',
cwd: dir,
});
} catch (thrown: unknown) {
return thrown;
}
return undefined;
})();

if (!CliStructuredError.is(error)) throw new Error('expected a structured error');
expect(error.code).toBe('CONFIG.PATH_NOT_ABSOLUTE');
expect(error.message).toContain('./prisma-composer.config.ts');
});

test('with no configPath it is the entry-anchored walk, and the walk is not explicit', () => {
const dir = makeTree();
const configPath = path.join(dir, CONFIG_FILENAME);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { type ComposerSection, composerSection } from '../section.ts';

const VERSION = '0.6.0-test';
const NO_CONFIG = (): Promise<LoadedConfig> =>
Promise.resolve({ path: '/app/prisma.config.ts', sections: {}, diagnostics: [] });
Promise.resolve({ files: [{ path: '/app/prisma.config.ts', sections: {} }], diagnostics: [] });

function fakeHost(cwd: string): HostProcess & { out: string[]; err: string[] } {
const out: string[] = [];
Expand Down Expand Up @@ -196,13 +196,54 @@ describe('the composer section through the engine', () => {
expect(result.presented?.data).toEqual({});
});

test('a configPath reaches the handler as ctx.config', async () => {
test('a configPath reaches the handler as ctx.config, resolved against the declaring file', async () => {
// The harness seeds the config file at the run's cwd, `/`, so the
// section's relative path resolves against that directory — not against
// wherever this test process happens to run.
const result = await probeCli({
composer: { configPath: './x/prisma-composer.config.ts' },
}).run(['probe', '--json']);
expect(result.exitCode).toBe(0);
expect(result.presented?.data).toEqual({
configPath: './x/prisma-composer.config.ts',
configPath: path.resolve(path.sep, 'x', 'prisma-composer.config.ts'),
} satisfies ComposerSection);
});

/**
* The reason the section resolves paths at all. prisma.config.ts files form a
* chain — discovered from cwd up to the repo root and merged per key — so a
* `composer` section written once at the root reaches commands run in any
* subdirectory. The root file is the only one declaring `configPath`, and the
* run happens two directories below it, so both wrong answers are visible:
* resolving against cwd or against the nearest file on the chain would name
* `/repo/apps/shop/prisma-composer.config.ts`.
*/
test('a configPath declared at the repo root names the same file from a subdirectory', async () => {
const repo = path.resolve(path.sep, 'repo');
const appDir = path.join(repo, 'apps', 'shop');
const cli = createTestCli({
commandFamilies: [
defineCommandFamily({ configSection: composerSection, commands: { probe } }),
],
commands: { probe },
loadConfig: () =>
Promise.resolve({
files: [
{ path: path.join(appDir, 'prisma.config.ts'), sections: {} },
{
path: path.join(repo, 'prisma.config.ts'),
sections: { composer: { configPath: './prisma-composer.config.ts' } },
},
],
diagnostics: [],
}),
});

const result = await cli.run(['probe', '--json'], { cwd: appDir });

expect(result.exitCode).toBe(0);
expect(result.presented?.data).toEqual({
configPath: path.join(repo, 'prisma-composer.config.ts'),
} satisfies ComposerSection);
});

Expand All @@ -219,7 +260,7 @@ describe('the composer section through the engine', () => {
}).run(['probe', '--json']);
expect(result.exitCode).toBe(0);
expect(result.presented?.data).toEqual({
configPath: './x/prisma-composer.config.ts',
configPath: path.resolve('/x/prisma-composer.config.ts'),
} satisfies ComposerSection);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ describe('runComposerCli() — the real Runtime, on a command that needs config'
// The section's own field, not just the fact that dev ran: `configPath`
// travels in the operation's SECOND argument, so a handler that read the
// section but forgot to pass it on would still satisfy the call count.
expect(double.calls.deps.dev[0]?.configPath).toBe('custom');
// The value arrives resolved against the config file that declared it
// (which the loader realpaths), not against the run's cwd.
expect(double.calls.deps.dev[0]?.configPath).toBe(path.join(fs.realpathSync(dir), 'custom'));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type { HostProcess, LoadedConfig } from '@prisma/cli-engine';
import { createRuntime, detectPackageManager } from '../runtime.ts';

const noConfig = (): Promise<LoadedConfig> =>
Promise.resolve({ path: '/app/prisma.config.ts', sections: {}, diagnostics: [] });
Promise.resolve({ files: [{ path: '/app/prisma.config.ts', sections: {} }], diagnostics: [] });

interface FakeHost extends HostProcess {
readonly listeners: Map<string, Set<() => void>>;
Expand Down Expand Up @@ -267,8 +267,7 @@ describe('createRuntime()', () => {

test('the loader is exposed as loadConfig, and its result is passed through untouched', async () => {
const config: LoadedConfig = {
path: '/app/prisma.config.ts',
sections: { composer: { configPath: 'x.ts' } },
files: [{ path: '/app/prisma.config.ts', sections: { composer: { configPath: 'x.ts' } } }],
diagnostics: [],
};
const runtime = createRuntime(fakeHost(), () => Promise.resolve(config));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,65 @@
* user's typo as a bug in composer.
*/
import { describe, expect, test } from 'bun:test';
import * as path from 'node:path';
import type { SectionProvenance } from '@prisma/cli-engine';
import { composerSection } from '../section.ts';

const DECLARING_FILE = path.resolve(path.sep, 'repo', 'prisma.config.ts');
Comment thread
wmadden-electric marked this conversation as resolved.

function provenance(file: string = DECLARING_FILE): SectionProvenance {
return { files: [file], keys: { configPath: file } };
}

describe('composerSection.validate()', () => {
test('absence is valid and yields an empty section', () => {
const result = composerSection.validate(undefined);
const result = composerSection.validate(undefined, provenance());
expect(result.ok).toBe(true);
expect(result.ok && result.value).toEqual({});
expect(result.diagnostics).toEqual([]);
});

test('an empty section is valid — every field is optional', () => {
const result = composerSection.validate({});
const result = composerSection.validate({}, provenance());
expect(result.ok).toBe(true);
expect(result.ok && result.value).toEqual({});
});

test('a configPath comes through', () => {
const result = composerSection.validate({ configPath: './app/prisma-composer.config.ts' });
/**
* The declaring file is `/repo/prisma.config.ts` and this test process runs
* somewhere else entirely, so a validator that resolved against the process
* cwd could not produce the expected path.
*/
test('a relative configPath resolves against the file that declared it', () => {
const result = composerSection.validate(
{ configPath: './app/prisma-composer.config.ts' },
provenance(),
);
expect(result.ok).toBe(true);
expect(result.ok && result.value).toEqual({
configPath: path.resolve(path.sep, 'repo', 'app', 'prisma-composer.config.ts'),
});
});

test('an absolute configPath passes through unchanged', () => {
const absolute = path.resolve(path.sep, 'elsewhere', 'prisma-composer.config.ts');
const result = composerSection.validate({ configPath: absolute }, provenance());
expect(result.ok).toBe(true);
expect(result.ok && result.value).toEqual({ configPath: './app/prisma-composer.config.ts' });
expect(result.ok && result.value).toEqual({ configPath: absolute });
});

test('a provenance without the configPath key fails instead of throwing', () => {
const result = composerSection.validate(
{ configPath: './x.ts' },
{ files: [DECLARING_FILE], keys: {} },
);
expect(result.ok).toBe(false);
expect(result.diagnostics[0]?.code).toBe('CONFIG.FIELD_INVALID');
});

test('a non-object section fails with a field diagnostic', () => {
for (const raw of ['nope', 42, [], null]) {
const result = composerSection.validate(raw);
const result = composerSection.validate(raw, provenance());
expect(result.ok).toBe(false);
expect(result.diagnostics[0]?.code).toBe('CONFIG.FIELD_INVALID');
expect(result.diagnostics[0]?.severity).toBe('error');
Expand All @@ -39,23 +73,25 @@ describe('composerSection.validate()', () => {

test('a non-string or empty configPath fails', () => {
for (const configPath of [42, '', {}, true]) {
const result = composerSection.validate({ configPath });
const result = composerSection.validate({ configPath }, provenance());
expect(result.ok).toBe(false);
expect(result.diagnostics[0]?.summary).toContain('configPath');
}
});

test('an unrecognized field warns but does not fail — a newer config still runs', () => {
const result = composerSection.validate({ configPath: 'x.ts', stage: 'prod' });
const result = composerSection.validate({ configPath: 'x.ts', stage: 'prod' }, provenance());
expect(result.ok).toBe(true);
expect(result.ok && result.value).toEqual({ configPath: 'x.ts' });
expect(result.ok && result.value).toEqual({
configPath: path.resolve(path.sep, 'repo', 'x.ts'),
});
expect(result.diagnostics).toHaveLength(1);
expect(result.diagnostics[0]?.severity).toBe('warn');
expect(result.diagnostics[0]?.summary).toContain('stage');
});

test('every diagnostic carries the nextActions the engine renders', () => {
const result = composerSection.validate({ configPath: 42 });
const result = composerSection.validate({ configPath: 42 }, provenance());
expect(result.diagnostics[0]?.nextActions.length).toBeGreaterThan(0);
expect(result.diagnostics[0]?.nextActions[0]?.label).toBeTruthy();
});
Expand Down Expand Up @@ -86,7 +122,7 @@ describe('composerSection.validate()', () => {
),
];
for (const raw of hostile) {
expect(() => composerSection.validate(raw)).not.toThrow();
expect(() => composerSection.validate(raw, provenance())).not.toThrow();
}
});

Expand All @@ -100,6 +136,7 @@ describe('composerSection.validate()', () => {
},
},
),
provenance(),
);
expect(result.ok).toBe(false);
expect(result.diagnostics[0]?.code).toBe('CONFIG.FIELD_INVALID');
Expand Down
40 changes: 35 additions & 5 deletions packages/0-framework/3-tooling/cli/src/family/section.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,19 @@
import {
type ConfigSection,
defineConfigSection,
resolveSectionPath,
type SectionProvenance,
type SectionValidation,
} from '@prisma/cli-engine';
import type { Diagnostic } from '@prisma/cli-engine/protocol';

export interface ComposerSection {
/**
* Path to `prisma-composer.config.ts`, absolute or relative to the process
* cwd. Absent — the common case — means composer searches upward from the
* command's entry argument, as it always has.
* Absolute path to `prisma-composer.config.ts`. In the config file it may
* be written relative; the validator resolves it against the file that
* declared it, so a root-declared path means the same file from every
* subdirectory. Absent — the common case — means composer searches upward
* from the command's entry argument, as it always has.
*/
readonly configPath?: string | undefined;
}
Expand All @@ -46,7 +50,7 @@ function diagnostic(spec: {
};
}

function validate(raw: unknown): SectionValidation<ComposerSection> {
function validate(raw: unknown, provenance: SectionProvenance): SectionValidation<ComposerSection> {
// Absence is normal and is the validator's to own: with no section,
// composer walks up from the entry exactly as it does today.
if (raw === undefined) return { ok: true, value: {}, diagnostics: [] };
Expand Down Expand Up @@ -103,6 +107,32 @@ function validate(raw: unknown): SectionValidation<ComposerSection> {
};
}

// A relative configPath means "relative to the file that declared it": a
// section written once at the repo root must name the same file from every
// subdirectory a command runs in. resolveSectionPath throws only when the
// key is missing from the provenance, which cannot happen for a key just
// read out of the section — but this validator must never throw, so even
// the impossible case becomes a diagnostic rather than an internal error.
let resolvedConfigPath: string | undefined;
if (configPath !== undefined) {
try {
resolvedConfigPath = resolveSectionPath(provenance, 'configPath', configPath);
} catch {
return {
ok: false,
diagnostics: [
diagnostic({
code: 'CONFIG.FIELD_INVALID',
severity: 'error',
summary:
'`composer.configPath` could not be resolved against the file that declared it.',
fix: 'Write `configPath` as an absolute path, or check the `composer` section of prisma.config.ts.',
}),
],
};
}
}

// An unrecognized field is a warning, not a failure: the section's fields
// grow by contract amendment, so a config written for a newer composer must
// still run on this one. A warning on an ok validation reaches stderr and
Expand All @@ -111,7 +141,7 @@ function validate(raw: unknown): SectionValidation<ComposerSection> {

return {
ok: true,
value: configPath === undefined ? {} : { configPath },
value: resolvedConfigPath === undefined ? {} : { configPath: resolvedConfigPath },
diagnostics: unknown.map((key) =>
diagnostic({
code: 'CONFIG.FIELD_UNKNOWN',
Expand Down
Loading
Loading