diff --git a/.agents/rules/arktype-usage.mdc b/.agents/rules/arktype-usage.mdc index 9c222e08cb16..be996a4eedf2 100644 --- a/.agents/rules/arktype-usage.mdc +++ b/.agents/rules/arktype-usage.mdc @@ -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`). diff --git a/.agents/rules/config-validation-and-normalization.mdc b/.agents/rules/config-validation-and-normalization.mdc index 993e20169895..9feddd0e939a 100644 --- a/.agents/rules/config-validation-and-normalization.mdc +++ b/.agents/rules/config-validation-and-normalization.mdc @@ -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 --- @@ -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 @@ -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 diff --git a/docs/CLI Style Guide.md b/docs/CLI Style Guide.md index e1479284de79..07c33a859707 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 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 ` 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 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=` or `config.db.connection`. diff --git a/docs/architecture docs/adrs/ADR 207 - Codec call context per-query AbortSignal and column metadata.md b/docs/architecture docs/adrs/ADR 207 - Codec call context per-query AbortSignal and column metadata.md index e7f4aa664e0b..49bcb2f70f5d 100644 --- a/docs/architecture docs/adrs/ADR 207 - Codec call context per-query AbortSignal and column metadata.md +++ b/docs/architecture docs/adrs/ADR 207 - Codec call context per-query AbortSignal and column metadata.md @@ -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'; @@ -51,7 +50,7 @@ class EncryptedJsonDescriptor extends CodecDescriptorImpl { 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(); } ``` diff --git a/docs/architecture docs/adrs/ADR 208 - Higher-order codecs for parameterized types.md b/docs/architecture docs/adrs/ADR 208 - Higher-order codecs for parameterized types.md index bdc1e59a4281..d0df444ed31d 100644 --- a/docs/architecture docs/adrs/ADR 208 - Higher-order codecs for parameterized types.md +++ b/docs/architecture docs/adrs/ADR 208 - Higher-order codecs for parameterized types.md @@ -39,8 +39,8 @@ export interface CodecDescriptor

{ readonly traits: readonly CodecTrait[]; readonly targetTypes: readonly string[]; readonly meta?: CodecMeta; - readonly paramsSchema: StandardSchemaV1

; - readonly isParameterized: boolean; + readonly paramsSchema: StandardSchemaV1

| undefined; // undefined when P = void + readonly isParameterized: boolean; // paramsSchema !== undefined readonly renderOutputType?: (params: P) => string | undefined; readonly factory: (params: P) => (ctx: CodecInstanceContext) => Codec; } @@ -51,8 +51,8 @@ export abstract class CodecDescriptorImpl

implements CodecDescriptor

; - readonly isParameterized: boolean; // derived from `paramsSchema !== voidParamsSchema` + abstract readonly paramsSchema: StandardSchemaV1

| undefined; // undefined when P = void + readonly isParameterized: boolean; // derived from `paramsSchema !== undefined` renderOutputType?(params: P): string | undefined; abstract factory(params: P): (ctx: CodecInstanceContext) => Codec; } @@ -109,7 +109,7 @@ class PgTextDescriptor extends CodecDescriptorImpl { 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; diff --git a/docs/architecture docs/subsystems/2. Contract Emitter & Types.md b/docs/architecture docs/subsystems/2. Contract Emitter & Types.md index 5b271f282796..b369a8cb0d56 100644 --- a/docs/architecture docs/subsystems/2. Contract Emitter & Types.md +++ b/docs/architecture docs/subsystems/2. Contract Emitter & Types.md @@ -287,7 +287,7 @@ Notes: ### Parameterized codecs and the unified descriptor -Parameterized codecs (e.g. `pg/vector@1`, `arktype/json@1`) ship a `CodecDescriptor

` with `paramsSchema: StandardSchemaV1

`, 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` 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

` with `paramsSchema: StandardSchemaV1

`, 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` 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..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. diff --git a/docs/reference/arktype-usage.md b/docs/reference/arktype-usage.md index 5c36b0d4544a..334259dd75c8 100644 --- a/docs/reference/arktype-usage.md +++ b/docs/reference/arktype-usage.md @@ -60,6 +60,55 @@ const Schema = type.declare().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:** diff --git a/docs/reference/codec-authoring-guide.md b/docs/reference/codec-authoring-guide.md index 627706114fcf..648b885a5099 100644 --- a/docs/reference/codec-authoring-guide.md +++ b/docs/reference/codec-authoring-guide.md @@ -17,7 +17,6 @@ The framework imports live at `@internal/framework-components/codec`: - `dataType(id, spec)` — declares a data type with its casts; `DataType`, `DataTypeId`, `Cast`. - `ColumnHelperFor` / `ColumnHelperForStrict` — `satisfies` shapes for per-codec helpers. - `column(codecFactory, codecId, typeParams, nativeType)` — column-spec packager (`nativeType` is the database spelling for migrations and contract meta). -- `voidParamsSchema` — Standard Schema validator for `P = void` (non-parameterized codecs). - `Codec<...>`, `CodecDescriptor

`, `AnyCodecDescriptor` — consumer-facing interfaces (consumers depend on these; target-neutral authors extend the `*Impl` classes, while target-bound SQL authors use target-owned bases). SQL codecs use the same framework `CodecImpl` base. Their `encodeJson` and `decodeJson` methods define the codec's JSON-safe contract representation; `decode` remains responsible for the driver's ordinary column wire value. Keep that representation stable and mutually consistent, and keep `decodeJson` compatible with the values the current SQL JSON renderer returns for the codec. This distinction matters for types such as PostgreSQL `bytea` and extension-defined types whose values inside database-produced JSON may differ from their normal driver representation. @@ -57,7 +56,6 @@ import { CodecImpl, type ColumnHelperFor, column, - voidParamsSchema, } from '@internal/framework-components/codec'; import type { ProjectionExpr } from '@internal/sql-relational-core/ast'; import { PostgresCodecDescriptor } from '@internal/target-postgres/codec-descriptor'; @@ -91,7 +89,7 @@ class PgTextDescriptor extends PostgresCodecDescriptor { 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; @@ -500,7 +498,7 @@ export class SqlTextDescriptor extends CodecDescriptorTemplateImpl { override readonly codecId = SQL_TEXT_CODEC_ID; override readonly traits = ['equality', 'order', 'textual'] as const; override readonly targetTypes = ['text'] as const; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => SqlTextCodec { return () => new SqlTextCodec(this); } diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index cc3395568a03..6c6e95f9c76f 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -67,7 +67,7 @@ Reserved: `db verify` needs `db.queryRunnerFactory` in `prisma.config.ts` and it ### CONFIG.VALIDATION_FAILED -`prisma.config.ts` loaded but a config section is missing or malformed. The config loader validates the evaluated config and returns one diagnostic per problem, each tagged with the top-level config section it concerns (`meta.section`: `family`, `target`, `adapter`, `driver`, `extensions`, `db`, `contract`, `migrations`, or `formatter`); a command fails with the diagnostic (exit `2`) only when it reads that section. Also raised by framework-component resolution for fields like `frameworkComponents[]`, `frameworkComponents[].kind`/`familyId`/`targetId`, `contract.targetFamily`, and `contract.target`, and by contract-path resolution when `config.contract.output` is absent (those sites carry no `section`). Payload: `field` (loader diagnostics), `section` (loader diagnostics; optional elsewhere). +`prisma.config.ts` loaded but a config section is missing or malformed, as seen by the ORM's own config loader (the language server, the vite plugin, and any tool that calls `loadConfig` outside a command run). The loader validates the `orm` section against its schema and returns one diagnostic per problem, each tagged with the top-level subsection it concerns (`meta.section`: `family`, `target`, `adapter`, `driver`, `extensions`, `db`, `contract`, `migrations`, or `formatter`); a command fails with the diagnostic (exit `2`) only when it reads that section. Under the `prisma` CLI the same problems are reported by the engine instead, as `CLI.CONFIG_FIELD_INVALID` diagnostics (one per field, `meta.section: 'orm'`, `meta.field` the dotted path, `where.path` the config file that declared the field) accompanying `CLI.CONFIG_SECTION_INVALID`. Also raised by framework-component resolution for fields like `frameworkComponents[]`, `frameworkComponents[].kind`/`familyId`/`targetId`, `contract.targetFamily`, and `contract.target`, and by contract-path resolution when `config.contract.output` is absent (those sites carry no `section`). Payload: `field` (loader diagnostics), `section` (loader diagnostics; optional elsewhere). ### CONFIG.VERSION_MARKER_MISSING diff --git a/packages/0-config/tsdown/shell-testkit.ts b/packages/0-config/tsdown/shell-testkit.ts index 5ee17901e729..5222cfdee6dc 100644 --- a/packages/0-config/tsdown/shell-testkit.ts +++ b/packages/0-config/tsdown/shell-testkit.ts @@ -332,7 +332,6 @@ export const knownInternalNamesInDist: readonly string[] = [ '@internal/cli-telemetry/sender', '@internal/cli/migration-cli', '@internal/config', - '@internal/config/config-validation', '@internal/contract/types', '@internal/driver-mongo/control', '@internal/emitter', diff --git a/packages/1-framework/1-core/config/package.json b/packages/1-framework/1-core/config/package.json index 6af9e8a929f5..7a7990af48e6 100644 --- a/packages/1-framework/1-core/config/package.json +++ b/packages/1-framework/1-core/config/package.json @@ -43,7 +43,6 @@ ], "exports": { "./config-types": "./dist/config-types.mjs", - "./config-validation": "./dist/config-validation.mjs", "./package.json": "./package.json" }, "engines": { 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 61d459484f27..796ef4b1f35d 100644 --- a/packages/1-framework/1-core/config/src/config-types.ts +++ b/packages/1-framework/1-core/config/src/config-types.ts @@ -24,7 +24,7 @@ export interface ContractConfig { */ readonly source: ContractSourceProvider; /** - * Path to contract.json artifact. Providers that know an input path (PSL, + * 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`). @@ -107,10 +107,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, recorded when + * the section is validated; every relative path above was resolved against + * the file that declared it. Commands that need the project's location + * start here. + */ + readonly baseDir?: string; } /** diff --git a/packages/1-framework/1-core/config/src/config-validation.ts b/packages/1-framework/1-core/config/src/config-validation.ts deleted file mode 100644 index ad42a501367a..000000000000 --- a/packages/1-framework/1-core/config/src/config-validation.ts +++ /dev/null @@ -1,348 +0,0 @@ -/** - * Top-level config sections. Diagnostics carry the section they concern so - * commands can fail on the sections they read and ignore the rest. - */ -export type ConfigSection = - | 'family' - | 'target' - | 'adapter' - | 'driver' - | 'extensions' - | 'db' - | 'contract' - | 'migrations' - | 'formatter'; - -export interface ConfigValidationIssue { - readonly section: ConfigSection; - readonly field: string; - readonly message: string; -} - -class IssueCollector { - readonly issues: ConfigValidationIssue[] = []; - - add(section: ConfigSection, field: string, message?: string): void { - this.issues.push({ - section, - field, - message: message ?? `Config must have a "${field}" field`, - }); - } -} - -function isObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -function validateFamily( - config: Record, - issues: IssueCollector, -): string | undefined { - const family = config['family']; - if (!family) { - issues.add('family', 'family'); - return undefined; - } - if (!isObject(family)) { - issues.add('family', 'family', 'Config.family must be an object'); - return undefined; - } - if (family['kind'] !== 'family') { - issues.add('family', 'family.kind', 'Config.family must have kind: "family"'); - } - if (typeof family['id'] !== 'string') { - issues.add('family', 'family.id', 'Config.family must have id: string'); - } - if (typeof family['familyId'] !== 'string') { - issues.add('family', 'family.familyId', 'Config.family must have familyId: string'); - } - if (typeof family['version'] !== 'string') { - issues.add('family', 'family.version', 'Config.family must have version: string'); - } - if (!family['emission'] || typeof family['emission'] !== 'object') { - issues.add('family', 'family.emission', 'Config.family must have emission: EmissionSpi'); - } - if (typeof family['create'] !== 'function') { - issues.add('family', 'family.create', 'Config.family must have create: function'); - } - return typeof family['familyId'] === 'string' ? family['familyId'] : undefined; -} - -interface DescriptorExpectations { - readonly section: ConfigSection; - readonly kind: string; - readonly label: string; - readonly familyId: string | undefined; - readonly targetId: string | undefined; -} - -function validateTargetLikeDescriptor( - descriptor: Record, - fieldPrefix: string, - expectations: DescriptorExpectations, - issues: IssueCollector, -): void { - const { section, kind, label, familyId, targetId } = expectations; - const describe = (suffix: string, requirement: string) => - issues.add(section, `${fieldPrefix}.${suffix}`, `${label} must have ${requirement}`); - - if (descriptor['kind'] !== kind) { - describe('kind', `kind: "${kind}"`); - } - if (typeof descriptor['id'] !== 'string') { - describe('id', 'id: string'); - } - if (typeof descriptor['familyId'] !== 'string') { - describe('familyId', 'familyId: string'); - } else if (familyId !== undefined && descriptor['familyId'] !== familyId) { - issues.add( - section, - `${fieldPrefix}.familyId`, - `Config.${fieldPrefix}.familyId must match Config.family.familyId (expected: ${familyId}, got: ${descriptor['familyId']})`, - ); - } - if (typeof descriptor['version'] !== 'string') { - describe('version', 'version: string'); - } - if (typeof descriptor['targetId'] !== 'string') { - describe('targetId', 'targetId: string'); - } else if (targetId !== undefined && descriptor['targetId'] !== targetId) { - issues.add( - section, - `${fieldPrefix}.targetId`, - `Config.${fieldPrefix}.targetId must match Config.target.targetId (expected: ${targetId}, got: ${descriptor['targetId']})`, - ); - } - if (typeof descriptor['create'] !== 'function') { - describe('create', 'create: function'); - } -} - -function validateTarget( - config: Record, - familyId: string | undefined, - issues: IssueCollector, -): string | undefined { - const target = config['target']; - if (!target) { - issues.add('target', 'target'); - return undefined; - } - if (!isObject(target)) { - issues.add('target', 'target', 'Config.target must be an object'); - return undefined; - } - validateTargetLikeDescriptor( - target, - 'target', - { section: 'target', kind: 'target', label: 'Config.target', familyId, targetId: undefined }, - issues, - ); - return typeof target['targetId'] === 'string' ? target['targetId'] : undefined; -} - -function validateExtensions( - config: Record, - familyId: string | undefined, - targetId: string | undefined, - issues: IssueCollector, -): void { - if (config['extensionPacks'] !== undefined) { - issues.add( - 'extensions', - 'extensionPacks', - 'Config.extensionPacks is no longer supported; rename it to Config.extensions', - ); - } - - if (config['extensions'] === undefined) { - return; - } - if (!Array.isArray(config['extensions'])) { - issues.add('extensions', 'extensions', 'Config.extensions must be an array'); - return; - } - for (const ext of config['extensions']) { - if (!isObject(ext)) { - issues.add( - 'extensions', - 'extensions[]', - 'Config.extensions must contain ControlExtensionDescriptor objects', - ); - continue; - } - validateTargetLikeDescriptor( - ext, - 'extensions[]', - { - section: 'extensions', - kind: 'extension', - label: 'Config.extensions items', - familyId, - targetId, - }, - issues, - ); - } -} - -function validateContract(config: Record, issues: IssueCollector): void { - if (config['contract'] === undefined) { - return; - } - if (!isObject(config['contract'])) { - issues.add('contract', 'contract', 'Config.contract must be an object'); - return; - } - const contract = config['contract']; - - if (!Object.hasOwn(contract, 'source')) { - issues.add( - 'contract', - 'contract.source', - 'Config.contract.source is required when contract is provided', - ); - return; - } - - const source = contract['source']; - if (!isObject(source)) { - issues.add('contract', 'contract.source', 'Config.contract.source must be a provider object'); - return; - } - - // Entries are glob patterns (a wildcard-free entry is the degenerate glob, - // a literal path); this only checks the array-of-strings shape, not glob - // syntax. - const inputs = Object.hasOwn(source, 'inputs') ? source['inputs'] : undefined; - if (inputs !== undefined) { - if (!Array.isArray(inputs)) { - issues.add( - 'contract', - 'contract.source.inputs', - 'Config.contract.source.inputs must be an array of strings when provided', - ); - } else if (inputs.some((input) => typeof input !== 'string')) { - issues.add( - 'contract', - 'contract.source.inputs[]', - 'Config.contract.source.inputs must contain only strings', - ); - } - } - - const format = Object.hasOwn(source, 'format') ? source['format'] : undefined; - if (format !== undefined && typeof format !== 'string') { - issues.add( - 'contract', - 'contract.source.format', - 'Config.contract.source.format must be a string when provided', - ); - } - - if (!Object.hasOwn(source, 'load') || typeof source['load'] !== 'function') { - issues.add( - 'contract', - 'contract.source.load', - 'Config.contract.source.load must be a function', - ); - } - - const output = Object.hasOwn(contract, 'output') ? contract['output'] : undefined; - if (output !== undefined && typeof output !== 'string') { - issues.add( - 'contract', - 'contract.output', - 'Config.contract.output must be a string when provided', - ); - } -} - -function validateMigrations(config: Record, issues: IssueCollector): void { - if (config['migrations'] === undefined) { - return; - } - if (!isObject(config['migrations'])) { - issues.add('migrations', 'migrations', 'Config.migrations must be an object'); - return; - } - const dir = config['migrations']['dir']; - if (dir !== undefined && typeof dir !== 'string') { - issues.add('migrations', 'migrations.dir', 'Config.migrations.dir must be a string'); - } -} - -function validateFormatter(config: Record, issues: IssueCollector): void { - if (config['formatter'] === undefined) { - return; - } - if (!isObject(config['formatter'])) { - issues.add('formatter', 'formatter', 'Config.formatter must be an object'); - return; - } - const { indent, newline } = config['formatter']; - const indentIsValid = - indent === undefined || - indent === 'tab' || - (typeof indent === 'number' && Number.isInteger(indent) && indent >= 1); - if (!indentIsValid) { - issues.add( - 'formatter', - 'formatter.indent', - 'Config.formatter.indent must be an integer >= 1 or "tab"', - ); - } - if (newline !== undefined && newline !== 'LF' && newline !== 'CRLF') { - issues.add('formatter', 'formatter.newline', 'Config.formatter.newline must be "LF" or "CRLF"'); - } -} - -/** - * Validates the config structure and returns every problem found, each tagged - * with the config section it concerns. Pure validation logic with no file I/O - * or CLI awareness; it never throws. - */ -export function collectConfigIssues( - config: Record, -): readonly ConfigValidationIssue[] { - const issues = new IssueCollector(); - - const familyId = validateFamily(config, issues); - const targetId = validateTarget(config, familyId, issues); - - const adapter = config['adapter']; - if (!adapter) { - issues.add('adapter', 'adapter'); - } else if (!isObject(adapter)) { - issues.add('adapter', 'adapter', 'Config.adapter must be an object'); - } else { - validateTargetLikeDescriptor( - adapter, - 'adapter', - { section: 'adapter', kind: 'adapter', label: 'Config.adapter', familyId, targetId }, - issues, - ); - } - - const driver = config['driver']; - if (driver !== undefined) { - if (!isObject(driver)) { - issues.add('driver', 'driver', 'Config.driver must be an object'); - } else { - validateTargetLikeDescriptor( - driver, - 'driver', - { section: 'driver', kind: 'driver', label: 'Config.driver', familyId, targetId }, - issues, - ); - } - } - - validateExtensions(config, familyId, targetId, issues); - validateContract(config, issues); - validateMigrations(config, issues); - validateFormatter(config, issues); - - return issues.issues; -} diff --git a/packages/1-framework/1-core/config/src/exports/config-validation.ts b/packages/1-framework/1-core/config/src/exports/config-validation.ts deleted file mode 100644 index 43e07447cacb..000000000000 --- a/packages/1-framework/1-core/config/src/exports/config-validation.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { ConfigSection, ConfigValidationIssue } from '../config-validation'; -export { collectConfigIssues } from '../config-validation'; 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 deleted file mode 100644 index 42f4c0f04996..000000000000 --- a/packages/1-framework/1-core/config/test/config-validation.test.ts +++ /dev/null @@ -1,389 +0,0 @@ -import type { Contract } from '@internal/contract/types'; -import { ok } from '@internal/utils/result'; -import { describe, expect, it, vi } from 'vitest'; -import { collectConfigIssues } from '../src/config-validation'; - -function createSourceProvider(overrides: Record = {}) { - return { - load: async () => ok({ targetFamily: 'sql' } as Contract), - ...overrides, - }; -} - -type RawConfigOverrides = Record & { - family?: Record; - target?: Record; - adapter?: Record; - driver?: Record; -}; - -function createValidRawConfig(overrides: RawConfigOverrides = {}) { - const { family, target, adapter, driver, ...rest } = overrides; - - return { - family: { - kind: 'family', - id: 'sql', - familyId: 'sql', - version: '0.0.1', - manifest: {}, - emission: {}, - create: vi.fn(), - ...(family as Record | undefined), - }, - target: { - kind: 'target', - familyId: 'sql', - targetId: 'postgres', - id: 'postgres', - version: '0.0.1', - manifest: {}, - create: vi.fn(), - ...(target as Record | undefined), - }, - adapter: { - kind: 'adapter', - familyId: 'sql', - targetId: 'postgres', - id: 'postgres', - version: '0.0.1', - manifest: {}, - create: vi.fn(), - ...(adapter as Record | undefined), - }, - driver: { - kind: 'driver', - familyId: 'sql', - targetId: 'postgres', - id: 'postgres', - version: '0.0.1', - manifest: {}, - create: vi.fn(), - ...(driver as Record | undefined), - }, - ...rest, - }; -} - -function expectIssue(config: Record, field: string, section: string) { - const issues = collectConfigIssues(config); - expect(issues).toContainEqual(expect.objectContaining({ field, section })); -} - -describe('collectConfigIssues', () => { - it('returns no issues for a valid config', () => { - expect(collectConfigIssues(createValidRawConfig())).toEqual([]); - }); - - it('reports each missing top-level descriptor under its own section', () => { - const issues = collectConfigIssues({}); - expect(issues).toEqual([ - { section: 'family', field: 'family', message: 'Config must have a "family" field' }, - { section: 'target', field: 'target', message: 'Config must have a "target" field' }, - { section: 'adapter', field: 'adapter', message: 'Config must have a "adapter" field' }, - ]); - }); - - it('reports one object-type issue per section holding a non-object', () => { - const issues = collectConfigIssues({ - ...createValidRawConfig(), - family: 'sql', - target: 'postgres', - adapter: 42, - driver: true, - }); - - expect(issues).toEqual([ - { section: 'family', field: 'family', message: 'Config.family must be an object' }, - { section: 'target', field: 'target', message: 'Config.target must be an object' }, - { section: 'adapter', field: 'adapter', message: 'Config.adapter must be an object' }, - { section: 'driver', field: 'driver', message: 'Config.driver must be an object' }, - ]); - }); - - it('reports a broken family without hiding issues in other sections', () => { - const issues = collectConfigIssues( - createValidRawConfig({ - family: { kind: 'invalid' }, - target: { targetId: 123 }, - }), - ); - - expect(issues).toContainEqual(expect.objectContaining({ section: 'family' })); - expect(issues).toContainEqual( - expect.objectContaining({ section: 'target', field: 'target.targetId' }), - ); - }); - - it('collects family descriptor field issues', () => { - expectIssue(createValidRawConfig({ family: { kind: 'invalid' } }), 'family.kind', 'family'); - expectIssue(createValidRawConfig({ family: { id: 123 } }), 'family.id', 'family'); - expectIssue(createValidRawConfig({ family: { familyId: 123 } }), 'family.familyId', 'family'); - expectIssue(createValidRawConfig({ family: { version: 123 } }), 'family.version', 'family'); - expectIssue( - createValidRawConfig({ family: { emission: undefined } }), - 'family.emission', - 'family', - ); - expectIssue(createValidRawConfig({ family: { create: 'invalid' } }), 'family.create', 'family'); - }); - - it('collects target descriptor field issues', () => { - expectIssue(createValidRawConfig({ target: { kind: 'invalid' } }), 'target.kind', 'target'); - expectIssue(createValidRawConfig({ target: { id: 123 } }), 'target.id', 'target'); - expectIssue(createValidRawConfig({ target: { familyId: 123 } }), 'target.familyId', 'target'); - expectIssue(createValidRawConfig({ target: { version: 123 } }), 'target.version', 'target'); - expectIssue(createValidRawConfig({ target: { targetId: 123 } }), 'target.targetId', 'target'); - expectIssue(createValidRawConfig({ target: { create: 'invalid' } }), 'target.create', 'target'); - }); - - it('reports a family mismatch on the target section', () => { - expectIssue( - createValidRawConfig({ - family: { familyId: 'sql' }, - target: { familyId: 'document' }, - }), - 'target.familyId', - 'target', - ); - }); - - it('skips the family mismatch check when the family id itself is invalid', () => { - const issues = collectConfigIssues( - createValidRawConfig({ - family: { familyId: 123 }, - target: { familyId: 'document' }, - }), - ); - - expect(issues).toContainEqual(expect.objectContaining({ field: 'family.familyId' })); - expect(issues).not.toContainEqual(expect.objectContaining({ field: 'target.familyId' })); - }); - - it('collects adapter descriptor field and compatibility issues', () => { - expectIssue(createValidRawConfig({ adapter: { kind: 'invalid' } }), 'adapter.kind', 'adapter'); - expectIssue(createValidRawConfig({ adapter: { id: 123 } }), 'adapter.id', 'adapter'); - expectIssue( - createValidRawConfig({ adapter: { familyId: 'document' } }), - 'adapter.familyId', - 'adapter', - ); - expectIssue( - createValidRawConfig({ adapter: { targetId: 'mysql' } }), - 'adapter.targetId', - 'adapter', - ); - expectIssue( - createValidRawConfig({ adapter: { create: 'invalid' } }), - 'adapter.create', - 'adapter', - ); - }); - - it('collects driver descriptor field and compatibility issues', () => { - expectIssue(createValidRawConfig({ driver: { kind: 'invalid' } }), 'driver.kind', 'driver'); - expectIssue(createValidRawConfig({ driver: { id: 123 } }), 'driver.id', 'driver'); - expectIssue(createValidRawConfig({ driver: { version: 123 } }), 'driver.version', 'driver'); - expectIssue( - createValidRawConfig({ driver: { familyId: 'document' } }), - 'driver.familyId', - 'driver', - ); - expectIssue( - createValidRawConfig({ driver: { targetId: 'mysql' } }), - 'driver.targetId', - 'driver', - ); - expectIssue(createValidRawConfig({ driver: { create: 'invalid' } }), 'driver.create', 'driver'); - }); - - it('collects extension issues under the extensions section', () => { - expectIssue(createValidRawConfig({ extensions: 'invalid' }), 'extensions', 'extensions'); - expectIssue(createValidRawConfig({ extensions: ['invalid'] }), 'extensions[]', 'extensions'); - expectIssue( - createValidRawConfig({ - extensions: [{ kind: 'invalid', id: 'ext', familyId: 'sql', targetId: 'postgres' }], - }), - 'extensions[].kind', - 'extensions', - ); - expectIssue( - createValidRawConfig({ - extensions: [ - { - kind: 'extension', - id: 'ext', - familyId: 'sql', - targetId: 'mysql', - version: '0.0.1', - create: vi.fn(), - }, - ], - }), - 'extensions[].targetId', - 'extensions', - ); - }); - - it('reports the removed extensionPacks key under the extensions section', () => { - const issues = collectConfigIssues(createValidRawConfig({ extensionPacks: [] })); - expect(issues).toContainEqual( - expect.objectContaining({ - section: 'extensions', - field: 'extensionPacks', - message: expect.stringContaining('rename it to Config.extensions'), - }), - ); - }); - - it('collects contract issues under the contract section', () => { - expectIssue(createValidRawConfig({ contract: 'invalid' }), 'contract', 'contract'); - expectIssue(createValidRawConfig({ contract: {} }), 'contract.source', 'contract'); - expectIssue( - createValidRawConfig({ contract: { source: {} } }), - 'contract.source.load', - 'contract', - ); - expectIssue( - createValidRawConfig({ contract: { source: { load: 'invalid' } } }), - 'contract.source.load', - 'contract', - ); - expectIssue( - createValidRawConfig({ - contract: { source: createSourceProvider({ inputs: 123 }) }, - }), - 'contract.source.inputs', - 'contract', - ); - expectIssue( - createValidRawConfig({ - contract: { source: createSourceProvider({ inputs: ['valid', 123] }) }, - }), - 'contract.source.inputs[]', - 'contract', - ); - expectIssue( - createValidRawConfig({ - contract: { source: createSourceProvider({ format: 123 }) }, - }), - 'contract.source.format', - 'contract', - ); - expectIssue( - createValidRawConfig({ - contract: { source: createSourceProvider(), output: 123 }, - }), - 'contract.output', - 'contract', - ); - }); - - it('ignores inherited contract keys', () => { - const inheritedSourceContract = Object.create({ - source: createSourceProvider(), - }) as Record; - expectIssue( - createValidRawConfig({ contract: inheritedSourceContract }), - 'contract.source', - 'contract', - ); - - const inheritedOutputContract = Object.create( - { output: 123 }, - { source: { value: createSourceProvider(), enumerable: true } }, - ) as Record; - expect( - collectConfigIssues(createValidRawConfig({ contract: inheritedOutputContract })), - ).toEqual([]); - }); - - it('accepts inputs entries that are glob patterns, not just literal paths', () => { - const issues = collectConfigIssues( - createValidRawConfig({ - contract: { - source: createSourceProvider({ inputs: ['./prisma/**/*.prisma', './extra.prisma'] }), - }, - }), - ); - expect(issues).toEqual([]); - }); - - it('accepts a provider with an unknown format string and extra keys', () => { - const issues = collectConfigIssues( - createValidRawConfig({ - contract: { - source: createSourceProvider({ - format: 'made-up-format', - inputs: ['./schema.prisma'], - interpret: () => [], - }), - }, - }), - ); - expect(issues).toEqual([]); - }); - - it('collects migrations issues under the migrations section', () => { - 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([]); - }); - - it('collects formatter issues under the formatter section', () => { - expectIssue(createValidRawConfig({ formatter: 'invalid' }), 'formatter', 'formatter'); - expectIssue( - createValidRawConfig({ formatter: { indent: 0 } }), - 'formatter.indent', - 'formatter', - ); - expectIssue( - createValidRawConfig({ formatter: { indent: -1 } }), - 'formatter.indent', - 'formatter', - ); - expectIssue( - createValidRawConfig({ formatter: { indent: 1.5 } }), - 'formatter.indent', - 'formatter', - ); - expectIssue( - createValidRawConfig({ formatter: { indent: 'spaces' } }), - 'formatter.indent', - 'formatter', - ); - expectIssue( - createValidRawConfig({ formatter: { newline: 'crlf' } }), - 'formatter.newline', - 'formatter', - ); - expect(collectConfigIssues(createValidRawConfig({ formatter: {} }))).toEqual([]); - expect( - collectConfigIssues(createValidRawConfig({ formatter: { indent: 4, newline: 'CRLF' } })), - ).toEqual([]); - expect(collectConfigIssues(createValidRawConfig({ formatter: { indent: 'tab' } }))).toEqual([]); - }); - - it('accepts descriptors without manifest fields', () => { - const config = createValidRawConfig({ - family: { manifest: undefined }, - target: { manifest: undefined }, - adapter: { manifest: undefined }, - driver: { manifest: undefined }, - extensions: [ - { - kind: 'extension', - id: 'pgvector', - familyId: 'sql', - targetId: 'postgres', - version: '0.0.1', - manifest: undefined, - create: vi.fn(), - }, - ], - }); - - expect(collectConfigIssues(config)).toEqual([]); - }); -}); diff --git a/packages/1-framework/1-core/config/tsdown.config.ts b/packages/1-framework/1-core/config/tsdown.config.ts index fa2737cff6d3..d07d443de287 100644 --- a/packages/1-framework/1-core/config/tsdown.config.ts +++ b/packages/1-framework/1-core/config/tsdown.config.ts @@ -1,5 +1,5 @@ import { defineConfig } from '@repo/tsdown'; export default defineConfig({ - entry: ['src/exports/config-types.ts', 'src/exports/config-validation.ts'], + entry: ['src/exports/config-types.ts'], }); diff --git a/packages/1-framework/1-core/framework-components/README.md b/packages/1-framework/1-core/framework-components/README.md index cd8cb6355889..484b8a5504b8 100644 --- a/packages/1-framework/1-core/framework-components/README.md +++ b/packages/1-framework/1-core/framework-components/README.md @@ -72,10 +72,9 @@ Codec metadata, parameterized-codec registration, and runtime materialization li ```ts import type { CodecDescriptor, CodecInstanceContext } from '@internal/framework-components/codec'; -import { voidParamsSchema } from '@internal/framework-components/codec'; ``` -- `CodecDescriptor

` carries `dataType`, `codecId`, `traits`, `targetTypes`, `meta`, `paramsSchema: StandardSchemaV1

`, optional `renderOutputType`, and a curried `factory: (P) => (CodecInstanceContext) => Codec`. `dataType` is the id of the data type the codec represents; assembly refuses a codec whose data type no component registers (see [ADR 254 — Data types and casts](../../../../../docs/architecture%20docs/adrs/ADR%20254%20-%20Data%20types%20and%20casts.md)). A codec whose data type depends on the target that adapts it is declared as a `CodecDescriptorTemplate

`, the same shape without `dataType`, and the target supplies the id when it adapts the template. Non-parameterized codecs use `P = void` (with the framework-supplied `voidParamsSchema`) and a constant factory; parameterized codecs use a non-empty `P` (e.g. `{ length: number }` for pgvector). +- `CodecDescriptor

` carries `dataType`, `codecId`, `traits`, `targetTypes`, `meta`, `paramsSchema: StandardSchemaV1

| undefined`, optional `renderOutputType`, and a curried `factory: (P) => (CodecInstanceContext) => Codec`. `dataType` is the id of the data type the codec represents; assembly refuses a codec whose data type no component registers (see [ADR 254 — Data types and casts](../../../../../docs/architecture%20docs/adrs/ADR%20254%20-%20Data%20types%20and%20casts.md)). A codec whose data type depends on the target that adapts it is declared as a `CodecDescriptorTemplate

`, the same shape without `dataType`, and the target supplies the id when it adapts the template. Non-parameterized codecs use `P = void`, set `paramsSchema` to `undefined`, and use a constant factory. Such a codec rejects any `typeParams`. Parameterized codecs use a non-empty `P` (e.g. `{ length: number }` for pgvector). - `CodecInstanceContext` (family-agnostic, `{ name }` only) is supplied by the runtime when materializing a per-instance codec. Pack authors close over it inside the factory; they never construct it. This is the **per-materialization** context, sibling to the **per-call** `CodecCallContext` documented above. Family-specific extensions augment it — the SQL family ships `SqlCodecInstanceContext extends CodecInstanceContext` in `@internal/sql-relational-core/ast`, adding `usedAt: ReadonlyArray<{ table; column }>` for SQL-domain codecs that need column-set metadata. - Contributors expose their descriptors through `ComponentMetadata.types.codecTypes.codecDescriptors` and the unified `codecs: () => ReadonlyArray` slot. `extractCodecLookup` reads `targetTypes` / `meta` / `renderOutputType` directly off the descriptors — there is no parameterized vs. non-parameterized split and no synthesis bridge. diff --git a/packages/1-framework/1-core/framework-components/src/exports/codec.ts b/packages/1-framework/1-core/framework-components/src/exports/codec.ts index 583c4720281b..c478b0a1d872 100644 --- a/packages/1-framework/1-core/framework-components/src/exports/codec.ts +++ b/packages/1-framework/1-core/framework-components/src/exports/codec.ts @@ -23,7 +23,7 @@ export type { CodecRegistry, CodecTrait, } from '../shared/codec-types'; -export { emptyCodecLookup, voidParamsSchema } from '../shared/codec-types'; +export { emptyCodecLookup } from '../shared/codec-types'; export type { ColumnHelperFor, ColumnHelperForStrict, diff --git a/packages/1-framework/1-core/framework-components/src/shared/codec-descriptor.ts b/packages/1-framework/1-core/framework-components/src/shared/codec-descriptor.ts index 9c02a838386a..0c95621ebe79 100644 --- a/packages/1-framework/1-core/framework-components/src/shared/codec-descriptor.ts +++ b/packages/1-framework/1-core/framework-components/src/shared/codec-descriptor.ts @@ -11,7 +11,7 @@ import type { JsonValue } from '@internal/contract/types'; import type { StandardSchemaV1 } from '@standard-schema/spec'; import type { Codec } from './codec'; -import { type CodecInstanceContext, type CodecTrait, voidParamsSchema } from './codec-types'; +import type { CodecInstanceContext, CodecTrait } from './codec-types'; import type { DataTypeId } from './data-type'; /** @@ -32,9 +32,9 @@ export interface CodecDescriptorTemplate

{ readonly traits: readonly CodecTrait[]; /** Database-native type names this codec handles (e.g. `['timestamptz']`). */ readonly targetTypes: readonly string[]; - /** Standard Schema validator for the factory's params. Validates JSON-sourced params at the contract boundary (PSL → IR; `contract.json` → runtime). For non-parameterized codecs (`P = void`), the schema validates `void`/`undefined` — the framework supplies no params at the call boundary. */ - readonly paramsSchema: StandardSchemaV1

; - /** Whether this descriptor is parameterized — i.e. its `paramsSchema` is something other than the singleton `voidParamsSchema`. Consumers that need to gate column-aware dispatch read this directly rather than threading a free-floating `(codecId) => boolean` callback. */ + /** Standard Schema validator for the factory's params. Validates JSON-sourced params at the contract boundary (PSL → IR; `contract.json` → runtime). `undefined` for a codec that takes no params (`P = void`), which then rejects any `typeParams`. */ + readonly paramsSchema: StandardSchemaV1

| undefined; + /** Whether this descriptor takes params, i.e. has a `paramsSchema`. Consumers that need to gate column-aware dispatch read this directly rather than threading a free-floating `(codecId) => boolean` callback. */ readonly isParameterized: boolean; /** Emit-path string renderer for `contract.d.ts`. Returns the TypeScript output type expression for given params (e.g. `Vector<1536>`). Optional; absent renderers cause the emitter to fall back to the codec's base output type. Non-parameterized codecs typically omit it. */ readonly renderOutputType?: (params: P) => string | undefined; @@ -96,11 +96,10 @@ export abstract class CodecDescriptorTemplateImpl abstract readonly traits: readonly CodecTrait[]; abstract readonly targetTypes: readonly string[]; - abstract readonly paramsSchema: StandardSchemaV1; + abstract readonly paramsSchema: StandardSchemaV1 | undefined; - /** Boolean derived from `paramsSchema`: `true` whenever the schema is not the singleton `voidParamsSchema`. */ get isParameterized(): boolean { - return this.paramsSchema !== voidParamsSchema; + return this.paramsSchema !== undefined; } /** Optional emit-path string renderer for `contract.d.ts`. Returns the TypeScript output type expression for the given params (e.g. `Vector<1536>`). Non-parameterized codecs typically omit it. */ diff --git a/packages/1-framework/1-core/framework-components/src/shared/codec-types.ts b/packages/1-framework/1-core/framework-components/src/shared/codec-types.ts index e5f148bdfc7f..91cc80222c25 100644 --- a/packages/1-framework/1-core/framework-components/src/shared/codec-types.ts +++ b/packages/1-framework/1-core/framework-components/src/shared/codec-types.ts @@ -1,5 +1,4 @@ import type { JsonValue } from '@internal/contract/types'; -import type { StandardSchemaV1 } from '@standard-schema/spec'; import type { Codec } from './codec'; import type { AnyCodecDescriptor } from './codec-descriptor'; @@ -18,7 +17,7 @@ export function isCodecTrait(value: unknown): value is CodecTrait { * * `(codecId, typeParams?)` is the single fact the runtime needs to materialize a codec via `descriptorFor(codecId).factory(typeParams)(ctx)`. The pair is content-keyed: two refs with the same `codecId` and structurally equal `typeParams` (regardless of object key ordering) resolve to the same memoized {@link Codec} instance. * - * `typeParams` is `JsonValue`-constrained so the ref survives JSON serialization (relevant for AST-embedded migration ops). Non-parameterized codecs leave `typeParams` undefined; the descriptor's `paramsSchema` validates the value at the JSON boundary. + * `typeParams` is `JsonValue`-constrained so the ref survives JSON serialization (relevant for AST-embedded migration ops). Non-parameterized codecs leave `typeParams` undefined or empty; a parameterized codec's `paramsSchema` validates the value at the JSON boundary. * * `many` marks a scalar-array (list-typed) column. When `true`, the encode/decode paths map the element codec over array elements rather than applying the codec to the whole value. The element codec id is `codecId`; each family or target owns how its stored list frame is traversed. For Postgres, inbound list framing is target-owned while outbound parameters still rely on the driver/library's array serialization under the adapter-emitted SQL type context. Absent for scalar columns. * @@ -110,23 +109,3 @@ export const emptyCodecLookup: CodecLookup = { export interface CodecInstanceContext { readonly name: string; } - -/** - * Standard Schema validator for `void` params. Accepts only `undefined` (or absent input); rejects any other value so a contract that tries to thread `typeParams` through a non-parameterized codec id fails fast at the JSON boundary instead of silently coercing the value away. Used by the framework-supplied non-parameterized descriptor synthesizer. - */ -export const voidParamsSchema: StandardSchemaV1 = { - '~standard': { - version: 1, - vendor: 'prisma', - validate: (input) => - input === undefined - ? { value: undefined } - : { - issues: [ - { - message: 'unexpected typeParams for non-parameterized codec (void params expected)', - }, - ], - }, - }, -}; diff --git a/packages/1-framework/1-core/framework-components/src/shared/resolve-codec.ts b/packages/1-framework/1-core/framework-components/src/shared/resolve-codec.ts index 68fb58d3f8c2..46996c08a821 100644 --- a/packages/1-framework/1-core/framework-components/src/shared/resolve-codec.ts +++ b/packages/1-framework/1-core/framework-components/src/shared/resolve-codec.ts @@ -26,22 +26,43 @@ export function resolveCodecDescriptorOrThrow( return descriptor; } +function isAbsentOrEmpty(typeParams: CodecRef['typeParams']): boolean { + return ( + typeParams === undefined || + (typeof typeParams === 'object' && + typeParams !== null && + !Array.isArray(typeParams) && + Object.keys(typeParams).length === 0) + ); +} + /** * Validates `ref.typeParams` against `descriptor.paramsSchema`. * - * Parameterized codecs that omit `typeParams` have it normalized to `{}` before - * validation (mirrors `ast-codec-resolver.ts` semantics). Throws - * `RUNTIME.TYPE_PARAMS_INVALID` when the validator returns a `Promise` or - * reports issues. + * A codec without a `paramsSchema` takes no params: it accepts absent or empty + * `typeParams` (a bare native-type alias carries `{}`) and rejects anything else. + * A parameterized codec whose ref omits `typeParams` validates `{}` (mirrors + * `ast-codec-resolver.ts` semantics). Throws `RUNTIME.TYPE_PARAMS_INVALID` when + * params are given to a codec without params, or when the validator returns a + * `Promise` or reports issues. */ export function validateCodecTypeParams(descriptor: AnyCodecDescriptor, ref: CodecRef): unknown { - const normalized = - descriptor.isParameterized && ref.typeParams === undefined ? { ...ref, typeParams: {} } : ref; + const schema = descriptor.paramsSchema; + if (schema === undefined) { + if (!isAbsentOrEmpty(ref.typeParams)) { + throw runtimeError( + 'RUNTIME.TYPE_PARAMS_INVALID', + `Invalid typeParams for codec '${ref.codecId}': unexpected typeParams for non-parameterized codec`, + { codecId: ref.codecId, typeParams: ref.typeParams }, + ); + } + return undefined; + } const result = blindCast< { value: unknown } | { issues: ReadonlyArray<{ message: string }> } | Promise, 'Standard Schema validate returns unknown; the spec guarantees this union shape' - >(descriptor.paramsSchema['~standard'].validate(normalized.typeParams)); + >(schema['~standard'].validate(ref.typeParams ?? {})); if (result instanceof Promise) { throw runtimeError( diff --git a/packages/1-framework/1-core/framework-components/test/codec.test.ts b/packages/1-framework/1-core/framework-components/test/codec.test.ts index f3e5cabb0481..1bd25b7b8b5a 100644 --- a/packages/1-framework/1-core/framework-components/test/codec.test.ts +++ b/packages/1-framework/1-core/framework-components/test/codec.test.ts @@ -17,7 +17,6 @@ import { type CodecInstanceContext, type CodecTrait, dataTypeId, - voidParamsSchema, } from '../src/exports/codec'; class Int4FixtureCodec extends CodecImpl<'demo/int4@1', readonly ['equality'], number, number> { @@ -40,7 +39,7 @@ class Int4FixtureDescriptor extends CodecDescriptorImpl { override readonly codecId = 'demo/int4@1' as const; override readonly traits: readonly CodecTrait[] = ['equality']; override readonly targetTypes: readonly string[] = ['int4']; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => Int4FixtureCodec { return () => new Int4FixtureCodec(this); } @@ -121,7 +120,7 @@ test('alias descriptor produces codec whose id reads the alias codecId', ({ expe override readonly codecId = 'demo/aliased-int@1' as const; override readonly traits: readonly CodecTrait[] = ['equality']; override readonly targetTypes: readonly string[] = ['int4']; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => Int4FixtureCodec { return () => new Int4FixtureCodec(this); } diff --git a/packages/1-framework/1-core/framework-components/test/codec.types.test-d.ts b/packages/1-framework/1-core/framework-components/test/codec.types.test-d.ts index 571a9cffac94..7951e35f73b3 100644 --- a/packages/1-framework/1-core/framework-components/test/codec.types.test-d.ts +++ b/packages/1-framework/1-core/framework-components/test/codec.types.test-d.ts @@ -23,7 +23,6 @@ import { type ColumnSpec, column, dataTypeId, - voidParamsSchema, } from '../src/exports/codec'; class Int4FixtureCodec extends CodecImpl<'demo/int4@1', readonly ['equality'], number, number> { @@ -46,7 +45,7 @@ class Int4FixtureDescriptor extends CodecDescriptorImpl implements CodecDe override readonly codecId = 'demo/int4@1' as const; override readonly traits: readonly CodecTrait[] = ['equality']; override readonly targetTypes: readonly string[] = ['int4']; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => Int4FixtureCodec { return () => new Int4FixtureCodec(this); } diff --git a/packages/1-framework/1-core/framework-components/test/data-type-descriptor.types.test-d.ts b/packages/1-framework/1-core/framework-components/test/data-type-descriptor.types.test-d.ts index 8774cd28eb03..23caa9fa6da4 100644 --- a/packages/1-framework/1-core/framework-components/test/data-type-descriptor.types.test-d.ts +++ b/packages/1-framework/1-core/framework-components/test/data-type-descriptor.types.test-d.ts @@ -18,7 +18,6 @@ import { type CodecTrait, type DataTypeId, dataTypeId, - voidParamsSchema, } from '../src/exports/codec'; const demoInt = dataTypeId('demo/int'); @@ -42,8 +41,7 @@ abstract class DemoDescriptorBody

extends CodecDescriptorTemplateImpl

{ override readonly codecId = 'demo/int@1' as const; override readonly traits: readonly CodecTrait[] = ['equality']; override readonly targetTypes: readonly string[] = ['int']; - override readonly paramsSchema: StandardSchemaV1

= - voidParamsSchema as unknown as StandardSchemaV1

; + override readonly paramsSchema: StandardSchemaV1

| undefined = undefined; override factory(): (ctx: CodecInstanceContext) => Codec { return () => new DemoCodec(this); } @@ -54,7 +52,7 @@ class DemoDescriptor extends CodecDescriptorImpl { override readonly codecId = 'demo/int@1' as const; override readonly traits: readonly CodecTrait[] = ['equality']; override readonly targetTypes: readonly string[] = ['int']; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => Codec { return () => new DemoCodec(this); } @@ -77,7 +75,7 @@ test('a descriptor without a data type does not type-check', () => { codecId: 'demo/int@1', traits: ['equality'] as readonly CodecTrait[], targetTypes: ['int'] as readonly string[], - paramsSchema: voidParamsSchema, + paramsSchema: undefined, isParameterized: false, factory: () => () => new DemoCodec(new DemoDescriptor()), }; diff --git a/packages/1-framework/1-core/framework-components/test/materialize-codec.test.ts b/packages/1-framework/1-core/framework-components/test/materialize-codec.test.ts index 1fd1eeebac0a..687e264ae6a9 100644 --- a/packages/1-framework/1-core/framework-components/test/materialize-codec.test.ts +++ b/packages/1-framework/1-core/framework-components/test/materialize-codec.test.ts @@ -12,7 +12,6 @@ import { type CodecTrait, dataTypeId, materializeCodec, - voidParamsSchema, } from '../src/exports/codec'; class Int4FixtureCodec extends CodecImpl<'demo/int4@1', readonly ['equality'], number, number> { @@ -35,7 +34,7 @@ class Int4FixtureDescriptor extends CodecDescriptorImpl { override readonly codecId = 'demo/int4@1' as const; override readonly traits: readonly CodecTrait[] = ['equality']; override readonly targetTypes: readonly string[] = ['int4']; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => Int4FixtureCodec { return () => new Int4FixtureCodec(this); } @@ -126,3 +125,44 @@ test('materializeCodec produces a codec whose encode/decode still run through th expect(wire).toBe('[1,2,3]'); expect(await codec.decode(wire, {})).toEqual([1, 2, 3]); }); + +/** + * Copies a descriptor the way arktype's default clone does when a config section is validated: + * same prototype, and every plain object it holds is a new object with the same keys. + */ +function copyLikeArktype(original: T): T { + const copy = Object.create(Object.getPrototypeOf(original)); + for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(original))) { + const value: unknown = descriptor.value; + const isPlainObject = + typeof value === 'object' && + value !== null && + Object.getPrototypeOf(value) === Object.prototype; + Object.defineProperty(copy, key, { + ...descriptor, + value: isPlainObject ? { ...value } : value, + }); + } + return copy; +} + +test('a codec without params stays non-parameterized when its descriptor is copied', ({ + expect, +}) => { + expect(copyLikeArktype(int4FixtureDescriptor).isParameterized).toBe(false); + expect(copyLikeArktype(vectorFixtureDescriptor).isParameterized).toBe(true); +}); + +test('materializeCodec rejects typeParams for a codec without params', ({ expect }) => { + const ref: CodecRef = { codecId: 'demo/int4@1', typeParams: { length: 3 } }; + expect(() => materializeCodec(descriptorFor(ref), ref, stubCtx)).toThrow( + "Invalid typeParams for codec 'demo/int4@1': unexpected typeParams for non-parameterized codec", + ); +}); + +test('materializeCodec treats empty typeParams as none for a codec without params', ({ + expect, +}) => { + const ref: CodecRef = { codecId: 'demo/int4@1', typeParams: {} }; + expect(materializeCodec(descriptorFor(ref), ref, stubCtx).id).toBe('demo/int4@1'); +}); diff --git a/packages/1-framework/1-core/framework-components/test/psl-extension-block-validator.test.ts b/packages/1-framework/1-core/framework-components/test/psl-extension-block-validator.test.ts index 968ed004d778..bc2d2bbc3abd 100644 --- a/packages/1-framework/1-core/framework-components/test/psl-extension-block-validator.test.ts +++ b/packages/1-framework/1-core/framework-components/test/psl-extension-block-validator.test.ts @@ -20,7 +20,6 @@ */ import type { JsonValue } from '@internal/contract/types'; -import type { StandardSchemaV1 } from '@standard-schema/spec'; import { describe, expect, it } from 'vitest'; import { extractCodecLookup } from '../src/control/control-stack'; import { @@ -39,7 +38,6 @@ import { CodecImpl, type CodecInstanceContext, dataTypeId, - voidParamsSchema, } from '../src/exports/codec'; import type { PslExtensionBlock } from '../src/exports/psl-ast'; @@ -83,7 +81,7 @@ class StubStringDescriptor extends CodecDescriptorImpl { override readonly codecId = 'stub/string@1' as const; override readonly traits = ['textual'] as const; override readonly targetTypes = ['text'] as const; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => StubStringCodec { return () => new StubStringCodec(this); } diff --git a/packages/1-framework/2-authoring/psl-printer/test/declarative-policy-select.round-trip.test.ts b/packages/1-framework/2-authoring/psl-printer/test/declarative-policy-select.round-trip.test.ts index 15bef52dc70a..469ab5c39456 100644 --- a/packages/1-framework/2-authoring/psl-printer/test/declarative-policy-select.round-trip.test.ts +++ b/packages/1-framework/2-authoring/psl-printer/test/declarative-policy-select.round-trip.test.ts @@ -23,7 +23,6 @@ import { CodecImpl, type CodecInstanceContext, dataTypeId, - voidParamsSchema, } from '@internal/framework-components/codec'; import { assembleAuthoringContributions, @@ -46,7 +45,6 @@ import { validateExtensionBlockFromSymbol, } from '@internal/psl-parser'; import { type PslSources, parse } from '@internal/psl-parser/syntax'; -import type { StandardSchemaV1 } from '@standard-schema/spec'; import { describe, expect, it } from 'vitest'; import { printPslFromAst } from '../src/print-psl'; import { @@ -87,7 +85,7 @@ class FixturePolicyTextDescriptor extends CodecDescriptorImpl { override readonly codecId = FIXTURE_POLICY_CODEC_ID as typeof FIXTURE_POLICY_CODEC_ID; override readonly traits = ['textual'] as const; override readonly targetTypes = ['text'] as const; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => FixturePolicyTextCodec { return () => new FixturePolicyTextCodec(this); } diff --git a/packages/1-framework/2-authoring/psl-printer/test/generic-extension-block-printer.test.ts b/packages/1-framework/2-authoring/psl-printer/test/generic-extension-block-printer.test.ts index 143c9e6a9157..5cf1a971c5be 100644 --- a/packages/1-framework/2-authoring/psl-printer/test/generic-extension-block-printer.test.ts +++ b/packages/1-framework/2-authoring/psl-printer/test/generic-extension-block-printer.test.ts @@ -21,7 +21,6 @@ import { CodecImpl, type CodecInstanceContext, dataTypeId, - voidParamsSchema, } from '@internal/framework-components/codec'; import { assembleAuthoringContributions, @@ -77,7 +76,7 @@ class StubPolicyTextDescriptor extends CodecDescriptorImpl { override readonly codecId = FIXTURE_POLICY_CODEC_ID as typeof FIXTURE_POLICY_CODEC_ID; override readonly traits = ['textual'] as const; override readonly targetTypes = ['text'] as const; - override readonly paramsSchema = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => StubPolicyTextCodec { return () => new StubPolicyTextCodec(this); } @@ -242,7 +241,7 @@ describe('generic extension-block printer (P2)', () => { override readonly codecId = FIXTURE_POLICY_CODEC_ID as typeof FIXTURE_POLICY_CODEC_ID; override readonly traits = ['numeric'] as const; override readonly targetTypes = ['numeric'] as const; - override readonly paramsSchema = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => NumericExpressionCodec { return () => new NumericExpressionCodec(this); } diff --git a/packages/1-framework/3-tooling/cli-telemetry/package.json b/packages/1-framework/3-tooling/cli-telemetry/package.json index 15d02273d2af..9abab76d0556 100644 --- a/packages/1-framework/3-tooling/cli-telemetry/package.json +++ b/packages/1-framework/3-tooling/cli-telemetry/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@internal/config": "workspace:8.0.0-rc.11", + "@internal/config-loader": "workspace:8.0.0-rc.11", "@internal/utils": "workspace:8.0.0-rc.11", "@vercel/detect-agent": "^1.2.4", "arktype": "catalog:", diff --git a/packages/1-framework/3-tooling/cli-telemetry/src/enrich.ts b/packages/1-framework/3-tooling/cli-telemetry/src/enrich.ts index 646c0df5375e..01b4e50f4a68 100644 --- a/packages/1-framework/3-tooling/cli-telemetry/src/enrich.ts +++ b/packages/1-framework/3-tooling/cli-telemetry/src/enrich.ts @@ -1,5 +1,4 @@ import { readFileSync } from 'node:fs'; -import type { PrismaNextConfig } from '@internal/config/config-types'; import { blindCast } from '@internal/utils/casts'; import { determineAgent } from '@vercel/detect-agent'; import { join } from 'pathe'; @@ -30,7 +29,7 @@ const EMPTY_PROJECT_CONFIG: ProjectConfigFields = { * shape, etc. Telemetry is non-blocking and best-effort; an empty * result is the only downside of an unloadable or invalid config. * - * Both `c12` and `@internal/config/config-validation` are imported + * Both `c12` and the config loader are imported * lazily so the detached sender's cold-start cost is paid only when * telemetry actually fires, not on every fork even when gates * short-circuit before reaching this code path. @@ -69,14 +68,12 @@ export async function loadProjectConfig(projectRoot: string): Promise, 'a non-null non-array object indexes by string keys' >(orm); - const validation = await import('@internal/config/config-validation'); - if (validation.collectConfigIssues(config).length > 0) { + const { validateOrmSection } = await import('@internal/config-loader'); + const validation = validateOrmSection(config, { files: [], keys: {} }); + if (!validation.ok) { return EMPTY_PROJECT_CONFIG; } - const validConfig = blindCast< - PrismaNextConfig, - 'collectConfigIssues returned no issues, so the validated sections are present' - >(config); + const validConfig = validation.value; return { databaseTarget: validConfig.target.targetId, extensions: (validConfig.extensions ?? []).map((pack) => pack.id), diff --git a/packages/1-framework/3-tooling/cli-telemetry/test/enrich.test.ts b/packages/1-framework/3-tooling/cli-telemetry/test/enrich.test.ts index 96c2dc5fe1c6..b24251bb6a59 100644 --- a/packages/1-framework/3-tooling/cli-telemetry/test/enrich.test.ts +++ b/packages/1-framework/3-tooling/cli-telemetry/test/enrich.test.ts @@ -199,7 +199,7 @@ describe('buildTelemetryEvent', () => { /** * Build a `prisma.config.mjs` source string that satisfies - * `validateConfig` from `@internal/config/config-validation`. + * the `orm` config schema (`ormConfigSchema` in `@internal/config-loader`). * `target.targetId` is the only structurally-significant variable * the telemetry projection cares about; `extensions` defaults to * empty. Caller can override either via the parameters; pass a diff --git a/packages/1-framework/3-tooling/cli-telemetry/test/integration.test.ts b/packages/1-framework/3-tooling/cli-telemetry/test/integration.test.ts index 1f5a5ae79ac4..5876b0f10d40 100644 --- a/packages/1-framework/3-tooling/cli-telemetry/test/integration.test.ts +++ b/packages/1-framework/3-tooling/cli-telemetry/test/integration.test.ts @@ -14,8 +14,8 @@ let projectDir: string; /** * Build a `prisma.config.mjs` source string with the minimum - * descriptor shape that `validateConfig` (from - * `@internal/config/config-validation`) accepts. The integration + * descriptor shape that the `orm` config schema (`ormConfigSchema` in + * `@internal/config-loader`) accepts. The integration * test exercises the full c12 + validator pipeline in the detached * child, so the fixture has to be structurally valid. */ diff --git a/packages/1-framework/3-tooling/cli/package.json b/packages/1-framework/3-tooling/cli/package.json index 4d017599d0c5..17115f92c25d 100644 --- a/packages/1-framework/3-tooling/cli/package.json +++ b/packages/1-framework/3-tooling/cli/package.json @@ -52,7 +52,7 @@ "@internal/sql-contract-ts": "workspace:8.0.0-rc.11", "@internal/sql-operations": "workspace:8.0.0-rc.11", "@internal/sql-runtime": "workspace:8.0.0-rc.11", - "@prisma/cli-engine": "0.4.0", + "@prisma/cli-engine": "0.6.1", "@repo/test-utils": "workspace:8.0.0-rc.11", "@repo/tsconfig": "workspace:8.0.0-rc.11", "@repo/tsdown": "workspace:8.0.0-rc.11", @@ -62,7 +62,7 @@ "vitest": "catalog:" }, "peerDependencies": { - "@prisma/cli-engine": "0.4.0", + "@prisma/cli-engine": "0.6.1", "typescript": ">=5.9" }, "peerDependenciesMeta": { 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 05f85d69ae6d..0ffa6d7eee2b 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 @@ -352,7 +352,7 @@ export async function executeContractEmit( ): Promise { const { config, - configPath, + projectDir, outputPath, signal = new AbortController().signal, onProgress, @@ -443,9 +443,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 fa741f96eabd..aadd28b431a1 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 @@ -42,8 +42,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 validated 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`. */ @@ -64,7 +64,6 @@ export async function executeMigrationNewCommand( const config = options.config; const cwd = options.cwd; const { migrationsDir, appMigrationsDir, appMigrationsRelative } = resolveMigrationPaths( - options.configPath, config, cwd, ); @@ -224,7 +223,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 75897339481d..2df7a18e765b 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 @@ -68,8 +68,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 validated config's `baseDir`; locates the project manifest. */ + readonly projectDir?: string; readonly name?: string; readonly from?: string; readonly to?: string; @@ -373,7 +373,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); @@ -521,7 +521,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 ca937db55901..70f9670c2681 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 @@ -64,8 +64,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 { @@ -86,11 +84,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); @@ -158,7 +152,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) { @@ -171,7 +165,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..90f9b08ba926 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,13 @@ 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 + * The project's directory, normally the validated 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/exports/index.ts b/packages/1-framework/3-tooling/cli/src/exports/index.ts index 6a9ab73c6dc8..c2215c424b26 100644 --- a/packages/1-framework/3-tooling/cli/src/exports/index.ts +++ b/packages/1-framework/3-tooling/cli/src/exports/index.ts @@ -1,9 +1,10 @@ // The package surface, published as `@prisma/orm-toolchain/cli`: the // engine-mounted `orm` command family the unified `prisma` shell imports, // plus the helpers the in-repo harnesses evaluate config and contracts with. + +export { ormConfigSection } from '@internal/config-loader'; export type { LoadTsContractOptions } from '../load-ts-contract'; export { loadContractFromTs } from '../load-ts-contract'; -export { ormConfigSection } from '../orm/config-section'; export { ormCommandFamily } from '../orm/family'; export type { LoadOrmConfigOptions } from '../orm/load-config'; export { loadOrmConfig } from '../orm/load-config'; 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 deleted file mode 100644 index cb0d885eba06..000000000000 --- a/packages/1-framework/3-tooling/cli/src/orm/config-section.ts +++ /dev/null @@ -1,163 +0,0 @@ -import type { PrismaNextConfig } from '@internal/config/config-types'; -import type { ConfigValidationIssue } from '@internal/config/config-validation'; -import { collectConfigIssues } from '@internal/config/config-validation'; -import { getEmittedArtifactPaths } from '@internal/emitter'; -import { blindCast } from '@internal/utils/casts'; -import type { SectionValidation } from '@prisma/cli-engine'; -import { defineConfigSection } from '@prisma/cli-engine'; -import type { Diagnostic, NextAction } from '@prisma/cli-engine/protocol'; -import { normalize } from 'pathe'; - -/** - * The single config section the `orm` command family owns. The whole Prisma - * Next configuration is nested under it, so its subsections - * (`contract`, `db`, `migrations`, …) are subsections of `orm`. - */ -export const ORM_CONFIG_SECTION_NAME = 'orm'; - -const MISSING_CONFIG_ACTION: NextAction = { - kind: 'run-command', - label: 'Create a config file', - command: '{bin} orm init', -}; - -function editConfigAction(field: string): NextAction { - return { kind: 'edit-file', label: `Correct ${field} in prisma.config.ts` }; -} - -function issueDiagnostic(issue: ConfigValidationIssue): Diagnostic { - return { - code: 'CONFIG.VALIDATION_FAILED', - severity: 'error', - summary: issue.message, - why: issue.message, - nextActions: [editConfigAction(issue.field)], - meta: { field: issue.field, section: issue.section }, - }; -} - -function sectionAbsentDiagnostic(): Diagnostic { - return { - code: 'CONFIG.FILE_NOT_FOUND', - severity: 'error', - summary: 'No Prisma ORM configuration was loaded', - why: `The ${ORM_CONFIG_SECTION_NAME} config section is absent, so prisma.config.ts was never evaluated.`, - nextActions: [MISSING_CONFIG_ACTION], - }; -} - -function notAnObjectDiagnostic(): Diagnostic { - return { - code: 'CONFIG.VALIDATION_FAILED', - severity: 'error', - summary: 'Prisma ORM configuration must be an object', - why: `The ${ORM_CONFIG_SECTION_NAME} config section is not an object, so no section can be read from it.`, - nextActions: [ - { kind: 'edit-file', label: 'Export a configuration object from prisma.config.ts' }, - ], - }; -} - -function unreadableDiagnostic(error: unknown): Diagnostic { - return { - code: 'CONFIG.VALIDATION_FAILED', - severity: 'error', - summary: 'Prisma ORM configuration could not be inspected', - why: error instanceof Error ? error.message : String(error), - nextActions: [ - { - kind: 'edit-file', - label: 'Export a plain configuration object from prisma.config.ts', - }, - ], - }; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -/** - * An emitted artifact listed as its own contract input makes every emit - * consume its previous output. Structural, filesystem-free, and therefore the - * section validator's business rather than the loader's. - */ -function collectArtifactCollisionIssues( - raw: Record, -): readonly ConfigValidationIssue[] { - const contract = raw['contract']; - if (!isRecord(contract)) { - return []; - } - const source = contract['source']; - const output = contract['output']; - if (!isRecord(source) || typeof output !== 'string') { - return []; - } - const inputs = source['inputs']; - if (!Array.isArray(inputs)) { - return []; - } - - let emitted: ReturnType; - try { - emitted = getEmittedArtifactPaths(output); - } catch (error) { - return [ - { - section: 'contract', - field: 'contract.output', - message: error instanceof Error ? error.message : String(error), - }, - ]; - } - - // `./out/contract.json` and `out/contract.json` are the same file, so the - // comparison is between normalized spellings rather than raw strings. - const emittedPaths = new Set([normalize(emitted.jsonPath), normalize(emitted.dtsPath)]); - if (!inputs.some((input) => typeof input === 'string' && emittedPaths.has(normalize(input)))) { - return []; - } - return [ - { - section: 'contract', - field: 'contract.source.inputs[]', - message: - 'Config.contract.source.inputs must not include emitted artifact paths derived from contract.output', - }, - ]; -} - -function validate(raw: unknown): SectionValidation { - if (raw === undefined) { - return { ok: false as const, diagnostics: [sectionAbsentDiagnostic()] }; - } - if (!isRecord(raw)) { - return { ok: false as const, diagnostics: [notAnObjectDiagnostic()] }; - } - - let issues: readonly ConfigValidationIssue[]; - try { - issues = [...collectConfigIssues(raw), ...collectArtifactCollisionIssues(raw)]; - } catch (error) { - return { ok: false as const, diagnostics: [unreadableDiagnostic(error)] }; - } - - if (issues.length > 0) { - return { ok: false as const, diagnostics: issues.map(issueDiagnostic) }; - } - - return { - ok: true as const, - value: blindCast< - PrismaNextConfig, - 'collectConfigIssues found no structural problem, so every required section is present and well-typed' - >(raw), - diagnostics: [], - }; -} - -export const ormConfigSection = defineConfigSection({ - name: ORM_CONFIG_SECTION_NAME, - validate, -}); diff --git a/packages/1-framework/3-tooling/cli/src/orm/contract/emit.ts b/packages/1-framework/3-tooling/cli/src/orm/contract/emit.ts index 12f29e806749..031bf990e810 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/contract/emit.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/contract/emit.ts @@ -1,10 +1,10 @@ +import { ormConfigSection } from '@internal/config-loader'; import { ifDefined } from '@internal/utils/defined'; import type { Block, Presentations } from '@prisma/cli-engine'; import { flag } from '@prisma/cli-engine'; import { ok } from '@prisma/cli-engine/protocol'; import { dirname, relative, resolve } from 'pathe'; import { executeContractEmit as executeContractEmitOperation } from '../../control-api/operations/contract-emit'; -import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { controlProgressReporter } from '../progress'; diff --git a/packages/1-framework/3-tooling/cli/src/orm/contract/infer.ts b/packages/1-framework/3-tooling/cli/src/orm/contract/infer.ts index b2013ebdac49..10ecf6b5f3f8 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/contract/infer.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/contract/infer.ts @@ -1,4 +1,5 @@ import { existsSync } from 'node:fs'; +import { ormConfigSection } from '@internal/config-loader'; import { printPsl as printPslFromAst } from '@internal/psl-printer'; import { ifDefined } from '@internal/utils/defined'; import type { Block, Presentations } from '@prisma/cli-engine'; @@ -16,7 +17,6 @@ import { } from '../../utils/cli-errors'; import { closeQuietly, maskConnectionUrl, sanitizeErrorMessage } from '../../utils/command-helpers'; import { publishTextArtifact } from '../../utils/publish-text-artifact'; -import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { dbFlag } from '../flags'; import { normalizeError } from '../normalize-error'; 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..dc7757fe9f44 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 @@ -1,3 +1,4 @@ +import { ormConfigSection } from '@internal/config-loader'; import { ifDefined } from '@internal/utils/defined'; import { isStructuredError } from '@internal/utils/structured-error'; import type { Block, Presentations } from '@prisma/cli-engine'; @@ -20,10 +21,9 @@ import { import { closeQuietly, sanitizeErrorMessage } from '../../utils/command-helpers'; import { mapDbInitFailure } from '../../utils/db-init-failure'; 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/schema.ts b/packages/1-framework/3-tooling/cli/src/orm/db/schema.ts index 031aa71309d7..e4514c294648 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/schema.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/schema.ts @@ -1,3 +1,4 @@ +import { ormConfigSection } from '@internal/config-loader'; import type { CoreSchemaView, SchemaTreeNode } from '@internal/framework-components/control'; import { ifDefined } from '@internal/utils/defined'; import type { Block, Presentations, Span, TreeNode } from '@prisma/cli-engine'; @@ -11,7 +12,6 @@ import { errorUnexpected, } from '../../utils/cli-errors'; import { closeQuietly, maskConnectionUrl, sanitizeErrorMessage } from '../../utils/command-helpers'; -import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { dbFlag } from '../flags'; import { normalizeError } from '../normalize-error'; 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..8e820c1a4e2f 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 @@ -1,3 +1,4 @@ +import { ormConfigSection } from '@internal/config-loader'; import type { SignDatabaseResult, VerifyDatabaseSchemaResult, @@ -19,15 +20,9 @@ import { import { errorAdvanceRefArgConflict, errorContractArgConflict } from '../../utils/cli-errors'; import { closeQuietly, maskConnectionUrl } from '../../utils/command-helpers'; 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..954f16aadf00 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 @@ -1,3 +1,4 @@ +import { ormConfigSection } from '@internal/config-loader'; import { ifDefined } from '@internal/utils/defined'; import { isStructuredError } from '@internal/utils/structured-error'; import type { Block, Presentations } from '@prisma/cli-engine'; @@ -25,10 +26,9 @@ import { import { closeQuietly, sanitizeErrorMessage } from '../../utils/command-helpers'; import { mapDbUpdateFailure } from '../../utils/db-update-failure'; 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..cc3d1e898d86 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 @@ -1,3 +1,4 @@ +import { ormConfigSection } from '@internal/config-loader'; import type { VerifyDatabaseResult } from '@internal/framework-components/control'; import { VERIFY_CODE_HASH_MISMATCH, @@ -25,7 +26,6 @@ import { import { closeQuietly, maskConnectionUrl } from '../../utils/command-helpers'; import type { DbVerifyReport } from '../../utils/formatters/verify'; import { runCommandAction } from '../../utils/next-actions'; -import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { dbFlag } from '../flags'; import { migrationsDirFor } from '../migration/paths'; @@ -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/family.ts b/packages/1-framework/3-tooling/cli/src/orm/family.ts index 544e986d10a9..7b1b48731ae1 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/family.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/family.ts @@ -1,7 +1,7 @@ +import { ormConfigSection } from '@internal/config-loader'; import { DOCS_BASE } from '@internal/utils/structured-error'; import type { AnyCommand, RedirectSpec } from '@prisma/cli-engine'; import { defineCommandFamily } from '@prisma/cli-engine'; -import { ormConfigSection } from './config-section'; import { contractEmitCommand } from './contract/emit'; import { contractInferCommand } from './contract/infer'; import { dbInitCommand } from './db/init'; diff --git a/packages/1-framework/3-tooling/cli/src/orm/format.ts b/packages/1-framework/3-tooling/cli/src/orm/format.ts index 9a0f1e5cd34e..ff54e95f96b4 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/format.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/format.ts @@ -1,9 +1,9 @@ +import { ormConfigSection } from '@internal/config-loader'; import type { Block, Presentations } from '@prisma/cli-engine'; import { notOk, ok } from '@prisma/cli-engine/protocol'; import { relative } from 'pathe'; import type { FormatOperationResult } from '../control-api/operations/format'; import { executeFormat } from '../control-api/operations/format'; -import { ormConfigSection } from './config-section'; import { defineOrmCommand } from './define-command'; import { normalizeError } from './normalize-error'; diff --git a/packages/1-framework/3-tooling/cli/src/orm/load-config.ts b/packages/1-framework/3-tooling/cli/src/orm/load-config.ts index c49c0a200845..f8e1c732c795 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/load-config.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/load-config.ts @@ -1,12 +1,7 @@ -import { loadConfig } from '@internal/config-loader'; +import { loadConfigFiles } from '@internal/config-loader'; import type { LoadedConfig } from '@prisma/cli-engine'; -import { resolve } from 'pathe'; -import { ORM_CONFIG_SECTION_NAME } from './config-section'; import { toEngineDiagnostic } from './normalize-error'; -/** The file the ORM's loader reads when `--config` names none. */ -const ORM_CONFIG_FILENAME = 'prisma.config.ts'; - export interface LoadOrmConfigOptions { /** Where the CLI was invoked. Config discovery starts and ends here. */ readonly cwd: string; @@ -17,29 +12,23 @@ export interface LoadOrmConfigOptions { /** * Builds the engine's `Runtime.config` from `prisma.config.ts`. * - * The engine ships its own synchronous loader, but the bin owns the load: the - * ORM's c12 loader evaluates the module asynchronously and finalizes paths - * against the config file's own directory. It reads the same shape the engine - * does — definePrismaConfig from `@prisma/cli-engine` with the whole Prisma ORM - * configuration nested as the single `orm` section. + * The engine ships its own loader, but the bin owns the load: the ORM's c12 + * loader evaluates the module asynchronously and follows `extends`. It hands + * the engine each file on the chain with its sections as written; the engine + * validates the `orm` section against its schema with that provenance, + * resolving every path against the file that declared it. * * Only failures that prevent evaluation entirely are diagnostics here, and * they carry `section: null` so they fail exactly the commands that read - * config. Structural verdicts belong to the section validator. + * config. Structural verdicts belong to the section schema. */ export async function loadOrmConfig(options: LoadOrmConfigOptions): Promise { - const path = resolve(options.cwd, options.configPath ?? ORM_CONFIG_FILENAME); - const loaded = await loadConfig(options.configPath, { cwd: options.cwd }); + const loaded = await loadConfigFiles(options.configPath, { cwd: options.cwd }); if (!loaded.ok) { return { - path, - sections: {}, + files: [], diagnostics: [{ section: null, diagnostic: toEngineDiagnostic(loaded.failure) }], }; } - return { - path, - sections: { [ORM_CONFIG_SECTION_NAME]: loaded.value.config }, - diagnostics: [], - }; + return { files: loaded.value.files, diagnostics: [] }; } 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 3da9812d0185..850b6aaf6991 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts @@ -1,3 +1,4 @@ +import { ormConfigSection } from '@internal/config-loader'; import type { Contract } from '@internal/contract/types'; import { createControlStack } from '@internal/framework-components/control'; import { contractSnapshotDir } from '@internal/migration-tools/contract-snapshot-store'; @@ -47,12 +48,11 @@ import { toneDrawing } from '../utils/formatters/tone-markup'; import { mapMigrateFailure } from '../utils/migrate-failure'; import { runCommandAction } from '../utils/next-actions'; import { snapshotVerifierFor } from '../utils/snapshot-content-verification'; -import { ormConfigSection } from './config-section'; 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'; @@ -244,7 +244,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({ @@ -425,7 +425,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 f32a2e8e4ffe..c7c3ec891784 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 @@ -1,3 +1,4 @@ +import { ormConfigSection } from '@internal/config-loader'; import { ifDefined } from '@internal/utils/defined'; import { InternalError } from '@internal/utils/internal-error'; import { isStructuredErrorCode } from '@internal/utils/structured-error'; @@ -17,7 +18,6 @@ import { import { errorMigrationPackageNotFound } from '../../utils/cli-errors'; import { integrityViolationToCheckFailure } from '../../utils/integrity-violation-to-check-failure'; import { snapshotVerifierFor } from '../../utils/snapshot-content-verification'; -import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { normalizeError } from '../normalize-error'; import { appMigrationsDirFor, displayPath, migrationsDirFor } from './paths'; @@ -126,8 +126,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 verifySnapshotContent = snapshotVerifierFor(ctx.config); 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..a52460bf349b 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 @@ -1,3 +1,4 @@ +import { ormConfigSection } from '@internal/config-loader'; import { ifDefined } from '@internal/utils/defined'; import type { Block, Presentations } from '@prisma/cli-engine'; import { flag } from '@prisma/cli-engine'; @@ -23,7 +24,6 @@ import { import { createToneMigrationListStyler } from '../../utils/formatters/migration-list-styler'; import { toneDrawing } from '../../utils/formatters/tone-markup'; import type { GlyphMode } from '../../utils/glyph-mode'; -import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { normalizeError } from '../normalize-error'; import { displayPath, migrationsDirFor } from './paths'; @@ -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..2c12bcfe739e 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 @@ -1,3 +1,4 @@ +import { ormConfigSection } from '@internal/config-loader'; import { ifDefined } from '@internal/utils/defined'; import type { Block, Presentations } from '@prisma/cli-engine'; import { flag } from '@prisma/cli-engine'; @@ -14,7 +15,6 @@ import { createToneMigrationListStyler } from '../../utils/formatters/migration- import type { MigrationListResult } from '../../utils/formatters/migration-list-types'; import { toneDrawing } from '../../utils/formatters/tone-markup'; import type { GlyphMode } from '../../utils/glyph-mode'; -import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { normalizeError } from '../normalize-error'; import { displayPath, migrationsDirFor } from './paths'; @@ -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/log.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/log.ts index 39861a30f37e..7ca60debdb78 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/log.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/log.ts @@ -1,3 +1,4 @@ +import { ormConfigSection } from '@internal/config-loader'; import type { LedgerEntryRecord } from '@internal/contract/types'; import { ifDefined } from '@internal/utils/defined'; import type { Block, Presentations, Text } from '@prisma/cli-engine'; @@ -26,7 +27,6 @@ import { } from '../../utils/formatters/migration-log-table'; import { toneSpans } from '../../utils/formatters/tone-markup'; import type { GlyphMode } from '../../utils/glyph-mode'; -import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { dbFlag } from '../flags'; import { normalizeError } from '../normalize-error'; 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..1180f0de9a91 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 @@ -1,3 +1,4 @@ +import { ormConfigSection } from '@internal/config-loader'; import { ifDefined } from '@internal/utils/defined'; import type { Block, Presentations } from '@prisma/cli-engine'; import { flag } from '@prisma/cli-engine'; @@ -9,10 +10,9 @@ import type { MigrationNewResult } from '../../control-api/operations/migration- import { executeMigrationNewCommand } from '../../control-api/operations/migration-new'; import type { CreateControlClient } from '../../control-api/types'; 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..42602425248a 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,42 @@ 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, recorded when the + * engine validated it. Every command runs behind that validation, 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; the validated config carries it absolute and defaulted. */ +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 validated 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 5b5e4cc06478..3eb007be78fd 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 @@ -1,3 +1,4 @@ +import { ormConfigSection } from '@internal/config-loader'; import { blindCast } from '@internal/utils/casts'; import { ifDefined } from '@internal/utils/defined'; import type { Block, Presentations, Text, TreeNode } from '@prisma/cli-engine'; @@ -16,17 +17,16 @@ import type { CreateControlClient, DestructivePlanOperation } from '../../contro import { ERROR_CODE_DESTRUCTIVE_CHANGES } from '../../utils/cli-errors'; import { previewBlockHeader } from '../../utils/formatters/migrations'; import { runCommandAction } from '../../utils/next-actions'; -import { ormConfigSection } from '../config-section'; import { destructiveOperationList, errorConsentOperationsMissing } from '../db/consent'; import { defineOrmCommand } from '../define-command'; import { consentToken } from '../init-inputs'; 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 } { @@ -320,7 +320,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), @@ -383,7 +383,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 }, @@ -391,8 +391,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 543b10133e04..ad9417c507d8 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 @@ -1,4 +1,5 @@ import { readFile } from 'node:fs/promises'; +import { ormConfigSection } from '@internal/config-loader'; import type { Contract } from '@internal/contract/types'; import { APP_SPACE_ID, @@ -34,7 +35,6 @@ import { resolveAppTargetPath, } from '../../utils/migration-path-target'; import { snapshotVerifierFor } from '../../utils/snapshot-content-verification'; -import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { normalizeError } from '../normalize-error'; import { appMigrationsDirFor, contractPathFor, displayPath, migrationsDirFor } from './paths'; @@ -224,7 +224,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( @@ -235,8 +235,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..e264f5c4ad88 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 @@ -1,3 +1,4 @@ +import { ormConfigSection } from '@internal/config-loader'; import type { LedgerEntryRecord } from '@internal/contract/types'; import type { AggregateContractSpace, @@ -51,7 +52,6 @@ import { createToneMigrationListStyler } from '../../utils/formatters/migration- import type { MigrationListEntry } from '../../utils/formatters/migration-list-types'; import { toneDrawing } from '../../utils/formatters/tone-markup'; import type { GlyphMode } from '../../utils/glyph-mode'; -import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { dbFlag } from '../flags'; import { normalizeError } from '../normalize-error'; @@ -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/orm/ref/delete.ts b/packages/1-framework/3-tooling/cli/src/orm/ref/delete.ts index 28aeff28eb27..5a4921d8c3e0 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/ref/delete.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/ref/delete.ts @@ -1,9 +1,9 @@ +import { ormConfigSection } from '@internal/config-loader'; import type { Block, Presentations } from '@prisma/cli-engine'; import { positional } from '@prisma/cli-engine'; import { notOk, ok } from '@prisma/cli-engine/protocol'; import type { RefDeleteResult } from '../../control-api/operations/ref'; import { executeRefDeleteCommand } from '../../control-api/operations/ref'; -import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { normalizeError } from '../normalize-error'; diff --git a/packages/1-framework/3-tooling/cli/src/orm/ref/list.ts b/packages/1-framework/3-tooling/cli/src/orm/ref/list.ts index 1592aee2fd76..fdc73dbeae3e 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/ref/list.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/ref/list.ts @@ -1,9 +1,9 @@ +import { ormConfigSection } from '@internal/config-loader'; import type { Refs } from '@internal/migration-tools/refs'; import type { Block, Presentations, Text } from '@prisma/cli-engine'; import { notOk, ok } from '@prisma/cli-engine/protocol'; import type { RefListResult } from '../../control-api/operations/ref'; import { executeRefListCommand } from '../../control-api/operations/ref'; -import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { normalizeError } from '../normalize-error'; diff --git a/packages/1-framework/3-tooling/cli/src/orm/ref/set.ts b/packages/1-framework/3-tooling/cli/src/orm/ref/set.ts index f3a542cfd607..d5974f948caa 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/ref/set.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/ref/set.ts @@ -1,9 +1,9 @@ +import { ormConfigSection } from '@internal/config-loader'; import type { Block, Presentations } from '@prisma/cli-engine'; import { positional } from '@prisma/cli-engine'; import { notOk, ok } from '@prisma/cli-engine/protocol'; import type { RefSetResult } from '../../control-api/operations/ref'; import { executeRefSetCommand } from '../../control-api/operations/ref'; -import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { normalizeError } from '../normalize-error'; 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..9be1e7869c58 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 validated 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/framework-components.ts b/packages/1-framework/3-tooling/cli/src/utils/framework-components.ts index f6c0e6a55e2a..f69de5f49866 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/framework-components.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/framework-components.ts @@ -16,7 +16,7 @@ import { errorConfigValidation, errorContractMissingExtensions } from './cli-err * * This validation happens at the CLI composition boundary, before passing components * to typed planner/runner instances. It fills the gap between runtime validation - * (via the config loader's `collectConfigIssues` diagnostics) and compile-time + * (the `orm` config schema's diagnostics) and compile-time * type enforcement. * * @param expectedFamilyId - The expected family ID (e.g., 'sql') 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/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..490e86cbc033 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/helpers/orm-test-cli.ts @@ -0,0 +1,26 @@ +import { createTestCli } from '@prisma/cli-engine/testing'; +import { join } from 'pathe'; + +type TestCliSpec = Parameters[0]; +type TestCli = ReturnType; +type RunArgs = Parameters; + +/** + * A test CLI whose config file is `prisma.config.ts` in the run's working + * directory, holding the given `orm` section as written. The engine validates + * it against the section schema with that file as provenance, so relative + * paths in the seed resolve against the run directory and the handler receives + * the value a real load would produce. + */ +export function createOrmTestCli(spec: Omit & { readonly orm: unknown }) { + const { orm, ...rest } = spec; + return { + run: (argv: RunArgs[0], opts?: RunArgs[1]): ReturnType => { + const path = join(opts?.cwd ?? process.cwd(), 'prisma.config.ts'); + return createTestCli({ + ...rest, + loadConfig: async () => ({ files: [{ path, sections: { orm } }], diagnostics: [] }), + }).run(argv, opts); + }, + }; +} diff --git a/packages/1-framework/3-tooling/cli/test/orm/cli.test.ts b/packages/1-framework/3-tooling/cli/test/orm/cli.test.ts index bde454728ff0..09446ae16dbe 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/cli.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/cli.test.ts @@ -22,11 +22,7 @@ function recordingLoader(): { asked, loadConfig: (configPath) => { asked.push(configPath ?? '(none)'); - return Promise.resolve({ - path: configPath ?? 'prisma.config.ts', - sections: {}, - diagnostics: [], - }); + return Promise.resolve({ files: [], diagnostics: [] }); }, }; } 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 deleted file mode 100644 index 37d40fe70b12..000000000000 --- a/packages/1-framework/3-tooling/cli/test/orm/config-section.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { ormConfigSection } from '../../src/orm/config-section'; - -function validFamily() { - return { - kind: 'family', - id: 'sql', - familyId: 'sql', - version: '1.0.0', - emission: {}, - create: () => ({}), - }; -} - -function validDescriptor(kind: string) { - return { - kind, - id: `${kind}-id`, - familyId: 'sql', - targetId: 'postgres', - version: '1.0.0', - create: () => ({}), - }; -} - -function validConfig() { - return { - family: validFamily(), - target: { ...validDescriptor('target'), targetId: 'postgres' }, - adapter: validDescriptor('adapter'), - }; -} - -describe('ormConfigSection', () => { - it('is named orm', () => { - expect(ormConfigSection.name).toBe('orm'); - }); - - describe('a structurally valid section', () => { - it('validates and hands the value through untouched', () => { - const raw = validConfig(); - const result = ormConfigSection.validate(raw); - - expect(result).toEqual({ ok: true, value: raw, diagnostics: [] }); - }); - - it('accepts the optional subsections', () => { - const raw = { - ...validConfig(), - migrations: { dir: 'migrations' }, - formatter: { indent: 2, newline: 'LF' }, - db: { connection: 'postgres://localhost/app' }, - }; - - expect(ormConfigSection.validate(raw).ok).toBe(true); - }); - }); - - describe('absence', () => { - it('reports the missing config file rather than throwing', () => { - const result = ormConfigSection.validate(undefined); - - expect(result).toEqual({ - ok: false, - diagnostics: [ - { - code: 'CONFIG.FILE_NOT_FOUND', - severity: 'error', - summary: 'No Prisma ORM configuration was loaded', - why: 'The orm config section is absent, so prisma.config.ts was never evaluated.', - nextActions: [ - { - kind: 'run-command', - label: 'Create a config file', - command: '{bin} orm init', - }, - ], - }, - ], - }); - }); - }); - - describe('whole-section blocking', () => { - it('fails the whole section when one subsection is malformed', () => { - const raw = { ...validConfig(), migrations: { dir: 42 } }; - const result = ormConfigSection.validate(raw); - - expect(result.ok).toBe(false); - expect(result.diagnostics).toEqual([ - { - code: 'CONFIG.VALIDATION_FAILED', - severity: 'error', - summary: 'Config.migrations.dir must be a string', - why: 'Config.migrations.dir must be a string', - nextActions: [ - { - kind: 'edit-file', - label: 'Correct migrations.dir in prisma.config.ts', - }, - ], - meta: { field: 'migrations.dir', section: 'migrations' }, - }, - ]); - }); - - it('reports every issue it found, not just the first', () => { - const result = ormConfigSection.validate({ migrations: { dir: 42 } }); - - expect(result.ok).toBe(false); - expect(result.diagnostics.map((diagnostic) => diagnostic.meta?.['field'])).toEqual([ - 'family', - 'target', - 'adapter', - 'migrations.dir', - ]); - }); - - it('reports an emitted artifact listed as a contract input', () => { - const result = ormConfigSection.validate({ - ...validConfig(), - contract: { - source: { format: 'psl', inputs: ['/app/contract.json'], load: () => ({}) }, - output: '/app/contract.json', - }, - }); - - expect(result.ok).toBe(false); - expect(result.diagnostics.map((diagnostic) => diagnostic.meta?.['field'])).toEqual([ - 'contract.source.inputs[]', - ]); - }); - - it('reports the collision when the input spells the same file differently', () => { - const result = ormConfigSection.validate({ - ...validConfig(), - contract: { - source: { format: 'psl', inputs: ['./out/./contract.json'], load: () => ({}) }, - output: 'out/contract.json', - }, - }); - - expect(result.ok).toBe(false); - expect(result.diagnostics.map((diagnostic) => diagnostic.meta?.['field'])).toEqual([ - 'contract.source.inputs[]', - ]); - }); - - it('does not report a collision for a genuinely different file', () => { - const result = ormConfigSection.validate({ - ...validConfig(), - contract: { - source: { format: 'psl', inputs: ['./src/contract.prisma'], load: () => ({}) }, - output: 'out/contract.json', - }, - }); - - expect(result.ok).toBe(true); - }); - }); - - describe('hostile input', () => { - it.each([ - ['null', null], - ['a string', 'prisma.config.ts'], - ['a number', 7], - ['a boolean', true], - ['an array', []], - ['a function', () => undefined], - ])('rejects %s without throwing', (_label, raw) => { - const result = ormConfigSection.validate(raw); - - expect(result.ok).toBe(false); - expect(result.diagnostics).toEqual([ - { - code: 'CONFIG.VALIDATION_FAILED', - severity: 'error', - summary: 'Prisma ORM configuration must be an object', - why: 'The orm config section is not an object, so no section can be read from it.', - nextActions: [ - { - kind: 'edit-file', - label: 'Export a configuration object from prisma.config.ts', - }, - ], - }, - ]); - }); - - it.each([ - ['a config whose subsections are all wrong types', { family: 1, target: 'x', adapter: [] }], - ['a prototype-polluted object', JSON.parse('{"__proto__": {"family": 1}}')], - [ - 'an object with a throwing getter on an unread key', - { - get unrelated() { - throw new Error('boom'); - }, - ...validConfig(), - }, - ], - ['deeply nested garbage', { family: { kind: { kind: { kind: {} } } } }], - ])('never throws on %s', (_label, raw) => { - expect(() => ormConfigSection.validate(raw)).not.toThrow(); - }); - - it('never throws when a descriptor getter explodes', () => { - const raw = { - ...validConfig(), - get extensions(): never { - throw new Error('boom'); - }, - }; - - expect(() => ormConfigSection.validate(raw)).not.toThrow(); - expect(ormConfigSection.validate(raw).ok).toBe(false); - }); - }); -}); 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 f227754bd181..8136296730e8 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 @@ -8,6 +8,7 @@ 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 +80,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 { @@ -101,8 +102,7 @@ function countingLoader(config: Record = ormConfig()): { loadConfig: (configPath) => { calls.push(configPath ?? '(none)'); return Promise.resolve({ - path: join(PROJECT_DIR, 'prisma.config.ts'), - sections: { orm: config }, + files: [{ path: join(PROJECT_DIR, 'prisma.config.ts'), sections: { orm: config } }], diagnostics: [], }); }, @@ -141,6 +141,44 @@ describe('contract emit', () => { }); }); + it('resolves relative config paths against 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) => + Promise.resolve({ + files: [ + { path: join(PROJECT_DIR, configPath ?? 'prisma.config.ts'), sections: { orm: config } }, + ], + 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'), + }, + baseDir: configDir, + }, + 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 bc63011e02d5..978da934afc5 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'; @@ -67,10 +67,10 @@ function pslConfigMulti(inputPaths: readonly 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/load-config.test.ts b/packages/1-framework/3-tooling/cli/test/orm/load-config.test.ts index a26fea13e443..2c40468ee7fb 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/load-config.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/load-config.test.ts @@ -46,7 +46,7 @@ afterEach(() => { describe('loadOrmConfig', () => { it( - 'nests the whole configuration under the orm section', + 'hands the engine the requested file with the whole configuration under the orm section', async () => { const dir = projectDir(); writeConfig(dir, VALID_BODY); @@ -54,23 +54,24 @@ describe('loadOrmConfig', () => { const loaded = await loadOrmConfig({ cwd: dir }); expect(loaded.diagnostics).toEqual([]); - expect(Object.keys(loaded.sections)).toEqual(['orm']); + expect(loaded.files.map((file) => file.path)).toEqual([join(dir, 'prisma.config.ts')]); + expect(Object.keys(loaded.files[0]?.sections ?? {})).toEqual(['orm']); }, // The first load pays jiti's cold transform for the config module. timeouts.coldTransformImport, ); - it('finalizes contract paths against the config file directory', async () => { + it('leaves paths as written: the engine resolves them against the file that declared them', async () => { const dir = projectDir(); writeConfig(dir, VALID_BODY); const loaded = await loadOrmConfig({ cwd: dir }); - const orm = loaded.sections['orm'] as { + const orm = loaded.files[0]?.sections['orm'] as { contract?: { output?: string; source?: { inputs?: readonly string[] } }; }; - expect(orm.contract?.output).toBe(join(dir, 'output/contract.json')); - expect(orm.contract?.source?.inputs).toEqual([join(dir, 'contract.prisma')]); + expect(orm.contract?.output).toBe('output/contract.json'); + expect(orm.contract?.source?.inputs).toEqual(['contract.prisma']); }); it('discovers the config in the supplied cwd rather than the process cwd', async () => { @@ -78,7 +79,7 @@ describe('loadOrmConfig', () => { writeConfig(dir, VALID_BODY); expect(dir).not.toBe(process.cwd()); - expect((await loadOrmConfig({ cwd: dir })).sections['orm']).toBeDefined(); + expect((await loadOrmConfig({ cwd: dir })).files[0]?.sections['orm']).toBeDefined(); }); it('reads an explicit config path relative to the cwd', async () => { @@ -88,14 +89,15 @@ describe('loadOrmConfig', () => { const loaded = await loadOrmConfig({ cwd: dir, configPath: 'custom.config.ts' }); expect(loaded.diagnostics).toEqual([]); - expect(loaded.sections['orm']).toBeDefined(); + expect(loaded.files[0]?.path).toBe(join(dir, 'custom.config.ts')); + expect(loaded.files[0]?.sections['orm']).toBeDefined(); }); describe('a config that cannot be evaluated', () => { it('reports a file-level diagnostic and no sections', async () => { const loaded = await loadOrmConfig({ cwd: projectDir() }); - expect(loaded.sections).toEqual({}); + expect(loaded.files).toEqual([]); expect(loaded.diagnostics).toHaveLength(1); expect(loaded.diagnostics[0]?.section).toBeNull(); expect(loaded.diagnostics[0]?.diagnostic).toMatchObject({ @@ -122,7 +124,7 @@ describe('loadOrmConfig', () => { const loaded = await loadOrmConfig({ cwd: dir }); - expect(loaded.sections).toEqual({}); + expect(loaded.files).toEqual([]); expect(loaded.diagnostics).toHaveLength(1); expect(loaded.diagnostics[0]).toMatchObject({ section: null }); expect(loaded.diagnostics[0]?.diagnostic.code).toMatch(/^[A-Z][A-Z0-9]*\.[A-Z][A-Z0-9_]*$/); @@ -134,7 +136,7 @@ describe('loadOrmConfig', () => { const loaded = await loadOrmConfig({ cwd: dir }); - expect(loaded.sections).toEqual({}); + expect(loaded.files).toEqual([]); expect(loaded.diagnostics[0]).toMatchObject({ section: null, diagnostic: { code: 'CONFIG.VERSION_MARKER_MISSING' }, @@ -143,14 +145,14 @@ describe('loadOrmConfig', () => { }); describe('a structurally invalid config that still evaluates', () => { - it('leaves the structural verdict to the section validator', async () => { + it('leaves the structural verdict to the section schema', async () => { const dir = projectDir(); writeConfig(dir, '{ migrations: { dir: 42 } }'); const loaded = await loadOrmConfig({ cwd: dir }); expect(loaded.diagnostics).toEqual([]); - expect(loaded.sections['orm']).toMatchObject({ migrations: { dir: 42 } }); + expect(loaded.files[0]?.sections['orm']).toMatchObject({ migrations: { dir: 42 } }); }); }); }); 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 536193937498..ea2591075ca3 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 80608a055e48..2ae80f842178 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 @@ -4,10 +4,11 @@ import { contractSnapshotDir } from '@internal/migration-tools/contract-snapshot import { computeMigrationHash } from '@internal/migration-tools/hash'; import { notOk } from '@internal/utils/result'; import { createTestCli } from '@prisma/cli-engine/testing'; -import { basename, 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 { errorUnfilledPlaceholder } from '../../src/utils/cli-errors'; +import { createOrmTestCli } from '../helpers/orm-test-cli'; import { ADDITIVE_OP, contractJson, @@ -39,14 +40,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, }, }); } @@ -83,6 +82,65 @@ async function upToDateProject(): Promise { return project; } +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, + // The engine's own loader: the file's sections exactly as written. + loadConfig: async (configPath) => ({ + files: [ + { + path: join( + project.dir, + configPath === undefined ? 'prisma.config.ts' : basename(configPath), + ), + sections: { + orm: { + ...offlineConfig({ project }), + contract: { + source: { + format: 'typescript', + inputs: [], + load: async () => contractJson('unused'), + }, + output: './output/contract.json', + }, + migrations: { dir: './migrations' }, + }, + }, + }, + ], + diagnostics: [], + }), + }); + + 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-snapshot-content.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-snapshot-content.test.ts index d45c447f359f..c97ddb5bf8ad 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-snapshot-content.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-snapshot-content.test.ts @@ -1,10 +1,10 @@ import { writeFile } from 'node:fs/promises'; import { computeStorageHash } from '@internal/contract/hashing'; import { contractSnapshotDir } from '@internal/migration-tools/contract-snapshot-store'; -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, type OfflineProject, @@ -32,14 +32,12 @@ afterEach(removeOfflineProjects); function harness(project: OfflineProject) { const config = offlineConfig({ project }); const target = config['target'] as Record; - return createTestCli({ + return createOrmTestCli({ commands: BIN_COMMANDS, groups: BIN_GROUPS, - config: { - orm: { - ...config, - target: { ...target, contractSerializer: { hashCanonicalizationHooks: {} } }, - }, + orm: { + ...config, + target: { ...target, contractSerializer: { hashCanonicalizationHooks: {} } }, }, }); } 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..a56bdec1eeb9 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,41 @@ 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 validated 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'); - expect(paths.migrationsDir).toBe('/app/migrations'); + it('defaults the migrations dir under baseDir when the config names none', () => { + expect(resolveMigrationPaths({ baseDir: '/work/app' }, '/tmp').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', () => { + expect(resolveMigrationPaths({ migrations: { dir: 'db' } }, '/work/app').migrationsDir).toBe( + '/work/app/db', + ); + }); - expect(paths).toMatchObject({ - configPath: 'prisma.config.ts', + it('anchors the default on cwd', () => { + expect(resolveMigrationPaths({}, '/work/app')).toMatchObject({ 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 d10b59aa1175..72f12612abc0 100644 --- a/packages/1-framework/3-tooling/config-loader/README.md +++ b/packages/1-framework/3-tooling/config-loader/README.md @@ -2,27 +2,37 @@ > **Internal package.** This package is an implementation detail of Prisma 8 and is published only to support its runtime. Its API is unstable and may change without notice. Do not depend on this package directly; install `@prisma/cli` and a database facade (e.g. `@prisma/orm-postgres`) instead. -Discovers, validates, and finalizes `prisma.config.ts`. +Discovers and evaluates `prisma.config.ts`, and declares the `orm` section's schema. ## 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`) — and the declaration of the `orm` +section's shape (`ormConfigSchema`), from which the CLI engine derives validation, diagnostics and +the resolution of every path against the config file that wrote it. `loadConfig` runs that same +validation for readers outside a command run and turns a `prisma.config.ts` on disk into a +resolved `PrismaNextConfig`. It also performs the emitter-derived artifact-collision check (`getEmittedArtifactPaths`). -Its main entry is `loadConfig(configPath?)`, which maps failures to the CLI's structured `@internal/errors/control` errors (`CliStructuredError`). Consumers that need to react to specific failures (e.g. the language server degrading on a missing/invalid config) branch on the structured error's stable `code` (`4001` = config file not found, `4009` = config validation). The package also exports the pieces `loadConfig` is built from: `evaluateConfigModule` returns a config file's raw default export, `buildLoadedConfig` validates and finalizes an `orm` section built in memory, and `finalizeConfig` resolves a config's paths. +`loadConfig(configPath?)` returns a `Result`. It fails with a `CliStructuredError` only when the file cannot serve at all: `CONFIG.FILE_NOT_FOUND`, `CONFIG.EVALUATION_FAILED` when evaluating it throws, or `CONFIG.VERSION_MARKER_MISSING` when it does not export a `definePrismaConfig` value. A file that loads but breaks the `orm` schema succeeds with `diagnostics`, one `CONFIG.VALIDATION_FAILED` per bad field, each naming the subsection it concerns. `requireConfigSections` then fails only when a subsection the caller reads has a diagnostic, so a tool that needs `contract` keeps working, with its paths resolved, when `migrations` is malformed. + +The package also exports the pieces `loadConfig` is built from: `loadConfigFiles` evaluates the config chain and returns each file's sections as written, `evaluateConfigModule` returns one config file's raw default export, and `buildLoadedConfig` validates an `orm` section built in memory, resolving its paths against a given directory as if a `prisma.config.ts` there had written it. ## Usage ```ts -import { loadConfig } from '@internal/config-loader'; -import { CliStructuredError } from '@internal/errors/control'; - -try { - const config = await loadConfig('prisma.config.ts'); -} catch (error) { - if (error instanceof CliStructuredError && error.code === '4001') { - // degrade gracefully on a missing config +import { loadConfig, requireConfigSections } from '@internal/config-loader'; + +async function loadContractConfig() { + const loaded = await loadConfig('prisma.config.ts'); + if (!loaded.ok) { + // the file is missing, throws when evaluated, or is not a Prisma config: loaded.failure.code says which + return undefined; + } + const sections = requireConfigSections(loaded.value, ['contract', 'formatter']); + if (!sections.ok) { + // a subsection this caller reads failed validation + return undefined; } + return sections.value; } ``` diff --git a/packages/1-framework/3-tooling/config-loader/package.json b/packages/1-framework/3-tooling/config-loader/package.json index 100dcec4c2bc..ac262f6574b4 100644 --- a/packages/1-framework/3-tooling/config-loader/package.json +++ b/packages/1-framework/3-tooling/config-loader/package.json @@ -20,11 +20,13 @@ "@internal/emitter": "workspace:8.0.0-rc.11", "@internal/errors": "workspace:8.0.0-rc.11", "@internal/utils": "workspace:8.0.0-rc.11", + "arktype": "^2.2.2", "c12": "^3.3.4", "pathe": "^2.0.3", "tinyglobby": "catalog:" }, "devDependencies": { + "@prisma/cli-engine": "0.6.1", "@repo/test-utils": "workspace:8.0.0-rc.11", "@repo/tsconfig": "workspace:8.0.0-rc.11", "@repo/tsdown": "workspace:8.0.0-rc.11", @@ -34,6 +36,7 @@ "vitest": "catalog:" }, "peerDependencies": { + "@prisma/cli-engine": "0.6.1", "typescript": ">=5.9" }, "peerDependenciesMeta": { diff --git a/packages/1-framework/3-tooling/config-loader/src/expand-contract-inputs.ts b/packages/1-framework/3-tooling/config-loader/src/expand-contract-inputs.ts index 4f245bd08afa..af59ff0a3968 100644 --- a/packages/1-framework/3-tooling/config-loader/src/expand-contract-inputs.ts +++ b/packages/1-framework/3-tooling/config-loader/src/expand-contract-inputs.ts @@ -4,8 +4,8 @@ import { glob, isDynamicPattern } from 'tinyglobby'; /** * Expands a finalized contract source input list into its member file set: * absolute, deduped by canonical path, sorted. `patterns` must already be - * absolute (`finalizeConfig` resolves each entry against the config - * directory but does not expand it). + * absolute (the `orm` config schema resolves each entry against the config + * file that wrote it but does not expand it). * * A wildcard-free entry (per tinyglobby's own magic-character check) passes * through verbatim — no globbing, no existence check, no directory 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 39674faa2705..b7c6c007d9fc 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,13 +1,21 @@ export type { PrismaNextConfig } from '@internal/config/config-types'; export { expandContractInputs } from '../expand-contract-inputs'; -export { finalizeConfig } from '../finalize-config'; -export type { ConfigSection, LoadedConfig } from '../load'; +export type { ConfigFile, ConfigFiles, LoadedConfig } from '../load'; export { buildLoadedConfig, evaluateConfigModule, findNearestConfigPathForFile, loadConfig, + loadConfigFiles, loadConfigForFile, loadConfigForSections, requireConfigSections, } from '../load'; +export type { ConfigSection } from '../orm-section'; +export { + isConfigSection, + ORM_CONFIG_SECTION_NAME, + ormConfigSchema, + ormConfigSection, + validateOrmSection, +} from '../orm-section'; 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 1708fe6e3340..458dd99e8930 100644 --- a/packages/1-framework/3-tooling/config-loader/src/load.ts +++ b/packages/1-framework/3-tooling/config-loader/src/load.ts @@ -3,8 +3,6 @@ import { access } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; import type { PrismaNextConfig } from '@internal/config/config-types'; -import type { ConfigSection } from '@internal/config/config-validation'; -import { collectConfigIssues } from '@internal/config/config-validation'; import { getEmittedArtifactPaths } from '@internal/emitter'; import { CliStructuredError, @@ -17,8 +15,15 @@ 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 type { SectionProvenance } from '@prisma/cli-engine'; import { dirname, join, resolve } from 'pathe'; -import { finalizeContractConfig, finalizeMigrationsConfig } from './finalize-config'; +import { + type ConfigSection, + descriptorRelationshipProblems, + isConfigSection, + validateOrmSection, + validateOrmSectionExcept, +} from './orm-section'; const CONFIG_FILENAME = 'prisma.config.ts'; @@ -99,6 +104,54 @@ function collectArtifactCollisionDiagnostics( return []; } +function validateLoadedSection( + rawConfig: Record, + provenance: SectionProvenance, +): LoadedConfig { + const validation = validateOrmSection(rawConfig, provenance); + if (!validation.ok) { + const diagnostics = validation.diagnostics.map((diagnostic) => { + const field = typeof diagnostic.meta?.['field'] === 'string' ? diagnostic.meta['field'] : ''; + const section = field.split('.')[0] ?? ''; + return errorConfigValidation(field, { + why: diagnostic.summary, + ...(isConfigSection(section) ? { section } : {}), + }); + }); + // Subsections that failed stay exactly as authored; the rest are validated + // on their own, so a command that reads only them sees resolved paths, + // and are held to the same relationship and artifact rules. + const failing = new Set( + diagnostics.map((diagnostic) => String(diagnostic.meta?.['section'] ?? '')), + ); + const partial = validateOrmSectionExcept(rawConfig, provenance, failing); + const config = blindCast< + PrismaNextConfig, + 'a config with diagnostics is guarded by requireConfigSections before any subsection is read' + >(partial.ok ? partial.value : rawConfig); + if (!partial.ok) { + return { config, diagnostics }; + } + const related = descriptorRelationshipProblems(config, (section) => !failing.has(section)).map( + (problem) => + errorConfigValidation(problem.path.join('.'), { + why: `${problem.path.join('.')} must be ${problem.expected} (was ${problem.actual})`, + section: String(problem.path[0]), + }), + ); + const collisions = + failing.has('contract') || config.contract === undefined + ? [] + : collectArtifactCollisionDiagnostics(config.contract); + return { config, diagnostics: [...diagnostics, ...related, ...collisions] }; + } + const config = validation.value; + if (config.contract === undefined) { + return { config, diagnostics: [] }; + } + return { config, diagnostics: collectArtifactCollisionDiagnostics(config.contract) }; +} + /** * Validates a raw `orm` section and resolves its paths against `configDir`, the way a loaded config * file is. A caller that builds the section in memory gets the same diagnostics as one that wrote @@ -108,29 +161,11 @@ export function buildLoadedConfig( rawConfig: Record, configDir: string, ): LoadedConfig { - const issues = collectConfigIssues(rawConfig); - 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) }; - - 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 }; + const file = join(configDir, CONFIG_FILENAME); + return validateLoadedSection(rawConfig, { + files: [file], + keys: Object.fromEntries(Object.keys(rawConfig).map((key) => [key, file])), + }); } function toConfigLoadFailure(error: unknown, configPath?: string): CliStructuredError { @@ -206,10 +241,98 @@ async function evaluateWithC12( return ok(result); } +/** One evaluated config file: its path and its top-level sections. */ +export interface ConfigFile { + readonly path: string; + /** The file's top-level keys minus the ones the file format keeps for itself. */ + readonly sections: Readonly>; +} + +/** The evaluated config chain, before any section is validated. */ +export interface ConfigFiles { + /** The requested file first, then the files it extends. */ + readonly files: readonly ConfigFile[]; + /** c12's merge of every file, nearest file winning. */ + readonly merged: Readonly>; +} + +const FILE_FORMAT_KEYS = new Set(['$prismaConfig', 'extends']); + +function sectionsOf(exported: Record): Record { + return Object.fromEntries(Object.entries(exported).filter(([key]) => !FILE_FORMAT_KEYS.has(key))); +} + +/** + * Evaluates `prisma.config.ts` and hands back each file on the chain with its + * sections as written, plus c12's merge. Failures that prevent evaluation are + * the Result failure. Nothing is validated or resolved here: the CLI engine + * validates each section against its schema with the files' provenance, and + * {@link loadConfig} does the same for the `orm` section outside a command run. + */ +export async function loadConfigFiles( + configPath?: string, + options?: { readonly cwd?: string }, +): Promise> { + const cwd = options?.cwd ?? process.cwd(); + const resolvedConfigPath = configPath ? resolve(cwd, configPath) : undefined; + const configCwd = resolvedConfigPath ? dirname(resolvedConfigPath) : cwd; + + const evaluated = await evaluateWithC12(configPath, cwd); + if (!evaluated.ok) { + return evaluated; + } + const result = evaluated.value; + + if (!result.config || Object.keys(result.config).length === 0) { + /* v8 ignore next -- @preserve */ + const displayPath = result.configFile || resolvedConfigPath || configPath; + return notOk(errorConfigFileNotFound(displayPath)); + } + + // The marker is read from the raw module export in c12's first layer — the + // requested config file. (`extends` bases and rc files follow it, and their + // markers must not vouch for a file that does not carry one itself.) + /* v8 ignore next -- c12 always returns layers for a config it evaluated */ + const layers = result.layers ?? []; + const layerConfig = layers[0]?.config; + + // The engine's shape: definePrismaConfig from @prisma/cli-engine stamps the + // enumerable `$prismaConfig` key and nests the whole Prisma 8 config as + // the `orm` section. + const engineMarker = isRecord(layerConfig) ? layerConfig['$prismaConfig'] : undefined; + if (engineMarker === undefined) { + /* v8 ignore next -- a config that evaluated always carries its resolved path */ + return notOk(errorConfigVersionMarkerMissing(result.configFile ?? resolvedConfigPath)); + } + if (engineMarker !== 1) { + /* v8 ignore next -- a config that evaluated always carries its resolved path */ + return notOk(errorConfigVersionMarkerMissing(result.configFile ?? resolvedConfigPath)); + } + + /* v8 ignore next -- @preserve */ + const requested = result.configFile ?? join(configCwd, CONFIG_FILENAME); + // c12 adds rc and package.json layers with empty configs; only files that + // wrote something are on the chain. + const files = layers.flatMap((layer, index) => + isRecord(layer.config) && Object.keys(layer.config).length > 0 + ? [ + { + path: index === 0 ? requested : resolve(configCwd, layer.configFile ?? requested), + sections: sectionsOf(layer.config), + }, + ] + : [], + ); + return ok({ + files: files.length === 0 ? [{ path: requested, sections: sectionsOf(result.config) }] : files, + merged: result.config, + }); +} + /** * The raw default export of the module at `configPath`, evaluated exactly as - * {@link loadConfig} evaluates it and returned without validation, finalization, - * or the version-marker check. The caller decides what the export means — a + * {@link loadConfig} evaluates it and returned without validation or the + * version-marker check. The caller decides what the export means — a * default export without the `$prismaConfig` marker is what an earlier Prisma * CLI's config looks like. */ @@ -231,8 +354,28 @@ export async function evaluateConfigModule( return ok(requestedLayer?.config); } +/** Which file wrote each top-level key of the `orm` section, nearest file first. */ +function ormProvenance( + files: readonly ConfigFile[], + merged: Record, +): SectionProvenance { + const contributors = files.filter((file) => isRecord(file.sections['orm'])); + const paths = contributors.map((file) => file.path); + const keys = Object.fromEntries( + Object.keys(merged).flatMap((key) => { + const file = + contributors.find((contributor) => { + const orm = contributor.sections['orm']; + return isRecord(orm) && Object.hasOwn(orm, key); + })?.path ?? paths[0]; + return file === undefined ? [] : [[key, file]]; + }), + ); + return { files: paths, keys }; +} + /** - * Loads and finalizes the Prisma 8 config. + * Loads the Prisma 8 config and validates its `orm` section. * * Failures that prevent evaluation entirely — missing file, module that does * not evaluate (`CONFIG.FILE_NOT_FOUND`, `CONFIG.EVALUATION_FAILED`) — are the @@ -244,58 +387,36 @@ export async function loadConfig( configPath?: string, options?: { readonly cwd?: string }, ): Promise> { - const cwd = options?.cwd ?? process.cwd(); - const resolvedConfigPath = configPath ? resolve(cwd, configPath) : undefined; - const configCwd = resolvedConfigPath ? dirname(resolvedConfigPath) : cwd; - - const evaluated = await evaluateWithC12(configPath, cwd); - if (!evaluated.ok) { - return evaluated; - } - const result = evaluated.value; - - if (!result.config || Object.keys(result.config).length === 0) { - /* v8 ignore next -- @preserve */ - const displayPath = result.configFile || resolvedConfigPath || configPath; - return notOk(errorConfigFileNotFound(displayPath)); + const loaded = await loadConfigFiles(configPath, options); + if (!loaded.ok) { + return loaded; } - - /* v8 ignore next -- @preserve */ - const loadedConfigDir = result.configFile ? dirname(result.configFile) : configCwd; - - // The marker is read from the raw module export in c12's first layer — the - // requested config file. (`extends` bases and rc files follow it, and their - // markers must not vouch for a file that does not carry one itself.) - /* v8 ignore next -- c12 always returns layers for a config it evaluated */ - const [requestedLayer] = result.layers ?? []; - const layerConfig = requestedLayer?.config; - - // The engine's shape: definePrismaConfig from @prisma/cli-engine stamps the - // enumerable `$prismaConfig` key and nests the whole Prisma 8 config as - // the `orm` section. - const engineMarker = isRecord(layerConfig) ? layerConfig['$prismaConfig'] : undefined; - if (engineMarker !== undefined) { - if (engineMarker !== 1) { - /* v8 ignore next -- a config that evaluated always carries its resolved path */ - return notOk(errorConfigVersionMarkerMissing(result.configFile ?? resolvedConfigPath)); - } - const orm = result.config['orm']; - if (orm !== undefined && !isRecord(orm)) { - const base = buildLoadedConfig({}, loadedConfigDir); - return ok({ - config: base.config, - diagnostics: [ - errorConfigValidation('orm', { - why: `The orm section of ${CONFIG_FILENAME} must be an object`, - }), - ], - }); - } - return ok(buildLoadedConfig(orm ?? {}, loadedConfigDir)); + const orm = loaded.value.merged['orm']; + if (orm !== undefined && !isRecord(orm)) { + const base = validateLoadedSection({}, { files: [], keys: {} }); + return ok({ + config: base.config, + diagnostics: [ + errorConfigValidation('orm', { + why: `The orm section of ${CONFIG_FILENAME} must be an object`, + }), + ], + }); } - - /* v8 ignore next -- a config that evaluated always carries its resolved path */ - return notOk(errorConfigVersionMarkerMissing(result.configFile ?? resolvedConfigPath)); + const section = orm ?? {}; + const provenance = ormProvenance(loaded.value.files, section); + const requested = loaded.value.files[0]?.path; + return ok( + validateLoadedSection( + section, + provenance.files.length === 0 && requested !== undefined + ? { + files: [requested], + keys: Object.fromEntries(Object.keys(section).map((key) => [key, requested])), + } + : provenance, + ), + ); } /** diff --git a/packages/1-framework/3-tooling/config-loader/src/orm-section.ts b/packages/1-framework/3-tooling/config-loader/src/orm-section.ts new file mode 100644 index 000000000000..dc0107a08da0 --- /dev/null +++ b/packages/1-framework/3-tooling/config-loader/src/orm-section.ts @@ -0,0 +1,219 @@ +import type { PrismaNextConfig } from '@internal/config/config-types'; +import { blindCast } from '@internal/utils/casts'; +import type { ConfigSection as EngineConfigSection, SectionProvenance } from '@prisma/cli-engine'; +import { + configSchema, + defineConfigSection, + reference, + validateSectionWithSchema, +} from '@prisma/cli-engine'; + +/** The single config section the `orm` command family owns. */ +export const ORM_CONFIG_SECTION_NAME = 'orm'; + +/** + * Top-level subsections of the `orm` section. Diagnostics carry the + * subsection they concern so commands can fail on the ones they read and + * ignore the rest. + */ +export type ConfigSection = + | 'family' + | 'target' + | 'adapter' + | 'driver' + | 'extensions' + | 'db' + | 'contract' + | 'migrations' + | 'formatter'; + +const CONFIG_SECTIONS: readonly ConfigSection[] = [ + 'family', + 'target', + 'adapter', + 'driver', + 'extensions', + 'db', + 'contract', + 'migrations', + 'formatter', +]; + +export function isConfigSection(value: string): value is ConfigSection { + return CONFIG_SECTIONS.some((section) => section === value); +} + +/** + * The fields that identify a control descriptor. Only these are declared: a + * descriptor is a runtime object the family builds, and its other members + * (codec tables, the contract serializer, migration hooks) pass through. Each + * descriptor is declared a reference, so the command receives the object the + * config file built. + */ +const descriptorFields = { + id: 'string', + familyId: 'string', + version: 'string', + create: 'Function', +} as const; + +const targetLikeFields = { ...descriptorFields, targetId: 'string' } as const; + +/** + * The contract source provider: `load` closes over the authored contract, + * and `inputs` are paths the schema resolves against the config file. Other + * keys a provider carries pass through. + */ +const contractSource = { + load: 'Function', + 'inputs?': 'path[]', + 'format?': 'string', +} as const; + +/** Each subsection's own shape, without the rules that relate subsections to one another. */ +const ormSubsectionsSchema = configSchema({ + family: reference(configSchema({ kind: "'family'", ...descriptorFields, emission: 'object' })), + target: reference(configSchema({ kind: "'target'", ...targetLikeFields })), + adapter: reference(configSchema({ kind: "'adapter'", ...targetLikeFields })), + 'driver?': reference(configSchema({ kind: "'driver'", ...targetLikeFields })), + 'extensions?': [reference(configSchema({ kind: "'extension'", ...targetLikeFields })), '[]'], + 'db?': { 'connection?': reference(configSchema('unknown')) }, + 'contract?': { + source: contractSource, + output: ['path', '=', () => 'src/prisma/contract.json'], + }, + migrations: [{ dir: ['path', '=', () => './migrations'] }, '=', () => ({})], + 'formatter?': { 'indent?': "number.integer >= 1 | 'tab'", 'newline?': "'LF' | 'CRLF'" }, +}); + +interface RelatedDescriptor { + readonly familyId: string; + readonly targetId: string; +} + +/** The subsections the relationship rules read, each present only if it validated. */ +interface DescriptorSubsections { + readonly family?: { readonly familyId: string }; + readonly target?: RelatedDescriptor; + readonly adapter?: RelatedDescriptor; + readonly driver?: RelatedDescriptor | undefined; + readonly extensions?: readonly RelatedDescriptor[] | undefined; +} + +/** A broken rule relating two subsections, at the field that breaks it. */ +export interface RelationshipProblem { + readonly path: readonly PropertyKey[]; + readonly expected: string; + readonly actual: string; +} + +/** + * Every broken rule relating the descriptor subsections: each descriptor + * shares the family's `familyId`, and each target-bound descriptor shares + * the target's `targetId`. A rule is checked only when `checked` accepts + * both subsections it relates, so a loader can check the subsections that + * validated while others have diagnostics. + */ +export function descriptorRelationshipProblems( + config: DescriptorSubsections, + checked: (section: ConfigSection) => boolean, +): RelationshipProblem[] { + const family = checked('family') ? config.family : undefined; + const target = checked('target') ? config.target : undefined; + const bound: ReadonlyArray = [ + [['target'], target], + [['adapter'], checked('adapter') ? config.adapter : undefined], + [['driver'], checked('driver') ? config.driver : undefined], + ...(checked('extensions') ? (config.extensions ?? []) : []).map( + (extension, index) => [['extensions', index], extension] as const, + ), + ]; + const problems: RelationshipProblem[] = []; + for (const [path, descriptor] of bound) { + if (descriptor === undefined) { + continue; + } + if (family !== undefined && descriptor.familyId !== family.familyId) { + problems.push({ + path: [...path, 'familyId'], + expected: `Config.family.familyId (${family.familyId})`, + actual: descriptor.familyId, + }); + } + if (target !== undefined && path[0] !== 'target' && descriptor.targetId !== target.targetId) { + problems.push({ + path: [...path, 'targetId'], + expected: `Config.target.targetId (${target.targetId})`, + actual: descriptor.targetId, + }); + } + } + return problems; +} + +/** + * The shape of the `orm` section of `prisma.config.ts`, declared once. The + * CLI engine derives validation, the diagnostics naming a bad field and the + * file to fix, and the resolution of every `path` field against the config + * file that wrote it (prisma-cli ADR 0005). Relative paths are resolved + * before any command sees the value, and the value carries `baseDir`. + */ +export const ormConfigSchema = ormSubsectionsSchema.narrow((config, ctx) => { + if ('extensionPacks' in config) { + return ctx.reject({ + path: ['extensionPacks'], + message: 'Config.extensionPacks is no longer supported; rename it to Config.extensions', + }); + } + const problems = descriptorRelationshipProblems(config, () => true); + for (const problem of problems) { + ctx.error({ path: [...problem.path], expected: problem.expected, actual: problem.actual }); + } + return problems.length === 0; +}); + +/** + * The section token the ORM's commands declare in `needs.config`. The + * engine validates the merged section with its provenance and hands the + * handler the value: absolute paths, `migrations.dir` defaulted, `baseDir` + * recorded. + */ +export const ormConfigSection: EngineConfigSection = blindCast< + EngineConfigSection, + 'the schema declares the shape PrismaNextConfig describes; descriptor generics are erased by validation' +>(defineConfigSection({ name: ORM_CONFIG_SECTION_NAME, schema: ormConfigSchema })); + +/** + * Validates a raw `orm` section the way the engine does for a command, + * for the loaders and tools that read the config outside a command run. + */ +export function validateOrmSection( + raw: unknown, + provenance: SectionProvenance, +): ReturnType { + return blindCast< + ReturnType, + 'the schema declares the shape PrismaNextConfig describes; descriptor generics are erased by validation' + >(validateSectionWithSchema(ORM_CONFIG_SECTION_NAME, ormConfigSchema, raw, provenance)); +} + +/** + * Validates the shape of every subsection except `failing`, which comes back + * as authored. A loader uses it when some subsections have diagnostics, so a + * caller that reads only the others still gets their paths resolved and + * defaults applied. The rules relating subsections are not applied here; see + * {@link descriptorRelationshipProblems}. + */ +export function validateOrmSectionExcept( + raw: unknown, + provenance: SectionProvenance, + failing: ReadonlySet, +): ReturnType { + const healthy = CONFIG_SECTIONS.filter((section) => !failing.has(section)); + return validateSectionWithSchema( + ORM_CONFIG_SECTION_NAME, + ormSubsectionsSchema.pick(...healthy), + raw, + provenance, + ); +} diff --git a/packages/1-framework/3-tooling/config-loader/test/build-loaded-config.test.ts b/packages/1-framework/3-tooling/config-loader/test/build-loaded-config.test.ts index bcebeb50a9c5..6631e1dc0efb 100644 --- a/packages/1-framework/3-tooling/config-loader/test/build-loaded-config.test.ts +++ b/packages/1-framework/3-tooling/config-loader/test/build-loaded-config.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { buildLoadedConfig } from '../src/exports/index'; +import { buildLoadedConfig, requireConfigSections } from '../src/exports/index'; const load = async () => ({ ok: true, value: {} }); @@ -22,6 +22,22 @@ describe('buildLoadedConfig', () => { }); }); + it('returns a section it cannot read as authored, with a diagnostic that blocks every subsection', () => { + const raw = { + get family(): never { + throw new Error('boom'); + }, + }; + + const loaded = buildLoadedConfig(raw, '/project'); + + expect(loaded.config).toBe(raw); + expect(loaded.diagnostics).toContainEqual( + expect.objectContaining({ code: 'CONFIG.VALIDATION_FAILED' }), + ); + expect(requireConfigSections(loaded, ['contract']).ok).toBe(false); + }); + it('tags a diagnostic with the section it concerns', () => { expect(sectionsWithDiagnostics({ contract: { source: { load } } })).not.toContain('contract'); expect(sectionsWithDiagnostics({ contract: 'prisma/schema.prisma' })).toContain('contract'); 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 7fda67e48366..6c38a8861ab7 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 @@ -4,7 +4,12 @@ import { join } from 'node:path'; import { getEmittedArtifactPaths } from '@internal/emitter'; import { timeouts } from '@repo/test-utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { findNearestConfigPathForFile, loadConfig, loadConfigForFile } from '../src/load'; +import { + findNearestConfigPathForFile, + loadConfig, + loadConfigForFile, + requireConfigSections, +} from '../src/load'; vi.mock('@internal/emitter', async (importOriginal) => { const actual = await importOriginal(); @@ -205,6 +210,88 @@ describe('loadConfig', () => { timeouts.typeScriptCompilation, ); + it( + 'resolves the paths of healthy subsections when another subsection is invalid', + async () => { + writeFileSync( + join(tempDir, 'prisma.config.ts'), + `${CONFIG_BODY}\nObject.assign(config, { formatter: { indent: 0 } });\n${NEW_SHAPE_STAMP}`, + ); + process.chdir(tempDir); + + const loaded = (await loadConfig()).assertOk(); + const config = requireConfigSections(loaded, ['contract', 'migrations']).assertOk(); + + expect(loaded.diagnostics).toContainEqual( + expect.objectContaining({ meta: { field: 'formatter.indent', section: 'formatter' } }), + ); + expect(config.contract?.output).toBe(join(tempDir, 'generated', 'contract.json')); + expect(config.contract?.source.inputs).toEqual([join(tempDir, 'schema.prisma')]); + expect(config.migrations?.dir).toBe(join(tempDir, 'migrations')); + expect(config.formatter).toEqual({ indent: 0 }); + }, + timeouts.typeScriptCompilation, + ); + + it( + 'checks the contract artifacts when another subsection is invalid', + async () => { + const colliding = CONFIG_BODY.replace( + "inputs: ['./schema.prisma']", + "inputs: ['./generated/contract.json']", + ); + writeFileSync( + join(tempDir, 'prisma.config.ts'), + `${colliding}\nObject.assign(config, { formatter: { indent: 0 } });\n${NEW_SHAPE_STAMP}`, + ); + process.chdir(tempDir); + + const loaded = (await loadConfig()).assertOk(); + + expect(loaded.diagnostics).toContainEqual( + expect.objectContaining({ + meta: { field: 'contract.source.inputs[]', section: 'contract' }, + }), + ); + expect(requireConfigSections(loaded, ['contract']).ok).toBe(false); + }, + timeouts.typeScriptCompilation, + ); + + it( + 'checks how descriptors relate when another subsection is invalid', + async () => { + writeFileSync( + join(tempDir, 'prisma.config.ts'), + `${CONFIG_BODY}\nObject.assign(config.target, { familyId: 'mongo' });\nObject.assign(config, { formatter: { indent: 0 } });\n${NEW_SHAPE_STAMP}`, + ); + process.chdir(tempDir); + + const loaded = (await loadConfig()).assertOk(); + + expect(loaded.diagnostics).toContainEqual( + expect.objectContaining({ meta: { field: 'target.familyId', section: 'target' } }), + ); + expect(requireConfigSections(loaded, ['target']).ok).toBe(false); + expect(requireConfigSections(loaded, ['contract']).ok).toBe(true); + }, + timeouts.typeScriptCompilation, + ); + + it( + 'refuses a config whose default export is not an object', + async () => { + writeFileSync(join(tempDir, 'prisma.config.ts'), 'export default [1];\n'); + process.chdir(tempDir); + + const result = await loadConfig(); + + expect(result.ok).toBe(false); + expect(!result.ok && result.failure.code).toBe('CONFIG.VERSION_MARKER_MISSING'); + }, + timeouts.typeScriptCompilation, + ); + it( 'reads an empty orm section from a config that declares none', async () => { diff --git a/packages/1-framework/3-tooling/config-loader/test/orm-section.test.ts b/packages/1-framework/3-tooling/config-loader/test/orm-section.test.ts new file mode 100644 index 000000000000..57baf3cf6564 --- /dev/null +++ b/packages/1-framework/3-tooling/config-loader/test/orm-section.test.ts @@ -0,0 +1,261 @@ +import type { SectionProvenance } from '@prisma/cli-engine'; +import { describe, expect, it } from 'vitest'; +import { + descriptorRelationshipProblems, + ormConfigSection, + validateOrmSection, +} from '../src/orm-section'; + +const FILE = '/project/prisma.config.ts'; + +function provenanceFor(raw: Record): SectionProvenance { + return { files: [FILE], keys: Object.fromEntries(Object.keys(raw).map((key) => [key, FILE])) }; +} + +const descriptorBase = { + familyId: 'sql', + targetId: 'postgres', + version: '1.0.0', + create: () => ({}), +}; + +function validRaw(overrides: Record = {}): Record { + return { + family: { + kind: 'family', + id: 'sql', + familyId: 'sql', + version: '1.0.0', + emission: {}, + create: () => ({}), + }, + target: { ...descriptorBase, kind: 'target', id: 'postgres' }, + adapter: { ...descriptorBase, kind: 'adapter', id: 'pg' }, + ...overrides, + }; +} + +function fields(raw: Record): readonly string[] { + const result = validateOrmSection(raw, provenanceFor(raw)); + return result.ok + ? [] + : result.diagnostics.map((diagnostic) => String(diagnostic.meta?.['field'])); +} + +describe('the orm section', () => { + it('is the section the commands declare', () => { + expect(ormConfigSection.name).toBe('orm'); + }); + + it('supplies the default contract output next to the default source directory', () => { + const raw = validRaw({ contract: { source: { load: () => ({}) } } }); + + const result = validateOrmSection(raw, provenanceFor(raw)); + + expect(result.ok && result.value.contract?.output).toBe('/project/src/prisma/contract.json'); + }); + + it('accepts a valid config, supplies the migrations dir, and records baseDir', () => { + const raw = validRaw(); + + const result = validateOrmSection(raw, provenanceFor(raw)); + + expect(result.ok && result.value).toMatchObject({ + target: { targetId: 'postgres' }, + migrations: { dir: '/project/migrations' }, + baseDir: '/project', + }); + }); + + it('resolves contract inputs, output and migrations dir against the config file', () => { + const raw = validRaw({ + contract: { + source: { format: 'psl', inputs: ['./schema.prisma'], load: () => ({}) }, + output: 'out/contract.json', + }, + migrations: { dir: './db' }, + }); + + const result = validateOrmSection(raw, provenanceFor(raw)); + + expect(result.ok && result.value).toMatchObject({ + contract: { + source: { inputs: ['/project/schema.prisma'] }, + output: '/project/out/contract.json', + }, + migrations: { dir: '/project/db' }, + }); + }); + + it('resolves glob pattern inputs against the config file and keeps the pattern', () => { + const raw = validRaw({ + contract: { + source: { load: () => ({}), inputs: ['./prisma/**/*.prisma', './extra.prisma'] }, + }, + }); + + const result = validateOrmSection(raw, provenanceFor(raw)); + + expect(result.ok && result.value.contract?.source.inputs).toEqual([ + '/project/prisma/**/*.prisma', + '/project/extra.prisma', + ]); + }); + + it('leaves an absolute path alone', () => { + const raw = validRaw({ migrations: { dir: '/elsewhere/db' } }); + + const result = validateOrmSection(raw, provenanceFor(raw)); + + expect(result.ok && result.value.migrations?.dir).toBe('/elsewhere/db'); + }); + + it('reports each missing descriptor under its own field', () => { + expect(fields({})).toEqual(['adapter', 'family', 'target']); + }); + + it('reports descriptor field problems', () => { + expect(fields(validRaw({ family: { kind: 'family' } }))).toEqual([ + 'family.create', + 'family.emission', + 'family.familyId', + 'family.id', + 'family.version', + ]); + }); + + it('reports a family mismatch on the target', () => { + expect( + fields( + validRaw({ target: { ...descriptorBase, kind: 'target', id: 'x', familyId: 'mongo' } }), + ), + ).toEqual(['target.familyId']); + }); + + it('reports adapter, driver and extension mismatches against the target', () => { + expect( + fields( + validRaw({ adapter: { ...descriptorBase, kind: 'adapter', id: 'pg', targetId: 'mysql' } }), + ), + ).toEqual(['adapter.targetId']); + expect( + fields( + validRaw({ driver: { ...descriptorBase, kind: 'driver', id: 'd', familyId: 'mongo' } }), + ), + ).toEqual(['driver.familyId']); + expect( + fields( + validRaw({ + extensions: [ + { ...descriptorBase, kind: 'extension', id: 'e' }, + { ...descriptorBase, kind: 'extension', id: 'f', targetId: 'mysql' }, + ], + }), + ), + ).toEqual(['extensions.1.targetId']); + }); + + it('checks a relationship only between subsections the caller accepts', () => { + const mismatched = { familyId: 'mongo', targetId: 'mysql' }; + const config = { + family: { familyId: 'sql' }, + target: { familyId: 'sql', targetId: 'postgres' }, + driver: mismatched, + extensions: [mismatched], + }; + + expect(descriptorRelationshipProblems(config, () => true).map((p) => p.path)).toEqual([ + ['driver', 'familyId'], + ['driver', 'targetId'], + ['extensions', 0, 'familyId'], + ['extensions', 0, 'targetId'], + ]); + expect( + descriptorRelationshipProblems( + config, + (section) => section !== 'driver' && section !== 'extensions', + ), + ).toEqual([]); + }); + + it('reports the removed extensionPacks key', () => { + expect(fields(validRaw({ extensionPacks: [] }))).toEqual(['extensionPacks']); + }); + + it('reports contract problems under contract fields', () => { + expect( + fields(validRaw({ contract: { source: { inputs: ['a', 1], load: () => ({}) }, output: 3 } })), + ).toEqual(['contract.source.inputs.1', 'contract.output']); + expect(fields(validRaw({ contract: {} }))).toEqual(['contract.source']); + }); + + it('reports migrations and formatter problems', () => { + expect(fields(validRaw({ migrations: { dir: 42 } }))).toEqual(['migrations.dir']); + expect(fields(validRaw({ formatter: { indent: 0, newline: 'CR' } }))).toEqual([ + 'formatter.indent', + 'formatter.newline', + ]); + expect(fields(validRaw({ formatter: { indent: 'tab', newline: 'LF' } }))).toEqual([]); + }); + + it('keeps every descriptor and the connection as the config file built them', () => { + class Serializer { + deserializeContract(json: unknown): unknown { + return json; + } + } + const target = { + ...descriptorBase, + kind: 'target', + id: 'postgres', + contractSerializer: new Serializer(), + create() { + return this.id; + }, + }; + const source = { format: 'psl', inputs: ['./schema.prisma'], load: () => ({}) }; + const connection = { pool: new Serializer() }; + const raw = validRaw({ target, contract: { source }, db: { connection } }); + + const result = validateOrmSection(raw, provenanceFor(raw)); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + expect(result.value.target).toBe(target); + expect(result.value.family).toBe(raw['family']); + expect(result.value.adapter).toBe(raw['adapter']); + expect(result.value.db?.connection).toBe(connection); + expect((result.value.target as unknown as typeof target).create()).toBe('postgres'); + expect(result.value.contract?.source.load).toBe(source.load); + expect(result.value.contract?.source.inputs).toEqual(['/project/schema.prisma']); + }); + + it('keeps fields the schema does not name, on descriptors and on the contract source', () => { + const raw = validRaw({ + family: { ...(validRaw()['family'] as object), manifest: { note: 1 } }, + contract: { source: { load: () => ({}), dialect: 'sql' } }, + }); + + const result = validateOrmSection(raw, provenanceFor(raw)); + + expect(result.ok && result.value).toMatchObject({ + family: { manifest: { note: 1 } }, + contract: { source: { dialect: 'sql' } }, + }); + }); + + it('never throws on hostile input', () => { + for (const raw of [ + null, + 7, + [], + { family: 1 }, + { + get family() { + throw new Error('boom'); + }, + }, + ]) { + expect(() => validateOrmSection(raw, { files: [FILE], keys: {} })).not.toThrow(); + } + }); +}); 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 5671fb5b1bb9..dcffa2aa5863 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 { expandContractInputs, 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 dd7162a55be8..750b5dd3edda 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 @@ -37,6 +37,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, }; @@ -255,7 +256,7 @@ describe('prismaVitePlugin', () => { expect(mockedExecuteContractEmit).toHaveBeenCalledWith( expect.objectContaining({ - configPath: '/project/prisma.config.ts', + projectDir: '/project', }), ); }); @@ -274,7 +275,7 @@ describe('prismaVitePlugin', () => { expect(mockedExecuteContractEmit).toHaveBeenCalledWith( expect.objectContaining({ - configPath: '/absolute/prisma.config.ts', + projectDir: '/absolute', }), ); }); @@ -425,7 +426,7 @@ describe('prismaVitePlugin', () => { expect(mockedExecuteContractEmit).toHaveBeenCalledWith( expect.objectContaining({ - configPath: expect.stringContaining('prisma.config.ts'), + projectDir: expect.any(String), }), ); }); @@ -546,7 +547,7 @@ describe('prismaVitePlugin', () => { ); expect(mockedExecuteContractEmit).toHaveBeenCalledWith( expect.objectContaining({ - configPath: '/project/prisma.config.ts', + projectDir: '/project', }), ); expect(consoleErrorSpy).not.toHaveBeenCalledWith( diff --git a/packages/2-mongo-family/2-authoring/contract-ts/test/enum-type.authoring.test.ts b/packages/2-mongo-family/2-authoring/contract-ts/test/enum-type.authoring.test.ts index 543a511b6905..7e899336666a 100644 --- a/packages/2-mongo-family/2-authoring/contract-ts/test/enum-type.authoring.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-ts/test/enum-type.authoring.test.ts @@ -1,5 +1,5 @@ import type { AnyCodecDescriptor, Codec } from '@internal/framework-components/codec'; -import { dataTypeId, voidParamsSchema } from '@internal/framework-components/codec'; +import { dataTypeId } from '@internal/framework-components/codec'; import type { FamilyPackRef, TargetPackRef } from '@internal/framework-components/components'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { MongoContractSchema } from '@internal/mongo-contract'; @@ -20,7 +20,7 @@ const identityDescriptor = (id: string): AnyCodecDescriptor => ({ dataType: dataTypeId('demo/fixture'), traits: ['equality'], targetTypes: ['string'], - paramsSchema: voidParamsSchema, + paramsSchema: undefined, isParameterized: false, factory: () => () => ({ @@ -269,7 +269,7 @@ describe('defineContract() — codec-encoded value set', () => { dataType: dataTypeId('test/upper'), traits: ['equality'], targetTypes: ['string'], - paramsSchema: voidParamsSchema, + paramsSchema: undefined, isParameterized: false, factory: () => () => ({ diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/provider.test.ts b/packages/2-sql/2-authoring/contract-prisma7/test/provider.test.ts index 3f8f927f1894..f48434c98ca0 100644 --- a/packages/2-sql/2-authoring/contract-prisma7/test/provider.test.ts +++ b/packages/2-sql/2-authoring/contract-prisma7/test/provider.test.ts @@ -107,7 +107,7 @@ describe('prisma7Contract', () => { writeFileSync(join(dir, 'b.prisma'), 'model B {\n id Int\n}\n'); writeFileSync(join(dir, 'c.prisma'), 'model C {\n id Int\n}\n'); - // `dir` stands in for the absolute path finalizeConfig would have + // `dir` stands in for the absolute path the orm config schema would have // produced from a relative schema path resolved against the config // directory — expandContractInputs only ever sees already-absolute // patterns in production. diff --git a/packages/2-sql/2-authoring/contract-psl/test/fixture-codec-descriptors.ts b/packages/2-sql/2-authoring/contract-psl/test/fixture-codec-descriptors.ts index df557e3e3680..ad83fab9ac17 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/fixture-codec-descriptors.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/fixture-codec-descriptors.ts @@ -6,12 +6,11 @@ */ import type { JsonValue } from '@internal/contract/types'; -import { - type AnyCodecDescriptor, - type CodecLookup, - type CodecTrait, - type DataTypeId, - voidParamsSchema, +import type { + AnyCodecDescriptor, + CodecLookup, + CodecTrait, + DataTypeId, } from '@internal/framework-components/codec'; import { blindCast } from '@internal/utils/casts'; import { @@ -194,7 +193,7 @@ function fixtureDescriptor(codecId: string): AnyCodecDescriptor | undefined { dataType: dataTypeByCodecId[codecId] ?? pgText.id, traits: codec.traits, targetTypes: targetTypesByCodecId[codecId] ?? [], - paramsSchema: parameterized ? vectorParamsSchema : voidParamsSchema, + paramsSchema: parameterized ? vectorParamsSchema : undefined, isParameterized: parameterized, factory: (params: unknown) => () => ({ id: codecId, diff --git a/packages/2-sql/4-lanes/relational-core/README.md b/packages/2-sql/4-lanes/relational-core/README.md index 8be11214a853..b81f7d41096d 100644 --- a/packages/2-sql/4-lanes/relational-core/README.md +++ b/packages/2-sql/4-lanes/relational-core/README.md @@ -105,7 +105,6 @@ SQL codec authors extend the framework `CodecImpl` base (and pair the codec with import { CodecDescriptorImpl, CodecImpl, - voidParamsSchema, type CodecCallContext, type CodecInstanceContext, } from '@internal/framework-components/codec'; @@ -123,7 +122,7 @@ class PgTextDescriptor extends CodecDescriptorImpl { override readonly codecId = 'pg/text@1'; override readonly traits = ['equality'] as const; override readonly targetTypes = ['text'] as const; - override readonly paramsSchema = voidParamsSchema; + override readonly paramsSchema = undefined; override readonly factory = () => (_ctx: CodecInstanceContext) => new PgTextCodec(); } ``` diff --git a/packages/2-sql/4-lanes/relational-core/src/ast/sql-codecs.ts b/packages/2-sql/4-lanes/relational-core/src/ast/sql-codecs.ts index f877aa672265..86bf30dfa379 100644 --- a/packages/2-sql/4-lanes/relational-core/src/ast/sql-codecs.ts +++ b/packages/2-sql/4-lanes/relational-core/src/ast/sql-codecs.ts @@ -18,7 +18,6 @@ import { type ColumnHelperFor, type ColumnHelperForStrict, column, - voidParamsSchema, } from '@internal/framework-components/codec'; import type { StandardSchemaV1 } from '@standard-schema/spec'; import { type as arktype } from 'arktype'; @@ -74,7 +73,7 @@ export class SqlTextDescriptor extends CodecDescriptorTemplateImpl { override readonly codecId = SQL_TEXT_CODEC_ID; override readonly traits = ['equality', 'order', 'textual'] as const; override readonly targetTypes = ['text'] as const; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => SqlTextCodec { return () => new SqlTextCodec(this); } @@ -112,7 +111,7 @@ export class SqlIntDescriptor extends CodecDescriptorTemplateImpl { override readonly codecId = SQL_INT_CODEC_ID; override readonly traits = ['equality', 'order', 'numeric'] as const; override readonly targetTypes = ['int'] as const; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => SqlIntCodec { return () => new SqlIntCodec(this); } @@ -150,7 +149,7 @@ export class SqlFloatDescriptor extends CodecDescriptorTemplateImpl { override readonly codecId = SQL_FLOAT_CODEC_ID; override readonly traits = ['equality', 'order', 'numeric'] as const; override readonly targetTypes = ['float'] as const; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => SqlFloatCodec { return () => new SqlFloatCodec(this); } diff --git a/packages/2-sql/4-lanes/relational-core/test/ast/sql-codec-helpers.test.ts b/packages/2-sql/4-lanes/relational-core/test/ast/sql-codec-helpers.test.ts index 5a8c61e4fa06..26db74f44259 100644 --- a/packages/2-sql/4-lanes/relational-core/test/ast/sql-codec-helpers.test.ts +++ b/packages/2-sql/4-lanes/relational-core/test/ast/sql-codec-helpers.test.ts @@ -53,9 +53,9 @@ describe('sql-codec-helpers', () => { targetTypes: ['varchar'], hasParamsSchema: true, }, - { scalar: 'int', id: SQL_INT_CODEC_ID, targetTypes: ['int'], hasParamsSchema: true }, - { scalar: 'float', id: SQL_FLOAT_CODEC_ID, targetTypes: ['float'], hasParamsSchema: true }, - { scalar: 'text', id: SQL_TEXT_CODEC_ID, targetTypes: ['text'], hasParamsSchema: true }, + { scalar: 'int', id: SQL_INT_CODEC_ID, targetTypes: ['int'], hasParamsSchema: false }, + { scalar: 'float', id: SQL_FLOAT_CODEC_ID, targetTypes: ['float'], hasParamsSchema: false }, + { scalar: 'text', id: SQL_TEXT_CODEC_ID, targetTypes: ['text'], hasParamsSchema: false }, ]; it.each(codecDefinitionCases)( diff --git a/packages/2-sql/4-lanes/relational-core/test/typed-codec-flow.test-d.ts b/packages/2-sql/4-lanes/relational-core/test/typed-codec-flow.test-d.ts index 26dc210d9583..d7391f701e00 100644 --- a/packages/2-sql/4-lanes/relational-core/test/typed-codec-flow.test-d.ts +++ b/packages/2-sql/4-lanes/relational-core/test/typed-codec-flow.test-d.ts @@ -20,9 +20,7 @@ import { type CodecInstanceContext, type CodecTrait, dataTypeId, - voidParamsSchema, } from '@internal/framework-components/codec'; -import type { StandardSchemaV1 } from '@standard-schema/spec'; import { expectTypeOf, test } from 'vitest'; import type { SqlIntCodec, @@ -73,7 +71,7 @@ class TestVectorDescriptor extends CodecDescriptorImpl { override readonly codecId = 'test/vector@1' as const; override readonly traits = ['equality'] as const; override readonly targetTypes = ['vector'] as const; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => TestVectorCodec { return () => new TestVectorCodec(this); } diff --git a/packages/2-sql/5-runtime/src/sql-context.ts b/packages/2-sql/5-runtime/src/sql-context.ts index 05ac2a745784..bc210491b743 100644 --- a/packages/2-sql/5-runtime/src/sql-context.ts +++ b/packages/2-sql/5-runtime/src/sql-context.ts @@ -66,7 +66,10 @@ import { createAstCodecResolver } from './codecs/ast-codec-resolver'; * * Codec-registry-unification spec § Decision. */ -export type RuntimeParameterizedCodecDescriptor

> = CodecDescriptor

; +export type RuntimeParameterizedCodecDescriptor

> = + CodecDescriptor

& { + readonly paramsSchema: NonNullable['paramsSchema']>; + }; /** * Contributor protocol for SQL components (target, adapter, extension pack). The unified `codecs:` slot returns the full {@link CodecDescriptor} list — non-parameterized and parameterized descriptors live side-by-side in the same array. The framework dispatches every codec id through the unified descriptor map without branching on parameterization. @@ -262,7 +265,7 @@ function validateTypeParams( } /** - * Collect every {@link CodecDescriptor} contributed by the SQL stack and partition into "parameterized" vs "non-parameterized" via the descriptor's own {@link CodecDescriptorImpl.isParameterized} getter. The getter is the canonical discriminator — a `paramsSchema` identity check would misroute any descriptor that doesn't reuse the exact `voidParamsSchema` singleton (e.g. a non-parameterized codec authoring its own no-op schema). + * Collect every {@link CodecDescriptor} contributed by the SQL stack and partition into "parameterized" vs "non-parameterized" via the descriptor's own {@link CodecDescriptorImpl.isParameterized} getter: a descriptor is parameterized when it has a `paramsSchema`. * * The unified descriptor list collapses the legacy split (a separate slot used to register parameterized codecs) — every codec id resolves through the same map (codec-registry-unification spec § Decision). */ @@ -415,13 +418,14 @@ function assertColumnCodecIntegrity( ); } - if (descriptor.isParameterized && ref.typeParams === undefined) { + const paramsSchema = descriptor.paramsSchema; + if (paramsSchema !== undefined && ref.typeParams === undefined) { // Some parameterized codecs declare every paramsSchema field as optional // (e.g. `pg/timestamptz-temporal@1` precision). Defer to the descriptor's own // schema rather than rejecting purely on structural absence: probe the // schema with an empty params object and only fail when the schema // rejects it (i.e. at least one field is required). - const probe = descriptor.paramsSchema['~standard'].validate({}); + const probe = paramsSchema['~standard'].validate({}); if (probe instanceof Promise) { // Swallow the probe Promise's rejection so Node doesn't warn about an // unhandled rejection once we throw synchronously below. diff --git a/packages/2-sql/5-runtime/test/ast-codec-resolver.test.ts b/packages/2-sql/5-runtime/test/ast-codec-resolver.test.ts index 9b1ce8f9d168..4812e6d8c25d 100644 --- a/packages/2-sql/5-runtime/test/ast-codec-resolver.test.ts +++ b/packages/2-sql/5-runtime/test/ast-codec-resolver.test.ts @@ -4,11 +4,12 @@ import type { CodecDescriptor, CodecRef, } from '@internal/framework-components/codec'; -import { dataTypeId, voidParamsSchema } from '@internal/framework-components/codec'; +import { dataTypeId } from '@internal/framework-components/codec'; import type { Codec, SqlCodecInstanceContext } from '@internal/sql-relational-core/ast'; import { buildCodecDescriptorRegistry } from '@internal/sql-relational-core/codec-descriptor-registry'; import { describe, expect, it, vi } from 'vitest'; import { createAstCodecResolver } from '../src/codecs/ast-codec-resolver'; +import type { RuntimeParameterizedCodecDescriptor } from '../src/sql-context'; import { defineTestCodec } from './test-codec'; function instanceContextFactory(): SqlCodecInstanceContext { @@ -20,7 +21,7 @@ interface VectorParams { readonly [key: string]: JsonValue | undefined; } -function makeVectorDescriptor(): CodecDescriptor { +function makeVectorDescriptor(): RuntimeParameterizedCodecDescriptor { return { codecId: 'pg/vector@1', dataType: dataTypeId('pg/vector'), @@ -59,7 +60,7 @@ function makeScalarDescriptor(): CodecDescriptor { dataType: dataTypeId('test/scalar'), traits: [], targetTypes: ['scalar'], - paramsSchema: voidParamsSchema, + paramsSchema: undefined, isParameterized: false, factory: () => () => defineTestCodec({ diff --git a/packages/2-sql/5-runtime/test/codec-integrity.test.ts b/packages/2-sql/5-runtime/test/codec-integrity.test.ts index 41026fdbec78..4f268b42ccb2 100644 --- a/packages/2-sql/5-runtime/test/codec-integrity.test.ts +++ b/packages/2-sql/5-runtime/test/codec-integrity.test.ts @@ -1,7 +1,7 @@ import type { Contract } from '@internal/contract/types'; import { coreHash, profileHash } from '@internal/contract/types'; import type { CodecDescriptor } from '@internal/framework-components/codec'; -import { dataTypeId, voidParamsSchema } from '@internal/framework-components/codec'; +import { dataTypeId } from '@internal/framework-components/codec'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { SqlStorage } from '@internal/sql-contract/types'; import type { Codec, SqlCodecInstanceContext } from '@internal/sql-relational-core/ast'; @@ -97,7 +97,7 @@ describe('createExecutionContext — column codec integrity', () => { dataType: dataTypeId('test/scalar'), traits: [], targetTypes: ['scalar'], - paramsSchema: voidParamsSchema, + paramsSchema: undefined, isParameterized: false, factory: ((_params: undefined) => (_ctx: SqlCodecInstanceContext) => makeCodec()) as unknown as CodecDescriptor['factory'], diff --git a/packages/2-sql/5-runtime/test/contract-codec-registry.test.ts b/packages/2-sql/5-runtime/test/contract-codec-registry.test.ts index f9e299bb7186..2572dcf94688 100644 --- a/packages/2-sql/5-runtime/test/contract-codec-registry.test.ts +++ b/packages/2-sql/5-runtime/test/contract-codec-registry.test.ts @@ -1,7 +1,7 @@ import type { Contract } from '@internal/contract/types'; import { coreHash, profileHash } from '@internal/contract/types'; import type { CodecDescriptor, CodecInstanceContext } from '@internal/framework-components/codec'; -import { dataTypeId, voidParamsSchema } from '@internal/framework-components/codec'; +import { dataTypeId } from '@internal/framework-components/codec'; import { SqlStorage } from '@internal/sql-contract/types'; import type { Codec } from '@internal/sql-relational-core/ast'; import { ifDefined } from '@internal/utils/defined'; @@ -84,7 +84,7 @@ function createNonParameterizedExtensionDescriptor(): SqlRuntimeExtensionDescrip dataType: dataTypeId('test/scalar'), traits: [], targetTypes: ['scalar'], - paramsSchema: voidParamsSchema, + paramsSchema: undefined, isParameterized: false, factory: () => () => scalarCodec, }; diff --git a/packages/2-sql/5-runtime/test/sql-context.codec-context.test.ts b/packages/2-sql/5-runtime/test/sql-context.codec-context.test.ts index 33bc1f1a2944..9175d304ad20 100644 --- a/packages/2-sql/5-runtime/test/sql-context.codec-context.test.ts +++ b/packages/2-sql/5-runtime/test/sql-context.codec-context.test.ts @@ -1,7 +1,7 @@ import type { Contract } from '@internal/contract/types'; import { coreHash, profileHash } from '@internal/contract/types'; import type { CodecDescriptor } from '@internal/framework-components/codec'; -import { dataTypeId, voidParamsSchema } from '@internal/framework-components/codec'; +import { dataTypeId } from '@internal/framework-components/codec'; import { SqlStorage, type StorageTable } from '@internal/sql-contract/types'; import type { Codec, SqlCodecInstanceContext } from '@internal/sql-relational-core/ast'; import { ifDefined } from '@internal/utils/defined'; @@ -25,7 +25,7 @@ describe('buildContractCodecRegistry — per-column codec instance context', () dataType: dataTypeId('test/captures-ctx'), traits: [], targetTypes: ['captures'], - paramsSchema: voidParamsSchema, + paramsSchema: undefined, isParameterized: false, // Family-agnostic descriptor slot; SQL-side test consumer reads `usedAt` so the factory parameter is typed as the SQL-extended context. The cast through `unknown` mirrors what production SQL extensions do (see pgvector's family-agnostic factory cast). factory: ((_params: undefined) => (ctx: SqlCodecInstanceContext) => { @@ -388,7 +388,7 @@ describe('buildContractCodecRegistry — forColumn delegates to forCodecRef', () dataType: dataTypeId('test/shared'), traits: [], targetTypes: ['shared'], - paramsSchema: voidParamsSchema, + paramsSchema: undefined, isParameterized: false, factory: ((_params: undefined) => (ctx: SqlCodecInstanceContext) => { const codec: Codec = { diff --git a/packages/3-extensions/paradedb/package.json b/packages/3-extensions/paradedb/package.json index bbf99b61622e..e2967c053156 100644 --- a/packages/3-extensions/paradedb/package.json +++ b/packages/3-extensions/paradedb/package.json @@ -34,7 +34,7 @@ "@internal/operations": "workspace:8.0.0-rc.11", "@internal/sql-contract-ts": "workspace:8.0.0-rc.11", "@internal/target-postgres": "workspace:8.0.0-rc.11", - "@prisma/cli-engine": "0.4.0", + "@prisma/cli-engine": "0.6.1", "@repo/test-utils": "workspace:8.0.0-rc.11", "@repo/tsconfig": "workspace:8.0.0-rc.11", "@repo/tsdown": "workspace:8.0.0-rc.11", diff --git a/packages/3-extensions/pgvector/package.json b/packages/3-extensions/pgvector/package.json index 6eefa451526a..afef802a78c4 100644 --- a/packages/3-extensions/pgvector/package.json +++ b/packages/3-extensions/pgvector/package.json @@ -39,7 +39,7 @@ "@internal/postgres": "workspace:8.0.0-rc.11", "@internal/postgres-codec-testkit": "workspace:8.0.0-rc.11", "@internal/sql-contract-ts": "workspace:8.0.0-rc.11", - "@prisma/cli-engine": "0.4.0", + "@prisma/cli-engine": "0.6.1", "@repo/test-utils": "workspace:8.0.0-rc.11", "@repo/tsconfig": "workspace:8.0.0-rc.11", "@repo/tsdown": "workspace:8.0.0-rc.11", diff --git a/packages/3-extensions/postgis/package.json b/packages/3-extensions/postgis/package.json index 737e25e3a844..d90b03f61409 100644 --- a/packages/3-extensions/postgis/package.json +++ b/packages/3-extensions/postgis/package.json @@ -37,7 +37,7 @@ "@internal/operations": "workspace:8.0.0-rc.11", "@internal/postgres": "workspace:8.0.0-rc.11", "@internal/sql-contract-ts": "workspace:8.0.0-rc.11", - "@prisma/cli-engine": "0.4.0", + "@prisma/cli-engine": "0.6.1", "@repo/test-utils": "workspace:8.0.0-rc.11", "@repo/tsconfig": "workspace:8.0.0-rc.11", "@repo/tsdown": "workspace:8.0.0-rc.11", diff --git a/packages/3-extensions/supabase/package.json b/packages/3-extensions/supabase/package.json index 446373923539..63bf2b4ce346 100644 --- a/packages/3-extensions/supabase/package.json +++ b/packages/3-extensions/supabase/package.json @@ -48,7 +48,7 @@ "@internal/operations": "workspace:8.0.0-rc.11", "@internal/psl-printer": "workspace:8.0.0-rc.11", "@internal/sql-contract-psl": "workspace:8.0.0-rc.11", - "@prisma/cli-engine": "0.4.0", + "@prisma/cli-engine": "0.6.1", "@repo/test-utils": "workspace:8.0.0-rc.11", "@repo/tsconfig": "workspace:8.0.0-rc.11", "@repo/tsdown": "workspace:8.0.0-rc.11", diff --git a/packages/3-mongo-target/2-mongo-adapter/src/core/codecs.ts b/packages/3-mongo-target/2-mongo-adapter/src/core/codecs.ts index 047e641e2895..199c60c8fada 100644 --- a/packages/3-mongo-target/2-mongo-adapter/src/core/codecs.ts +++ b/packages/3-mongo-target/2-mongo-adapter/src/core/codecs.ts @@ -1,5 +1,5 @@ import type { CodecDescriptor, CodecTrait, DataTypeId } from '@internal/framework-components/codec'; -import { renderTsLiteral, voidParamsSchema } from '@internal/framework-components/codec'; +import { renderTsLiteral } from '@internal/framework-components/codec'; import { type MongoCodec, type MongoCodecRegistry, @@ -118,7 +118,7 @@ function descriptorFor( dataType: metadata.dataType, traits: metadata.traits, targetTypes: metadata.targetTypes, - paramsSchema: voidParamsSchema as CodecDescriptor['paramsSchema'], + paramsSchema: undefined as CodecDescriptor['paramsSchema'], isParameterized: false, factory: (() => () => codec) as CodecDescriptor['factory'], ...ifDefined('renderOutputType', renderOutputType), diff --git a/packages/3-targets/3-targets/postgres/src/core/codec-descriptor.ts b/packages/3-targets/3-targets/postgres/src/core/codec-descriptor.ts index 8669b0643037..a7ee40c2f7bb 100644 --- a/packages/3-targets/3-targets/postgres/src/core/codec-descriptor.ts +++ b/packages/3-targets/3-targets/postgres/src/core/codec-descriptor.ts @@ -223,11 +223,12 @@ export function isPostgresCodecDescriptor(value: unknown): value is AnyPostgresC Array.isArray(value.targetTypes) && value.targetTypes.every((targetType) => typeof targetType === 'string') && 'paramsSchema' in value && - isObjectLike(value.paramsSchema) && - '~standard' in value.paramsSchema && - isObjectLike(value.paramsSchema['~standard']) && - 'validate' in value.paramsSchema['~standard'] && - typeof value.paramsSchema['~standard'].validate === 'function' && + (value.paramsSchema === undefined || + (isObjectLike(value.paramsSchema) && + '~standard' in value.paramsSchema && + isObjectLike(value.paramsSchema['~standard']) && + 'validate' in value.paramsSchema['~standard'] && + typeof value.paramsSchema['~standard'].validate === 'function')) && 'isParameterized' in value && typeof value.isParameterized === 'boolean' && 'factory' in value && diff --git a/packages/3-targets/3-targets/postgres/src/core/codecs.ts b/packages/3-targets/3-targets/postgres/src/core/codecs.ts index 88277b0ec9d2..aa7c30866ebb 100644 --- a/packages/3-targets/3-targets/postgres/src/core/codecs.ts +++ b/packages/3-targets/3-targets/postgres/src/core/codecs.ts @@ -20,7 +20,6 @@ import { type ColumnHelperForStrict, column, renderTsLiteral, - voidParamsSchema, } from '@internal/framework-components/codec'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { @@ -385,7 +384,7 @@ export class PgTextDescriptor extends PostgresCodecDescriptor { override readonly codecId = PG_TEXT_CODEC_ID; override readonly traits = ['equality', 'order', 'textual'] as const; override readonly targetTypes = ['text'] as const; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override renderValueLiteral(value: JsonValue): string | undefined { return renderTsLiteral(value); } @@ -587,7 +586,7 @@ export class PgTextArrayDescriptor extends PostgresCodecDescriptor { override readonly codecId = PG_TEXT_ARRAY_CODEC_ID; override readonly traits = ['equality'] as const; override readonly targetTypes = ['text[]'] as const; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => PgTextArrayCodec { return () => new PgTextArrayCodec(this); } @@ -628,7 +627,7 @@ export class PgInt4Descriptor extends PostgresCodecDescriptor { override readonly codecId = PG_INT4_CODEC_ID; override readonly traits = ['equality', 'order', 'numeric'] as const; override readonly targetTypes = ['int4'] as const; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override renderValueLiteral(value: JsonValue): string | undefined { return renderTsLiteral(value); } @@ -678,7 +677,7 @@ export class PgInt2Descriptor extends PostgresCodecDescriptor { override readonly codecId = PG_INT2_CODEC_ID; override readonly traits = ['equality', 'order', 'numeric'] as const; override readonly targetTypes = ['int2'] as const; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override renderValueLiteral(value: JsonValue): string | undefined { return renderTsLiteral(value); } @@ -739,7 +738,7 @@ export class PgInt8Descriptor extends PostgresCodecDescriptor { override readonly codecId = PG_INT8_CODEC_ID; override readonly traits = ['equality', 'order', 'numeric'] as const; override readonly targetTypes = ['int8'] as const; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override renderValueLiteral(value: JsonValue): string | undefined { return decimalTextBigintLiteral(value); } @@ -795,7 +794,7 @@ export class PgInt8NumberDescriptor extends PostgresCodecDescriptor { override readonly codecId = PG_INT8_NUMBER_CODEC_ID; override readonly traits = ['equality', 'order', 'numeric'] as const; override readonly targetTypes = [] as const; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override renderValueLiteral(value: JsonValue): string | undefined { return decimalTextNumberLiteral(value); } @@ -843,7 +842,7 @@ export class PgFloat4Descriptor extends PostgresCodecDescriptor { override readonly codecId = PG_FLOAT4_CODEC_ID; override readonly traits = ['equality', 'order', 'numeric'] as const; override readonly targetTypes = ['float4'] as const; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override renderValueLiteral(value: JsonValue): string | undefined { return renderTsLiteral(value); } @@ -891,7 +890,7 @@ export class PgFloat8Descriptor extends PostgresCodecDescriptor { override readonly codecId = PG_FLOAT8_CODEC_ID; override readonly traits = ['equality', 'order', 'numeric'] as const; override readonly targetTypes = ['float8'] as const; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override renderValueLiteral(value: JsonValue): string | undefined { return renderTsLiteral(value); } @@ -939,7 +938,7 @@ export class PgBoolDescriptor extends PostgresCodecDescriptor { override readonly codecId = PG_BOOL_CODEC_ID; override readonly traits = ['equality', 'boolean'] as const; override readonly targetTypes = ['bool'] as const; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override renderValueLiteral(value: JsonValue): string | undefined { return renderTsLiteral(value); } @@ -1062,7 +1061,7 @@ export class PgUnboundedIntDescriptor extends PostgresCodecDescriptor { override readonly codecId = PG_UNBOUNDED_INT_CODEC_ID; override readonly traits = ['equality', 'order', 'numeric'] as const; override readonly targetTypes = [] as const; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override renderValueLiteral(value: JsonValue): string | undefined { return decimalTextBigintLiteral(value); } @@ -1266,7 +1265,7 @@ export class PgByteaDescriptor extends PostgresCodecDescriptor { override readonly codecId = PG_BYTEA_CODEC_ID; override readonly traits = ['equality'] as const; override readonly targetTypes = ['bytea'] as const; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => PgByteaCodec { return () => new PgByteaCodec(this); } @@ -1313,7 +1312,7 @@ export class PgUuidDescriptor extends PostgresCodecDescriptor { override readonly codecId = PG_UUID_CODEC_ID; override readonly traits = ['equality', 'order'] as const; override readonly targetTypes = ['uuid'] as const; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => PgUuidCodec { return () => new PgUuidCodec(this); } @@ -1360,7 +1359,7 @@ export class PgInetDescriptor extends PostgresCodecDescriptor { override readonly codecId = PG_INET_CODEC_ID; override readonly traits = ['equality', 'order'] as const; override readonly targetTypes = ['inet'] as const; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => PgInetCodec { return () => new PgInetCodec(this); } @@ -1423,7 +1422,7 @@ export class PgTsqueryDescriptor extends PostgresCodecDescriptor { override readonly codecId = PG_TSQUERY_CODEC_ID; override readonly traits = [] as const; override readonly targetTypes = ['tsquery'] as const; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => PgTsqueryCodec { return () => new PgTsqueryCodec(this); } @@ -1533,7 +1532,7 @@ export class PgJsonDescriptor extends PostgresCodecDescriptor { override readonly codecId = PG_JSON_CODEC_ID; override readonly traits = [] as const; override readonly targetTypes = ['json'] as const; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => PgJsonCodec { return () => new PgJsonCodec(this); } @@ -1578,7 +1577,7 @@ export class PgJsonbDescriptor extends PostgresCodecDescriptor { override readonly codecId = PG_JSONB_CODEC_ID; override readonly traits = ['equality'] as const; override readonly targetTypes = ['jsonb'] as const; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => PgJsonbCodec { return () => new PgJsonbCodec(this); } diff --git a/packages/3-targets/3-targets/postgres/src/core/temporal-codecs.ts b/packages/3-targets/3-targets/postgres/src/core/temporal-codecs.ts index 2321f1706bde..15d9d04d3f04 100644 --- a/packages/3-targets/3-targets/postgres/src/core/temporal-codecs.ts +++ b/packages/3-targets/3-targets/postgres/src/core/temporal-codecs.ts @@ -6,7 +6,6 @@ import { type ColumnHelperFor, type ColumnHelperForStrict, column, - voidParamsSchema, } from '@internal/framework-components/codec'; import { CastExpr, type ProjectionExpr } from '@internal/sql-relational-core/ast'; import { blindCast } from '@internal/utils/casts'; @@ -68,7 +67,7 @@ export class PgDateTemporalDescriptor extends PostgresCodecDescriptor { override readonly codecId = PG_DATE_TEMPORAL_CODEC_ID; override readonly traits = ['equality', 'order'] as const; override readonly targetTypes = ['date'] as const; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => PgDateTemporalCodec { return () => new PgDateTemporalCodec(this); } diff --git a/packages/3-targets/3-targets/postgres/src/core/temporal-string-codecs.ts b/packages/3-targets/3-targets/postgres/src/core/temporal-string-codecs.ts index 90b8fb5909b5..bd465e0584e5 100644 --- a/packages/3-targets/3-targets/postgres/src/core/temporal-string-codecs.ts +++ b/packages/3-targets/3-targets/postgres/src/core/temporal-string-codecs.ts @@ -6,7 +6,6 @@ import { type ColumnHelperFor, type ColumnHelperForStrict, column, - voidParamsSchema, } from '@internal/framework-components/codec'; import { CastExpr, type ProjectionExpr } from '@internal/sql-relational-core/ast'; import { blindCast } from '@internal/utils/casts'; @@ -60,7 +59,7 @@ export class PgDateStringDescriptor extends PostgresCodecDescriptor { override readonly codecId = PG_DATE_STRING_CODEC_ID; override readonly traits = ['equality', 'order'] as const; override readonly targetTypes = [] as const; - override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => PgDateStringCodec { return () => new PgDateStringCodec(this); } diff --git a/packages/3-targets/3-targets/sqlite/src/core/codec-descriptor.ts b/packages/3-targets/3-targets/sqlite/src/core/codec-descriptor.ts index dadf91e0aa90..b6a9e0abeeb8 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/codec-descriptor.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/codec-descriptor.ts @@ -160,11 +160,12 @@ export function isSqliteCodecDescriptor(value: unknown): value is AnySqliteCodec Array.isArray(value.targetTypes) && value.targetTypes.every((targetType) => typeof targetType === 'string') && 'paramsSchema' in value && - isObjectLike(value.paramsSchema) && - '~standard' in value.paramsSchema && - isObjectLike(value.paramsSchema['~standard']) && - 'validate' in value.paramsSchema['~standard'] && - typeof value.paramsSchema['~standard'].validate === 'function' && + (value.paramsSchema === undefined || + (isObjectLike(value.paramsSchema) && + '~standard' in value.paramsSchema && + isObjectLike(value.paramsSchema['~standard']) && + 'validate' in value.paramsSchema['~standard'] && + typeof value.paramsSchema['~standard'].validate === 'function')) && 'isParameterized' in value && typeof value.isParameterized === 'boolean' && 'factory' in value && diff --git a/packages/3-targets/3-targets/sqlite/src/core/codecs.ts b/packages/3-targets/3-targets/sqlite/src/core/codecs.ts index 562b36f11df1..a4fff46582ea 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/codecs.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/codecs.ts @@ -18,7 +18,6 @@ import { type ColumnHelperFor, type ColumnHelperForStrict, column, - voidParamsSchema, } from '@internal/framework-components/codec'; import { CaseExpr, @@ -282,7 +281,7 @@ export class SqliteTextDescriptor extends SqliteCodecDescriptor { override readonly codecId = SQLITE_TEXT_CODEC_ID; 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) => SqliteTextCodec { return () => new SqliteTextCodec(this); } @@ -338,7 +337,7 @@ export class SqliteIntegerDescriptor extends SqliteCodecDescriptor { override readonly codecId = SQLITE_INTEGER_CODEC_ID; override readonly traits = ['equality', 'order', 'numeric'] as const; override readonly targetTypes = ['integer'] as const; - override readonly paramsSchema = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => SqliteIntegerCodec { return () => new SqliteIntegerCodec(this); } @@ -389,7 +388,7 @@ export class SqliteRealDescriptor extends SqliteCodecDescriptor { override readonly codecId = SQLITE_REAL_CODEC_ID; override readonly traits = ['equality', 'order', 'numeric'] as const; override readonly targetTypes = ['real'] as const; - override readonly paramsSchema = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => SqliteRealCodec { return () => new SqliteRealCodec(this); } @@ -438,7 +437,7 @@ export class SqliteBlobDescriptor extends SqliteCodecDescriptor { override readonly codecId = SQLITE_BLOB_CODEC_ID; override readonly traits = ['equality'] as const; override readonly targetTypes = ['blob'] as const; - override readonly paramsSchema = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => SqliteBlobCodec { return () => new SqliteBlobCodec(this); } @@ -499,7 +498,7 @@ export class SqliteDatetimeDescriptor extends SqliteCodecDescriptor { override readonly codecId = SQLITE_DATETIME_CODEC_ID; override readonly traits = ['equality', 'order'] as const; override readonly targetTypes = ['text'] as const; - override readonly paramsSchema = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => SqliteDatetimeCodec { return () => new SqliteDatetimeCodec(this); } @@ -541,7 +540,7 @@ export class SqliteJsonDescriptor extends SqliteCodecDescriptor { override readonly codecId = SQLITE_JSON_CODEC_ID; override readonly traits = ['equality'] as const; override readonly targetTypes = ['text'] as const; - override readonly paramsSchema = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => SqliteJsonCodec { return () => new SqliteJsonCodec(this); } @@ -613,7 +612,7 @@ export class SqliteBigintDescriptor extends SqliteCodecDescriptor { override readonly codecId = SQLITE_BIGINT_CODEC_ID; override readonly traits = ['equality', 'order', 'numeric'] as const; override readonly targetTypes = ['integer'] as const; - override readonly paramsSchema = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => SqliteBigintCodec { return () => new SqliteBigintCodec(this); } @@ -684,7 +683,7 @@ export class SqliteBigintNumberDescriptor extends SqliteCodecDescriptor { override readonly codecId = SQLITE_BIGINT_NUMBER_CODEC_ID; override readonly traits = ['equality', 'order', 'numeric'] as const; override readonly targetTypes = [] as const; - override readonly paramsSchema = voidParamsSchema; + override readonly paramsSchema = undefined; override renderValueLiteral(value: JsonValue): string | undefined { return decimalTextNumberLiteral(value); } diff --git a/packages/3-targets/6-adapters/postgres/test/adapter.test.ts b/packages/3-targets/6-adapters/postgres/test/adapter.test.ts index ba3f864cab31..e24366cb4a8f 100644 --- a/packages/3-targets/6-adapters/postgres/test/adapter.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/adapter.test.ts @@ -629,8 +629,12 @@ describe('Postgres adapter', () => { expect(descriptors.length).toBeGreaterThan(0); const ids = descriptors.map((d: { codecId: string }) => d.codecId); expect(ids).toEqual(expect.arrayContaining(['pg/numeric@1', 'pg/timestamptz-temporal@1'])); + const parameterized = descriptors.filter((d) => d.isParameterized).map((d) => d.codecId); + expect(parameterized).toEqual( + expect.arrayContaining(['pg/numeric@1', 'pg/timestamptz-temporal@1']), + ); for (const descriptor of descriptors) { - expect(descriptor.paramsSchema).toBeDefined(); + expect(descriptor.isParameterized).toBe(descriptor.paramsSchema !== undefined); } }, timeouts.coldTransformImport, diff --git a/packages/3-targets/6-adapters/postgres/test/lower-to-execute-request.test.ts b/packages/3-targets/6-adapters/postgres/test/lower-to-execute-request.test.ts index 29ba728aea86..1a240972e4a2 100644 --- a/packages/3-targets/6-adapters/postgres/test/lower-to-execute-request.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/lower-to-execute-request.test.ts @@ -1,9 +1,5 @@ import type { AnyCodecDescriptor, Codec } from '@internal/framework-components/codec'; -import { - CodecDescriptorImpl, - dataTypeId, - voidParamsSchema, -} from '@internal/framework-components/codec'; +import { CodecDescriptorImpl, dataTypeId } from '@internal/framework-components/codec'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { SqlStorage, type StorageTableInput } from '@internal/sql-contract/types'; import type { ContractCodecRegistry, ProjectionExpr } from '@internal/sql-relational-core/ast'; @@ -44,7 +40,7 @@ const transformingCodecDescriptor: AnyCodecDescriptor = { dataType: dataTypeId('test/transform'), traits: [], targetTypes: ['text'], - paramsSchema: voidParamsSchema, + paramsSchema: undefined, isParameterized: false, factory: () => () => transformingCodec, }; @@ -290,7 +286,7 @@ class ExtTransformDescriptor extends CodecDescriptorImpl { override readonly codecId = EXT_CODEC_ID; override readonly traits = [] as const; override readonly targetTypes = ['text'] as const; - override readonly paramsSchema = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: object) => Codec { return () => ({ diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/data-transform.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/data-transform.test.ts index 94275a733692..a3090d583b95 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/data-transform.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/data-transform.test.ts @@ -3,7 +3,7 @@ import { CliStructuredError } from '@internal/errors/control'; import { placeholder } from '@internal/errors/migration'; import type { SqlControlAdapter } from '@internal/family-sql/control-adapter'; import type { AnyCodecDescriptor, Codec } from '@internal/framework-components/codec'; -import { dataTypeId, voidParamsSchema } from '@internal/framework-components/codec'; +import { dataTypeId } from '@internal/framework-components/codec'; import type { SqlStorage } from '@internal/sql-contract/types'; import type { ContractCodecRegistry, ProjectionExpr } from '@internal/sql-relational-core/ast'; import type { SqlQueryPlan } from '@internal/sql-relational-core/plan'; @@ -229,7 +229,7 @@ const transformingCodecDescriptor: AnyCodecDescriptor = { dataType: dataTypeId('demo/fixture'), traits: [], targetTypes: ['text'], - paramsSchema: voidParamsSchema, + paramsSchema: undefined, isParameterized: false, factory: () => () => transformingCodec, }; diff --git a/packages/3-targets/6-adapters/postgres/test/postgres-codec-registry-composition.test.ts b/packages/3-targets/6-adapters/postgres/test/postgres-codec-registry-composition.test.ts index e9c91eb3f650..a65b2424d66f 100644 --- a/packages/3-targets/6-adapters/postgres/test/postgres-codec-registry-composition.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/postgres-codec-registry-composition.test.ts @@ -3,7 +3,7 @@ import type { AnyCodecDescriptor, AnyCodecDescriptorTemplate, } from '@internal/framework-components/codec'; -import { dataType, dataTypeId, voidParamsSchema } from '@internal/framework-components/codec'; +import { dataType, dataTypeId } from '@internal/framework-components/codec'; import type { ControlExtensionDescriptor } from '@internal/framework-components/control'; import type { RuntimeExtensionDescriptor } from '@internal/framework-components/execution'; import { @@ -95,7 +95,7 @@ function genericDescriptor(codecId: string): AnyCodecDescriptorTemplate { codecId, traits: ['equality'], targetTypes: [], - paramsSchema: voidParamsSchema, + paramsSchema: undefined, isParameterized: false, factory: () => () => codec, }; @@ -130,7 +130,7 @@ function transformingPostgresDescriptor( codecId, traits: ['equality'], targetTypes: [nativeType], - paramsSchema: voidParamsSchema, + paramsSchema: undefined, isParameterized: false, factory: () => () => { onMaterialize?.(); diff --git a/packages/3-targets/6-adapters/postgres/test/sql-renderer.cast-policy.test.ts b/packages/3-targets/6-adapters/postgres/test/sql-renderer.cast-policy.test.ts index e4ff33358bb5..fc027112b6e1 100644 --- a/packages/3-targets/6-adapters/postgres/test/sql-renderer.cast-policy.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/sql-renderer.cast-policy.test.ts @@ -1,6 +1,6 @@ import type { JsonValue } from '@internal/contract/types'; import type { AnyCodecDescriptorTemplate } from '@internal/framework-components/codec'; -import { dataTypeId, voidParamsSchema } from '@internal/framework-components/codec'; +import { dataTypeId } from '@internal/framework-components/codec'; import type { RuntimeExtensionDescriptor } from '@internal/framework-components/execution'; import { BinaryExpr, @@ -38,7 +38,7 @@ function genericDescriptor(codecId: string): AnyCodecDescriptorTemplate { codecId, traits: ['equality'], targetTypes: [], - paramsSchema: voidParamsSchema, + paramsSchema: undefined, isParameterized: false, factory: () => () => codec, }; diff --git a/packages/3-targets/6-adapters/sqlite/test/lower-to-execute-request.test.ts b/packages/3-targets/6-adapters/sqlite/test/lower-to-execute-request.test.ts index a2bb46c5b744..c8a4e9a20f22 100644 --- a/packages/3-targets/6-adapters/sqlite/test/lower-to-execute-request.test.ts +++ b/packages/3-targets/6-adapters/sqlite/test/lower-to-execute-request.test.ts @@ -1,9 +1,5 @@ import type { Codec } from '@internal/framework-components/codec'; -import { - CodecDescriptorImpl, - dataTypeId, - voidParamsSchema, -} from '@internal/framework-components/codec'; +import { CodecDescriptorImpl, dataTypeId } from '@internal/framework-components/codec'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { SqlStorage, type StorageTableInput } from '@internal/sql-contract/types'; import type { ContractCodecRegistry } from '@internal/sql-relational-core/ast'; @@ -47,7 +43,7 @@ const transformingDescriptor: AnySqliteCodecDescriptor = { dataType: dataTypeId('test/transform'), traits: [], targetTypes: ['TEXT'], - paramsSchema: voidParamsSchema, + paramsSchema: undefined, isParameterized: false, factory: () => () => transformingCodec, projectJson: (expression) => expression, @@ -284,7 +280,7 @@ class ExtTransformDescriptor extends CodecDescriptorImpl { override readonly codecId = EXT_CODEC_ID; override readonly traits = [] as const; override readonly targetTypes = ['TEXT'] as const; - override readonly paramsSchema = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: object) => Codec { return () => ({ diff --git a/packages/3-targets/6-adapters/sqlite/test/sqlite-codec-registry-composition.test.ts b/packages/3-targets/6-adapters/sqlite/test/sqlite-codec-registry-composition.test.ts index ab3ad3524df4..a770f3ee7184 100644 --- a/packages/3-targets/6-adapters/sqlite/test/sqlite-codec-registry-composition.test.ts +++ b/packages/3-targets/6-adapters/sqlite/test/sqlite-codec-registry-composition.test.ts @@ -8,7 +8,6 @@ import { type CodecInstanceContext, dataType, dataTypeId, - voidParamsSchema, } from '@internal/framework-components/codec'; import type { ControlExtensionDescriptor } from '@internal/framework-components/control'; import { createControlStack } from '@internal/framework-components/control'; @@ -94,7 +93,7 @@ class TestGenericDescriptor extends CodecDescriptorImpl { override readonly dataType = dataTypeId('demo/fixture'); override readonly traits = ['equality'] as const; override readonly targetTypes = ['text'] as const; - override readonly paramsSchema = voidParamsSchema; + override readonly paramsSchema = undefined; constructor( override readonly codecId: string, diff --git a/packages/9-public/@prisma/orm-framework/package.json b/packages/9-public/@prisma/orm-framework/package.json index b9d4038c6aa8..e51a1d4b133a 100644 --- a/packages/9-public/@prisma/orm-framework/package.json +++ b/packages/9-public/@prisma/orm-framework/package.json @@ -61,7 +61,6 @@ "./components/utils": "./dist/components__utils.mjs", "./config": "./dist/config.mjs", "./config/config-types": "./dist/config__config-types.mjs", - "./config/config-validation": "./dist/config__config-validation.mjs", "./contract": "./dist/contract.mjs", "./contract/apply-specifier-default-control-policy": "./dist/contract__apply-specifier-default-control-policy.mjs", "./contract/contract-validation-error": "./dist/contract__contract-validation-error.mjs", diff --git a/packages/9-public/@prisma/orm-toolchain/package.json b/packages/9-public/@prisma/orm-toolchain/package.json index 79d4e289917c..5b9ebb83b55e 100644 --- a/packages/9-public/@prisma/orm-toolchain/package.json +++ b/packages/9-public/@prisma/orm-toolchain/package.json @@ -49,7 +49,7 @@ "typescript": "catalog:" }, "peerDependencies": { - "@prisma/cli-engine": "0.4.0", + "@prisma/cli-engine": "0.6.1", "typescript": ">=5.9", "vite": "^7.0.0 || ^8.0.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 950bba0a2e54..05707ebeeccd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1422,8 +1422,8 @@ importers: specifier: workspace:8.0.0-rc.11 version: link:../../../2-sql/5-runtime '@prisma/cli-engine': - specifier: 0.4.0 - version: 0.4.0(@prisma/management-api-sdk@1.61.0)(magicast@0.5.4) + specifier: 0.6.1 + version: 0.6.1(@prisma/management-api-sdk@1.61.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.11 version: link:../../../../test/utils @@ -1451,6 +1451,9 @@ importers: '@internal/config': specifier: workspace:8.0.0-rc.11 version: link:../../1-core/config + '@internal/config-loader': + specifier: workspace:8.0.0-rc.11 + version: link:../config-loader '@internal/utils': specifier: workspace:8.0.0-rc.11 version: link:../../0-foundation/utils @@ -1524,6 +1527,9 @@ importers: '@internal/utils': specifier: workspace:8.0.0-rc.11 version: link:../../0-foundation/utils + arktype: + specifier: ^2.2.2 + version: 2.2.3 c12: specifier: ^3.3.4 version: 3.3.4(magicast@0.5.4) @@ -1534,6 +1540,9 @@ importers: specifier: 'catalog:' version: 0.2.17 devDependencies: + '@prisma/cli-engine': + specifier: 0.6.1 + version: 0.6.1(@prisma/management-api-sdk@1.61.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.11 version: link:../../../../test/utils @@ -3167,8 +3176,8 @@ importers: specifier: workspace:8.0.0-rc.11 version: link:../../3-targets/3-targets/postgres '@prisma/cli-engine': - specifier: 0.4.0 - version: 0.4.0(@prisma/management-api-sdk@1.61.0)(magicast@0.5.4) + specifier: 0.6.1 + version: 0.6.1(@prisma/management-api-sdk@1.61.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.11 version: link:../../../test/utils @@ -3255,8 +3264,8 @@ importers: specifier: workspace:8.0.0-rc.11 version: link:../../2-sql/2-authoring/contract-ts '@prisma/cli-engine': - specifier: 0.4.0 - version: 0.4.0(@prisma/management-api-sdk@1.61.0)(magicast@0.5.4) + specifier: 0.6.1 + version: 0.6.1(@prisma/management-api-sdk@1.61.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.11 version: link:../../../test/utils @@ -3337,8 +3346,8 @@ importers: specifier: workspace:8.0.0-rc.11 version: link:../../2-sql/2-authoring/contract-ts '@prisma/cli-engine': - specifier: 0.4.0 - version: 0.4.0(@prisma/management-api-sdk@1.61.0)(magicast@0.5.4) + specifier: 0.6.1 + version: 0.6.1(@prisma/management-api-sdk@1.61.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.11 version: link:../../../test/utils @@ -3704,8 +3713,8 @@ importers: specifier: workspace:8.0.0-rc.11 version: link:../../2-sql/2-authoring/contract-psl '@prisma/cli-engine': - specifier: 0.4.0 - version: 0.4.0(@prisma/management-api-sdk@1.61.0)(magicast@0.5.4) + specifier: 0.6.1 + version: 0.6.1(@prisma/management-api-sdk@1.61.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.11 version: link:../../../test/utils @@ -5224,8 +5233,8 @@ importers: packages/9-public/@prisma/orm-toolchain: dependencies: '@prisma/cli-engine': - specifier: 0.4.0 - version: 0.4.0(@prisma/management-api-sdk@1.61.0)(magicast@0.5.4) + specifier: 0.6.1 + version: 0.6.1(@prisma/management-api-sdk@1.61.0)(magicast@0.5.4) '@prisma/orm-framework': specifier: workspace:8.0.0-rc.11 version: link:../orm-framework @@ -5566,8 +5575,8 @@ importers: specifier: workspace:8.0.0-rc.11 version: link:../../packages/0-shared/publish-surface '@prisma/cli-engine': - specifier: 0.4.0 - version: 0.4.0(@prisma/management-api-sdk@1.61.0)(magicast@0.5.4) + specifier: 0.6.1 + version: 0.6.1(@prisma/management-api-sdk@1.61.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.11 version: link:../utils @@ -7543,6 +7552,12 @@ packages: peerDependencies: '@prisma/management-api-sdk': ^1.55.0 + '@prisma/cli-engine@0.6.1': + resolution: {integrity: sha512-z9x6WCUzJdnM35tcHPYdw1DVwNYp4uv0T3oCjGZB4MdtXOUoN912oAYA7RYTjSvbulbhq+Z4pNXJ34XgdX8C4g==} + engines: {node: '>=22.12.0'} + peerDependencies: + '@prisma/management-api-sdk': ^1.55.0 + '@prisma/client-runtime-utils@7.10.0': resolution: {integrity: sha512-cnCy7lUV8/CctgKVEmqAbSLAmwqJdE/qAlqTBk/0NDk59zEb2cZ0M0M0E4vVPnqbSEYudRroQDvOWfUZH6RIfw==} @@ -13394,6 +13409,19 @@ snapshots: transitivePeerDependencies: - magicast + '@prisma/cli-engine@0.6.1(@prisma/management-api-sdk@1.61.0)(magicast@0.5.4)': + dependencies: + '@clack/prompts': 1.5.0 + '@prisma/management-api-sdk': 1.61.0 + '@stricli/core': 1.3.0 + arktype: 2.2.3 + c12: 3.3.4(magicast@0.5.4) + colorette: 2.0.20 + package-manager-detector: 1.8.0 + string-width: 8.2.2 + transitivePeerDependencies: + - magicast + '@prisma/client-runtime-utils@7.10.0': {} '@prisma/client@7.10.0(prisma@packages+1-framework+3-tooling+cli)(typescript@5.9.3)': diff --git a/test/integration/package.json b/test/integration/package.json index c081c3426814..7f20aebae7a3 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -79,7 +79,7 @@ "@internal/middleware-cache": "workspace:8.0.0-rc.11", "@internal/mongo-lowering": "workspace:8.0.0-rc.11", "@internal/publish-surface": "workspace:8.0.0-rc.11", - "@prisma/cli-engine": "0.4.0", + "@prisma/cli-engine": "0.6.1", "@repo/test-utils": "workspace:8.0.0-rc.11", "@repo/tsdown": "workspace:8.0.0-rc.11", "@repo/tsconfig": "workspace:8.0.0-rc.11", diff --git a/test/integration/test/cli.config-section-requirements.test.ts b/test/integration/test/cli.config-section-requirements.test.ts index 6759823ca63f..f11f5faff768 100644 --- a/test/integration/test/cli.config-section-requirements.test.ts +++ b/test/integration/test/cli.config-section-requirements.test.ts @@ -77,8 +77,11 @@ describe('commands declare the config sections they read', () => { ok: false, diagnostics: expect.arrayContaining([ expect.objectContaining({ - code: 'CONFIG.VALIDATION_FAILED', - meta: expect.objectContaining({ section: 'contract' }), + code: 'CLI.CONFIG_FIELD_INVALID', + meta: expect.objectContaining({ + section: 'orm', + field: expect.stringMatching(/^contract(\.|$)/), + }), }), ]), }, @@ -102,8 +105,11 @@ describe('commands declare the config sections they read', () => { ok: false, diagnostics: expect.arrayContaining([ expect.objectContaining({ - code: 'CONFIG.VALIDATION_FAILED', - meta: expect.objectContaining({ section: 'migrations' }), + code: 'CLI.CONFIG_FIELD_INVALID', + meta: expect.objectContaining({ + section: 'orm', + field: expect.stringMatching(/^migrations(\.|$)/), + }), }), ]), }, diff --git a/test/integration/test/cli.emit-command.test.ts b/test/integration/test/cli.emit-command.test.ts index 2cb4abed5d35..b7378f84d36e 100644 --- a/test/integration/test/cli.emit-command.test.ts +++ b/test/integration/test/cli.emit-command.test.ts @@ -179,7 +179,7 @@ describe('emit command', () => { error: { code: 'CLI.CONFIG_SECTION_INVALID', summary: expect.any(String) }, }); expect(envelope?.diagnostics).toEqual( - expect.arrayContaining([expect.objectContaining({ code: 'CONFIG.VALIDATION_FAILED' })]), + expect.arrayContaining([expect.objectContaining({ code: 'CLI.CONFIG_FIELD_INVALID' })]), ); } finally { documentSetup.cleanup(); 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); } diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.prisma7.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.prisma7.ts index d53fbfe6fd37..7e989d14eb37 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.prisma7.ts +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.prisma7.ts @@ -2,9 +2,9 @@ // app stays on one import root (ADR 242), and the published shell re-exports // exactly these two functions. import { defineConfig as postgres, prisma7Schema } from '@internal/postgres/config'; -import { defineConfig } from '@prisma/cli-engine'; +import { definePrismaConfig } from '@prisma/cli-engine'; -export default defineConfig({ +export default definePrismaConfig({ orm: postgres({ contract: prisma7Schema('./schema.prisma'), db: { diff --git a/test/integration/test/ports/engines/writes/top_level_mutations/create_many/_fixture/prisma.config.ts b/test/integration/test/ports/engines/writes/top_level_mutations/create_many/_fixture/prisma.config.ts index f197173e5b84..c19c293e813c 100644 --- a/test/integration/test/ports/engines/writes/top_level_mutations/create_many/_fixture/prisma.config.ts +++ b/test/integration/test/ports/engines/writes/top_level_mutations/create_many/_fixture/prisma.config.ts @@ -1,7 +1,7 @@ import { defineConfig as ormConfig } from '@internal/postgres/config'; -import { defineConfig } from '@prisma/cli-engine'; +import { definePrismaConfig } from '@prisma/cli-engine'; -export default defineConfig({ +export default definePrismaConfig({ orm: ormConfig({ contract: './contract.prisma', output: 'generated', diff --git a/test/integration/test/sql-orm-client/include-codecs.test.ts b/test/integration/test/sql-orm-client/include-codecs.test.ts index f579d2bfe22d..63fd3d18b90d 100644 --- a/test/integration/test/sql-orm-client/include-codecs.test.ts +++ b/test/integration/test/sql-orm-client/include-codecs.test.ts @@ -13,7 +13,6 @@ import { type CodecInstanceContext, type ColumnTypeDescriptor, dataTypeId, - voidParamsSchema, } from '@internal/framework-components/codec'; import { defineContract, field, model, rel } from '@internal/postgres/contract-builder'; import { Collection } from '@internal/sql-orm-client'; @@ -65,7 +64,7 @@ class IncludedTextDescriptor extends CodecDescriptorImpl { override readonly codecId = TEST_INCLUDED_TEXT_CODEC_ID; override readonly traits = ['textual'] as const; override readonly targetTypes = ['text'] as const; - override readonly paramsSchema = voidParamsSchema; + override readonly paramsSchema = undefined; override factory(): (ctx: CodecInstanceContext) => IncludedTextCodec { return () => new IncludedTextCodec(this); diff --git a/upgrade-instructions/pending/codec-without-params-schema/extension/instructions.md b/upgrade-instructions/pending/codec-without-params-schema/extension/instructions.md new file mode 100644 index 000000000000..bed58ccff46a --- /dev/null +++ b/upgrade-instructions/pending/codec-without-params-schema/extension/instructions.md @@ -0,0 +1,38 @@ +--- +changes: + - id: codec-without-params-has-no-params-schema + summary: A codec that takes no params sets `paramsSchema` to `undefined`; `voidParamsSchema` is removed. + detection: + glob: "**/*.ts" + contains: + - "voidParamsSchema" +--- + +# A codec without params has no `paramsSchema` + +`voidParamsSchema` is no longer exported from `@internal/framework-components/codec`. A codec descriptor that takes no params (`P = void`) sets `paramsSchema` to `undefined`, and `isParameterized` is `true` exactly when a descriptor has a `paramsSchema`. A codec without params still rejects any `typeParams` with `RUNTIME.TYPE_PARAMS_INVALID`. + +In every file matched by `detection`: + +```ts +// before +import { CodecDescriptorImpl, voidParamsSchema } from '@internal/framework-components/codec'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; + +class MyTextDescriptor extends CodecDescriptorImpl { + override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; +} + +// after +import { CodecDescriptorImpl } from '@internal/framework-components/codec'; + +class MyTextDescriptor extends CodecDescriptorImpl { + override readonly paramsSchema = undefined; +} +``` + +- Remove `voidParamsSchema` from the import. Remove the `StandardSchemaV1` import too if nothing else in the file uses it. +- In a descriptor written as a plain object, replace `paramsSchema: voidParamsSchema` with `paramsSchema: undefined`. +- Code that reads `descriptor.paramsSchema` now sees `StandardSchemaV1

| undefined`. Check `descriptor.paramsSchema !== undefined` before calling its `validate`. + +Parameterized codecs are unchanged.