upgrading/app/upgrades/0.12-to-0.13/re-emit-mti-variant-link-columns.ts
upgrading/app/upgrades/0.12-to-0.13/re-emit-mti-variant-link-columns.tsBrowse 76 files
1,940 tokens
7,838 bytes
Token encoding: o200k_base
Snapshot fac8604
← Back to SKILL.md
1/**2 * Re-emits every contract whose MTI variant tables (PSL `@@base(Parent, "tag")`3 * models that carry their own `@@map`) predate the base-PK link column.4 *5 * Starting at this release, a `@@base` variant stored in its own table6 * materialises a base-PK link column in storage: the variant table gains an7 * `id` column, a single-column primary key on it, and a cascading foreign key8 * referencing the base table's primary key. Before the change, the variant9 * table held only the variant-specific columns with no primary key.10 *11 * Detection: a contract is a candidate when its domain carries a model with a12 * `base` reference (an MTI variant) whose matching storage table has no13 * `primaryKey` — the pre-change shape. After re-emit the table gains its14 * `id` PK + cascading FK and the contract's `storageHash` changes.15 *16 * Dispatch: walks the project root for `prisma.config.ts` directories,17 * resolves each space's committed `contract.json`, and re-emits when a variant18 * table still lacks its link column. Uses the nearest ancestor `package.json`19 * `scripts.emit` when present; otherwise runs20 * `prisma-next contract emit --config <path>`.21 *22 * Flags:23 * --check dry-run; lists contract-spaces that still need re-emitting and24 * exits 1 if any remain.25 */26import { execFile } from 'node:child_process';27import { access, readdir, readFile } from 'node:fs/promises';28import { dirname, join } from 'node:path';29import { promisify } from 'node:util';30 31const execFileAsync = promisify(execFile);32 33const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);34 35const dryRun = process.argv.includes('--check');36const projectRoot = process.cwd();37 38async function pathExists(path: string): Promise<boolean> {39 try {40 await access(path);41 return true;42 } catch {43 return false;44 }45}46 47function isJsonObject(value: unknown): value is Record<string, unknown> {48 return typeof value === 'object' && value !== null && !Array.isArray(value);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 91/**92 * A contract needs the MTI link-column migration when any of its domain models93 * is an MTI variant (carries a `base` reference) whose matching storage table94 * has no `primaryKey` — the pre-change shape that lacks the link column.95 */96function contractNeedsMtiLinkColumns(raw: string): boolean {97 let parsed: unknown;98 try {99 parsed = JSON.parse(raw);100 } catch {101 return false;102 }103 if (!isJsonObject(parsed)) return false;104 105 const domain = parsed['domain'];106 const storage = parsed['storage'];107 if (!isJsonObject(domain) || !isJsonObject(storage)) return false;108 109 const domainNamespaces = domain['namespaces'];110 const storageNamespaces = storage['namespaces'];111 if (!isJsonObject(domainNamespaces) || !isJsonObject(storageNamespaces)) return false;112 113 for (const [nsKey, ns] of Object.entries(domainNamespaces)) {114 if (!isJsonObject(ns)) continue;115 const models = ns['models'];116 if (!isJsonObject(models)) continue;117 for (const model of Object.values(models)) {118 if (!isJsonObject(model)) continue;119 if (!isJsonObject(model['base'])) continue;120 const variantStorage = model['storage'];121 if (!isJsonObject(variantStorage)) continue;122 const tableName = variantStorage['table'];123 // The variant table's namespace defaults to the model's enclosing domain124 // namespace when `storage.namespace` is absent.125 const namespaceId =126 typeof variantStorage['namespace'] === 'string' ? variantStorage['namespace'] : nsKey;127 if (typeof tableName !== 'string') continue;128 const storageNs = storageNamespaces[namespaceId];129 if (!isJsonObject(storageNs)) continue;130 const tables = storageNs['tables'];131 if (!isJsonObject(tables)) continue;132 const table = tables[tableName];133 if (!isJsonObject(table)) continue;134 if (table['primaryKey'] === undefined || table['primaryKey'] === null) {135 return true;136 }137 }138 }139 return false;140}141 142async function packageJsonHasScript(dir: string, name: string): Promise<boolean> {143 const pkgPath = join(dir, 'package.json');144 if (!(await pathExists(pkgPath))) return false;145 const raw = await readFile(pkgPath, 'utf-8');146 try {147 const parsed: unknown = JSON.parse(raw);148 if (!isJsonObject(parsed)) return false;149 const scripts = parsed['scripts'];150 if (!isJsonObject(scripts)) return false;151 const value = scripts[name];152 return typeof value === 'string' && value.length > 0;153 } catch {154 return false;155 }156}157 158async function resolveEmitInvocation(configDir: string): Promise<{159 readonly cwd: string;160 readonly args: string[];161 readonly key: string;162}> {163 let dir = configDir;164 while (dir.startsWith(projectRoot)) {165 if (await packageJsonHasScript(dir, 'emit')) {166 return { cwd: dir, args: ['emit'], key: `script:${dir}` };167 }168 const parent = dirname(dir);169 if (parent === dir) break;170 dir = parent;171 }172 const configPath = join(configDir, 'prisma.config.ts');173 return {174 cwd: projectRoot,175 args: ['exec', 'prisma-next', 'contract', 'emit', '--config', configPath],176 key: `config:${configPath}`,177 };178}179 180async function runEmit(configDir: string): Promise<void> {181 const { cwd, args } = await resolveEmitInvocation(configDir);182 await execFileAsync('pnpm', args, { cwd, env: process.env });183}184 185const configDirs = await findPrismaNextConfigDirs(projectRoot);186const emitKeys = new Set<string>();187const targets: Array<{ configDir: string; contractPath: string }> = [];188 189for (const configDir of configDirs) {190 if (await packageJsonHasScript(configDir, 'build:contract-space')) continue;191 const contractPath = await resolveContractJson(configDir);192 if (contractPath === null) continue;193 const raw = await readFile(contractPath, 'utf-8');194 if (!contractNeedsMtiLinkColumns(raw)) continue;195 const { key } = await resolveEmitInvocation(configDir);196 if (emitKeys.has(key)) continue;197 emitKeys.add(key);198 targets.push({ configDir, contractPath });199}200 201if (targets.length === 0) {202 console.error(`No MTI variant link-column migration candidates under ${projectRoot}.`);203 process.exit(dryRun ? 0 : 1);204}205 206let needsFix = 0;207 208for (const { configDir, contractPath } of targets) {209 const rel = configDir.slice(projectRoot.length + 1) || '.';210 const raw = await readFile(contractPath, 'utf-8');211 if (!contractNeedsMtiLinkColumns(raw)) {212 console.log(`OK ${rel}`);213 continue;214 }215 needsFix += 1;216 if (dryRun) {217 console.log(`WOULD RE-EMIT ${rel}`);218 continue;219 }220 console.log(`EMIT ${rel}`);221 await runEmit(configDir);222}223 224console.log();225console.log(226 `${targets.length} contract-space(s): ${needsFix} ${dryRun ? 'needing re-emit' : 're-emitted'}.`,227);228 229if (dryRun && needsFix > 0) process.exit(1);230