You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit ced245b
Browse filesBrowse the repository at this point in the historyBrowse 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>
Copy file name to clipboardExpand all lines: docs/architecture docs/adrs/ADR 306 - One plain-data definition and one builder per family.md
+2-2Lines changed: 2 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -57,14 +57,14 @@ The builder checks the assembled contract last. Those refusals (kind `validator`
57
57
-**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.
58
58
-**Prisma 6.** The reader builds with `writesCollectionValidators: false`, because a Prisma 6 database has none. It reports each refusal in its own diagnostics.
59
59
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.
61
61
62
62
## Consequences
63
63
64
64
- One rule, one implementation. A rule added to the builder applies to every source.
65
65
- A source can report every refusal at once, at its own position, because refusals are data, not thrown errors.
66
66
- 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.
68
68
69
69
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).
Copy file name to clipboardExpand all lines: docs/architecture docs/subsystems/10. MongoDB Family.md
+3-1Lines changed: 3 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -256,7 +256,7 @@ The builder decides these behaviours the same way for every source:
256
256
257
257
| Behaviour | Every source |
258
258
| --- | --- |
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|
260
260
|`extensions`| The stack's extension packs |
261
261
| Roots | Explicit roots when the source gives them, else one per model with a collection, no owner and no base |
262
262
| 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:
268
268
| Generated values | As on SQL: an optional generated field is accepted; a list that generates a value is refused |
269
269
| Index order | Each source's own; the builder does not reorder, because the storage hash depends on the order |
270
270
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
+
271
273
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.
Copy file name to clipboardExpand all lines: docs/reference/error-reference.md
+12-1Lines changed: 12 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -535,7 +535,7 @@ The contract module at the configured path loads but exposes neither a `default`
535
535
536
536
### CONTRACT.NAME_DUPLICATE
537
537
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`.
539
539
540
540
### CONTRACT.NAMESPACE_INVALID
541
541
@@ -715,6 +715,17 @@ The model's `@id` is not a required `String @db.ObjectId` stored as `_id`. Decla
715
715
716
716
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.
717
717
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
+
718
729
### PSL.PRISMA6_MONGO_INDEX_ARGUMENT_UNSUPPORTED
719
730
720
731
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.
Copy file name to clipboardExpand all lines: drive/calibration/dod.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -83,7 +83,7 @@ In addition to the canonical slice DoD:
83
83
84
84
-[ ] 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.)
85
85
-[ ] 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 everythread 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.)
87
87
-[ ] 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.)
Copy file name to clipboardExpand all lines: drive/pr/README.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -24,7 +24,7 @@ A PR open against the wrong branch is recoverable but visible; this gate costs n
24
24
25
25
## Merge queue needs every review thread resolved
26
26
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).
0 commit comments