109 lines
3.4 KiB
TypeScript
109 lines
3.4 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<string, string>,
|
||
|
|
rowNumber: number,
|
||
|
|
policy: ConflictPolicy,
|
||
|
|
ctx: ImportCtx,
|
||
|
|
): Promise<ClassifiedRow> {
|
||
|
|
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<string, string> = {};
|
||
|
|
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<string, string>,
|
||
|
|
policy: ConflictPolicy,
|
||
|
|
ctx: ImportCtx,
|
||
|
|
): Promise<DryRunSummary> {
|
||
|
|
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 };
|
||
|
|
}
|