Created
March 30, 2026 19:46
-
-
Save nicksteffens/221562d6fa44a301cb33c776091def89 to your computer and use it in GitHub Desktop.
Codemods for migrating MirageJS models and factories to miragejs-orm (sc-194195)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Codemod: Migrate Mirage factories to miragejs-orm | |
| * | |
| * Appends typed miragejs-orm factory definitions alongside existing Mirage factories. | |
| * Simple factories (no afterCreate/traits) get fully converted. | |
| * Complex factories (afterCreate/traits) get a skeleton with TODOs. | |
| * | |
| * Usage: npx tsx scripts/codemod-mirage-factories.ts [--dry-run] [--filter canvas] | |
| */ | |
| import * as fs from 'fs'; | |
| import * as path from 'path'; | |
| import { fileURLToPath } from 'url'; | |
| const __filename = fileURLToPath(import.meta.url); | |
| const __dirname = path.dirname(__filename); | |
| const FACTORIES_DIR = path.resolve(__dirname, '../src/mirage/factories'); | |
| const MODELS_DIR = path.resolve(__dirname, '../src/mirage/models'); | |
| const DRY_RUN = process.argv.includes('--dry-run'); | |
| const FILTER = process.argv.find((a, i) => process.argv[i - 1] === '--filter') || ''; | |
| function kebabToCamel(str: string): string { | |
| return str.replace(/[-_]([a-z])/g, (_, c) => c.toUpperCase()); | |
| } | |
| function kebabToPascal(str: string): string { | |
| const camel = kebabToCamel(str); | |
| return camel.charAt(0).toUpperCase() + camel.slice(1); | |
| } | |
| interface FactoryAttr { | |
| name: string; | |
| isFunction: boolean; | |
| rawValue: string; | |
| } | |
| function hasAfterCreateOrTraits(content: string): boolean { | |
| return /afterCreate\s*\(/.test(content) || /:\s*trait\s*\(/.test(content); | |
| } | |
| function parseSimpleAttrs(content: string): FactoryAttr[] { | |
| const attrs: FactoryAttr[] = []; | |
| const extendMatch = content.match(/Factory\.extend\(\{([\s\S]*)\}\)/); | |
| if (!extendMatch) return attrs; | |
| const body = extendMatch[1]; | |
| const lines = body.split('\n'); | |
| let braceDepth = 0; | |
| let skipUntilBraceClose = false; | |
| for (const line of lines) { | |
| const trimmed = line.trim(); | |
| if (!trimmed || trimmed.startsWith('//')) continue; | |
| // Track nested braces to skip complex values | |
| if (skipUntilBraceClose) { | |
| braceDepth += (trimmed.match(/\{/g) || []).length; | |
| braceDepth -= (trimmed.match(/\}/g) || []).length; | |
| if (braceDepth <= 0) { | |
| skipUntilBraceClose = false; | |
| braceDepth = 0; | |
| } | |
| continue; | |
| } | |
| // Skip afterCreate, traits, _ | |
| if (/^(afterCreate|_:|withPublish|withBlock)/.test(trimmed)) { | |
| const opens = (trimmed.match(/\{/g) || []).length; | |
| const closes = (trimmed.match(/\}/g) || []).length; | |
| braceDepth = opens - closes; | |
| if (braceDepth > 0) skipUntilBraceClose = true; | |
| continue; | |
| } | |
| // Function form: name(i) { return `value ${i}`; } | |
| const funcMatch = trimmed.match(/^(\w+)\s*\([^)]*\)\s*\{(.+)\},?\s*$/); | |
| if (funcMatch) { | |
| const [, name, body] = funcMatch; | |
| if (name === 'afterCreate' || name === '_') continue; | |
| const returnMatch = body.match(/return\s+(.+?);?\s*$/); | |
| if (returnMatch) { | |
| attrs.push({ name, isFunction: true, rawValue: returnMatch[1] }); | |
| } | |
| continue; | |
| } | |
| // Multi-line function form: name(i) { | |
| const funcStartMatch = trimmed.match(/^(\w+)\s*\([^)]*\)\s*\{$/); | |
| if (funcStartMatch) { | |
| const name = funcStartMatch[1]; | |
| if (name === 'afterCreate' || name === '_') { | |
| braceDepth = 1; | |
| skipUntilBraceClose = true; | |
| continue; | |
| } | |
| // Look ahead for return statement in next lines | |
| const lineIdx = lines.indexOf(line); | |
| let returnVal: string | null = null; | |
| for (let j = lineIdx + 1; j < lines.length && j < lineIdx + 5; j++) { | |
| const nextTrimmed = lines[j].trim(); | |
| const retMatch = nextTrimmed.match(/^return\s+(.+?);?\s*$/); | |
| if (retMatch) { | |
| returnVal = retMatch[1]; | |
| break; | |
| } | |
| } | |
| attrs.push({ name, isFunction: true, rawValue: returnVal || `'TODO'` }); | |
| braceDepth = 1; | |
| skipUntilBraceClose = true; | |
| continue; | |
| } | |
| // Arrow function form: name: () => value, | |
| const arrowMatch = trimmed.match(/^(\w+):\s*\(\)\s*=>\s*(.+?),?\s*$/); | |
| if (arrowMatch) { | |
| const [, name, value] = arrowMatch; | |
| attrs.push({ name, isFunction: true, rawValue: value }); | |
| continue; | |
| } | |
| // Static value: name: value, | |
| const staticMatch = trimmed.match(/^(\w+):\s*(.+?),?\s*$/); | |
| if (staticMatch) { | |
| const [, name, value] = staticMatch; | |
| if (name === 'afterCreate' || name === '_') continue; | |
| // Check if value opens an object/array that doesn't close | |
| const opens = (value.match(/[\{\[]/g) || []).length; | |
| const closes = (value.match(/[\}\]]/g) || []).length; | |
| if (opens > closes) { | |
| braceDepth = opens - closes; | |
| skipUntilBraceClose = true; | |
| attrs.push({ name, isFunction: false, rawValue: value }); | |
| continue; | |
| } | |
| attrs.push({ name, isFunction: false, rawValue: value }); | |
| } | |
| } | |
| return attrs; | |
| } | |
| function convertAttrValue(attr: FactoryAttr): string { | |
| if (attr.isFunction) { | |
| // Convert name(i) { return `value ${i}` } → (id) => `value ${id}` | |
| let val = attr.rawValue.replace(/\bi\b/g, 'id'); | |
| // Template literals with ${i+1} etc | |
| val = val.replace(/\$\{i\s*\+\s*1\}/g, '${id}'); | |
| val = val.replace(/\$\{i\}/g, '${id}'); | |
| return `(id) => ${val}`; | |
| } | |
| return attr.rawValue; | |
| } | |
| function generateFactoryFile( | |
| fileName: string, | |
| originalContent: string, | |
| namespace: string, | |
| ): string { | |
| const baseName = kebabToCamel(fileName.replace('.ts', '').replace(/_/g, '-')); | |
| const pascalName = kebabToPascal(fileName.replace('.ts', '').replace(/_/g, '-')); | |
| const modelImportName = `typed${pascalName}Model`; | |
| const isComplex = hasAfterCreateOrTraits(originalContent); | |
| const attrs = parseSimpleAttrs(originalContent); | |
| // Filter out 'id' since miragejs-orm handles it | |
| const nonIdAttrs = attrs.filter((a) => a.name !== 'id'); | |
| const attrLines = nonIdAttrs.map((attr) => { | |
| const val = convertAttrValue(attr); | |
| return ` ${attr.name}: ${val},`; | |
| }); | |
| const modelRelPath = `../models/${namespace}/${fileName.replace('.ts', '')}`; | |
| let output = `${originalContent.trimEnd()} | |
| // --- miragejs-orm typed factory --- | |
| import { factory } from 'miragejs-orm'; | |
| import { ${modelImportName} } from '${modelRelPath}'; | |
| export const typed${pascalName}Factory = factory() | |
| .model(${modelImportName}) | |
| .attrs({ | |
| ${attrLines.join('\n')} | |
| })`; | |
| if (isComplex) { | |
| output += ` | |
| // TODO: migrate afterCreate hooks and traits | |
| // see miragejs-orm docs: .traits({ traitName: { afterCreate(model, schema) { ... } } })`; | |
| } | |
| output += ` | |
| .build(); | |
| `; | |
| return output; | |
| } | |
| function main() { | |
| const namespaces = ['canvas', 'v3', 'v4']; | |
| for (const ns of namespaces) { | |
| if (FILTER && ns !== FILTER) continue; | |
| const dir = path.join(FACTORIES_DIR, ns); | |
| if (!fs.existsSync(dir)) continue; | |
| const files = fs.readdirSync(dir).filter((f) => f.endsWith('.ts') && f !== 'index.ts'); | |
| for (const file of files) { | |
| const filePath = path.join(dir, file); | |
| const content = fs.readFileSync(filePath, 'utf-8'); | |
| const output = generateFactoryFile(file, content, ns); | |
| console.log(`\n${'='.repeat(60)}`); | |
| console.log(`📦 ${ns}/${file}`); | |
| console.log('='.repeat(60)); | |
| if (DRY_RUN) { | |
| console.log(output); | |
| } else { | |
| fs.writeFileSync(filePath, output); | |
| console.log(` ✅ Written`); | |
| } | |
| } | |
| } | |
| console.log(`\nDone! ${DRY_RUN ? '(dry run — no files written)' : ''}`); | |
| } | |
| main(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Codemod: Migrate Mirage models to miragejs-orm | |
| * | |
| * Reads each model file, extracts relationships (hasMany, belongsTo). | |
| * Reads the matching factory file, extracts attribute names and infers types. | |
| * Generates a new model file that keeps the old Mirage default export | |
| * (so config.ts doesn't break) and adds a typed miragejs-orm named export. | |
| * | |
| * Usage: npx tsx scripts/codemod-mirage-models.ts [--dry-run] [--filter canvas] | |
| */ | |
| import * as fs from 'fs'; | |
| import * as path from 'path'; | |
| import { fileURLToPath } from 'url'; | |
| const __filename = fileURLToPath(import.meta.url); | |
| const __dirname = path.dirname(__filename); | |
| const MODELS_DIR = path.resolve(__dirname, '../src/mirage/models'); | |
| const FACTORIES_DIR = path.resolve(__dirname, '../src/mirage/factories'); | |
| const DRY_RUN = process.argv.includes('--dry-run'); | |
| const FILTER = process.argv.find((a, i) => process.argv[i - 1] === '--filter') || ''; | |
| interface Relationship { | |
| name: string; | |
| type: 'hasMany' | 'belongsTo'; | |
| target?: string; | |
| options?: string; | |
| } | |
| interface FactoryAttr { | |
| name: string; | |
| inferredType: string; | |
| defaultValue: string; | |
| } | |
| function camelToPascal(str: string): string { | |
| return str.charAt(0).toUpperCase() + str.slice(1); | |
| } | |
| function kebabToCamel(str: string): string { | |
| return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); | |
| } | |
| function kebabToPascal(str: string): string { | |
| return camelToPascal(kebabToCamel(str)); | |
| } | |
| function pluralize(str: string): string { | |
| if (str.endsWith('s')) return str + 'es'; | |
| if (str.endsWith('y') && !str.endsWith('ay') && !str.endsWith('ey') && !str.endsWith('oy') && !str.endsWith('uy')) { | |
| return str.slice(0, -1) + 'ies'; | |
| } | |
| return str + 's'; | |
| } | |
| function parseRelationships(content: string): Relationship[] { | |
| const rels: Relationship[] = []; | |
| // Match: propertyName: hasMany('target', { options }) or hasMany() | |
| const relRegex = /(\w+):\s*(hasMany|belongsTo)\(([^)]*)\)/g; | |
| let match; | |
| while ((match = relRegex.exec(content)) !== null) { | |
| const [, name, type, args] = match; | |
| const argParts = args.split(',').map((s) => s.trim()); | |
| const target = argParts[0]?.replace(/['"]/g, '') || undefined; | |
| const options = argParts.slice(1).join(',').trim() || undefined; | |
| rels.push({ name, type: type as 'hasMany' | 'belongsTo', target, options }); | |
| } | |
| return rels; | |
| } | |
| function inferTypeFromValue(value: string): string { | |
| value = value.trim(); | |
| // Remove trailing comma, semicolons, closing braces from capture | |
| value = value.replace(/[,;}\s]+$/, '').trim(); | |
| if (value === 'false' || value === 'true') return 'boolean'; | |
| if (value.startsWith("'") || value.startsWith('"') || value.startsWith('`')) return 'string'; | |
| // Template literals with expressions | |
| if (value.includes('`') && value.includes('${')) return 'string'; | |
| if (/^\d+$/.test(value)) return 'number'; | |
| if (value === 'null') return 'unknown | null'; | |
| if (value.startsWith('[]') || value.startsWith('Array')) return 'unknown[]'; | |
| if (value.startsWith('{}')) return 'Record<string, unknown>'; | |
| if (value.includes('as Record<string, boolean>')) return 'Record<string, boolean>'; | |
| if (value.includes('as Record<')) { | |
| const asMatch = value.match(/as\s+(Record<[^>]+>)/); | |
| if (asMatch) return asMatch[1]; | |
| } | |
| if (value.includes('as string[]')) return 'string[]'; | |
| if (value.includes('new Date')) return 'string'; | |
| // Function returning a value — infer from the return | |
| if (value.startsWith('() =>')) { | |
| const retVal = value.replace(/^\(\)\s*=>\s*/, ''); | |
| return inferTypeFromValue(retVal); | |
| } | |
| // Object literal with known shape — just use Record | |
| if (value.startsWith('{') && value.includes(':')) { | |
| // Try to detect specific shapes | |
| if (value.includes('label') && value.includes('value')) return '{ label: string; value: string; authorizedActions: string[] }'; | |
| return 'Record<string, unknown>'; | |
| } | |
| // Numeric expression like i + 1 | |
| if (/^\d|^i\s*[+\-*]/.test(value)) return 'number'; | |
| return 'unknown'; | |
| } | |
| function parseFactoryAttrs(content: string): FactoryAttr[] { | |
| const attrs: FactoryAttr[] = []; | |
| // Remove comments | |
| const cleaned = content.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, ''); | |
| // Find the Factory.extend({ ... }) body | |
| const extendMatch = cleaned.match(/Factory\.extend\(\{([\s\S]*)\}\)/); | |
| if (!extendMatch) return attrs; | |
| const body = extendMatch[1]; | |
| // Match property definitions - both function and value forms | |
| // Skip afterCreate, traits, and _ (separator) | |
| const lines = body.split('\n'); | |
| let braceDepth = 0; | |
| let currentAttr: string | null = null; | |
| let currentValue = ''; | |
| for (const line of lines) { | |
| const trimmed = line.trim(); | |
| // Skip empty lines | |
| if (!trimmed) continue; | |
| // Track brace depth for multi-line values | |
| if (currentAttr && braceDepth > 0) { | |
| braceDepth += (trimmed.match(/\{/g) || []).length; | |
| braceDepth -= (trimmed.match(/\}/g) || []).length; | |
| currentValue += ' ' + trimmed; | |
| if (braceDepth === 0) { | |
| // Skip afterCreate, trait definitions, and the _ separator | |
| if (!['afterCreate', '_'].includes(currentAttr) && !currentAttr.startsWith('with')) { | |
| attrs.push({ | |
| name: currentAttr, | |
| inferredType: inferTypeFromValue(currentValue), | |
| defaultValue: currentValue.trim(), | |
| }); | |
| } | |
| currentAttr = null; | |
| currentValue = ''; | |
| } | |
| continue; | |
| } | |
| // Match: attrName(i) { return value; } (function form) | |
| const funcMatch = trimmed.match(/^(\w+)\s*\([^)]*\)\s*\{/); | |
| if (funcMatch) { | |
| const name = funcMatch[1]; | |
| if (['afterCreate', '_'].includes(name) || name.startsWith('with')) { | |
| // Skip - track braces to skip body | |
| braceDepth = (trimmed.match(/\{/g) || []).length - (trimmed.match(/\}/g) || []).length; | |
| if (braceDepth === 0) continue; | |
| currentAttr = null; | |
| continue; | |
| } | |
| // Single-line function | |
| const returnMatch = trimmed.match(/return\s+(.+?);?\s*\}?,?\s*$/); | |
| if (returnMatch) { | |
| attrs.push({ | |
| name, | |
| inferredType: inferTypeFromValue(returnMatch[1]), | |
| defaultValue: returnMatch[1], | |
| }); | |
| } else { | |
| // Multi-line function - check next line for return | |
| currentAttr = name; | |
| braceDepth = (trimmed.match(/\{/g) || []).length - (trimmed.match(/\}/g) || []).length; | |
| currentValue = trimmed; | |
| } | |
| continue; | |
| } | |
| // Match: attrName: value, (static value form) | |
| const staticMatch = trimmed.match(/^(\w+):\s*(.+?),?\s*$/); | |
| if (staticMatch) { | |
| const [, name, value] = staticMatch; | |
| if (['afterCreate', '_'].includes(name) || name.startsWith('with')) continue; | |
| // Check if value opens a brace/bracket that doesn't close | |
| const openBraces = (value.match(/\{/g) || []).length - (value.match(/\}/g) || []).length; | |
| if (openBraces > 0) { | |
| currentAttr = name; | |
| braceDepth = openBraces; | |
| currentValue = value; | |
| continue; | |
| } | |
| attrs.push({ | |
| name, | |
| inferredType: inferTypeFromValue(value), | |
| defaultValue: value, | |
| }); | |
| continue; | |
| } | |
| // Multi-line return statement | |
| if (currentAttr && trimmed.startsWith('return ')) { | |
| const retVal = trimmed.replace(/^return\s+/, '').replace(/;?\s*$/, ''); | |
| attrs.push({ | |
| name: currentAttr, | |
| inferredType: inferTypeFromValue(retVal), | |
| defaultValue: retVal, | |
| }); | |
| } | |
| } | |
| return attrs; | |
| } | |
| function generateModelFile( | |
| fileName: string, | |
| originalContent: string, | |
| relationships: Relationship[], | |
| factoryAttrs: FactoryAttr[], | |
| ): string { | |
| const baseName = kebabToCamel(fileName.replace('.ts', '').replace(/_/g, '-')); | |
| const pascalName = kebabToPascal(fileName.replace('.ts', '').replace(/_/g, '-')); | |
| const collectionName = pluralize(baseName); | |
| // Build attrs interface from factory | |
| const attrLines: string[] = [ | |
| ' id: string;', | |
| ]; | |
| for (const attr of factoryAttrs) { | |
| if (attr.name === 'id') continue; | |
| let type = attr.inferredType; | |
| // Clean up unknowns | |
| if (type === 'unknown' || type === 'null') type = 'unknown'; | |
| attrLines.push(` ${attr.name}: ${type};`); | |
| } | |
| // Add foreign keys from belongsTo relationships | |
| for (const rel of relationships) { | |
| if (rel.type === 'belongsTo') { | |
| const fkName = `${rel.name}Id`; | |
| if (!factoryAttrs.find((a) => a.name === fkName)) { | |
| attrLines.push(` ${fkName}?: string;`); | |
| } | |
| } | |
| } | |
| // Build relationship comments for reference | |
| const relComments = relationships.map((r) => { | |
| const targetStr = r.target ? `'${r.target}'` : `'${r.name}'`; | |
| const optStr = r.options ? `, ${r.options}` : ''; | |
| return `// ${r.name}: ${r.type}(${targetStr}${optStr})`; | |
| }); | |
| const output = `${originalContent} | |
| // --- miragejs-orm typed model --- | |
| import { model } from 'miragejs-orm'; | |
| export interface ${pascalName}Attrs { | |
| ${attrLines.join('\n')} | |
| } | |
| ${relComments.length > 0 ? `// Relationships (to be wired in schema.ts):\n${relComments.join('\n')}\n` : ''}export const typed${pascalName}Model = model() | |
| .name('${baseName}') | |
| .collection('${collectionName}') | |
| .attrs<${pascalName}Attrs>() | |
| .build(); | |
| `; | |
| return output; | |
| } | |
| function processFile(modelPath: string): { file: string; output: string } | null { | |
| const content = fs.readFileSync(modelPath, 'utf-8'); | |
| const fileName = path.basename(modelPath); | |
| const namespace = path.basename(path.dirname(modelPath)); // canvas, v3, v4 | |
| // Find matching factory | |
| const factoryPath = modelPath.replace('/models/', '/factories/'); | |
| let factoryAttrs: FactoryAttr[] = []; | |
| if (fs.existsSync(factoryPath)) { | |
| const factoryContent = fs.readFileSync(factoryPath, 'utf-8'); | |
| factoryAttrs = parseFactoryAttrs(factoryContent); | |
| } | |
| const relationships = parseRelationships(content); | |
| const output = generateModelFile(fileName, content.trimEnd(), relationships, factoryAttrs); | |
| return { file: modelPath, output }; | |
| } | |
| // Main | |
| function main() { | |
| const namespaces = ['canvas', 'v3', 'v4']; | |
| for (const ns of namespaces) { | |
| if (FILTER && ns !== FILTER) continue; | |
| const dir = path.join(MODELS_DIR, ns); | |
| if (!fs.existsSync(dir)) continue; | |
| const files = fs.readdirSync(dir).filter((f) => f.endsWith('.ts') && f !== 'index.ts'); | |
| for (const file of files) { | |
| const filePath = path.join(dir, file); | |
| const result = processFile(filePath); | |
| if (!result) continue; | |
| console.log(`\n${'='.repeat(60)}`); | |
| console.log(`📦 ${ns}/${file}`); | |
| console.log('='.repeat(60)); | |
| if (DRY_RUN) { | |
| console.log(result.output); | |
| } else { | |
| fs.writeFileSync(filePath, result.output); | |
| console.log(` ✅ Written`); | |
| } | |
| } | |
| } | |
| console.log(`\nDone! ${DRY_RUN ? '(dry run — no files written)' : ''}`); | |
| } | |
| main(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment