upgrading/app/upgrades/0.11-to-0.12/re-emit-closed-mongo-contracts.ts
upgrading/app/upgrades/0.11-to-0.12/re-emit-closed-mongo-contracts.tsBrowse 76 files
1,729 tokens
7,014 bytes
Token encoding: o200k_base
Snapshot fac8604
← Back to SKILL.md
1/**2 * Re-emits every Mongo contract in the consumer project so emitted3 * `contract.json` / `contract.d.ts` pick up closed `$jsonSchema`4 * validators (`additionalProperties: false` at every level, including5 * polymorphic `oneOf` branches).6 *7 * Background: starting at the 0.12 release, MongoDB emits closed8 * `$jsonSchema` validators by default. The contract canonicalizer also9 * preserves `additionalProperties` through emission, so re-emitting is10 * the consumer-facing migration for on-disk contract artefacts. A11 * non-variant Mongo model must resolve to an `objectId` `_id`; otherwise12 * interpret fails with `PSL_MONGO_ID_REQUIRED` — fix the PSL/TS source13 * before re-emitting.14 *15 * After re-emitting, apply the resulting open→closed validator migration16 * with `prisma-next db update -y` (or `pnpm db:update -y` if your17 * project wraps it). The planner classifies the validator tightening as18 * `destructive`; without `-y` the apply step refuses to run.19 *20 * Dispatch: walks the project root for directories that contain both21 * `prisma.config.ts` and a committed `contract.json` whose storage22 * tree includes `"kind": "mongo-database"`. In each match, runs23 * `pnpm emit` when a `package.json` scripts.emit entry exists, otherwise24 * `pnpm exec prisma-next contract emit`.25 *26 * Flags:27 * --check dry-run; lists directories that would be re-emitted and28 * exits 1 if any contract.json still lacks closed validators.29 */30import { execFile } from 'node:child_process';31import { access, readdir, readFile } from 'node:fs/promises';32import { join } from 'node:path';33import { promisify } from 'node:util';34 35const execFileAsync = promisify(execFile);36 37const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);38 39const dryRun = process.argv.includes('--check');40const projectRoot = process.cwd();41 42async function pathExists(path: string): Promise<boolean> {43 try {44 await access(path);45 return true;46 } catch {47 return false;48 }49}50 51async 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.config.ts') {66 out.push(dir);67 }68 }69 }70 71 await walk(root);72 return out.sort();73}74 75function 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 84async 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 91async 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/**97 * A contract is in the closed-validator (post-0.12) format when every object98 * schema that declares a `properties` map also carries `additionalProperties:99 * false`. That covers collection validators, nested value objects, and each100 * polymorphic `oneOf` branch — all of which expose `properties`.101 *102 * The one exception is a polymorphic schema's top-level node: it carries both103 * base `properties` and a `oneOf`, and is deliberately left open because104 * closure is enforced on each branch (a document must match exactly one closed105 * branch). Such a node is exempt from the `additionalProperties: false`106 * requirement, but its branches are still walked and checked.107 *108 * A substring scan is unsafe here: a single closed branch would mask a sibling109 * that still needs re-emitting.110 */111/** Narrows an arbitrary JSON-parsed value to a plain object (non-null, non-array). */112function isJsonObject(value: unknown): value is Record<string, unknown> {113 return typeof value === 'object' && value !== null && !Array.isArray(value);114}115 116function contractLooksClosed(raw: string): boolean {117 let parsed: unknown;118 try {119 parsed = JSON.parse(raw);120 } catch {121 return false;122 }123 124 function isClosed(node: unknown): boolean {125 if (Array.isArray(node)) return node.every(isClosed);126 if (!isJsonObject(node)) return true;127 128 const hasProperties = isJsonObject(node['properties']);129 const isPolymorphicTopLevel = Array.isArray(node['oneOf']);130 if (hasProperties && !isPolymorphicTopLevel && node['additionalProperties'] !== false) {131 return false;132 }133 134 return Object.values(node).every(isClosed);135 }136 137 return isClosed(parsed);138}139 140async function packageJsonHasEmitScript(configDir: string): Promise<boolean> {141 const pkgPath = join(configDir, 'package.json');142 if (!(await pathExists(pkgPath))) return false;143 const raw = await readFile(pkgPath, 'utf-8');144 try {145 const parsed: unknown = JSON.parse(raw);146 if (!isJsonObject(parsed)) return false;147 const scripts = parsed['scripts'];148 if (!isJsonObject(scripts)) return false;149 return typeof scripts['emit'] === 'string' && scripts['emit'].length > 0;150 } catch {151 return false;152 }153}154 155async function runEmit(configDir: string): Promise<void> {156 const hasEmitScript = await packageJsonHasEmitScript(configDir);157 const cmd = hasEmitScript ? 'pnpm' : 'pnpm';158 const args = hasEmitScript ? ['emit'] : ['exec', 'prisma-next', 'contract', 'emit'];159 await execFileAsync(cmd, args, { cwd: configDir, env: process.env });160}161 162const configDirs = await findPrismaNextConfigDirs(projectRoot);163const mongoDirs: Array<{ dir: string; contractPath: string }> = [];164 165for (const dir of configDirs) {166 const contractPath = await resolveContractJson(dir);167 if (contractPath === null) continue;168 if (!(await isMongoContract(contractPath))) continue;169 mongoDirs.push({ dir, contractPath });170}171 172if (mongoDirs.length === 0) {173 console.error(`No Mongo contract directories found under ${projectRoot}.`);174 process.exit(1);175}176 177let needsFix = 0;178let alreadyClean = 0;179 180for (const { dir, contractPath } of mongoDirs) {181 const rel = dir.slice(projectRoot.length + 1) || '.';182 const raw = await readFile(contractPath, 'utf-8');183 if (contractLooksClosed(raw)) {184 alreadyClean += 1;185 console.log(`OK ${rel}`);186 continue;187 }188 needsFix += 1;189 if (dryRun) {190 console.log(`WOULD RE-EMIT ${rel}`);191 continue;192 }193 console.log(`EMIT ${rel}`);194 await runEmit(dir);195}196 197console.log();198console.log(199 `${mongoDirs.length} Mongo contract(s): ${needsFix} ${dryRun ? 'needing re-emit' : 're-emitted'}, ${alreadyClean} already closed.`,200);201 202if (dryRun && needsFix > 0) process.exit(1);203