diff --git a/docs/self-hosting/reproductive-health-data.md b/docs/self-hosting/reproductive-health-data.md index 557f96633..128dbc6d3 100644 --- a/docs/self-hosting/reproductive-health-data.md +++ b/docs/self-hosting/reproductive-health-data.md @@ -90,8 +90,11 @@ different things depending on which button you press. it out and it is gone from the app, from exports, and from the doctor report. The row itself stays in PostgreSQL as a tombstone, which is what lets a phone that was offline at delete time learn that the entry - went away. No job removes cycle tombstones on a schedule, so the row - stays until one of the two actions below. + went away. A nightly job removes a tombstone 75 days after the delete + (the same horizon as the sync feed's); until then, or until one of the + two actions below, the row stays. A start that was folded into a later + cycle stays for as long as that cycle does, because deleting the later + cycle gives it back. - **Settings → Delete All Data** removes the rows outright, tombstones included. Everything the account owns goes except what you sign in with; the confirmation dialog lists it. diff --git a/src/lib/jobs/__tests__/soft-delete-retention-register.test.ts b/src/lib/jobs/__tests__/soft-delete-retention-register.test.ts new file mode 100644 index 000000000..70b86563b --- /dev/null +++ b/src/lib/jobs/__tests__/soft-delete-retention-register.test.ts @@ -0,0 +1,128 @@ +/** + * Structural guard: every model that soft-deletes has exactly one retention + * entry, and every entry names a model that soft-deletes. + * + * Twenty of the twenty-three soft-delete models had no purge at all: their + * tombstones stayed for as long as the instance ran. The set is derived from + * `prisma/schema.prisma` (a `deletedAt` field), so a model that starts + * soft-deleting cannot ship without saying how long its tombstones live. + */ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + readDeclaredSchema, + type DeclaredSchema, +} from "@/__tests__/helpers/prisma-schema-names"; +import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client"; +import { + SOFT_DELETE_RETENTION, + purgeModels, + type SoftDeleteRetention, +} from "@/lib/jobs/soft-delete-retention"; + +function problems( + schema: DeclaredSchema, + register: Readonly>, +): string[] { + const found: string[] = []; + const softDeleting = schema.models.filter((model) => + model.fields.some((f) => f.name === "deletedAt"), + ); + for (const model of softDeleting) { + const entry = register[model.name]; + if (!entry) { + found.push(`${model.name}: soft-deletes but has no retention entry`); + continue; + } + if (entry.kind !== "purge") continue; + if (entry.table !== model.physicalName) { + found.push( + `${model.name}: entry purges ${entry.table}, the model is ${model.physicalName}`, + ); + } + for (const column of ["user_id", "deleted_at"]) { + if (!model.fields.some((f) => f.physicalName === column)) { + found.push(`${model.name}: the purge needs a ${column} column`); + } + } + } + const names = new Set(softDeleting.map((m) => m.name)); + for (const name of Object.keys(register)) { + if (!names.has(name)) { + found.push(`${name}: has a retention entry but does not soft-delete`); + } + } + return found; +} + +describe("soft-delete retention register", () => { + const schema = readDeclaredSchema(); + + it("covers every soft-delete model exactly once", () => { + const softDeleting = schema.models.filter((model) => + model.fields.some((f) => f.name === "deletedAt"), + ); + // A reader that stopped matching would find nothing and pass. + expect(softDeleting.length).toBeGreaterThanOrEqual(23); + const found = problems(schema, SOFT_DELETE_RETENTION); + expect(found, found.join("\n")).toEqual([]); + }); + + it("keeps every tombstone at least as long as the sync feed's horizon", () => { + for (const [model, entry] of Object.entries(SOFT_DELETE_RETENTION)) { + if (entry.kind !== "purge") continue; + expect(entry.days(), model).toBeGreaterThanOrEqual( + TOMBSTONE_RETENTION_DAYS, + ); + } + expect(purgeModels()).toContain("MenstrualCycle"); + expect(purgeModels()).toContain("CycleDayLog"); + }); + + it("explains every entry that departs from the default", () => { + for (const [model, entry] of Object.entries(SOFT_DELETE_RETENTION)) { + if (entry.kind !== "purge") continue; + const departs = + entry.keep !== undefined || entry.days() !== TOMBSTONE_RETENTION_DAYS; + if (departs) expect(entry.why?.length ?? 0, model).toBeGreaterThan(40); + } + }); + + it("counter-check: names a new soft-delete model, a stale entry and a wrong table", () => { + const dir = mkdtempSync(join(tmpdir(), "hl-retention-")); + try { + const path = join(dir, "schema.prisma"); + writeFileSync( + path, + `model Widget { + id String @id + userId String @map("user_id") + deletedAt DateTime? @map("deleted_at") + @@map("widgets") +} +model Gadget { + id String @id + userId String @map("user_id") + deletedAt DateTime? @map("deleted_at") + @@map("gadgets") +} +`, + ); + const synthetic = readDeclaredSchema(path); + const found = problems(synthetic, { + Gadget: { kind: "purge", table: "gadget", days: () => 75 }, + Gone: { kind: "purge", table: "gone", days: () => 75 }, + }); + expect(found).toEqual([ + "Widget: soft-deletes but has no retention entry", + "Gadget: entry purges gadget, the model is gadgets", + "Gone: has a retention entry but does not soft-delete", + ]); + } finally { + rmSync(dir, { recursive: true }); + } + }); +}); diff --git a/src/lib/jobs/queue-runtime.ts b/src/lib/jobs/queue-runtime.ts index 14a722a49..56a12bda8 100644 --- a/src/lib/jobs/queue-runtime.ts +++ b/src/lib/jobs/queue-runtime.ts @@ -313,6 +313,9 @@ export const QUEUE_RUNTIME: Readonly> = { "At most 40 batches of 5 000 rows per leg.", ), "document-tombstone-purge": short("One deleteMany."), + "soft-delete-purge": short( + "At most 40 batches of 5 000 rows per table, over a few small tables.", + ), "document-summary-reaper": short("One updateMany."), "cycle-prediction-refresh": short( "Arithmetic per eligible account, no provider call.", diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index ef5f655b2..3b2215244 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -290,6 +290,12 @@ import { handleDocumentPurge, type DocumentPurgePayload, } from "@/lib/jobs/document-purge"; +import { + SOFT_DELETE_PURGE_QUEUE, + SOFT_DELETE_PURGE_CRON, + handleSoftDeletePurge, + type SoftDeletePurgePayload, +} from "@/lib/jobs/soft-delete-purge"; import { DOCUMENT_SUMMARY_REAPER_QUEUE, DOCUMENT_SUMMARY_REAPER_CRON, @@ -580,6 +586,9 @@ const allQueues = [ // the daily schedule silently no-ops and "deleted" documents hold backup // weight forever. DOCUMENT_PURGE_QUEUE, + // Nightly purge of expired tombstones for every soft-delete model the + // retention register hands to it (`soft-delete-retention.ts`). + SOFT_DELETE_PURGE_QUEUE, // Document vault — hourly reaper for summary rows stuck on PENDING (a // job dead past its retry budget). Without this entry the schedule // silently no-ops and a dead "wird generiert" claim never converges to @@ -779,6 +788,9 @@ const schedules: ScheduleEntry[] = [ // Document vault — daily 04:10 Europe/Berlin purge for tombstoned // documents past the 30-day undo grace. [DOCUMENT_PURGE_QUEUE, DOCUMENT_PURGE_CRON, cronIsTheRetry], + // Daily 03:42 Europe/Berlin, after the sync-domain tombstone cleanup. The + // next night is the retry: the purge picks up whatever a failed run left. + [SOFT_DELETE_PURGE_QUEUE, SOFT_DELETE_PURGE_CRON, cronIsTheRetry], // Document vault — hourly heal of summary rows stuck on PENDING. The next // tick is the retry: the updateMany is idempotent and the TTL predicate // re-selects anything a failed tick left behind. @@ -1140,6 +1152,14 @@ export async function registerMaintenanceQueues( { localConcurrency: 1 }, handleDocumentPurge, ); + // Single-flight like every other cleanup queue; both deletes are bounded + // and idempotent, so a duplicate tick finds nothing. + await createAndWork( + boss, + SOFT_DELETE_PURGE_QUEUE, + { localConcurrency: 1 }, + handleSoftDeletePurge, + ); // v1.40 — background document AI runs. Each job is one run, under the lock // `lockedPass` takes per run id, so a redelivery never reads beside the // first delivery; the claim inside is conditional as well. diff --git a/src/lib/jobs/soft-delete-purge.ts b/src/lib/jobs/soft-delete-purge.ts new file mode 100644 index 000000000..6699b6ce7 --- /dev/null +++ b/src/lib/jobs/soft-delete-purge.ts @@ -0,0 +1,63 @@ +/** + * Nightly purge of expired tombstones for every soft-delete model the + * register hands to it (`SOFT_DELETE_RETENTION`, kind `purge`). + * + * One leg per table, one table after the other: the tables are small next to + * the sync domains, and the job has the night. A leg that stops at its batch + * cap, or leaves an account under restore for the next night, says so in the + * run report rather than looking like a night with nothing to do. + */ +import type { Job } from "pg-boss"; + +import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { getWorkerPrisma } from "@/lib/jobs/reminder/shared"; +import { purgeSoftDeletedRows } from "@/lib/jobs/soft-delete-retention"; +import { withBackgroundEvent } from "@/lib/logging/background"; + +export const SOFT_DELETE_PURGE_QUEUE = "soft-delete-purge"; +/** Daily at 03:42 Europe/Berlin, after the sync-domain tombstone cleanup. */ +export const SOFT_DELETE_PURGE_CRON = "42 3 * * *"; + +export interface SoftDeletePurgePayload { + triggeredAt?: string; +} + +export async function handleSoftDeletePurge( + jobs: Job[], +): Promise { + void jobs; + return withBackgroundEvent("job.soft_delete_purge", async (evt) => { + try { + const legs = await purgeSoftDeletedRows(getWorkerPrisma()); + const deleted = legs.reduce((sum, leg) => sum + leg.deleted, 0); + const drained = legs.every((leg) => leg.drained); + const deferredAccounts = Math.max( + 0, + ...legs.map((leg) => leg.deferredAccounts), + ); + evt.addMeta("soft_delete_purge_deleted", deleted); + evt.addMeta("soft_delete_purge_drained", drained); + evt.addMeta("soft_delete_purge_deferred_accounts", deferredAccounts); + for (const leg of legs) { + if (leg.deleted > 0) { + evt.addMeta(`soft_delete_purge_${leg.table}`, leg.deleted); + } + } + if (!drained) { + evt.addWarning( + "soft-delete-purge stopped at the batch cap; a backlog remains for the next run", + ); + } + return jobDone({ + deleted, + drained, + deferred_accounts: deferredAccounts, + }); + } catch (err) { + // Rethrow, as the sync-domain purge does: a retention purge that fails + // quietly every night looks exactly like one with nothing to do. + evt.addWarning(`soft-delete-purge failed: ${err}`); + throw err; + } + }); +} diff --git a/src/lib/jobs/soft-delete-retention.ts b/src/lib/jobs/soft-delete-retention.ts new file mode 100644 index 000000000..056f54c78 --- /dev/null +++ b/src/lib/jobs/soft-delete-retention.ts @@ -0,0 +1,178 @@ +/** + * How long a soft-deleted row is kept, for every model that soft-deletes. + * + * A delete in HealthLog tombstones the row (`deletedAt`) so the undo and the + * restore endpoints can bring it back and the `/api/sync/changes` feed can + * tell an offline phone about it. Without a horizon the tombstone is forever: + * a deleted lab result, visit or allergy stayed in the database, in every + * backup and in every key rotation, for as long as the instance ran. + * + * Every model with a `deletedAt` column has exactly one entry here + * (`src/lib/jobs/__tests__/soft-delete-retention-register.test.ts` derives the + * set from the schema). An entry says which job removes the tombstones and + * after how long: + * + * - `sync-feed`: the three dense sync domains, purged by the nightly + * `measurement-tombstone-cleanup` job after `TOMBSTONE_RETENTION_DAYS`. + * - `document-grace`: the document vault, purged by `document-tombstone-purge` + * after its 30-day undo grace (it also carries the import keys over). + * - `purge`: everything else, purged here by `soft-delete-purge`. The horizon + * is `TOMBSTONE_RETENTION_DAYS` unless the entry says otherwise: the same as + * the sync feed's (two of these models ride it, and a cursor older than the + * horizon is already answered with `cursorExpired`), and long past any undo. + * A restore endpoint asked for a purged row finds nothing, as it does for a + * purged measurement. + * + * The delete runs per account under the restore lock, in bounded batches + * (`purgeTombstonesByAccount`). Foreign keys into these tables cascade or set + * null (none restricts), so a purged visit takes its link rows with it and a + * live vaccination that named it simply loses the pointer. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client"; +import { getCoachMessageRetentionDays } from "@/lib/jobs/coach-message-cleanup"; +import { DOCUMENT_PURGE_GRACE_DAYS } from "@/lib/jobs/document-purge"; +import { + purgeTombstonesByAccount, + type TombstonePurgeOutcome, +} from "@/lib/jobs/measurement-tombstone-cleanup"; + +export type SoftDeleteRetention = + | { kind: "sync-feed"; days: number } + | { kind: "document-grace"; days: number } + | { + kind: "purge"; + /** The physical table; a literal, spliced into the purge SQL. */ + table: string; + /** Days a tombstone is kept. */ + days: () => number; + /** + * Extra condition a purgeable row must meet, a literal SQL fragment over + * the alias `t`. For a tombstone something live still depends on. + */ + keep?: string; + /** Why the horizon or the condition differs from the default. */ + why?: string; + }; + +const standard = (table: string): SoftDeleteRetention => ({ + kind: "purge", + table, + days: () => TOMBSTONE_RETENTION_DAYS, +}); + +export const SOFT_DELETE_RETENTION: Readonly< + Record +> = { + Measurement: { kind: "sync-feed", days: TOMBSTONE_RETENTION_DAYS }, + MoodEntry: { kind: "sync-feed", days: TOMBSTONE_RETENTION_DAYS }, + MedicationIntakeEvent: { kind: "sync-feed", days: TOMBSTONE_RETENTION_DAYS }, + InboundDocument: { kind: "document-grace", days: DOCUMENT_PURGE_GRACE_DAYS }, + + // Both ride the sync feed; the default horizon is the feed's. + CycleDayLog: standard("cycle_day_logs"), + MenstrualCycle: { + kind: "purge", + table: "menstrual_cycles", + days: () => TOMBSTONE_RETENTION_DAYS, + keep: `NOT EXISTS (SELECT 1 FROM "menstrual_cycles" a + WHERE a."id" = t."absorbed_into_id" + AND a."deleted_at" IS NULL)`, + why: "A start folded into a later cycle is tombstoned with `absorbedIntoId` and given back when that cycle is deleted (`cycle-boundaries.ts`), so it stays while the cycle that absorbed it is live.", + }, + CoachFact: { + kind: "purge", + table: "coach_facts", + days: getCoachMessageRetentionDays, + why: "A stored answer's trail can name a removed medication fact, and the trail is withheld against the fact rows (`trail-read.ts`); the fact therefore outlives every message that may name it, which is the coach message retention.", + }, + + MentalHealthAssessment: standard("mental_health_assessments"), + MeasurementReminder: standard("measurement_reminders"), + LabResult: standard("lab_results"), + CustomMetric: standard("custom_metrics"), + CustomMetricEntry: standard("custom_metric_entries"), + CoachPlan: standard("coach_plans"), + CoachReminder: standard("coach_reminders"), + IllnessEpisode: standard("illness_episodes"), + IllnessDayLog: standard("illness_day_logs"), + Allergy: standard("allergies"), + FamilyHistoryEntry: standard("family_history_entries"), + Encounter: standard("encounters"), + Practitioner: standard("practitioners"), + VaccinationRecord: standard("vaccination_records"), + CustomVaccine: standard("custom_vaccines"), + LifeEvent: standard("life_events"), +}; + +const TABLE = /^[a-z_]+$/; +const DAY_MS = 86_400_000; + +/** One table's purge, for the run report. */ +export interface SoftDeletePurgeLeg extends TombstonePurgeOutcome { + model: string; + table: string; +} + +/** + * Purge one register entry's expired tombstones. The table name and the extra + * condition come from the register's literals, never from input; the name is + * asserted against a closed pattern before it is spliced. + */ +export async function purgeSoftDeletedTable( + prisma: PrismaClient, + model: string, + now: Date = new Date(), +): Promise { + const entry = SOFT_DELETE_RETENTION[model]; + if (!entry || entry.kind !== "purge" || !TABLE.test(entry.table)) { + throw new Error(`No purge entry for ${model}`); + } + const table = `"${entry.table}"`; + const cutoff = new Date(now.getTime() - entry.days() * DAY_MS); + const keep = entry.keep ? `AND ${entry.keep}` : ""; + const outcome = await purgeTombstonesByAccount({ + prisma, + findRows: (take, skipUserIds) => + prisma.$queryRawUnsafe>( + `SELECT t."id", t."user_id" AS "userId" FROM ${table} t + WHERE t."deleted_at" IS NOT NULL AND t."deleted_at" < $1 + AND NOT (t."user_id" = ANY($2::text[])) ${keep} + LIMIT $3`, + cutoff, + skipUserIds, + take, + ), + // The row is re-checked inside the delete: a restore between the read and + // the delete un-tombstones it, and then it stays. + deleteIds: (tx, ids, userId) => + tx.$executeRawUnsafe( + `DELETE FROM ${table} t + WHERE t."id" = ANY($1::text[]) AND t."user_id" = $2 + AND t."deleted_at" IS NOT NULL AND t."deleted_at" < $3 ${keep}`, + ids, + userId, + cutoff, + ), + }); + return { ...outcome, model, table: entry.table }; +} + +/** Every `purge` entry of the register, in declaration order. */ +export function purgeModels(): string[] { + return Object.entries(SOFT_DELETE_RETENTION) + .filter(([, entry]) => entry.kind === "purge") + .map(([model]) => model); +} + +/** Run every `purge` entry, one table after the other. */ +export async function purgeSoftDeletedRows( + prisma: PrismaClient, + now: Date = new Date(), +): Promise { + const legs: SoftDeletePurgeLeg[] = []; + for (const model of purgeModels()) { + legs.push(await purgeSoftDeletedTable(prisma, model, now)); + } + return legs; +} diff --git a/src/lib/jobs/subsystem-surface.ts b/src/lib/jobs/subsystem-surface.ts index 1aa67653b..ebc49012e 100644 --- a/src/lib/jobs/subsystem-surface.ts +++ b/src/lib/jobs/subsystem-surface.ts @@ -127,6 +127,7 @@ const subsystemSurface = { "mood-prognosis-refresh": { audience: "system" }, "achievement-unlock-sweep": { audience: "system" }, "measurement-tombstone-cleanup": { audience: "system" }, + "soft-delete-purge": { audience: "system" }, "coach-message-cleanup": { audience: "system" }, "workout-insight-claim-cleanup": { audience: "system" }, "note-encryption-backfill": { audience: "system" }, diff --git a/tests/integration/soft-delete-purge.test.ts b/tests/integration/soft-delete-purge.test.ts new file mode 100644 index 000000000..bd14bbb06 --- /dev/null +++ b/tests/integration/soft-delete-purge.test.ts @@ -0,0 +1,165 @@ +/** + * The nightly purge of expired tombstones for the soft-delete models the + * retention register hands to `soft-delete-purge`. + * + * Proves, against the migrated schema: every table's purge statement runs; an + * expired tombstone goes while a recent one and a live row stay; a purged + * visit cascades its links and leaves a live vaccination that named it in + * place; a start folded into a live cycle stays; the coach facts keep their + * longer horizon. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client"; +import { + purgeModels, + purgeSoftDeletedRows, + purgeSoftDeletedTable, +} from "@/lib/jobs/soft-delete-retention"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const USER = "soft-delete-purge-user"; +const NOW = new Date("2026-10-20T03:42:00.000Z"); +const DAY = 86_400_000; +const EXPIRED = new Date(NOW.getTime() - (TOMBSTONE_RETENTION_DAYS + 5) * DAY); +const RECENT = new Date(NOW.getTime() - 10 * DAY); + +const prisma = getPrismaClient(); + +beforeEach(async () => { + await truncateAllTables(prisma); + await prisma.user.create({ + data: { id: USER, username: USER, role: "USER", timezone: "UTC" }, + }); +}); + +describe("soft-delete purge", () => { + it("runs every table's purge against the real schema", async () => { + const legs = await purgeSoftDeletedRows(prisma, NOW); + expect(legs.map((leg) => leg.model)).toEqual(purgeModels()); + expect(legs.length).toBeGreaterThanOrEqual(19); + for (const leg of legs) { + expect(leg, leg.model).toMatchObject({ deleted: 0, drained: true }); + } + }); + + it("removes an expired tombstone and keeps a recent one and a live row", async () => { + const lab = (id: string, deletedAt: Date | null) => + prisma.labResult.create({ + data: { + id, + userId: USER, + analyte: "LDL", + unit: "mg/dL", + value: 100, + takenAt: new Date("2026-01-01T08:00:00.000Z"), + deletedAt, + }, + }); + await lab("lab-expired", EXPIRED); + await lab("lab-recent", RECENT); + await lab("lab-live", null); + + const leg = await purgeSoftDeletedTable(prisma, "LabResult", NOW); + + expect(leg).toMatchObject({ deleted: 1, drained: true }); + const left = await prisma.labResult.findMany({ + select: { id: true }, + orderBy: { id: "asc" }, + }); + expect(left.map((r) => r.id)).toEqual(["lab-live", "lab-recent"]); + }); + + it("cascades a purged visit's links and unhooks a live vaccination", async () => { + await prisma.encounter.create({ + data: { + id: "visit-expired", + userId: USER, + occurredAt: new Date("2026-01-01T08:00:00.000Z"), + deletedAt: EXPIRED, + }, + }); + await prisma.vaccinationRecord.create({ + data: { + id: "vacc-live", + userId: USER, + occurredAt: new Date("2026-01-01T08:00:00.000Z"), + encounterId: "visit-expired", + }, + }); + + const leg = await purgeSoftDeletedTable(prisma, "Encounter", NOW); + + expect(leg.deleted).toBe(1); + expect(await prisma.encounter.count()).toBe(0); + expect( + await prisma.vaccinationRecord.findUniqueOrThrow({ + where: { id: "vacc-live" }, + select: { encounterId: true, deletedAt: true }, + }), + ).toEqual({ encounterId: null, deletedAt: null }); + }); + + it("keeps a start folded into a live cycle until that cycle goes", async () => { + const cycle = ( + id: string, + startDate: string, + deletedAt: Date | null, + absorbedIntoId: string | null = null, + ) => + prisma.menstrualCycle.create({ + data: { id, userId: USER, startDate, deletedAt, absorbedIntoId }, + }); + await cycle("cycle-live", "2026-01-01", null); + await cycle("cycle-folded", "2026-01-03", EXPIRED, "cycle-live"); + await cycle("cycle-gone-absorber", "2025-12-01", EXPIRED); + await cycle( + "cycle-folded-orphan", + "2025-12-03", + EXPIRED, + "cycle-gone-absorber", + ); + await cycle("cycle-plain", "2025-11-01", EXPIRED); + + const leg = await purgeSoftDeletedTable(prisma, "MenstrualCycle", NOW); + + expect(leg.deleted).toBe(3); + const left = await prisma.menstrualCycle.findMany({ + select: { id: true }, + orderBy: { id: "asc" }, + }); + expect(left.map((r) => r.id)).toEqual(["cycle-folded", "cycle-live"]); + }); + + it("keeps a removed coach fact for the coach message retention", async () => { + const fact = (id: string, deletedAt: Date) => + prisma.coachFact.create({ + data: { + id, + userId: USER, + category: "medication", + factEncrypted: Buffer.from("opaque"), + deletedAt, + }, + }); + await fact("fact-past-sync-horizon", EXPIRED); + await fact("fact-past-a-year", new Date(NOW.getTime() - 400 * DAY)); + + const leg = await purgeSoftDeletedTable(prisma, "CoachFact", NOW); + + expect(leg.deleted).toBe(1); + const left = await prisma.coachFact.findMany({ select: { id: true } }); + expect(left.map((r) => r.id)).toEqual(["fact-past-sync-horizon"]); + }); + + it("counter-check: refuses a model the register does not hand to it", async () => { + await expect( + purgeSoftDeletedTable(prisma, "Measurement", NOW), + ).rejects.toThrow("No purge entry for Measurement"); + }); +});