Files
HealthLog/src/__tests__/helpers/wipe-schema-shape.ts
T
Marc-André Bombeck ef35fe681d fix(admin): wipe every account's data, not nine tables
The admin "Delete all data" action carried its delete list inline. That
list named nine tables, which was most of the schema on the day it was
written, and it stayed at nine while the schema grew to a hundred and
twenty-two models. Laboratory results, documents, cycle logs, journals,
mood, workouts, sleep and Coach conversations all survived the wipe an
operator runs before handing a box on, and the toast said it was done.

The per-account wipe had the same defect and grew a derived plan for it.
The admin route now reads that plan: ADMIN_WIPE_MODELS is WIPE_MODELS
minus a single documented subtraction, so a model added to the schema
lands in both wipes at once.

- AccountGrant is the one difference, with its reason in the plan: it is
  the only access path to a managed profile, and this action preserves
  accounts by definition. Deleting every grant would leave records
  nobody can open, administer or delete.
- The audit history now goes with everything else. It is a record of who
  did what from where, which is exactly what clearing a box is for; the
  receipt written after the transaction commits replaces it.
- One transaction with the same budget as the account wipe. A
  half-applied wipe is worse than none, because the person is told it
  succeeded either way.
- The result line reports rows and tables instead of nine fixed names,
  which is how nobody noticed: it read the same whether the wipe had
  cleared nine tables or ninety.

Guards, both watched red before landing:
admin-data-wipe-completeness.test.ts fails when a user-scoped model
reaches neither the wipe nor a written exemption, and when the route
names a table inline again. The integration guard seeds every table in
the schema for two accounts, runs the route, and asks the database what
is left — drop one model from the plan and it names the surviving table.
2026-08-08 01:05:31 +02:00

103 lines
3.3 KiB
TypeScript

/**
* The shape of `prisma/schema.prisma` the two wipe guards read.
*
* Both of them ask the same two questions — which models carry a `userId`, and
* which models the database removes on its own when a row they do delete goes
* — so the parser answering them lives in one place. Two copies would drift,
* and a guard that answers from its own private idea of the schema is a guard
* that can be green for the wrong reason.
*
* Deliberately regex-shaped rather than a real parser: it runs inside
* `pnpm test` with no database and no Prisma engine, and model blocks plus
* field lines are the most stable syntax in the file. Every consumer asserts a
* floor on how much it found, so a parser that silently stops matching fails
* loudly instead of passing vacuously.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
export interface Relation {
/** Target model name. */
target: string;
cascade: boolean;
/** `true` when the relation field is optional (`Model?`). */
optional: boolean;
}
export interface ModelShape {
scalars: string[];
relations: Relation[];
}
export const WIPE_SCHEMA_PATH = join(process.cwd(), "prisma", "schema.prisma");
export function parseWipeSchema(
schemaPath: string = WIPE_SCHEMA_PATH,
): Map<string, ModelShape> {
const schema = readFileSync(schemaPath, "utf8");
const blocks = [...schema.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm)].map(
(m) => ({ name: m[1], body: m[2] }),
);
const modelNames = new Set(blocks.map((b) => b.name));
const out = new Map<string, ModelShape>();
for (const block of blocks) {
const scalars: string[] = [];
const relations: Relation[] = [];
for (const rawLine of block.body.split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("//") || line.startsWith("@@")) continue;
const match = /^(\w+)\s+(\w+)(\[\])?(\?)?/.exec(line);
if (!match) continue;
const [, field, type, isList, isOptional] = match;
if (modelNames.has(type)) {
// A list side of a relation carries no column and cannot cascade.
if (isList) continue;
relations.push({
target: type,
cascade: line.includes("onDelete: Cascade"),
optional: Boolean(isOptional),
});
continue;
}
if (isList && type === type.toUpperCase()) {
// Scalar / enum list column — still a column.
scalars.push(field);
continue;
}
scalars.push(field);
}
out.set(block.name, { scalars, relations });
}
return out;
}
/**
* Every model the database itself removes once the seed models are deleted.
*
* A fixpoint over REQUIRED cascade relations: an optional relation survives
* its parent, so it proves nothing about the row going away. The result
* includes the seeds.
*/
export function reachedByCascade(
models: Map<string, ModelShape>,
seeds: Iterable<string>,
): Set<string> {
const covered = new Set<string>(seeds);
for (;;) {
let grew = false;
for (const [name, shape] of models) {
if (covered.has(name)) continue;
const reached = shape.relations.some(
(r) => r.cascade && !r.optional && covered.has(r.target),
);
if (reached) {
covered.add(name);
grew = true;
}
}
if (!grew) break;
}
return covered;
}