diff --git a/messages/de.json b/messages/de.json index ffdc1bf2d..f47b95c2c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6921,7 +6921,8 @@ "docsLink": "Dokumentation", "restoreSkippedPersonalRecordReference": "Herkunft einer Bestleistung", "restoreSkippedEcgReference": "Herkunft eines EKG-Streifens", - "restoreSkippedMedicationTarget": "Zielwert-Analyt einer Medikation" + "restoreSkippedMedicationTarget": "Zielwert-Analyt einer Medikation", + "restoreSkippedScheduleRevisionLink": "Abfolge der Schema-Zeiträume" }, "danger-zone": { "title": "Gefahrenzone", diff --git a/messages/en.json b/messages/en.json index ab5924367..f335611ac 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6921,7 +6921,8 @@ "docsLink": "Documentation", "restoreSkippedPersonalRecordReference": "Personal best provenance", "restoreSkippedEcgReference": "ECG strip provenance", - "restoreSkippedMedicationTarget": "Medication target analyte" + "restoreSkippedMedicationTarget": "Medication target analyte", + "restoreSkippedScheduleRevisionLink": "Schedule era succession" }, "danger-zone": { "title": "Danger Zone", diff --git a/messages/es.json b/messages/es.json index 02d3bba88..c33bbad2d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6921,7 +6921,8 @@ "docsLink": "Documentación", "restoreSkippedPersonalRecordReference": "Procedencia de un récord personal", "restoreSkippedEcgReference": "Procedencia de un trazado de ECG", - "restoreSkippedMedicationTarget": "Analito objetivo de un medicamento" + "restoreSkippedMedicationTarget": "Analito objetivo de un medicamento", + "restoreSkippedScheduleRevisionLink": "Sucesión de periodos de pauta" }, "danger-zone": { "title": "Zona de peligro", diff --git a/messages/fr.json b/messages/fr.json index ad7830b76..9edb08991 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6921,7 +6921,8 @@ "docsLink": "Documentation", "restoreSkippedPersonalRecordReference": "Provenance d'un record personnel", "restoreSkippedEcgReference": "Provenance d'un tracé ECG", - "restoreSkippedMedicationTarget": "Analyte cible d'un médicament" + "restoreSkippedMedicationTarget": "Analyte cible d'un médicament", + "restoreSkippedScheduleRevisionLink": "Succession des périodes de schéma" }, "danger-zone": { "title": "Zone de danger", diff --git a/messages/it.json b/messages/it.json index 606aee262..5df1f3bab 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6921,7 +6921,8 @@ "docsLink": "Documentazione", "restoreSkippedPersonalRecordReference": "Provenienza di un primato personale", "restoreSkippedEcgReference": "Provenienza di un tracciato ECG", - "restoreSkippedMedicationTarget": "Analita obiettivo di un farmaco" + "restoreSkippedMedicationTarget": "Analita obiettivo di un farmaco", + "restoreSkippedScheduleRevisionLink": "Successione dei periodi di schema" }, "danger-zone": { "title": "Zona pericolosa", diff --git a/messages/pl.json b/messages/pl.json index 84dfdf332..ef3622552 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6921,7 +6921,8 @@ "docsLink": "Dokumentacja", "restoreSkippedPersonalRecordReference": "Pochodzenie rekordu osobistego", "restoreSkippedEcgReference": "Pochodzenie zapisu EKG", - "restoreSkippedMedicationTarget": "Analit docelowy leku" + "restoreSkippedMedicationTarget": "Analit docelowy leku", + "restoreSkippedScheduleRevisionLink": "Następstwo okresów schematu" }, "danger-zone": { "title": "Strefa zagrożenia", diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 5e4a31c63..f15f3bc8a 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -577,6 +577,12 @@ const handler = apiHandler( medicationId: string; target: (typeof payload.medications)[number]["efficacyTargets"][number]; }> = []; + // Supersede pointers the file states and the restore cannot honour. + // Reported as JSON paths into the file rather than as ids: the file + // is where an operator has to go to see what the position meant, and + // an id would be one the restore never wrote. + const unresolvedRevisionLinks: string[] = []; + let medicationIndex = 0; for (const m of payload.medications) { const created = await tx.medication.create({ data: { @@ -782,13 +788,93 @@ const handler = apiHandler( }); restoredMedicationIds.add(created.id); if (!medByName.has(m.name)) medByName.set(m.name, created.id); + + // ── The archived schedule eras, in two passes ──────────────── + // + // An era carries `supersededByRevisionId`, the pointer from a + // corrected ARCHIVED row to the MANUAL one that replaced it. The + // column has no `@relation`, so a value addressing nothing costs + // no error — it just stops meaning anything, which is the more + // dangerous shape here, because every era consumer SKIPS a row + // that carries the pointer. Lose it and the superseded original + // goes live again beside its own correction: two overlapping eras + // for one window, and past days minted against a plan the account + // had already corrected. + // + // A portable restore mints fresh ids, so the pointer cannot travel + // as one. It travels as a POSITION in this drug's own ordered era + // list, because that list is the only thing both ends agree on: + // the builder sorts it totally (validFrom, then createdAt, then + // id) so two eras sharing an instant cannot swap places between + // two exports of the same account, and the pointer never crosses a + // drug — every route that writes it scopes the update to one + // medication — so the drug's own list is a complete address space. + // + // Pass one writes every era with the link NULL, because the row a + // link addresses is as often written after the row addressing it + // as before. Pass two patches the links once every position has an + // id. + const revisionIds: string[] = []; + for (const revision of m.scheduleRevisions) { + const row = await tx.medicationScheduleRevision.create({ + data: { + ...(revision.id ? { id: revision.id } : {}), + medicationId: created.id, + validFrom: new Date(revision.validFrom), + validUntil: new Date(revision.validUntil), + payload: toJson(revision.payload), + source: revision.source ?? "ARCHIVED", + supersededByRevisionId: null, + ...(revision.createdAt + ? { createdAt: new Date(revision.createdAt) } + : {}), + }, + select: { id: true }, + }); + revisionIds.push(row.id); + } + for (const [index, revision] of m.scheduleRevisions.entries()) { + const to = revision.supersededByIndex; + if (to === null || to === undefined) continue; + // A hand-edited file can name a position this drug does not + // have, or the era's OWN position — and a row that supersedes + // itself is skipped by every consumer, so the era would + // disappear from the timeline while still sitting in the table. + // Neither can be honoured and neither may be guessed at, so the + // link stays NULL and the position is reported. The era still + // restores: the window and the plan it held are the history, and + // the correction pointer is the smaller loss. + if ( + !Number.isInteger(to) || + to < 0 || + to >= revisionIds.length || + to === index + ) { + unresolvedRevisionLinks.push( + `medications[${medicationIndex}].scheduleRevisions[${index}].supersededByIndex=${to}`, + ); + continue; + } + await tx.medicationScheduleRevision.update({ + where: { id: revisionIds[index] }, + data: { supersededByRevisionId: revisionIds[to] }, + }); + } + for (const target of m.efficacyTargets) { pendingEfficacyTargets.push({ medicationId: created.id, target, }); } + medicationIndex += 1; } + recordUnknownKeys( + skips, + "scheduleRevisionLink", + [...new Set(unresolvedRevisionLinks)], + unresolvedRevisionLinks, + ); if (payload.intakeEvents.length > 0) { // An event whose medication did not come back used to be mapped to diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index ce0575f7e..598d6aab9 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -362,6 +362,9 @@ function catalogueLabel( if (catalogue === "medicationTarget") { return t("admin.section.backups.restoreSkippedMedicationTarget"); } + if (catalogue === "scheduleRevisionLink") { + return t("admin.section.backups.restoreSkippedScheduleRevisionLink"); + } // Not a fallback: the chain above is exhaustive and this line is what makes // the compiler say so. A catalogue added without a label here now stops the // build instead of shipping under a label that belongs to something else, diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 04a99afee..99d035567 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -236,6 +236,29 @@ function makePrisma() { redValue: 180, redMode: "MINUTES", }, + // Two archived eras, the first corrected by the second. The + // pointer is what makes this more than two rows: the payload has + // to carry it as the SECOND era's position, not as its id. + scheduleRevisions: [ + { + id: "era-original", + validFrom: new Date("2026-01-01T00:00:00.000Z"), + validUntil: new Date("2026-03-01T00:00:00.000Z"), + payload: [{ windowStart: "07:00", windowEnd: "09:00" }], + source: "ARCHIVED", + supersededByRevisionId: "era-correction", + createdAt: new Date("2026-03-01T00:00:01.000Z"), + }, + { + id: "era-correction", + validFrom: new Date("2026-03-01T00:00:00.000Z"), + validUntil: new Date("2026-05-01T00:00:00.000Z"), + payload: [{ windowStart: "08:00", windowEnd: "10:00" }], + source: "MANUAL", + supersededByRevisionId: null, + createdAt: new Date("2026-05-02T00:00:01.000Z"), + }, + ], // One pinned efficacy target, on the lab arm. The biomarker rides // as a NAME, the way a lab result's cross-reference does. efficacyTargets: [ diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index dde330dc7..5f47cadf2 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -454,6 +454,21 @@ export const TWO_ENDED_MODELS = [ // fortnight at home, days after a restore that reported success. "EnvironmentContext", "EnvironmentTravelLocation", + // How a schedule got to where it is. The register said a restored + // medication keeps today's schedule and loses the record of when the dose or + // the timing moved. Measured, it costs more than a record: the era minter + // reads past days against the schedule that was live THEN, so an account + // that came back without its eras has its own compliance history rewritten + // against a plan it was not on. + // + // `supersededByRevisionId` is the reference that made this its own problem. + // It is a bare id column with no `@relation`, so a dangling value costs no + // error and simply stops meaning anything — and every era consumer skips a + // row that carries it, which means losing the pointer puts a corrected era + // back on the timeline beside its own correction. It travels as a POSITION + // in the drug's own ordered era list, because a portable restore mints fresh + // ids and the list is the only address space both ends share. + "MedicationScheduleRevision", // What each drug was supposed to move. The register said the drug comes // back with no statement of what it was for, and that is exactly the row: // one exists ONLY where the person overrode the derived ATC / name @@ -516,8 +531,6 @@ export const STRUCTURALLY_UNATTRIBUTABLE: Readonly> = { * rather than by what is cheapest to write. */ export const COVERAGE_PENDING: Readonly> = { - MedicationScheduleRevision: - "The history of how a schedule changed. Without it a restored medication keeps today's schedule and loses the record of when the dose or timing moved, which is the part a doctor asks about.", WorkoutRoute: "GPS traces. Deliberately absent from the payload today and DISCLOSED as absent in the file's own manifest, which is why this is a documented exclusion rather than a silent one — but it is still a loss for a self-hoster with no other copy.", WorkoutSamples: diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 396f3504b..b04d6e6c6 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -129,6 +129,7 @@ export interface FullBackupCounts medicationInventoryEvents: number; reminderPhaseConfigs: number; medicationEfficacyTargets: number; + medicationScheduleRevisions: number; moodEntries: number; customMoodTagCategories: number; hiddenMoodTags: number; @@ -426,6 +427,18 @@ export async function buildFullBackupPayload( inventoryItems: { orderBy: { createdAt: "asc" } }, inventoryEvents: { orderBy: { occurredAt: "asc" } }, phaseConfig: true, + // The archived schedule eras. Ordered TOTALLY, not just by the column + // that reads like the sort key: the position in this list is the + // identifier the supersede pointer travels as, so two eras sharing a + // `validFrom` must not be free to swap places between two exports of + // the same account. + scheduleRevisions: { + orderBy: [ + { validFrom: "asc" }, + { createdAt: "asc" }, + { id: "asc" }, + ] as const, + }, // What the drug was supposed to move. The biomarker is read by NAME // rather than by id, exactly as the lab results beside it are: a // portable restore mints a fresh biomarker id, and `(userId, name)` is @@ -613,6 +626,19 @@ export async function buildFullBackupPayload( const environmentSection: EnvironmentBackupSection = environment; const ecgSection: EcgBackupSection = ecg; + // Where each archived era sits in its OWN drug's ordered list. Built once + // here rather than per row, and scoped per medication because the supersede + // pointer never crosses a drug — every route that writes it scopes the + // update to the medication it belongs to. + const revisionIndexByMedication = new Map( + medications.map((m) => [ + m.id, + new Map( + m.scheduleRevisions.map((revision, index) => [revision.id, index]), + ), + ]), + ); + const payload = { schemaVersion: BACKUP_SCHEMA_VERSION, exportedAt: (options.exportedAt ?? new Date()).toISOString(), @@ -806,6 +832,33 @@ export async function buildFullBackupPayload( reason: e.reason, occurredAt: e.occurredAt.toISOString(), })), + // How the schedule got to where it is. The drug comes back on today's + // plan either way; without these the record of when the dose or the + // timing moved is gone, and the era minter reads every past day against + // the CURRENT schedule instead of the one that was live then — so a + // restored account's own compliance history quietly changes. + // + // `supersededByRevisionId` travels as a POSITION in this list rather + // than as an id. See the restore for the whole argument; the short form + // is that a portable restore mints fresh ids, so an id would point at + // nothing, and the list this array IS is the only stable thing both ends + // agree on. A pointer at a row outside this drug's own list cannot be + // expressed and is written as null — the schema allows it, no code path + // produces it, and inventing an index for it would be worse than saying + // nothing. + scheduleRevisions: m.scheduleRevisions.map((r) => ({ + ...(disasterRecovery ? { id: r.id } : {}), + validFrom: r.validFrom.toISOString(), + validUntil: r.validUntil.toISOString(), + payload: r.payload, + source: r.source, + supersededByIndex: r.supersededByRevisionId + ? (revisionIndexByMedication + .get(m.id) + ?.get(r.supersededByRevisionId) ?? null) + : null, + createdAt: r.createdAt.toISOString(), + })), // What the drug is FOR, in the person's own words rather than the // resolver's. A row exists only where they overrode the derived // ATC / name inference, so it is the one durable statement of intent @@ -1034,6 +1087,10 @@ export async function buildFullBackupPayload( (total, m) => total + m.efficacyTargets.length, 0, ), + medicationScheduleRevisions: medications.reduce( + (total, m) => total + m.scheduleRevisions.length, + 0, + ), moodEntries: moodEntries.length, customMoodTagCategories: customMoodTagCategories.length, hiddenMoodTags: hiddenMoodTags.length, diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index 0a110e7d5..ef8acb6c3 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -123,7 +123,16 @@ * the primary slot. The drug, its schedule and its whole history come back; * what is lost is one statement of intent, and the operator is told which. * - * The twelfth, `checkupClosure`, is not about a restore at all, and it borrows + * The twelfth, `scheduleRevisionLink`, names a supersede pointer between two + * archived schedule eras that the restore could not honour. Its key is a JSON + * PATH into the file rather than an id, because the pointer travels as a + * position in the drug's own era list and there is no id on either end that + * the file and the database both know. Only a hand-edited or truncated file + * can trip it: the builder writes a position it has just computed from the + * same list. What is lost is the pointer and not the era — the window and the + * plan it held still restore. + * + * The thirteenth, `checkupClosure`, is not about a restore at all, and it borrows * this shape deliberately rather than growing a second reporting mechanism * beside it. The situation is the same one: something a write was asked to do * could not be done, the record itself survives, and the person is told which @@ -147,6 +156,7 @@ export type SkippedCatalogue = | "personalRecordReference" | "ecgReference" | "medicationTarget" + | "scheduleRevisionLink" | "checkupClosure"; /** One key this instance does not know, and the links it cost. */ diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index ddc768dad..9be51be04 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -264,6 +264,34 @@ const reminderPhaseConfigSchema = z }) .passthrough(); +/** + * One archived schedule era. + * + * `supersededByIndex` is the self-reference, and it is a POSITION in the + * drug's own `scheduleRevisions` array rather than an id: a portable restore + * mints fresh ids, so an id would address nothing. It stays a plain integer + * with no range check here on purpose — the file is the wrong place to learn + * that a position is out of range, because the array it indexes into is the + * array being parsed. The restore does that check, against the rows it + * actually wrote, and reports what it cannot resolve. + * + * `payload` is the snapshot of the superseded schedule rows, carried as + * written. Unknown on purpose rather than typed: it is an era's frozen copy of + * a shape that has changed several times, and a restore must not refuse a file + * because a five-release-old snapshot spells a field the current form does not. + */ +const medicationScheduleRevisionSchema = z + .object({ + id: z.string().min(1).optional(), + validFrom: isoDateTime, + validUntil: isoDateTime, + payload: z.unknown(), + source: z.string().min(1).optional(), + supersededByIndex: z.number().int().nullable().optional(), + createdAt: isoDateTime.optional(), + }) + .passthrough(); + /** * What a medication was supposed to move. * @@ -331,6 +359,9 @@ const medicationSchema = z // Same default, same reason: an older file parses, and a drug whose // target the resolver derives rather than the person pinning it writes []. efficacyTargets: z.array(medicationEfficacyTargetSchema).default([]), + // Same default again. A drug whose plan has never been replaced has no + // archived era, and a file written before the eras travelled has no key. + scheduleRevisions: z.array(medicationScheduleRevisionSchema).default([]), }) .passthrough(); @@ -1723,6 +1754,8 @@ export interface BackupSummary { medicationSideEffects: number; /** Pinned efficacy targets, across every medication. */ medicationEfficacyTargets: number; + /** Archived schedule eras, across every medication. */ + medicationScheduleRevisions: number; moodEntries: number; /** v1.15.0 — observed cycle spans in the backup. */ cycles: number; @@ -1817,6 +1850,10 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { (sum, medication) => sum + medication.efficacyTargets.length, 0, ), + medicationScheduleRevisions: payload.medications.reduce( + (sum, medication) => sum + medication.scheduleRevisions.length, + 0, + ), moodEntries: payload.moodEntries.length, cycles: payload.cycles.length, cycleDayLogs: payload.cycleDayLogs.length, diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index b785734cf..a6daaf312 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -232,6 +232,8 @@ const COUNT_BACK: Record< // No own `userId` column — reached through the drug, like the schedules. MedicationEfficacyTarget: (p, userId) => p.medicationEfficacyTarget.count({ where: { medication: { userId } } }), + MedicationScheduleRevision: (p, userId) => + p.medicationScheduleRevision.count({ where: { medication: { userId } } }), }; /** @@ -467,6 +469,42 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { }, ], }); + // Three archived eras, and the CHAIN through them is the content. The + // middle one was corrected, so it points at the third; the first and the + // third stand on their own. A restore that returned three rows with every + // link null satisfies any count and has destroyed the only thing the + // pointer says — every era consumer skips a superseded row, so the + // corrected middle era would come back live beside the correction that + // replaced it and the timeline would show the same window twice. + await prisma.medicationScheduleRevision.create({ + data: { + medicationId: medication.id, + validFrom: AT("2026-01-01T00:00:00.000Z"), + validUntil: AT("2026-03-01T00:00:00.000Z"), + payload: [{ windowStart: "07:00", windowEnd: "09:00", dose: "5 mg" }], + source: "ARCHIVED", + }, + }); + const correctionEra = await prisma.medicationScheduleRevision.create({ + data: { + medicationId: medication.id, + validFrom: AT("2026-03-01T00:00:00.000Z"), + validUntil: AT("2026-05-01T00:00:00.000Z"), + payload: [{ windowStart: "08:00", windowEnd: "10:00", dose: "10 mg" }], + source: "MANUAL", + }, + }); + await prisma.medicationScheduleRevision.create({ + data: { + medicationId: medication.id, + validFrom: AT("2026-02-01T00:00:00.000Z"), + validUntil: AT("2026-03-01T00:00:00.000Z"), + payload: [{ windowStart: "12:00", windowEnd: "13:00", dose: "10 mg" }], + source: "ARCHIVED", + supersededByRevisionId: correctionEra.id, + }, + }); + await prisma.medicationPauseEra.createMany({ data: [ { @@ -1817,6 +1855,71 @@ describe("every model the plan claims two-ended survives a real restore", () => confidence: 0.94, }, }); + // The archived eras, and the CHAIN through them. + // + // Counting says three eras returned, and three eras with every link null + // would satisfy it exactly while having thrown away the only thing the + // pointer states. That is not a cosmetic loss: every era consumer skips a + // superseded row, so a corrected era whose link came back null goes live + // again beside the correction that replaced it, and the account gets two + // overlapping plans for one window with its past compliance minted against + // whichever the query happened to read. + // + // Asserted as the shape of the chain rather than as ids: the ids are fresh + // on a portable restore, and what the two-pass write owes is that era two + // still points at era three and that nothing else points anywhere. + const scheduleEras = await prisma.medicationScheduleRevision.findMany({ + where: { medication: { userId: OWNER_ID } }, + orderBy: [{ validFrom: "asc" }, { createdAt: "asc" }, { id: "asc" }], + }); + const eraPositionById = new Map( + scheduleEras.map((era, index) => [era.id, index]), + ); + expect( + scheduleEras.map((era) => ({ + validFrom: era.validFrom.toISOString(), + validUntil: era.validUntil.toISOString(), + source: era.source, + payload: era.payload, + supersedes: era.supersededByRevisionId + ? (eraPositionById.get(era.supersededByRevisionId) ?? "dangling") + : null, + })), + "the corrected era must come back still pointing at its correction", + ).toEqual([ + { + validFrom: "2026-01-01T00:00:00.000Z", + validUntil: "2026-03-01T00:00:00.000Z", + source: "ARCHIVED", + payload: [{ windowStart: "07:00", windowEnd: "09:00", dose: "5 mg" }], + supersedes: null, + }, + { + validFrom: "2026-02-01T00:00:00.000Z", + validUntil: "2026-03-01T00:00:00.000Z", + source: "ARCHIVED", + payload: [{ windowStart: "12:00", windowEnd: "13:00", dose: "10 mg" }], + supersedes: 2, + }, + { + validFrom: "2026-03-01T00:00:00.000Z", + validUntil: "2026-05-01T00:00:00.000Z", + source: "MANUAL", + payload: [{ windowStart: "08:00", windowEnd: "10:00", dose: "10 mg" }], + supersedes: null, + }, + ]); + // What the pointer actually buys, stated as the thing the app reads: the + // timeline query drops superseded rows, so exactly two eras are live. + expect( + await prisma.medicationScheduleRevision.count({ + where: { + medication: { userId: OWNER_ID }, + supersededByRevisionId: null, + }, + }), + "a lost pointer puts a corrected era back on the timeline", + ).toBe(2); // The pinned target, and the analyte it points at. // @@ -2102,6 +2205,121 @@ describe("every model the plan claims two-ended survives a real restore", () => }); }); + /** + * A supersede pointer that names a position the file does not have. + * + * The builder computes the index from the same list it writes, so no file + * this release produces can trip this. A hand-edited or truncated one can, + * and the question it asks has to be answered somewhere: the era it names + * does not exist, the column is a bare id with no constraint to stop a + * wrong value, and inventing a neighbouring era to point at would be worse + * than either dropping or refusing. + * + * The answer is that the ERA restores and the POINTER does not, and the + * position is reported. Refusing the file would cost an operator every era + * of every drug over one bad integer; writing the link would put a + * correction pointer at whatever row happened to be there. + */ + it("keeps an archived era whose supersede pointer names a position out of range, and reports the position", async () => { + const prisma = getPrismaClient(); + await seedAdminSession(prisma); + await createOwner(prisma); + + const medication = await prisma.medication.create({ + data: { userId: OWNER_ID, name: "Out-of-range tablet", dose: "5 mg" }, + }); + await prisma.medicationScheduleRevision.createMany({ + data: [ + { + medicationId: medication.id, + validFrom: AT("2026-01-01T00:00:00.000Z"), + validUntil: AT("2026-02-01T00:00:00.000Z"), + payload: [{ windowStart: "07:00", windowEnd: "09:00" }], + source: "ARCHIVED", + }, + { + medicationId: medication.id, + validFrom: AT("2026-02-01T00:00:00.000Z"), + validUntil: AT("2026-03-01T00:00:00.000Z"), + payload: [{ windowStart: "08:00", windowEnd: "10:00" }], + source: "MANUAL", + }, + ], + }); + + const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { + purpose: "disaster-recovery", + }); + // Hand-edit the file the way a person with a text editor would: point the + // first era at an era that is not there. + const medications = payload.medications as Array<{ + scheduleRevisions: Array<{ supersededByIndex: number | null }>; + }>; + medications[0].scheduleRevisions[0].supersededByIndex = 7; + + await prisma.user.delete({ where: { id: OWNER_ID } }); + await createOwner(prisma); + + const backup = await prisma.dataBackup.create({ + data: { + userId: OWNER_ID, + type: "TWO_ENDED_ROUND_TRIP", + data: encrypt(JSON.stringify(payload)), + }, + }); + const response = await POST( + new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as never, + { params: Promise.resolve({ id: backup.id }) }, + ); + const body = await response.json(); + expect(response.status, JSON.stringify(body)).toBe(200); + + const reported = body.data.skipped.catalogueKeys as Array<{ + catalogue: string; + key: string; + links: number; + }>; + expect( + reported.filter((entry) => entry.catalogue === "scheduleRevisionLink"), + "the position the file named is not in the file, so dropping the link " + + "is correct and saying nothing about it is not", + ).toEqual([ + { + catalogue: "scheduleRevisionLink", + key: "medications[0].scheduleRevisions[0].supersededByIndex=7", + links: 1, + }, + ]); + + const restoredEras = await prisma.medicationScheduleRevision.findMany({ + where: { medication: { userId: OWNER_ID } }, + orderBy: { validFrom: "asc" }, + }); + expect( + restoredEras.map((era) => ({ + validFrom: era.validFrom.toISOString(), + source: era.source, + supersededByRevisionId: era.supersededByRevisionId, + })), + "both eras come back; only the pointer is lost", + ).toEqual([ + { + validFrom: "2026-01-01T00:00:00.000Z", + source: "ARCHIVED", + supersededByRevisionId: null, + }, + { + validFrom: "2026-02-01T00:00:00.000Z", + source: "MANUAL", + supersededByRevisionId: null, + }, + ]); + }); + /** * The reference a portable file genuinely lacks, and the slug the restore * must not judge.