Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
59ef4d0
feat(config): the orm section is declared once as a schema, and the e…
wmadden-electric Sep 22, 2026
91d2dd5
fix(config): descriptors are validated by predicate so the config fil…
wmadden-electric Sep 22, 2026
305921a
test(integration): a malformed orm field under the prisma CLI is a CL…
wmadden-electric Sep 22, 2026
a27ad1c
refactor(config): control descriptors are declared as plain arktype o…
wmadden-electric Sep 23, 2026
4042ad3
fix(cli-telemetry): the bundled comment no longer names an internal p…
wmadden-electric Sep 24, 2026
504ba9d
refactor(codecs): a codec without params has no paramsSchema
wmadden-electric Sep 24, 2026
768f068
docs(arktype): read the docs before building validation machinery, an…
wmadden-electric Sep 24, 2026
48ab41c
fix(codecs): a codec without params accepts empty typeParams
wmadden-electric Sep 24, 2026
be6f532
docs(config-loader): the README shows loadConfig's Result and per-fie…
wmadden-electric Sep 24, 2026
d1c605c
docs(codecs): the codec consumer interface allows a missing paramsSchema
wmadden-electric Sep 24, 2026
079059c
fix(config-loader): valid subsections are resolved when another subse…
wmadden-electric Sep 24, 2026
a4134fa
fix(config-loader): valid subsections are held to the relationship an…
wmadden-electric Sep 24, 2026
cf7cd3b
Merge main into feat/orm-config-schema
wmadden-electric Sep 24, 2026
9421c3f
docs(config): comments name the orm config schema instead of the dele…
wmadden-electric Sep 24, 2026
3d8eda2
feat(config): the orm schema declares its descriptors and connection …
wmadden-electric Sep 24, 2026
f7f2d9b
Merge main into feat/orm-config-schema
wmadden-electric Sep 24, 2026
285b2f4
chore(deps): pin @prisma/cli-engine 0.6.1
wmadden-electric Sep 24, 2026
4abcbb5
test(config-loader): cover unreadable sections, skipped relationships…
wmadden-electric Sep 24, 2026
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
13 changes: 13 additions & 0 deletions .agents/rules/arktype-usage.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ alwaysApply: false

This rulecard is intentionally short. For examples and edge cases, see `docs/reference/arktype-usage.md`.

## 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 seem to need bespoke code. Never read arktype's compiled node tree (`schema.internal`, `.structure`, `.branches`); it is not a public API.

## What a transformation does to its input

- A morph is any transformation: `.pipe()`, `=>`, or a default value. When any morph applies anywhere in a value, arktype first clones the whole input, then writes the results into the clone.
- The default clone keeps prototypes but rebuilds every plain object and class instance. Object 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, settable per scope (`scope(def, { clone })`) or per type (`.configure({ clone })`). `clone: false` writes into the input instead, and throws on frozen input.
- Do not make a value's behaviour depend on object identity if it may pass through validation. Represent "absent" as `undefined`, not as a shared stand-in object.

Details and examples: `docs/reference/arktype-usage.md` § "What a transformation does to its input".

## Rules of thumb

- Use optional keys via `'key?'` (not `string | undefined`).
Expand Down
29 changes: 14 additions & 15 deletions .agents/rules/config-validation-and-normalization.mdc
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
description: Config validation and normalization responsibilities (defineConfig normalizes, loader validates into section diagnostics)
description: Config validation and normalization responsibilities (defineConfig normalizes, the orm section schema validates and resolves paths, the loader reports per-section diagnostics)
globs: ["packages/1-framework/1-core/config/**", "packages/1-framework/3-tooling/config-loader/**", "packages/1-framework/3-tooling/cli/**", "**/prisma.config.ts"]
alwaysApply: false
---
Expand All @@ -14,12 +14,20 @@ Responsibilities are split across exactly three places. Keep them there.

The marker does not survive spreads: configs must export the `defineConfig` result directly. The loader rejects unmarked configs with `CONFIG.VERSION_MARKER_MISSING`.

## Loader — validate into per-section diagnostics
## The `orm` section schema — validate and resolve paths

`loadConfig` (in `@internal/config-loader`) returns `Result<{ config, diagnostics }, CliStructuredError>`:
The shape of the `orm` section is declared once, as `ormConfigSchema` in `@internal/config-loader/src/orm-section.ts`, with the CLI engine's `configSchema` (arktype plus a `path` keyword). Every structural rule lives there: descriptor fields, cross-descriptor `familyId`/`targetId` agreement, contract source shape, formatter values, and which fields are paths. Do not add a hand-written check next to it.

The engine derives everything from the declaration: validation, a `CLI.CONFIG_FIELD_INVALID` diagnostic per bad field naming the field and the file to fix, and the resolution of every `path` field against the config file that declared it (prisma-cli ADR 0005). The validated value has absolute paths, `migrations.dir` and `contract.output` defaulted, and `baseDir`, the directory of the nearest config file. A relative `path` default is declared as a thunk, `['path', '=', () => './migrations']`, never as a literal.

## Loader — evaluate, then report per-section diagnostics

`loadConfigFiles` (in `@internal/config-loader`) evaluates `prisma.config.ts` with c12 and returns each file on the chain with its sections as written. The ORM's bin hands those files to the engine, which validates the `orm` section with their provenance before a command runs.

`loadConfig` is the same evaluation plus the same schema validation, for the language server and the vite plugin, which read the config outside a command run. It returns `Result<{ config, diagnostics }, CliStructuredError>`:

- Failures that prevent evaluation entirely are the `Result` failure: `CONFIG.FILE_NOT_FOUND`, `CONFIG.EVALUATION_FAILED`, `CONFIG.VERSION_MARKER_MISSING`. Every command fails early with these.
- Structural problems inside an evaluated config never fail the load. `collectConfigIssues` (in `@internal/config/config-validation`) returns every problem, each tagged with the top-level section it concerns; the loader wraps them as `CONFIG.VALIDATION_FAILED` diagnostics with `meta.section` and `meta.field`.
- Structural problems inside an evaluated config never fail the load. Each schema diagnostic is wrapped as `CONFIG.VALIDATION_FAILED` with `meta.section` (the top-level subsection the field sits under) and `meta.field`.

## Commands — declare the sections they read

Expand All @@ -37,18 +45,9 @@ const config = configResult.value;

Command handlers use normalized values directly — they do NOT set defaults or normalize structure.

## Default Path Resolution

`defineConfig` supplies a default `contract.output` as a config-relative string. The loader then makes every config-relative path absolute by resolving it against the directory of the config file it loaded, using `pathe` (never `node:path`) so the result is the same on every platform:

```typescript
import { dirname, resolve } from 'pathe';

const configDir = dirname(loadedConfigFile);
const output = resolve(configDir, contract.output ?? `${DEFAULT_CONTRACT_SOURCE_DIR}/contract.json`);
```
## Path Resolution

Command handlers read the already-absolute value. They never re-resolve it and never substitute a default of their own.
Paths in the config are relative to the config file that wrote them, and the schema resolves them during validation using the engine's per-key provenance, so a parent config's `./migrations` resolves under the parent even when a child config extends it. Nothing resolves a config path after loading: command handlers read absolute values and `baseDir`, never re-resolve, and never anchor anything on `cwd`. The one path relative to the working directory is one typed on the command line, such as `--output-path`.

## Related Patterns

Expand Down
3 changes: 2 additions & 1 deletion docs/CLI Style Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` > `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 that wrote them, 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 <dir>` 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 <dir>` 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 that wrote it, 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=<URL>` or `config.db.connection`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ A codec author shipping an encrypted-JSON column type with cancellation and colu
import {
CodecDescriptorImpl,
CodecImpl,
voidParamsSchema,
type CodecInstanceContext,
} from '@internal/framework-components/codec';
import type { JsonValue } from '@internal/contract/types';
Expand Down Expand Up @@ -51,7 +50,7 @@ class EncryptedJsonDescriptor extends CodecDescriptorImpl<void> {
override readonly codecId = 'encrypted/json@1';
override readonly traits = [] as const;
override readonly targetTypes = ['jsonb'] as const;
override readonly paramsSchema = voidParamsSchema;
override readonly paramsSchema = undefined;
override readonly factory = () => (_ctx: CodecInstanceContext) => new EncryptedJsonCodec();
}
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ export interface CodecDescriptor<P = void> {
readonly traits: readonly CodecTrait[];
readonly targetTypes: readonly string[];
readonly meta?: CodecMeta;
readonly paramsSchema: StandardSchemaV1<P>;
readonly isParameterized: boolean;
readonly paramsSchema: StandardSchemaV1<P> | undefined; // undefined when P = void
readonly isParameterized: boolean; // paramsSchema !== undefined
readonly renderOutputType?: (params: P) => string | undefined;
readonly factory: (params: P) => (ctx: CodecInstanceContext) => Codec;
}
Expand All @@ -51,8 +51,8 @@ export abstract class CodecDescriptorImpl<P = void> implements CodecDescriptor<P
abstract readonly traits: readonly CodecTrait[];
abstract readonly targetTypes: readonly string[];
readonly meta?: CodecMeta;
abstract readonly paramsSchema: StandardSchemaV1<P>;
readonly isParameterized: boolean; // derived from `paramsSchema !== voidParamsSchema`
abstract readonly paramsSchema: StandardSchemaV1<P> | undefined; // undefined when P = void
readonly isParameterized: boolean; // derived from `paramsSchema !== undefined`
renderOutputType?(params: P): string | undefined;
abstract factory(params: P): (ctx: CodecInstanceContext) => Codec;
}
Expand Down Expand Up @@ -109,7 +109,7 @@ class PgTextDescriptor extends CodecDescriptorImpl<void> {
override readonly codecId = 'pg/text@1' as const;
override readonly traits = ['equality', 'order', 'textual'] as const;
override readonly targetTypes = ['text'] as const;
override readonly paramsSchema = voidParamsSchema;
override readonly paramsSchema = undefined;
override factory(): (ctx: CodecInstanceContext) => PgTextCodec {
const shared = new PgTextCodec(this);
return () => shared;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ Notes:

### Parameterized codecs and the unified descriptor

Parameterized codecs (e.g. `pg/vector@1`, `arktype/json@1`) ship a `CodecDescriptor<P>` with `paramsSchema: StandardSchemaV1<P>`, optional `renderOutputType: (params: P) => string`, and a curried `factory: (params: P) => (ctx: CodecInstanceContext) => Codec`. The descriptor unifies what previous iterations split across the codec object's optional `paramsSchema?` / `init?` / `renderOutputType?` slots and per-codec hand-rolled column-helper factories. Non-parameterized codecs are the degenerate `P = void` case authored as `class extends CodecDescriptorImpl<void>` with a constant factory that returns the same shared codec instance for every column (see [ADR 208](../adrs/ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md) for the unified authoring pattern). The descriptor map is the single read source for codec-id-keyed metadata across both shapes.
Parameterized codecs (e.g. `pg/vector@1`, `arktype/json@1`) ship a `CodecDescriptor<P>` with `paramsSchema: StandardSchemaV1<P>`, optional `renderOutputType: (params: P) => string`, and a curried `factory: (params: P) => (ctx: CodecInstanceContext) => Codec`. The descriptor unifies what previous iterations split across the codec object's optional `paramsSchema?` / `init?` / `renderOutputType?` slots and per-codec hand-rolled column-helper factories. Non-parameterized codecs are the degenerate `P = void` case authored as `class extends CodecDescriptorImpl<void>` with `paramsSchema = undefined` and a constant factory that returns the same shared codec instance for every column (see [ADR 208](../adrs/ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md) for the unified authoring pattern). The descriptor map is the single read source for codec-id-keyed metadata across both shapes.

The emit path consults `renderOutputType` via the framework's `CodecLookup`. Inline `field.type.typeParams` always wins when present; the framework only consults the resolver as a fallback for `typeRef`-backed columns whose inline `typeParams` is absent. The family-specific emitter (e.g. SQL) implements `EmissionSpi.resolveFieldTypeParams(modelName, fieldName, model, contract)` to walk `model.storage.fields → storage.namespaces.<ns>.entries['table'][tableName]` and return the named instance's `typeParams`, so typeRef-backed columns render with the same fidelity as inline-`typeParams` columns. Mongo and other families that don't use named storage types simply don't implement the optional hook.

Expand Down
49 changes: 49 additions & 0 deletions docs/reference/arktype-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,55 @@ const Schema = type.declare<Expected, { side: 'out' }>().type({

Reference: [Arktype Declare API](https://arktype.io/docs/declare)

## Read the Docs Before Building Machinery

Read the arktype docs (https://arktype.io/docs) before you write code that walks a schema, copies values around validation, or repairs arktype's output afterwards. The Configuration, Morphs and Scopes pages cover most of the problems that look like they need bespoke code. Do not read arktype's compiled node tree (`schema.internal`, `.structure`, `.branches`, `.in`): it is not a public API, and code built on it breaks on shapes you did not anticipate (unions, tuples, a root `.narrow()` combined with defaults).

An example of what goes wrong otherwise: the CLI engine's config schemas once copied the input along the schema's declared shape before validation and put the config file's objects back afterwards, by walking the compiled node tree twice. arktype's documented `clone` option, with the objects to keep declared in the schema, does the same job without reading arktype's internals.

## What a Transformation Does to Its Input

### arktype clones the input before any morph applies

A morph is any transformation: `.pipe()`, `=>`, or a default value (`'string = "x"'`, `['path', '=', () => './x']`). When at least one morph applies anywhere in a value, arktype clones the whole root input before it applies the morphs, then writes each result into the clone at the morph's path. Without morphs nothing is cloned and validation returns the input itself.

The clone comes from the `clone` config option. Its default, `deepClone` from `@ark/util`:

- keeps each object's prototype, but builds a new object with copies of its own properties;
- rebuilds plain objects, arrays and class instances all the way down;
- keeps functions, `Map`, `Set` and other built-ins as they are, and re-creates `Date`s.

So after validation:

- `===` against a shared object fails. Codec descriptors used to mark "takes no params" with a shared `voidParamsSchema` object and compare against it; a cloned descriptor carried a copy, and every codec looked parameterized.
- Private `#fields` are missing on the copy, so methods that read them throw.
- Anything keyed by object identity (`WeakMap`, `Set` of objects) no longer finds the value.

### Configuring the clone

The option can be set globally, per scope, or per type:

```typescript
const configScope = scope(
{ path: type('string').pipe((value, ctx) => resolveAgainstConfigFile(value, ctx.path)) },
{ clone: (original) => copyExceptDeclaredReferences(original) },
);
```

- A custom function replaces the clone. The CLI engine's config scope copies every value except the ones a section schema declares with `reference(schema)`: the config file's own objects, such as descriptors and clients. A `reference` check runs during validation, before arktype clones, and records the value it accepted; the clone keeps recorded values as they are. The schema says which values are references, rather than the clone guessing from a value's type.
- `clone: false` writes results into the caller's input. That mutates the caller's objects and throws on frozen input.
- `structuredClone` is not a safe substitute: it throws on functions and drops prototypes.

Reference: [Configuration](https://arktype.io/docs/configuration), "clone".

### Other behaviour worth knowing

- **Literal defaults run their morph when the schema is defined; thunk defaults run it when the default is applied.** `['path', '=', './x']` resolves `./x` once, with no validation context. `['path', '=', () => './x']` resolves it during each validation. Use a thunk when the morph needs validation-time context.
- **A morph receives its location.** In `.pipe((value, ctx) => ...)`, `ctx.path` is the key path of the value within the root. The engine's `path` keyword uses it to find which config file declared the value.
- **A path given inside a `.narrow()` is taken from the root.** `ctx.error({ path: ['id'], ... })` inside a narrow on `family` reports at `id`, not `family.id`. Prepend the narrowed value's location to report under it: `path: [...ctx.path, 'id']`. Better, declare the fields structurally, so arktype reports each bad field at its full path itself.
- **Undeclared keys are kept by default.** Declaring only the keys you check lets the rest of an object pass through unchanged.
- **Errors for missing keys come out in alphabetical key order**, not declaration order.

## Optional Keys

**❌ WRONG:**
Expand Down
Loading
Loading