275 lines
9.0 KiB
TypeScript
275 lines
9.0 KiB
TypeScript
|
|
/**
|
|||
|
|
* Migration report writer — turns a `MigrationPlan` (from
|
|||
|
|
* `migration-transform.ts`) into a CSV + a human-readable Markdown
|
|||
|
|
* summary on disk under `.migration/<timestamp>/`.
|
|||
|
|
*
|
|||
|
|
* 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<string, number>();
|
|||
|
|
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/<this-dir>/`.');
|
|||
|
|
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<void> {
|
|||
|
|
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');
|
|||
|
|
}
|