Files
HealthLog/src/lib/export/reminders-backup.ts
T
Marc-André Bombeck 333dfe8746 fix(backup): give every export read a total order
Several export reads sorted only by a column two rows can share (a
creation time, a day, a name), and a few to-many reads (medication
schedules, mood tag links, cycle and illness symptom links) had no order
at all. Tied rows then came back in whatever order they sat on disk, so
two exports of one account could differ, and the parity goldens flapped
once the fixture's child rows shared their pinned timestamps.

Each read now ends its order in the model's primary key: the existing
sort key plus `id`, or the remaining key columns of a compound key
(nutrient `source`, hidden tag `moodTagId`, link `symptomId` /
`moodTagId` / `documentId`). Schedules sort by window start, then id.
The restore re-export reads through the same modules, so it inherits
the order.

The parity goldens are unchanged: they already hold the tie-broken
order, so no regeneration was needed and they still pin the output of
the pre-split exporter.

A new integration test runs both purposes, built and streamed, through
a client that records every findMany and every nested to-many read, and
fails on any whose order does not cover the primary key (keys pinned by
an equality in `where`, or by the foreign key to the parent, count as
covered). A second case writes the same tied rows in forward and
reverse physical order and requires byte-identical files.
2026-10-23 00:28:38 +02:00

401 lines
14 KiB
TypeScript

/**
* The Vorsorge reminders and their completion ledger, with both backup ends
* in one file.
*
* Same arrangement as `visits-backup.ts` and `vaccinations-backup.ts`, for
* the same reason: a reader asking "is this carried at both ends?" answers it
* here, and a reader who greps only the restore ROUTE gets a false negative
* because the route delegates.
*
* Two models ride: the reminder and the ledger row behind it
* (v1.37.20, #223 / iOS #68). Both are carried rather than owed. The
* reminder spent its time on the coverage-pending register — "a restore
* leaves the account silent until each one is set up again" — and the ledger
* raised the cost of that debt the release it landed: the engine is
* single-cursor, so the ledger is the ONLY copy of every satisfy and skip
* before the latest one. A restore without this section hands back a silent
* account with no history of it ever having been diligent.
*
* Each model uses its own named select constant and its own delegate call.
* `events` is a relation field name here and nowhere else today, but the
* delegate-per-model shape is kept anyway — `documentLinks` taught this
* directory what a shared relation name does to a structural matcher.
*
* One reference needs care on the way back: `MeasurementReminderEvent.reminderId`
* addresses a reminder in this same section. The builder filters the ledger to
* the reminders it actually carries — a portable export omits tombstoned
* reminders, and a ledger row whose reminder is not in the file could never
* restore — so a file this release writes always resolves completely. A
* hand-edited or truncated file that does not is reported, never guessed at.
*/
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import type {
MeasurementReminderEventKind,
MeasurementType,
ReminderOrigin,
} from "@/generated/prisma/client";
import {
recordUnknownKeys,
type RestoreSkipLog,
} from "@/lib/export/restore-skips";
export interface RemindersBackupOptions {
purpose?: "portable-export" | "disaster-recovery";
}
/** One configured Vorsorge cadence, exactly as the engine holds it. */
export interface MeasurementReminderBackupEntry {
/** Always carried: the ledger rows, an encounter and a vaccination record
* all address a reminder by it. */
id: string;
label: string;
measurementType: MeasurementType | null;
intervalDays: number | null;
rrule: string | null;
anchorDate: string | null;
endsOn: string | null;
origin: ReminderOrigin;
notifyHour: number;
location: string | null;
/** Server-computed and carried as computed — the restore writes it back
* verbatim rather than recomputing, so the account resumes exactly the
* due-state it was exported with. */
nextDueAt: string | null;
lastSatisfiedAt: string | null;
enabled: boolean;
vaccinationAntigen: string | null;
/** v1.37.20 — the snooze cursor. Verbatim: a snooze that has since expired
* restores as an instant in the past, which every consumer already treats
* as "not snoozed". */
snoozedUntil: string | null;
/** v1.37.20 — last honest skip. Never folded into `lastSatisfiedAt`; done
* and skipped stay distinguishable through a restore too. */
lastSkippedAt: string | null;
/** v1.37.20 — lifetime skip counter. */
skipCount: number;
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
}
/** One completion-ledger row: an honest satisfy or an honest skip. */
export interface MeasurementReminderEventBackupEntry {
id: string;
reminderId: string;
kind: MeasurementReminderEventKind;
occurredAt: string;
/** Derived at write time against the pre-event `nextDueAt`, which is gone.
* Carried verbatim because nothing can ever re-derive it. */
onTime: boolean;
source: string;
createdAt: string;
}
export interface RemindersBackupSection {
measurementReminders: MeasurementReminderBackupEntry[];
measurementReminderEvents: MeasurementReminderEventBackupEntry[];
}
export interface RemindersBackupCounts {
measurementReminders: number;
measurementReminderEvents: number;
}
/**
* Named select constants, one per model — a structural matcher binds a model
* to the literal beside its delegate call, matching the other section files.
*/
const MEASUREMENT_REMINDER_BACKUP_SELECT = {
id: true,
label: true,
measurementType: true,
intervalDays: true,
rrule: true,
anchorDate: true,
endsOn: true,
origin: true,
notifyHour: true,
location: true,
nextDueAt: true,
lastSatisfiedAt: true,
enabled: true,
vaccinationAntigen: true,
snoozedUntil: true,
lastSkippedAt: true,
skipCount: true,
createdAt: true,
updatedAt: true,
deletedAt: true,
} as const;
const MEASUREMENT_REMINDER_EVENT_BACKUP_SELECT = {
id: true,
reminderId: true,
kind: true,
occurredAt: true,
onTime: true,
source: true,
createdAt: true,
} as const;
/**
* Build the reminders slice of a user's full backup.
*
* Takes the delegates it uses rather than a whole client, matching the other
* section builders. A portable export omits tombstoned reminders so a restore
* cannot resurrect them — and it omits the ledger rows of an omitted reminder
* with it, because a ledger row whose reminder is not in the file could only
* ever restore as a reported drop. A disaster-recovery payload keeps both so
* the account comes back as itself.
*/
export async function buildRemindersBackupSection(
prisma: Pick<
PrismaClient,
"measurementReminder" | "measurementReminderEvent"
>,
userId: string,
options: RemindersBackupOptions = {},
): Promise<RemindersBackupSection> {
const disasterRecovery = options.purpose === "disaster-recovery";
const [reminderRows, eventRows] = await Promise.all([
prisma.measurementReminder.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
select: MEASUREMENT_REMINDER_BACKUP_SELECT,
}),
prisma.measurementReminderEvent.findMany({
where: { userId },
orderBy: [{ occurredAt: "asc" }, { id: "asc" }],
select: MEASUREMENT_REMINDER_EVENT_BACKUP_SELECT,
}),
]);
// The ledger is filtered to the reminders the file actually carries. On a
// DR payload every reminder is carried and this is a no-op; on a portable
// export it removes the ledger of each omitted tombstone, so the file never
// states an event against a reminder it refuses to restore.
const carriedReminderIds = new Set(reminderRows.map((row) => row.id));
return {
measurementReminders: reminderRows.map((row) => ({
id: row.id,
label: row.label,
measurementType: row.measurementType,
intervalDays: row.intervalDays,
rrule: row.rrule,
anchorDate: row.anchorDate?.toISOString() ?? null,
endsOn: row.endsOn?.toISOString() ?? null,
origin: row.origin,
notifyHour: row.notifyHour,
// An appointment's address lives encrypted on its practitioner; a
// readable copy the backfill has not cleared yet is not carried.
location: row.origin === "ENCOUNTER" ? null : row.location,
nextDueAt: row.nextDueAt?.toISOString() ?? null,
lastSatisfiedAt: row.lastSatisfiedAt?.toISOString() ?? null,
enabled: row.enabled,
vaccinationAntigen: row.vaccinationAntigen,
snoozedUntil: row.snoozedUntil?.toISOString() ?? null,
lastSkippedAt: row.lastSkippedAt?.toISOString() ?? null,
skipCount: row.skipCount,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
...(disasterRecovery
? { deletedAt: row.deletedAt?.toISOString() ?? null }
: {}),
})),
measurementReminderEvents: eventRows
.filter((row) => carriedReminderIds.has(row.reminderId))
.map((row) => ({
id: row.id,
reminderId: row.reminderId,
kind: row.kind,
occurredAt: row.occurredAt.toISOString(),
onTime: row.onTime,
source: row.source,
createdAt: row.createdAt.toISOString(),
})),
};
}
/** Row counts for the audit trail, mirroring the other section counters. */
export function countRemindersBackupSection(
section: RemindersBackupSection,
): RemindersBackupCounts {
return {
measurementReminders: section.measurementReminders.length,
measurementReminderEvents: section.measurementReminderEvents.length,
};
}
/** Counts the reminder restore wiped, for the audit trail. */
export interface RemindersRestoreCleared {
measurementReminders: number;
measurementReminderEvents: number;
}
/**
* The slice of a parsed backup this restore consumes.
*
* Required rather than optional: the payload schema defaults every key, so
* every caller already satisfies this, and one that stops satisfying it fails
* to compile instead of passing `undefined` into a loop that iterates zero
* times and reports success.
*/
export interface RemindersRestoreInput {
measurementReminders: RestoredMeasurementReminder[];
measurementReminderEvents: RestoredMeasurementReminderEvent[];
}
/**
* What the restore reads, as the parsed file actually presents it — a wider
* set than what this release writes: a portable export omits the tombstone,
* and every optional column arrives as `undefined` rather than `null`.
*/
type OptionalNullable<T> = { [K in keyof T]?: T[K] | undefined };
export type RestoredMeasurementReminder = Pick<
MeasurementReminderBackupEntry,
"id" | "label" | "createdAt" | "updatedAt"
> &
OptionalNullable<
Pick<
MeasurementReminderBackupEntry,
| "measurementType"
| "intervalDays"
| "rrule"
| "anchorDate"
| "endsOn"
| "origin"
| "notifyHour"
| "location"
| "nextDueAt"
| "lastSatisfiedAt"
| "enabled"
| "vaccinationAntigen"
| "snoozedUntil"
| "lastSkippedAt"
| "skipCount"
>
> & { deletedAt?: string | null };
export type RestoredMeasurementReminderEvent = Pick<
MeasurementReminderEventBackupEntry,
"id" | "reminderId" | "occurredAt" | "onTime" | "source" | "createdAt"
> & {
kind: MeasurementReminderEventKind;
};
/**
* Re-create the account's Vorsorge reminders and their completion ledger.
*
* Delete-then-recreate inside the caller's transaction, matching every other
* section. The ledger is deleted before the reminders so its cleared count is
* truthful (the cascade would empty it anyway, silently), and recreated after
* them, because a ledger row addresses its reminder by id.
*
* MUST be called BEFORE the encounters and the vaccination records are
* restored: both carry a `reminderId` that they remap against the reminders
* in the database, and calling this later would make every one of those
* references read as unresolvable and be dropped — a restore counted as
* successful while it quietly disconnected every booster and appointment.
*/
export async function restoreRemindersData(
tx: Prisma.TransactionClient,
ownerId: string,
payload: RemindersRestoreInput,
skips: RestoreSkipLog,
): Promise<RemindersRestoreCleared> {
const clearedEvents = await tx.measurementReminderEvent.deleteMany({
where: { userId: ownerId },
});
const clearedReminders = await tx.measurementReminder.deleteMany({
where: { userId: ownerId },
});
if (payload.measurementReminders.length > 0) {
await tx.measurementReminder.createMany({
data: payload.measurementReminders.map((entry) => ({
id: entry.id,
userId: ownerId,
label: entry.label,
measurementType: entry.measurementType ?? null,
intervalDays: entry.intervalDays ?? null,
rrule: entry.rrule ?? null,
anchorDate: entry.anchorDate ? new Date(entry.anchorDate) : null,
endsOn: entry.endsOn ? new Date(entry.endsOn) : null,
// A file written before a column existed says nothing about it, and
// the schema default is what those rows were living as.
origin: entry.origin ?? "VORSORGE",
notifyHour: entry.notifyHour ?? 9,
// An appointment reminder resolves its address from the practitioner
// when it fires; a file from before that must not plant a readable
// copy of it back.
location:
entry.origin === "ENCOUNTER" ? null : (entry.location ?? null),
// Verbatim, not recomputed: `nextDueAt` is server-authoritative and
// the exported value IS what the server had resolved. Recomputing
// here would move a due date the person had already been shown.
nextDueAt: entry.nextDueAt ? new Date(entry.nextDueAt) : null,
lastSatisfiedAt: entry.lastSatisfiedAt
? new Date(entry.lastSatisfiedAt)
: null,
enabled: entry.enabled ?? true,
vaccinationAntigen: entry.vaccinationAntigen ?? null,
snoozedUntil: entry.snoozedUntil ? new Date(entry.snoozedUntil) : null,
lastSkippedAt: entry.lastSkippedAt
? new Date(entry.lastSkippedAt)
: null,
skipCount: entry.skipCount ?? 0,
createdAt: new Date(entry.createdAt),
updatedAt: new Date(entry.updatedAt),
deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null,
})),
});
}
// The builder filters the ledger to carried reminders, so a file this
// release writes never drops here. A file that does reference a reminder it
// does not carry cannot have that row invented, and a dangling id would
// fail the foreign key and roll the whole restore back over one ledger
// line. The row is dropped and reported instead.
const restoredReminders = new Set(
payload.measurementReminders.map((entry) => entry.id),
);
const writableEvents: RestoredMeasurementReminderEvent[] = [];
const droppedReminderRefs: string[] = [];
for (const entry of payload.measurementReminderEvents) {
if (restoredReminders.has(entry.reminderId)) {
writableEvents.push(entry);
} else {
droppedReminderRefs.push(entry.reminderId);
}
}
if (writableEvents.length > 0) {
await tx.measurementReminderEvent.createMany({
data: writableEvents.map((entry) => ({
id: entry.id,
userId: ownerId,
reminderId: entry.reminderId,
kind: entry.kind,
occurredAt: new Date(entry.occurredAt),
onTime: entry.onTime,
source: entry.source,
createdAt: new Date(entry.createdAt),
})),
});
}
recordUnknownKeys(
skips,
"reminderReference",
[...new Set(droppedReminderRefs)],
droppedReminderRefs,
);
return {
measurementReminders: clearedReminders.count,
measurementReminderEvents: clearedEvents.count,
};
}