From 59ef4d0743e5fed49c1b4b1f0e949ffcc2e41f99 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 22 Sep 2026 11:18:02 +0200 Subject: [PATCH 01/16] feat(config): the orm section is declared once as a schema, and the engine resolves its paths Under the unified prisma CLI, contract emit --config ./sub/prisma.config.ts run from the parent failed with CONTRACT.SOURCE_LOAD_FAILED: the ORM resolved the section's relative paths against the working directory, because the engine handed the section over as written and nothing told the ORM which file wrote it. The orm section is now declared once, as ormConfigSchema in @internal/config-loader, with the engine's configSchema and its `path` keyword (prisma-cli ADR 0005, engine 0.6.0). The engine derives validation, a diagnostic per bad field naming the file to fix, and the resolution of every path field against the config file that declared it. Commands read absolute paths and baseDir; migrations.dir and contract.output default in the declaration. Gone: collectConfigIssues and the hand-written descriptor checks, the loader's finalize step, the command wrapper's cwd anchoring, and projectConfigPathFor, which rebuilt /prisma.config.ts for the package.json walk and read the wrong manifest from a parent directory. The ORM's bin hands the engine each evaluated file with its sections as written (loadConfigFiles); loadConfig validates through the same schema for the language server and the vite plugin. Control API operations that located the project through a configPath take projectDir. Engine 0.6.0 dropped the deprecated defineConfig alias, so fixtures that imported it from @prisma/cli-engine now import definePrismaConfig. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- .../config-validation-and-normalization.mdc | 29 +- docs/CLI Style Guide.md | 3 +- packages/0-config/tsdown/shell-testkit.ts | 1 - .../1-framework/1-core/config/package.json | 1 - .../1-core/config/src/config-types.ts | 11 +- .../1-core/config/src/config-validation.ts | 345 ---------------- .../config/src/exports/config-validation.ts | 2 - .../config/test/config-validation.test.ts | 378 ------------------ .../1-core/config/tsdown.config.ts | 2 +- .../3-tooling/cli-telemetry/package.json | 1 + .../3-tooling/cli-telemetry/src/enrich.ts | 13 +- .../1-framework/3-tooling/cli/package.json | 4 +- .../control-api/operations/contract-emit.ts | 10 +- .../control-api/operations/migrate-show.ts | 3 - .../control-api/operations/migration-new.ts | 7 +- .../control-api/operations/migration-plan.ts | 8 +- .../control-api/operations/ref-advancement.ts | 4 +- .../cli/src/control-api/operations/ref.ts | 12 +- .../3-tooling/cli/src/control-api/types.ts | 10 +- .../3-tooling/cli/src/exports/index.ts | 3 +- .../3-tooling/cli/src/orm/config-section.ts | 163 -------- .../3-tooling/cli/src/orm/contract/emit.ts | 2 +- .../3-tooling/cli/src/orm/contract/infer.ts | 2 +- .../3-tooling/cli/src/orm/db/init.ts | 6 +- .../3-tooling/cli/src/orm/db/prepare.ts | 6 +- .../3-tooling/cli/src/orm/db/schema.ts | 2 +- .../3-tooling/cli/src/orm/db/sign.ts | 15 +- .../3-tooling/cli/src/orm/db/update.ts | 6 +- .../3-tooling/cli/src/orm/db/verification.ts | 2 +- .../3-tooling/cli/src/orm/db/verify.ts | 4 +- .../3-tooling/cli/src/orm/define-command.ts | 37 +- .../3-tooling/cli/src/orm/family.ts | 2 +- .../3-tooling/cli/src/orm/format.ts | 2 +- .../3-tooling/cli/src/orm/load-config.ts | 31 +- .../3-tooling/cli/src/orm/migrate.ts | 8 +- .../3-tooling/cli/src/orm/migration/check.ts | 6 +- .../3-tooling/cli/src/orm/migration/graph.ts | 4 +- .../3-tooling/cli/src/orm/migration/list.ts | 4 +- .../3-tooling/cli/src/orm/migration/log.ts | 2 +- .../3-tooling/cli/src/orm/migration/new.ts | 10 +- .../3-tooling/cli/src/orm/migration/paths.ts | 47 +-- .../3-tooling/cli/src/orm/migration/plan.ts | 12 +- .../3-tooling/cli/src/orm/migration/show.ts | 8 +- .../3-tooling/cli/src/orm/migration/status.ts | 8 +- .../3-tooling/cli/src/orm/ref/delete.ts | 2 +- .../3-tooling/cli/src/orm/ref/list.ts | 2 +- .../3-tooling/cli/src/orm/ref/set.ts | 2 +- .../cli/src/utils/command-helpers.ts | 13 +- .../cli/src/utils/project-import-root.ts | 14 +- .../migration-ref-error-mapping.test.ts | 3 - .../control-api/migrate-show-plan.test.ts | 5 - .../migration-plan-prologue.test.ts | 4 +- .../test/control-api/ref-advancement.test.ts | 10 +- .../cli/test/helpers/orm-test-cli.ts | 26 ++ .../3-tooling/cli/test/orm/cli.test.ts | 6 +- .../cli/test/orm/config-section.test.ts | 219 ---------- .../cli/test/orm/contract-emit.test.ts | 44 +- .../cli/test/orm/contract-infer.test.ts | 4 +- .../test/orm/cross-consumer-integrity.test.ts | 4 +- .../3-tooling/cli/test/orm/db-init.test.ts | 4 +- .../3-tooling/cli/test/orm/db-schema.test.ts | 4 +- .../cli/test/orm/db-sign-fixtures.ts | 4 +- .../cli/test/orm/db-update-consent.test.ts | 4 +- .../test/orm/db-update-to-resolution.test.ts | 4 +- .../3-tooling/cli/test/orm/db-update.test.ts | 4 +- .../3-tooling/cli/test/orm/db-verify.test.ts | 4 +- .../cli/test/orm/define-command.test.ts | 54 --- .../3-tooling/cli/test/orm/format.test.ts | 6 +- .../cli/test/orm/load-config.test.ts | 28 +- .../cli/test/orm/migrate-show.test.ts | 4 +- .../cli/test/orm/migrate-to-contract.test.ts | 4 +- .../3-tooling/cli/test/orm/migrate.test.ts | 4 +- .../orm/migration-check-multi-space.test.ts | 4 +- .../cli/test/orm/migration-check.test.ts | 4 +- .../cli/test/orm/migration-graph.test.ts | 4 +- .../cli/test/orm/migration-invariants.test.ts | 4 +- .../cli/test/orm/migration-list.test.ts | 6 +- .../cli/test/orm/migration-log.test.ts | 4 +- .../cli/test/orm/migration-new.test.ts | 6 +- .../cli/test/orm/migration-plan.test.ts | 72 +++- .../cli/test/orm/migration-show.test.ts | 6 +- .../orm/migration-snapshot-content.test.ts | 12 +- .../cli/test/orm/migration-status.test.ts | 4 +- .../cli/test/orm/migration-tamper.test.ts | 4 +- .../3-tooling/cli/test/orm/ref-fixtures.ts | 6 +- .../orm/ref-format-error-boundary.test.ts | 26 +- .../cli/test/utils/command-helpers.test.ts | 55 +-- .../3-tooling/config-loader/README.md | 9 +- .../3-tooling/config-loader/package.json | 2 + .../config-loader/src/exports/index.ts | 12 +- .../config-loader/src/finalize-config.ts | 65 --- .../3-tooling/config-loader/src/load.ts | 192 ++++++--- .../config-loader/src/orm-section.ts | 148 +++++++ .../test/finalize-config.test.ts | 134 ------- .../config-loader/test/orm-section.test.ts | 184 +++++++++ .../vite-plugin-contract-emit/src/plugin.ts | 4 +- .../test/plugin.test.ts | 9 +- packages/3-extensions/paradedb/package.json | 2 +- packages/3-extensions/pgvector/package.json | 2 +- packages/3-extensions/postgis/package.json | 2 +- packages/3-extensions/supabase/package.json | 2 +- .../@prisma/orm-framework/package.json | 1 - .../@prisma/orm-toolchain/package.json | 2 +- pnpm-lock.yaml | 53 ++- test/integration/package.json | 2 +- .../test/cli.init-templates.e2e.test.ts | 6 +- .../cli-journeys/prisma.config.prisma7.ts | 4 +- .../create_many/_fixture/prisma.config.ts | 4 +- 108 files changed, 953 insertions(+), 1825 deletions(-) delete mode 100644 packages/1-framework/1-core/config/src/config-validation.ts delete mode 100644 packages/1-framework/1-core/config/src/exports/config-validation.ts delete mode 100644 packages/1-framework/1-core/config/test/config-validation.test.ts delete mode 100644 packages/1-framework/3-tooling/cli/src/orm/config-section.ts create mode 100644 packages/1-framework/3-tooling/cli/test/helpers/orm-test-cli.ts delete mode 100644 packages/1-framework/3-tooling/cli/test/orm/config-section.test.ts delete mode 100644 packages/1-framework/3-tooling/config-loader/src/finalize-config.ts create mode 100644 packages/1-framework/3-tooling/config-loader/src/orm-section.ts delete mode 100644 packages/1-framework/3-tooling/config-loader/test/finalize-config.test.ts create mode 100644 packages/1-framework/3-tooling/config-loader/test/orm-section.test.ts 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/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 402e545202f4..000000000000 --- a/packages/1-framework/1-core/config/src/config-validation.ts +++ /dev/null @@ -1,345 +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; - } - - 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 7792f380dfe0..000000000000 --- a/packages/1-framework/1-core/config/test/config-validation.test.ts +++ /dev/null @@ -1,378 +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 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/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..92f5e9f55832 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 `@internal/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/package.json b/packages/1-framework/3-tooling/cli/package.json index 4d017599d0c5..c40d102ed905 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.0", "@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.0", "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 4fc413a58658..0ae40a1c0886 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 @@ -244,7 +244,7 @@ export async function executeContractEmit( ): Promise { const { config, - configPath, + projectDir, outputPath, signal = new AbortController().signal, onProgress, @@ -367,9 +367,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 5a1a29c3b90c..36eb37525f41 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/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 e26bc9591fa5..259944cefdb6 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'; @@ -63,10 +63,10 @@ function pslConfig(inputPath: 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 7e456bac0c1d..e4f68fd9a83b 100644 --- a/packages/1-framework/3-tooling/config-loader/README.md +++ b/packages/1-framework/3-tooling/config-loader/README.md @@ -2,12 +2,15 @@ > **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`). It exposes a single `loadConfig(configPath?)` that maps failures to the CLI's structured diff --git a/packages/1-framework/3-tooling/config-loader/package.json b/packages/1-framework/3-tooling/config-loader/package.json index 4bbcebee6e97..653c7edc06a2 100644 --- a/packages/1-framework/3-tooling/config-loader/package.json +++ b/packages/1-framework/3-tooling/config-loader/package.json @@ -24,6 +24,7 @@ "pathe": "^2.0.3" }, "devDependencies": { + "@prisma/cli-engine": "0.6.0", "@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", @@ -33,6 +34,7 @@ "vitest": "catalog:" }, "peerDependencies": { + "@prisma/cli-engine": "0.6.0", "typescript": ">=5.9" }, "peerDependenciesMeta": { 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 e13af5fc6e29..0cff04f69cd0 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,10 +1,18 @@ export type { PrismaNextConfig } from '@internal/config/config-types'; -export { finalizeConfig } from '../finalize-config'; -export type { ConfigSection, LoadedConfig } from '../load'; +export type { ConfigFile, ConfigFiles, LoadedConfig } from '../load'; export { 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 aff5e610a057..14cdaea06dca 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,9 @@ 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, isConfigSection, validateOrmSection } from './orm-section'; const CONFIG_FILENAME = 'prisma.config.ts'; @@ -99,30 +98,33 @@ function collectArtifactCollisionDiagnostics( return []; } -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')) { +function buildLoadedConfig( + 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 } : {}), + }); + }); + // A section that failed validation is left exactly as authored, for the + // commands that read only its valid subsections. + const config = blindCast< + PrismaNextConfig, + 'a config with diagnostics is guarded by requireConfigSections before any subsection is read' + >(rawConfig); return { config, diagnostics }; } - - const contract = finalizeContractConfig(config.contract, configDir); - diagnostics.push(...collectArtifactCollisionDiagnostics(contract)); - return { config: { ...config, contract }, diagnostics }; + const config = validation.value; + if (config.contract === undefined) { + return { config, diagnostics: [] }; + } + return { config, diagnostics: collectArtifactCollisionDiagnostics(config.contract) }; } function toConfigLoadFailure(error: unknown, configPath?: string): CliStructuredError { @@ -174,10 +176,38 @@ async function importC12(): Promise { return await import(pathToFileURL(entry).href); } -export async function loadConfig( +/** 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> { +): Promise> { const cwd = options?.cwd ?? process.cwd(); const resolvedConfigPath = configPath ? resolve(cwd, configPath) : undefined; const configCwd = resolvedConfigPath ? dirname(resolvedConfigPath) : cwd; @@ -204,42 +234,100 @@ export async function loadConfig( return notOk(errorConfigFileNotFound(displayPath)); } - /* 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; + 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) { - 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)); + 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 -- 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, + }); +} + +/** 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 }; +} + +export async function loadConfig( + configPath?: string, + options?: { readonly cwd?: string }, +): Promise> { + const loaded = await loadConfigFiles(configPath, options); + if (!loaded.ok) { + return loaded; + } + const orm = loaded.value.merged['orm']; + if (orm !== undefined && !isRecord(orm)) { + const base = buildLoadedConfig({}, { files: [], keys: {} }); + return ok({ + config: base.config, + diagnostics: [ + errorConfigValidation('orm', { + why: `The orm section of ${CONFIG_FILENAME} must be an object`, + }), + ], + }); + } + const section = orm ?? {}; + const provenance = ormProvenance(loaded.value.files, section); + const requested = loaded.value.files[0]?.path; + return ok( + buildLoadedConfig( + 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..aa99c158bf1e --- /dev/null +++ b/packages/1-framework/3-tooling/config-loader/src/orm-section.ts @@ -0,0 +1,148 @@ +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, 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); +} + +const targetDescriptor = { + kind: "'target'", + id: 'string', + familyId: 'string', + targetId: 'string', + version: 'string', + create: 'Function', +} as const; + +const adapterDescriptor = { ...targetDescriptor, kind: "'adapter'" } as const; +const driverDescriptor = { ...targetDescriptor, kind: "'driver'" } as const; +const extensionDescriptor = { ...targetDescriptor, kind: "'extension'" } as const; + +/** + * 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 = configSchema({ + family: { + kind: "'family'", + id: 'string', + familyId: 'string', + version: 'string', + emission: 'object', + create: 'Function', + }, + target: targetDescriptor, + adapter: adapterDescriptor, + 'driver?': driverDescriptor, + 'extensions?': [extensionDescriptor, '[]'], + 'db?': { 'connection?': 'unknown' }, + 'contract?': { + source: { 'format?': 'string', 'inputs?': 'path[]', load: 'Function' }, + output: ['path', '=', () => 'src/prisma/contract.json'], + }, + migrations: [{ dir: ['path', '=', () => './migrations'] }, '=', () => ({})], + 'formatter?': { 'indent?': "number.integer >= 1 | 'tab'", 'newline?': "'LF' | 'CRLF'" }, +}).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 { familyId } = config.family; + const { targetId } = config.target; + if (config.target.familyId !== familyId) { + return ctx.reject({ + path: ['target', 'familyId'], + expected: `Config.family.familyId (${familyId})`, + actual: config.target.familyId, + }); + } + const targetLike: ReadonlyArray< + readonly [readonly PropertyKey[], { readonly familyId: string; readonly targetId: string }] + > = [ + [['adapter'], config.adapter], + ...(config.driver === undefined ? [] : [[['driver'], config.driver] as const]), + ...(config.extensions ?? []).map( + (extension, index) => [['extensions', index], extension] as const, + ), + ]; + for (const [path, descriptor] of targetLike) { + if (descriptor.familyId !== familyId) { + return ctx.reject({ + path: [...path, 'familyId'], + expected: `Config.family.familyId (${familyId})`, + actual: descriptor.familyId, + }); + } + if (descriptor.targetId !== targetId) { + return ctx.reject({ + path: [...path, 'targetId'], + expected: `Config.target.targetId (${targetId})`, + actual: descriptor.targetId, + }); + } + } + return true; +}); + +/** + * 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)); +} 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/orm-section.test.ts b/packages/1-framework/3-tooling/config-loader/test/orm-section.test.ts new file mode 100644 index 000000000000..c49e9c37c714 --- /dev/null +++ b/packages/1-framework/3-tooling/config-loader/test/orm-section.test.ts @@ -0,0 +1,184 @@ +import type { SectionProvenance } from '@prisma/cli-engine'; +import { describe, expect, it } from 'vitest'; +import { 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('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('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 descriptor fields the schema does not name', () => { + const raw = validRaw({ + family: { ...(validRaw()['family'] as object), manifest: { note: 1 } }, + }); + + const result = validateOrmSection(raw, provenanceFor(raw)); + + expect(result.ok && result.value.family).toMatchObject({ manifest: { note: 1 } }); + }); + + 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 55d6d0d15c24..8e80da517b14 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 { 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 9407f7f5bb20..4dd199c877a5 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 @@ -32,6 +32,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, }; @@ -250,7 +251,7 @@ describe('prismaVitePlugin', () => { expect(mockedExecuteContractEmit).toHaveBeenCalledWith( expect.objectContaining({ - configPath: '/project/prisma.config.ts', + projectDir: '/project', }), ); }); @@ -269,7 +270,7 @@ describe('prismaVitePlugin', () => { expect(mockedExecuteContractEmit).toHaveBeenCalledWith( expect.objectContaining({ - configPath: '/absolute/prisma.config.ts', + projectDir: '/absolute', }), ); }); @@ -420,7 +421,7 @@ describe('prismaVitePlugin', () => { expect(mockedExecuteContractEmit).toHaveBeenCalledWith( expect.objectContaining({ - configPath: expect.stringContaining('prisma.config.ts'), + projectDir: expect.any(String), }), ); }); @@ -541,7 +542,7 @@ describe('prismaVitePlugin', () => { ); expect(mockedExecuteContractEmit).toHaveBeenCalledWith( expect.objectContaining({ - configPath: '/project/prisma.config.ts', + projectDir: '/project', }), ); expect(consoleErrorSpy).not.toHaveBeenCalledWith( diff --git a/packages/3-extensions/paradedb/package.json b/packages/3-extensions/paradedb/package.json index bbf99b61622e..7dd33b423ec6 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.0", "@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..7e99186af4e5 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.0", "@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..194d526ac6c2 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.0", "@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..6885f50adc04 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.0", "@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/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 cd96195253f1..807d3b5ed399 100644 --- a/packages/9-public/@prisma/orm-toolchain/package.json +++ b/packages/9-public/@prisma/orm-toolchain/package.json @@ -48,7 +48,7 @@ "typescript": "catalog:" }, "peerDependencies": { - "@prisma/cli-engine": "0.4.0", + "@prisma/cli-engine": "0.6.0", "typescript": ">=5.9", "vite": "^7.0.0 || ^8.0.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4314ccb0214..b63d0e8ea158 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1419,8 +1419,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.0 + version: 0.6.0(@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 @@ -1448,6 +1448,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 @@ -1528,6 +1531,9 @@ importers: specifier: ^2.0.3 version: 2.0.3 devDependencies: + '@prisma/cli-engine': + specifier: 0.6.0 + version: 0.6.0(@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 @@ -3152,8 +3158,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.0 + version: 0.6.0(@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 @@ -3240,8 +3246,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.0 + version: 0.6.0(@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 @@ -3322,8 +3328,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.0 + version: 0.6.0(@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 @@ -3680,8 +3686,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.0 + version: 0.6.0(@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 @@ -5188,8 +5194,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.0 + version: 0.6.0(@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 @@ -5524,8 +5530,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.0 + version: 0.6.0(@prisma/management-api-sdk@1.61.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.11 version: link:../utils @@ -7501,6 +7507,12 @@ packages: peerDependencies: '@prisma/management-api-sdk': ^1.55.0 + '@prisma/cli-engine@0.6.0': + resolution: {integrity: sha512-YDVdHKUQ+0VS8iYCCWYPm5ath2FNrNS1IGN8i14PidoWm+8G8wOnD6swO9KpdxFHutt/6cIIz1Zv9OnOEHgzyg==} + 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==} @@ -13352,6 +13364,19 @@ snapshots: transitivePeerDependencies: - magicast + '@prisma/cli-engine@0.6.0(@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 c452bc1ffaa3..7191e3c08b21 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -78,7 +78,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.0", "@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.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', From 91d2dd55f08c411d8aa08dd0e0631f619059e6d4 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 22 Sep 2026 11:52:46 +0200 Subject: [PATCH 02/16] fix(config): descriptors are validated by predicate so the config file's objects reach commands unchanged A control descriptor is a runtime object the config file constructs: its create closes over module state, and its codec tables, contract serializer and migration hooks rely on their prototypes and on this. Declaring its shape in the schema told arktype to rebuild it, and the rebuilt copy broke db init with "unexpected typeParams for non-parameterized codec" across the integration suite. Each descriptor is now checked by predicate, every problem reported at its full path, and the object the file built is what the command receives. The contract source keeps load by reference the same way, with inputs still resolved as paths. Requires the engine to restore opaque values after validation (prisma-cli#280). Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-tooling/config-loader/package.json | 1 + .../config-loader/src/orm-section.ts | 129 +++++++++++++----- .../config-loader/test/orm-section.test.ts | 35 ++++- pnpm-lock.yaml | 3 + 4 files changed, 129 insertions(+), 39 deletions(-) diff --git a/packages/1-framework/3-tooling/config-loader/package.json b/packages/1-framework/3-tooling/config-loader/package.json index 653c7edc06a2..1a08f8340f8e 100644 --- a/packages/1-framework/3-tooling/config-loader/package.json +++ b/packages/1-framework/3-tooling/config-loader/package.json @@ -20,6 +20,7 @@ "@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" }, 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 index aa99c158bf1e..7a6efb5d2797 100644 --- a/packages/1-framework/3-tooling/config-loader/src/orm-section.ts +++ b/packages/1-framework/3-tooling/config-loader/src/orm-section.ts @@ -2,6 +2,7 @@ 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, validateSectionWithSchema } from '@prisma/cli-engine'; +import { type } from 'arktype'; /** The single config section the `orm` command family owns. */ export const ORM_CONFIG_SECTION_NAME = 'orm'; @@ -38,18 +39,78 @@ export function isConfigSection(value: string): value is ConfigSection { return CONFIG_SECTIONS.some((section) => section === value); } -const targetDescriptor = { - kind: "'target'", - id: 'string', - familyId: 'string', - targetId: 'string', - version: 'string', - create: 'Function', -} as const; +type DescriptorKind = 'family' | 'target' | 'adapter' | 'driver' | 'extension'; + +const DESCRIPTOR_STRING_FIELDS = ['id', 'familyId', 'version'] as const; + +type Problem = { readonly field: string; readonly expected: string; readonly actual: string }; + +function descriptorProblems( + kind: DescriptorKind, + targetLike: boolean, + value: Record, +): readonly Problem[] { + const problems: Problem[] = []; + if (value['kind'] !== kind) { + problems.push({ field: 'kind', expected: `'${kind}'`, actual: JSON.stringify(value['kind']) }); + } + for (const field of DESCRIPTOR_STRING_FIELDS) { + if (typeof value[field] !== 'string') { + problems.push({ field, expected: 'a string', actual: typeof value[field] }); + } + } + if (targetLike && typeof value['targetId'] !== 'string') { + problems.push({ field: 'targetId', expected: 'a string', actual: typeof value['targetId'] }); + } + if (kind === 'family' && (typeof value['emission'] !== 'object' || value['emission'] === null)) { + problems.push({ + field: 'emission', + expected: 'an EmissionSpi object', + actual: typeof value['emission'], + }); + } + if (typeof value['create'] !== 'function') { + problems.push({ field: 'create', expected: 'a function', actual: typeof value['create'] }); + } + return problems; +} -const adapterDescriptor = { ...targetDescriptor, kind: "'adapter'" } as const; -const driverDescriptor = { ...targetDescriptor, kind: "'driver'" } as const; -const extensionDescriptor = { ...targetDescriptor, kind: "'extension'" } as const; +/** + * A control descriptor is a runtime object the config file constructs: its + * `create` closes over module state, and its nested tables (codecs, the + * contract serializer, migration hooks) rely on their prototypes and on + * `this`. arktype rebuilds every object it validates structurally, which would + * strip all of that. So a descriptor is validated by predicate, which keeps + * the reference the file built, and only its identifying fields are checked. + * Every problem is reported, at its full path: a predicate's own `path` is + * relative to the value, so the traversal path is prepended by hand. + */ +function descriptor(kind: DescriptorKind, targetLike: boolean) { + return type('object').narrow((value, ctx) => { + const problems = descriptorProblems(kind, targetLike, value as Record); + for (const problem of problems) { + ctx.error({ + path: [...ctx.path, problem.field], + expected: problem.expected, + actual: problem.actual, + }); + } + return problems.length === 0; + }); +} + +/** + * The contract source provider: its `load` closes over the authored contract + * and is kept by reference (arktype never clones functions), while `inputs` + * are paths the schema resolves against the config file. Other keys a + * provider carries (`format`, provider-specific fields) pass through. + */ +const contractSource = { + load: 'Function', + 'inputs?': 'path[]', + 'format?': 'string', + '+': 'ignore', +} as const; /** * The shape of the `orm` section of `prisma.config.ts`, declared once. The @@ -59,21 +120,14 @@ const extensionDescriptor = { ...targetDescriptor, kind: "'extension'" } as cons * before any command sees the value, and the value carries `baseDir`. */ export const ormConfigSchema = configSchema({ - family: { - kind: "'family'", - id: 'string', - familyId: 'string', - version: 'string', - emission: 'object', - create: 'Function', - }, - target: targetDescriptor, - adapter: adapterDescriptor, - 'driver?': driverDescriptor, - 'extensions?': [extensionDescriptor, '[]'], + family: descriptor('family', false), + target: descriptor('target', true), + adapter: descriptor('adapter', true), + 'driver?': descriptor('driver', true), + 'extensions?': [descriptor('extension', true), '[]'], 'db?': { 'connection?': 'unknown' }, 'contract?': { - source: { 'format?': 'string', 'inputs?': 'path[]', load: 'Function' }, + source: contractSource, output: ['path', '=', () => 'src/prisma/contract.json'], }, migrations: [{ dir: ['path', '=', () => './migrations'] }, '=', () => ({})], @@ -85,36 +139,39 @@ export const ormConfigSchema = configSchema({ message: 'Config.extensionPacks is no longer supported; rename it to Config.extensions', }); } - const { familyId } = config.family; - const { targetId } = config.target; - if (config.target.familyId !== familyId) { + const family = config.family as { readonly familyId: string }; + const target = config.target as { readonly familyId: string; readonly targetId: string }; + if (target.familyId !== family.familyId) { return ctx.reject({ path: ['target', 'familyId'], - expected: `Config.family.familyId (${familyId})`, - actual: config.target.familyId, + expected: `Config.family.familyId (${family.familyId})`, + actual: target.familyId, }); } const targetLike: ReadonlyArray< readonly [readonly PropertyKey[], { readonly familyId: string; readonly targetId: string }] > = [ - [['adapter'], config.adapter], - ...(config.driver === undefined ? [] : [[['driver'], config.driver] as const]), + [['adapter'], config.adapter as { familyId: string; targetId: string }], + ...(config.driver === undefined + ? [] + : [[['driver'], config.driver as { familyId: string; targetId: string }] as const]), ...(config.extensions ?? []).map( - (extension, index) => [['extensions', index], extension] as const, + (extension, index) => + [['extensions', index], extension as { familyId: string; targetId: string }] as const, ), ]; for (const [path, descriptor] of targetLike) { - if (descriptor.familyId !== familyId) { + if (descriptor.familyId !== family.familyId) { return ctx.reject({ path: [...path, 'familyId'], - expected: `Config.family.familyId (${familyId})`, + expected: `Config.family.familyId (${family.familyId})`, actual: descriptor.familyId, }); } - if (descriptor.targetId !== targetId) { + if (descriptor.targetId !== target.targetId) { return ctx.reject({ path: [...path, 'targetId'], - expected: `Config.target.targetId (${targetId})`, + expected: `Config.target.targetId (${target.targetId})`, actual: descriptor.targetId, }); } 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 index c49e9c37c714..763e1d7ab187 100644 --- 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 @@ -97,11 +97,11 @@ describe('the orm section', () => { it('reports descriptor field problems', () => { expect(fields(validRaw({ family: { kind: 'family' } }))).toEqual([ - 'family.create', - 'family.emission', - 'family.familyId', 'family.id', + 'family.familyId', 'family.version', + 'family.emission', + 'family.create', ]); }); @@ -156,6 +156,35 @@ describe('the orm section', () => { expect(fields(validRaw({ formatter: { indent: 'tab', newline: 'LF' } }))).toEqual([]); }); + it('keeps every descriptor by reference, so closures, prototypes and this survive', () => { + 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 raw = validRaw({ target, contract: { source } }); + + 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.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 descriptor fields the schema does not name', () => { const raw = validRaw({ family: { ...(validRaw()['family'] as object), manifest: { note: 1 } }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b63d0e8ea158..38fb510ba6a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1524,6 +1524,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) From 305921a9fffd06dbfc1208b651fd8199da1c377f Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 22 Sep 2026 12:25:58 +0200 Subject: [PATCH 03/16] test(integration): a malformed orm field under the prisma CLI is a CLI.CONFIG_FIELD_INVALID diagnostic The engine now reports each bad field of a schema-declared section itself, with meta.field and the file that declared it, under CLI.CONFIG_SECTION_INVALID. CONFIG.VALIDATION_FAILED remains the code the ORM's own loader raises for readers outside a command run; the error reference records the split. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/reference/error-reference.md | 2 +- .../test/cli.config-section-requirements.test.ts | 14 ++++++++++---- test/integration/test/cli.emit-command.test.ts | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index e768c1f78438..d3d662d59873 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/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 c483e8097380..552ea86037e2 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(); From a27ad1ca78e9a44bd0ed5bc79fc52093ce0f9a32 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 23 Sep 2026 10:26:44 +0200 Subject: [PATCH 04/16] refactor(config): control descriptors are declared as plain arktype objects Descriptors were validated by a hand-written predicate so that arktype would not rebuild them: its built-in clone turned their codec tables and contract serializer into lookalikes. Engine 0.6.1 supplies its own clone through arktype's clone option, which rebuilds only plain objects and arrays, so a descriptor's class instances and functions reach the command as the config file built them however the descriptor is declared. The predicate, its problem list and the manual error paths are replaced by an ordinary declaration of the fields that identify a descriptor. arktype reports each missing or wrong field itself, and the cross-descriptor check no longer needs casts because the declared types carry familyId and targetId. The contract source drops its explicit '+': 'ignore', which is arktype's default and which the descriptors rely on too; a test now covers that unnamed source keys pass through. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../config-loader/src/orm-section.ts | 100 +++++------------- .../config-loader/test/orm-section.test.ts | 24 +++-- 2 files changed, 38 insertions(+), 86 deletions(-) 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 index 7a6efb5d2797..6aa55939b9ab 100644 --- a/packages/1-framework/3-tooling/config-loader/src/orm-section.ts +++ b/packages/1-framework/3-tooling/config-loader/src/orm-section.ts @@ -2,7 +2,6 @@ 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, validateSectionWithSchema } from '@prisma/cli-engine'; -import { type } from 'arktype'; /** The single config section the `orm` command family owns. */ export const ORM_CONFIG_SECTION_NAME = 'orm'; @@ -39,77 +38,30 @@ export function isConfigSection(value: string): value is ConfigSection { return CONFIG_SECTIONS.some((section) => section === value); } -type DescriptorKind = 'family' | 'target' | 'adapter' | 'driver' | 'extension'; - -const DESCRIPTOR_STRING_FIELDS = ['id', 'familyId', 'version'] as const; - -type Problem = { readonly field: string; readonly expected: string; readonly actual: string }; - -function descriptorProblems( - kind: DescriptorKind, - targetLike: boolean, - value: Record, -): readonly Problem[] { - const problems: Problem[] = []; - if (value['kind'] !== kind) { - problems.push({ field: 'kind', expected: `'${kind}'`, actual: JSON.stringify(value['kind']) }); - } - for (const field of DESCRIPTOR_STRING_FIELDS) { - if (typeof value[field] !== 'string') { - problems.push({ field, expected: 'a string', actual: typeof value[field] }); - } - } - if (targetLike && typeof value['targetId'] !== 'string') { - problems.push({ field: 'targetId', expected: 'a string', actual: typeof value['targetId'] }); - } - if (kind === 'family' && (typeof value['emission'] !== 'object' || value['emission'] === null)) { - problems.push({ - field: 'emission', - expected: 'an EmissionSpi object', - actual: typeof value['emission'], - }); - } - if (typeof value['create'] !== 'function') { - problems.push({ field: 'create', expected: 'a function', actual: typeof value['create'] }); - } - return problems; -} - /** - * A control descriptor is a runtime object the config file constructs: its - * `create` closes over module state, and its nested tables (codecs, the - * contract serializer, migration hooks) rely on their prototypes and on - * `this`. arktype rebuilds every object it validates structurally, which would - * strip all of that. So a descriptor is validated by predicate, which keeps - * the reference the file built, and only its identifying fields are checked. - * Every problem is reported, at its full path: a predicate's own `path` is - * relative to the value, so the traversal path is prepended by hand. + * 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 as + * the config file constructed them. */ -function descriptor(kind: DescriptorKind, targetLike: boolean) { - return type('object').narrow((value, ctx) => { - const problems = descriptorProblems(kind, targetLike, value as Record); - for (const problem of problems) { - ctx.error({ - path: [...ctx.path, problem.field], - expected: problem.expected, - actual: problem.actual, - }); - } - return problems.length === 0; - }); -} +const descriptorFields = { + id: 'string', + familyId: 'string', + version: 'string', + create: 'Function', +} as const; + +const targetLikeFields = { ...descriptorFields, targetId: 'string' } as const; /** - * The contract source provider: its `load` closes over the authored contract - * and is kept by reference (arktype never clones functions), while `inputs` - * are paths the schema resolves against the config file. Other keys a - * provider carries (`format`, provider-specific fields) pass through. + * 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', - '+': 'ignore', } as const; /** @@ -120,11 +72,11 @@ const contractSource = { * before any command sees the value, and the value carries `baseDir`. */ export const ormConfigSchema = configSchema({ - family: descriptor('family', false), - target: descriptor('target', true), - adapter: descriptor('adapter', true), - 'driver?': descriptor('driver', true), - 'extensions?': [descriptor('extension', true), '[]'], + family: { kind: "'family'", ...descriptorFields, emission: 'object' }, + target: { kind: "'target'", ...targetLikeFields }, + adapter: { kind: "'adapter'", ...targetLikeFields }, + 'driver?': { kind: "'driver'", ...targetLikeFields }, + 'extensions?': [{ kind: "'extension'", ...targetLikeFields }, '[]'], 'db?': { 'connection?': 'unknown' }, 'contract?': { source: contractSource, @@ -139,8 +91,7 @@ export const ormConfigSchema = configSchema({ message: 'Config.extensionPacks is no longer supported; rename it to Config.extensions', }); } - const family = config.family as { readonly familyId: string }; - const target = config.target as { readonly familyId: string; readonly targetId: string }; + const { family, target } = config; if (target.familyId !== family.familyId) { return ctx.reject({ path: ['target', 'familyId'], @@ -151,13 +102,10 @@ export const ormConfigSchema = configSchema({ const targetLike: ReadonlyArray< readonly [readonly PropertyKey[], { readonly familyId: string; readonly targetId: string }] > = [ - [['adapter'], config.adapter as { familyId: string; targetId: string }], - ...(config.driver === undefined - ? [] - : [[['driver'], config.driver as { familyId: string; targetId: string }] as const]), + [['adapter'], config.adapter], + ...(config.driver === undefined ? [] : [[['driver'], config.driver] as const]), ...(config.extensions ?? []).map( - (extension, index) => - [['extensions', index], extension as { familyId: string; targetId: string }] as const, + (extension, index) => [['extensions', index], extension] as const, ), ]; for (const [path, descriptor] of targetLike) { 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 index 763e1d7ab187..8b1592228061 100644 --- 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 @@ -97,11 +97,11 @@ describe('the orm section', () => { it('reports descriptor field problems', () => { expect(fields(validRaw({ family: { kind: 'family' } }))).toEqual([ - 'family.id', + 'family.create', + 'family.emission', 'family.familyId', + 'family.id', 'family.version', - 'family.emission', - 'family.create', ]); }); @@ -156,7 +156,7 @@ describe('the orm section', () => { expect(fields(validRaw({ formatter: { indent: 'tab', newline: 'LF' } }))).toEqual([]); }); - it('keeps every descriptor by reference, so closures, prototypes and this survive', () => { + it('keeps what a descriptor was built from, so closures, prototypes and this survive', () => { class Serializer { deserializeContract(json: unknown): unknown { return json; @@ -177,22 +177,26 @@ describe('the orm section', () => { 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.target as unknown as typeof target).create()).toBe('postgres'); + const validatedTarget = result.value.target as unknown as typeof target; + expect(validatedTarget.contractSerializer).toBe(target.contractSerializer); + expect(validatedTarget.create()).toBe('postgres'); + expect(result.value.family.create).toBe((raw['family'] as { create: unknown }).create); expect(result.value.contract?.source.load).toBe(source.load); expect(result.value.contract?.source.inputs).toEqual(['/project/schema.prisma']); }); - it('keeps descriptor fields the schema does not name', () => { + 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.family).toMatchObject({ manifest: { note: 1 } }); + expect(result.ok && result.value).toMatchObject({ + family: { manifest: { note: 1 } }, + contract: { source: { dialect: 'sql' } }, + }); }); it('never throws on hostile input', () => { From 4042ad35149d034daa48b3e460ac946d20f379a1 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 24 Sep 2026 07:57:27 +0200 Subject: [PATCH 05/16] fix(cli-telemetry): the bundled comment no longer names an internal package The published toolchain bundles cli-telemetry, and its comment on loadProjectConfig named @internal/config-loader. The shell tarball tests reject any internal package name that is not on their recorded list, so the comment now says "the config loader". Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/1-framework/3-tooling/cli-telemetry/src/enrich.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 92f5e9f55832..01b4e50f4a68 100644 --- a/packages/1-framework/3-tooling/cli-telemetry/src/enrich.ts +++ b/packages/1-framework/3-tooling/cli-telemetry/src/enrich.ts @@ -29,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-loader` 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. From 504ba9d80187224eab98528e0cc19f8d5f986fdb Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 24 Sep 2026 07:57:41 +0200 Subject: [PATCH 06/16] refactor(codecs): a codec without params has no paramsSchema A codec that took no params declared paramsSchema = voidParamsSchema, a shared schema that accepts only undefined, and isParameterized asked whether a descriptor's schema was that exact object. The config file hands descriptors to the engine's validator, and a copy of a descriptor carries a copy of that schema, so every codec on the copy reported itself parameterized and db init failed with "Invalid typeParams for codec 'pg/text@1'". The stand-in object meant "no schema", so it is now written as no schema: paramsSchema is StandardSchemaV1

| undefined, a codec without params sets it to undefined, and isParameterized is paramsSchema !== undefined. No copy can change that answer. Validation of type params rejects any typeParams for a codec without a schema with the same error as before, and the SQL runtime's parameterized-descriptor type requires the schema. voidParamsSchema is removed; the extension upgrade fragment tells extension authors how to follow. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- ...r-query AbortSignal and column metadata.md | 3 +- ...er-order codecs for parameterized types.md | 6 +-- docs/reference/codec-authoring-guide.md | 6 +-- .../1-core/framework-components/README.md | 3 +- .../framework-components/src/exports/codec.ts | 2 +- .../src/shared/codec-descriptor.ts | 13 +++---- .../src/shared/codec-types.ts | 21 ---------- .../src/shared/resolve-codec.ts | 24 ++++++++---- .../framework-components/test/codec.test.ts | 5 +-- .../test/codec.types.test-d.ts | 3 +- .../test/data-type-descriptor.types.test-d.ts | 8 ++-- .../test/materialize-codec.test.ts | 37 +++++++++++++++++- .../psl-extension-block-validator.test.ts | 4 +- ...clarative-policy-select.round-trip.test.ts | 4 +- .../generic-extension-block-printer.test.ts | 5 +-- .../test/enum-type.authoring.test.ts | 6 +-- .../test/fixture-codec-descriptors.ts | 13 +++---- .../2-sql/4-lanes/relational-core/README.md | 3 +- .../relational-core/src/ast/sql-codecs.ts | 7 ++-- .../test/ast/sql-codec-helpers.test.ts | 6 +-- .../test/typed-codec-flow.test-d.ts | 4 +- packages/2-sql/5-runtime/src/sql-context.ts | 12 ++++-- .../5-runtime/test/ast-codec-resolver.test.ts | 7 ++-- .../5-runtime/test/codec-integrity.test.ts | 4 +- .../test/contract-codec-registry.test.ts | 4 +- .../test/sql-context.codec-context.test.ts | 6 +-- .../2-mongo-adapter/src/core/codecs.ts | 4 +- .../postgres/src/core/codec-descriptor.ts | 11 +++--- .../3-targets/postgres/src/core/codecs.ts | 31 ++++++++------- .../postgres/src/core/temporal-codecs.ts | 3 +- .../src/core/temporal-string-codecs.ts | 3 +- .../sqlite/src/core/codec-descriptor.ts | 11 +++--- .../3-targets/sqlite/src/core/codecs.ts | 17 ++++----- .../6-adapters/postgres/test/adapter.test.ts | 6 ++- .../test/lower-to-execute-request.test.ts | 10 ++--- .../test/migrations/data-transform.test.ts | 4 +- ...ostgres-codec-registry-composition.test.ts | 6 +-- .../test/sql-renderer.cast-policy.test.ts | 4 +- .../test/lower-to-execute-request.test.ts | 10 ++--- .../sqlite-codec-registry-composition.test.ts | 3 +- .../sql-orm-client/include-codecs.test.ts | 3 +- .../extension/instructions.md | 38 +++++++++++++++++++ 42 files changed, 209 insertions(+), 171 deletions(-) create mode 100644 upgrade-instructions/pending/codec-without-params-schema/extension/instructions.md 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..3111da73da6e 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 @@ -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/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/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..0991ebb2d711 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'; @@ -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..b1638522eaec 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 @@ -29,19 +29,29 @@ export function resolveCodecDescriptorOrThrow( /** * 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 and accepts no `typeParams`. + * 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 (ref.typeParams !== undefined) { + 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..8d98d33784d5 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,37 @@ 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", + ); +}); 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/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-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-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 9aacc18d67d1..e20e48ce9b99 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 { @@ -383,7 +382,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); } @@ -585,7 +584,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); } @@ -626,7 +625,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); } @@ -676,7 +675,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); } @@ -737,7 +736,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); } @@ -793,7 +792,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); } @@ -841,7 +840,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); } @@ -889,7 +888,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); } @@ -937,7 +936,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); } @@ -1060,7 +1059,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); } @@ -1264,7 +1263,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); } @@ -1311,7 +1310,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); } @@ -1358,7 +1357,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); } @@ -1474,7 +1473,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); } @@ -1519,7 +1518,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 fc8dc7e00a77..391f4b98cd03 100644 --- a/packages/3-targets/6-adapters/postgres/test/adapter.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/adapter.test.ts @@ -630,8 +630,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/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. From 768f0680ff24f4b761304aea5a23fc04ca5c19eb Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 24 Sep 2026 08:03:54 +0200 Subject: [PATCH 07/16] docs(arktype): read the docs before building validation machinery, and what a transformation does to its input The config work built a copy-and-restore walk over arktype's compiled node tree to stop arktype rebuilding a config file's objects, when arktype's documented clone option solves the same problem in a few lines. The arktype rule now says to read the arktype docs before writing code that walks schemas or repairs arktype's output, and never to read the compiled node tree. The reference doc records what was learned: any morph (a pipe or a default) makes arktype clone the whole input first, the default clone rebuilds plain objects and class instances so identity and private fields are lost, and the clone option is configurable per scope. It also covers literal versus thunk defaults, ctx.path in a morph, that a path given inside a narrow is taken from the root, that undeclared keys are kept, and that missing keys are reported alphabetically. Each claim was checked against arktype 2.2. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .agents/rules/arktype-usage.mdc | 13 +++++++++ docs/reference/arktype-usage.md | 49 +++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) 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/docs/reference/arktype-usage.md b/docs/reference/arktype-usage.md index 5c36b0d4544a..86129a951966 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 does the same job in about twenty lines. + +## 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) => copyPlainObjectsAndArrays(original) }, +); +``` + +- A custom function replaces the clone. The CLI engine's config scope copies only plain objects and arrays, where arktype writes results, and returns everything else as it is. +- `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:** From 48ab41c857759a9a04c95c96ac34bd1afe7f8322 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 24 Sep 2026 10:07:20 +0200 Subject: [PATCH 08/16] fix(codecs): a codec without params accepts empty typeParams The PSL interpreter emits typeParams: {} for a bare native-type alias, and the SQL runtime's integrity check already treats {} as no params. Type-param validation now agrees: a codec without a paramsSchema accepts absent or empty typeParams and still rejects anything else with RUNTIME.TYPE_PARAMS_INVALID. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/shared/codec-types.ts | 2 +- .../src/shared/resolve-codec.ts | 15 +++++++++++++-- .../test/materialize-codec.test.ts | 7 +++++++ 3 files changed, 21 insertions(+), 3 deletions(-) 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 0991ebb2d711..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 @@ -17,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. * 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 b1638522eaec..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,10 +26,21 @@ 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`. * - * A codec without a `paramsSchema` takes no params and accepts no `typeParams`. + * 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 @@ -38,7 +49,7 @@ export function resolveCodecDescriptorOrThrow( export function validateCodecTypeParams(descriptor: AnyCodecDescriptor, ref: CodecRef): unknown { const schema = descriptor.paramsSchema; if (schema === undefined) { - if (ref.typeParams !== undefined) { + if (!isAbsentOrEmpty(ref.typeParams)) { throw runtimeError( 'RUNTIME.TYPE_PARAMS_INVALID', `Invalid typeParams for codec '${ref.codecId}': unexpected typeParams for non-parameterized codec`, 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 8d98d33784d5..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 @@ -159,3 +159,10 @@ test('materializeCodec rejects typeParams for a codec without params', ({ expect "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'); +}); From be6f5322b45e220bcd3a9ffc66b28dc07274c99f Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 24 Sep 2026 10:07:24 +0200 Subject: [PATCH 09/16] docs(config-loader): the README shows loadConfig's Result and per-field diagnostics The usage example wrapped loadConfig in try/catch and branched on numeric codes. loadConfig returns a Result: it fails only when the file is missing, throws when evaluated, or is not a Prisma config, and a schema problem comes back as a CONFIG.VALIDATION_FAILED diagnostic that requireConfigSections turns into a failure only for the subsections a caller reads. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-tooling/config-loader/README.md | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/1-framework/3-tooling/config-loader/README.md b/packages/1-framework/3-tooling/config-loader/README.md index e4f68fd9a83b..25da02c5096e 100644 --- a/packages/1-framework/3-tooling/config-loader/README.md +++ b/packages/1-framework/3-tooling/config-loader/README.md @@ -13,22 +13,22 @@ validation for readers outside a command run and turns a `prisma.config.ts` on d resolved `PrismaNextConfig`. It also performs the emitter-derived artifact-collision check (`getEmittedArtifactPaths`). -It exposes a single `loadConfig(configPath?)` that 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). +`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 when `migrations` is malformed. ## 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'; + +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; +} +const sections = requireConfigSections(loaded.value, ['contract', 'formatter']); +if (!sections.ok) { + // a subsection this caller reads failed validation + return; } +const config = sections.value; ``` From d1c605c1441466e7802289ad15c597b7ce43f6cb Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 24 Sep 2026 10:09:00 +0200 Subject: [PATCH 10/16] docs(codecs): the codec consumer interface allows a missing paramsSchema ADR 208's CodecDescriptor example still required a paramsSchema while the author class next to it allowed undefined, so a reader copying both got incompatible types for a codec without params. The subsystem doc now says such a codec sets paramsSchema to undefined. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../ADR 208 - Higher-order codecs for parameterized types.md | 4 ++-- .../subsystems/2. Contract Emitter & Types.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 3111da73da6e..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; } 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. From 079059cca76d159b0f1dbb513d7ba9c2920c2769 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 24 Sep 2026 10:35:17 +0200 Subject: [PATCH 11/16] fix(config-loader): valid subsections are resolved when another subsection has a diagnostic A bad field anywhere in the orm section failed the whole schema, and the loader then handed back the section exactly as authored. A caller that read only healthy subsections, such as the vite plugin reading contract, got relative paths and no defaults, and emitted relative to the working directory. The loader now validates every subsection without a diagnostic on its own (the schema's structural part, picked to those subsections), so their paths are resolved and defaults applied; the failing subsections stay as authored. The README example is also valid TypeScript now. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-tooling/config-loader/README.md | 24 ++++++----- .../3-tooling/config-loader/src/load.ts | 17 ++++++-- .../config-loader/src/orm-section.ts | 40 ++++++++++++++----- .../3-tooling/config-loader/test/load.test.ts | 30 +++++++++++++- 4 files changed, 86 insertions(+), 25 deletions(-) diff --git a/packages/1-framework/3-tooling/config-loader/README.md b/packages/1-framework/3-tooling/config-loader/README.md index 25da02c5096e..6ff21141c922 100644 --- a/packages/1-framework/3-tooling/config-loader/README.md +++ b/packages/1-framework/3-tooling/config-loader/README.md @@ -13,22 +13,24 @@ validation for readers outside a command run and turns a `prisma.config.ts` on d resolved `PrismaNextConfig`. It also performs the emitter-derived artifact-collision check (`getEmittedArtifactPaths`). -`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 when `migrations` is malformed. +`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. ## Usage ```ts import { loadConfig, requireConfigSections } from '@internal/config-loader'; -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; +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; } -const sections = requireConfigSections(loaded.value, ['contract', 'formatter']); -if (!sections.ok) { - // a subsection this caller reads failed validation - return; -} -const config = sections.value; ``` 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 14cdaea06dca..6d632170d4fc 100644 --- a/packages/1-framework/3-tooling/config-loader/src/load.ts +++ b/packages/1-framework/3-tooling/config-loader/src/load.ts @@ -17,7 +17,12 @@ 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 { type ConfigSection, isConfigSection, validateOrmSection } from './orm-section'; +import { + type ConfigSection, + isConfigSection, + validateOrmSection, + validateOrmSectionExcept, +} from './orm-section'; const CONFIG_FILENAME = 'prisma.config.ts'; @@ -112,12 +117,16 @@ function buildLoadedConfig( ...(isConfigSection(section) ? { section } : {}), }); }); - // A section that failed validation is left exactly as authored, for the - // commands that read only its valid subsections. + // Subsections that failed stay exactly as authored; the rest are validated + // on their own, so a command that reads only them sees resolved paths. + 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' - >(rawConfig); + >(partial.ok ? partial.value : rawConfig); return { config, diagnostics }; } const config = validation.value; 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 index 6aa55939b9ab..2b7c47589d98 100644 --- a/packages/1-framework/3-tooling/config-loader/src/orm-section.ts +++ b/packages/1-framework/3-tooling/config-loader/src/orm-section.ts @@ -64,14 +64,8 @@ const contractSource = { 'format?': 'string', } as const; -/** - * 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 = configSchema({ +/** Each subsection's own shape, without the rules that relate subsections to one another. */ +const ormSubsectionsSchema = configSchema({ family: { kind: "'family'", ...descriptorFields, emission: 'object' }, target: { kind: "'target'", ...targetLikeFields }, adapter: { kind: "'adapter'", ...targetLikeFields }, @@ -84,7 +78,16 @@ export const ormConfigSchema = configSchema({ }, migrations: [{ dir: ['path', '=', () => './migrations'] }, '=', () => ({})], 'formatter?': { 'indent?': "number.integer >= 1 | 'tab'", 'newline?': "'LF' | 'CRLF'" }, -}).narrow((config, ctx) => { +}); + +/** + * 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'], @@ -151,3 +154,22 @@ export function validateOrmSection( 'the schema declares the shape PrismaNextConfig describes; descriptor generics are erased by validation' >(validateSectionWithSchema(ORM_CONFIG_SECTION_NAME, ormConfigSchema, raw, provenance)); } + +/** + * Validates 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. + */ +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/load.test.ts b/packages/1-framework/3-tooling/config-loader/test/load.test.ts index 7fda67e48366..ef5702720f62 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,29 @@ 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( 'reads an empty orm section from a config that declares none', async () => { From a4134fa77c6a9f94c3425bc3ac5539692f611dda Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 24 Sep 2026 10:53:37 +0200 Subject: [PATCH 12/16] fix(config-loader): valid subsections are held to the relationship and artifact rules When another subsection had a diagnostic, arktype never reached the rules that relate subsections, and the loader's pass over the valid subsections applied only their shapes. A target whose familyId differed from the family's, or contract inputs that included the emitted artifacts, reached a caller that asked for target or contract. The relationship rules are now one function, descriptorRelationshipProblems, which the schema's narrow calls for the whole section and the loader calls for the pairs of subsections that both validated. The loader also runs the artifact collision check on a valid contract. The narrow now reports every broken relationship rather than the first. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-tooling/config-loader/src/load.ts | 20 +++- .../config-loader/src/orm-section.ts | 108 ++++++++++++------ .../3-tooling/config-loader/test/load.test.ts | 45 ++++++++ 3 files changed, 136 insertions(+), 37 deletions(-) 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 6d632170d4fc..1077cd59be71 100644 --- a/packages/1-framework/3-tooling/config-loader/src/load.ts +++ b/packages/1-framework/3-tooling/config-loader/src/load.ts @@ -19,6 +19,7 @@ import type { SectionProvenance } from '@prisma/cli-engine'; import { dirname, join, resolve } from 'pathe'; import { type ConfigSection, + descriptorRelationshipProblems, isConfigSection, validateOrmSection, validateOrmSectionExcept, @@ -118,7 +119,8 @@ function buildLoadedConfig( }); }); // Subsections that failed stay exactly as authored; the rest are validated - // on their own, so a command that reads only them sees resolved paths. + // 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'] ?? '')), ); @@ -127,7 +129,21 @@ function buildLoadedConfig( PrismaNextConfig, 'a config with diagnostics is guarded by requireConfigSections before any subsection is read' >(partial.ok ? partial.value : rawConfig); - return { config, diagnostics }; + 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) { 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 index 2b7c47589d98..27547910a3f8 100644 --- a/packages/1-framework/3-tooling/config-loader/src/orm-section.ts +++ b/packages/1-framework/3-tooling/config-loader/src/orm-section.ts @@ -80,54 +80,90 @@ const ormSubsectionsSchema = configSchema({ '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; +} + /** - * 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`. + * 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 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 { family, target } = config; - if (target.familyId !== family.familyId) { - return ctx.reject({ - path: ['target', 'familyId'], - expected: `Config.family.familyId (${family.familyId})`, - actual: target.familyId, - }); - } - const targetLike: ReadonlyArray< - readonly [readonly PropertyKey[], { readonly familyId: string; readonly targetId: string }] - > = [ - [['adapter'], config.adapter], - ...(config.driver === undefined ? [] : [[['driver'], config.driver] as const]), - ...(config.extensions ?? []).map( +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, ), ]; - for (const [path, descriptor] of targetLike) { - if (descriptor.familyId !== family.familyId) { - return ctx.reject({ + 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 (descriptor.targetId !== target.targetId) { - return ctx.reject({ + 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 true; + 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; }); /** @@ -156,9 +192,11 @@ export function validateOrmSection( } /** - * Validates 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. + * 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, 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 ef5702720f62..d9dc7a630204 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 @@ -233,6 +233,51 @@ describe('loadConfig', () => { 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( 'reads an empty orm section from a config that declares none', async () => { From 9421c3f03665dec7ea4d9b595162da3d114475b3 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 24 Sep 2026 12:14:38 +0200 Subject: [PATCH 13/16] docs(config): comments name the orm config schema instead of the deleted validators Three comments still pointed at collectConfigIssues and @internal/config/config-validation, which this branch removed in favour of the orm config schema. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../1-framework/3-tooling/cli-telemetry/test/enrich.test.ts | 2 +- .../3-tooling/cli-telemetry/test/integration.test.ts | 4 ++-- .../3-tooling/cli/src/utils/framework-components.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) 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/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') From 3d8eda2d43504040e7fdb5ef1a4618106f706ea7 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 24 Sep 2026 12:49:32 +0200 Subject: [PATCH 14/16] feat(config): the orm schema declares its descriptors and connection as references Each control descriptor (family, target, adapter, driver, every extension) and db.connection is declared with the engine's reference(), so the command receives the object the config file built. Every other value is copied before paths are resolved and defaults applied. The test asserts identity again, and the arktype reference doc describes declared references instead of the plain-object rule. Needs @prisma/cli-engine with reference() (prisma/prisma-cli#284); the pins move once it is published. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/reference/arktype-usage.md | 6 ++--- .../config-loader/src/orm-section.ts | 24 ++++++++++++------- .../config-loader/test/orm-section.test.ts | 14 ++++++----- 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/docs/reference/arktype-usage.md b/docs/reference/arktype-usage.md index 86129a951966..334259dd75c8 100644 --- a/docs/reference/arktype-usage.md +++ b/docs/reference/arktype-usage.md @@ -64,7 +64,7 @@ Reference: [Arktype Declare API](https://arktype.io/docs/declare) 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 does the same job in about twenty lines. +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 @@ -91,11 +91,11 @@ 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) => copyPlainObjectsAndArrays(original) }, + { clone: (original) => copyExceptDeclaredReferences(original) }, ); ``` -- A custom function replaces the clone. The CLI engine's config scope copies only plain objects and arrays, where arktype writes results, and returns everything else as it is. +- 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. 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 index 27547910a3f8..dc0107a08da0 100644 --- a/packages/1-framework/3-tooling/config-loader/src/orm-section.ts +++ b/packages/1-framework/3-tooling/config-loader/src/orm-section.ts @@ -1,7 +1,12 @@ 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, validateSectionWithSchema } 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'; @@ -41,8 +46,9 @@ export function isConfigSection(value: string): value is ConfigSection { /** * 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 as - * the config file constructed them. + * (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', @@ -66,12 +72,12 @@ const contractSource = { /** Each subsection's own shape, without the rules that relate subsections to one another. */ const ormSubsectionsSchema = configSchema({ - family: { kind: "'family'", ...descriptorFields, emission: 'object' }, - target: { kind: "'target'", ...targetLikeFields }, - adapter: { kind: "'adapter'", ...targetLikeFields }, - 'driver?': { kind: "'driver'", ...targetLikeFields }, - 'extensions?': [{ kind: "'extension'", ...targetLikeFields }, '[]'], - 'db?': { 'connection?': 'unknown' }, + 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'], 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 index 8b1592228061..c396f37cca10 100644 --- 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 @@ -156,7 +156,7 @@ describe('the orm section', () => { expect(fields(validRaw({ formatter: { indent: 'tab', newline: 'LF' } }))).toEqual([]); }); - it('keeps what a descriptor was built from, so closures, prototypes and this survive', () => { + it('keeps every descriptor and the connection as the config file built them', () => { class Serializer { deserializeContract(json: unknown): unknown { return json; @@ -172,15 +172,17 @@ describe('the orm section', () => { }, }; const source = { format: 'psl', inputs: ['./schema.prisma'], load: () => ({}) }; - const raw = validRaw({ target, contract: { source } }); + 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)); - const validatedTarget = result.value.target as unknown as typeof target; - expect(validatedTarget.contractSerializer).toBe(target.contractSerializer); - expect(validatedTarget.create()).toBe('postgres'); - expect(result.value.family.create).toBe((raw['family'] as { create: unknown }).create); + 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']); }); From 285b2f4bfbcaba1305dabe1c35adafef711d8fce Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 24 Sep 2026 14:52:37 +0200 Subject: [PATCH 15/16] chore(deps): pin @prisma/cli-engine 0.6.1 0.6.1 carries reference(), which the orm schema uses to declare its descriptors and connection, and is now on the registry. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../1-framework/3-tooling/cli/package.json | 4 +- .../3-tooling/config-loader/package.json | 4 +- packages/3-extensions/paradedb/package.json | 2 +- packages/3-extensions/pgvector/package.json | 2 +- packages/3-extensions/postgis/package.json | 2 +- packages/3-extensions/supabase/package.json | 2 +- .../@prisma/orm-toolchain/package.json | 2 +- pnpm-lock.yaml | 38 +++++++++---------- test/integration/package.json | 2 +- 9 files changed, 29 insertions(+), 29 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/package.json b/packages/1-framework/3-tooling/cli/package.json index c40d102ed905..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.6.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.6.0", + "@prisma/cli-engine": "0.6.1", "typescript": ">=5.9" }, "peerDependenciesMeta": { diff --git a/packages/1-framework/3-tooling/config-loader/package.json b/packages/1-framework/3-tooling/config-loader/package.json index 8621b83f9e72..ac262f6574b4 100644 --- a/packages/1-framework/3-tooling/config-loader/package.json +++ b/packages/1-framework/3-tooling/config-loader/package.json @@ -26,7 +26,7 @@ "tinyglobby": "catalog:" }, "devDependencies": { - "@prisma/cli-engine": "0.6.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", @@ -36,7 +36,7 @@ "vitest": "catalog:" }, "peerDependencies": { - "@prisma/cli-engine": "0.6.0", + "@prisma/cli-engine": "0.6.1", "typescript": ">=5.9" }, "peerDependenciesMeta": { diff --git a/packages/3-extensions/paradedb/package.json b/packages/3-extensions/paradedb/package.json index 7dd33b423ec6..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.6.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 7e99186af4e5..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.6.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 194d526ac6c2..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.6.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 6885f50adc04..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.6.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/9-public/@prisma/orm-toolchain/package.json b/packages/9-public/@prisma/orm-toolchain/package.json index 89e8ba5300b8..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.6.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 f7497ae67ff5..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.6.0 - version: 0.6.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 @@ -1541,8 +1541,8 @@ importers: version: 0.2.17 devDependencies: '@prisma/cli-engine': - specifier: 0.6.0 - version: 0.6.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 @@ -3176,8 +3176,8 @@ importers: specifier: workspace:8.0.0-rc.11 version: link:../../3-targets/3-targets/postgres '@prisma/cli-engine': - specifier: 0.6.0 - version: 0.6.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 @@ -3264,8 +3264,8 @@ importers: specifier: workspace:8.0.0-rc.11 version: link:../../2-sql/2-authoring/contract-ts '@prisma/cli-engine': - specifier: 0.6.0 - version: 0.6.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 @@ -3346,8 +3346,8 @@ importers: specifier: workspace:8.0.0-rc.11 version: link:../../2-sql/2-authoring/contract-ts '@prisma/cli-engine': - specifier: 0.6.0 - version: 0.6.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 @@ -3713,8 +3713,8 @@ importers: specifier: workspace:8.0.0-rc.11 version: link:../../2-sql/2-authoring/contract-psl '@prisma/cli-engine': - specifier: 0.6.0 - version: 0.6.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 @@ -5233,8 +5233,8 @@ importers: packages/9-public/@prisma/orm-toolchain: dependencies: '@prisma/cli-engine': - specifier: 0.6.0 - version: 0.6.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 @@ -5575,8 +5575,8 @@ importers: specifier: workspace:8.0.0-rc.11 version: link:../../packages/0-shared/publish-surface '@prisma/cli-engine': - specifier: 0.6.0 - version: 0.6.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 @@ -7552,8 +7552,8 @@ packages: peerDependencies: '@prisma/management-api-sdk': ^1.55.0 - '@prisma/cli-engine@0.6.0': - resolution: {integrity: sha512-YDVdHKUQ+0VS8iYCCWYPm5ath2FNrNS1IGN8i14PidoWm+8G8wOnD6swO9KpdxFHutt/6cIIz1Zv9OnOEHgzyg==} + '@prisma/cli-engine@0.6.1': + resolution: {integrity: sha512-z9x6WCUzJdnM35tcHPYdw1DVwNYp4uv0T3oCjGZB4MdtXOUoN912oAYA7RYTjSvbulbhq+Z4pNXJ34XgdX8C4g==} engines: {node: '>=22.12.0'} peerDependencies: '@prisma/management-api-sdk': ^1.55.0 @@ -13409,7 +13409,7 @@ snapshots: transitivePeerDependencies: - magicast - '@prisma/cli-engine@0.6.0(@prisma/management-api-sdk@1.61.0)(magicast@0.5.4)': + '@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 diff --git a/test/integration/package.json b/test/integration/package.json index bf197e96ffc1..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.6.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", From 4abcbb55145458b710d746b0d827f9e3b63e460b Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 24 Sep 2026 15:08:55 +0200 Subject: [PATCH 16/16] test(config-loader): cover unreadable sections, skipped relationships and non-object exports Branch coverage of the config loader fell below its 95% threshold. New tests cover a section whose read throws (it comes back as authored with a diagnostic that blocks every subsection), relationship checks skipping the subsections a caller excludes, and a config whose default export is not an object. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../test/build-loaded-config.test.ts | 18 +++++++++++- .../3-tooling/config-loader/test/load.test.ts | 14 +++++++++ .../config-loader/test/orm-section.test.ts | 29 ++++++++++++++++++- 3 files changed, 59 insertions(+), 2 deletions(-) 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/load.test.ts b/packages/1-framework/3-tooling/config-loader/test/load.test.ts index d9dc7a630204..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 @@ -278,6 +278,20 @@ describe('loadConfig', () => { 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 index f45871cb0955..57baf3cf6564 100644 --- 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 @@ -1,6 +1,10 @@ import type { SectionProvenance } from '@prisma/cli-engine'; import { describe, expect, it } from 'vitest'; -import { ormConfigSection, validateOrmSection } from '../src/orm-section'; +import { + descriptorRelationshipProblems, + ormConfigSection, + validateOrmSection, +} from '../src/orm-section'; const FILE = '/project/prisma.config.ts'; @@ -151,6 +155,29 @@ describe('the orm section', () => { ).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']); });