Files
HealthLog/src/lib/data-wipe/wipe-plan.ts
T
Marc-André Bombeck d81a6ddf25 feat(environment): fill air quality for every past day with entries
A background job fetches air quality, pollen and UV for every earlier
local day with a reading, mood entry, dose, symptom or workout: after the
update (boot and nightly discovery), when the home is set and when air
quality is switched on. A day with a weather row gets its air quality; a
day without one gets both, at the covering location period or else at the
home, also before the home was set. Days the source does not reach (before
2013 in Europe, before August 2022 elsewhere) stay empty and are not
counted.

Pending days are grouped by coarse location and cut into ranges of up to
90 days, joining days at most 14 apart, newest first. A run holds itself to
300 of the account's 400 daily calls and half of each instance window,
stores its progress on the user (migration 0391) and sends a follow-up while
it makes progress; a budget stop waits for the next discovery. The
Location & weather card shows the progress.

The air-quality client now asks pollen and dust from 2021 and skips the
request outside Europe before CAMS global starts.
2026-10-09 09:43:01 +02:00

671 lines
30 KiB
TypeScript

/**
* The one declaration of what "Delete All Data" means.
*
* The route this feeds used to carry its delete calls inline. That list was
* correct on the day it was written and then stopped being correct, silently,
* every time a model was added to `prisma/schema.prisma` — a new table is
* user-scoped from birth and outside the wipe by default, and nothing anywhere
* noticed. Sixty-odd tables accumulated on the wrong side of a confirmation
* dialog that says "permanently delete all your health data".
*
* So the list lives here, next to its own exemption list, and
* `src/__tests__/data-wipe-completeness.test.ts` enumerates the schema and
* fails when a model belongs to neither. Adding a model to the schema now
* forces a decision instead of granting a default.
*
* ── The boundary ───────────────────────────────────────────────────────────
*
* This action erases everything the account owns EXCEPT what is needed to sign
* in again. Removing the sign-in credentials too would not be data deletion,
* it would be account deletion — and that has its own route
* (`src/app/api/settings/account/route.ts`, which deletes the `User` row and
* lets the schema's `onDelete: Cascade` do the rest).
*
* Everything in {@link WIPE_MODELS} is hard-deleted, including rows that were
* only soft-deleted before: `deleteMany({ where: { userId } })` carries no
* `deletedAt` filter, so a tombstone goes with the record.
*
* ── Why the list is ordered ────────────────────────────────────────────────
*
* Correctness does not depend on the order — every child of a wiped model
* cascades at the database level. The order exists so the per-model counts in
* the audit row are truthful: delete a parent first and its children are gone
* before their own `deleteMany` runs, which would report 0 for rows that did
* exist. Children therefore come before their parents, and the completeness
* test asserts that property rather than trusting the author.
*/
import { Prisma } from "@/generated/prisma/client";
/**
* Every user-scoped model this action deletes, children before parents.
*
* Names are Prisma model names; {@link wipeDelegateKey} maps each to its
* client delegate. Keeping model names (not delegate keys) is what lets the
* completeness test compare this list against `schema.prisma` literally.
*/
export const WIPE_MODELS = [
// ── Measurements and their derived tiers ────────────────────────────────
"Measurement",
// The rollup tier is a per-day, per-metric reconstruction of the same
// history. A wipe that leaves it behind is not a wipe.
"MeasurementRollup",
// The intraday cumulative profile is the hourly shape of a day that the
// fold kept after deleting the samples it came from. It is that day's
// record and nothing else holds it, so a wipe that leaves it behind leaves
// behind an account's activity pattern.
"IntradayCumulativeProfile",
// That the fold repair went through this account's means. With the readings
// gone it describes nothing; without it the repair simply runs once more.
"MeasurementFoldRepair",
// The health score as it was shown, one row per local day. Not a cache of
// the live computation — nothing can rebuild a past day once the readings
// behind it have moved — so leaving it behind leaves behind a record of how
// the account was doing, day by day, after a confirmation that said
// otherwise.
"HealthScoreRecord",
// The completion ledger before the reminder it hangs off: it cascades either
// way, but the child deleted first is what keeps its own count truthful on
// the audit row (v1.37.20, #223 / iOS #68).
"MeasurementReminderEvent",
"MeasurementReminder",
// ── Medication ──────────────────────────────────────────────────────────
"MedicationIntakeEvent",
"MedicationPauseEra",
"MedicationSideEffect",
"MedicationInventoryItem",
"MedicationIntakeImportJob",
"MedicationComplianceRollup",
// The account's own category names (v1.40, #1041). No child rows; the
// medications that name one go with their own entry.
"MedicationCategoryLabel",
// The courses (v1.40, #1024), before the medications they cascade from.
"MedicationCourse",
// ── Mood ────────────────────────────────────────────────────────────────
// The day context, before the entry it hangs off: it cascades either way,
// but a parent deleted first leaves the child's own count reporting zero for
// rows that existed, and the confirmation screen quotes those counts.
"MoodContext",
// Derived, and wiped anyway. The forecast rows are recomputable from the
// entries above — which is why they stay out of the backup — but they are
// still a day-by-day statement about somebody's mood, and leaving them
// behind after a screen that promised to delete all health data would leave
// the shape of the record standing where the record used to be.
"MoodPrediction",
"MoodEntry",
"MoodEntryRollup",
"MoodReminderDispatch",
"MoodTagHidden",
// ── Visits ──────────────────────────────────────────────────────────────
// The three link tables cascade from the encounter AND from the far side
// they point at — a lab result, a document, a condition episode — so they
// are deleted before any of those, or their per-model counts read zero on an
// audit row that claims to say what was removed. The encounter follows its
// own links; the address book it points at is a parent and goes last.
"EncounterDocumentLink",
"EncounterLabLink",
"EncounterConditionLink",
"Encounter",
// ── Vaccinations ────────────────────────────────────────────────────────
// Same ordering argument as the visit links: the document link cascades
// from the dose AND from the stored document, so it is deleted before
// either or its count reads zero on the audit row. The dose points at a
// practitioner and an encounter with SetNull, which is why it goes here,
// ahead of both.
"VaccinationDocumentLink",
"VaccinationRecord",
// v1.42 (#1005) — the person's own vaccine definitions. After the doses,
// which point at them with SetNull, so neither count reads short.
"CustomVaccine",
// ── Clinical record ─────────────────────────────────────────────────────
"MentalHealthAssessment",
"LabResult",
"Biomarker",
"CustomMetricEntry",
"MenstrualCycle",
"CycleDayLog",
"CycleSymptom",
"CycleProfile",
"CyclePrediction",
"Allergy",
"FamilyHistoryEntry",
"IllnessDayLog",
// Before its definition and before the episode it may point at (SetNull).
"SymptomEvent",
// v1.42 — the person's life events. Nothing points at them.
"LifeEvent",
"EcgRecording",
"NutrientIntakeDay",
// ── Activity ────────────────────────────────────────────────────────────
"WorkoutInsight",
"WorkoutInsightGenerationClaim",
"PersonalRecord",
"StrainTrimpCache",
// ── Context and engagement ──────────────────────────────────────────────
"EnvironmentContext",
"EnvironmentTravelLocation",
"HealthProfileFactRevision",
"CorrelationPattern",
"UserHealthProfile",
"UserAchievement",
"ArrivalReaction",
"DismissedPriorityItem",
"RecommendationFeedback",
// ── Documents ───────────────────────────────────────────────────────────
"DocumentContentIndex",
"DocumentThumbnail",
"DocumentThumbnailFailure",
"DocumentConditionLink",
// The memory of which imported documents the person deleted (#1038). The
// documents themselves go below; keeping the memory would make a fresh
// import after the wipe skip files for a decision about a record that no
// longer exists.
"DocumentImportKey",
// Further import keys that resolved to a document by content; a child of
// the document, removed before it so the counts are truthful.
"DocumentSourceAlias",
// The person's own connections to Paperless-ngx / Papra (#1038), with their
// encrypted API tokens. A wipe that left them would leave a working
// credential to another system behind an account that erased everything.
"DocumentSourceConnection",
"ExtractedFact",
// v1.40 — background AI runs over a document or a lab scan. A queued scan
// holds the uploaded report until it is read, and a finished run holds what
// was read off it; a child of the document, so it goes before it.
"DocumentAiRun",
"ImportJob",
// v1.42 (#1173) — when each HealthKit type last arrived. Diagnostic, but a
// record of what the account's phone sent and when.
"HealthKitTypeSync",
// v1.42 (#959) — handover links, on both sides: the ones minted for this
// profile, and the ones this account minted for a profile it looks after.
// The second is the AccountGrant argument: an account that asked for
// everything of its own to be deleted must not leave a live link behind.
"ManagedProfileHandover",
// ── AI ──────────────────────────────────────────────────────────────────
"CoachConversation",
"CoachFact",
"CoachPlan",
"CoachReminder",
"CoachUsage",
"InsightNarrative",
"InsightStatusCache",
"ProviderHealth",
// ── Sharing ─────────────────────────────────────────────────────────────
// Deleting the row is the revocation: `resolveShareToken` /
// `resolveShareGateState` both resolve an unknown `tokenHash` to `null`, and
// null is the same blunt 404 a revoked link gets.
"ClinicianShareLink",
// v1.36.0 — account grants, on BOTH sides (see WIPE_OWNER_FIELDS): the ones
// this account made over its own record, and the ones it holds over someone
// else's.
//
// Deleting rather than revoking, and the difference is worth stating because
// the domain module refuses to delete these rows anywhere else. Revocation
// preserves the row precisely because the row is the consent record — but a
// person who types the confirmation on "Delete All Data" is asking for the
// record itself to be gone, and the same request already takes their entire
// audit history and their consent receipts with it. Leaving a grant behind
// would also leave a delegate holding live access to an account that just
// erased everything it had.
"AccountGrant",
// ── Integrations ────────────────────────────────────────────────────────
"WithingsConnection",
"WithingsOAuthState",
"WhoopConnection",
"WhoopOAuthState",
"WhoopConnectTicket",
"FitbitConnection",
"FitbitOAuthState",
"GoogleHealthConnection",
"GoogleHealthOAuthState",
"McpOAuthConnection",
"IntegrationStatus",
// ── Notification transports ─────────────────────────────────────────────
"NotificationChannel",
"PushSubscription",
"PushAttempt",
"NotificationEvent",
// A Guardian egress claim belongs to both the record whose data prompted
// the notification and the recipient whose channel would receive it. A
// wipe by either account must remove it; see WIPE_OWNER_FIELDS below.
"NotificationEgressAuthorization",
"Device",
"TelegramScheduledDeletion",
"TelegramPromptContext",
// ── API surface and operational trails ──────────────────────────────────
"StepUpElevation",
// Cached response envelopes — the bodies are the health payloads the API
// just returned.
"IdempotencyKey",
"DataBackup",
// The restores run from those copies: which copy, who asked, and how each
// ended. About this account's record, so they go with it.
"BackupRestoreJob",
// What this host last put in the operator's bucket for this account. The
// objects themselves are deleted by the purge request the wipe writes
// (`requestOffhostPurge`); this row, saying when they were written, is about
// this account and goes with it.
"OffhostBackupState",
// Where this account stands in the two backup passes. About this account,
// and meaningless without it.
"BackupPassAttempt",
"AuditLog",
"ConsentReceipt",
"UserKnownDevice",
// v1.39 — the setup answers and the step ledger. The USER_RESET block below
// already puts the wizard of today back to the start; this is the same
// decision for the needs-based flow, and it belongs on the same side of the
// line for the same reason: the record the answers were about is gone, so
// the answers describe nothing.
"OnboardingRecord",
// ── Parents, last ───────────────────────────────────────────────────────
"Medication",
"MoodTag",
"MoodTagCategory",
"CustomMetric",
"IllnessEpisode",
"SymptomDefinition",
"Workout",
"InboundDocument",
// Encounters point here with SetNull, so the address book outlives the
// visits it is attached to and is removed after them.
"Practitioner",
"ApiToken",
] as const;
/**
* User-scoped models this action deliberately keeps, each with the reason.
*
* The rule is narrow and stated in the confirmation copy: what the account
* needs to sign in again survives. Anything added here that is not a sign-in
* credential needs a reason that survives being read aloud to the person who
* typed the confirmation.
*/
export const WIPE_EXEMPT: Readonly<Record<string, string>> = {
Session:
"the signed-in session performing the wipe; deleting it signs the person out mid-action and the account is preserved",
RefreshToken:
"per-device sign-in continuity; deleting it signs every paired device out and is account deletion by another name",
Passkey: "a sign-in credential — without it the account may be unreachable",
WebauthnMfaCredential:
"a second-factor credential; removing it weakens the account rather than clearing data",
MfaRecoveryCode:
"the second-factor recovery path; removing it can lock the account out permanently",
MfaChallenge:
"an in-flight authentication challenge, seconds-lived and swept by its own expiry",
AuthChallenge:
"an in-flight WebAuthn challenge, seconds-lived and swept by its own expiry",
TrustedDevice:
"the remember-this-device grant that suppresses a second-factor prompt; a sign-in credential",
OidcNativeHandoff:
"an in-flight one-time sign-in code, minutes-lived and swept by its own expiry",
InviteRedemption:
"the instance's registration ledger, not the person's record; even account deletion only detaches it (onDelete: SetNull) rather than removing it",
};
/**
* Models that carry no `userId` and belong to the instance rather than to any
* account, so no wipe of a single account may touch them.
*
* Everything else without a `userId` reaches a wiped or exempt model through a
* chain of `onDelete: Cascade` relations and needs no entry here; the
* completeness test proves that reachability rather than assuming it.
*/
export const INSTANCE_SCOPED: Readonly<Record<string, string>> = {
AppSettings: "instance-wide configuration singleton",
EncryptionKeyCanary:
"one known value per configured encryption key id, checked at boot; belongs to the instance's keys, not to any account",
RateLimit: "instance-wide rate-limit buckets, keyed by string, self-expiring",
HostMetric: "instance-wide host telemetry, not attributable to an account",
OffhostBackupKeyUse:
"per encryption key, when the nightly off-host run last wrote an object needing it; says nothing about any account",
OffhostPurgeRequest:
"the deletions an account deletion or wipe asked of the off-host bucket; written by the wipe itself and removed once the bucket holds nothing of the account",
CycleSymptomCategory:
"seeded catalogue shared by every account; a user's custom symptoms live on CycleSymptom",
IllnessSymptom: "seeded symptom catalogue shared by every account",
InviteToken:
"the instance's invite ledger; the creator relation cascades on account deletion, and a data wipe keeps the account",
User: "the account itself — preserved by definition; its columns are handled by USER_RESET / USER_KEPT_FIELDS",
};
/**
* The `User` columns this action clears, and the value each is reset to.
*
* The same rot applies here: a column added to `User` is personal data from
* birth and outside the reset by default. The completeness test compares these
* keys against the model's scalar columns, so a new column has to be
* classified.
*
* `Prisma.DbNull` rather than `null` for the JSON columns — on a nullable JSON
* field `null` is the JSON value `null`, not SQL NULL.
*/
export const USER_RESET = {
// Body and identity-of-the-record
heightCm: null,
dateOfBirth: null,
gender: null,
hasDiabetes: false,
fullName: null,
displayName: null,
insurerName: null,
insuranceNumberEncrypted: null,
insurerIkNumber: null,
lastReportPracticeName: null,
avatarBytes: null,
avatarContentType: null,
avatarUpdatedAt: null,
// Where the person lives — an environment-context input, and a location
homeLat: null,
homeLon: null,
homeLabel: null,
homeTimezone: null,
homeSince: null,
homeLocationEncrypted: null,
environmentAirQualityEnabled: true,
environmentAqHistoryJson: Prisma.DbNull,
// Derived AI output cached on the row
insightsPrivacyMode: "aggregated",
insightsCachedAt: null,
insightsCachedText: null,
insightsCachedLocale: null,
insightsSnapshotHash: null,
insightsWarmFailedAt: null,
insightsBriefingRerollDate: null,
morningDigestRefreshedOn: null,
insightsExcludeMetrics: [],
insightsLayoutJson: Prisma.DbNull,
// AI provider configuration and keys
aiProvider: null,
aiModel: null,
aiBaseUrl: null,
aiAnthropicKeyEncrypted: null,
aiLocalKeyEncrypted: null,
aiOpenaiKeyEncrypted: null,
aiCompatBaseUrl: null,
aiCompatKeyEncrypted: null,
aiCompatModel: null,
aiProviderChain: Prisma.DbNull,
aiResponseTimeoutSeconds: null,
disableCoach: false,
coachPrefsJson: Prisma.DbNull,
coachLastSeenAt: null,
documentsAutoAiRead: false,
labsLocalOcrEnabled: false,
codexAccessTokenEncrypted: null,
codexRefreshTokenEncrypted: null,
codexTokenExpiresAt: null,
codexConnectedAt: null,
codexConnectionStatus: "disconnected",
useCentralCodex: false,
// Integration credentials and per-integration state
telegramBotToken: null,
telegramChatId: null,
telegramEnabled: false,
withingsClientIdEncrypted: null,
withingsClientSecretEncrypted: null,
whoopClientIdEncrypted: null,
whoopClientSecretEncrypted: null,
fitbitClientIdEncrypted: null,
fitbitClientSecretEncrypted: null,
googleHealthClientIdEncrypted: null,
googleHealthClientSecretEncrypted: null,
nightscoutUrlEncrypted: null,
nightscoutTokenEncrypted: null,
nightscoutAllowPrivateHost: false,
polarAccessTokenEncrypted: null,
polarUserIdEncrypted: null,
polarClientIdEncrypted: null,
polarClientSecretEncrypted: null,
ouraAccessTokenEncrypted: null,
ouraRefreshTokenEncrypted: null,
ouraClientIdEncrypted: null,
ouraClientSecretEncrypted: null,
stravaClientIdEncrypted: null,
stravaClientSecretEncrypted: null,
stravaAccessTokenEncrypted: null,
stravaRefreshTokenEncrypted: null,
stravaAthleteId: null,
stravaLastActivityAt: null,
stravaBackfillCompletedAt: null,
healthKitConfigJson: Prisma.DbNull,
healthKitLastSyncedAt: null,
healthKitLastSyncTrigger: null,
healthKitLastBackgroundSyncAt: null,
lastSyncedAt: null,
sourcePriorityJson: Prisma.DbNull,
// Choices that describe the record rather than the interface
thresholdsJson: Prisma.DbNull,
dashboardWidgetsJson: Prisma.DbNull,
medicationListLayoutJson: Prisma.DbNull,
moodTagLayoutJson: Prisma.DbNull,
documentsLayoutJson: Prisma.DbNull,
reportSelectionJson: Prisma.DbNull,
modulePreferencesJson: Prisma.DbNull,
healthScoreConfigJson: Prisma.DbNull,
globalExcludedInjectionSites: [],
moodReminderEnabled: false,
notificationPrefs: Prisma.DbNull,
// Back to the start of the tour — the record it was built around is gone
onboardingCompletedAt: null,
onboardingTourCompleted: false,
onboardingTourProgressJson: Prisma.DbNull,
} satisfies Prisma.UserUpdateInput;
/**
* `User` columns the wipe leaves alone, each with the reason. Same contract as
* {@link WIPE_EXEMPT}: sign-in, identity, and how the interface is rendered.
*/
export const USER_KEPT_FIELDS: Readonly<Record<string, string>> = {
id: "the account's primary key",
username: "the account's sign-in identity",
email: "the account's sign-in identity and recovery address",
passwordHash: "a sign-in credential — the account password",
role: "the account's authorisation level, set by the operator",
oidcIssuer: "a sign-in credential — the external identity binding",
oidcSub: "a sign-in credential — the external identity binding",
createdAt: "when the account was created; the account is preserved",
updatedAt: "row bookkeeping maintained by Prisma",
managedProfileAt:
"whether this record is one somebody else runs — account shape, not record content. Clearing it would strip the guardian's reach from a record that has no credentials of its own, leaving an account nobody can open, administer or delete. Emptying a record is not emancipating the person in it.",
totpSecretEncrypted: "a second-factor credential",
totpConfirmedAt: "second-factor enrolment state",
totpLastStep:
"second-factor replay guard; clearing it re-opens a replay window",
mfaEnforced: "the operator's per-account second-factor policy",
passkeyUpgradeNudgeDismissed:
"an interface nudge the person already dismissed",
timezone: "an interface preference — how dates and times are rendered",
locale: "an interface preference — which language the interface speaks",
unitPreference: "an interface preference — metric or imperial rendering",
timeFormat: "an interface preference — 12- or 24-hour rendering",
dateFormat: "an interface preference — date-order rendering",
glucoseUnit: "an interface preference — mg/dL or mmol/L rendering",
disclaimerAcknowledgedAt:
"the account's acknowledgement of the medical disclaimer, which is about the app and not about the record",
disclaimerAcknowledgedVersion:
"the version of the medical disclaimer the account acknowledged",
documentQuotaBytes:
"a per-account storage quota set by the operator, not by the person",
syncResetAt:
"sync bookkeeping, not record content: the wipe sets it to its own time rather than clearing it, so a paired client's delta cursor from before the wipe is refused and the client starts over",
};
/**
* Wiped models that do NOT carry a `userId`, and the columns that say the row
* belongs to an account.
*
* The convention is one `userId` column and almost every table keeps it. A
* table that relates two accounts cannot: an account grant names an owner and
* a delegate, and a wipe by either of them has to take the row.
*
* Declared here rather than at the wipe route, so the route keeps asking for
* exactly one thing — "delete this model's rows for this account" — and the
* question of what "for this account" means for a given model stays in the
* file that already answers every other question about the wipe.
* {@link resolveWipeDelegate} translates; the route is unaware.
*/
export const WIPE_OWNER_FIELDS: Readonly<Record<string, readonly string[]>> = {
// The record owner owns the import payload. The actor is durable audit
// attribution and must not let deleting a manager erase another record's
// import history.
MedicationIntakeImportJob: ["recordUserId"],
NotificationEvent: ["recordUserId"],
NotificationEgressAuthorization: ["recordUserId", "recipientUserId"],
ManagedProfileHandover: ["profileId", "createdById"],
// Both sides. Wiping only the grantor side would leave the account still
// holding read access to other people's records after it asked for
// everything of its own to be deleted.
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);
}
/**
* The `where` that selects one account's rows of `model`.
*
* `{ userId }` for the convention, an `OR` over the declared owner columns for
* the models that relate two accounts.
*/
export function wipeWhere(model: string, userId: string): object {
const fields = WIPE_OWNER_FIELDS[model];
if (!fields) return { userId };
return { OR: fields.map((field) => ({ [field]: userId })) };
}
/** The narrow slice of a Prisma delegate the wipe loop uses. */
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.
*
* 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
* translation moved, and it moved to the file that declares the columns rather
* than to the loop that does not know about them.
*/
export function resolveWipeDelegate(
client: object,
model: string,
): UserScopedDeleteDelegate {
const delegate = rawDelegate(client, model);
if (!WIPE_OWNER_FIELDS[model]) {
return delegate as UserScopedDeleteDelegate;
}
return {
deleteMany: ({ where }) =>
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({}) };
}