diff --git a/.agents/rules/README.md b/.agents/rules/README.md
index 953a14ea1255..f3db988b631a 100644
--- a/.agents/rules/README.md
+++ b/.agents/rules/README.md
@@ -79,7 +79,7 @@ Rules below are listed by bare filename; the canonical file is `.agents/rules/(Prisma 7)"] --> parser["@internal/psl-parser grammar: 'prisma7'"]
+ schema["schema.prisma (Prisma 7)"] --> parser["@internal/psl-parser one grammar for every PSL document"]
parser --> interpreter["@internal/sql-contract-prisma7 rules of the Prisma 7 language for the SQL family"]
binding["@internal/target-postgres prisma7PostgresBinding: what Postgres creates"] --> interpreter
interpreter --> contract["Contract"]
@@ -94,7 +94,7 @@ flowchart LR
facade -. wires .-> binding
```
-**The parser is shared, and the earlier grammar is opt-in.** `@internal/psl-parser` reads both languages. The two additions the earlier language needs, attributes on enum members and field lines inside a `view` block, are read only under the `grammar: 'prisma7'` parse option. The default grammar is unchanged, so a Prisma 8 schema keeps rejecting exactly what it rejected before, and nothing here reaches a user who never adopts the reader.
+**The parser is shared, and it has one grammar.** `@internal/psl-parser` parses every PSL document the same way, whichever Prisma version wrote it. The two constructs the earlier language needs, attributes on enum members and field lines inside a `view` block, parse in every document. Each reader decides what it accepts. The Prisma 8 readers report an attribute on an enum member, and report a `view` block like any other block whose keyword no composed pack claims, so a Prisma 8 PSL contract that uses either fails. The Prisma 7 reader reads enum member attributes and refuses a `view` with the error shown above.
**Rules of the language live in the family authoring package.** `@internal/sql-contract-prisma7` holds everything that is true of the Prisma 7 language for the SQL family: blocks and attributes, relation pairing, junction tables, defaults, and the diagnostics. It knows nothing about a particular database and depends on no Prisma 7 package.
diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md
index 7bb5a29dcc33..c053944127f2 100644
--- a/docs/reference/error-reference.md
+++ b/docs/reference/error-reference.md
@@ -281,6 +281,80 @@ A Mongo model's collection attachment is wrong: the model declares `indexes`, `c
A model declares an empty unique constraint (a unique with no fields), raised during SQL contract lowering (meta: `modelName`). Also raised when a CHECK constraint reaches SQLite migration DDL rendering: the SQLite target does not support CHECK constraints, and `sql.checkConstraint` is a Postgres-only capability. A `@@check` is refused earlier, by the PSL capability gate; a `check()` declared through the TypeScript builder is not, because capabilities reach the contract only after it is built, so this is where a SQLite `check()` is refused (meta: `constraintName`, and `tableName` where available).
+### CONTRACT.PRINT_OUTPUT_IS_PROJECT_FILE
+
+`prisma contract print --output` was asked to write over a file the project needs: the `prisma.config.ts` in the directory of the config that defines the `orm` section, or one of the files `contract emit` writes (the JSON `contract.output` names, and the `.d.ts` beside it). Writing there would put PSL where the CLI reads its config when `--config` names no other file, or the next `contract emit` would write over the printed PSL. The check compares the files the paths name: a path through a symbolic link, or one that differs only in case on a volume that ignores case, counts as the same file. Raised before the source is read, so nothing is written. Pick another `--output` path. Payload: `output` and `file`, both relative to the invocation directory.
+
+### CONTRACT.PRINT_OUTPUT_IS_SOURCE
+
+`prisma contract print --output` was asked to write over a file it reads: the resolved `--output` path is one of the contract source's inputs, or sits inside a directory of source files. Writing there would destroy the source the printed contract is made from. The same code is raised when the path names a new file that a glob input of the source would match once written, because the next `contract emit` would read the printed file together with the source files; `source` is then the glob. Pick another `--output` path, outside the files the config names. Raised before the source is read, so nothing is written and the source file is untouched. Payload: `output` and `source`, both relative to the invocation directory.
+
+### CONTRACT.PRINT_UNSUPPORTED
+
+`contract print` cannot write the loaded contract as Prisma 8 PSL that reads back as the same contract, so it writes nothing. The message names what it stopped on. Raised when the configured family cannot print a contract (no meta), or when the target's descriptor has no `buildPslContract` hook (meta: `targetId`). The Postgres printer raises it in each case below; each case is one function in its `psl-print/refusals.ts`, in this order. Every case is a contract that passes validation. The printer takes a validated contract and does not check its structure again.
+
+- Column types and defaults:
+ - no PSL type in the configured stack produces a column's codec, native type and type parameters, including a column that has no value for an argument its type constructor requires. Add the extension that contributes the type to the config (meta: `coordinate`, `nativeType`, `codecId`);
+ - a string type argument holds a quote, backslash or line break, which the PSL source reads back differently (meta: `coordinate`, `argument`);
+ - a domain enum column defaults to a value that is not a member of the enum (meta: `coordinate`, `pslTypeName`);
+ - a column's literal default has no PSL literal that reads back as the stored value, including when the column's codec has no data type in the stack (meta: `coordinate`, `pslTypeName`).
+- Generated values:
+ - a column pairs the wall-clock-now generator with a different generator (meta: `coordinate`, `onCreate`, `onUpdate`);
+ - a column generates a value on update other than through a temporal preset (meta: `coordinate`, `onCreate`, `onUpdate`);
+ - a column is generated by a generator no PSL default function of the Postgres adapter produces (meta: `coordinate`, `onCreate`, `onUpdate`);
+ - a column has both an id generator and a database default (meta: `coordinate`, `onCreate`);
+ - a generated value names a column no field is stored in (meta: `coordinate`).
+- Fields and columns:
+ - a field and its column disagree where PSL writes them once: the field is optional and the column is not nullable or the reverse, a column of a single-table variant is not nullable, one of them is a list and the other is not, a scalar field's codec or type parameters differ from its column's, or they do not name the enum and value set the PSL source derives for a field typed by an enum (meta: `coordinate`);
+ - a model or value-object field's type is a union of types (meta: `coordinate`, `kind`), or a field is a dictionary (meta: `coordinate`);
+ - a value-object field carries type parameters or a value set, which the PSL source does not keep on a value-object field (meta: `coordinate`);
+ - a value-object field uses a codec that no Postgres codec in the configured stack names a native type for (meta: `coordinate`, `codecId`);
+ - a value-object field uses a codec that names a native type only from type parameters (meta: `coordinate`, `codecId`);
+ - a field is stored in no column (meta: `namespaceId`, `modelName`, `field`);
+ - a model stores a column under a field name the model does not declare (meta: `namespaceId`, `modelName`, `field`);
+ - a column is typed by a named type the contract does not declare, or its native type or codec is not the named type's (meta: `coordinate`, `typeRef`);
+ - a column has its own control policy (meta: `coordinate`, `control`);
+ - a table has no model stored in it (meta: `namespaceId`, `table`), or a column is stored by no field, other than the primary key columns that link a multi-table variant to its base (meta: `namespaceId`, `table`, `column`).
+- Models:
+ - a model has an owner (meta: `namespaceId`, `modelName`, `owner`);
+ - a multi-table variant is linked to its base other than through the base's primary key columns as its unnamed primary key and an unnamed foreign key that cascades on delete (meta: `namespaceId`, `modelName`);
+ - one model name is declared in more than one namespace (meta: `modelName`, `namespaces`);
+ - a domain enum is declared outside the default namespace (meta: `namespaceId`, `names`);
+ - a value object is declared outside the default namespace (meta: `namespaceId`, `names`).
+- Keys, checks and indexes:
+ - a check or index has a prefix, but its name is not that prefix followed by the hash of its content (meta: `namespaceId`, `table`, `name`, `prefix`);
+ - a managed table lacks a check the PSL source derives for an enum or list column, or has a check with that check's name but not its prefix and expression (meta: `namespaceId`, `table`, `name`);
+ - an index has options but no type (meta: `namespaceId`, `table`, `index`), or an option whose value is not a string (meta: `namespaceId`, `table`, `index`, `key`).
+- Relations:
+ - a to-one relation has no foreign key behind it (meta: `model`, `field`);
+ - a foreign key has no relation that travels it (meta: `namespaceId`, `table`, `columns`);
+ - a relation targets a model in another contract space, which the printer does not write yet (meta: `model`, `field`, `space`);
+ - a many-to-many relation goes through a table whose model has no relation back to the relation's model (meta: `model`, `field`);
+ - a one-to-many or one-to-one relation has no foreign key of its own, and the model it targets has no relation back that holds the foreign key (meta: `model`, `field`);
+ - a relation names no fields to join on (meta: `model`, `field`).
+- Enums and value sets:
+ - a value set is not the value set of an enum or native enum of that name holding exactly its values, or an enum has no value set holding its members (meta: `namespaceId`, `name`);
+ - an enum and a native enum would derive the same value set (meta: `namespaceId`, `name`);
+ - a native enum has no value set holding its members (meta: `namespaceId`, `typeName`);
+ - a native enum has its own control policy (meta: `namespaceId`, `typeName`, `control`).
+- Namespaces, meta and roots:
+ - a storage or domain namespace holds nothing PSL writes, or the contract lacks a namespace the PSL source would create, such as the default namespace (meta: `plane`, `namespaceId`);
+ - a namespace is named `unbound` and is not the late-binding namespace, which PSL writes as `namespace unbound` (meta: `namespaceId`);
+ - the contract has top-level `meta` entries (meta: `keys`);
+ - the contract has roots other than one per model that is not a variant, keyed by its table name (meta: `root`).
+- Row-level security:
+ - a table has row-level security enabled but no model (meta: `namespaceId`, `table`);
+ - a policy is on a table with no model (meta: `namespaceId`, `table`, `name`);
+ - a policy is on a table without row-level security (meta: `namespaceId`, `table`, `name`);
+ - a wire-named policy's name is not its block name followed by the hash of its content (meta: `namespaceId`, `table`, `name`);
+ - a role is declared outside the unbound namespace (meta: `namespaceId`, `name`);
+ - a row-level security setting or role is filed under a key the PSL source would not file it under (meta: `namespaceId`, `kind`, `name`);
+ - a row-level security setting, role or policy records a namespace other than the one it is stored in (meta: `namespaceId`, `kind`, `name`).
+- Names and storage entries:
+ - a table or column is named `__proto__`, which the PSL source loses when it reads the name from `@@map` or `@map` (meta: `kind`, `name`);
+ - a name PSL writes as an identifier is not one, or is `__proto__`: a namespace, model, field, value object, enum, enum member, native enum, named type, policy, role or index option key. `NaN` and `Infinity` are number words, not identifiers (meta: `kind`, `name`);
+ - a namespace holds a storage entity kind other than tables, value sets, native enums, row-level security settings, policies and roles (meta: `namespaceId`, `kind`, `names`).
+
### CONTRACT.DATA_TYPE_DUPLICATE
Two components in the composed stack register the same data type id, which has exactly one owner. Raised while assembling the stack's data types. Payload: `dataType`, `contributedBy`, `owner`.
@@ -483,11 +557,11 @@ The TypeScript contract module imports something outside the contract-source imp
### CONTRACT.SOURCE_DIAGNOSTIC
-One finding a contract source reported with a code that is not yet dotted, such as the Prisma 8 PSL interpreter's `PSL_UNSUPPORTED_FIELD_TYPE` or a parser's `PSL_PARSE_ERROR`. This is its only producer case: it exists until those codes convert to dotted ones, and a source code that is already dotted, such as `PSL.PRISMA7_VIEW_UNSUPPORTED`, is reported under its own code instead. Never raised on its own; carried, one per such source diagnostic, in the `diagnostics` list of a `CONTRACT.SOURCE_LOAD_FAILED` error during `contract emit`, printed under it in the terminal and serialized as the envelope's `diagnostics` in JSON. `summary` is `::: ` (the location is omitted when the source gave none). `where` carries `path` and `line`. Payload: `code` (the source's own diagnostic code). Fix: edit the schema at each location the findings name, then run `prisma contract emit` again. One such source code is `PSL_UNKNOWN_DEFAULT_FUNCTION`, reported by the Prisma 8 PSL interpreter for a `@default` function the composed stack does not register; its message lists the supported functions, and for the removed `dbgenerated(...)` it is `` Default function "dbgenerated" was removed. Write the SQL as a tagged literal: @default(sql``). Supported functions: . ``
+One finding a contract source reported with a code that is not yet dotted, such as the Prisma 8 PSL interpreter's `PSL_UNSUPPORTED_FIELD_TYPE` or a parser's `PSL_PARSE_ERROR`. This is its only producer case: it exists until those codes convert to dotted ones, and a source code that is already dotted, such as `PSL.PRISMA7_VIEW_UNSUPPORTED`, is reported under its own code instead. Never raised on its own; carried, one per such source diagnostic, in the `diagnostics` list of a `CONTRACT.SOURCE_LOAD_FAILED` error during `contract emit` or `contract print`, printed under it in the terminal and serialized as the envelope's `diagnostics` in JSON. `summary` is `::: ` (the location is omitted when the source gave none). `where` carries `path` and `line`. Payload: `code` (the source's own diagnostic code). Fix: edit the schema at each location the findings name, then run the command again. One such source code is `PSL_UNKNOWN_DEFAULT_FUNCTION`, reported by the Prisma 8 PSL interpreter for a `@default` function the composed stack does not register; its message lists the supported functions, and for the removed `dbgenerated(...)` it is `` Default function "dbgenerated" was removed. Write the SQL as a tagged literal: @default(sql``). Supported functions: . ``
### CONTRACT.SOURCE_LOAD_FAILED
-Loading the contract source failed: bundling or evaluating the TypeScript contract module (esbuild bundle error, or the module threw on import), the contract source provider returning a failure or a malformed result during `contract emit`, or `format` failing to read the PSL source file. The underlying failure is attached as `cause` where one exists. Payload: `path`, `stage` (`bundle` or `import`) at the TS-loader site; `diagnostics`, `issues`, `providerMeta` at the emit provider site; none at the format read site. At the emit provider site the error also carries a `diagnostics` list with one finding per source diagnostic: under the source's own code when it is dotted (for example `PSL.PRISMA7_VIEW_UNSUPPORTED`), otherwise as `CONTRACT.SOURCE_DIAGNOSTIC`.
+Loading the contract source failed: bundling or evaluating the TypeScript contract module (esbuild bundle error, or the module threw on import), the contract source provider returning a failure or a malformed result during `contract emit` or `contract print`, or `format` failing to read the PSL source file. The underlying failure is attached as `cause` where one exists. Payload: `path`, `stage` (`bundle` or `import`) at the TS-loader site; `diagnostics`, `issues`, `providerMeta` at the emit provider site; none at the format read site. At the emit provider site the error also carries a `diagnostics` list with one finding per source diagnostic: under the source's own code when it is dotted (for example `PSL.PRISMA7_VIEW_UNSUPPORTED`), otherwise as `CONTRACT.SOURCE_DIAGNOSTIC`.
### CONTRACT.TABLE_AMBIGUOUS
@@ -621,55 +695,55 @@ The Prisma 7 schema gives a contract that Prisma 8 rejects, for a cause the sour
### PSL.PRISMA7_ENUM_NAMESPACE_MISMATCH
-A field uses an enum declared under a different `@@schema`; a Postgres enum lives in one schema and a Prisma 8 column references the enum of its own namespace. Declare the enum in the model's schema, or move the model. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
+A field uses an enum declared under a different `@@schema`; a Postgres enum lives in one schema and a Prisma 8 column references the enum of its own namespace. Declare the enum in the model's schema, or move the model. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
### PSL.PRISMA7_IGNORED_FIELD_REFERENCED
-An `@ignore`d field is used by `@id`, `@unique`, `@@id`, `@@unique`, `@@index`, or a relation's `fields:`, and Prisma 7 still creates that key, index, or foreign key over its column. Remove `@ignore` from the field. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
+An `@ignore`d field is used by `@id`, `@unique`, `@@id`, `@@unique`, `@@index`, or a relation's `fields:`, and Prisma 7 still creates that key, index, or foreign key over its column. Remove `@ignore` from the field. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
### PSL.PRISMA7_INDEX_ARGUMENT_UNSUPPORTED
-An index argument Prisma 8 indexes cannot carry (`sort`, `length`, `ops`, an unknown index type), or an indexed field that is not a column. Remove the argument. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
+An index argument Prisma 8 indexes cannot carry (`sort`, `length`, `ops`, an unknown index type), or an indexed field that is not a column. Remove the argument. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
### PSL.PRISMA7_JSON_NULL_DEFAULT_UNSUPPORTED
-A `Json` default of `"null"`, or a `Json[]` default holding it: the contract cannot tell the JSON value null apart from SQL `NULL`. Remove the `@default` or give it another JSON value. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
+A `Json` default of `"null"`, or a `Json[]` default holding it: the contract cannot tell the JSON value null apart from SQL `NULL`. Remove the `@default` or give it another JSON value. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
### PSL.PRISMA7_JUNCTION_ID_UNSUPPORTED
-An implicit many-to-many relation on a model without a single-field `@id`. Give the model a single-field `@id`, or write the junction model out. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
+An implicit many-to-many relation on a model without a single-field `@id`. Give the model a single-field `@id`, or write the junction model out. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
### PSL.PRISMA7_JUNCTION_NAME_COLLISION
-A model in the same schema as an implicit many-to-many junction has the junction model's name (`PostToTag`, or the relation name). Rename the model and keep its table with `@@map`. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
+A model in the same schema as an implicit many-to-many junction has the junction model's name (`PostToTag`, or the relation name). Rename the model and keep its table with `@@map`. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
### PSL.PRISMA7_NATIVE_TYPE_UNSUPPORTED
-A `@db.*` type with no Prisma 8 codec (`Citext`, `Bit`, `VarBit`, `Xml`, `Oid`, `Money`, or an unknown spelling). Add `@ignore` to the field when no key, index, or relation uses it, or `@@ignore` to the model. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
+A `@db.*` type with no Prisma 8 codec (`Citext`, `Bit`, `VarBit`, `Xml`, `Oid`, `Money`, or an unknown spelling). Add `@ignore` to the field when no key, index, or relation uses it, or `@@ignore` to the model. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
### PSL.PRISMA7_OPTIONAL_GENERATED_FIELD_UNSUPPORTED
-An ORM-side generator such as `@default(uuid())`, or `@updatedAt`, on an optional field. Remove the generator or `@updatedAt` and keep the `?`. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
+An ORM-side generator such as `@default(uuid())`, or `@updatedAt`, on an optional field. Remove the generator or `@updatedAt` and keep the `?`. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
### PSL.PRISMA7_PROVIDER_MISMATCH
-The Prisma 7 schema has no `datasource` block, or its `provider` is not one the target accepts (`postgresql` or `postgres` for Postgres). Use the source only with a schema for the configured target. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
+The Prisma 7 schema has no `datasource` block, or its `provider` is not one the target accepts (`postgresql` or `postgres` for Postgres). Use the source only with a schema for the configured target. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
### PSL.PRISMA7_REFERENTIAL_ACTION_UNSUPPORTED
-`SetNull` on a relation over a required foreign key field, or `SetDefault` over a required field with no column default. Make the fields optional, give them a column default, or choose another action. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
+`SetNull` on a relation over a required foreign key field, or `SetDefault` over a required field with no column default. Make the fields optional, give them a column default, or choose another action. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
### PSL.PRISMA7_RELATION_MODE_UNSUPPORTED
-`relationMode = "prisma"`, or the older `referentialIntegrity = "prisma"`. Remove it or set `relationMode = "foreignKeys"`. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
+`relationMode = "prisma"`, or the older `referentialIntegrity = "prisma"`. Remove it or set `relationMode = "foreignKeys"`. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
### PSL.PRISMA7_RELATION_NAME_SHARED
-Two or more implicit many-to-many relations in the same schema use the same relation name; Prisma 7 creates one table for all of them, wired to only one. Give each relation its own name. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
+Two or more implicit many-to-many relations in the same schema use the same relation name; Prisma 7 creates one table for all of them, wired to only one. Give each relation its own name. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
### PSL.PRISMA7_RELATION_UNRESOLVED
-A relation field that cannot be paired: no matching side, an ambiguous unnamed pair, a singular back-relation over a non-unique foreign key, a `fields`/`references` mismatch, or a required relation field over an optional foreign key field. Name both sides with `@relation("name")`, add the missing `fields`/`references`, or make the relation field optional. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
+A relation field that cannot be paired: no matching side, an ambiguous unnamed pair, a singular back-relation over a non-unique foreign key, a `fields`/`references` mismatch, or a required relation field over an optional foreign key field. Name both sides with `@relation("name")`, add the missing `fields`/`references`, or make the relation field optional. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
### PSL.PRISMA7_SCHEMA_READ_FAILED
@@ -677,31 +751,31 @@ The schema path could not be read, or the schema directory holds no `.prisma` fi
### PSL.PRISMA7_TABLE_COLLISION
-Two models map to the same table in one schema, or a model maps to the table of an implicit many-to-many relation. Give each model its own table with `@@map`. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
+Two models map to the same table in one schema, or a model maps to the table of an implicit many-to-many relation. Give each model its own table with `@@map`. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
### PSL.PRISMA7_UNKNOWN_ATTRIBUTE
-An attribute Prisma 7 for the target does not have, or one the source does not read (`@@fulltext`, `@shardKey`). Remove it. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
+An attribute Prisma 7 for the target does not have, or one the source does not read (`@@fulltext`, `@shardKey`). Remove it. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
### PSL.PRISMA7_UNKNOWN_DEFAULT
-A `@default` value the source cannot read, or one the column's data type or codec refuses. The message is `Field ".": @default `. Every reason below carries ` at element ` after the value it is about when that value is one element of a list. The reasons that come from reading the value are: `holds text that this contract source does not read: `; `holds a literal, which this stack does not register.`; `holds a value, which this target has no data type for.`; `holds a value, which has no cast from; it casts from .` (or `it casts from nothing`); and `holds a value that does not read: `. The rest do not involve the value's type — an unknown function, an enum member on a non-enum field or a non-member, and a `dbgenerated(...)` argument list that is not a single positional string with text in it. Write a value of a type the column's type is or casts from, an enum member, or a supported function. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
+A `@default` value the source cannot read, or one the column's data type or codec refuses. The message is `Field ".": @default `. Every reason below carries ` at element ` after the value it is about when that value is one element of a list. The reasons that come from reading the value are: `holds text that this contract source does not read: `; `holds a literal, which this stack does not register.`; `holds a value, which this target has no data type for.`; `holds a value, which has no cast from; it casts from .` (or `it casts from nothing`); and `holds a value that does not read: `. The rest do not involve the value's type — an unknown function, an enum member on a non-enum field or a non-member, and a `dbgenerated(...)` argument list that is not a single positional string with text in it. Write a value of a type the column's type is or casts from, an enum member, or a supported function. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
### PSL.PRISMA7_UNSUPPORTED_TYPE
-`Unsupported("...")` or an unknown field type. Add `@@ignore` to the model, or correct the type name. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
+`Unsupported("...")` or an unknown field type. Add `@@ignore` to the model, or correct the type name. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
### PSL.PRISMA7_UPDATED_AT_TYPE_UNSUPPORTED
-`@updatedAt` on a column whose codec has no "now" generator in the target, such as `@db.Date`. Remove `@updatedAt`. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
+`@updatedAt` on a column whose codec has no "now" generator in the target, such as `@db.Date`. Remove `@updatedAt`. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
### PSL.PRISMA7_UPDATED_AT_WITH_DEFAULT_UNSUPPORTED
-`@updatedAt` combined with `@default`. Remove the `@default`; `@updatedAt` still sets the value on create and update. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
+`@updatedAt` combined with `@default`. Remove the `@default`; `@updatedAt` still sets the value on create and update. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
### PSL.PRISMA7_VIEW_UNSUPPORTED
-A `view` block; Prisma 8 has no views. Remove the view, or replace it with a model over the underlying table. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
+A `view` block; Prisma 8 has no views. Remove the view, or replace it with a model over the underlying table. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `::`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
### PSL_BACKTICK_STRING_REQUIRES_TAG
@@ -747,6 +821,10 @@ A list column declares `@default(autoincrement())`: `Field "." is
A `` @default(sql`...`) `` body fails the SQL family's body check: `Default SQL must not contain semicolons, SQL comment tokens, dollar-quoting, or subqueries.` (the rule the migration planners apply at DDL time, run at authoring time so it has a source span), or is exactly `now()` or `autoincrement()`: `` Write @default(now()) instead of sql`now()`; now() is a Prisma default function, not raw SQL. `` The message names the tag as written (`sql`, `pg.sql` or `sqlite.sql`). Reported at the literal.
+### PSL_UNSUPPORTED_ENUM_MEMBER_ATTRIBUTE
+
+An enum member carries an attribute, as in `USER @map("user")`: `enum "": member "" carries @, but an enum member takes no attributes`. Reported by the SQL and Mongo PSL readers, once per attribute, at the attribute. Remove the attribute. To store a value other than the member's name, write it as the member's value, as in `USER = "user"`.
+
### PSL_PRESET_ON_VARIANT_FIELD
A Mongo field preset that sets execution defaults, such as `temporal.createdAt()`, is declared on a field of a polymorphic variant model (one with `@@base`): `Preset "" on variant "" field "": execution defaults apply to every document in collection "", so declare them on the base model.` Execution defaults are keyed by collection and field, so a default on one variant would also fill that field on the base model and every sibling variant. Declare the field on the base model; variants inherit it. Reported at the preset.
diff --git a/examples/prisma7-adoption/README.md b/examples/prisma7-adoption/README.md
index 3b20f6a83314..b5d500223797 100644
--- a/examples/prisma7-adoption/README.md
+++ b/examples/prisma7-adoption/README.md
@@ -63,7 +63,7 @@ Two rules to know before you start:
### 4. Transfer migration ownership, then 5. remove Prisma 7
-Out of scope here. When the last route has moved, follow the guide's phase 4 (`prisma migration plan --name baseline`, `prisma db sign`, `prisma migration ref set db _baseline`) and phase 5.
+Out of scope here. When the last route has moved, `prisma contract print --output prisma/contract.prisma` writes the Prisma 8 PSL that produces the same contract this example emits from `prisma/schema.prisma`; point `contract` in `prisma.config.ts` at the written file and run `prisma contract emit` again to confirm the contract is unchanged. Then follow the guide's phase 4 (`prisma migration plan --name baseline`, `prisma db sign`, `prisma migration ref set db _baseline`) and phase 5.
## What a Prisma 7 user meets along the way
diff --git a/packages/0-shared/publish-surface/src/shells.ts b/packages/0-shared/publish-surface/src/shells.ts
index dd9c8fc3f461..b2cecb1600f9 100644
--- a/packages/0-shared/publish-surface/src/shells.ts
+++ b/packages/0-shared/publish-surface/src/shells.ts
@@ -423,7 +423,7 @@ export const publicShells: ReadonlyMap = new Map<
dir: 'packages/2-sql/2-authoring/contract-psl',
name: '@internal/sql-contract-psl',
entry: 'contract-psl',
- subpaths: ['.', 'attribute-specs', 'default-table-name', 'provider'],
+ subpaths: ['.', 'attribute-specs', 'default-table-name', 'map-names', 'provider'],
},
{
dir: 'packages/2-sql/2-authoring/contract-ts',
diff --git a/packages/0-shared/publish-surface/test/shells.test.ts b/packages/0-shared/publish-surface/test/shells.test.ts
index 2490600d166c..c816e8bccfa7 100644
--- a/packages/0-shared/publish-surface/test/shells.test.ts
+++ b/packages/0-shared/publish-surface/test/shells.test.ts
@@ -104,6 +104,7 @@ describe('publicShells', () => {
'./contract-psl',
'./contract-psl/attribute-specs',
'./contract-psl/default-table-name',
+ './contract-psl/map-names',
'./contract-psl/provider',
],
);
diff --git a/packages/1-framework/1-core/config/README.md b/packages/1-framework/1-core/config/README.md
index 7ee5e51fb26f..48ead16ad8f1 100644
--- a/packages/1-framework/1-core/config/README.md
+++ b/packages/1-framework/1-core/config/README.md
@@ -12,7 +12,7 @@ This package owns the shared config contract used by tooling and authoring packa
- contract source provider + diagnostics protocol
- provider-declared input metadata for tooling integrations
- `defineConfig()` normalization/defaulting
-- `validateConfig()` structural/runtime-shape validation
+- `collectConfigIssues()` structural/runtime-shape validation
## Responsibilities
@@ -31,7 +31,7 @@ This package owns the shared config contract used by tooling and authoring packa
```ts
import { defineConfig } from '@internal/config/config-types';
-import { validateConfig } from '@internal/config/config-validation';
+import { collectConfigIssues } from '@internal/config/config-validation';
const config = defineConfig({
family: sqlFamilyDescriptor,
@@ -39,6 +39,7 @@ const config = defineConfig({
adapter: postgresAdapterDescriptor,
contract: {
source: {
+ format: 'psl',
inputs: ['./prisma/schema.prisma'],
load: async (_context) =>
/* Result */ null as never,
@@ -46,9 +47,11 @@ const config = defineConfig({
},
});
-validateConfig(config);
+const issues = collectConfigIssues(config);
```
+Every source states the language of its inputs in `source.format`: `'psl'` or `'typescript'`. `collectConfigIssues` reports a missing `format` and any other value as an issue on `contract.source.format`. Tooling that reads the inputs itself, such as `contract format` and the language server, reads only a `'psl'` source's inputs.
+
Declare `source.inputs` only for source files that are not already covered by the config module
graph, such as PSL schema paths or TypeScript contract paths passed as strings. Do not include
emitted artifact paths derived from `contract.output` (for example `contract.json` or the
diff --git a/packages/1-framework/1-core/config/package.json b/packages/1-framework/1-core/config/package.json
index 9ad6797d7cdf..becfa5348ce3 100644
--- a/packages/1-framework/1-core/config/package.json
+++ b/packages/1-framework/1-core/config/package.json
@@ -20,7 +20,8 @@
"@internal/framework-components": "workspace:8.0.0-rc.12",
"@internal/utils": "workspace:8.0.0-rc.12",
"arktype": "^2.2.2",
- "pathe": "^2.0.3"
+ "pathe": "^2.0.3",
+ "tinyglobby": "catalog:"
},
"devDependencies": {
"@repo/tsconfig": "workspace:8.0.0-rc.12",
diff --git a/packages/1-framework/1-core/config/src/contract-source-types.ts b/packages/1-framework/1-core/config/src/contract-source-types.ts
index 06f1604f5fd7..76e727100049 100644
--- a/packages/1-framework/1-core/config/src/contract-source-types.ts
+++ b/packages/1-framework/1-core/config/src/contract-source-types.ts
@@ -62,7 +62,11 @@ export interface ContractSourceContext {
readonly reportWarning?: (diagnostic: ContractSourceDiagnostic) => void;
}
-/** Lets format-aware tooling avoid file-extension sniffing and opaque loader introspection. */
+/**
+ * The language a contract source's inputs are written in. Every source states
+ * one. Tooling that reads the inputs itself, such as `contract format` and the
+ * language server, checks this instead of guessing from file extensions.
+ */
export type ContractSourceFormat = 'psl' | 'typescript';
export interface ContractSourceProviderBase {
@@ -85,17 +89,4 @@ export interface TypeScriptContractSourceProvider extends ContractSourceProvider
readonly format: 'typescript';
}
-/**
- * Third-party or unspecified source formats. Absent (or unrecognized)
- * `format` means format-aware tooling must leave the source untouched.
- * Narrowing to a known format flows only through capability guards owned by
- * the authoring layer.
- */
-export interface OpaqueContractSourceProvider extends ContractSourceProviderBase {
- readonly format?: string;
-}
-
-export type ContractSourceProvider =
- | PslContractSourceProvider
- | TypeScriptContractSourceProvider
- | OpaqueContractSourceProvider;
+export type ContractSourceProvider = PslContractSourceProvider | TypeScriptContractSourceProvider;
diff --git a/packages/1-framework/1-core/config/src/default-contract-output-path.ts b/packages/1-framework/1-core/config/src/default-contract-output-path.ts
new file mode 100644
index 000000000000..0c548a050f94
--- /dev/null
+++ b/packages/1-framework/1-core/config/src/default-contract-output-path.ts
@@ -0,0 +1,25 @@
+import { extname } from 'pathe';
+import { isDynamicPattern } from 'tinyglobby';
+
+function staticPrefixDirectory(pattern: string): string {
+ const staticSegments: string[] = [];
+ for (const segment of pattern.replaceAll('\\', '/').split('/')) {
+ if (isDynamicPattern(segment)) break;
+ staticSegments.push(segment);
+ }
+ return staticSegments.join('/');
+}
+
+/**
+ * Where `contract emit` writes the contract JSON for a config that names
+ * `contractPath` and sets no output: beside the contract file and named after
+ * it, or `contract.json` in the static prefix directory of a glob.
+ */
+export function defaultContractOutputPath(contractPath: string): string {
+ if (isDynamicPattern(contractPath)) {
+ const prefix = staticPrefixDirectory(contractPath);
+ return prefix.length === 0 ? 'contract.json' : `${prefix}/contract.json`;
+ }
+ const extension = extname(contractPath);
+ return `${extension.length === 0 ? contractPath : contractPath.slice(0, -extension.length)}.json`;
+}
diff --git a/packages/1-framework/1-core/config/src/exports/config-types.ts b/packages/1-framework/1-core/config/src/exports/config-types.ts
index 973e772dfe3e..15e7cd235546 100644
--- a/packages/1-framework/1-core/config/src/exports/config-types.ts
+++ b/packages/1-framework/1-core/config/src/exports/config-types.ts
@@ -13,7 +13,7 @@ export type {
ContractSourceFormat,
ContractSourceProvider,
ContractSourceProviderBase,
- OpaqueContractSourceProvider,
PslContractSourceProvider,
TypeScriptContractSourceProvider,
} from '../contract-source-types';
+export { defaultContractOutputPath } from '../default-contract-output-path';
diff --git a/packages/1-framework/1-core/config/test/config-types.test-d.ts b/packages/1-framework/1-core/config/test/config-types.test-d.ts
index 0838461b7e1a..1f52ac2a02d8 100644
--- a/packages/1-framework/1-core/config/test/config-types.test-d.ts
+++ b/packages/1-framework/1-core/config/test/config-types.test-d.ts
@@ -14,7 +14,6 @@ import type {
ContractSourceDiagnostic,
ContractSourceFormat,
ContractSourceProvider,
- OpaqueContractSourceProvider,
PslContractSourceProvider,
TypeScriptContractSourceProvider,
} from '../src/contract-source-types';
@@ -125,7 +124,7 @@ test('accepts contract source providers with declared inputs', () => {
};
const result = defineConfig(config);
- expectTypeOf(result.contract!.source.format).toEqualTypeOf();
+ expectTypeOf(result.contract!.source.format).toEqualTypeOf();
expectTypeOf(result.contract!.source.inputs).toEqualTypeOf();
expectTypeOf(result.contract!.source.load).toEqualTypeOf();
});
@@ -146,33 +145,19 @@ test('source diagnostics require a filename but not a span', () => {
void missingFilename;
});
-test('contract source providers form a format-keyed union', () => {
+test('a contract source is PSL or TypeScript, and nothing else', () => {
expectTypeOf().toEqualTypeOf<
- PslContractSourceProvider | TypeScriptContractSourceProvider | OpaqueContractSourceProvider
+ PslContractSourceProvider | TypeScriptContractSourceProvider
>();
+ expectTypeOf().toEqualTypeOf<'psl' | 'typescript'>();
expectTypeOf().toEqualTypeOf<'psl'>();
expectTypeOf().toEqualTypeOf<'typescript'>();
- expectTypeOf().toEqualTypeOf();
- expectTypeOf().toExtend();
- expectTypeOf().toExtend();
-});
-
-test('provider literals remain assignable to the union without casts', () => {
- const load: ContractSourceProvider['load'] = async (_context) => ok({} as never);
-
- const psl: ContractSourceProvider = {
- format: 'psl',
- inputs: ['./schema.prisma'],
- load,
- };
- const typescript: ContractSourceProvider = { format: 'typescript', load };
- const absent: ContractSourceProvider = { load };
- const thirdParty: ContractSourceProvider = { format: 'made-up-format', load };
-
- expectTypeOf(psl).toExtend();
- expectTypeOf(typescript).toExtend();
- expectTypeOf(absent).toExtend();
- expectTypeOf(thirdParty).toExtend();
+ // @ts-expect-error every contract source states its format
+ const untagged: ContractSourceProvider = { inputs: [], load: async () => ok({} as never) };
+ void untagged;
+ // @ts-expect-error a source format the framework does not have
+ const other: ContractSourceProvider = { format: 'other', inputs: [], load: async () => ok({}) };
+ void other;
});
test('carries an optional formatter section', () => {
diff --git a/packages/1-framework/1-core/config/test/default-contract-output-path.test.ts b/packages/1-framework/1-core/config/test/default-contract-output-path.test.ts
new file mode 100644
index 000000000000..bae548a2c10f
--- /dev/null
+++ b/packages/1-framework/1-core/config/test/default-contract-output-path.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest';
+import { defaultContractOutputPath } from '../src/default-contract-output-path';
+
+describe('defaultContractOutputPath', () => {
+ it('names the JSON after the contract file, beside it', () => {
+ expect(defaultContractOutputPath('./prisma/contract.prisma')).toBe('./prisma/contract.json');
+ expect(defaultContractOutputPath('src/schema.ts')).toBe('src/schema.json');
+ });
+
+ it('appends .json to a path with no extension', () => {
+ expect(defaultContractOutputPath('./prisma/contract')).toBe('./prisma/contract.json');
+ });
+
+ it('uses contract.json in the static prefix directory of a glob', () => {
+ expect(defaultContractOutputPath('./prisma/**/*.prisma')).toBe('./prisma/contract.json');
+ expect(defaultContractOutputPath('.\\prisma\\**\\*.prisma')).toBe('./prisma/contract.json');
+ });
+
+ it('uses contract.json when a glob has no static prefix', () => {
+ expect(defaultContractOutputPath('*.prisma')).toBe('contract.json');
+ });
+});
diff --git a/packages/1-framework/1-core/config/test/define-config.test.ts b/packages/1-framework/1-core/config/test/define-config.test.ts
index 37d51a8eab83..1ad2b63d66d0 100644
--- a/packages/1-framework/1-core/config/test/define-config.test.ts
+++ b/packages/1-framework/1-core/config/test/define-config.test.ts
@@ -20,6 +20,7 @@ const mockHook = {
function createSourceProvider(overrides: Record = {}) {
return {
+ format: 'typescript',
load: async () => ok({ targetFamily: 'sql' } as Contract),
...overrides,
};
diff --git a/packages/1-framework/1-core/framework-components/README.md b/packages/1-framework/1-core/framework-components/README.md
index 484b8a5504b8..2376da865629 100644
--- a/packages/1-framework/1-core/framework-components/README.md
+++ b/packages/1-framework/1-core/framework-components/README.md
@@ -7,7 +7,7 @@ Framework component types, authoring logic, control stack assembly, and emission
## What this package provides
- **Component types** (`./components`): Base descriptor and instance interfaces for framework components (family, target, adapter, driver, extension), pack refs, and type renderer system
-- **Authoring types** (`./authoring`): Declarative authoring contribution types, template resolution, and validation for type constructors and field presets
+- **Authoring types** (`./authoring`): Declarative authoring contribution types, template resolution, and validation for type constructors and field presets. `findAuthoringTypeConstructorCall(namespace, output)` is the inverse of `instantiateAuthoringTypeConstructor`: the first call to a constructor in a namespace that produces a given codec, native type and type parameters
- **Codec base interface** (`./codec`): The cross-family `Codec` base type that SQL `Codec` and Mongo `MongoCodec` extend
- **Control stack** (`./control`): Assembly functions that combine component descriptors into a unified `ControlStack` with derived state (codec imports, renderers, authoring contributions)
- **Emission SPI** (`./emission`): Types for the emission pipeline — `TargetFamilyHook`, `ValidationContext`, `GenerateContractTypesOptions`, `TypeRenderEntry`, `TypeRenderer`, `ParameterizedCodecDescriptor`, and related types
diff --git a/packages/1-framework/1-core/framework-components/src/control/control-capabilities.ts b/packages/1-framework/1-core/framework-components/src/control/control-capabilities.ts
index 3ee4806052fc..f945c9615057 100644
--- a/packages/1-framework/1-core/framework-components/src/control/control-capabilities.ts
+++ b/packages/1-framework/1-core/framework-components/src/control/control-capabilities.ts
@@ -1,3 +1,4 @@
+import type { Contract, ControlPolicy } from '@internal/contract/types';
import type { ControlTargetDescriptor } from './control-descriptors';
import type { ControlFamilyInstance } from './control-instances';
import type { MigrationPlanOperation, TargetMigrationsCapability } from './control-migration-types';
@@ -53,6 +54,37 @@ export function hasPslContractInfer(
);
}
+/**
+ * Settings a PSL contract source takes from the config, because a PSL file
+ * cannot carry them. Emitting the PSL file produces the same contract only
+ * when the config sets each of them on the new source.
+ */
+export interface PslSourceSettings {
+ readonly defaultControlPolicy?: ControlPolicy;
+}
+
+/** The PSL document built from a contract, and the settings its PSL source must carry. */
+export interface PslContractDocument {
+ readonly document: PslDocumentAst;
+ readonly sourceSettings: PslSourceSettings;
+}
+
+/**
+ * Capability declaring that a family can build the PSL document AST of a contract. `contract` must be one the family's contract serializer accepted. `prisma contract print` renders the document as text.
+ */
+export interface PslContractBuildCapable {
+ buildPslContract(contract: TContract): PslContractDocument;
+}
+
+export function hasPslContractBuild(
+ instance: ControlFamilyInstance,
+): instance is ControlFamilyInstance & PslContractBuildCapable {
+ return (
+ 'buildPslContract' in instance &&
+ typeof Reflect.get(instance, 'buildPslContract') === 'function'
+ );
+}
+
/**
* Capability declaring that a family can render a textual preview of migration
* operations for the CLI's "DDL preview" output. SQL families emit
diff --git a/packages/1-framework/1-core/framework-components/src/control/psl-ast.ts b/packages/1-framework/1-core/framework-components/src/control/psl-ast.ts
index c23fc3064c35..1bcd517517d3 100644
--- a/packages/1-framework/1-core/framework-components/src/control/psl-ast.ts
+++ b/packages/1-framework/1-core/framework-components/src/control/psl-ast.ts
@@ -197,6 +197,12 @@ export interface PslTypesBlock {
*/
export const UNSPECIFIED_PSL_NAMESPACE_ID = '__unspecified__';
+/**
+ * The name the unbound namespace is written under in PSL: `namespace unbound { … }`. Readers map it
+ * to the IR's `__unbound__` namespace id, and writers map that id back to it.
+ */
+export const UNBOUND_PSL_NAMESPACE_NAME = 'unbound';
+
/** A value in {@link PslNamespace.entries}: a built-in entity node or an extension-contributed {@link PslExtensionBlock}. */
export type PslNamespaceEntry = PslModel | PslCompositeType | PslExtensionBlock;
diff --git a/packages/1-framework/1-core/framework-components/src/exports/authoring.ts b/packages/1-framework/1-core/framework-components/src/exports/authoring.ts
index 51d430a7fbeb..91b4a779d2d5 100644
--- a/packages/1-framework/1-core/framework-components/src/exports/authoring.ts
+++ b/packages/1-framework/1-core/framework-components/src/exports/authoring.ts
@@ -1,3 +1,8 @@
+export type {
+ AuthoringTypeConstructorCall,
+ AuthoringTypeConstructorOutput,
+} from '../shared/authoring-type-constructor-call';
+export { findAuthoringTypeConstructorCall } from '../shared/authoring-type-constructor-call';
export {
checkUncomposedNamespace,
getAuthoringFieldPreset,
diff --git a/packages/1-framework/1-core/framework-components/src/exports/control.ts b/packages/1-framework/1-core/framework-components/src/exports/control.ts
index 4cd1b0f9c0b0..8f210c5a0339 100644
--- a/packages/1-framework/1-core/framework-components/src/exports/control.ts
+++ b/packages/1-framework/1-core/framework-components/src/exports/control.ts
@@ -10,13 +10,17 @@ export type {
DiffSubjectGranularity,
MigratableTargetDescriptor,
OperationPreviewCapable,
+ PslContractBuildCapable,
+ PslContractDocument,
PslContractInferCapable,
+ PslSourceSettings,
SchemaSubjectClassifierCapable,
SchemaViewCapable,
} from '../control/control-capabilities';
export {
hasMigrations,
hasOperationPreview,
+ hasPslContractBuild,
hasPslContractInfer,
hasSchemaSubjectClassifier,
hasSchemaView,
diff --git a/packages/1-framework/1-core/framework-components/src/shared/authoring-type-constructor-call.ts b/packages/1-framework/1-core/framework-components/src/shared/authoring-type-constructor-call.ts
new file mode 100644
index 000000000000..b2c77c7594d6
--- /dev/null
+++ b/packages/1-framework/1-core/framework-components/src/shared/authoring-type-constructor-call.ts
@@ -0,0 +1,93 @@
+import { canonicalizeJson } from '../utils/canonicalize-json';
+import {
+ type AuthoringTypeConstructorDescriptor,
+ type AuthoringTypeNamespace,
+ instantiateAuthoringTypeConstructor,
+ isAuthoringArgRef,
+ isAuthoringTypeConstructorDescriptor,
+} from './framework-authoring';
+
+/** What a type constructor call produces for a column. */
+export type AuthoringTypeConstructorOutput = ReturnType;
+
+/** A type constructor, by its path in an authoring namespace, and the arguments of one call to it. */
+export interface AuthoringTypeConstructorCall {
+ readonly path: readonly string[];
+ readonly args: readonly unknown[];
+}
+
+function* typeConstructors(
+ namespace: AuthoringTypeNamespace,
+ path: readonly string[] = [],
+): Generator<{
+ readonly path: readonly string[];
+ readonly descriptor: AuthoringTypeConstructorDescriptor;
+}> {
+ for (const [name, value] of Object.entries(namespace)) {
+ if (isAuthoringTypeConstructorDescriptor(value)) {
+ yield { path: [...path, name], descriptor: value };
+ } else {
+ yield* typeConstructors(value, [...path, name]);
+ }
+ }
+}
+
+function produces(
+ produced: AuthoringTypeConstructorOutput,
+ wanted: AuthoringTypeConstructorOutput,
+): boolean {
+ const wantedFields: Readonly> = {
+ ...wanted,
+ typeParams: wanted.typeParams ?? {},
+ };
+ return Object.entries({ ...produced, typeParams: produced.typeParams ?? {} }).every(
+ ([key, value]) => canonicalizeJson(value) === canonicalizeJson(wantedFields[key]),
+ );
+}
+
+/**
+ * The arguments that make `descriptor` produce exactly `output`, when each of its type parameters
+ * comes straight from one argument; `undefined` when it cannot produce `output`, including when
+ * `output` has no value for an argument the constructor requires.
+ */
+function argumentsFor(
+ descriptor: AuthoringTypeConstructorDescriptor,
+ output: AuthoringTypeConstructorOutput,
+): readonly unknown[] | undefined {
+ if (descriptor.entityRefArg !== undefined || descriptor.output.codecId !== output.codecId) {
+ return undefined;
+ }
+ const args: unknown[] = [];
+ for (const [key, template] of Object.entries(descriptor.output.typeParams ?? {})) {
+ const value = output.typeParams?.[key];
+ if (value !== undefined && isAuthoringArgRef(template) && template.path === undefined) {
+ args[template.index] = value;
+ }
+ }
+ const requiredArgs = (descriptor.args ?? []).filter((arg) => arg.optional !== true).length;
+ if (args.length < requiredArgs || Array.from(args).includes(undefined)) return undefined;
+ try {
+ return produces(instantiateAuthoringTypeConstructor(descriptor, args), output)
+ ? args
+ : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+/**
+ * The first call, in namespace order, to a type constructor in `namespace` that produces exactly
+ * `output`. The inverse of {@link instantiateAuthoringTypeConstructor} for constructors whose type
+ * parameters each come straight from one argument. A constructor whose argument names another
+ * entity is never chosen. `undefined` when no call produces `output`.
+ */
+export function findAuthoringTypeConstructorCall(
+ namespace: AuthoringTypeNamespace,
+ output: AuthoringTypeConstructorOutput,
+): AuthoringTypeConstructorCall | undefined {
+ for (const { path, descriptor } of typeConstructors(namespace)) {
+ const args = argumentsFor(descriptor, output);
+ if (args !== undefined) return { path, args };
+ }
+ return undefined;
+}
diff --git a/packages/1-framework/1-core/framework-components/test/authoring-type-constructor-call.test.ts b/packages/1-framework/1-core/framework-components/test/authoring-type-constructor-call.test.ts
new file mode 100644
index 000000000000..2bb3f267f31a
--- /dev/null
+++ b/packages/1-framework/1-core/framework-components/test/authoring-type-constructor-call.test.ts
@@ -0,0 +1,133 @@
+import { describe, expect, it } from 'vitest';
+import { findAuthoringTypeConstructorCall } from '../src/shared/authoring-type-constructor-call';
+import type { AuthoringTypeNamespace } from '../src/shared/framework-authoring';
+
+const namespace = {
+ Text: {
+ kind: 'typeConstructor',
+ output: { codecId: 'test/text@1', nativeType: 'text' },
+ },
+ Decimal: {
+ kind: 'typeConstructor',
+ args: [
+ { kind: 'number', name: 'precision', integer: true, optional: true },
+ { kind: 'number', name: 'scale', integer: true, optional: true },
+ ],
+ output: {
+ codecId: 'test/numeric@1',
+ nativeType: 'numeric',
+ typeParams: {
+ precision: { kind: 'arg', index: 0 },
+ scale: { kind: 'arg', index: 1 },
+ },
+ },
+ },
+ vector: {
+ Vector: {
+ kind: 'typeConstructor',
+ args: [{ kind: 'number', name: 'length', integer: true }],
+ output: {
+ codecId: 'test/vector@1',
+ nativeType: 'vector',
+ typeParams: { length: { kind: 'arg', index: 0 } },
+ },
+ },
+ },
+ Ref: {
+ kind: 'typeConstructor',
+ entityRefArg: { index: 0, entityKind: 'native_enum' },
+ output: { codecId: 'test/enum@1', nativeType: 'text' },
+ },
+} as const satisfies AuthoringTypeNamespace;
+
+describe('findAuthoringTypeConstructorCall', () => {
+ it('finds a constructor that takes no arguments', () => {
+ expect(
+ findAuthoringTypeConstructorCall(namespace, { codecId: 'test/text@1', nativeType: 'text' }),
+ ).toEqual({ path: ['Text'], args: [] });
+ });
+
+ it('finds a nested constructor, with the arguments its type parameters come from', () => {
+ expect(
+ findAuthoringTypeConstructorCall(namespace, {
+ codecId: 'test/vector@1',
+ nativeType: 'vector',
+ typeParams: { length: 3 },
+ }),
+ ).toEqual({ path: ['vector', 'Vector'], args: [3] });
+ });
+
+ it('compares type parameters whatever order their keys are in', () => {
+ expect(
+ findAuthoringTypeConstructorCall(namespace, {
+ codecId: 'test/numeric@1',
+ nativeType: 'numeric',
+ typeParams: { scale: 2, precision: 10 },
+ }),
+ ).toEqual({ path: ['Decimal'], args: [10, 2] });
+ });
+
+ it('leaves out optional arguments the type parameters do not name', () => {
+ expect(
+ findAuthoringTypeConstructorCall(namespace, {
+ codecId: 'test/numeric@1',
+ nativeType: 'numeric',
+ }),
+ ).toEqual({ path: ['Decimal'], args: [] });
+ });
+
+ it('finds nothing when no constructor produces the native type', () => {
+ expect(
+ findAuthoringTypeConstructorCall(namespace, {
+ codecId: 'test/text@1',
+ nativeType: 'varchar',
+ }),
+ ).toBeUndefined();
+ });
+
+ it('finds nothing when the type parameters differ from what the constructor produces', () => {
+ expect(
+ findAuthoringTypeConstructorCall(namespace, {
+ codecId: 'test/text@1',
+ nativeType: 'text',
+ typeParams: { length: 3 },
+ }),
+ ).toBeUndefined();
+ });
+
+ it('finds nothing when an argument before a given one has no type parameter to come from', () => {
+ expect(
+ findAuthoringTypeConstructorCall(namespace, {
+ codecId: 'test/numeric@1',
+ nativeType: 'numeric',
+ typeParams: { scale: 2 },
+ }),
+ ).toBeUndefined();
+ });
+
+ it('finds nothing when a required argument has no type parameter to come from', () => {
+ expect(
+ findAuthoringTypeConstructorCall(namespace, {
+ codecId: 'test/vector@1',
+ nativeType: 'vector',
+ }),
+ ).toBeUndefined();
+ });
+
+ it('never calls a constructor whose argument names another entity', () => {
+ expect(
+ findAuthoringTypeConstructorCall(namespace, { codecId: 'test/enum@1', nativeType: 'text' }),
+ ).toBeUndefined();
+ });
+
+ it('takes the first constructor in namespace order when two produce the same output', () => {
+ const twice = {
+ First: { kind: 'typeConstructor', output: { codecId: 'test/text@1', nativeType: 'text' } },
+ Second: { kind: 'typeConstructor', output: { codecId: 'test/text@1', nativeType: 'text' } },
+ } as const satisfies AuthoringTypeNamespace;
+
+ expect(
+ findAuthoringTypeConstructorCall(twice, { codecId: 'test/text@1', nativeType: 'text' }),
+ ).toEqual({ path: ['First'], args: [] });
+ });
+});
diff --git a/packages/1-framework/1-core/framework-components/test/control-capabilities.test.ts b/packages/1-framework/1-core/framework-components/test/control-capabilities.test.ts
index 6b6d11de1c9a..d315a9efb954 100644
--- a/packages/1-framework/1-core/framework-components/test/control-capabilities.test.ts
+++ b/packages/1-framework/1-core/framework-components/test/control-capabilities.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
hasOperationPreview,
+ hasPslContractBuild,
hasPslContractInfer,
hasSchemaView,
} from '../src/control/control-capabilities';
@@ -48,6 +49,21 @@ describe('hasPslContractInfer', () => {
});
});
+describe('hasPslContractBuild', () => {
+ it('returns true when instance exposes buildPslContract function', () => {
+ const instance = {
+ ...baseInstance,
+ buildPslContract: () => ({ document: SYNTHETIC_AST, sourceSettings: {} }),
+ } as ControlFamilyInstance<'sql', unknown>;
+
+ expect(hasPslContractBuild(instance)).toBe(true);
+ });
+
+ it('returns false when instance does not declare buildPslContract', () => {
+ expect(hasPslContractBuild(baseInstance)).toBe(false);
+ });
+});
+
describe('hasSchemaView', () => {
it('returns true when instance exposes toSchemaView function', () => {
const instance = {
diff --git a/packages/1-framework/1-core/framework-components/test/psl-contract-build.types.test-d.ts b/packages/1-framework/1-core/framework-components/test/psl-contract-build.types.test-d.ts
new file mode 100644
index 000000000000..029b0beddc0b
--- /dev/null
+++ b/packages/1-framework/1-core/framework-components/test/psl-contract-build.types.test-d.ts
@@ -0,0 +1,24 @@
+import type { Contract, ControlPolicy } from '@internal/contract/types';
+import { expectTypeOf, test } from 'vitest';
+import type {
+ PslContractBuildCapable,
+ PslContractDocument,
+ PslSourceSettings,
+} from '../src/control/control-capabilities';
+import type { PslDocumentAst } from '../src/control/psl-ast';
+
+test('building the PSL document of a contract returns the document and the settings its PSL source must carry', () => {
+ expectTypeOf().parameters.toEqualTypeOf<
+ [Contract]
+ >();
+ expectTypeOf<
+ PslContractBuildCapable['buildPslContract']
+ >().returns.toEqualTypeOf();
+ expectTypeOf().toEqualTypeOf<{
+ readonly document: PslDocumentAst;
+ readonly sourceSettings: PslSourceSettings;
+ }>();
+ expectTypeOf().toEqualTypeOf<{
+ readonly defaultControlPolicy?: ControlPolicy;
+ }>();
+});
diff --git a/packages/1-framework/2-authoring/psl-parser/README.md b/packages/1-framework/2-authoring/psl-parser/README.md
index be7f90496704..cfe0695ac5ba 100644
--- a/packages/1-framework/2-authoring/psl-parser/README.md
+++ b/packages/1-framework/2-authoring/psl-parser/README.md
@@ -39,7 +39,7 @@ Interpretation/validation (for example `@internal/sql-contract-psl`) is responsi
## Public API
-- `parse(source, filename, options?)` in `src/parse.ts` (also at `@internal/psl-parser/syntax`) — the CST parser: returns the `DocumentAst`, a `PslSources` registry for resolving nodes to their named `SourceFile`, and syntactic diagnostics. The recursive-descent / lossless-CST path supersedes the legacy `parsePslDocument`.
+- `parse(source, filename)` in `src/parse.ts` (also at `@internal/psl-parser/syntax`) — the CST parser: returns the `DocumentAst`, a `PslSources` registry for resolving nodes to their named `SourceFile`, and syntactic diagnostics. The recursive-descent / lossless-CST path supersedes the legacy `parsePslDocument`. Every document parses the same way: a `view` body parses as fields, and an `enum` member may carry attributes. Each reader decides whether it accepts them.
- `buildSymbolTable({ documents, sources, pslBlockDescriptors })` in `src/symbol-table.ts` — a pure, fault-tolerant pass over an ordered `readonly DocumentAst[]` that returns `{ symbolTable, diagnostics }`, with a scope-aware `SymbolTable` (top-level namespaces / named types / blocks / models / composite-types as keyed records discriminated by `kind`, namespace members and block fields nested under their owner, declaration symbols carrying their CST AST `node` plus declaration `span`, and namespace symbols retaining every authored node and span in `declarations`) plus its own source-associated diagnostics (the same `ParseDiagnostic` shape as parser errors: `filename`, `code`, `message`, and a file-local `range`). Duplicate names are first-wins across documents and kinds within one scope (`PSL_DUPLICATE_DECLARATION`); repeated namespaces reopen the same scope, retaining distinct members and diagnosing duplicate member names across declarations and documents. Every supplied document root must be registered in the shared `sources`, even for empty documents. An empty collection returns an empty scope. Single-file callers pass `documents: [document]`; no file discovery is performed. `pslBlockDescriptors` is supplied from authoring contributions so generic/extension blocks can be reconstructed once into `BlockSymbol.block`. The pass also **resolves** the field/named-type read set once: each `FieldSymbol` carries the split type (`typeName`/`typeNamespaceId`/`typeContractSpaceId`), `optional`/`list`, `typeConstructor?`, rendered `attributes`, and `malformedType?` (set, with a `PSL_INVALID_QUALIFIED_TYPE` diagnostic, when the type is over-qualified); `NamedTypeSymbol` carries the resolved binding (`baseType`/`typeConstructor`/`isConstructor`). Interpreters consume this resolved shape directly — there is no per-package field/attribute view layer.
- `createBinder({ sources, symbolTable, typeConstructors, attributeSpecs })` in `src/binder.ts` — the name resolver. It returns `{ binder, diagnostics }`, mirroring `buildSymbolTable`: resolution runs eagerly over the symbol table at creation, and the returned diagnostics are complete when the factory returns. Queries are map reads and say nothing about when resolution ran. See [the binder section below](#binder).
- `referencedModel` / `modelAttributeContext` / `fieldAttributeContext` in `src/binder-context.ts` — build the ADR 249 parse-time attribute contexts from a binder. `resolveReferencedModel` becomes one map read (`symbolForNode(typeReferenceNode(field))`, narrowed to a model) instead of a resolver each consumer supplies for itself, and the context carries the binder itself so the reference combinators stop raising their own existence diagnostics. See [attribute contexts and the single voice](#attribute-contexts-and-the-single-voice).
@@ -50,6 +50,9 @@ Interpretation/validation (for example `@internal/sql-contract-psl`) is responsi
(a `BlockSymbol`) and run the framework's standalone `validateExtensionBlock`
over it, building the ref-resolution context from the symbol table.
- `parseQuotedStringLiteral` / `getPositionalArgument` in `src/attribute-helpers.ts`.
+- `isPslIdentifier(text)` in `src/tokenizer.ts`: whether the tokenizer reads the text as one identifier. Code that writes PSL checks a name with it before writing the name where PSL reads an identifier.
+- `NAME_THE_PSL_SOURCE_LOSES` in `src/name-the-psl-source-loses.ts`: the name `__proto__`, which is lost when a PSL file is read: the parser keeps block members, and the PSL contract sources keep other names, as keys of plain objects. Code that writes PSL refuses this name wherever a PSL source reads a name, `@map` and `@@map` included.
+- Rules both PSL readers apply, at `@internal/psl-parser/interpret`: `claimedBlockKeywords` and `unsupportedBlockDiagnostic` (`src/unclaimed-blocks.ts`) report a generic block whose keyword no composed descriptor claims; `enumMemberAttributeDiagnostics` (`src/enum-member-attributes.ts`) reports an attribute on an enum member; `src/relation-backrelations.ts` holds the back-relation pairing rules.
- Legacy AST/span types live in `@internal/framework-components/psl-ast` and are re-exported from this package's root entry. The attribute kit's `PslDiagnostic` lives in `src/diagnostic.ts`; framework contribution diagnostics retain their separate external contract.
- Subpath exports:
- `@internal/psl-parser/syntax`
diff --git a/packages/1-framework/2-authoring/psl-parser/src/enum-member-attributes.ts b/packages/1-framework/2-authoring/psl-parser/src/enum-member-attributes.ts
new file mode 100644
index 000000000000..51545b6713c6
--- /dev/null
+++ b/packages/1-framework/2-authoring/psl-parser/src/enum-member-attributes.ts
@@ -0,0 +1,18 @@
+import { diagnosticSource, type PslDiagnostic } from './diagnostic';
+import { nodePslSpan } from './resolve';
+import type { PslSources } from './source-file';
+import type { BlockSymbol } from './symbol-table';
+
+export function enumMemberAttributeDiagnostics(
+ enumBlock: BlockSymbol,
+ sources: PslSources,
+): PslDiagnostic[] {
+ const source = diagnosticSource(sources, enumBlock.node.syntax);
+ return Array.from(enumBlock.node.entries()).flatMap((member) =>
+ Array.from(member.attributes(), (attribute) => ({
+ code: 'PSL_UNSUPPORTED_ENUM_MEMBER_ATTRIBUTE',
+ message: `enum "${enumBlock.block.name}": member "${member.key()?.name() ?? '?'}" carries @${attribute.name()?.path().join('.') ?? '?'}, but an enum member takes no attributes`,
+ ...source.at(nodePslSpan(attribute.syntax, sources)),
+ })),
+ );
+}
diff --git a/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts b/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts
index f8ea51b4f8d6..112a17eaaa31 100644
--- a/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts
+++ b/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts
@@ -130,6 +130,7 @@ export type {
ResolvedEntityReference,
} from '../entity-reference';
export { findBlockDescriptor, validateExtensionBlockFromSymbol } from '../extension-block';
+export { NAME_THE_PSL_SOURCE_LOSES } from '../name-the-psl-source-loses';
export {
keywordPslSpan,
nodePslSpan,
@@ -156,3 +157,4 @@ export type {
TopLevelScope,
} from '../symbol-table';
export { buildSymbolTable } from '../symbol-table';
+export { isPslIdentifier } from '../tokenizer';
diff --git a/packages/1-framework/2-authoring/psl-parser/src/exports/interpret.ts b/packages/1-framework/2-authoring/psl-parser/src/exports/interpret.ts
index 4d6b80f0c5ed..e0a1ae22b87d 100644
--- a/packages/1-framework/2-authoring/psl-parser/src/exports/interpret.ts
+++ b/packages/1-framework/2-authoring/psl-parser/src/exports/interpret.ts
@@ -1,4 +1,5 @@
export { mapPslHelperArgs } from '../authoring-arguments';
+export { enumMemberAttributeDiagnostics } from '../enum-member-attributes';
export {
instantiatePslFieldPreset,
reportUncomposedNamespace,
@@ -13,3 +14,4 @@ export {
fkRelationPairKey,
requiredOneToOneBackrelationDiagnostic,
} from '../relation-backrelations';
+export { claimedBlockKeywords, unsupportedBlockDiagnostic } from '../unclaimed-blocks';
diff --git a/packages/1-framework/2-authoring/psl-parser/src/exports/syntax.ts b/packages/1-framework/2-authoring/psl-parser/src/exports/syntax.ts
index c5af24201186..96797ed28fb7 100644
--- a/packages/1-framework/2-authoring/psl-parser/src/exports/syntax.ts
+++ b/packages/1-framework/2-authoring/psl-parser/src/exports/syntax.ts
@@ -1,4 +1,4 @@
-export type { ParseDiagnostic, ParseOptions, ParseResult } from '../parse';
+export type { ParseDiagnostic, ParseResult } from '../parse';
export { parse } from '../parse';
export type { Position, Range } from '../source-file';
export { PslSources, SourceFile } from '../source-file';
diff --git a/packages/1-framework/2-authoring/psl-parser/src/format/emit.ts b/packages/1-framework/2-authoring/psl-parser/src/format/emit.ts
index 74ba5f8f2574..932712fcd4c2 100644
--- a/packages/1-framework/2-authoring/psl-parser/src/format/emit.ts
+++ b/packages/1-framework/2-authoring/psl-parser/src/format/emit.ts
@@ -103,7 +103,8 @@ class LineWriter {
}
}
-// Qualified-name separators hug; argument/object colons keep the usual value space.
+// Qualified-name separators hug. `[` hugs a type (`String[]`) but keeps the space before a list
+// value after `:`, `,` or `=`.
function spaceBetween(
prev: TokenKind | undefined,
cur: TokenKind,
@@ -115,8 +116,9 @@ function spaceBetween(
if (prev === 'Ident' && cur === 'StringLiteral') return false;
switch (cur) {
- case 'LParen':
case 'LBracket':
+ return prev === 'Colon' || prev === 'Comma' || prev === 'Equals';
+ case 'LParen':
case 'RParen':
case 'RBracket':
case 'Comma':
@@ -236,24 +238,20 @@ type MemberCategory = 'regular' | 'blockAttribute' | 'nestedBlock';
interface BlockMember {
readonly category: MemberCategory;
- /** Stays on the source line it shares with the member that follows it. */
- readonly keepsSourceLine: boolean;
- emit(trailing: string | undefined, endLine: boolean): number;
+ emit(trailing: string | undefined): number;
}
function leafMember(
writer: LineWriter,
category: MemberCategory,
print: () => number,
- keepsSourceLine = false,
): BlockMember {
return {
category,
- keepsSourceLine,
- emit(trailing, endLine) {
+ emit(trailing) {
const continuation = print();
if (trailing !== undefined) writer.comment(trailing);
- else if (endLine) writer.newline();
+ else writer.newline();
return continuation;
},
};
@@ -295,11 +293,6 @@ function emitCompositeType(
});
}
-/**
- * With the `prisma7` grammar a `view` body holds `FieldDeclaration` members, so they print through
- * the model field path. With the `psl` grammar the same source line reads as several entries with
- * no `=`; those entries keep the source line they share, so `id Int` does not become two lines.
- */
function emitGenericBlock(
writer: LineWriter,
block: GenericBlockDeclarationAst,
@@ -310,13 +303,7 @@ function emitGenericBlock(
const field = FieldDeclarationAst.cast(node);
if (field) return leafMember(writer, 'regular', () => emitField(writer, field, alignment));
const entry = KeyValuePairAst.cast(node);
- if (entry)
- return leafMember(
- writer,
- 'regular',
- () => emitKeyValue(writer, entry),
- entry.equals() === undefined,
- );
+ if (entry) return leafMember(writer, 'regular', () => emitKeyValue(writer, entry));
const attribute = ModelAttributeAst.cast(node);
if (attribute)
return leafMember(writer, 'blockAttribute', () => emitBlockAttribute(writer, attribute));
@@ -361,7 +348,6 @@ type BlockEmitter = (writer: LineWriter, trailing: string | undefined) => void;
function nestedBlockMember(writer: LineWriter, block: BlockEmitter): BlockMember {
return {
category: 'nestedBlock',
- keepsSourceLine: false,
emit(trailing) {
block(writer, trailing);
return 0;
@@ -398,12 +384,15 @@ function emitBlockBody(
const children = Array.from(node.children());
const openIndex = children.findIndex((el) => !(el instanceof SyntaxNode) && el.kind === 'LBrace');
- streamHeader(writer, node);
- const headerComment = sameLineCommentAfter(children, openIndex);
- if (headerComment !== undefined) writer.comment(headerComment);
+ const headerComments = streamHeader(writer, node);
+ const openingComment = sameLineCommentAfter(children, openIndex);
+ if (openingComment !== undefined) headerComments.push(openingComment);
+ const [firstComment, ...otherComments] = headerComments;
+ if (firstComment !== undefined) writer.comment(firstComment);
else writer.newline();
writer.indent();
+ for (const comment of otherComments) writer.comment(comment);
walkRegion(writer, children, 'RBrace', classify);
writer.unindent();
@@ -412,7 +401,9 @@ function emitBlockBody(
else writer.newline();
}
-function streamHeader(writer: LineWriter, node: SyntaxNode): void {
+/** Writes the block header through `{` and returns the source comments found before the `{`. */
+function streamHeader(writer: LineWriter, node: SyntaxNode): string[] {
+ const comments: string[] = [];
let done = false;
const walk = (parent: SyntaxNode): void => {
for (const child of parent.children()) {
@@ -421,7 +412,11 @@ function streamHeader(writer: LineWriter, node: SyntaxNode): void {
walk(child);
continue;
}
- if (child.kind === 'Whitespace' || child.kind === 'Newline' || child.kind === 'Comment') {
+ if (child.kind === 'Comment') {
+ comments.push(child.text);
+ continue;
+ }
+ if (child.kind === 'Whitespace' || child.kind === 'Newline') {
continue;
}
const space = spaceBetween(writer.prevKind(), child.kind, false);
@@ -433,6 +428,7 @@ function streamHeader(writer: LineWriter, node: SyntaxNode): void {
}
};
walk(node);
+ return comments;
}
function walkRegion(
@@ -467,8 +463,7 @@ function walkRegion(
}
const trailing = sameLineTrailingComment(elements, i);
- const endLine = !member.keepsSourceLine || !memberFollowsOnSameLine(elements, i);
- closeContinuation(writer, member.emit(trailing.text, endLine));
+ closeContinuation(writer, member.emit(trailing.text));
if (trailing.index !== undefined) i = trailing.index;
sawContent = true;
lastWasRegular = member.category !== 'blockAttribute';
@@ -508,17 +503,6 @@ function walkRegion(
}
}
-function memberFollowsOnSameLine(elements: readonly SyntaxElement[], memberIndex: number): boolean {
- for (let i = memberIndex + 1; i < elements.length; i++) {
- const element = elements[i];
- if (element === undefined) continue;
- if (element instanceof SyntaxNode) return true;
- if (element.kind === 'Whitespace') continue;
- return false;
- }
- return false;
-}
-
function separationBlankWanted(
writer: LineWriter,
category: MemberCategory,
diff --git a/packages/1-framework/2-authoring/psl-parser/src/name-the-psl-source-loses.ts b/packages/1-framework/2-authoring/psl-parser/src/name-the-psl-source-loses.ts
new file mode 100644
index 000000000000..f1d919ab6585
--- /dev/null
+++ b/packages/1-framework/2-authoring/psl-parser/src/name-the-psl-source-loses.ts
@@ -0,0 +1,4 @@
+/**
+ * A name that is lost when a PSL file is read. The parser keeps block members, and the PSL contract sources keep other names, as keys of plain objects, where assigning this key sets the object's prototype instead of adding a key. Code that writes PSL must not write this name anywhere a PSL source reads a name, including inside `@map` and `@@map`.
+ */
+export const NAME_THE_PSL_SOURCE_LOSES = '__proto__';
diff --git a/packages/1-framework/2-authoring/psl-parser/src/parse.ts b/packages/1-framework/2-authoring/psl-parser/src/parse.ts
index 3d628ecc70a3..a6afee6185f1 100644
--- a/packages/1-framework/2-authoring/psl-parser/src/parse.ts
+++ b/packages/1-framework/2-authoring/psl-parser/src/parse.ts
@@ -17,16 +17,6 @@ export interface ParseResult {
readonly sources: PslSources;
}
-export type PslGrammar = 'psl' | 'prisma7';
-
-export interface ParseOptions {
- /**
- * `prisma7` also reads two Prisma 7 constructs: `@` attributes after an `enum` member, and field
- * lines in a `view` body. Defaults to `psl`, which reads neither.
- */
- readonly grammar?: PslGrammar;
-}
-
const TRIVIA_KINDS: ReadonlySet = new Set([
'Whitespace',
'Newline',
@@ -43,7 +33,7 @@ export interface DiagnosticMark {
}
/**
- * The fault-tolerant parser substrate the grammars drive. Trivia is flushed
+ * The fault-tolerant parser substrate the parse functions below drive. Trivia is flushed
* into the enclosing open node, so every child node spans exactly its first
* through last significant token.
*/
@@ -525,19 +515,19 @@ type MemberParser = (cursor: Cursor) => void;
* Parses a full PSL document. Never throws — malformed input yields diagnostics
* and a recovered tree, not an exception.
*/
-export function parse(source: string, filename: string, options: ParseOptions = {}): ParseResult {
+export function parse(source: string, filename: string): ParseResult {
const cursor = new Cursor(filename, source);
- const green = parseDocument(cursor, options.grammar ?? 'psl');
+ const green = parseDocument(cursor);
const root = createSyntaxTree(green);
const document = DocumentAst.cast(root) ?? new DocumentAst(root);
const sources = new PslSources([[document.syntax, cursor.sourceFile]]);
return { document, diagnostics: cursor.diagnostics, sources };
}
-function parseDocument(cursor: Cursor, grammar: PslGrammar): GreenNode {
+function parseDocument(cursor: Cursor): GreenNode {
cursor.startNode('Document');
while (cursor.peekKind() !== 'Eof') {
- parseDeclaration(cursor, false, grammar);
+ parseDeclaration(cursor, false);
}
cursor.flushTrivia(); // attach trailing trivia so the round-trip stays lossless
return cursor.finishNode();
@@ -560,7 +550,7 @@ function keywordIs(cursor: Cursor, keyword: string): boolean {
* Recovery runs via the `if (!node)` tail rather than as a `??` arm, because it
* appends raw tokens to the open parent instead of returning a child node.
*/
-function parseDeclaration(cursor: Cursor, insideNamespace: boolean, grammar: PslGrammar): void {
+function parseDeclaration(cursor: Cursor, insideNamespace: boolean): void {
const name = cursor.peekKind(1) === 'Ident' ? cursor.peekToken(1).text : '';
if (insideNamespace && keywordIs(cursor, 'namespace')) {
cursor.diagnostic(
@@ -584,10 +574,10 @@ function parseDeclaration(cursor: Cursor, insideNamespace: boolean, grammar: Psl
const node =
parseModel(cursor) ??
- parseNamespace(cursor, grammar) ??
+ parseNamespace(cursor) ??
parseCompositeType(cursor) ??
parseTypesBlock(cursor) ??
- parseGenericBlock(cursor, grammar);
+ parseGenericBlock(cursor);
if (!node) {
parseUnsupportedTopLevel(cursor);
}
@@ -638,15 +628,8 @@ export function parseModel(cursor: Cursor): GreenNode | undefined {
* {` with no name) routed to its dedicated parser. The generic keyword set is
* open, so a bare identifier with no brace (e.g. `oops`) is read as an unfinished
* custom declaration rather than unsupported content.
- *
- * With the `prisma7` grammar, a `view` block stays a generic block (so interpreters keep rejecting
- * the keyword), but its body is read like a model body, so the field lines parse as
- * `FieldDeclaration` nodes with spans instead of mangled entries.
*/
-export function parseGenericBlock(
- cursor: Cursor,
- grammar: PslGrammar = 'psl',
-): GreenNode | undefined {
+export function parseGenericBlock(cursor: Cursor): GreenNode | undefined {
if (cursor.peekKind() !== 'Ident') return undefined;
const keyword = cursor.peekToken().text;
if (RESERVED_BLOCK_KEYWORDS.has(keyword)) return undefined;
@@ -657,7 +640,7 @@ export function parseGenericBlock(
parseIdentifier(cursor);
}
if (cursor.peekKind() === 'LBrace') {
- parseBlockBody(cursor, genericBlockMemberParser(keyword, grammar));
+ parseBlockBody(cursor, genericBlockMemberParser(keyword));
} else {
cursor.diagnostic(
'PSL_INVALID_DECLARATION',
@@ -669,9 +652,9 @@ export function parseGenericBlock(
return cursor.finishNode();
}
-export function parseNamespace(cursor: Cursor, grammar: PslGrammar = 'psl'): GreenNode | undefined {
+export function parseNamespace(cursor: Cursor): GreenNode | undefined {
if (!keywordIs(cursor, 'namespace')) return undefined;
- return parseBlock(cursor, 'Namespace', true, (inner) => parseDeclaration(inner, true, grammar));
+ return parseBlock(cursor, 'Namespace', true, (inner) => parseDeclaration(inner, true));
}
export function parseCompositeType(cursor: Cursor): GreenNode | undefined {
@@ -744,13 +727,13 @@ function parseNamedTypeMember(cursor: Cursor): void {
}
/**
- * With the `prisma7` grammar, a `view` body is read like a model body and an `enum` member may
- * carry `@` attributes (`USER @map("user")`). Every other generic block, and every generic block
- * with the `psl` grammar, reads plain `key = value` entries.
+ * A `view` body is read like a model body. Every other generic block reads `key = value` entries
+ * and bare keys, and in an `enum` block those may carry `@` attributes (`USER @map("user")`). Each
+ * interpreter decides whether it accepts the block and its members.
*/
-function genericBlockMemberParser(keyword: string, grammar: PslGrammar): MemberParser {
- if (grammar === 'prisma7' && keyword === 'view') return parseModelMember;
- if (grammar === 'prisma7' && keyword === 'enum') return parseEnumMember;
+function genericBlockMemberParser(keyword: string): MemberParser {
+ if (keyword === 'view') return parseModelMember;
+ if (keyword === 'enum') return parseEnumMember;
return parseKeyValueMember;
}
@@ -819,9 +802,9 @@ export function parseNamedType(cursor: Cursor): GreenNode | undefined {
/**
* A generic-block entry is either `key = value` or a bare `key` (committing a
- * `KeyValuePair` carrying only the key). With `memberAttributes` (enum blocks parsed with the
- * `prisma7` grammar) any number of `@` attributes may follow, as in `USER @map("user")`. A
- * `key =` with no following expression is flagged.
+ * `KeyValuePair` carrying only the key). With `memberAttributes` (enum blocks) any number of `@`
+ * attributes may follow, as in `USER @map("user")`. A `key =` with no following expression is
+ * flagged.
*/
export function parseKeyValue(
cursor: Cursor,
diff --git a/packages/1-framework/2-authoring/psl-parser/src/syntax/ast/declarations.ts b/packages/1-framework/2-authoring/psl-parser/src/syntax/ast/declarations.ts
index 88171bd9645e..57c079c1b8bf 100644
--- a/packages/1-framework/2-authoring/psl-parser/src/syntax/ast/declarations.ts
+++ b/packages/1-framework/2-authoring/psl-parser/src/syntax/ast/declarations.ts
@@ -225,7 +225,7 @@ export class GenericBlockDeclarationAst implements BracedBlock {
yield* filterChildren(this.syntax, KeyValuePairAst.cast);
}
- /** Field lines of a block parsed with the model-member grammar (a Prisma 7 `view`). Empty for every other generic block. */
+ /** Field lines of a `view` body. Empty for every other generic block. */
*fields(): Iterable {
yield* filterChildren(this.syntax, FieldDeclarationAst.cast);
}
@@ -278,7 +278,7 @@ export class KeyValuePairAst implements AstNode {
return undefined;
}
- /** `@` attributes after the key or value (a Prisma 7 enum member's `@map`). */
+ /** `@` attributes after the key or value, as in `USER @map("user")`. */
*attributes(): Iterable {
yield* filterChildren(this.syntax, FieldAttributeAst.cast);
}
diff --git a/packages/1-framework/2-authoring/psl-parser/src/tokenizer.ts b/packages/1-framework/2-authoring/psl-parser/src/tokenizer.ts
index 45d37ed6266e..5526220e9297 100644
--- a/packages/1-framework/2-authoring/psl-parser/src/tokenizer.ts
+++ b/packages/1-framework/2-authoring/psl-parser/src/tokenizer.ts
@@ -81,6 +81,15 @@ export class Tokenizer {
}
}
+/**
+ * Whether the tokenizer reads `text` as one identifier: a letter or `_`, then letters, `_`, ASCII
+ * digits or `-`, and not one of the number words `NaN` and `Infinity`.
+ */
+export function isPslIdentifier(text: string): boolean {
+ const token = scan(text, 0);
+ return token.kind === 'Ident' && token.text === text;
+}
+
function scan(source: string, pos: number): Token {
if (pos >= source.length) {
return { kind: 'Eof', text: '' };
diff --git a/packages/1-framework/2-authoring/psl-parser/src/unclaimed-blocks.ts b/packages/1-framework/2-authoring/psl-parser/src/unclaimed-blocks.ts
new file mode 100644
index 000000000000..0b3b576e2fee
--- /dev/null
+++ b/packages/1-framework/2-authoring/psl-parser/src/unclaimed-blocks.ts
@@ -0,0 +1,31 @@
+import {
+ type AuthoringPslBlockDescriptorNamespace,
+ isAuthoringPslBlockDescriptor,
+} from '@internal/framework-components/authoring';
+import { diagnosticSource, type PslDiagnostic } from './diagnostic';
+import { keywordPslSpan } from './resolve';
+import type { PslSources } from './source-file';
+import type { BlockSymbol } from './symbol-table';
+
+/** A generic block is read only when a composed descriptor claims its keyword. */
+export function claimedBlockKeywords(
+ descriptors: AuthoringPslBlockDescriptorNamespace | undefined,
+): ReadonlySet {
+ const keywords = new Set();
+ for (const [keyword, value] of Object.entries(descriptors ?? {})) {
+ if (isAuthoringPslBlockDescriptor(value)) {
+ keywords.add(keyword);
+ }
+ }
+ return keywords;
+}
+
+export function unsupportedBlockDiagnostic(block: BlockSymbol, sources: PslSources): PslDiagnostic {
+ return {
+ code: 'PSL_UNSUPPORTED_TOP_LEVEL_BLOCK',
+ message: `Unsupported top-level block "${block.keyword}"`,
+ ...diagnosticSource(sources, block.node.syntax).at(
+ keywordPslSpan(block.node.syntax, block.keyword, sources),
+ ),
+ };
+}
diff --git a/packages/1-framework/2-authoring/psl-parser/test/format/fixtures.test.ts b/packages/1-framework/2-authoring/psl-parser/test/format/fixtures.test.ts
index 5ec82392f109..49b7874ee840 100644
--- a/packages/1-framework/2-authoring/psl-parser/test/format/fixtures.test.ts
+++ b/packages/1-framework/2-authoring/psl-parser/test/format/fixtures.test.ts
@@ -10,7 +10,7 @@ const cases = readdirSync(fixturesDir, { withFileTypes: true })
.map((entry) => entry.name)
.sort();
-const authoredCaseCount = 23;
+const authoredCaseCount = 25;
describe('format side-by-side fixture pairs', () => {
it('discovers at least the authored set of fixture cases', () => {
diff --git a/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/header-comment-before-brace/expected.prisma b/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/header-comment-before-brace/expected.prisma
new file mode 100644
index 000000000000..a2f5edba9512
--- /dev/null
+++ b/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/header-comment-before-brace/expected.prisma
@@ -0,0 +1,22 @@
+datasource db { // the main database
+ provider = "postgresql"
+}
+
+model User { // one row per account
+ id Int @id
+}
+
+enum Role { // account roles
+ USER
+}
+
+types { // shared aliases
+ Email = String
+}
+
+namespace billing { // invoicing
+ model Invoice { // one row per invoice
+ // issued monthly
+ id Int @id
+ }
+}
diff --git a/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/header-comment-before-brace/input.prisma b/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/header-comment-before-brace/input.prisma
new file mode 100644
index 000000000000..6e0626e453b8
--- /dev/null
+++ b/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/header-comment-before-brace/input.prisma
@@ -0,0 +1,27 @@
+datasource db // the main database
+{
+ provider = "postgresql"
+}
+
+model User // one row per account
+{
+ id Int @id
+}
+
+enum Role // account roles
+{
+ USER
+}
+
+types // shared aliases
+{
+ Email = String
+}
+
+namespace billing // invoicing
+{
+ model Invoice // one row per invoice
+ { // issued monthly
+ id Int @id
+ }
+}
diff --git a/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/list-value-spacing/expected.prisma b/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/list-value-spacing/expected.prisma
new file mode 100644
index 000000000000..7f95db3c2630
--- /dev/null
+++ b/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/list-value-spacing/expected.prisma
@@ -0,0 +1,13 @@
+datasource db {
+ extensions = [pgvector, postgis]
+}
+
+model Post {
+ id Int @id
+ authorId Int
+ author User @relation(fields: [authorId], references: [id])
+ tags String[]
+
+ @@index(fields: [authorId, id])
+ @@index([[authorId], [id]])
+}
diff --git a/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/list-value-spacing/input.prisma b/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/list-value-spacing/input.prisma
new file mode 100644
index 000000000000..0bd125c5072c
--- /dev/null
+++ b/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/list-value-spacing/input.prisma
@@ -0,0 +1,12 @@
+datasource db {
+extensions=[pgvector,postgis]
+}
+
+model Post {
+id Int @id
+authorId Int
+author User @relation(fields:[authorId], references:[id])
+tags String[]
+@@index(fields:[authorId,id])
+@@index([[authorId],[id]])
+}
diff --git a/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/view-block-plain-fields/expected.prisma b/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/view-block-plain-fields/expected.prisma
index 494940b86444..4fa17c61a6c6 100644
--- a/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/view-block-plain-fields/expected.prisma
+++ b/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/view-block-plain-fields/expected.prisma
@@ -1,4 +1,4 @@
view ActiveUsers {
- id Int
+ id Int
email String
}
diff --git a/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/view-block-plain-fields/input.prisma b/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/view-block-plain-fields/input.prisma
index 4fa17c61a6c6..515316f07c8f 100644
--- a/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/view-block-plain-fields/input.prisma
+++ b/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/view-block-plain-fields/input.prisma
@@ -1,4 +1,4 @@
view ActiveUsers {
- id Int
- email String
+id Int
+email String
}
diff --git a/packages/1-framework/2-authoring/psl-parser/test/format/prisma7-constructs.test.ts b/packages/1-framework/2-authoring/psl-parser/test/format/prisma7-constructs.test.ts
deleted file mode 100644
index f75ebb09f6b8..000000000000
--- a/packages/1-framework/2-authoring/psl-parser/test/format/prisma7-constructs.test.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { format } from '../../src/exports/format';
-import { emitDocument } from '../../src/format/emit';
-import { parse } from '../../src/parse';
-
-describe('format given a Prisma 7 construct in a Prisma 8 document', () => {
- it('refuses a view block whose field lines carry attributes', () => {
- expect(() => format('view ActiveUsers {\n id Int @unique\n email String\n}\n')).toThrow(
- expect.objectContaining({
- code: 'PSL.PARSE_FAILED',
- message: 'Cannot format PSL with parse errors: Invalid block entry',
- }),
- );
- });
-
- it('refuses an enum member with an attribute', () => {
- expect(() => format('enum Role {\n USER @map("user")\n ADMIN\n}\n')).toThrow(
- expect.objectContaining({
- code: 'PSL.PARSE_FAILED',
- message: 'Cannot format PSL with parse errors: Invalid block entry',
- }),
- );
- });
-});
-
-describe('emitDocument given a view block parsed with the Prisma 7 grammar', () => {
- it('keeps each field on one line with its type, aligned like a model field', () => {
- const { document } = parse('view ActiveUsers {\n id Int\n email String\n}\n', 'test.psl', {
- grammar: 'prisma7',
- });
- expect(emitDocument(document, ' ', '\n')).toEqual(
- 'view ActiveUsers {\n id Int\n email String\n}\n',
- );
- });
-
- it('keeps a field attribute on the same line as its field', () => {
- const { document } = parse('view ActiveUsers {\n id Int @unique\n}\n', 'test.psl', {
- grammar: 'prisma7',
- });
- expect(emitDocument(document, ' ', '\n')).toEqual('view ActiveUsers {\n id Int @unique\n}\n');
- });
-});
diff --git a/packages/1-framework/2-authoring/psl-parser/test/format/view-and-enum-member.test.ts b/packages/1-framework/2-authoring/psl-parser/test/format/view-and-enum-member.test.ts
new file mode 100644
index 000000000000..0798d95833fc
--- /dev/null
+++ b/packages/1-framework/2-authoring/psl-parser/test/format/view-and-enum-member.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from 'vitest';
+import { format } from '../../src/exports/format';
+
+describe('format given a view block', () => {
+ it('aligns field lines and their attributes like a model body', () => {
+ expect(
+ format('view ActiveUsers {\n id Int @unique\n email String @map("user_email")\n}\n'),
+ ).toBe('view ActiveUsers {\n id Int @unique\n email String @map("user_email")\n}\n');
+ });
+
+ it('separates block attributes from the fields with a blank line', () => {
+ expect(format('view ActiveUsers {\n id Int\n @@map("active_users")\n}\n')).toBe(
+ 'view ActiveUsers {\n id Int\n\n @@map("active_users")\n}\n',
+ );
+ });
+});
+
+describe('format given enum members', () => {
+ it('keeps an attribute on its member, leaving its validity to the interpreter', () => {
+ expect(format('enum Role {\n USER @map("user")\n ADMIN\n}\n')).toBe(
+ 'enum Role {\n USER @map("user")\n ADMIN\n}\n',
+ );
+ });
+
+ it('puts members written on one line on separate lines', () => {
+ expect(format('enum Role { USER ADMIN }\n')).toBe('enum Role {\n USER\n ADMIN\n}\n');
+ });
+});
+
+describe('format given datasource, generator, enum and model blocks', () => {
+ it('formats every block in one document', () => {
+ const schema = [
+ 'datasource db {',
+ ' provider = "postgresql"',
+ ' url = env("DATABASE_URL")',
+ '}',
+ 'generator client {',
+ ' provider = "prisma-client-js"',
+ '}',
+ 'enum Role {',
+ ' USER @map("user")',
+ ' ADMIN @map("admin")',
+ '}',
+ 'model User {',
+ ' id Int @id @default(autoincrement())',
+ ' role Role @default(USER)',
+ ' @@map("users")',
+ '}',
+ '',
+ ].join('\n');
+ expect(format(schema)).toBe(
+ [
+ 'datasource db {',
+ ' provider = "postgresql"',
+ ' url = env("DATABASE_URL")',
+ '}',
+ '',
+ 'generator client {',
+ ' provider = "prisma-client-js"',
+ '}',
+ '',
+ 'enum Role {',
+ ' USER @map("user")',
+ ' ADMIN @map("admin")',
+ '}',
+ '',
+ 'model User {',
+ ' id Int @id @default(autoincrement())',
+ ' role Role @default(USER)',
+ '',
+ ' @@map("users")',
+ '}',
+ '',
+ ].join('\n'),
+ );
+ });
+});
diff --git a/packages/1-framework/2-authoring/psl-parser/test/interpret.test.ts b/packages/1-framework/2-authoring/psl-parser/test/interpret.test.ts
index 1eadd93e33df..9786518899e3 100644
--- a/packages/1-framework/2-authoring/psl-parser/test/interpret.test.ts
+++ b/packages/1-framework/2-authoring/psl-parser/test/interpret.test.ts
@@ -42,20 +42,6 @@ describe('hasPslInterpreter', () => {
expect(hasPslInterpreter(source)).toBe(false);
});
- it('rejects an opaque provider with an unknown format carrying interpret', () => {
- const provider = { format: 'made-up-format', load, interpret: () => [] };
- const source: ContractSourceProvider = provider;
-
- expect(hasPslInterpreter(source)).toBe(false);
- });
-
- it('rejects a provider without a format', () => {
- const provider = { load, interpret: () => [] };
- const source: ContractSourceProvider = provider;
-
- expect(hasPslInterpreter(source)).toBe(false);
- });
-
it('rejects a psl provider without an interpret method', () => {
const source: ContractSourceProvider = { format: 'psl', load };
diff --git a/packages/1-framework/2-authoring/psl-parser/test/is-psl-identifier.test.ts b/packages/1-framework/2-authoring/psl-parser/test/is-psl-identifier.test.ts
new file mode 100644
index 000000000000..627c6bba67e5
--- /dev/null
+++ b/packages/1-framework/2-authoring/psl-parser/test/is-psl-identifier.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from 'vitest';
+import { isPslIdentifier } from '../src/tokenizer';
+
+describe('isPslIdentifier', () => {
+ it.each(['name', '_hidden', 'naïve', 'kebab-case', 'v2', 'Infinityx'])('accepts %s', (text) => {
+ expect(isPslIdentifier(text)).toBe(true);
+ });
+
+ it.each([
+ ['the empty string', ''],
+ ['a leading digit', '2nd'],
+ ['a leading hyphen', '-name'],
+ ['a space', 'two words'],
+ ['a digit outside ASCII', 'v٢'],
+ ['a number keyword', 'NaN'],
+ ['the infinity keyword', 'Infinity'],
+ ])('rejects %s', (_, text) => {
+ expect(isPslIdentifier(text)).toBe(false);
+ });
+});
diff --git a/packages/1-framework/2-authoring/psl-parser/test/name-the-psl-source-loses.test.ts b/packages/1-framework/2-authoring/psl-parser/test/name-the-psl-source-loses.test.ts
new file mode 100644
index 000000000000..e10dd0038b03
--- /dev/null
+++ b/packages/1-framework/2-authoring/psl-parser/test/name-the-psl-source-loses.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest';
+import { NAME_THE_PSL_SOURCE_LOSES } from '../src/name-the-psl-source-loses';
+import { parse } from '../src/parse';
+import { buildSymbolTable } from '../src/symbol-table';
+
+function blockMemberNames(source: string, blockName: string): readonly string[] {
+ const { document, sources } = parse(source, 'test.psl');
+ const { symbolTable } = buildSymbolTable({
+ documents: [document],
+ sources,
+ pslBlockDescriptors: {},
+ });
+ return Object.keys(symbolTable.topLevel.blocks[blockName]?.block.parameters ?? {});
+}
+
+describe('NAME_THE_PSL_SOURCE_LOSES', () => {
+ it('names a block member the parser does not keep', () => {
+ expect(
+ blockMemberNames(`enum Status {\n ${NAME_THE_PSL_SOURCE_LOSES}\n Active\n}`, 'Status'),
+ ).toEqual(['Active']);
+ });
+});
diff --git a/packages/1-framework/2-authoring/psl-parser/test/parse-enum-member-attributes.test.ts b/packages/1-framework/2-authoring/psl-parser/test/parse-enum-member-attributes.test.ts
new file mode 100644
index 000000000000..0cb12374d6c6
--- /dev/null
+++ b/packages/1-framework/2-authoring/psl-parser/test/parse-enum-member-attributes.test.ts
@@ -0,0 +1,111 @@
+import { describe, expect, it } from 'vitest';
+import { parse } from '../src/parse';
+import type { FieldAttributeAst } from '../src/syntax/ast/attributes';
+import { GenericBlockDeclarationAst } from '../src/syntax/ast/declarations';
+import { StringLiteralExprAst } from '../src/syntax/ast/expressions';
+import type { GreenElement } from '../src/syntax/green';
+import { printTree } from './support';
+
+function greenText(element: GreenElement): string {
+ if (element.type === 'token') return element.text;
+ return element.children.map(greenText).join('');
+}
+
+function onlyGenericBlock(source: string): GenericBlockDeclarationAst {
+ const result = parse(source, 'test.psl');
+ expect(result.diagnostics).toEqual([]);
+ expect(greenText(result.document.syntax.green)).toBe(source);
+ const [declaration] = Array.from(result.document.declarations());
+ expect(declaration).toBeInstanceOf(GenericBlockDeclarationAst);
+ if (!(declaration instanceof GenericBlockDeclarationAst)) throw new Error('unreachable');
+ return declaration;
+}
+
+function attributeArgs(attribute: FieldAttributeAst) {
+ return Array.from(attribute.argList()?.args() ?? [], (arg) => ({
+ name: arg.name()?.token()?.text,
+ value: StringLiteralExprAst.cast(arg.value()?.syntax ?? attribute.syntax)?.value(),
+ }));
+}
+
+describe('enum member attributes', () => {
+ it('parses positional and named attribute arguments on members with spans', () => {
+ const source = 'enum Role {\n USER @map("user")\n ADMIN @map(name: "admin") @deprecated\n}';
+ const block = onlyGenericBlock(source);
+ const [user, admin] = Array.from(block.entries());
+
+ expect(user?.key()?.token()?.text).toBe('USER');
+ expect(user?.value()).toBeUndefined();
+ const userAttributes = Array.from(user?.attributes() ?? []);
+ expect(userAttributes).toHaveLength(1);
+ expect(userAttributes[0]?.name()?.path()).toEqual(['map']);
+ expect(attributeArgs(userAttributes[0]!)).toEqual([{ name: undefined, value: 'user' }]);
+ expect(userAttributes[0]?.syntax.offset).toBe(source.indexOf('@map("user")'));
+ expect(userAttributes[0]?.syntax.textLength).toBe('@map("user")'.length);
+
+ const adminAttributes = Array.from(admin?.attributes() ?? []);
+ expect(adminAttributes.map((attribute) => attribute.name()?.path())).toEqual([
+ ['map'],
+ ['deprecated'],
+ ]);
+ expect(attributeArgs(adminAttributes[0]!)).toEqual([{ name: 'name', value: 'admin' }]);
+ expect(adminAttributes[1]?.argList()).toBeUndefined();
+ });
+
+ it('parses a member attribute list as FieldAttribute children of the KeyValuePair', () => {
+ const result = parse('enum Role {\n USER @map("user")\n}', 'test.psl');
+ expect(printTree(result.document.syntax.green)).toMatchInlineSnapshot(`
+ "Document
+ GenericBlockDeclaration
+ Ident "enum"
+ Whitespace " "
+ Identifier
+ Ident "Role"
+ Whitespace " "
+ LBrace "{"
+ Newline "\\n"
+ Whitespace " "
+ KeyValuePair
+ Identifier
+ Ident "USER"
+ Whitespace " "
+ FieldAttribute
+ At "@"
+ QualifiedName
+ Identifier
+ Ident "map"
+ AttributeArgList
+ LParen "("
+ AttributeArg
+ StringLiteralExpr
+ StringLiteral "\\"user\\""
+ RParen ")"
+ Newline "\\n"
+ RBrace "}""
+ `);
+ });
+
+ it('parses an attribute after an enum member value', () => {
+ const block = onlyGenericBlock('enum Role {\n Admin = "admin" @map("ADMIN")\n}');
+ const [admin] = Array.from(block.entries());
+ expect(Array.from(admin?.attributes() ?? [], (a) => a.name()?.path())).toEqual([['map']]);
+ });
+
+ it('parses a bare enum member with no attributes', () => {
+ const block = onlyGenericBlock('enum Role {\n ADMIN\n USER\n}');
+ expect(Array.from(block.entries(), (entry) => Array.from(entry.attributes()).length)).toEqual([
+ 0, 0,
+ ]);
+ });
+
+ it('reports an attribute on an entry outside an enum block as an invalid block entry', () => {
+ const result = parse('datasource db {\n provider = "postgresql" @map("x")\n}', 'test.psl');
+ expect(result.diagnostics.map((d) => d.code)).toEqual(['PSL_INVALID_EXTENSION_BLOCK_MEMBER']);
+ const [block] = Array.from(result.document.declarations());
+ expect(block).toBeInstanceOf(GenericBlockDeclarationAst);
+ if (!(block instanceof GenericBlockDeclarationAst)) throw new Error('unreachable');
+ for (const entry of block.entries()) {
+ expect(Array.from(entry.attributes())).toEqual([]);
+ }
+ });
+});
diff --git a/packages/1-framework/2-authoring/psl-parser/test/parse-prisma7.test.ts b/packages/1-framework/2-authoring/psl-parser/test/parse-prisma7.test.ts
deleted file mode 100644
index 5ebccb2413bb..000000000000
--- a/packages/1-framework/2-authoring/psl-parser/test/parse-prisma7.test.ts
+++ /dev/null
@@ -1,247 +0,0 @@
-/**
- * The `prisma7` grammar reads two Prisma 7 constructs so the Prisma 7
- * interpreter can walk them with spans: attributes on enum members, and field
- * lines inside `view` blocks. The default grammar reads neither.
- */
-import { describe, expect, it } from 'vitest';
-import { parse } from '../src/parse';
-import type { FieldAttributeAst } from '../src/syntax/ast/attributes';
-import { GenericBlockDeclarationAst } from '../src/syntax/ast/declarations';
-import { StringLiteralExprAst } from '../src/syntax/ast/expressions';
-import type { GreenElement } from '../src/syntax/green';
-import { printTree } from './support';
-
-const prisma7 = { grammar: 'prisma7' } as const;
-
-function greenText(element: GreenElement): string {
- if (element.type === 'token') return element.text;
- return element.children.map(greenText).join('');
-}
-
-function onlyGenericBlock(source: string): GenericBlockDeclarationAst {
- const result = parse(source, 'test.psl', prisma7);
- expect(result.diagnostics).toEqual([]);
- expect(greenText(result.document.syntax.green)).toBe(source);
- const [declaration] = Array.from(result.document.declarations());
- expect(declaration).toBeInstanceOf(GenericBlockDeclarationAst);
- if (!(declaration instanceof GenericBlockDeclarationAst)) throw new Error('unreachable');
- return declaration;
-}
-
-function attributeArgs(attribute: FieldAttributeAst) {
- return Array.from(attribute.argList()?.args() ?? [], (arg) => ({
- name: arg.name()?.token()?.text,
- value: StringLiteralExprAst.cast(arg.value()?.syntax ?? attribute.syntax)?.value(),
- }));
-}
-
-describe('enum member attributes', () => {
- describe('given the prisma7 grammar', () => {
- it('parses positional and named attribute arguments on members with spans', () => {
- const source = 'enum Role {\n USER @map("user")\n ADMIN @map(name: "admin") @deprecated\n}';
- const block = onlyGenericBlock(source);
- const [user, admin] = Array.from(block.entries());
-
- expect(user?.key()?.token()?.text).toBe('USER');
- expect(user?.value()).toBeUndefined();
- const userAttributes = Array.from(user?.attributes() ?? []);
- expect(userAttributes).toHaveLength(1);
- expect(userAttributes[0]?.name()?.path()).toEqual(['map']);
- expect(attributeArgs(userAttributes[0]!)).toEqual([{ name: undefined, value: 'user' }]);
- expect(userAttributes[0]?.syntax.offset).toBe(source.indexOf('@map("user")'));
- expect(userAttributes[0]?.syntax.textLength).toBe('@map("user")'.length);
-
- const adminAttributes = Array.from(admin?.attributes() ?? []);
- expect(adminAttributes.map((attribute) => attribute.name()?.path())).toEqual([
- ['map'],
- ['deprecated'],
- ]);
- expect(attributeArgs(adminAttributes[0]!)).toEqual([{ name: 'name', value: 'admin' }]);
- expect(adminAttributes[1]?.argList()).toBeUndefined();
- });
-
- it('parses a member attribute list as FieldAttribute children of the KeyValuePair', () => {
- const result = parse('enum Role {\n USER @map("user")\n}', 'test.psl', prisma7);
- expect(printTree(result.document.syntax.green)).toMatchInlineSnapshot(`
- "Document
- GenericBlockDeclaration
- Ident "enum"
- Whitespace " "
- Identifier
- Ident "Role"
- Whitespace " "
- LBrace "{"
- Newline "\\n"
- Whitespace " "
- KeyValuePair
- Identifier
- Ident "USER"
- Whitespace " "
- FieldAttribute
- At "@"
- QualifiedName
- Identifier
- Ident "map"
- AttributeArgList
- LParen "("
- AttributeArg
- StringLiteralExpr
- StringLiteral "\\"user\\""
- RParen ")"
- Newline "\\n"
- RBrace "}""
- `);
- });
-
- it('keeps the invalid-member diagnostic for an entry attribute outside an enum block', () => {
- const result = parse(
- 'datasource db {\n provider = "postgresql" @map("x")\n}',
- 'test.psl',
- prisma7,
- );
- expect(result.diagnostics.map((d) => d.code)).toEqual(['PSL_INVALID_EXTENSION_BLOCK_MEMBER']);
- const [block] = Array.from(result.document.declarations());
- expect(block).toBeInstanceOf(GenericBlockDeclarationAst);
- if (!(block instanceof GenericBlockDeclarationAst)) throw new Error('unreachable');
- for (const entry of block.entries()) {
- expect(Array.from(entry.attributes())).toEqual([]);
- }
- });
-
- it('parses a bare enum block into the same tree as the default grammar', () => {
- const source = 'enum Role {\n ADMIN\n USER\n}';
- const block = onlyGenericBlock(source);
- for (const entry of block.entries()) {
- expect(Array.from(entry.attributes())).toEqual([]);
- }
- expect(printTree(parse(source, 'test.psl', prisma7).document.syntax.green)).toBe(
- printTree(parse(source, 'test.psl').document.syntax.green),
- );
- });
- });
-
- describe('given the default grammar', () => {
- it('reports an attribute after an enum member as an invalid block entry at the attribute', () => {
- const result = parse('enum Role {\n USER @map("user")\n}', 'test.psl');
- expect(result.diagnostics).toEqual([
- {
- filename: 'test.psl',
- code: 'PSL_INVALID_EXTENSION_BLOCK_MEMBER',
- message: 'Invalid block entry',
- range: { start: { line: 1, character: 7 }, end: { line: 1, character: 8 } },
- },
- ]);
- });
-
- it('reports an attribute after an enum member value as an invalid block entry', () => {
- const result = parse('enum Role {\n Admin = "admin" @map("ADMIN")\n}', 'test.psl');
- expect(result.diagnostics).toEqual([
- {
- filename: 'test.psl',
- code: 'PSL_INVALID_EXTENSION_BLOCK_MEMBER',
- message: 'Invalid block entry',
- range: { start: { line: 1, character: 18 }, end: { line: 1, character: 19 } },
- },
- ]);
- });
-
- it('reports an attribute after an enum member inside a namespace block as an invalid block entry', () => {
- const result = parse(
- 'namespace auth {\n enum Role {\n USER @map("user")\n }\n}',
- 'test.psl',
- );
- expect(result.diagnostics).toEqual([
- {
- filename: 'test.psl',
- code: 'PSL_INVALID_EXTENSION_BLOCK_MEMBER',
- message: 'Invalid block entry',
- range: { start: { line: 2, character: 9 }, end: { line: 2, character: 10 } },
- },
- ]);
- });
- });
-});
-
-describe('view blocks', () => {
- const source =
- 'view ActiveUsers {\n id Int @unique\n email String @db.VarChar(255)\n posts Post[]\n\n @@map("active_users")\n}';
-
- describe('given the prisma7 grammar', () => {
- it('parses a view with the model body grammar and keeps the view keyword', () => {
- const block = onlyGenericBlock(source);
- expect(block.keyword()?.text).toBe('view');
- expect(block.name()?.token()?.text).toBe('ActiveUsers');
- const fields = Array.from(block.fields());
- expect(fields.map((field) => field.name()?.token()?.text)).toEqual(['id', 'email', 'posts']);
- expect(fields[0]?.typeAnnotation()?.syntax.offset).toBe(source.indexOf('Int'));
- expect(Array.from(fields[1]!.attributes()).map((a) => a.name()?.path())).toEqual([
- ['db', 'VarChar'],
- ]);
- expect(Array.from(block.attributes()).map((a) => a.name()?.path())).toEqual([['map']]);
- expect(Array.from(block.entries())).toEqual([]);
- });
-
- it('parses a view body as FieldDeclaration children', () => {
- const result = parse('view ActiveUsers {\n id Int @unique\n}', 'test.psl', prisma7);
- expect(printTree(result.document.syntax.green)).toMatchInlineSnapshot(`
- "Document
- GenericBlockDeclaration
- Ident "view"
- Whitespace " "
- Identifier
- Ident "ActiveUsers"
- Whitespace " "
- LBrace "{"
- Newline "\\n"
- Whitespace " "
- FieldDeclaration
- Identifier
- Ident "id"
- Whitespace " "
- TypeAnnotation
- QualifiedName
- Identifier
- Ident "Int"
- Whitespace " "
- FieldAttribute
- At "@"
- QualifiedName
- Identifier
- Ident "unique"
- Newline "\\n"
- RBrace "}""
- `);
- });
-
- it('reports a malformed view member with the model-member diagnostic', () => {
- const result = parse('view ActiveUsers {\n 123\n id Int\n}', 'test.psl', prisma7);
- expect(result.diagnostics.map((d) => d.code)).toEqual(['PSL_INVALID_MODEL_MEMBER']);
- });
- });
-
- describe('given the default grammar', () => {
- it('reports an attribute on a view field line as an invalid block entry', () => {
- const result = parse('view ActiveUsers {\n id Int @unique\n}', 'test.psl');
- expect(result.diagnostics).toEqual([
- {
- filename: 'test.psl',
- code: 'PSL_INVALID_EXTENSION_BLOCK_MEMBER',
- message: 'Invalid block entry',
- range: { start: { line: 1, character: 9 }, end: { line: 1, character: 10 } },
- },
- ]);
- });
-
- it('reads the words of a plain view field line as bare entries', () => {
- const result = parse('view ActiveUsers {\n id Int\n}', 'test.psl');
- expect(result.diagnostics).toEqual([]);
- const [block] = Array.from(result.document.declarations());
- expect(block).toBeInstanceOf(GenericBlockDeclarationAst);
- if (!(block instanceof GenericBlockDeclarationAst)) throw new Error('unreachable');
- expect({
- entries: Array.from(block.entries(), (entry) => entry.key()?.token()?.text),
- fields: Array.from(block.fields()),
- }).toEqual({ entries: ['id', 'Int'], fields: [] });
- });
- });
-});
diff --git a/packages/1-framework/2-authoring/psl-parser/test/parse-view-body.test.ts b/packages/1-framework/2-authoring/psl-parser/test/parse-view-body.test.ts
new file mode 100644
index 000000000000..b97c08e08a45
--- /dev/null
+++ b/packages/1-framework/2-authoring/psl-parser/test/parse-view-body.test.ts
@@ -0,0 +1,95 @@
+import { describe, expect, it } from 'vitest';
+import { parse } from '../src/parse';
+import { GenericBlockDeclarationAst } from '../src/syntax/ast/declarations';
+import type { GreenElement } from '../src/syntax/green';
+import { printTree } from './support';
+
+function greenText(element: GreenElement): string {
+ if (element.type === 'token') return element.text;
+ return element.children.map(greenText).join('');
+}
+
+function onlyGenericBlock(source: string): GenericBlockDeclarationAst {
+ const result = parse(source, 'test.psl');
+ expect(result.diagnostics).toEqual([]);
+ expect(greenText(result.document.syntax.green)).toBe(source);
+ const [declaration] = Array.from(result.document.declarations());
+ expect(declaration).toBeInstanceOf(GenericBlockDeclarationAst);
+ if (!(declaration instanceof GenericBlockDeclarationAst)) throw new Error('unreachable');
+ return declaration;
+}
+
+describe('a view block body', () => {
+ const source =
+ 'view ActiveUsers {\n id Int @unique\n email String @db.VarChar(255)\n posts Post[]\n\n @@map("active_users")\n}';
+
+ it('parses as fields and block attributes, keeping the view a generic block', () => {
+ const block = onlyGenericBlock(source);
+ expect(block.keyword()?.text).toBe('view');
+ expect(block.name()?.token()?.text).toBe('ActiveUsers');
+ const fields = Array.from(block.fields());
+ expect(fields.map((field) => field.name()?.token()?.text)).toEqual(['id', 'email', 'posts']);
+ expect(fields[0]?.typeAnnotation()?.syntax.offset).toBe(source.indexOf('Int'));
+ expect(Array.from(fields[1]!.attributes()).map((a) => a.name()?.path())).toEqual([
+ ['db', 'VarChar'],
+ ]);
+ expect(Array.from(block.attributes()).map((a) => a.name()?.path())).toEqual([['map']]);
+ expect(Array.from(block.entries())).toEqual([]);
+ });
+
+ it('parses each field line as a FieldDeclaration child', () => {
+ const result = parse('view ActiveUsers {\n id Int @unique\n}', 'test.psl');
+ expect(printTree(result.document.syntax.green)).toMatchInlineSnapshot(`
+ "Document
+ GenericBlockDeclaration
+ Ident "view"
+ Whitespace " "
+ Identifier
+ Ident "ActiveUsers"
+ Whitespace " "
+ LBrace "{"
+ Newline "\\n"
+ Whitespace " "
+ FieldDeclaration
+ Identifier
+ Ident "id"
+ Whitespace " "
+ TypeAnnotation
+ QualifiedName
+ Identifier
+ Ident "Int"
+ Whitespace " "
+ FieldAttribute
+ At "@"
+ QualifiedName
+ Identifier
+ Ident "unique"
+ Newline "\\n"
+ RBrace "}""
+ `);
+ });
+
+ it('reports a malformed member with the model-member diagnostic', () => {
+ const result = parse('view ActiveUsers {\n 123\n id Int\n}', 'test.psl');
+ expect(result.diagnostics.map((d) => d.code)).toEqual(['PSL_INVALID_MODEL_MEMBER']);
+ });
+
+ it('parses the same way inside a namespace block', () => {
+ const result = parse(
+ 'namespace app {\n view ActiveUsers {\n id Int @unique\n }\n}',
+ 'test.psl',
+ );
+ expect(result.diagnostics).toEqual([]);
+ expect(printTree(result.document.syntax.green)).toContain('FieldDeclaration');
+ });
+});
+
+describe('a generic block that is not a view or an enum', () => {
+ it('reads two bare words on one line as two entries, not as a field', () => {
+ const block = onlyGenericBlock('native_enum Level { low high }');
+ expect({
+ entries: Array.from(block.entries(), (entry) => entry.key()?.token()?.text),
+ fields: Array.from(block.fields()),
+ }).toEqual({ entries: ['low', 'high'], fields: [] });
+ });
+});
diff --git a/packages/1-framework/2-authoring/psl-parser/test/reader-block-checks.test.ts b/packages/1-framework/2-authoring/psl-parser/test/reader-block-checks.test.ts
new file mode 100644
index 000000000000..c0bb007fd911
--- /dev/null
+++ b/packages/1-framework/2-authoring/psl-parser/test/reader-block-checks.test.ts
@@ -0,0 +1,88 @@
+import type { AuthoringPslBlockDescriptorNamespace } from '@internal/framework-components/authoring';
+import { describe, expect, it } from 'vitest';
+import { enumMemberAttributeDiagnostics } from '../src/enum-member-attributes';
+import { parse } from '../src/parse';
+import { buildSymbolTable } from '../src/symbol-table';
+import { claimedBlockKeywords, unsupportedBlockDiagnostic } from '../src/unclaimed-blocks';
+
+const descriptors: AuthoringPslBlockDescriptorNamespace = {
+ enum: {
+ kind: 'pslBlock',
+ keyword: 'enum',
+ discriminator: 'enum',
+ name: { required: true },
+ parameters: {},
+ variadicParameters: true,
+ },
+ pack: {
+ policy: {
+ kind: 'pslBlock',
+ keyword: 'policy',
+ discriminator: 'policy',
+ name: { required: true },
+ parameters: {},
+ },
+ },
+};
+
+function blocksOf(source: string) {
+ const { document, sources } = parse(source, 'schema.prisma');
+ const { symbolTable } = buildSymbolTable({
+ documents: [document],
+ sources,
+ pslBlockDescriptors: descriptors,
+ });
+ return { blocks: symbolTable.topLevel.blocks, sources };
+}
+
+describe('claimedBlockKeywords', () => {
+ it('claims the keywords of top-level descriptors, not of nested descriptor namespaces', () => {
+ expect([...claimedBlockKeywords(descriptors)]).toEqual(['enum']);
+ });
+
+ it('claims nothing when no descriptors are composed', () => {
+ expect([...claimedBlockKeywords(undefined)]).toEqual([]);
+ });
+});
+
+describe('unsupportedBlockDiagnostic', () => {
+ it('reports the block at its keyword', () => {
+ const { blocks, sources } = blocksOf(
+ 'model User {\n id Int\n}\n\nview Active {\n id Int\n}\n',
+ );
+ expect(unsupportedBlockDiagnostic(blocks['Active']!, sources)).toEqual({
+ filename: 'schema.prisma',
+ code: 'PSL_UNSUPPORTED_TOP_LEVEL_BLOCK',
+ message: 'Unsupported top-level block "view"',
+ range: { start: { line: 4, character: 0 }, end: { line: 4, character: 4 } },
+ });
+ });
+});
+
+describe('enumMemberAttributeDiagnostics', () => {
+ it('reports each attribute on an enum member at the attribute', () => {
+ const { blocks, sources } = blocksOf(
+ 'enum Role {\n USER @map("user") @deprecated\n ADMIN\n}\n',
+ );
+ expect(enumMemberAttributeDiagnostics(blocks['Role']!, sources)).toEqual([
+ {
+ filename: 'schema.prisma',
+ code: 'PSL_UNSUPPORTED_ENUM_MEMBER_ATTRIBUTE',
+ message: 'enum "Role": member "USER" carries @map, but an enum member takes no attributes',
+ range: { start: { line: 1, character: 7 }, end: { line: 1, character: 19 } },
+ },
+ {
+ filename: 'schema.prisma',
+ code: 'PSL_UNSUPPORTED_ENUM_MEMBER_ATTRIBUTE',
+ message:
+ 'enum "Role": member "USER" carries @deprecated, but an enum member takes no attributes',
+ range: { start: { line: 1, character: 20 }, end: { line: 1, character: 31 } },
+ },
+ ]);
+ });
+
+ it('reports nothing for members without attributes', () => {
+ const { blocks, sources } = blocksOf('enum Role {\n USER\n ADMIN\n}\n');
+ expect(enumMemberAttributeDiagnostics(blocks['Role']!, sources)).toEqual([]);
+ });
+});
diff --git a/packages/1-framework/2-authoring/psl-printer/README.md b/packages/1-framework/2-authoring/psl-printer/README.md
index d4714f52d171..ddfd92500173 100644
--- a/packages/1-framework/2-authoring/psl-printer/README.md
+++ b/packages/1-framework/2-authoring/psl-printer/README.md
@@ -13,6 +13,7 @@ Prints Prisma Schema Language (PSL) from `PslDocumentAst` (`@internal/framework-
- Convert structured AST (`model`, `field`, `enum`, `types`) into valid PSL output.
- Preserve `@map` / `@@map` and relation attributes from AST nodes.
- Generate deterministic output so snapshot-based tests remain stable.
+- Open every printed file with the `// use prisma-8` marker. A caller adds one line saying where the file came from with the `description` option; `contract infer` and `contract print` each pass their own.
## Related Docs
diff --git a/packages/1-framework/2-authoring/psl-printer/src/ast-to-print-document.ts b/packages/1-framework/2-authoring/psl-printer/src/ast-to-print-document.ts
index 57eab736154d..ab89596bf75a 100644
--- a/packages/1-framework/2-authoring/psl-printer/src/ast-to-print-document.ts
+++ b/packages/1-framework/2-authoring/psl-printer/src/ast-to-print-document.ts
@@ -1,6 +1,7 @@
import type {
PslAttribute,
PslAttributeArgument,
+ PslCompositeType,
PslDocumentAst,
PslExtensionBlock,
PslField,
@@ -17,15 +18,16 @@ import type { PrintDocument, PrintNamespaceSection } from './print-document';
import { escapePslString } from './serialize-print-document';
import type { PrinterField, PrinterModel, PrinterNamedType } from './types';
-// `contract infer` produces a starting-point PSL contract from a live database
-// schema; the user is expected to edit it (rename models/fields, tighten types,
-// add `@id` where introspection couldn't infer one, etc.) and then run
-// `contract emit` to produce the canonical artifacts. The header invites that
-// workflow rather than warning against it.
-const DEFAULT_AST_PRINT_HEADER =
- '// use prisma-8\n// Contract inferred from the live database schema. Edit as needed, then run `prisma contract emit`.';
+const PRISMA_8_MARKER = '// use prisma-8';
-export function astDocumentToPrintDocument(ast: PslDocumentAst): PrintDocument {
+function headerCommentFor(description: string | undefined): string {
+ return description === undefined ? PRISMA_8_MARKER : `${PRISMA_8_MARKER}\n// ${description}`;
+}
+
+export function astDocumentToPrintDocument(
+ ast: PslDocumentAst,
+ description?: string,
+): PrintDocument {
// FK dependencies are resolved across the whole document — a model in one
// namespace can reference a model in another, and the topo-sort needs to
// see every model to produce a stable order. After sorting, we re-bucket by
@@ -54,15 +56,20 @@ export function astDocumentToPrintDocument(ast: PslDocumentAst): PrintDocument {
// model or block print it once instead of emitting a duplicate declaration
// that would not parse back.
type Section = {
+ readonly compositeTypes: Map;
readonly models: Map;
readonly blocks: Map;
};
const sectionsByName = new Map();
for (const namespace of ast.namespaces) {
const section: Section = sectionsByName.get(namespace.name) ?? {
+ compositeTypes: new Map(),
models: new Map(),
blocks: new Map(),
};
+ for (const compositeType of namespace.compositeTypes) {
+ section.compositeTypes.set(compositeType.name, compositeType);
+ }
for (const model of namespace.models) {
section.models.set(model.name, model);
}
@@ -79,6 +86,9 @@ export function astDocumentToPrintDocument(ast: PslDocumentAst): PrintDocument {
const unranked = sortedModels.length;
const namespaceSections: PrintNamespaceSection[] = [...sectionsByName].map(([name, section]) => ({
name,
+ compositeTypes: [...section.compositeTypes.values()]
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
+ .map((compositeType) => modelToPrinterModel(compositeType)),
models: [...section.models.values()]
.sort((a, b) => (modelOrder.get(a.name) ?? unranked) - (modelOrder.get(b.name) ?? unranked))
.map((m) => modelToPrinterModel(m)),
@@ -98,7 +108,7 @@ export function astDocumentToPrintDocument(ast: PslDocumentAst): PrintDocument {
});
return {
- headerComment: DEFAULT_AST_PRINT_HEADER,
+ headerComment: headerCommentFor(description),
namedTypes,
namespaces: namespaceSections,
};
@@ -181,7 +191,7 @@ function unescapePslString(value: string): string {
return result;
}
-function modelToPrinterModel(model: PslModel): PrinterModel {
+function modelToPrinterModel(model: PslModel | PslCompositeType): PrinterModel {
let mapName: string | undefined;
const modelAttrStrings: string[] = [];
@@ -204,7 +214,7 @@ function modelToPrinterModel(model: PslModel): PrinterModel {
mapName,
fields: printerFields,
modelAttributes: modelAttrStrings,
- comment: model.comment,
+ comment: model.kind === 'model' ? model.comment : undefined,
};
}
diff --git a/packages/1-framework/2-authoring/psl-printer/src/print-document.ts b/packages/1-framework/2-authoring/psl-printer/src/print-document.ts
index bbf2cfccceda..6027b123eb32 100644
--- a/packages/1-framework/2-authoring/psl-printer/src/print-document.ts
+++ b/packages/1-framework/2-authoring/psl-printer/src/print-document.ts
@@ -19,6 +19,8 @@ import type { PrinterModel, PrinterNamedType } from './types';
*/
export type PrintNamespaceSection = {
readonly name: string;
+ /** Value-object `type` blocks, printed before the models in the same namespace. */
+ readonly compositeTypes: readonly PrinterModel[];
readonly models: readonly PrinterModel[];
readonly extensionBlocks: readonly PslExtensionBlock[];
};
diff --git a/packages/1-framework/2-authoring/psl-printer/src/print-psl.ts b/packages/1-framework/2-authoring/psl-printer/src/print-psl.ts
index e31c4ab9d3b1..04afef19c1b9 100644
--- a/packages/1-framework/2-authoring/psl-printer/src/print-psl.ts
+++ b/packages/1-framework/2-authoring/psl-printer/src/print-psl.ts
@@ -30,10 +30,15 @@ export interface PrintPslOptions {
* emitted as-is.
*/
readonly codecLookup?: CodecLookup;
+ /**
+ * One line saying where the file came from. The printed file opens with the
+ * `// use prisma-8` marker, then this line as a comment.
+ */
+ readonly description?: string;
}
export function printPslFromAst(ast: PslDocumentAst, options: PrintPslOptions = {}): string {
- const doc = astDocumentToPrintDocument(ast);
+ const doc = astDocumentToPrintDocument(ast, options.description);
return serializePrintDocument(doc, {
...ifDefined('pslBlockDescriptors', options.pslBlockDescriptors),
...ifDefined('codecLookup', options.codecLookup),
diff --git a/packages/1-framework/2-authoring/psl-printer/src/serialize-print-document.ts b/packages/1-framework/2-authoring/psl-printer/src/serialize-print-document.ts
index 3d9dc49230ed..2bd34659efd5 100644
--- a/packages/1-framework/2-authoring/psl-printer/src/serialize-print-document.ts
+++ b/packages/1-framework/2-authoring/psl-printer/src/serialize-print-document.ts
@@ -110,8 +110,11 @@ function serializeNamespaceContents(
codecLookup: CodecLookup | undefined,
): string[] {
const sections: string[] = [];
+ for (const compositeType of namespace.compositeTypes) {
+ sections.push(serializeModel(compositeType, 'type'));
+ }
for (const model of namespace.models) {
- sections.push(serializeModel(model));
+ sections.push(serializeModel(model, 'model'));
}
for (const extensionBlock of namespace.extensionBlocks) {
sections.push(serializeExtensionBlock(extensionBlock, blockDispatchMap, codecLookup));
@@ -308,13 +311,13 @@ function serializeTypesBlock(namedTypes: readonly PrinterNamedType[]): string {
return lines.join('\n');
}
-function serializeModel(model: import('./types').PrinterModel): string {
+function serializeModel(model: import('./types').PrinterModel, keyword: 'model' | 'type'): string {
const lines: string[] = [];
if (model.comment) {
lines.push(model.comment);
}
- lines.push(`model ${model.name} {`);
+ lines.push(`${keyword} ${model.name} {`);
const idFields = model.fields.filter((f) => f.isId);
const scalarFields = model.fields.filter((f) => !f.isId && !f.isRelation);
diff --git a/packages/1-framework/2-authoring/psl-printer/test/print-psl-from-ast.test.ts b/packages/1-framework/2-authoring/psl-printer/test/print-psl-from-ast.test.ts
index 95653932068f..be60d9f40f1a 100644
--- a/packages/1-framework/2-authoring/psl-printer/test/print-psl-from-ast.test.ts
+++ b/packages/1-framework/2-authoring/psl-printer/test/print-psl-from-ast.test.ts
@@ -1,4 +1,5 @@
import type {
+ PslCompositeType,
PslDocumentAst,
PslModel,
PslNamedTypeDeclaration,
@@ -70,6 +71,61 @@ describe('printPslFromAst', () => {
expect(printPslFromAst(ast)).toContain('@@map("foo")');
});
+ it('prints a value-object type block inside its namespace, before the models', () => {
+ const address: PslCompositeType = {
+ kind: 'compositeType',
+ name: 'Address',
+ fields: [
+ {
+ kind: 'field',
+ name: 'street',
+ typeName: 'String',
+ optional: false,
+ list: false,
+ attributes: [],
+ span: span(0),
+ },
+ {
+ kind: 'field',
+ name: 'tags',
+ typeName: 'String',
+ optional: true,
+ list: true,
+ attributes: [],
+ span: span(0),
+ },
+ ],
+ attributes: [],
+ span: span(0),
+ };
+ const shop: PslModel = {
+ kind: 'model',
+ name: 'Shop',
+ fields: [
+ {
+ kind: 'field',
+ name: 'home',
+ typeName: 'Address',
+ optional: true,
+ list: false,
+ attributes: [],
+ span: span(0),
+ },
+ ],
+ attributes: [],
+ span: span(0),
+ };
+ const ast: PslDocumentAst = {
+ kind: 'document',
+ sourceId: 't',
+ namespaces: [makeNs('public', [shop], [address], 0)],
+ span: span(0),
+ };
+ expect(printPslFromAst(ast)).toContain(
+ 'namespace public {\n type Address {\n street String\n tags String[]?\n }\n\n model Shop {\n home Address?\n }\n}',
+ );
+ });
+
it('prints types block', () => {
const named: PslNamedTypeDeclaration = {
kind: 'namedType',
@@ -306,4 +362,57 @@ describe('printPslFromAst', () => {
expect(out).toMatch(/labels\s+String\[\]\?/);
expect(out).toMatch(/tags\s+String\[\]\s*$/m);
});
+
+ describe('the header comment', () => {
+ const headerAst: PslDocumentAst = {
+ kind: 'document',
+ sourceId: 'header.prisma',
+ namespaces: [
+ makeNs(
+ UNSPECIFIED_PSL_NAMESPACE_ID,
+ [
+ {
+ kind: 'model',
+ name: 'X',
+ fields: [
+ {
+ kind: 'field',
+ name: 'id',
+ typeName: 'Int',
+ optional: false,
+ list: false,
+ attributes: [attr('field', 'id', [], 1)],
+ span: span(0),
+ },
+ ],
+ attributes: [],
+ span: span(0),
+ },
+ ],
+ [],
+ 0,
+ ),
+ ],
+ span: span(0),
+ };
+
+ function headerOf(printed: string): string {
+ return printed.split('\n\n')[0] ?? '';
+ }
+
+ it('opens with only the prisma-8 marker when the caller names no description', () => {
+ expect(headerOf(printPslFromAst(headerAst))).toBe('// use prisma-8');
+ });
+
+ it('opens with the prisma-8 marker, then the description the caller names', () => {
+ const printed = printPslFromAst(headerAst, {
+ description: 'Printed from prisma/schema.prisma by `prisma contract print`.',
+ });
+
+ expect(headerOf(printed)).toBe(
+ '// use prisma-8\n// Printed from prisma/schema.prisma by `prisma contract print`.',
+ );
+ expect(printed).toContain('model X {');
+ });
+ });
});
diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md
index 03be849812b7..ff6204a5c872 100644
--- a/packages/1-framework/3-tooling/cli/README.md
+++ b/packages/1-framework/3-tooling/cli/README.md
@@ -420,6 +420,81 @@ prisma db schema --json
prisma db schema -v
```
+### `prisma contract print`
+
+Load the contract from the source the config names and print the Prisma 8 PSL that reads back as the same contract, or write it to a file with `--output`. The source can be a Prisma 7 schema (`prisma7Schema(...)`), a TypeScript contract, or a PSL contract. The common use is cutover: a project on `prisma7Schema(...)` is ready to stop reading the Prisma 7 file and author in Prisma 8 PSL instead.
+
+**Command:**
+```bash
+prisma contract print [--config ] [--output ] [--json] [-v] [-q] [--color/--no-color]
+```
+
+Options:
+- `--config `: Optional. Path to `prisma.config.ts` (defaults to `./prisma.config.ts` if present)
+- `--output