Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions docs/architecture/adrs/0005-config-sections-declare-their-shape.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# ADR 0005 - Config sections declare their shape once, and the engine derives validation and path resolution from it

## Status

Accepted (operator, 2026-09-22).

## Decision

A command family declares the config section it accepts once, as a schema, and marks the fields that hold paths as `path`. The engine derives everything else from that declaration: structural validation, diagnostics that name the bad field and the file to fix, and the resolution of every `path` field against the config file that wrote it.

```ts
import { configSchema, defineConfigSection } from "@prisma/cli-engine";

export const ormConfigSection = defineConfigSection({
name: "orm",
schema: configSchema({
"contract?": {
source: { "inputs?": "path[]", load: "Function" },
"output?": "path",
},
"migrations?": { dir: ["path", "=", () => "./migrations"] },
}),
});
```

Given this file and this invocation:

```
exp/
sub/
prisma.config.ts # orm: { contract: { source: { inputs: ['./contract.prisma'] } } }
contract.prisma
```

```
cd exp && prisma contract emit --config ./sub/prisma.config.ts
```

the handler receives `contract.source.inputs` as `['/…/exp/sub/contract.prisma']`, `migrations.dir` as `/…/exp/sub/migrations`, and `baseDir` as `/…/exp/sub`, whatever directory the command ran from.

## Context

A relative path in a config file is relative to that file; there is no other reading an author could mean. But the engine handed a section over exactly as written and the command family did not know which file it came from, so families resolved against the working directory. The ORM's `contract emit --config ./sub/prisma.config.ts`, run from `exp`, looked for `./contract.prisma` in `exp` and failed; from `exp/sub` the same file worked.

Config discovery walks up to the repository root and merges files, most local value winning (`config-merge.ts`), and it records provenance: which file wrote each top-level key of the merged section. That is the information path resolution needs, per key, after the merge. What was missing was a way for the engine to know which fields are paths. A section was opaque to it.

Several designs were tried before this one, and each put the knowledge in the wrong place: telling the command which file was loaded (wrong under layering, where one section merges several files); asking authors to pass `import.meta` (a value the loader already has); a resolver function attached to each section and called per file (a protocol two parties must implement); publishing the file's directory to the file while it evaluates (ambient state, and it moved resolution into `defineConfig`, which the ORM's rules reserve for normalisation only). Declaring the shape removes the question: the family says which fields are paths, and the engine, which has the provenance, resolves them.

## How it works

- `configSchema` is arktype's `type` in a scope with one extra keyword, `path`: a string that validation resolves against the directory of the file that declared the value's top-level key, using the section's provenance. An absolute value passes through unchanged. Every other arktype feature (optional keys, defaults, unions, narrows for cross-field rules) is available as is.
- `defineConfigSection({ name, schema })` derives the section's validator. The engine runs it on the merged section value with its provenance, after discovery and merging, so defaults declared in the schema apply once to the merged value and never let one file's default shadow another file's authored value. A relative `path` default is declared as a thunk, `["path", "=", () => "./migrations"]`, which arktype evaluates and morphs when the default is applied, so it resolves against the nearest file like an authored value; a relative literal default would be stored unresolved, and is refused when the schema is defined.
- Each arktype error becomes a `CLI.CONFIG_FIELD_INVALID` diagnostic carrying `meta.section`, `meta.field`, and `where.path`, the file that declared the field's top-level key, so a chain of files still tells the user which one to fix.
- The validated value of a plain-object section carries `baseDir`, the directory of the nearest file declaring the section, for commands that need the project's location rather than one of its files. The key is reserved: a config file that writes it is refused.
- An absent section is validated as an empty object: a schema whose fields are all optional accepts it, and a required field is reported by name.
- `defineConfigSection({ name, validate })` remains for a section a schema cannot express; such a validator resolves its own path fields through `resolveSectionPath`.

The same declaration style is the contract for every product that mounts commands in the CLI: the ORM, Composer, and any future family declare their section with `configSchema` and get identical validation, diagnostics, and path semantics.

## Consequences

- A family with a schema writes no validation, resolution, or path-anchoring code. Its commands read absolute paths and `baseDir`.
- `@prisma/cli-engine` depends on arktype, which is what every product's schema is written in.
- A family whose section has `path` fields needs an engine that runs schemas. Under the exact peers of ADR 0004, a family release that adopts a schema moves its engine peer to the engine that ships this, and the engine ships first.
- Validation of one section is synchronous and self-contained; there is no ambient state to get wrong under concurrent loads.

## Alternatives considered

- **Command context carries the loaded file's path.** Fails as soon as one section merges several files: one path cannot anchor values from two directories.
- **Authors pass `import.meta` to the config helper.** Asks for a value the loader already knows, and because the helper runs before the outer call, still needs a deferred-resolution protocol.
- **A resolver function on the section, called by the loader per file.** Same result, through a protocol both the family and every loader must implement, plus per-layer resolution inside each loader.
- **A base directory published to the file while it evaluates.** Ambient state, needed an `AsyncLocalStorage` to survive concurrent loads, and moved resolution into the family's `defineConfig`, whose job is normalisation.
- **The engine resolves paths without a declaration.** It cannot: a section is opaque unless its owner declares which fields are paths. This ADR is that declaration.
1 change: 1 addition & 0 deletions docs/architecture/adrs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ long-term architecture boundaries.
| [0002](0002-workflow-command-model.md) | Accepted | Group commands by developer workflow using `prisma <group> <action>`. |
| [0003](0003-structured-output-and-errors.md) | Accepted | Treat structured output and stable error codes as public contracts. |
| [0004](0004-engine-version-pinning.md) | Accepted | One engine per install: product CLI packages declare the engine as an exact peer, product libraries carry no engine relationship. |
| [0005](0005-config-sections-declare-their-shape.md) | Accepted | A command family declares its config section once as a schema with `path` fields; the engine derives validation, diagnostics, and path resolution from it. |

## ADR Template

Expand Down
4 changes: 4 additions & 0 deletions docs/reference/error-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ A config file declares `parent` with a value that is neither `false` nor a path

A config file's explicit `parent` names a file that does not exist. Naming a parent is deliberate, so its absence is an error — unlike discovery, where finding no file is fine. Raised by the config loader while following the chain; the declaring file's absolute path is in `where.path` and the summary names the missing target. Meta: none.

### CLI.CONFIG_FIELD_INVALID

One field of a config section declared by schema failed that schema: the wrong type, a missing required field, or a value outside the declared set. The summary names the section and the field with arktype's description of the problem; `where.path` is the config file that declared the field's top-level key (so the file to fix on a chain), and `meta.section` and `meta.field` carry the names. Travels as an accompanying diagnostic under `CLI.CONFIG_SECTION_INVALID`. Raised by the engine's schema validation before the handler runs. Meta: `section`, `field`.

### CLI.CONFIG_SECTION_INVALID

The config section a command declared in `needs.config` failed its validator; the individual problems travel as accompanying diagnostics on the envelope, and the summary names the section and the config file actually read (respecting `--config`). Raised by the engine's needs check before the handler runs. Meta: none.
Expand Down
3 changes: 2 additions & 1 deletion packages/cli-engine/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@prisma/cli-engine",
"version": "0.5.0",
"version": "0.6.0",
"description": "The execution engine of the unified Prisma CLI.",
"type": "module",
"exports": {
Expand Down Expand Up @@ -48,6 +48,7 @@
"dependencies": {
"@clack/prompts": "1.5.0",
"@stricli/core": "1.3.0",
"arktype": "2.2.3",
"c12": "3.3.4",
"colorette": "^2.0.20",
"package-manager-detector": "1.8.0",
Expand Down
234 changes: 234 additions & 0 deletions packages/cli-engine/src/config-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import { dirname, isAbsolute, resolve } from "node:path";
import { type ArkErrors, scope, type Type, type } from "arktype";
import type { SectionProvenance } from "./config-merge";
import type { SectionValidation } from "./config-section";
import type { Diagnostic } from "./protocol";

/**
* The provenance of the section being validated, published for the
* duration of one synchronous schema run so the `path` keyword can
* resolve each value against the file that declared its top-level key.
*/
let current:
| { readonly name: string; readonly provenance: SectionProvenance }
| undefined;

/** The file that declared the top-level key a value sits under, else the nearest file. */
function declaringFile(
provenance: SectionProvenance,
path: readonly PropertyKey[],
): string | undefined {
const top = path[0];
return (
(typeof top === "string" ? provenance.keys[top] : undefined) ??
provenance.files[0]
);
}

/**
* A `path` value reaches this morph in two situations. During a section
* validation `current` names the provenance and the value resolves
* against the file that declared its top-level key. Outside one, arktype
* is evaluating a literal default while the schema is defined; a relative
* literal would be stored already resolved against nothing, so it is
* refused with the thunk form, which arktype evaluates and morphs at
* application time instead.
*/
function resolvePathValue(value: string, path: readonly PropertyKey[]): string {
if (isAbsolute(value)) {
return value;
}
if (current === undefined) {
throw new Error(
`@prisma/cli-engine: the relative 'path' default '${value}' must be declared as a thunk, ["path", "=", () => "..."], so it resolves against the config file when the default is applied`,
);
}
const file = declaringFile(current.provenance, path);
return file === undefined ? value : resolve(dirname(file), value);
}

const configScope = scope({
/**
* A string relative to the config file that wrote it. Validation turns it
* into an absolute path against that file's directory; an absolute value
* passes through unchanged.
*/
path: type("string").pipe((value, ctx) => resolvePathValue(value, ctx.path)),
});

/**
* Declares the shape of a config section once. Definitions are arktype
* definitions with one extra keyword, `path`, for a field holding a path
* relative to the config file. The declaration drives validation, the
* diagnostics that name the field and the file to fix, and path
* resolution; nothing else has to know which fields are paths.
*
* ```ts
* const toySchema = configSchema({
* "out?": "path",
* "inputs?": "path[]",
* dir: ["path", "=", () => "./migrations"],
* greeting: "string = 'hello'",
* });
* ```
*
* A relative `path` default is declared as a thunk, as above: arktype
* evaluates a thunk when the default is applied, so it resolves against
* the config file like an authored value. A relative literal default is
* refused when the schema is defined.
*/
export const configSchema: typeof configScope.type = configScope.type;

export type ConfigSchema<T = unknown> = Type<T, typeof configScope.t>;

/**
* The validated value a schema produces: its output type plus `baseDir`,
* the directory of the nearest file declaring the section, which the engine
* adds to a plain-object value. `baseDir` is reserved: a schema may not
* declare it and a config file may not write it.
*/
export type ConfigSchemaValue<S extends ConfigSchema> = S["infer"] & {
readonly baseDir?: string;
};

function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}

/** Plain objects and arrays copied; anything else, functions included, by reference. */
function copyPlainData(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(copyPlainData);
}
if (isPlainObject(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, copyPlainData(entry)]),
);
}
return value;
}

function fieldDiagnostic(
name: string,
error: ArkErrors[number],
provenance: SectionProvenance,
): Diagnostic {
const field = error.path.map(String).join(".");
const file = declaringFile(provenance, error.path);
return {
code: "CLI.CONFIG_FIELD_INVALID",
severity: "error",
summary: `In the '${name}' section, ${error.message}`,
nextActions: [
{
kind: "edit-file",
label:
file === undefined
? `Correct ${field === "" ? name : `${name}.${field}`} in prisma.config.ts`
: `Correct ${field === "" ? name : `${name}.${field}`} in ${file}`,
},
],
...(file === undefined ? {} : { where: { path: file } }),
meta: { section: name, field },
};
}

/**
* Validates one section's resolved value against its schema. An absent
* section is validated as an empty object, so a schema whose fields are
* all optional accepts it and a required field is reported by name. A
* plain-object value comes back frozen and carrying `baseDir`, the
* directory of the nearest file declaring the section. Never throws for
* any input: arktype reports problems as errors, and a `path` value is
* only ever resolved here.
*/
export function validateSectionWithSchema<S extends ConfigSchema>(
name: string,
schema: S,
raw: unknown,
provenance: SectionProvenance,
): SectionValidation<ConfigSchemaValue<S>> {
if (isPlainObject(raw) && Object.hasOwn(raw, "baseDir")) {
return {
ok: false,
diagnostics: [reservedKeyDiagnostic(name, "baseDir", provenance)],
};
}
const previous = current;
current = { name, provenance };
try {
// arktype applies defaults and morphs onto the objects it is handed, and
// the merged section value arrives frozen, so it validates a copy.
const out: unknown = schema(raw === undefined ? {} : copyPlainData(raw));
if (out instanceof type.errors) {
return {
ok: false,
diagnostics: [...out].map((error) =>
fieldDiagnostic(name, error, provenance),
),
};
}
const nearest = provenance.files[0];
const value =
isPlainObject(out) && nearest !== undefined
? Object.freeze({ ...out, baseDir: dirname(nearest) })
: out;
return { ok: true, value: value as ConfigSchemaValue<S>, diagnostics: [] };
} catch (cause) {
// A getter that throws when arktype reads it, or a morph that throws:
// config-file content, reported as such rather than as a bug.
return {
ok: false,
diagnostics: [unreadableDiagnostic(name, cause, provenance)],
};
} finally {
current = previous;
}
}

function reservedKeyDiagnostic(
name: string,
key: string,
provenance: SectionProvenance,
): Diagnostic {
const file = provenance.keys[key] ?? provenance.files[0];
return {
code: "CLI.CONFIG_FIELD_INVALID",
severity: "error",
summary: `In the '${name}' section, ${key} is reserved: the CLI records it when the section is loaded`,
nextActions: [
{
kind: "edit-file",
label: `Remove ${name}.${key} from ${file ?? "prisma.config.ts"}`,
},
],
...(file === undefined ? {} : { where: { path: file } }),
meta: { section: name, field: key },
};
}

function unreadableDiagnostic(
name: string,
cause: unknown,
provenance: SectionProvenance,
): Diagnostic {
const message = cause instanceof Error ? cause.message : String(cause);
const file = provenance.files[0];
return {
code: "CLI.CONFIG_FIELD_INVALID",
severity: "error",
summary: `The '${name}' section could not be validated: ${message.split("\n", 1)[0].trim()}`,
nextActions: [
{
kind: "edit-file",
label: `Export a plain configuration object for '${name}' in ${file ?? "prisma.config.ts"}`,
},
],
...(file === undefined ? {} : { where: { path: file } }),
meta: { section: name, field: "" },
};
}
Loading
Loading