Skip to content

Instantly share code, notes, and snippets.

@nicksteffens
Created March 30, 2026 19:46
Show Gist options
  • Select an option

  • Save nicksteffens/221562d6fa44a301cb33c776091def89 to your computer and use it in GitHub Desktop.

Select an option

Save nicksteffens/221562d6fa44a301cb33c776091def89 to your computer and use it in GitHub Desktop.
Codemods for migrating MirageJS models and factories to miragejs-orm (sc-194195)
/**
* 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();
/**
* 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