Second importer increment: the generic engine + the three no-FK adapters,
fully unit + integration tested.
- types: ImportAdapter contract (targetFields, matchKey, findExisting,
resolveForeignKeys, insert/update) + engine types.
- mapping: fuzzy header → target-field auto-suggest (exact / alias / edit
distance, one header per field) + applyMapping (drops empty cells).
- classify: per-field zod + cross-field extraValidate, FK resolution hook,
natural-key dedup, and the conflict-policy matrix
(skip-matches / update-matches / error-on-match) → row outcomes + summary.
- engine: CSV (papaparse) + XLSX (ExcelJS) parse into a uniform
{headers, rows} of trimmed strings.
- adapters (delegating to existing create/update services for audit +
validation): companies (name dedup, update), clients (flat email/phone →
contacts[], email-or-phone dedup, insert-only), berths (canonical mooring
dedup, numeric coercion, update).
- registry: implemented adapters in dependency order.
Tests: 11 unit (mapping/validation/matchKey/parse) + 3 integration
(dedup + all three conflict policies on a seeded DB). 14 passing.
Next increments: FK adapters (yachts/interests/tenancies/expenses),
commit runner + worker, API routes + permission, wizard UI + undo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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 };
|
|
}
|