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
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Several designs were tried before this one, and each put the knowledge in the wr
- `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.
- Resolving a path or applying a default transforms the value, and arktype clones what it is given before it transforms it, so a config file's own objects are never written to. Its built-in clone rebuilds every object it reaches, which would hand a command a lookalike of the codec table or the contract serializer the file built. The engine supplies a clone that rebuilds plain objects and arrays and nothing else, so a class instance, a `Map` or a function reaches the command exactly as the file constructed it.
- 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`.

Expand Down
25 changes: 25 additions & 0 deletions packages/cli-engine/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# AGENTS.md — @prisma/cli-engine

## arktype

Config section schemas (`configSchema`, the `path` keyword, `validateSectionWithSchema` in `src/config-schema.ts`) are arktype. See ADR 0005 for the design.

### Read the arktype docs before building machinery

Before you write code that walks a schema, copies values around validation, or repairs arktype's output afterwards, read the arktype docs at https://arktype.io/docs, in particular Configuration, Morphs and Scopes. arktype has documented options for most problems that look like they need bespoke code. Never read arktype's compiled node tree (`schema.internal`, `.structure`, `.branches`, `.in`): it is not a public API.

This engine once shipped a copy-and-restore walk over that node tree, about 200 lines, to stop arktype rebuilding the objects a config file built. arktype's documented `clone` option replaced it with about twenty.

### What a transformation does to its input

- A morph is any transformation: `.pipe()`, `=>`, or a default value. When at least one morph applies anywhere in a value, arktype clones the whole input first and writes each result into the clone. Without morphs, validation returns the input itself.
- The default clone keeps prototypes but rebuilds every plain object and class instance. Identity is lost, private `#fields` are lost, and `===` checks against shared objects fail. Functions, `Map` and `Set` are kept as they are.
- The clone is the `clone` config option. `configScope` sets it to `copyPlainParts`, which copies only plain objects and arrays, so everything else a config file constructed reaches the command unchanged. Keep it: a family's config objects must not be rebuilt.
- `clone: false` writes into the caller's input and throws on frozen input. `structuredClone` throws on functions and drops prototypes. Neither is a substitute.

### Other behaviour the config schemas rely on

- A literal default runs its morph when the schema is defined; a thunk default runs it when the default is applied. A relative `path` default must be a thunk, `["path", "=", () => "./migrations"]`, so it resolves against the config file. `resolvePathValue` refuses a relative literal.
- In `.pipe((value, ctx) => ...)`, `ctx.path` is the key path of the value within the section. The `path` keyword uses its first key to find the config file that declared the value.
- A path given to `ctx.error` or `ctx.reject` inside a `.narrow()` is taken from the root, not from the narrowed value. Prepend `ctx.path` to report under the narrowed value, or better, declare the fields so arktype reports each one itself.
- Undeclared keys are kept by default, and missing keys are reported in alphabetical order.
2 changes: 1 addition & 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.6.0",
"version": "0.6.1",
"description": "The execution engine of the unified Prisma CLI.",
"type": "module",
"exports": {
Expand Down
70 changes: 52 additions & 18 deletions packages/cli-engine/src/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,22 @@ function resolvePathValue(value: string, path: readonly PropertyKey[]): string {
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)),
});
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),
),
},
{
clone: <original extends object>(original: original): original =>
copyPlainParts(original, new Map()) as original,
},
);

/**
* Declares the shape of a config section once. Definitions are arktype
Expand Down Expand Up @@ -99,17 +107,45 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
return prototype === Object.prototype || prototype === null;
}

/** Plain objects and arrays copied; anything else, functions included, by reference. */
function copyPlainData(value: unknown): unknown {
/**
* Before arktype applies a morph it clones the value, so resolving a path or
* applying a default never writes into what the caller passed in. Its own
* clone rebuilds every object it reaches. A config file's objects cannot
* survive that: a codec table, a contract serializer, anything whose
* behaviour lives in the instance rather than in its keys comes back as a
* lookalike that no longer works.
*
* So the scope above clones through arktype's `clone` option instead, and
* rebuilds only the plain objects and arrays a schema can write into.
* Everything else a config file constructed reaches the command as the file
* built it. `seen` carries the copies made so far, so a value that refers
* back to itself is copied once rather than followed forever.
*/
function copyPlainParts(value: unknown, seen: Map<object, unknown>): unknown {
if (!Array.isArray(value) && !isPlainObject(value)) {
return value;
}
const copied = seen.get(value);
if (copied !== undefined) {
return copied;
}
if (Array.isArray(value)) {
return value.map(copyPlainData);
const elements: unknown[] = [];
seen.set(value, elements);
for (const element of value) {
elements.push(copyPlainParts(element, seen));
}
return elements;
}
if (isPlainObject(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, copyPlainData(entry)]),
const entries: Record<PropertyKey, unknown> = {};
seen.set(value, entries);
for (const key of Reflect.ownKeys(value)) {
entries[key] = copyPlainParts(
(value as Record<PropertyKey, unknown>)[key],
seen,
);
}
return value;
return entries;
}

function fieldDiagnostic(
Expand Down Expand Up @@ -161,9 +197,7 @@ export function validateSectionWithSchema<S extends ConfigSchema>(
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));
const out: unknown = schema(raw === undefined ? {} : raw);
if (out instanceof type.errors) {
return {
ok: false,
Expand Down
Loading
Loading