Files
edr-platform/apps/edr-freight-api/src/scripts/validate-export-datasets.ts
Nathnael 62f7b91315 feat(exports): dataset-driven table export, starting with bookings
Adds a parallel export system the reports module can also draw on. A dataset
describes a table's exportable fields — including related-entity detail the
list page never shows — and the engine assembles a query from whichever fields
the caller picked.

GET /exports                 catalog (metadata only; select/requires never ship)
GET /exports/:key/count      exact row count + per-format caps
GET /exports/:key/download   csv | xlsx | pdf

Two invariants carry the design:

- Every lazy join is a LEFT join, and ExportJoin has no 'kind' field to make
  anything else expressible. An inner join added because a checkbox was ticked
  would change the rowset, so two exports of the same filters would disagree on
  their row count.
- Because of that, the count cannot depend on field selection, so /count runs
  base + alwaysJoin only and is exact rather than an estimate. Verified: count
  and the delivered file both report 223 rows.

One-to-many relations (a booking's containers) aggregate in a correlated
subquery rather than joining, so a row can never multiply.

Export rides each dataset's existing view permission — no new permission keys
and no seeder change. Sensitive columns are simply never declared as fields:
raw gateway payloads, signature blobs, error dumps, raw jsonb snapshots,
internal user UUIDs and review notes are all absent by construction.

bookings ships 77 fields across 10 groups. scripts/validate-export-datasets.ts
EXPLAINs every dataset's widest query, its count query, and each field on its
own against the real database — the per-field pass is what catches a field
referencing a join it forgot to declare, which otherwise only fails when that
one field is picked alone.
2026-08-20 05:29:04 +00:00

84 lines
3.1 KiB
TypeScript

/**
* EXPLAIN-validates every export dataset against the real database.
*
* CLAUDE.md hard rule: raw SQL must be validated against a real DB before it
* ships. Every dataset is hand-written SQL expressions over wide tables where
* column drift is documented history, so a typo is a runtime 500 no type-check
* can catch. This builds each dataset's WIDEST query (all fields selected, so
* every join and every subquery is exercised) plus its count query, and runs
* both through EXPLAIN.
*
* npx ts-node -r tsconfig-paths/register src/scripts/validate-export-datasets.ts
*/
import 'dotenv/config';
import AppDataSource from '../data-source';
import { buildExportCountQuery, buildExportQuery } from '../modules/exports/export-query.builder';
import { DATASETS } from '../modules/exports/export.registry';
async function main(): Promise<void> {
await AppDataSource.initialize();
let failed = 0;
for (const dataset of DATASETS) {
const ctx = { ds: AppDataSource, params: {}, directions: null };
const cases: [string, () => { sql: string; params: unknown[] }][] = [
[
`${dataset.key} (all ${dataset.fields.length} fields)`,
() => {
const qb = buildExportQuery(dataset, dataset.fields, ctx);
return { sql: qb.getQuery(), params: qb.getParameters() as unknown as unknown[] };
},
],
[
`${dataset.key} (count)`,
() => {
const qb = buildExportCountQuery(dataset, ctx);
return { sql: qb.getQuery(), params: qb.getParameters() as unknown as unknown[] };
},
],
];
// Each field ALONE. The all-fields query above cannot catch a field that
// references an alias it forgot to declare in `requires` — some other
// field's `requires` pulls that join in, so it only 42P01s when that one
// checkbox is ticked on its own. This is the check that finds it.
for (const field of dataset.fields) {
cases.push([
`${dataset.key}.${field.key}`,
() => {
const qb = buildExportQuery(dataset, [field], ctx);
return { sql: qb.getQuery(), params: qb.getParameters() as unknown as unknown[] };
},
]);
}
let fieldFailures = 0;
for (const [label, build] of cases) {
const isPerField = label.startsWith(`${dataset.key}.`);
try {
const { sql } = build();
// Parameters are all optional filters and unset here, so the generated
// SQL carries no placeholders — EXPLAIN it directly.
await AppDataSource.query(`EXPLAIN ${sql}`);
if (!isPerField) console.log(` ok ${label}`);
} catch (error) {
failed += 1;
if (isPerField) fieldFailures += 1;
console.error(` FAIL ${label}`);
console.error(` ${(error as Error).message.split('\n')[0]}`);
}
}
if (!fieldFailures) {
console.log(` ok ${dataset.key} (each of ${dataset.fields.length} fields alone)`);
}
}
await AppDataSource.destroy();
console.log(failed ? `\n${failed} query/queries failed.` : '\nAll export dataset SQL validated.');
process.exit(failed ? 1 : 0);
}
void main();