mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
feat(jobs): give every soft-delete model a tombstone retention
Twenty of the twenty-three soft-delete models were never purged: a deleted lab result, visit, allergy or cycle stayed in the database, in every backup and in every key rotation for as long as the instance ran. soft-delete-retention.ts holds one entry per model that has a deletedAt column, derived from the schema by a guard with a counter-check. The sync domains and the document vault keep their purges; every other model is purged by a new nightly soft-delete-purge job after the sync feed's 75-day horizon, per account under the restore lock and in bounded batches. A cycle start folded into a live cycle stays while that cycle does, and a removed coach fact stays for the coach message retention, since stored trails are withheld against it.
This commit is contained in:
1 parent
03aec3ec12
commit
8e07cb179f
8 files changed
+563
-2
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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<Record<string, SoftDeleteRetention>>,
|
||||
): 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -313,6 +313,9 @@ export const QUEUE_RUNTIME: Readonly<Record<string, QueueRuntime>> = {
|
||||
"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.",
|
||||
|
||||
@@ -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<SoftDeletePurgePayload>(
|
||||
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.
|
||||
|
||||
@@ -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<SoftDeletePurgePayload>[],
|
||||
): Promise<JobOutcome> {
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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<string, SoftDeleteRetention>
|
||||
> = {
|
||||
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<SoftDeletePurgeLeg> {
|
||||
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<Array<{ id: string; userId: string }>>(
|
||||
`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<SoftDeletePurgeLeg[]> {
|
||||
const legs: SoftDeletePurgeLeg[] = [];
|
||||
for (const model of purgeModels()) {
|
||||
legs.push(await purgeSoftDeletedTable(prisma, model, now));
|
||||
}
|
||||
return legs;
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user