Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/architecture docs/ADR-INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ Open this list before you propose a new feature or surface, or judge a feature r
| 240 | Contract snapshots live in a content-addressed store | Every distinct migration contract is stored once per migrations root at `migrations/snapshots/<hex>/contract.{json,d.ts}`, keyed by storage hash; the old per-package sibling contract files and per-space head copies are gone, and `migration.ts` imports resolve through the store. Amends ADR 197 and ADR 232. | [ADR 240 - Contract snapshots live in a content-addressed store.md](adrs/ADR%20240%20-%20Contract%20snapshots%20live%20in%20a%20content-addressed%20store.md) |
| 243 | Name-identified indexes and exact-name adoption | Extends ADR 234's content-addressed wire names from RLS policies to every index, so expression and partial indexes become authorable and verifiable without comparing SQL bodies; adds an exact-name mode (`map:`/`@@map`) whose equivalence is content comparison, which makes a foreign database adoptable with zero operations; naming is a two-arm union (`wire`/`exact`) at construction with flat derived storage. Constraints stay outside the rule (amended: check constraints joined it in ADR 244). | [ADR 243 - Name-identified indexes and exact-name adoption.md](adrs/ADR%20243%20-%20Name-identified%20indexes%20and%20exact-name%20adoption.md) |
| 244 | Check constraints are opaque wire-named expressions | Extends ADR 234's content-addressed wire names to a third object kind: a check is one opaque SQL predicate whose name commits to its content, so equivalence is name equality and introspection never parses a predicate. Supersedes ADR 156's structured `inSet` check shape. The target renders the SQL, the family composes and byte-caps the wire prefix, legacy exact-named checks adopt by drop + add rather than rename, and derivation is scoped to `managed` tables — the contract describes an external schema without prescribing enforcement for it. | [ADR 244 - Check constraints are opaque wire-named expressions.md](adrs/ADR%20244%20-%20Check%20constraints%20are%20opaque%20wire-named%20expressions.md) |
| 317 | MongoDB indexes carry their names | A MongoDB contract records each index's name: `map:`/`name` when written, otherwise its keys and directions (`email_1`), or Prisma 6's name for a Prisma 6 schema; two indexes of a collection with one name are refused. The planner pairs indexes by content and drops and creates one whose name changed; operations create, drop and check indexes by name; `db verify` reports a name difference | [ADR 317 - MongoDB indexes carry their names.md](adrs/ADR%20317%20-%20MongoDB%20indexes%20carry%20their%20names.md) |
| 239 | Errors are structural envelopes with dotted namespace codes | Every user-facing error is a structural envelope with a dotted `NAMESPACE.SUBCODE` code, recognized by field shape rather than `instanceof`, so it survives the control/execution split, the wire, and duplicate library copies; bugs take the separate `InternalError` path; the numeric `PN-DOMAIN-NNNN` codes retire against a published crosswalk. Amended 2026-08-11: a command that ran to its end and found problems reports them as diagnostics in a completed envelope with a documented `4`–`99` exit code (never a thrown failure at exit `2`), and the freeform `fix` prose field becomes typed `nextActions`. | [ADR 239 - Errors are structural envelopes with dotted namespace codes.md](adrs/ADR%20239%20-%20Errors%20are%20structural%20envelopes%20with%20dotted%20namespace%20codes.md) |
| 245 | Errors are structured at origin; results carry one ok discriminator | Governs how ADR 239’s envelopes are built and carried: every surfaced failure is structured where it is raised, with its own dotted code, `why`, and typed `nextActions` (no catch-all codes; the generic factory takes the code as a required first argument; a non-structured error at a process boundary is a bug, exit 1); `code` is the error’s only machine identity (`meta` never carries an alternative one, and the error-reference registry records each code’s producing sites and meta shapes); every operation returns the shared `Result` with the single `ok` discriminator and a `CliStructuredError` failure (per-operation outcome enums banned). Error subclasses extend the one base class — structured data in `meta`, `name` untouched so structural recognition holds, no boundary mappers. Composer records the same rules as its ADR-0043/0044. | [ADR 245 - Errors are structured at origin; results carry one ok discriminator.md](adrs/ADR%20245%20-%20Errors%20are%20structured%20at%20origin%3B%20results%20carry%20one%20ok%20discriminator.md) |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@ Here is what the operation looks like:

```ts
const op: MongoMigrationPlanOperation = {
id: 'index.users.create(email:1)',
label: 'Create index on users (email ascending)',
id: 'index.users.create(email_1)',
label: 'Create index email_1 on users (email:1)',
operationClass: 'additive',
precheck: [
{
description: 'index does not already exist',
description: 'no index named email_1 exists on users',
source: new ListIndexesCommand('users'),
filter: MongoFieldFilter.eq('key', { email: 1 }),
filter: MongoFieldFilter.eq('name', 'email_1'),
expect: 'notExists',
},
],
Expand All @@ -40,9 +40,10 @@ const op: MongoMigrationPlanOperation = {
],
postcheck: [
{
description: 'unique index exists',
description: 'index email_1 exists on users',
source: new ListIndexesCommand('users'),
filter: MongoAndExpr.of([
MongoFieldFilter.eq('name', 'email_1'),
MongoFieldFilter.eq('key', { email: 1 }),
MongoFieldFilter.eq('unique', true),
]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
The planner diffs two contracts and determines that a unique ascending index on `users.email` needs to be created. Rather than directly constructing the full operation ([ADR 188](ADR%20188%20-%20MongoDB%20migration%20operation%20model.md)), it produces an IR node:

```ts
const call = new CreateIndexCall('users', [{ field: 'email', direction: 1 }], { unique: true });
const call = new CreateIndexCall('users', [{ field: 'email', direction: 1 }], { name: 'email_1', unique: true });
```

Two renderers interpret this node.
Expand All @@ -18,7 +18,7 @@ The **operation renderer** calls the `createIndex` factory function and produces

```ts
renderOps([call]);
// → [{ id: 'index.users.create(email:1)', operationClass: 'additive',
// → [{ id: 'index.users.create(email_1)', operationClass: 'additive',
// precheck: [...], execute: [...], postcheck: [...] }]
```

Expand All @@ -27,7 +27,7 @@ The **TypeScript renderer** produces a line of source code that calls the same f
```ts
renderCallsToTypeScript([call], meta);
// → ...
// createIndex('users', [{ field: 'email', direction: 1 }], { unique: true })
// createIndex('users', [{ field: 'email', direction: 1 }], { name: 'email_1', unique: true })
// ...
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ The builder checks the assembled contract last. Those refusals (kind `validator`
- One rule, one implementation. A rule added to the builder applies to every source.
- A source can report every refusal at once, at its own position, because refusals are data, not thrown errors.
- 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.
- 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.
- 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` name and merge indexes before they call the builder. `nameIndexes` names each index written without a name after its stored keys, such as `email_1` ([ADR 317](ADR%20317%20-%20MongoDB%20indexes%20carry%20their%20names.md)). `mergeRedundantIndexes` then merges two indexes in one collection with the same name when they are identical, or when one is a non-unique index made redundant by a unique index on the same keys and otherwise identical options: it removes one and keeps the survivor where it was written. The builder takes each index's name from the definition and refuses two identical indexes, or two indexes with one name, in one collection. The Prisma 6 reader names each index as Prisma 6 did.

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).

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# ADR 317 — MongoDB indexes carry their names

**Status:** Accepted
**Builds on:** [ADR 188 — MongoDB migration operation model](ADR%20188%20-%20MongoDB%20migration%20operation%20model.md), [ADR 306 — One plain-data definition and one builder per family](ADR%20306%20-%20One%20plain-data%20definition%20and%20one%20builder%20per%20family.md)

## At a glance

A Prisma 6 schema like this one built two indexes on `email` in MongoDB:

```prisma
model User {
id String @id @default(auto()) @map("_id") @db.ObjectId
email String @unique

@@index([email(sort: Asc)])
}
```

MongoDB holds them as `User_email_key` (unique) and `User_email_idx`. The contract now records each index's name:

```json
"indexes": [
{ "kind": "mongo-index", "name": "User_email_key", "keys": [{ "field": "email", "direction": 1 }], "unique": true },
{ "kind": "mongo-index", "name": "User_email_idx", "keys": [{ "field": "email", "direction": 1 }] }
]
```

`db verify` checks both names, a migration creates each under its name, and a drop removes an index by the name the database holds it under.

## Context

Before this decision a MongoDB contract stored no index name. Migrations named every index after its keys (`email_1`), gave each operation an id built from the keys, and checked whether an index exists by its keys alone. So two indexes on the same keys with different options could not both be created: a unique and a plain index, a plain and a sparse one, or the indexes two variants of one base write on the same field. A drop removed the index named `email_1`, which fails for an index held under another name and can remove the wrong index when two share keys.

## Decision

1. **`MongoIndex` has a required `name`.** It is the name MongoDB stores the index under. The builder refuses two indexes with one name in one collection (`indexNameDuplicate`).
2. **The sources fill the name.**
- Prisma 8 PSL takes it from `map:` on `@@index`, `@@unique`, `@@textIndex` and `@unique`; TypeScript `defineContract` from the index option `name`. An index written without one gets the name MongoDB gives an index created without one: its stored keys and directions, such as `email_1` or `title_text`. `nameIndexes` does this for both sources, before `mergeRedundantIndexes`, so the builder still builds the definition it is given (ADR 306). Every database Prisma 8 built keeps its index names, and a migration plans nothing for them.
- Two indexes of one collection that would get the same name are refused, and the message asks for a name with `map:` or `name`. Choosing a name automatically would either rename an existing index when a second one appears, or depend on the order the indexes are written in. MongoDB cannot rename an index, so a rename is a drop and a rebuild.
- `mergeRedundantIndexes` merges only indexes with the same name. Two identical indexes that the user named apart are kept, and the builder refuses them, because MongoDB cannot hold both.
- The Prisma 6 reader gives each index the name Prisma 6 gives it: `map:` (or `name:` on `@@index`) when written, otherwise `<collection>_<stored fields>_key` for a unique index and `_idx` for any other, cut to 127 bytes, as prisma-engines 6.19.3 `ConstraintNames` builds it. It refuses what Prisma 6 refuses: two indexes of one model with one name, an empty `map`, and a `map` longer than 127 bytes.
3. **The schema IR reads the name.** `MongoSchemaIndex` has `name`, and its `id` is the name, which MongoDB keeps unique within a collection. Introspection reads it from `listIndexes`.
4. **The planner pairs indexes by content.** It pairs an origin index with a destination index when `indexLookupKey`, which leaves the name out, is the same. Between two contracts the content compares exactly. A database does not report every index as it was written: it lists a text index's keys as `_fts` and `_ftsx` and fills in its weights and languages, and it fills in a collation's unwritten fields. So when the origin was read from a database (`db update`, `db init`), the planner first undoes these changes, as the verifier does, with `canonicalizeSchemasForVerification`; `MongoSchemaIR.fromDatabase`, which every schema states when it is built, tells the two origins apart. The canonical forms are only for pairing: each create is built from the contract's index as written, so `db init` and `db update` create what `migration plan` writes. Canonicalizing means a collation field the contract does not write may hold any value in the database: removing a field from a contract's collation, such as `strength`, is planned by `migration plan`, but `db update` and `db verify` do not see it. [TML-3795](https://linear.app/prisma-company/issue/TML-3795) tracks comparing unwritten collation fields with the locale's defaults. A pair whose names differ is a drop and a create, because MongoDB cannot rename an index. Drops run before creates, which MongoDB needs: it refuses an index with the content of one it already holds.
5. **Operations use the name.**
- `createIndex(collection, keys, options)` creates the index under `options.name`. Its prechecks require that the collection holds no index with that name and none with the same keys and options; its postcheck requires an index with that name, keys and options.
- `dropIndex(collection, keys, options)` drops the index named `options.name`, the name the origin contract or the introspected database holds it under. `options` lists every option the index has. Its precheck requires an index with that name, those keys and exactly those options; its postcheck requires that no index with that name is left.
- The operation id is `index.<collection>.<create|drop>(<name>)`.
- A call without a name, as migration files written by earlier releases have, names the index after its keys, and a `dropIndex` without options checks the keys only, as it did before. Those files keep working.
6. **`db verify` reports a name difference.** An index of the contract whose content the database holds under another name is a failure, with the contract index as `expected` and the database index as `actual`. The canonicalization that strips server defaults pairs a live index with the contract index of the same name when several share its keys.

## Consequences

- Every MongoDB contract with an index gets a new storage hash, because each index records its name. Databases need no change, because the names already match: users re-emit the contract and sign the database again, or plan a migration that has no operations.
- A schema with two unnamed indexes on the same keys and different options, which no migration could create before, is now refused until one is named.
- Contract snapshots written by earlier releases have indexes without names and fail validation. The upgrade note's script adds the names they always had: the name the newly emitted contract gives the same index, or else the name after its keys. It rewrites each snapshot in place and keeps its old storage hash on purpose, because database markers, the ledger and every migration's `from` and `to` name that hash; so a rewritten snapshot's content no longer recomputes to its hash. Nothing recomputes a MongoDB snapshot's hash today, and a future content check of MongoDB snapshots must accept these.

## Alternatives considered

- **Content-hash names, as SQL's ADR 243 does (`email_1_<8 hex>`).** SQL needs the hash because Postgres reprints expression text, so the content cannot be compared. MongoDB reports index options as written, so content compares exactly. And MongoDB has no index rename, so moving existing databases to hashed names would rebuild every index.
- **No names in the contract; drop by content when the migration runs.** This needs a new runner command that looks up an index by content and drops it, it leaves names unchecked, and it still cannot describe the names Prisma 6 gave.
- **An optional name, where a missing one means the default.** This keeps storage hashes, but the contract would no longer state what the database holds, and every reader of the contract would have to know the default rule.
Loading
Loading