mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Carry the archived schedule eras, chain and all
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 band minter reads each past day against the schedule that was live THEN, so an account that came back without its eras had its own compliance history rewritten against a plan it was never on. The hard part was supersededByRevisionId, the pointer from a corrected ARCHIVED era to the MANUAL row that replaced it. Three things about it had to be settled. It is a bare id column with no relation, so nothing in the database refuses a value that points at nothing. That makes losing it quieter and worse than a broken foreign key would be: every era consumer SKIPS a row that carries the pointer, so a corrected era whose link came back null goes live again beside its own correction, and the timeline shows one window twice with past days minted against whichever the query read first. A portable restore mints fresh ids, so the pointer cannot travel as one. It travels as a POSITION in the drug's own ordered era list. That list is the only address space both ends share: 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, because every route that writes it scopes the update to one medication. The same mechanism serves both purposes rather than one carrying ids and the other positions. Writing it needs two passes, because the row a link addresses is as often written after the row addressing it as before. Pass one creates every era with the link null; pass two patches the links once every position has an id. A hand-edited file that names a position out of range, or an era's own position, gets the era and not the pointer, and the position is reported as scheduleRevisionLink with the JSON path into the file. Refusing the file would cost an operator every era of every drug over one bad integer, and writing the link would point a correction at whatever row happened to be there. A row that supersedes itself is treated the same way: every consumer would skip it, so the era would vanish from the timeline while still sitting in the table. The round trip seeds three eras with one supersede among them and asserts the shape of the chain, then asserts that exactly two are live. Three rows with every link null satisfies any count and has thrown away the only thing the pointer says.
This commit is contained in:
1 parent
768320f3d3
commit
a74dc289a8
14 files changed
+462
-9
No files matched your search
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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<Record<string, string>> = {
|
||||
* rather than by what is cheapest to write.
|
||||
*/
|
||||
export const COVERAGE_PENDING: Readonly<Record<string, string>> = {
|
||||
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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
},
|
||||
],
|
||||
});
|
||||
// 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.
|
||||
|
||||
Reference in new issue
Block a user