Skip to content

Commit ced245b

Browse files
TML-3774: The Prisma 6 MongoDB reader refuses index pairs Prisma 6 refuses, and MongoDB PSL and TypeScript merge duplicate indexes themselves (#30921)
A Prisma 6 MongoDB schema that Prisma 6 itself rejects is now rejected by the Prisma 6 reader too: ```prisma model User { id String @id @default(auto()) @Map("_id") @db.ObjectId email String @unique @@index([email]) } ``` ```text PSL.PRISMA6_MONGO_INDEX_ALREADY_EXISTS @@index([email]) on model "User" has the same fields, with the same sort, as @unique on field "User.email". Prisma 6 refuses this: "Index already exists in the model." Remove one of them. ``` The same schema written in Prisma 8 PSL, or the same indexes in TypeScript `defineContract`, still gives one unique index on `email`. Emitted contracts and storage hashes do not change. ## The decision **The MongoDB contract builder builds exactly the definition it is given.** It checks the definition and refuses what is invalid. It no longer merges indexes. ADR 306 already said this; MongoDB now follows it. **Prisma 8 PSL and TypeScript merge identical and redundant indexes before they build,** through one shared function, `mergeRedundantIndexes`. It keeps the rule TML-3740 introduced: if two indexes in one collection have the same keys and options, one is dropped, and a unique index replaces a plain one. **The Prisma 6 reader merges nothing.** Its job (ADR 252) is to describe the database Prisma 6 built, so `db sign` accepts it unchanged. It now refuses exactly what Prisma 6 6.19.3 refuses. Two indexes of one model clash when their field paths and written sorts match position by position. Any kind counts: `@unique`, `@@unique`, `@@index` or `@@fulltext`. An unwritten sort is not the same as `sort: Asc`. This follows `contains_exactly_the_fields` in prisma-engines. Both indexes of a clashing pair are reported, as Prisma 6 reports them. So this schema, which Prisma 6 accepts, gives a contract with both a unique and a plain index on `{ email: 1 }`: ```prisma email String @unique @@index([email(sort: Asc)]) ``` That is what Prisma 6 `db push` creates (`User_email_key` and `User_email_idx`), and `db verify` reports nothing against it. ## What changes - **Builder** (`contract-ts/src/build-collections.ts`): no merge. Two identical indexes in one collection are refused again (`indexDuplicate`, reported as `CONTRACT.NAME_DUPLICATE`), because MongoDB cannot hold them. A unique and a plain index on the same keys are both kept, because MongoDB accepts them. A text index is counted after its variant scope is checked. - **One check of which indexes are valid**: `checkModelIndexes` returns, for each index a model writes, the scoped storage index or the builder's refusal. The builder and the merge both use it, so they cannot disagree. - **`mergeRedundantIndexes`** (exported from `@prisma/orm-family-mongo/contract-builder`): returns the merged definition and the positions it removed. PSL uses those positions so a later refusal is still reported at its own line. - **Prisma 6 reader**: the new diagnostic `PSL.PRISMA6_MONGO_INDEX_ALREADY_EXISTS`. It is also used, naming both indexes, for a pair Prisma 6 accepts but MongoDB cannot hold, such as `@@index([email])` beside `@@index([email(sort: Asc)])`. - **Docs**: ADR 306, the MongoDB family subsystem doc, the `contract-ts`, `contract-psl` and `contract-prisma6` READMEs, the error reference, and a new pending upgrade note, `mongo-index-merge-in-sources`. For apps, it covers Prisma 6 schemas that are now refused and Prisma 6 pairs that now keep both indexes. For extensions, it covers the `indexDuplicate` refusal coming back and the new `mergeRedundantIndexes`. The rc.22 note from the earlier merge change stays as released. - **Drive docs**: the rule "resolve every review thread before enabling auto-merge" and its command now live once, in `drive/pr/README.md`, and `drive/calibration/dod.md` links to it. ## Evidence - Unit tests in `contract-ts` (merge function, builder refusal, unique plus plain kept), `contract-psl` (a refusal after a merged index is reported at its own line) and `contract-prisma6` (every clash rule, each with its message). - The Prisma 6 journey `test/integration/test/cli-journeys/prisma6-source.e2e.test.ts` now lays out a unique and a plain index on `{ email: 1 }` and requires `db verify` to report nothing. - The MongoDB parity suite and `pnpm fixtures:check` show emitted contracts are unchanged. - Manual QA with the CLI against a MongoDB replica set: Prisma 8 PSL and TypeScript pairs plan and migrate; every Prisma 6 case is refused or accepted as Prisma 6 does; `db sign` and `db verify --strict` pass on a database laid out as Prisma 6 `db push` lays it out. ## Known gap Planning a migration for a contract that holds a unique and a plain index on the same keys fails, because the planner names both `email_1` and its prechecks compare keys only. The same cause makes a plain and a sparse index on one field fail `db init` on main today. Index names in the contract fix both; that is TML-3762. ## Alternatives considered - **Keep merging in the builder for every source.** It changes what a Prisma 6 source describes, so `db sign` could not accept the database Prisma 6 built. - **Let the Prisma 6 reader merge too, and ask users to drop the extra index.** A Prisma 6 database never holds a pair Prisma 6 refuses, and one it accepts must be described as it is. Closes TML-3774. Agent: pimpernel-41 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **Behavior Changes** * MongoDB contract building now rejects identical indexes instead of silently merging them. Unique and non-unique indexes on the same keys are retained when their definitions differ. * Prisma 6 schema reading now reports matching indexes with a diagnostic for each conflict; an omitted sort direction is treated differently from explicit ascending order. * Prisma 8 PSL and TypeScript `defineContract` continue to merge redundant indexes before building. * **Documentation** * Updated MongoDB index guidance, diagnostics, and upgrade instructions, including steps for reviewing duplicate-index reports and addressing storage hash changes. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
1 parent 6da9d8f commit ced245b

36 files changed

Lines changed: 902 additions & 106 deletions

File tree

‎docs/architecture docs/adrs/ADR 306 - One plain-data definition and one builder per family.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,14 @@ The builder checks the assembled contract last. Those refusals (kind `validator`
5757
- **PSL.** The interpreter reads the schema into the definition. It keeps the rules about how a schema is written, such as attribute arguments, `@id` placement, back-relation pairing and unresolved references. It reports each refusal as a diagnostic at the place its key names, with PSL's codes and wording.
5858
- **Prisma 6.** The reader builds with `writesCollectionValidators: false`, because a Prisma 6 database has none. It reports each refusal in its own diagnostics.
5959

60-
**The builder builds the definition it is given.** It checks the definition and refuses what is invalid, but it does not merge, drop or tidy what a source wrote. Tidying is a convenience of the authoring surface the user writes in. For example, Prisma 8 PSL and TypeScript `defineContract` merge an index the user wrote twice without a name to tell the two apart, and they do it before they call the builder. A source that reads an earlier Prisma version's schema exists to produce the contract of the database that version built, so `db sign` accepts it unchanged (ADR 252). It tidies nothing, and the builder keeps everything it describes, even an index the database holds that is redundant, such as an index on `[email, name]` beside one on `[email]`. Prisma 6 itself refuses two indexes on the same fields, so a Prisma 6 database never holds such a pair. MongoDB's builder still merges identical and redundant indexes itself, for every source, the Prisma 6 reader included; TML-3774 moves the merge into PSL and TypeScript and makes the Prisma 6 reader refuse two indexes on the same fields, as Prisma 6 does. SQL's builder still merges duplicate and redundant unnamed indexes itself, and the Prisma 7 reader escapes that only by naming every index; TML-3770 moves the merge into SQL's authoring sources.
60+
**The builder builds the definition it is given.** It checks the definition and refuses what is invalid, but it does not merge, drop or tidy what a source wrote. Tidying is a convenience of the authoring surface the user writes in. For example, Prisma 8 PSL and TypeScript `defineContract` merge an index the user wrote twice without a name to tell the two apart, and they do it before they call the builder. A source that reads an earlier Prisma version's schema exists to produce the contract of the database that version built, so `db sign` accepts it unchanged (ADR 252). It tidies nothing, and the builder keeps everything it describes, even an index the database holds that is redundant, such as an index on `[email, name]` beside one on `[email]`. Prisma 6 itself refuses two indexes of one model, of any kind, whose field paths are the same, in the same order, with the same written sort (an unwritten sort is not `sort: Asc`), so a Prisma 6 database never holds such a pair. The Prisma 6 reader refuses the same pairs, as Prisma 6 does, and merges nothing. SQL's builder still merges duplicate and redundant unnamed indexes itself, and the Prisma 7 reader escapes that only by naming every index; TML-3770 moves the merge into SQL's authoring sources.
6161

6262
## Consequences
6363

6464
- One rule, one implementation. A rule added to the builder applies to every source.
6565
- A source can report every refusal at once, at its own position, because refusals are data, not thrown errors.
6666
- Sources cannot differ in what they accept. A parity suite writes each case in TypeScript and in PSL and checks that the two emitted `contract.json` files are byte-identical.
67-
- Index order is the source's own. The builder does not reorder indexes, because the storage hash depends on their order. When two indexes in one collection are identical, or one is a non-unique index made redundant by a unique index on the same keys and otherwise identical options, the builder removes one and keeps the survivor where it was written. That merge leaves the builder with TML-3774.
67+
- Index order is the source's own. The builder does not reorder indexes, because the storage hash depends on their order. MongoDB PSL and TypeScript `defineContract` merge indexes before they call the builder, with `mergeRedundantIndexes`: when two indexes in one collection are identical, or one is a non-unique index made redundant by a unique index on the same keys and otherwise identical options, they remove one and keep the survivor where it was written. The builder itself refuses two identical indexes in one collection.
6868

6969
SQL does not follow this ADR fully yet. Its builder throws its first refusal, so SQL PSL places only the errors it catches and matches (TML-3730). SQL PSL also adds polymorphism to the contract after the builder runs, instead of carrying it in the definition (TML-2228).
7070

‎docs/architecture docs/subsystems/10. MongoDB Family.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@ The builder decides these behaviours the same way for every source:
256256

257257
| Behaviour | Every source |
258258
| --- | --- |
259-
| Identical or redundant indexes in one collection, after variant scoping | Merged into one. Two indexes are identical when they have the same keys and options, counting an option set to `false` as unset (`indexLookupKey`, which the planner also uses). A non-unique index is redundant beside a unique one that is otherwise identical. The unique one survives, else the first, and it keeps the position where it was written. `@unique` and `@@index` on one field give one unique index. The merge runs before the second text index is refused |
259+
| Two identical indexes in one collection, after variant scoping | Refused. Two indexes are identical when they have the same keys and options, counting an option set to `false` as unset (`indexLookupKey`, which the planner also uses). A unique and a plain index on the same keys are both kept |
260260
| `extensions` | The stack's extension packs |
261261
| Roots | Explicit roots when the source gives them, else one per model with a collection, no owner and no base |
262262
| A variant's collection | Its base's; another collection is refused |
@@ -268,6 +268,8 @@ The builder decides these behaviours the same way for every source:
268268
| Generated values | As on SQL: an optional generated field is accepted; a list that generates a value is refused |
269269
| Index order | Each source's own; the builder does not reorder, because the storage hash depends on the order |
270270

271+
MongoDB PSL and TypeScript `defineContract` merge indexes before they call the builder, with `mergeRedundantIndexes` from `@internal/mongo-contract-ts`. Of two identical indexes in one collection the first is kept. A non-unique index beside a unique one that is otherwise identical is removed, so `@unique` and `@@index` on one field give one unique index. The index that is kept stays where it was written. The Prisma 6 reader merges nothing: it refuses two indexes of one model with the same fields and the same written sort, as Prisma 6 does (`PSL.PRISMA6_MONGO_INDEX_ALREADY_EXISTS`).
272+
271273
A parity suite, `test/integration/test/authoring/mongo-parity.test.ts`, writes each case in TypeScript and in PSL and checks that the two emitted `contract.json` files are byte-identical.
272274

273275
## Contract

‎docs/reference/error-reference.md‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,7 @@ The contract module at the configured path loads but exposes neither a `default`
535535

536536
### CONTRACT.NAME_DUPLICATE
537537

538-
Two declarations claim the same name: duplicate namespace entries, model names, value objects, relations, tables (two models mapping to one table, two table nodes for one table, or duplicate table in a namespace), column mappings (two fields to one column, a field and a table node's column node for one column, or two column nodes for one column), stored names (two fields of one MongoDB model stored under one name with `.mongo({ field })`: `Model "<Model>" stores both "<a>" and "<b>" as "<name>".`, `kind: 'field'`), value-sets (enum and pack entity minting the same value-set), or pack entities of the same kind and name in one namespace. A Postgres extension is unique across the whole contract: one declared in two namespaces is refused while building a contract and again when a contract is read (`kind: 'extension'`, `namespaces`). Payload: `kind`, `name`, `namespaceId`, `tableName` (for a column), `first`, `second`. A MongoDB variant field stored under a name a field of its base is stored under, the discriminator included, is refused too (`reason: 'stored-name-shared-with-base'`, with `first` the base's field and `second` the variant's): a variant's documents hold its base's fields, so the two would overwrite each other. Mongo PSL reports it as `PSL_DUPLICATE_STORED_NAME`.
538+
Two declarations claim the same name: duplicate namespace entries, model names, value objects, relations, tables (two models mapping to one table, two table nodes for one table, or duplicate table in a namespace), column mappings (two fields to one column, a field and a table node's column node for one column, or two column nodes for one column), stored names (two fields of one MongoDB model stored under one name with `.mongo({ field })`: `Model "<Model>" stores both "<a>" and "<b>" as "<name>".`, `kind: 'field'`), indexes (two identical indexes in one MongoDB collection: `kind: 'index'`, with `collection`; TypeScript `defineContract` merges identical indexes before the build, so only a definition passed to `buildMongoContractFromDefinition` directly reaches this), value-sets (enum and pack entity minting the same value-set), or pack entities of the same kind and name in one namespace. A Postgres extension is unique across the whole contract: one declared in two namespaces is refused while building a contract and again when a contract is read (`kind: 'extension'`, `namespaces`). Payload: `kind`, `name`, `namespaceId`, `tableName` (for a column), `first`, `second`. A MongoDB variant field stored under a name a field of its base is stored under, the discriminator included, is refused too (`reason: 'stored-name-shared-with-base'`, with `first` the base's field and `second` the variant's): a variant's documents hold its base's fields, so the two would overwrite each other. Mongo PSL reports it as `PSL_DUPLICATE_STORED_NAME`.
539539

540540
### CONTRACT.NAMESPACE_INVALID
541541

@@ -715,6 +715,17 @@ The model's `@id` is not a required `String @db.ObjectId` stored as `_id`. Decla
715715

716716
An `@ignore`d field is used by `@unique`, `@@unique`, `@@index`, `@@fulltext`, or a relation's `fields:` or `references:`, and Prisma 6 still creates that index or reads that key. Remove `@ignore` from the field, which adds it to the Prisma 6 client, or remove what uses it: removing an index makes Prisma 6 `db push` drop it, and removing a relation field removes that relation from the Prisma 6 client. Reported by the Prisma 6 MongoDB contract source (`prisma6Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `<file>:<line>:<column> <message>`, 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.
717717

718+
### PSL.PRISMA6_MONGO_INDEX_ALREADY_EXISTS
719+
720+
A Prisma 6 schema holds an index that already exists, in one of two senses. Remove one of the two indexes.
721+
722+
- **Prisma 6 refuses the pair.** Two indexes of one model, of any kind (`@unique`, `@@unique`, `@@index`, `@@fulltext`), whose fields have, position by position, the same names and the same written sort, such as `@unique` and `@@index([email])` on one field. An unwritten sort is not the same as `sort: Asc`. Prisma 6 refuses such a schema with "Index already exists in the model.", so no Prisma 6 database holds the pair. The code is reported at each index of the pair; the message names the other index, written as the schema writes it with any sort, and quotes Prisma 6.
723+
- **MongoDB cannot hold the pair.** Two indexes that Prisma 6 accepts but that are identical in MongoDB, with the same keys and options, in one collection: `@@index([name])` beside `@@index([name(sort: Asc)])`, or the same index on two models stored in one collection. The code is reported at the second index, and the message names both.
724+
725+
The same fields in another order or with another sort are accepted. So is `@unique` beside `@@index([email(sort: Asc)])`, which gives a unique and a plain index on the same keys, as Prisma 6 `db push` creates them.
726+
727+
Reported by the Prisma 6 MongoDB contract source (`prisma6Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `<file>:<line>:<column> <message>`, 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.
728+
718729
### PSL.PRISMA6_MONGO_INDEX_ARGUMENT_UNSUPPORTED
719730

720731
An index argument a Mongo contract index cannot carry, such as `length`, an unknown argument, or a dotted path whose first segment is not a composite-type field (`title.first`), which Prisma 6 refuses as an unknown field. `sort` is read, and `map` and `name` are accepted and dropped, since Mongo verify matches indexes by keys and options. Remove the argument. Reported by the Prisma 6 MongoDB contract source (`prisma6Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `<file>:<line>:<column> <message>`, 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.

‎drive/calibration/dod.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ In addition to the canonical slice DoD:
8383

8484
- [ ] After merging `main`: if the merge resolved conflicts with a change `main` made to the same code, run a review round on `git diff <main commit> <merge commit>` before asking for review ([`failure-modes.md § F51`](./failure-modes.md#f51-a-merge-of-main-that-resolves-a-large-overlapping-change-goes-to-review-as-if-it-were-not-new-work)); if `main` had a release bump, `git diff <release commit> HEAD -- upgrade-instructions/releases/` is empty ([`F52`](./failure-modes.md#f52-a-release-bump-archives-pending-upgrade-instructions-and-the-branchs-later-edits-land-in-the-released-copy)). (Added 2026-10-28, datetime-convenience-types.)
8585
- [ ] If the slice changes what an existing type name means: every PSL and TypeScript source under `test/` and `examples/` that writes the name is checked, and the integration files that emit them are run ([`F53`](./failure-modes.md#f53-a-name-changes-meaning-and-a-hand-written-source-that-fixturescheck-does-not-emit-keeps-the-old-meaning)). (Added 2026-10-28, datetime-convenience-types.)
86-
- [ ] Before enabling auto-merge: every review thread on the PR is resolved, by a fix or by a reply that says why not. List the open ones with `gh api graphql --paginate -F number=<PR number> -f query='query($number:Int!,$endCursor:String){repository(owner:"prisma",name:"orm"){pullRequest(number:$number){reviewThreads(first:100,after:$endCursor){pageInfo{hasNextPage endCursor} nodes{id isResolved path}}}}}' --jq '.data.repository.pullRequest.reviewThreads.nodes[]|select(.isResolved==false)|"\(.id) \(.path)"'`, which prints nothing when every thread is resolved; bot reviewers count. (Added 2026-10-27, mongo-authoring-structure: slice 1 merged with four open CodeRabbit threads, found only at close-out.)
86+
- [ ] Before enabling auto-merge: every review thread on the PR is resolved, by a fix or by a reply that says why not; bot reviewers count. The command that lists open threads is in [`pr/README.md § Merge queue needs every review thread resolved`](../pr/README.md#merge-queue-needs-every-review-thread-resolved). (Added 2026-10-27, mongo-authoring-structure: slice 1 merged with four open CodeRabbit threads, found only at close-out.)
8787
- [ ] If the slice changes code that `defineContract`, a pack descriptor or the runtime reaches: run `pnpm size:build` in `examples/bundle-size` (it builds the workspace, then bundles) on the branch and on the PR base ref (`main`, or the base branch of a stacked PR), compare the metafiles, and explain any growth in the PR description before CI's size report asks. (Added 2026-10-27, mongo-authoring-structure: both slices grew a client bundle and the cause was traced only after CI reported it.)
8888

8989
### QA-side items

‎drive/pr/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ A PR open against the wrong branch is recoverable but visible; this gate costs n
2424

2525
## Merge queue needs every review thread resolved
2626

27-
The `main` ruleset requires conversation resolution. A PR with auto-merge enabled and every check green still sits at `BLOCKED` while any review thread is unresolved, including CodeRabbit's. Before enabling auto-merge, list unresolved threads: `reviewThreads` takes no `isResolved` filter, so query `reviewThreads(first: 100) { pageInfo { hasNextPage endCursor } nodes { id isResolved } }` through the GraphQL API, page with `after:` while `hasNextPage` is true, and keep the nodes whose `isResolved` is false. Then reply to each with the fix commit or the reason for no change, and resolve them. Seen on #30350 and #30380 (2026-09-22/23).
27+
The `main` ruleset requires conversation resolution. A PR with auto-merge enabled and every check green still sits at `BLOCKED` while any review thread is unresolved, including CodeRabbit's. Before enabling auto-merge, list the unresolved threads. `reviewThreads` takes no `isResolved` filter, so page through every thread and keep the unresolved ones: `gh api graphql --paginate -F number=<PR number> -f query='query($number:Int!,$endCursor:String){repository(owner:"prisma",name:"orm"){pullRequest(number:$number){reviewThreads(first:100,after:$endCursor){pageInfo{hasNextPage endCursor} nodes{id isResolved path}}}}}' --jq '.data.repository.pullRequest.reviewThreads.nodes[]|select(.isResolved==false)|"\(.id) \(.path)"'`. It prints nothing when every thread is resolved. Reply to each open thread with the fix commit or the reason for no change, and resolve it. Seen on #30350 and #30380 (2026-09-22/23).
2828

2929
## PR title convention
3030

0 commit comments

Comments
 (0)