import path from 'node:path'; import { existsSync, readFileSync } from 'node:fs'; import { findFiles } from '../../../lib/fs.js'; import { detectDbContexts, findStartupProject, hasDotnetProject } from '../lib/detect-dbcontexts.js'; import type { DbContextInfo } from '../lib/detect-dbcontexts.js'; import { parseCsprojVersion } from '../lib/parse-csproj-version.js'; import { runDotnet, dotnetAvailable, dotnetEfAvailable, buildMigrationsAddArgs, migrationsOutputDir } from '../lib/ef-runner.js'; import { buildMigrationName } from '../lib/migration-name.js'; import { getCurrentBranch } from '../../../lib/git.js'; import { decideMigrationAction, hasDestructiveUp } from '../lib/migration-policy.js'; import { ensureSqlObjectsInMigration, previewSqlObjectsForMigration } from '../../../lib/sql-objects.js'; import type { CreateSpec, CreateResult, CreateAssemblyResult } from './types.js'; /** Extract the Up() body from a generated migration .cs so the destructive scan never * sees the Down() inverse (whose ops are legitimately the opposite). Heuristic but * robust: Up() always precedes Down() in an EF-generated migration. */ function extractUpBody(csContent: string): string { const up = csContent.indexOf('void Up('); if (up === -1) return ''; const down = csContent.indexOf('void Down(', up); return csContent.slice(up, down > up ? down : undefined); } function capitalize(s: string): string { if (!s) return s; if (s.toLowerCase() === 'sqlserver') return 'SqlServer'; return s.charAt(0).toUpperCase() + s.slice(1); } async function countExistingMigrations(migrationsDir: string): Promise { const files = await findFiles('*.cs', { cwd: migrationsDir }); return files.filter( (f) => !f.endsWith('ModelSnapshot.cs') && !f.endsWith('.Designer.cs') && path.dirname(f) === migrationsDir, ).length; } /** * Bootstrap fallback for the FIRST migration. `detectDbContexts` discovers * contexts from existing *ModelSnapshot.cs files — a project with zero * migrations has no snapshot yet, so the requested context reads as "not * found". Here we discover it the only other deterministic way: the READ-ONLY * `dotnet ef dbcontext list` (allowed by ef-guard), then synthesize the * MigrationAssembly pointing at the EF-Design project's conventional * `Persistence/Migrations` output dir. Only used when snapshot detection misses * — projects that already have migrations never reach this path. */ async function bootstrapFirstMigrationContext( cwd: string, contextName: string, startupProject: string | undefined, ): Promise { // Need a runnable project (the web/host) to enumerate the registered contexts. if (!startupProject) return null; const csprojs = await findFiles('**/*.csproj', { cwd }); // Probe candidates, in order. The historical single probe pointed BOTH // --project and --startup-project at the startup (the Api): EF only scans // those two assemblies, so ExtensionsDbContext — whose design-time factory // lives in Infrastructure — was INVISIBLE and the CLI answered // "Context not found. Detected: none" on every first migration of a // generated app. So after the startup pair, try each *.Infrastructure // csproj as --project (with the startup, then as its own startup — the // field-validated pair). const infraCsprojs = csprojs.filter((f) => path.basename(f, '.csproj').endsWith('.Infrastructure')); const attempts: Array<[string, string]> = [ [startupProject, startupProject], ...infraCsprojs.flatMap((infra): Array<[string, string]> => [ [infra, startupProject], [infra, infra], ]), ]; for (const [project, startup] of attempts) { const hit = await probeDbContext(cwd, contextName, project, startup); if (!hit) continue; // The migration assembly is the context's OWN assembly (e.g. // "Application.Infrastructure"), NOT the startup/web project (which also // references EF Design). A context living in a NuGet assembly (e.g. // CoreDbContext -> SmartStack.Infrastructure) has no local csproj -> not // migratable from this worktree, so keep probing the other candidates. const asmName = (hit.assemblyQualifiedName ?? '').split(',')[1]?.trim(); if (!asmName) continue; const designCsproj = csprojs.find((f) => path.basename(f, '.csproj') === asmName); if (!designCsproj) continue; const migrationsDir = path.join(path.dirname(designCsproj), 'Persistence', 'Migrations'); return { name: contextName, assemblies: [ { csprojPath: designCsproj, assemblyName: path.basename(designCsproj, '.csproj'), migrationsDir, snapshotPath: path.join(migrationsDir, `${contextName}ModelSnapshot.cs`), provider: null, version: await parseCsprojVersion(designCsproj), }, ], }; } return null; } /** * ONE read-only `dotnet ef dbcontext list` probe against a (--project, * --startup-project) pair. Returns the matching context entry, or null when * the probe fails or the context is not visible from that pair. */ async function probeDbContext( cwd: string, contextName: string, project: string, startupProject: string, ): Promise<{ fullName?: string; name?: string; safeName?: string; assemblyQualifiedName?: string } | null> { const args = [ 'ef', 'dbcontext', 'list', '--json', '--no-color', '--project', project, '--startup-project', startupProject, ]; const r = await runDotnet(args, cwd, { timeoutMs: 5 * 60_000 }); if (r.exitCode !== 0) return null; // Extract the JSON array from the build-noisy stdout. `dotnet ef --json` prints // build + host-boot messages first — including Serilog lines like // `[08:00:52 INF] …` that ALSO start with '[' — then the context JSON array. So // try EVERY '['-prefixed line as a candidate array start and keep the first // block that parses to an array. const lines = r.stdout.split('\n'); const candidateStarts = lines.reduce((acc, l, i) => { if (l.trim().startsWith('[')) acc.push(i); return acc; }, []); let list: Array<{ fullName?: string; name?: string; safeName?: string; assemblyQualifiedName?: string }> | null = null; outer: for (const startLine of candidateStarts) { for (let endLine = lines.length - 1; endLine >= startLine; endLine--) { const block = lines.slice(startLine, endLine + 1).join('\n').trim(); if (!block.endsWith(']')) continue; try { const parsed = JSON.parse(block); if (Array.isArray(parsed)) { list = parsed; break outer; } } catch { /* keep shrinking / try the next candidate */ } } } if (!Array.isArray(list)) return null; return list.find( (c) => (c.fullName ?? '').split('.').pop() === contextName || c.name === contextName || c.safeName === contextName || c.fullName === contextName, ) ?? null; } export async function execute(spec: CreateSpec): Promise { const warnings: string[] = []; if (!(await hasDotnetProject(spec.cwd))) { return { success: false, cwd: spec.cwd, contextName: spec.contextName, assemblies: [], warnings, error: 'No .csproj in worktree — cannot create a migration.', }; } if (!spec.dryRun) { if (!(await dotnetAvailable())) { return { success: false, cwd: spec.cwd, contextName: spec.contextName, assemblies: [], warnings, error: 'dotnet CLI is not available on PATH', }; } if (!(await dotnetEfAvailable())) { return { success: false, cwd: spec.cwd, contextName: spec.contextName, assemblies: [], warnings, error: 'dotnet-ef is not installed — run: dotnet tool install --global dotnet-ef', }; } } // 3-tier policy gate (migration-policy): never mint a migration on a protected // production branch (🔴); require confirmation on release/hotfix (🟡). feature / // develop are 🟢 — the snapshot is reconciled by squash-before-PR at merge time. const branch = (await getCurrentBranch(spec.cwd)) ?? 'HEAD'; const policy = decideMigrationAction({ op: 'create', branch }); if (policy.blocked) { return { success: false, cwd: spec.cwd, contextName: spec.contextName, assemblies: [], warnings, policy, blocked: true, error: policy.reason }; } if (policy.requiresConfirmation && !spec.confirm) { return { success: false, cwd: spec.cwd, contextName: spec.contextName, assemblies: [], warnings: [...warnings, `Re-run with "confirm": true to create on ${branch} — ${policy.reason}`], policy, needsConfirmation: true, error: policy.reason, }; } const detection = await detectDbContexts(spec.cwd); let ctx = detection.contexts.find((c) => c.name === spec.contextName); if (!ctx) { // First-migration bootstrap: a project with zero migrations has no snapshot // for detectDbContexts to read. Discover the context via read-only // `dotnet ef dbcontext list` and synthesize its migration assembly. const sp = spec.startupProject ?? (await findStartupProject(spec.cwd)) ?? undefined; ctx = (await bootstrapFirstMigrationContext(spec.cwd, spec.contextName, sp)) ?? undefined; } if (!ctx) { return { success: false, cwd: spec.cwd, contextName: spec.contextName, assemblies: [], warnings, error: `Context "${spec.contextName}" not found in worktree. Detected: ${detection.contexts.map((c) => c.name).join(', ') || 'none'}`, }; } const targetAssemblies = spec.assembly ? ctx.assemblies.filter((a) => a.assemblyName === spec.assembly) : spec.allProviders ? ctx.assemblies : [ctx.assemblies[0]].filter(Boolean); if (targetAssemblies.length === 0) { return { success: false, cwd: spec.cwd, contextName: spec.contextName, assemblies: [], warnings, error: spec.assembly ? `Assembly "${spec.assembly}" not found for context ${spec.contextName}` : `No migration assembly detected for context ${spec.contextName}`, }; } const startupProject = spec.startupProject ?? (await findStartupProject(spec.cwd)) ?? undefined; if (!startupProject && !spec.dryRun) { warnings.push('No startup project detected — dotnet ef may fail. Pass --startupProject if necessary.'); } const results: CreateAssemblyResult[] = []; for (const asm of targetAssemblies) { const sequence = (await countExistingMigrations(asm.migrationsDir)) + 1; const migrationName = buildMigrationName({ contextName: spec.contextName, version: spec.version ?? asm.version, sequence, description: spec.description, }); if (spec.dryRun) { const wouldInline = await previewSqlObjectsForMigration({ projectDir: path.dirname(asm.csprojPath), migrationsDir: asm.migrationsDir, }); results.push({ assemblyName: asm.assemblyName, migrationName, csprojPath: asm.csprojPath, output: `[dry-run] dotnet ef migrations add ${migrationName} --project ${asm.csprojPath} --context ${spec.contextName} --output-dir ${migrationsOutputDir(asm.csprojPath, asm.migrationsDir)}${startupProject ? ` --startup-project ${startupProject}` : ''}${wouldInline.length ? ` + inline ${wouldInline.length} SQL object(s): ${wouldInline.join(', ')}` : ''}`, success: true, filesCreated: [], sqlObjectsInlined: wouldInline, }); continue; } const extraEnv = asm.provider ? { STUDIO_DESIGN_PROVIDER: capitalize(asm.provider) } : undefined; const args = buildMigrationsAddArgs({ migrationName, csprojPath: asm.csprojPath, contextName: spec.contextName, migrationsDir: asm.migrationsDir, startupProject, }); const r = await runDotnet(args, spec.cwd, { timeoutMs: 5 * 60_000, extraEnv }); const success = r.exitCode === 0; const filesCreated = success ? [ path.join(asm.migrationsDir, `${migrationName}.cs`), path.join(asm.migrationsDir, `${migrationName}.Designer.cs`), asm.snapshotPath, ] : []; // Freeze any new/changed SQL objects (functions, views, procs) from the // project's SqlObjects folder INTO the generated migration — they are not // tracked by the EF model, so `migrations add` never emits them. let sqlObjectsInlined: string[] = []; if (success) { try { const ensure = await ensureSqlObjectsInMigration({ projectDir: path.dirname(asm.csprojPath), migrationsDir: asm.migrationsDir, migrationName, }); sqlObjectsInlined = ensure.inlined; if (ensure.unparsable.length > 0) { warnings.push( `${asm.assemblyName}: ${ensure.unparsable.length} .sql file(s) under SqlObjects have no CREATE [OR ALTER] header — not inlined: ${ensure.unparsable.join(', ')}`, ); } if (ensure.injected) { warnings.push( `${asm.assemblyName}: inlined ${ensure.inlined.length} SQL object(s) into ${migrationName} so \`database update\` deploys them: ${ensure.inlined.join(', ')}`, ); } } catch (e) { warnings.push(`${asm.assemblyName}: SQL-object inlining skipped (${(e as Error).message}).`); } } // Surface a data-losing Up() (Drop/narrow) so the caller reviews before applying // or committing — a 🟡 signal even on a 🟢 branch (often an unintended rename). let destructive = false; if (success) { const migFile = path.join(asm.migrationsDir, `${migrationName}.cs`); if (existsSync(migFile)) { try { destructive = hasDestructiveUp(extractUpBody(readFileSync(migFile, 'utf-8'))); } catch { /* unreadable — skip the scan */ } } if (destructive) { warnings.push( `${asm.assemblyName}: ${migrationName} has a DESTRUCTIVE Up() (Drop/narrow) — confirm it is an intended schema change, not an unintended rename, before applying or committing.`, ); } } results.push({ assemblyName: asm.assemblyName, migrationName, csprojPath: asm.csprojPath, output: `${r.stdout}${r.stderr ? `\n${r.stderr}` : ''}`, success, error: success ? undefined : r.stderr || r.stdout || 'dotnet ef migrations add failed', filesCreated, sqlObjectsInlined, destructive, }); } const allOk = results.every((r) => r.success); return { success: allOk, cwd: detection.cwd, contextName: spec.contextName, assemblies: results, warnings, policy, error: allOk ? undefined : 'One or more assemblies failed to add migration — see per-assembly error', }; }