/** * Row classification — the heart of both the dry-run preview and the commit. * * For one mapped row: validate (required + per-field zod) → resolve FKs → * dedup by natural key → resolve to an outcome under the chosen conflict * policy. Pure with respect to writes; the commit step acts on the outcome. */ import { applyMapping } from './mapping'; import type { ClassifiedRow, ConflictPolicy, ImportAdapter, ImportCtx, MappedRow, RawRow, RowError, } from './types'; /** Required-field + per-field zod validation. Never throws; returns all errors. */ export function validateRow(adapter: ImportAdapter, mapped: MappedRow): RowError[] { const errors: RowError[] = []; for (const field of adapter.targetFields) { const value = mapped[field.key]; if (value === undefined) { if (field.required) errors.push({ field: field.key, message: `${field.label} is required` }); continue; } const res = field.zod.safeParse(value); if (!res.success) { const first = res.error.issues[0]; errors.push({ field: field.key, message: first?.message ?? 'Invalid value' }); } } if (errors.length === 0 && adapter.extraValidate) errors.push(...adapter.extraValidate(mapped)); return errors; } export async function classifyRow( adapter: ImportAdapter, raw: RawRow, mapping: Record, rowNumber: number, policy: ConflictPolicy, ctx: ImportCtx, ): Promise { const mapped = applyMapping(raw, mapping); const errors = validateRow(adapter, mapped); if (errors.length > 0) return { rowNumber, outcome: 'error', errors }; // Foreign keys (by natural key) — resolution failures are row errors. let resolved: Record = {}; if (adapter.resolveForeignKeys) { const fk = await adapter.resolveForeignKeys(mapped, ctx); if (!fk.ok) return { rowNumber, outcome: 'error', errors: fk.errors }; resolved = fk.resolved; } // Dedup by natural key. const key = adapter.matchKey(mapped); const existing = key ? await adapter.findExisting(ctx.portId, key) : null; if (existing) { if (policy === 'error-on-match') { return { rowNumber, outcome: 'error', existingId: existing.id, errors: [{ field: '*', message: 'Matches an existing record' }], }; } if (policy === 'update-matches' && adapter.update) { return { rowNumber, outcome: 'update', existingId: existing.id, mapped, resolved }; } // skip-matches, or update requested on an insert-only adapter. return { rowNumber, outcome: 'skip', existingId: existing.id, mapped, resolved }; } return { rowNumber, outcome: 'insert', mapped, resolved }; } export interface DryRunSummary { total: number; insert: number; update: number; skip: number; error: number; rows: ClassifiedRow[]; } /** Classify a whole file. Sequential to keep FK lookups + dedup ordering * deterministic (migration files are small). */ export async function classifyRows( adapter: ImportAdapter, rawRows: RawRow[], mapping: Record, policy: ConflictPolicy, ctx: ImportCtx, ): Promise { const rows: ClassifiedRow[] = []; const summary = { total: rawRows.length, insert: 0, update: 0, skip: 0, error: 0 }; for (let i = 0; i < rawRows.length; i++) { const c = await classifyRow(adapter, rawRows[i]!, mapping, i + 1, policy, ctx); rows.push(c); summary[c.outcome] += 1; } return { ...summary, rows }; }