Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 | /**
* Runtime CSV column validator.
*
* Used by dashboard data-loaders right after a CSV is parsed.
* If the parsed rows are missing any of the columns declared in the
* registered `CsvContract` for a given path, this module throws a
* clear, structured error. Dashboard error handlers surface the
* thrown message in their visible error banner, so a CSV schema
* regression is impossible to ship as a silently-empty chart.
*
* Validation rules (intentionally strict — no legacy fallbacks):
* 1. Header check: every name in `contract.requiredColumns` must
* appear in the row keys of the first parsed record.
* 2. Row count: `rows.length >= contract.minRows ?? 1`.
* 3. No partial rows: a row missing one of the required column
* keys (i.e. the parser lost a column) fails the check.
*
* Use `validateCsvRows()` from any dashboard data loader. Use
* `validateCsvRowsLenient()` when the CSV is best-effort (e.g. an
* unreleased export pulled from a remote CDN); it only logs to
* `console.error` instead of throwing.
*/
import { type CsvContract, getCsvContract } from './csv-contracts';
export class CsvContractError extends Error {
readonly path: string;
readonly missingColumns: readonly string[];
readonly availableColumns: readonly string[];
readonly rowCount: number;
constructor(
path: string,
missingColumns: readonly string[],
availableColumns: readonly string[],
rowCount: number,
) {
super(
`CSV ${path} fails its registered contract — missing columns: [${
missingColumns.join(', ')
}]; received ${rowCount} row(s); header: [${availableColumns.join(', ')}]`,
);
this.name = 'CsvContractError';
this.path = path;
this.missingColumns = missingColumns;
this.availableColumns = availableColumns;
this.rowCount = rowCount;
}
}
type CsvRowLike = Readonly<Record<string, unknown>>;
/**
* Inspect parsed rows against the registered contract for `path`.
* Returns the input unchanged on success. Throws `CsvContractError`
* on any violation. If no contract is registered for `path`,
* validation is skipped (returns input).
*/
export function validateCsvRows<T extends CsvRowLike>(
path: string,
rows: readonly T[],
overrideContract?: CsvContract,
): readonly T[] {
const contract = overrideContract ?? getCsvContract(path);
if (!contract) return rows;
const minRows = contract.minRows ?? 1;
if (rows.length < minRows) {
throw new CsvContractError(
path,
contract.requiredColumns,
rows[0] ? Object.keys(rows[0]) : [],
rows.length,
);
}
const headerKeys = Object.keys(rows[0]);
const headerSet = new Set(headerKeys);
const missing = contract.requiredColumns.filter((c) => !headerSet.has(c));
if (missing.length > 0) {
throw new CsvContractError(path, missing, headerKeys, rows.length);
}
return rows;
}
/**
* Same checks, but logs to `console.error` instead of throwing.
* Returns `true` when the contract is satisfied (or no contract is
* registered), `false` otherwise.
*/
export function validateCsvRowsLenient<T extends CsvRowLike>(
path: string,
rows: readonly T[],
): boolean {
try {
validateCsvRows(path, rows);
return true;
} catch (err) {
if (err instanceof CsvContractError) {
console.error('[csv-validator]', err.message);
return false;
}
throw err;
}
}
|