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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 01:05:31 +02:00
1 parent 93980ac7a1
commit ef35fe681d
15 files changed
+966 -484

No files matched your search

@@ -0,0 +1,170 @@
/**
* Structural guard binding `prisma/schema.prisma` to the ADMIN wipe.
*
* The per-account wipe grew a derived plan and a completeness test after it
* was found deleting thirteen tables out of eighty-odd. The admin wipe — the
* one an operator runs before handing a box on, "Deletes all data for all
* users" — kept its inline list of nine `deleteMany` calls, written when nine
* was most of the schema. It was still nine against a hundred and twenty-two
* models: laboratory results, documents, cycle logs, journals, mood, workouts,
* sleep and Coach conversations all survived a confirmation the operator typed
* by hand. Nothing failed, because nothing was asking.
*
* So the route reads `ADMIN_WIPE_MODELS` — `WIPE_MODELS` minus the entries in
* `ADMIN_WIPE_EXEMPT` — and this file asks the question its sibling asks for
* the account wipe.
*
* What it proves:
* 1. every model carrying a `userId` column is deleted by the admin wipe or
* excused by a named list with a written reason;
* 2. every model without one either reaches an admin-wiped model through a
* chain of required `onDelete: Cascade` relations or is declared
* instance-scoped;
* 3. every admin-only exemption names a real model that the per-account wipe
* DOES delete, so the entry is a deliberate subtraction rather than a
* stale name nobody re-read;
* 4. the route carries no table list of its own — it iterates the plan.
*
* Its honest limit is the same as its sibling's: it proves classification, not
* judgement. It cannot tell that a model was excused for a bad reason, only
* that somebody had to write the reason down. And rule 4 is source-shaped: it
* reads the route file for `deleteMany` calls on a literal delegate, so a
* future inline list built some other way would slip it.
*
* Mutation check: remove any entry from `WIPE_MODELS` and this goes red naming
* that model as unclassified; put an inline `tx.measurement.deleteMany({})`
* back into the route and rule 4 goes red naming it.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, it, expect } from "vitest";
import {
parseWipeSchema,
reachedByCascade,
} from "@/__tests__/helpers/wipe-schema-shape";
import {
ADMIN_WIPE_EXEMPT,
ADMIN_WIPE_MODELS,
INSTANCE_SCOPED,
WIPE_EXEMPT,
WIPE_MODELS,
} from "@/lib/data-wipe/wipe-plan";
const ROUTE = join(
process.cwd(),
"src",
"app",
"api",
"admin",
"data",
"route.ts",
);
const models = parseWipeSchema();
const adminWipeSet = new Set<string>(ADMIN_WIPE_MODELS);
/** Every list that is allowed to answer "why did this model survive?". */
function survivorReason(model: string): string | null {
return (
ADMIN_WIPE_EXEMPT[model] ??
WIPE_EXEMPT[model] ??
INSTANCE_SCOPED[model] ??
null
);
}
describe("admin data-wipe completeness", () => {
it("deletes essentially the whole user-scoped schema", () => {
// A floor, not a target. The defect this file exists for was a wipe that
// covered nine tables while reporting success; a silent shrink back
// towards that number fails here even if every survivor is classified.
expect(ADMIN_WIPE_MODELS.length).toBeGreaterThan(80);
});
it("every model with a userId column is wiped or excused with a reason", () => {
const userScoped = [...models]
.filter(([, shape]) => shape.scalars.includes("userId"))
.map(([name]) => name);
expect(userScoped.length).toBeGreaterThan(80);
const unclassified = userScoped.filter(
(name) => !adminWipeSet.has(name) && survivorReason(name) === null,
);
if (unclassified.length > 0) {
throw new Error(
`${unclassified.length} user-scoped model(s) survive the admin wipe with nothing saying why:\n` +
unclassified.map((n) => ` ❌ ${n}`).join("\n") +
"\n\nAn operator who types DELETE ALL before handing this server on " +
"expects these rows gone. Add each to WIPE_MODELS, or to " +
"ADMIN_WIPE_EXEMPT / WIPE_EXEMPT / INSTANCE_SCOPED with the reason " +
"keeping it is not keeping somebody's record.",
);
}
});
it("every model without a userId column is reached by cascade or declared instance-scoped", () => {
const covered = reachedByCascade(models, adminWipeSet);
const orphans = [...models]
.filter(([, shape]) => !shape.scalars.includes("userId"))
.map(([name]) => name)
.filter((name) => !covered.has(name) && survivorReason(name) === null);
if (orphans.length > 0) {
throw new Error(
`${orphans.length} model(s) carry no userId, are not removed by any cascade from an admin-wiped row, and are not declared instance-scoped:\n` +
orphans.map((n) => ` ❌ ${n}`).join("\n"),
);
}
});
it("excuses only models the per-account wipe does delete, each with a reason", () => {
for (const [name, reason] of Object.entries(ADMIN_WIPE_EXEMPT)) {
expect(
models.has(name),
`ADMIN_WIPE_EXEMPT names "${name}", which is not a model`,
).toBe(true);
// The list exists to record where the two wipes DIFFER. A name the
// account wipe does not delete either is not a difference; it is a
// stale entry hiding behind a plausible reason.
expect(
(WIPE_MODELS as readonly string[]).includes(name),
`ADMIN_WIPE_EXEMPT names "${name}", which the per-account wipe does not delete either — the entry records no difference`,
).toBe(true);
expect(
reason.length,
`ADMIN_WIPE_EXEMPT.${name} needs a real reason`,
).toBeGreaterThan(30);
}
});
it("derives the model set instead of listing tables in the route", () => {
const source = readFileSync(ROUTE, "utf8");
expect(
source.includes("ADMIN_WIPE_MODELS"),
"the admin wipe route no longer reads the shared plan",
).toBe(true);
// `tx.measurement.deleteMany(` and friends — the shape the inline list
// had. The loop calls `resolveGlobalWipeDelegate(tx, model).deleteMany`,
// which names no table and does not match.
const inline = [
...source.matchAll(
/\b(?:tx|prisma)\s*\.\s*(\w+)\s*\.\s*deleteMany\s*\(/g,
),
].map((m) => m[1]);
expect(
inline,
`the admin wipe names tables inline again:\n${inline.join("\n")}\n\n` +
"That list is how it drifted to nine tables: it was correct the day " +
"it was written and nothing noticed when the schema moved past it. " +
"Delete through ADMIN_WIPE_MODELS.",
).toEqual([]);
});
});
+6 -73
View File
@@ -35,11 +35,12 @@
* that model; add a column to `model User` without classifying it and it goes
* red naming the column.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, it, expect } from "vitest";
import {
parseWipeSchema,
reachedByCascade,
} from "@/__tests__/helpers/wipe-schema-shape";
import {
INSTANCE_SCOPED,
USER_KEPT_FIELDS,
@@ -51,61 +52,7 @@ import {
wipeWhere,
} from "@/lib/data-wipe/wipe-plan";
const SCHEMA = join(process.cwd(), "prisma", "schema.prisma");
interface Relation {
/** Target model name. */
target: string;
cascade: boolean;
/** `true` when the relation field is optional (`Model?`). */
optional: boolean;
}
interface ModelShape {
scalars: string[];
relations: Relation[];
}
function parseSchema(schema: string): Map<string, ModelShape> {
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;
}
const schemaText = readFileSync(SCHEMA, "utf8");
const models = parseSchema(schemaText);
const models = parseWipeSchema();
const wipeSet = new Set<string>(WIPE_MODELS);
describe("data-wipe completeness", () => {
@@ -158,21 +105,7 @@ describe("data-wipe completeness", () => {
it("every model without a userId column is reached by cascade or declared instance-scoped", () => {
// Fixpoint over required cascade relations: a model is covered when the
// database removes it as a consequence of a row the wipe deletes.
const covered = new Set<string>(wipeSet);
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;
}
const covered = reachedByCascade(models, wipeSet);
const orphans = [...models]
.filter(([, shape]) => !shape.scalars.includes("userId"))
+102
View File
@@ -0,0 +1,102 @@
/**
* 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;
}
+63 -50
View File
@@ -6,12 +6,36 @@ import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { NextRequest, NextResponse } from "next/server";
import { invalidateAllCaches } from "@/lib/cache/invalidate";
import {
ADMIN_WIPE_MODELS,
resolveGlobalWipeDelegate,
USER_RESET,
wipeDelegateKey,
WIPE_TRANSACTION_MAX_WAIT_MS,
WIPE_TRANSACTION_TIMEOUT_MS,
} from "@/lib/data-wipe/wipe-plan";
export const dynamic = "force-dynamic";
/**
* Admin-only global wipe of all health/integration data.
* Admin-only global wipe of every account's data.
* Keeps users/passkeys so access to the app remains possible.
*
* The set of tables is not written here. It is declared once in
* `@/lib/data-wipe/wipe-plan` — the same declaration the per-account wipe
* reads — and held against `prisma/schema.prisma` by
* `src/__tests__/data-wipe-completeness.test.ts` plus its admin-scope sibling,
* so a model added to the schema cannot quietly land outside this wipe. It had
* done exactly that: the inline list this route used to carry named nine
* tables, written when nine was most of the schema, and stayed at nine while
* the schema grew past a hundred. Laboratory results, documents, cycle logs,
* journals, mood, workouts, sleep and Coach conversations all survived the
* wipe an operator runs before handing the box on.
*
* `ADMIN_WIPE_MODELS` is `WIPE_MODELS` minus the entries in
* `ADMIN_WIPE_EXEMPT`, and every survivor — sign-in credentials, instance
* configuration, the delegation grants that keep managed profiles reachable —
* carries its reason in that file.
*/
export const DELETE = apiHandler(async (request: NextRequest) => {
const ip = getClientIp(request);
@@ -59,72 +83,61 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
}
// Audit-log the *intent* before the transaction begins so the trail
// describing "an admin is about to wipe data" survives even if the
// operation crashes midway. AuditLog rows are intentionally NOT
// deleted as part of the wipe — the entire point of an audit trail
// is to outlive the data it documents.
// describing "an admin is about to wipe data" survives a crash midway or a
// rollback. On success the transaction takes this row with the rest of the
// history — an audit trail is a record of who did what from where, which is
// exactly the personal data an operator clearing a box means to remove — and
// the receipt written after the commit replaces it.
await auditLog("admin.data.clear.start", {
userId: user.id,
ipAddress: getClientIp(request),
details: { confirm: "DELETE ALL" },
});
const result = await prisma.$transaction(async (tx) => {
const measurements = await tx.measurement.deleteMany({});
const intakeEvents = await tx.medicationIntakeEvent.deleteMany({});
const medications = await tx.medication.deleteMany({});
const apiTokens = await tx.apiToken.deleteMany({});
const withingsConnections = await tx.withingsConnection.deleteMany({});
const authChallenges = await tx.authChallenge.deleteMany({});
const counts = await prisma.$transaction(
async (tx) => {
const perModel: Record<string, number> = {};
for (const model of ADMIN_WIPE_MODELS) {
const { count } = await resolveGlobalWipeDelegate(tx, model).deleteMany(
{},
);
if (count > 0) perModel[wipeDelegateKey(model)] = count;
}
// v1.4.6 left these out, so encrypted Telegram bot tokens
// (NotificationChannel.config) and Web Push endpoints survived a
// wipe. Cascading via NotificationPreference is handled by the
// schema's onDelete: Cascade on the channel FK.
const notificationChannels = await tx.notificationChannel.deleteMany({});
const pushSubscriptions = await tx.pushSubscription.deleteMany({});
const telegramScheduledDeletions =
await tx.telegramScheduledDeletion.deleteMany({});
// Every account row survives; the personal data carried on its own
// columns does not. Same classification contract as the model list.
await tx.user.updateMany({ data: USER_RESET });
await tx.user.updateMany({
data: {
heightCm: null,
dateOfBirth: null,
gender: null,
insightsPrivacyMode: "aggregated",
insightsCachedAt: null,
insightsCachedText: null,
telegramBotToken: null,
telegramChatId: null,
telegramEnabled: false,
withingsClientIdEncrypted: null,
withingsClientSecretEncrypted: null,
onboardingCompletedAt: null,
},
});
return perModel;
},
{
timeout: WIPE_TRANSACTION_TIMEOUT_MS,
maxWait: WIPE_TRANSACTION_MAX_WAIT_MS,
},
);
return {
measurements: measurements.count,
intakeEvents: intakeEvents.count,
medications: medications.count,
apiTokens: apiTokens.count,
withingsConnections: withingsConnections.count,
authChallenges: authChallenges.count,
notificationChannels: notificationChannels.count,
pushSubscriptions: pushSubscriptions.count,
telegramScheduledDeletions: telegramScheduledDeletions.count,
};
});
const deletedRows = Object.values(counts).reduce((sum, n) => sum + n, 0);
// Written after the transaction commits, so the row cannot be removed by the
// AuditLog delete inside it. The instance's whole audit history goes; this
// one row stays as the receipt for the erasure the operator asked for.
await auditLog("admin.data.clear", {
userId: user.id,
ipAddress: getClientIp(request),
details: result,
details: { deletedRows, models: counts },
});
annotate({
meta: {
deleted_rows: deletedRows,
models_wiped: ADMIN_WIPE_MODELS.length,
models_with_rows: Object.keys(counts).length,
},
});
// v1.16.9 — the wipe touched every user's rows; clear every cache
// bucket so no per-user payload survives the reset.
invalidateAllCaches();
return apiSuccess({ cleared: true, ...result });
return apiSuccess({ cleared: true, deletedRows, models: counts });
});
+9 -18
View File
@@ -46,16 +46,14 @@ export function DangerZoneSection() {
// Idempotency-Key prevents a double-submit from re-running the
// destructive transaction, matching the Backups restore path.
const idempotencyKey = `admin-data-wipe-${crypto.randomUUID()}`;
// The server returns one count per table that actually held rows. It
// used to return nine fixed names, which is how nobody noticed the
// wipe had stopped covering the schema: the result line read the same
// whether it had cleared nine tables or ninety.
return apiDelete<{
measurements: number;
intakeEvents: number;
medications: number;
apiTokens: number;
withingsConnections: number;
authChallenges: number;
notificationChannels: number;
pushSubscriptions: number;
telegramScheduledDeletions: number;
cleared: boolean;
deletedRows: number;
models: Record<string, number>;
}>(
"/api/admin/data",
{ confirm: WIPE_CONFIRM_TOKEN },
@@ -66,15 +64,8 @@ export function DangerZoneSection() {
queryClient.invalidateQueries();
setWipeMsg(
t("admin.deletedResult", {
measurements: data.measurements,
medications: data.medications,
intakeEvents: data.intakeEvents,
apiTokens: data.apiTokens,
withingsConnections: data.withingsConnections,
authChallenges: data.authChallenges,
notificationChannels: data.notificationChannels,
pushSubscriptions: data.pushSubscriptions,
telegramScheduledDeletions: data.telegramScheduledDeletions,
rows: data.deletedRows,
tables: Object.keys(data.models).length,
}),
);
},
+86 -14
View File
@@ -434,6 +434,46 @@ export const WIPE_OWNER_FIELDS: Readonly<Record<string, readonly string[]>> = {
AccountGrant: ["grantorId", "granteeId"],
};
/**
* Models the ADMIN wipe keeps although the per-account wipe deletes them.
*
* `DELETE /api/admin/data` is the same action pointed at every account at
* once: the operator clearing a box before handing it on. It therefore reads
* {@link WIPE_MODELS} rather than a list of its own — that list was written
* inline once and had drifted to nine tables against a schema of a hundred and
* twenty-two by the time anybody counted.
*
* The two actions differ in exactly one place, and it is written here rather
* than folded into the route so the difference has to be argued for.
*/
export const ADMIN_WIPE_EXEMPT: Readonly<Record<string, string>> = {
AccountGrant:
"a grant is the only way into a managed profile — that record has no credentials of its own, so deleting every grant at once would leave a set of accounts nobody can open, administer or delete. This action preserves accounts by definition, and an access edge without the record behind it is not personal data: everything those profiles hold is wiped with everyone else's.",
};
/**
* Every model the admin wipe deletes, for all accounts, children first.
*
* Derived, so a model added to {@link WIPE_MODELS} is in both wipes at once
* and a model excused from this one has to say why above.
*/
export const ADMIN_WIPE_MODELS: readonly string[] = WIPE_MODELS.filter(
(model) => !(model in ADMIN_WIPE_EXEMPT),
);
/**
* Budget for a wipe transaction.
*
* Prisma's default interactive-transaction timeout is 5 s, which was already
* optimistic for thirteen `deleteMany` statements and is not survivable for
* the full model set on an account with a large import behind it — let alone
* for the admin wipe, which runs the same set across every account. The whole
* action is one transaction on purpose: a half-applied wipe is worse than
* none, because the person is told it succeeded either way.
*/
export const WIPE_TRANSACTION_TIMEOUT_MS = 120_000;
export const WIPE_TRANSACTION_MAX_WAIT_MS = 15_000;
/** Prisma client delegate key for a model name (`MoodEntry` → `moodEntry`). */
export function wipeDelegateKey(model: string): string {
return model.charAt(0).toLowerCase() + model.slice(1);
@@ -456,14 +496,37 @@ export interface UserScopedDeleteDelegate {
deleteMany(args: { where: { userId: string } }): Promise<{ count: number }>;
}
/** The same slice for the admin wipe, which selects no account at all. */
export interface UnscopedDeleteDelegate {
deleteMany(args: Record<string, never>): Promise<{ count: number }>;
}
interface RawDeleteDelegate {
deleteMany(args: { where?: object }): Promise<{ count: number }>;
}
/**
* The Prisma delegate for a model name, or a thrown error naming the model.
*
* One documented cast: {@link WIPE_MODELS} is a literal list of model names
* checked against `schema.prisma` by the completeness test, and every entry
* indexes a delegate that exposes `deleteMany`. The test also asserts every
* entry resolves, so an unresolvable name fails the build rather than the
* request.
*/
function rawDelegate(client: object, model: string): RawDeleteDelegate {
const delegate = (client as Record<string, RawDeleteDelegate>)[
wipeDelegateKey(model)
];
if (!delegate || typeof delegate.deleteMany !== "function") {
throw new Error(`No Prisma delegate for wipe model "${model}"`);
}
return delegate;
}
/**
* Resolve a model name to its delegate on a client or transaction handle.
*
* One documented cast: `WIPE_MODELS` is a literal list of model names checked
* against `schema.prisma` by the completeness test, and every entry indexes a
* delegate that exposes `deleteMany`. The test also asserts every entry
* resolves, so an unresolvable name fails the build rather than the request.
*
* A model listed in {@link WIPE_OWNER_FIELDS} gets a wrapper that rewrites the
* caller's `{ userId }` into that model's own ownership predicate. The caller
* still says "this account's rows" and still gets a count back; only the
@@ -474,15 +537,7 @@ export function resolveWipeDelegate(
client: object,
model: string,
): UserScopedDeleteDelegate {
const delegate = (
client as Record<
string,
{ deleteMany(args: { where: object }): Promise<{ count: number }> }
>
)[wipeDelegateKey(model)];
if (!delegate || typeof delegate.deleteMany !== "function") {
throw new Error(`No Prisma delegate for wipe model "${model}"`);
}
const delegate = rawDelegate(client, model);
if (!WIPE_OWNER_FIELDS[model]) {
return delegate as UserScopedDeleteDelegate;
}
@@ -491,3 +546,20 @@ export function resolveWipeDelegate(
delegate.deleteMany({ where: wipeWhere(model, where.userId) }),
};
}
/**
* Resolve a model name to a delegate that deletes every row of it.
*
* The admin wipe asks a different question from the per-account one — "every
* row of this model" rather than "this account's rows" — so it gets its own
* resolver instead of a `where` the caller has to remember to leave out.
* {@link WIPE_OWNER_FIELDS} is irrelevant here by construction: with no
* account to select, a row that belongs to two of them is deleted once.
*/
export function resolveGlobalWipeDelegate(
client: object,
model: string,
): UnscopedDeleteDelegate {
const delegate = rawDelegate(client, model);
return { deleteMany: () => delegate.deleteMany({}) };
}