Files
HealthLog/scripts/convert-noncanonical-measurement-units.ts
Marc-André Bombeck ddc598afcb test(measurements): cover the unit transform, MCP unit hardening, and a display guard
Add affine-correctness tests for the transform registry (°C→°F absolute vs a
delta that must stay offset-free, lb/in factors, inverse round-trips), a
resolver test for the inbound unit aliases, a preferred-unit entry-form label
test, and MCP cases for converting a lb hint to canonical kg and refusing an
unknown unit. Add the structural guard that trips if any dashboard or insight
surface reintroduces a hardcoded transformed-type unit or value scale. Ship the
diagnose/convert script pair for legacy non-canonical measurement rows.
2026-07-25 00:21:36 +02:00

105 lines
3.6 KiB
TypeScript

/**
* Convert measurement rows stored in a RECOGNISED non-canonical unit to
* canonical SI, in place. Companion to
* `diagnose-noncanonical-measurement-units.ts` — run the diagnose first.
*
* SAFETY:
* - Dry-run by default. Writes ONLY when called with `--execute`.
* - Touches ONLY rows whose stored `unit` differs from the type's canonical
* unit AND resolves through the shared alias resolver (lb/lbs/pound(s),
* in/inch(es), °F, mmol/L). A row whose unit is already canonical, or whose
* unit is unrecognised, is never touched.
* - Each row is rewritten to the canonical `{ value, unit }` the same
* resolver the CSV importer + MCP write path use produces, so the number is
* re-expressed, never re-labelled in place (which would corrupt it).
* - Affected users' measurement caches are busted at the end.
*
* This repairs the storage LABEL + value for the recognised-alias legacy rows
* (the MCP-leak class). It cannot repair a row whose unit was already canonical
* but whose value was a foreign unit — that class is not mechanically
* identifiable (see the diagnose script).
*
* Run (inside the app container):
* pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts # dry-run
* pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts --execute # write
*/
import "dotenv/config";
import { prisma } from "@/lib/db";
import { getUnitForType } from "@/lib/validations/measurement";
import { resolveToCanonicalUnit } from "@/lib/measurements/unit-aliases";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
async function main(): Promise<void> {
const execute = process.argv.includes("--execute");
const groups = await prisma.measurement.groupBy({
by: ["type", "unit"],
_count: { _all: true },
});
const convertibleGroups = groups.filter((g) => {
if (g.unit === null || g.unit === getUnitForType(g.type)) return false;
return resolveToCanonicalUnit(g.type, 1, g.unit) !== null;
});
if (convertibleGroups.length === 0) {
console.log(
"Nothing to convert — no recognised non-canonical units found.",
);
return;
}
const affectedUsers = new Set<string>();
let converted = 0;
for (const g of convertibleGroups) {
const canonical = getUnitForType(g.type);
const rows = await prisma.measurement.findMany({
where: { type: g.type, unit: g.unit },
select: { id: true, userId: true, value: true, unit: true },
});
for (const row of rows) {
const resolved = resolveToCanonicalUnit(
g.type,
row.value,
row.unit as string,
);
if (!resolved) continue; // defensive; group already filtered
converted += 1;
affectedUsers.add(row.userId);
console.log(
` ${execute ? "convert" : "would convert"} ${g.type} ${row.id}: ` +
`${row.value} ${row.unit} -> ${resolved.value} ${resolved.unit}`,
);
if (execute) {
await prisma.measurement.update({
where: { id: row.id },
data: { value: resolved.value, unit: canonical },
});
}
}
}
if (execute) {
for (const userId of affectedUsers) {
invalidateUserMeasurements(userId, { evict: true });
}
console.log(
`\nConverted ${converted} row(s) across ${affectedUsers.size} user(s). ` +
"Rollups recompute on the next read/backfill.",
);
} else {
console.log(
`\nDry run: ${converted} row(s) across ${affectedUsers.size} user(s) ` +
"would be converted. Re-run with --execute to write.",
);
}
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());