fix(backup): restore row stamps and carry sealed labels, course notes and marker context as ciphertext

A disaster-recovery restore wrote these rows stamped with the restore's own
time: the medication's updatedAt (moved again by the course window), the
category assignment, custom category labels, courses and composition, the
mood day context and tag links, illness and cycle symptom links, nutrient
days, and a flare's updatedAt after the parent pointer pass. The recovery
file now carries each stamp (additive fields: linkCreatedAt on a mood
entry, symptomLinkCreatedAt on a cycle day log, createdAt on an illness
symptom, categoryUpdatedAt on a medication, updatedAt where missing) and
the restore writes it back. A file without them restores as before.

A custom category label, a course note and a marker's context rode the
recovery file as readable text and were sealed again under the active key.
They now ride as ciphertext (labelEncrypted, noteEncrypted,
contextEncrypted) and come back as the same bytes; a file that carries the
readable value instead is sealed on the way in, as before. The portable
file keeps the readable values. The rotation script and the
encrypted-column writer guard already cover all three columns.

The parity golden copies change for exactly this: the new fields in the
recovery file, the restored stamps in place of <now>, and the three values
coming back under the key they were sealed with. The legacy readable coach
title and custom metric note are still sealed on restore, since the row
holds no ciphertext to carry. The round-trip column audit no longer
excludes the four link stamps.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 19:57:47 +02:00
1 parent 6c4c097517
commit 811fcf5509
29 files changed
+901 -154

No files matched your search

@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 411,
"total": 148227,
"total": 148208,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
@@ -305,7 +305,7 @@
"lib/api-handler.ts :: apiHandler > wrapped": 201,
"lib/auth/profile-update.ts :: applyProfileUpdate": 353,
"lib/cache/server-cache.ts :: createServerCaches": 230,
"lib/cycle/backup.ts :: restoreCycleData": 243,
"lib/cycle/backup.ts :: restoreCycleData": 224,
"lib/cycle/day-log-write.ts :: writeDayLog": 311,
"lib/daily/digest.ts :: buildDailyDigest": 227,
"lib/daily/load-digest.ts :: loadDailyDigest": 452,
@@ -55,18 +55,12 @@ const EXCLUDED_MOOD_CONTEXT_COLUMNS: Record<string, string> = {
moodEntryId:
"re-bound to the restored parent row's id; the context rides inside its entry",
userId: "user-scoped backup; owner is re-derived from the restoring account",
createdAt:
"the sub-row's own bookkeeping stamp; the entry carries the moment the person logged the day, and the context has nothing separate to say about when it was typed",
updatedAt:
"the sub-row's own bookkeeping stamp, moved by every write; a restore re-stamps it and nothing reads the old value",
};
const EXCLUDED_TAG_LINK_COLUMNS: Record<string, string> = {
moodEntryId: "re-bound to the restored parent row's id, never carried",
moodTagId:
"the link travels by the tag's key; a source-instance id need not exist in the target catalogue",
createdAt:
"the link's own row stamp; the entry carries the moment the person logged it, and a key-only BINARY tag has nowhere to put a second one",
};
function modelBlock(schema: string, model: string): string {
+15 -2
View File
@@ -92,8 +92,16 @@ describe("buildCycleBackupSection disaster-recovery mode", () => {
updatedAt,
// One symptom the person put a number on, one they did not.
symptomLinks: [
{ severity: 4, symptom: { key: "cramps" } },
{ severity: null, symptom: { key: "fatigue" } },
{
severity: 4,
symptom: { key: "cramps" },
createdAt: new Date("2026-04-10T07:00:00.000Z"),
},
{
severity: null,
symptom: { key: "fatigue" },
createdAt: new Date("2026-04-10T07:00:00.000Z"),
},
],
},
]),
@@ -171,6 +179,11 @@ describe("buildCycleBackupSection disaster-recovery mode", () => {
// Only the rated link is listed. An unrated one is left out rather than
// written as a zero, so the file says "never rated" and not "rated none".
symptomSeverities: [{ key: "cramps", severity: 4 }],
// When each link was written, every link, disaster recovery only.
symptomLinkCreatedAt: [
{ key: "cramps", createdAt: "2026-04-10T07:00:00.000Z" },
{ key: "fatigue", createdAt: "2026-04-10T07:00:00.000Z" },
],
});
});
});
+3
View File
@@ -122,6 +122,9 @@ export const cycleDayLogSchema = z
updatedAt: isoDateTime.optional(),
symptomKeys: z.array(z.string()).default([]),
symptomSeverities: z.array(cycleSymptomSeveritySchema).default([]),
symptomLinkCreatedAt: z
.array(z.object({ key: z.string().min(1), createdAt: isoDateTime }))
.optional(),
})
.passthrough();
/** Cycle-tracking preferences (one row per user). */
+59 -20
View File
@@ -154,6 +154,12 @@ export interface CycleBackupSection {
* never rated it — which is not the same as rating it zero.
*/
symptomSeverities: Array<{ key: string; severity: number }>;
/**
* When each symptom link was written, by key. Disaster-recovery files
* only, and sparse like `symptomSeverities`; a file without it restores
* the links stamped with the restore.
*/
symptomLinkCreatedAt?: Array<{ key: string; createdAt: string }>;
}>;
/**
* The account's OWN symptom definitions.
@@ -329,6 +335,14 @@ export async function buildCycleBackupSection(
symptomSeverities: d.symptomLinks
.filter((l): l is typeof l & { severity: number } => l.severity != null)
.map((l) => ({ key: l.symptom.key, severity: l.severity })),
...(disasterRecovery
? {
symptomLinkCreatedAt: d.symptomLinks.map((l) => ({
key: l.symptom.key,
createdAt: l.createdAt.toISOString(),
})),
}
: {}),
})),
customSymptoms: customSymptoms.map((sym) => ({
...(disasterRecovery ? { id: sym.id } : {}),
@@ -445,6 +459,50 @@ function enumOrNull<T extends string>(
: null;
}
/**
* The symptom links of one restored day log.
*
* Severities: the day's rated intensities, keyed by symptom. A file written
* before severities were carried has no list at all and every link comes back
* unrated, which is what it was. An entry naming a symptom the day does not
* have is simply never read. Each link's own write time comes back when the
* file carries it (disaster recovery), and is the restore's otherwise.
*
* Every unresolvable key was counted into `skips` by the caller, so dropping
* it here is a reported drop rather than a silent one. The rest of the day is
* unaffected by a symptom chip that will not resolve.
*/
function dayLogSymptomLinks(
d: {
symptomKeys?: string[];
symptomSeverities?: Array<{ key: string; severity?: number | null }>;
symptomLinkCreatedAt?: Array<{ key: string; createdAt: string }>;
},
symptomIdByKey: ReadonlyMap<string, string>,
) {
const severityByKey = new Map<string, number>();
for (const s of d.symptomSeverities ?? []) {
const severity = severityOrNull(s.severity);
if (severity !== null) severityByKey.set(s.key, severity);
}
const linkCreatedAt = new Map(
(d.symptomLinkCreatedAt ?? []).map((l) => [l.key, l.createdAt]),
);
return (d.symptomKeys ?? []).flatMap((k) => {
const id = symptomIdByKey.get(k);
const createdAt = linkCreatedAt.get(k);
return id
? [
{
symptomId: id,
severity: severityByKey.get(k) ?? null,
...(createdAt ? { createdAt: new Date(createdAt) } : {}),
},
]
: [];
});
}
/**
* Restore the cycle tables for `ownerId` from a parsed backup payload,
* inside an existing transaction. Delete-then-recreate, matching the
@@ -617,26 +675,7 @@ export async function restoreCycleData(
};
for (const d of payload.cycleDayLogs) {
// The day's rated intensities, keyed by symptom. A file written before
// severities were carried has no list at all and every link comes back
// unrated, which is what it was. An entry naming a symptom the day does not
// have is simply never read.
const severityByKey = new Map<string, number>();
for (const s of d.symptomSeverities ?? []) {
const severity = severityOrNull(s.severity);
if (severity !== null) severityByKey.set(s.key, severity);
}
// Every unresolvable key was counted into `skips` above, so dropping it
// here is a reported drop rather than a silent one. The rest of the day —
// flow, temperature, the encrypted note, every observation the person
// actually wrote — is unaffected by a symptom chip that will not resolve.
const symptomLinks = (d.symptomKeys ?? []).flatMap((k) => {
const id = symptomIdByKey.get(k);
return id
? [{ symptomId: id, severity: severityByKey.get(k) ?? null }]
: [];
});
const symptomLinks = dayLogSymptomLinks(d, symptomIdByKey);
const cycleId =
d.cycleId !== undefined
? d.cycleId === null
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { Buffer } from "node:buffer";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { parseBackupPayload } from "@/lib/validations/backup";
process.env.ENCRYPTION_KEY =
@@ -201,6 +201,7 @@ function makePrisma() {
enteredUnit: "IU",
source: "MANUAL",
createdAt: new Date("2026-07-01T08:00:00.000Z"),
updatedAt: new Date("2026-07-01T08:05:00.000Z"),
},
],
// v1.40 (#1024) — one ended course with a note, one running.
@@ -211,6 +212,7 @@ function makePrisma() {
endsOn: new Date("2026-03-07T00:00:00.000Z"),
noteEncrypted: encryptToBytes("the March flu"),
createdAt: new Date("2026-03-01T08:00:00.000Z"),
updatedAt: new Date("2026-03-01T08:05:00.000Z"),
},
{
id: "course-june",
@@ -218,6 +220,7 @@ function makePrisma() {
endsOn: null,
noteEncrypted: null,
createdAt: new Date("2026-06-10T08:00:00.000Z"),
updatedAt: new Date("2026-06-10T08:05:00.000Z"),
},
],
// A two-step ramp, one row carrying an encrypted note and one
@@ -374,10 +377,12 @@ function makePrisma() {
{
rating: 4,
moodTag: { key: "sleep_quality", kind: "RATED" },
createdAt: new Date("2026-07-19T20:00:00.000Z"),
},
{
rating: null,
moodTag: { key: "headache", kind: "BINARY" },
createdAt: new Date("2026-07-19T20:00:01.000Z"),
},
],
},
@@ -393,6 +398,7 @@ function makePrisma() {
source: "MANUAL",
externalSourceVersion: null,
createdAt: new Date("2026-07-19T20:15:00.000Z"),
updatedAt: new Date("2026-07-19T20:16:00.000Z"),
},
]),
},
@@ -785,32 +791,47 @@ describe("buildFullBackupPayload disaster-recovery mode", () => {
* decision.
*/
describe("buildFullBackupPayload — medication courses (v1.40, #1024)", () => {
it.each(["portable-export", "disaster-recovery"] as const)(
"carries every course with its note readable in a %s file",
async (purpose) => {
installSectionMocks();
const { payload } = await buildFullBackupPayload(
makePrisma() as never,
"user-1",
{ purpose, exportedAt: new Date("2026-07-20T00:00:00.000Z") },
);
const parsed = parseBackupPayload(payload);
expect(parsed.medications[0].courses).toEqual([
{
startsOn: "2026-03-01",
endsOn: "2026-03-07",
note: "the March flu",
createdAt: "2026-03-01T08:00:00.000Z",
},
{
startsOn: "2026-06-10",
endsOn: null,
note: null,
createdAt: "2026-06-10T08:00:00.000Z",
},
]);
},
);
async function courses(purpose: "portable-export" | "disaster-recovery") {
installSectionMocks();
const { payload } = await buildFullBackupPayload(
makePrisma() as never,
"user-1",
{ purpose, exportedAt: new Date("2026-07-20T00:00:00.000Z") },
);
return parseBackupPayload(payload).medications[0].courses;
}
it("carries every course with its note readable in a portable file", async () => {
expect(await courses("portable-export")).toEqual([
{
startsOn: "2026-03-01",
endsOn: "2026-03-07",
note: "the March flu",
createdAt: "2026-03-01T08:00:00.000Z",
},
{
startsOn: "2026-06-10",
endsOn: null,
note: null,
createdAt: "2026-06-10T08:00:00.000Z",
},
]);
});
it("carries every course with its note as ciphertext in a disaster-recovery file", async () => {
const [march, june] = (await courses("disaster-recovery"))!;
expect(march).toMatchObject({
startsOn: "2026-03-01",
endsOn: "2026-03-07",
note: null,
createdAt: "2026-03-01T08:00:00.000Z",
updatedAt: "2026-03-01T08:05:00.000Z",
});
expect(decryptFromBytes(Buffer.from(march!.noteEncrypted!, "base64"))).toBe(
"the March flu",
);
expect(june).toMatchObject({ note: null, noteEncrypted: null });
});
});
describe("buildFullBackupPayload — supplement composition (v1.43)", () => {
@@ -836,6 +857,9 @@ describe("buildFullBackupPayload — supplement composition (v1.43)", () => {
enteredUnit: "IU",
source: "MANUAL",
createdAt: "2026-07-01T08:00:00.000Z",
...(purpose === "disaster-recovery"
? { updatedAt: "2026-07-01T08:05:00.000Z" }
: {}),
},
]);
},
@@ -112,7 +112,13 @@ function buildMockPrisma() {
feverC: null,
noteEncrypted: encryptToBytes("Bad day."),
updatedAt: new Date("2026-04-10T00:00:00.000Z"),
symptomLinks: [{ severity: 3, symptom: { key: "headache" } }],
symptomLinks: [
{
severity: 3,
symptom: { key: "headache" },
createdAt: new Date("2026-04-10T07:00:00.000Z"),
},
],
},
],
},
@@ -298,7 +304,13 @@ describe("buildRecordsBackupSection", () => {
createdAt,
updatedAt,
deletedAt,
symptomLinks: [{ severity: 3, symptom: { key: "headache" } }],
symptomLinks: [
{
severity: 3,
symptom: { key: "headache" },
createdAt: new Date("2026-04-10T07:00:00.000Z"),
},
],
},
],
},
@@ -384,7 +396,13 @@ describe("buildRecordsBackupSection", () => {
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
deletedAt: deletedAt.toISOString(),
symptoms: [{ key: "headache", severity: 3 }],
symptoms: [
{
key: "headache",
severity: 3,
createdAt: "2026-04-10T07:00:00.000Z",
},
],
},
],
});
@@ -139,7 +139,12 @@ function sourceClient() {
deletedAt: null,
createdAt: new Date("2026-07-19T00:00:00.000Z"),
updatedAt: new Date("2026-07-19T00:00:00.000Z"),
symptomLinks: [{ symptom: { key: "custom:jaw_tension" } }],
symptomLinks: [
{
symptom: { key: "custom:jaw_tension" },
createdAt: new Date("2026-07-19T08:00:00.000Z"),
},
],
},
]),
},
+3
View File
@@ -18,6 +18,9 @@ export const illnessSymptomBackupSchema = z
.object({
key: z.string().min(1),
severity: z.number().int().nullable().optional(),
// When the link was written. Disaster-recovery files only; without it the
// link is stamped with the restore, as before.
createdAt: isoDateTime.optional(),
})
.passthrough();
export const illnessDayLogBackupSchema = z
+18 -2
View File
@@ -94,6 +94,7 @@ export async function buildIllnessBackupSlice(
symptoms: dayLog.symptomLinks.map((link) => ({
key: link.symptom.key,
severity: link.severity,
createdAt: link.createdAt.toISOString(),
})),
})),
}))
@@ -168,7 +169,14 @@ async function restoreEpisodes(ctx: RestoreContext): Promise<void> {
if (episode.parentConditionId) {
await tx.illnessEpisode.update({
where: { id: episode.id, userId: ownerId },
data: { parentConditionId: episode.parentConditionId },
// The pointer is a second write to the row; the row's own last write
// stays the one the file carries.
data: {
parentConditionId: episode.parentConditionId,
...(episode.updatedAt
? { updatedAt: new Date(episode.updatedAt) }
: {}),
},
});
}
}
@@ -212,7 +220,15 @@ async function restoreDayLogs(ctx: RestoreContext): Promise<void> {
const symptomLinks = dayLog.symptoms.flatMap((symptom) => {
const symptomId = symptomByKey.get(symptom.key);
return symptomId
? [{ symptomId, severity: symptom.severity ?? null }]
? [
{
symptomId,
severity: symptom.severity ?? null,
...(symptom.createdAt
? { createdAt: new Date(symptom.createdAt) }
: {}),
},
]
: [];
});
await tx.illnessDayLog.create({
+3
View File
@@ -81,6 +81,9 @@ export const biomarkerBackupSchema = z
// The catalogue analyte the marker resolves to.
analyteKey: z.string().nullable().optional(),
context: z.string().nullable().optional(),
// The context's ciphertext, disaster-recovery files only. A file without
// it carries the readable context, which the restore seals.
contextEncrypted: base64BytesSchema.nullable().optional(),
// The other spellings the marker answers to. Absent on
// files written before them.
aliases: z
+28 -8
View File
@@ -103,6 +103,11 @@ export interface BiomarkerBackupEntry {
panel: string | null;
hidden: boolean;
context: string | null;
/**
* The context's ciphertext as base64, disaster-recovery files only, where
* `context` is null. A file without it carries the readable context.
*/
contextEncrypted?: string | null;
/** The catalogue key the marker's name and unit resolve to. */
analyteKey?: string | null;
/**
@@ -259,11 +264,22 @@ export async function buildLabsBackupSlice(
upperBound: b.upperBound,
panel: b.panel,
hidden: b.hidden,
context: readForExport(
b.contextEncrypted,
"biomarker context",
decryptContextFromBytes,
),
// Ciphertext verbatim in a disaster-recovery file, like every other
// sealed column; the readable context in a portable one.
...(disasterRecovery
? {
context: null,
contextEncrypted: b.contextEncrypted
? Buffer.from(b.contextEncrypted).toString("base64")
: null,
}
: {
context: readForExport(
b.contextEncrypted,
"biomarker context",
decryptContextFromBytes,
),
}),
analyteKey: b.analyteKey,
aliases: aliasesByMarker.get(b.id) ?? [],
}));
@@ -347,9 +363,13 @@ export async function restoreLabMarkers(ctx: RestoreContext): Promise<void> {
panel: biomarker.panel ?? null,
hidden: biomarker.hidden ?? false,
contextEncrypted:
biomarker.context == null
? null
: encryptContextToBytes(biomarker.context),
biomarker.contextEncrypted !== undefined
? biomarker.contextEncrypted === null
? null
: decodeEncryptedBytes(biomarker.contextEncrypted)
: biomarker.context == null
? null
: encryptContextToBytes(biomarker.context),
analyteKey: biomarker.analyteKey ?? null,
...(biomarker.createdAt
? { createdAt: new Date(biomarker.createdAt) }
+21 -2
View File
@@ -109,6 +109,9 @@ export const medicationIngredientSchema = z
enteredUnit: z.string().max(8).nullable().optional(),
source: z.enum(["MANUAL", "LABEL_AI"]).default("MANUAL"),
createdAt: isoDateTime.optional(),
// The row's last write. Disaster-recovery files only; a file without it
// restores the row stamped with the restore, as before.
updatedAt: isoDateTime.optional(),
})
.passthrough();
/**
@@ -121,7 +124,11 @@ export const medicationCourseSchema = z
startsOn: z.iso.date(),
endsOn: z.iso.date().nullable(),
note: z.string().max(280).nullable().optional(),
// The note's ciphertext, disaster-recovery files only. A file without it
// (any written before) carries the readable note, which the restore seals.
noteEncrypted: base64BytesSchema.nullable().optional(),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
})
.passthrough();
/**
@@ -248,12 +255,21 @@ export const medicationEfficacyTargetSchema = z
export const customMedicationCategorySchema = z
.object({
key: z.string().regex(/^custom:[0-9a-f-]{36}$/),
label: z.string().trim().min(1).max(40),
// The readable label: a portable file, and every disaster-recovery file
// written before the label travelled as ciphertext.
label: z.string().trim().min(1).max(40).nullable().optional(),
// The label's ciphertext, as a disaster-recovery file carries it now.
labelEncrypted: base64BytesSchema.optional(),
sortOrder: z.number().int().min(0).default(0),
isActive: z.boolean().default(true),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
})
.passthrough();
.passthrough()
.refine((c) => c.labelEncrypted !== undefined || (c.label ?? "") !== "", {
message: "a custom category needs its label or its ciphertext",
path: ["label"],
});
export const medicationSchema = z
.object({
id: z.string().min(1).optional(),
@@ -293,6 +309,9 @@ export const medicationSchema = z
// whole file. Since v1.40 it may be `custom:<uuid>`, the key of an entry
// in `customMedicationCategories`.
category: z.string().max(64).optional(),
// When the category was last set. Disaster-recovery files only; without
// it the restored assignment is stamped with the restore, as before.
categoryUpdatedAt: isoDateTime.nullable().optional(),
deliveryForm: z.enum(MedicationDeliveryForm).optional(),
trackInjectionSites: z.boolean().optional(),
allowedInjectionSites: z.array(z.enum(InjectionSite)).optional(),
+28 -6
View File
@@ -54,6 +54,9 @@ const MEDICATION_BACKUP_INCLUDE = {
{ id: "asc" },
] as const,
},
// When the category was last set. Read for the disaster-recovery file,
// which gives the assignment back with its own timestamp.
categoryAssignment: { select: { updatedAt: true } },
// 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
@@ -101,6 +104,8 @@ function serialiseMedicationRow(
externalSource: m.externalSource,
externalId: m.externalId,
updatedAt: m.updatedAt.toISOString(),
categoryUpdatedAt:
m.categoryAssignment?.updatedAt.toISOString() ?? null,
}
: // No id: a portable restore mints a fresh one. The relation links
// still name this drug, so the file says which row they mean.
@@ -206,7 +211,9 @@ function serialiseMedicationHistory(
// counting toward the day's micronutrients. Canonical amount plus the
// amount as entered, so the form shows what was typed.
ingredients: m.ingredients.map((i) => ({
...(disasterRecovery ? { id: i.id } : {}),
...(disasterRecovery
? { id: i.id, updatedAt: i.updatedAt.toISOString() }
: {}),
nutrient: i.nutrient,
amountPerUnit: i.amountPerUnit,
unit: i.unit,
@@ -215,10 +222,21 @@ function serialiseMedicationHistory(
source: i.source,
createdAt: i.createdAt.toISOString(),
})),
// The note follows the contract of every other sealed column: the
// disaster-recovery file carries the ciphertext verbatim (and the row's
// own last write), a portable file the decrypted text.
courses: m.courses.map((c) => ({
startsOn: dayKeyOfDate(c.startsOn),
endsOn: c.endsOn ? dayKeyOfDate(c.endsOn) : null,
note: c.noteEncrypted ? decryptFromBytes(c.noteEncrypted) : null,
...(disasterRecovery
? {
note: null,
noteEncrypted: c.noteEncrypted
? Buffer.from(c.noteEncrypted).toString("base64")
: null,
updatedAt: c.updatedAt.toISOString(),
}
: { note: c.noteEncrypted ? decryptFromBytes(c.noteEncrypted) : null }),
createdAt: c.createdAt.toISOString(),
})),
// Titration: when the dose moved, to what, and why. The drug comes
@@ -473,11 +491,15 @@ export async function buildMedicationsBackupPart(
prisma,
);
// The account's own categories, restored before the medications that name
// them by key. Labels travel decrypted so a file restores on an instance
// with a different key; every one is carried, hidden ones included,
// because a medication can still be filed under it.
// them by key. Every one is carried, hidden ones included, because a
// medication can still be filed under it. A portable file carries the
// label decrypted, so it restores on an instance with another key; a
// disaster-recovery file carries the ciphertext verbatim, like every other
// sealed column, and the row's own last write.
const customMedicationCategories =
await readMedicationCategoryLabelsForBackup(userId, prisma);
await readMedicationCategoryLabelsForBackup(userId, prisma, {
disasterRecovery,
});
// 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
+41 -2
View File
@@ -77,10 +77,18 @@ async function restoreCustomCategories(
data: {
userId: ownerId,
key,
labelEncrypted: encryptCategoryLabel(c.label),
// The ciphertext verbatim when the file carries it (a
// disaster-recovery file), the readable label sealed here otherwise
// (a portable file, or a recovery file written before the label
// travelled sealed).
labelEncrypted:
c.labelEncrypted !== undefined
? decodeEncryptedBytes(c.labelEncrypted)
: encryptCategoryLabel(c.label ?? ""),
sortOrder: c.sortOrder,
isActive: c.isActive,
...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}),
...(c.updatedAt ? { updatedAt: new Date(c.updatedAt) } : {}),
},
});
}
@@ -254,6 +262,7 @@ function medicationChildrenData(m: BackupMedication, ownerId: string) {
enteredUnit: i.enteredUnit ?? null,
source: i.source,
...(i.createdAt ? { createdAt: new Date(i.createdAt) } : {}),
...(i.updatedAt ? { updatedAt: new Date(i.updatedAt) } : {}),
})),
},
// `resumedAt` passes through as null when the era is open. It is not
@@ -341,14 +350,20 @@ async function restoreCourses(
startsOn: Date;
endsOn: Date | null;
note: string | null;
noteEncrypted?: string | null;
createdAt?: Date;
updatedAt?: Date;
}> =
m.courses !== undefined
? m.courses.map((c) => ({
startsOn: dateOfDayKey(c.startsOn),
endsOn: c.endsOn ? dateOfDayKey(c.endsOn) : null,
note: c.note ?? null,
...(c.noteEncrypted !== undefined
? { noteEncrypted: c.noteEncrypted }
: {}),
...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}),
...(c.updatedAt ? { updatedAt: new Date(c.updatedAt) } : {}),
}))
: (() => {
const derived = courseFromWindow(
@@ -365,8 +380,18 @@ async function restoreCourses(
userId: ownerId,
startsOn: c.startsOn,
endsOn: c.endsOn,
noteEncrypted: c.note ? encryptToBytes(c.note) : null,
// Ciphertext verbatim from a disaster-recovery file; the readable note
// of a portable or older file sealed here.
noteEncrypted:
c.noteEncrypted !== undefined
? c.noteEncrypted === null
? null
: decodeEncryptedBytes(c.noteEncrypted)
: c.note
? encryptToBytes(c.note)
: null,
...(c.createdAt ? { createdAt: c.createdAt } : {}),
...(c.updatedAt ? { updatedAt: c.updatedAt } : {}),
})),
});
const window = projectCourseWindow(courseRows);
@@ -589,6 +614,12 @@ export async function restoreMedications(ctx: RestoreContext): Promise<void> {
categoryKeyRemap.get(m.category) ?? m.category,
tx,
);
if (m.categoryUpdatedAt) {
await tx.medicationCategoryAssignment.update({
where: { medicationId: created.id },
data: { updatedAt: new Date(m.categoryUpdatedAt) },
});
}
}
await restoreCourses(tx, ownerId, m, created, restoreTz);
await restoreScheduleRevisions(
@@ -598,6 +629,14 @@ export async function restoreMedications(ctx: RestoreContext): Promise<void> {
medicationIndex,
unresolvedRevisionLinks,
);
// The writes above (the course window) moved the row's `updatedAt` to
// now; a file that carries the row's own last write puts it back.
if (m.updatedAt) {
await tx.medication.update({
where: { id: created.id },
data: { updatedAt: new Date(m.updatedAt) },
});
}
for (const target of m.efficacyTargets) {
links.pendingEfficacyTargets.push({ medicationId: created.id, target });
}
+9
View File
@@ -81,6 +81,12 @@ export const moodEntrySchema = z
// RATED-only meaning, so every file written before this one parses
// unchanged and restores identically: absent reads as none.
structuredTags: z.array(z.string().min(1)).default([]),
// When each link (rated or not) was written, by tag key. Disaster-recovery
// files only; a file without it restores the links stamped with the
// restore, as before.
linkCreatedAt: z
.array(z.object({ key: z.string().min(1), createdAt: isoDateTime }))
.optional(),
// The day context, when the entry had one. Optional and nullable at the
// top: a file written before this existed carries no `context` key at all
// and must parse and restore exactly as it did. Every field inside is
@@ -116,6 +122,9 @@ export const moodEntrySchema = z
// plain text, a disaster-recovery file the ciphertext.
note: z.string().nullable().optional(),
notesEncrypted: base64BytesSchema.nullable().optional(),
// The context row's own stamps, disaster-recovery files only.
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
})
.passthrough()
.nullable()
+12 -3
View File
@@ -60,6 +60,7 @@ const MOOD_ENTRY_BACKUP_SELECT = {
*/
const MOOD_ENTRY_TAG_LINK_SELECT = {
rating: true,
createdAt: true,
moodTag: { select: { key: true, kind: true } },
} as const satisfies Prisma.MoodEntryTagLinkSelect;
@@ -69,9 +70,8 @@ const MOOD_ENTRY_TAG_LINK_SELECT = {
* `moodEntryId` and `userId` are absent for the reasons the exclusion list in
* `mood-backup-completeness.test.ts` states: the context rides inside its
* entry and is re-bound to whatever id that entry got, and the owner is
* re-derived from the restoring account. The two row stamps are absent for the
* third reason there — they describe when the sub-row was written, not when
* the day happened, and the entry already carries the moment that matters.
* re-derived from the restoring account. The two row stamps travel in the
* disaster-recovery file only, which gives the account back as it was.
*
* Named rather than inlined so the guard can compare it field-by-field against
* the model, exactly as it does for the two selects above. This is the whole
@@ -97,6 +97,8 @@ const MOOD_CONTEXT_BACKUP_SELECT = {
eventValence: true,
eventAt: true,
notesEncrypted: true,
createdAt: true,
updatedAt: true,
} as const satisfies Prisma.MoodContextSelect;
type BackupMoodContext = Prisma.MoodContextGetPayload<{
@@ -149,6 +151,8 @@ function serialiseMoodContext(
notesEncrypted: moodContext.notesEncrypted
? Buffer.from(moodContext.notesEncrypted).toString("base64")
: null,
createdAt: moodContext.createdAt.toISOString(),
updatedAt: moodContext.updatedAt.toISOString(),
}
: { note: readNote(moodContext.notesEncrypted, null) }),
workStatus: moodContext.workStatus,
@@ -220,6 +224,11 @@ function serialiseMoodEntry(
syncVersion: moodEntry.syncVersion,
createdAt: moodEntry.createdAt.toISOString(),
updatedAt: moodEntry.updatedAt.toISOString(),
// When each link was written, by tag key, for both link arms.
linkCreatedAt: moodEntry.tagLinks.map((tagLink) => ({
key: tagLink.moodTag.key,
createdAt: tagLink.createdAt.toISOString(),
})),
}
: { note: readNote(moodEntry.noteEncrypted, moodEntry.note) }),
id: moodEntry.id,
+13 -1
View File
@@ -256,6 +256,9 @@ async function restoreMoodContext(
context.notesEncrypted == null
? encryptNote(context.note ?? null)
: decodeEncryptedBytes(context.notesEncrypted),
// The row's own stamps when the file carries them (disaster recovery).
...(context.createdAt ? { createdAt: new Date(context.createdAt) } : {}),
...(context.updatedAt ? { updatedAt: new Date(context.updatedAt) } : {}),
},
});
}
@@ -320,7 +323,16 @@ async function restoreMoodEntry(
? [{ moodEntryId: restored.id, moodTagId, rating: null }]
: [];
});
const links = [...factorLinks, ...tagLinks];
// Each link's own write time, when the file carries it.
const linkCreatedAt = new Map(
(entry.linkCreatedAt ?? []).map((link) => [link.key, link.createdAt]),
);
const keyOfTag = new Map<string, string>();
for (const [key, id] of [...factorByKey, ...tagByKey]) keyOfTag.set(id, key);
const links = [...factorLinks, ...tagLinks].map((link) => {
const createdAt = linkCreatedAt.get(keyOfTag.get(link.moodTagId) ?? "");
return createdAt ? { ...link, createdAt: new Date(createdAt) } : link;
});
if (links.length > 0) {
await tx.moodEntryTagLink.createMany({ data: links });
}
@@ -29,5 +29,8 @@ export const nutrientDaySchema = z
// Absent from files written before v1.39.7.
externalSourceVersion: z.string().nullable().optional(),
createdAt: isoDateTime.optional(),
// Disaster-recovery files only; without it the day is stamped with the
// restore, as before.
updatedAt: isoDateTime.optional(),
})
.passthrough();
+7
View File
@@ -33,6 +33,7 @@ export async function buildNutrientsBackupPart(
source: true,
externalSourceVersion: true,
createdAt: true,
updatedAt: true,
},
orderBy: [{ day: "desc" }, { nutrient: "asc" }],
});
@@ -46,6 +47,11 @@ export async function buildNutrientsBackupPart(
source: n.source,
externalSourceVersion: n.externalSourceVersion,
createdAt: n.createdAt.toISOString(),
// The day's last write, for the disaster-recovery file, which gives
// the account back as it was.
...(ctx.disasterRecovery
? { updatedAt: n.updatedAt.toISOString() }
: {}),
})),
},
counts: { nutrientDays: nutrientDays.length },
@@ -79,6 +85,7 @@ export async function restoreNutrientDays(ctx: RestoreContext): Promise<void> {
source: n.source,
externalSourceVersion: n.externalSourceVersion ?? null,
...(n.createdAt ? { createdAt: new Date(n.createdAt) } : {}),
...(n.updatedAt ? { updatedAt: new Date(n.updatedAt) } : {}),
})),
});
}
+15 -2
View File
@@ -1,3 +1,4 @@
import { Buffer } from "node:buffer";
import { randomUUID } from "node:crypto";
import type { Prisma } from "@/generated/prisma/client";
@@ -254,13 +255,16 @@ export async function listMedicationCategoryLabels(
export async function readMedicationCategoryLabelsForBackup(
userId: string,
client: CategoryClient = prisma,
options: { disasterRecovery?: boolean } = {},
): Promise<
Array<{
key: string;
label: string;
label: string | null;
labelEncrypted?: string;
sortOrder: number;
isActive: boolean;
createdAt: string;
updatedAt?: string;
}>
> {
const rows = await client.medicationCategoryLabel.findMany({
@@ -272,14 +276,23 @@ export async function readMedicationCategoryLabelsForBackup(
sortOrder: true,
isActive: true,
createdAt: true,
updatedAt: true,
},
});
return rows.map((r) => ({
key: r.key,
label: decryptCategoryLabel(r.labelEncrypted),
...(options.disasterRecovery
? {
label: null,
labelEncrypted: Buffer.from(r.labelEncrypted).toString("base64"),
}
: { label: decryptCategoryLabel(r.labelEncrypted) }),
sortOrder: r.sortOrder,
isActive: r.isActive,
createdAt: r.createdAt.toISOString(),
...(options.disasterRecovery
? { updatedAt: r.updatedAt.toISOString() }
: {}),
}));
}