Skip to content

Commit c9648a0

Browse files
committed
docs(upgrade): record closed Mongo contract re-emit for 0.11→0.12
Adds a user-skill entry so consumers re-emit Mongo contracts for closed $jsonSchema validators and apply the destructive db update with -y. Signed-off-by: Will Madden <madden@prisma.io>
1 parent f83579e commit c9648a0

2 files changed

Lines changed: 218 additions & 0 deletions

File tree

skills/upgrade/prisma-next-upgrade/upgrades/0.11-to-0.12/instructions.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ changes:
3030
- '"hints"'
3131
anyMatch: true
3232
script: ./strip-migration-labels-hints.ts
33+
- id: re-emit-closed-mongo-contracts
34+
summary: |
35+
Re-emit Mongo contract artefacts so emitted `$jsonSchema` validators are closed (`additionalProperties: false` at every level, including polymorphic `oneOf` branches). Each non-variant Mongo model must resolve to an `objectId` `_id` before emit succeeds — otherwise interpret fails with `PSL_MONGO_ID_REQUIRED`. After re-emitting, apply the open→closed validator migration with `prisma-next db update -y`; the planner classifies the tightening as `destructive` and refuses without confirmation.
36+
detection:
37+
glob: "**/contract.json"
38+
contains:
39+
- '"kind": "mongo-database"'
40+
anyMatch: true
41+
script: ./re-emit-closed-mongo-contracts.ts
3342
---
3443

3544
# 0.11 → 0.12 — User upgrade instructions
@@ -210,3 +219,51 @@ pnpm exec tsx ./strip-migration-labels-hints.ts --check
210219
### Validation
211220

212221
After running the codemod, exercise any command that loads your migrations (your deploy or migration-status step). The loader recomputes and verifies each manifest's `migrationHash` on read: a manifest that still carried `labels`/`hints` would have thrown `INVALID_MANIFEST`, and a manifest with a stale hash would fail verification. Once the codemod has run, every manifest loads cleanly and its recomputed hash verifies against the slimmed envelope.
222+
223+
## `re-emit-closed-mongo-contracts`
224+
225+
Starting at the 0.12 release, MongoDB emits **closed** `$jsonSchema` validators by default. Every object schema in the emitted contract — collection validators, nested objects, and each branch of a polymorphic `oneOf` — carries `additionalProperties: false`. The contract canonicalizer also preserves `additionalProperties` through emission, so the on-disk migration for consumers is to re-emit their Mongo contracts and apply the resulting validator change to the database.
226+
227+
Two authoring constraints apply before emit succeeds:
228+
229+
- **Closed validators** land automatically on re-emit; no hand-editing of `contract.json` is required.
230+
- **Non-variant models need an `objectId` `_id`**. The new interpret-time rule `PSL_MONGO_ID_REQUIRED` rejects any non-variant Mongo model whose `_id` field does not resolve to `objectId`. Fix the PSL or TS contract source first — for example, ensure `@id` is present and typed as MongoDB's default `ObjectId` — then re-emit.
231+
232+
### Re-emit your Mongo contracts
233+
234+
Run the colocated script from your project root:
235+
236+
```bash
237+
pnpm exec tsx ./re-emit-closed-mongo-contracts.ts
238+
```
239+
240+
It finds every directory with a `prisma-next.config.ts` and a committed Mongo `contract.json`, then runs `pnpm emit` (or `prisma-next contract emit` when no emit script exists) in each. The regenerated `contract.json` / `contract.d.ts` pick up closed validators and an updated `storageHash`.
241+
242+
Use `--check` to list contracts that still need re-emitting without writing files:
243+
244+
```bash
245+
pnpm exec tsx ./re-emit-closed-mongo-contracts.ts --check
246+
```
247+
248+
### Apply the validator migration
249+
250+
Re-emitting changes the contract's `$jsonSchema` shape. The planner classifies the open→closed validator tightening as **`destructive`** — MongoDB replaces collection validators, and documents with fields outside the closed schema will fail validation after apply.
251+
252+
Plan first to review the ops:
253+
254+
```bash
255+
pnpm prisma-next db update --plan-only
256+
# or: prisma-next migration plan
257+
```
258+
259+
Then apply with explicit confirmation:
260+
261+
```bash
262+
pnpm prisma-next db update -y
263+
```
264+
265+
Wire `-y` into your deploy pipeline only after you have reviewed the plan in a lower environment. Without `-y`, apply refuses when destructive ops are present.
266+
267+
### Validation
268+
269+
After re-emitting and applying, run `pnpm typecheck && pnpm test` (or your application's equivalent). Contract hash/type drift shows up immediately in TypeScript imports of `StorageHash`. At runtime, confirm `db verify` passes against the updated validators.
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/**
2+
* Re-emits every Mongo contract in the consumer project so emitted
3+
* `contract.json` / `contract.d.ts` pick up closed `$jsonSchema`
4+
* validators (`additionalProperties: false` at every level, including
5+
* polymorphic `oneOf` branches).
6+
*
7+
* Background: starting at the 0.12 release, MongoDB emits closed
8+
* `$jsonSchema` validators by default. The contract canonicalizer also
9+
* preserves `additionalProperties` through emission, so re-emitting is
10+
* the consumer-facing migration for on-disk contract artefacts. A
11+
* non-variant Mongo model must resolve to an `objectId` `_id`; otherwise
12+
* interpret fails with `PSL_MONGO_ID_REQUIRED` — fix the PSL/TS source
13+
* before re-emitting.
14+
*
15+
* After re-emitting, apply the resulting open→closed validator migration
16+
* with `prisma-next db update -y` (or `pnpm db:update -y` if your
17+
* project wraps it). The planner classifies the validator tightening as
18+
* `destructive`; without `-y` the apply step refuses to run.
19+
*
20+
* Dispatch: walks the project root for directories that contain both
21+
* `prisma-next.config.ts` and a committed `contract.json` whose storage
22+
* tree includes `"kind": "mongo-database"`. In each match, runs
23+
* `pnpm emit` when a `package.json` scripts.emit entry exists, otherwise
24+
* `pnpm exec prisma-next contract emit`.
25+
*
26+
* Flags:
27+
* --check dry-run; lists directories that would be re-emitted and
28+
* exits 1 if any contract.json still lacks closed validators.
29+
*/
30+
import { execFile } from 'node:child_process';
31+
import { access, readdir, readFile } from 'node:fs/promises';
32+
import { join } from 'node:path';
33+
import { promisify } from 'node:util';
34+
35+
const execFileAsync = promisify(execFile);
36+
37+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
38+
39+
const dryRun = process.argv.includes('--check');
40+
const projectRoot = process.cwd();
41+
42+
async function pathExists(path: string): Promise<boolean> {
43+
try {
44+
await access(path);
45+
return true;
46+
} catch {
47+
return false;
48+
}
49+
}
50+
51+
async function findPrismaNextConfigDirs(root: string): Promise<string[]> {
52+
const out: string[] = [];
53+
54+
async function walk(dir: string): Promise<void> {
55+
let entries: Awaited<ReturnType<typeof readdir>>;
56+
try {
57+
entries = await readdir(dir, { withFileTypes: true });
58+
} catch {
59+
return;
60+
}
61+
for (const entry of entries) {
62+
if (entry.isDirectory()) {
63+
if (SKIP_DIRS.has(entry.name)) continue;
64+
await walk(join(dir, entry.name));
65+
} else if (entry.isFile() && entry.name === 'prisma-next.config.ts') {
66+
out.push(dir);
67+
}
68+
}
69+
}
70+
71+
await walk(root);
72+
return out.sort();
73+
}
74+
75+
function contractJsonCandidates(configDir: string): string[] {
76+
return [
77+
join(configDir, 'src', 'contract.json'),
78+
join(configDir, 'src', 'prisma', 'contract.json'),
79+
join(configDir, 'prisma', 'contract.json'),
80+
join(configDir, 'contract.json'),
81+
];
82+
}
83+
84+
async function resolveContractJson(configDir: string): Promise<string | null> {
85+
for (const candidate of contractJsonCandidates(configDir)) {
86+
if (await pathExists(candidate)) return candidate;
87+
}
88+
return null;
89+
}
90+
91+
async function isMongoContract(contractPath: string): Promise<boolean> {
92+
const raw = await readFile(contractPath, 'utf-8');
93+
return raw.includes('"kind": "mongo-database"') || raw.includes('"kind":"mongo-database"');
94+
}
95+
96+
function contractLooksClosed(raw: string): boolean {
97+
return (
98+
raw.includes('"additionalProperties": false') || raw.includes('"additionalProperties":false')
99+
);
100+
}
101+
102+
async function packageJsonHasEmitScript(configDir: string): Promise<boolean> {
103+
const pkgPath = join(configDir, 'package.json');
104+
if (!(await pathExists(pkgPath))) return false;
105+
const raw = await readFile(pkgPath, 'utf-8');
106+
try {
107+
const parsed = JSON.parse(raw) as { scripts?: Record<string, string> };
108+
return typeof parsed.scripts?.emit === 'string' && parsed.scripts.emit.length > 0;
109+
} catch {
110+
return false;
111+
}
112+
}
113+
114+
async function runEmit(configDir: string): Promise<void> {
115+
const hasEmitScript = await packageJsonHasEmitScript(configDir);
116+
const cmd = hasEmitScript ? 'pnpm' : 'pnpm';
117+
const args = hasEmitScript ? ['emit'] : ['exec', 'prisma-next', 'contract', 'emit'];
118+
await execFileAsync(cmd, args, { cwd: configDir, env: process.env });
119+
}
120+
121+
const configDirs = await findPrismaNextConfigDirs(projectRoot);
122+
const mongoDirs: Array<{ dir: string; contractPath: string }> = [];
123+
124+
for (const dir of configDirs) {
125+
const contractPath = await resolveContractJson(dir);
126+
if (contractPath === null) continue;
127+
if (!(await isMongoContract(contractPath))) continue;
128+
mongoDirs.push({ dir, contractPath });
129+
}
130+
131+
if (mongoDirs.length === 0) {
132+
console.error(`No Mongo contract directories found under ${projectRoot}.`);
133+
process.exit(1);
134+
}
135+
136+
let needsFix = 0;
137+
let alreadyClean = 0;
138+
139+
for (const { dir, contractPath } of mongoDirs) {
140+
const rel = dir.slice(projectRoot.length + 1) || '.';
141+
const raw = await readFile(contractPath, 'utf-8');
142+
if (contractLooksClosed(raw)) {
143+
alreadyClean += 1;
144+
console.log(`OK ${rel}`);
145+
continue;
146+
}
147+
needsFix += 1;
148+
if (dryRun) {
149+
console.log(`WOULD RE-EMIT ${rel}`);
150+
continue;
151+
}
152+
console.log(`EMIT ${rel}`);
153+
await runEmit(dir);
154+
}
155+
156+
console.log();
157+
console.log(
158+
`${mongoDirs.length} Mongo contract(s): ${needsFix} ${dryRun ? 'needing re-emit' : 're-emitted'}, ${alreadyClean} already closed.`,
159+
);
160+
161+
if (dryRun && needsFix > 0) process.exit(1);

0 commit comments

Comments
 (0)