/** * Migration report writer — turns a `MigrationPlan` (from * `migration-transform.ts`) into a CSV + a human-readable Markdown * summary on disk under `.migration//`. * * The CSV format is intentionally machine-friendly (one row per * planned operation) so it can be diffed across runs and inspected * by hand. The summary is designed for "open this in your editor and * eyeball it for 5 minutes before --apply." */ import { promises as fs } from 'node:fs'; import path from 'node:path'; import type { MigrationPlan } from './migration-transform'; // ─── Output directory ─────────────────────────────────────────────────────── export interface ReportPaths { rootDir: string; csvPath: string; summaryPath: string; planJsonPath: string; } /** Resolve report paths relative to the worktree root. The timestamped * directory is created lazily by `writeReport`. */ export function resolveReportPaths( rootDir: string, timestamp: string = new Date().toISOString().replace(/[:.]/g, '-'), ): ReportPaths { const dir = path.join(rootDir, '.migration', timestamp); return { rootDir: dir, csvPath: path.join(dir, 'report.csv'), summaryPath: path.join(dir, 'summary.md'), planJsonPath: path.join(dir, 'plan.json'), }; } // ─── CSV row shape ────────────────────────────────────────────────────────── interface CsvRow { op: string; // create_client / create_contact / create_interest / auto_link / flag / needs_review reason: string; source_id: string; target_table: string; target_value: string; confidence: string; manual_review: 'true' | 'false'; } // Trivial CSV escape: quote any cell that contains comma / quote / newline, // double up internal quotes per RFC 4180. No need for a dependency. function csvEscape(s: string): string { if (/[",\n\r]/.test(s)) { return `"${s.replace(/"/g, '""')}"`; } return s; } function rowToCsvLine(r: CsvRow): string { return [ r.op, r.reason, r.source_id, r.target_table, r.target_value, r.confidence, r.manual_review, ] .map(csvEscape) .join(','); } // ─── Build CSV ────────────────────────────────────────────────────────────── export function buildCsv(plan: MigrationPlan): string { const lines: string[] = []; lines.push( [ 'op', 'reason', 'source_id', 'target_table', 'target_value', 'confidence', 'manual_review', ].join(','), ); for (const client of plan.clients) { lines.push( rowToCsvLine({ op: 'create_client', reason: client.sourceIds.length > 1 ? 'auto-merged cluster' : 'new', source_id: client.sourceIds.join('|'), target_table: 'clients.fullName', target_value: client.fullName, confidence: 'N/A', manual_review: 'false', }), ); for (const c of client.contacts) { lines.push( rowToCsvLine({ op: 'create_contact', reason: c.flagged ?? 'new', source_id: client.sourceIds.join('|'), target_table: `clientContacts.${c.channel}`, target_value: c.value, confidence: 'N/A', manual_review: c.flagged ? 'true' : 'false', }), ); } for (const a of client.addresses) { lines.push( rowToCsvLine({ op: 'create_address', reason: 'address text present', source_id: client.sourceIds.join('|'), target_table: 'clientAddresses.countryIso', target_value: a.countryIso ?? '(unresolved)', confidence: a.countryConfidence ?? 'fallback', manual_review: a.countryConfidence === 'fallback' || !a.countryIso ? 'true' : 'false', }), ); } } for (const interest of plan.interests) { lines.push( rowToCsvLine({ op: 'create_interest', reason: `pipelineStage=${interest.pipelineStage}`, source_id: String(interest.sourceId), target_table: 'interests', target_value: `${interest.berthMooringNumber ?? '(no berth)'} / ${interest.yachtName ?? '(no yacht)'}`, confidence: 'N/A', manual_review: 'false', }), ); } for (const link of plan.autoLinks) { lines.push( rowToCsvLine({ op: 'auto_link', reason: link.reasons.join(' + '), source_id: `${link.leadSourceId}<-${link.mergedSourceIds.join(',')}`, target_table: 'clients', target_value: '(merged into lead)', confidence: `score=${link.score}`, manual_review: 'false', }), ); } for (const pair of plan.needsReview) { lines.push( rowToCsvLine({ op: 'needs_review', reason: pair.reasons.join(' + '), source_id: `${pair.aSourceId}<->${pair.bSourceId}`, target_table: 'clients', target_value: '(human review required)', confidence: `score=${pair.score}`, manual_review: 'true', }), ); } for (const flag of plan.flags) { lines.push( rowToCsvLine({ op: 'flag', reason: flag.reason, source_id: String(flag.sourceId), target_table: flag.sourceTable, target_value: JSON.stringify(flag.details ?? {}), confidence: 'N/A', manual_review: 'true', }), ); } return lines.join('\n') + '\n'; } // ─── Build summary markdown ───────────────────────────────────────────────── export function buildSummary(plan: MigrationPlan, generatedAt: string): string { const s = plan.stats; const lines: string[] = []; lines.push(`# Migration Dry-Run — ${generatedAt}`); lines.push(''); lines.push('## Input'); lines.push(`- ${s.inputInterestRows} NocoDB Interests`); lines.push(`- ${s.inputResidentialRows} NocoDB Residential Interests`); lines.push(''); lines.push('## Outcome'); lines.push(`- ${s.outputClients} clients`); lines.push(`- ${s.outputInterests} interests (one per source row, linked to deduped client)`); lines.push(`- ${s.outputContacts} client_contacts`); lines.push(`- ${s.outputAddresses} client_addresses`); lines.push(''); lines.push('## Auto-linked clusters'); if (plan.autoLinks.length === 0) { lines.push('_None — every input row maps to a unique client._'); } else { for (const link of plan.autoLinks) { const merged = link.mergedSourceIds.length; lines.push( `- Lead row \`${link.leadSourceId}\` ← merged ${merged} other row${merged === 1 ? '' : 's'} (\`${link.mergedSourceIds.join(', ')}\`) — score ${link.score} via ${link.reasons.join(' + ')}`, ); } } lines.push(''); lines.push('## Pairs flagged for human review'); if (plan.needsReview.length === 0) { lines.push('_None._'); } else { for (const pair of plan.needsReview) { lines.push( `- Rows \`${pair.aSourceId}\` ↔ \`${pair.bSourceId}\` — score ${pair.score} (${pair.reasons.join(' + ')})`, ); } } lines.push(''); lines.push('## Data quality flags'); if (plan.flags.length === 0) { lines.push('_No quality issues._'); } else { const byReason = new Map(); for (const f of plan.flags) { byReason.set(f.reason, (byReason.get(f.reason) ?? 0) + 1); } for (const [reason, count] of [...byReason].sort((a, b) => b[1] - a[1])) { lines.push(`- **${count}× ${reason}**`); } lines.push(''); lines.push('### Detail'); for (const f of plan.flags.slice(0, 30)) { lines.push( `- \`${f.sourceTable}#${f.sourceId}\`: ${f.reason}${f.details ? ` — \`${JSON.stringify(f.details)}\`` : ''}`, ); } if (plan.flags.length > 30) { lines.push(`- _… and ${plan.flags.length - 30} more (see report.csv for full list)_`); } } lines.push(''); lines.push('## Next step'); lines.push(''); lines.push('Eyeball the auto-linked + flagged-for-review pairs above.'); lines.push('When satisfied, re-run the script with `--apply --report .migration//`.'); lines.push('Apply will refuse to run if the source NocoDB has changed since this dry-run.'); return lines.join('\n') + '\n'; } // ─── Write to disk ────────────────────────────────────────────────────────── export async function writeReport( paths: ReportPaths, plan: MigrationPlan, generatedAt: string, ): Promise { await fs.mkdir(paths.rootDir, { recursive: true }); await fs.writeFile(paths.csvPath, buildCsv(plan), 'utf-8'); await fs.writeFile(paths.summaryPath, buildSummary(plan, generatedAt), 'utf-8'); await fs.writeFile(paths.planJsonPath, JSON.stringify(plan, null, 2), 'utf-8'); }