From 8eb36da2281cfc737701ad600dea86abeac62a80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 07:14:04 +0200 Subject: [PATCH] feat(backup): carry the mood day context through export and restore - the completeness guard now binds a third model, MoodContext, to a named select; the four omitted columns each carry the reason the backup is right to drop them - the disaster-recovery arm carries the ciphertext, the portable arm the plain text, and every other column rides in both - the restore rebuilds the context after its entry, inside the same transaction, and a file written before contexts existed restores without one - the round trip asserts the restored row by value, zero included --- .../mood-backup-completeness.test.ts | 73 +++++++- .../api/admin/backups/[id]/restore/route.ts | 44 +++++ src/lib/export/full-backup-payload.ts | 99 +++++++++++ src/lib/validations/backup.ts | 39 ++++ .../mood-backup-round-trip.test.ts | 168 ++++++++++++++++++ 5 files changed, 420 insertions(+), 3 deletions(-) diff --git a/src/__tests__/mood-backup-completeness.test.ts b/src/__tests__/mood-backup-completeness.test.ts index ef483325d..20d786091 100644 --- a/src/__tests__/mood-backup-completeness.test.ts +++ b/src/__tests__/mood-backup-completeness.test.ts @@ -1,6 +1,7 @@ /** - * Pair guard binding `prisma/schema.prisma`'s `MoodEntry` and - * `MoodEntryTagLink` models to the disaster-recovery backup selects. + * Pair guard binding `prisma/schema.prisma`'s `MoodEntry`, + * `MoodEntryTagLink` and `MoodContext` models to the disaster-recovery backup + * selects. * * The sibling of `measurement-backup-completeness.test.ts`, written as its own * file rather than as a second describe inside it, because that guard's own @@ -27,7 +28,8 @@ * this goes red naming that column; add a scalar to the model without adding * it to the select and it goes red naming the new one; rename the mapper's * `moodEntry` parameter and the serialization assertion goes red on all of - * them at once. + * them at once. The same three hold for `MOOD_CONTEXT_BACKUP_SELECT` and the + * `moodContext` mapper parameter beside it. */ import { readFileSync } from "node:fs"; import { join } from "node:path"; @@ -52,6 +54,16 @@ const EXCLUDED_MOOD_ENTRY_COLUMNS: Record = { userId: "user-scoped backup; owner is re-derived from the restoring account", }; +const EXCLUDED_MOOD_CONTEXT_COLUMNS: Record = { + 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 = { moodEntryId: "re-bound to the restored parent row's id, never carried", moodTagId: @@ -173,6 +185,11 @@ describe("mood backup completeness", () => { models, ); const linkSelected = selectFields(source, "MOOD_ENTRY_TAG_LINK_SELECT"); + const contextColumns = scalarFields( + modelBlock(schema, "MoodContext"), + models, + ); + const contextSelected = selectFields(source, "MOOD_CONTEXT_BACKUP_SELECT"); it("parses both ends into a plausible shape", () => { expect(models.size).toBeGreaterThan(50); @@ -194,6 +211,47 @@ describe("mood backup completeness", () => { expect(linkColumns).not.toContain("moodEntry"); expect(linkColumns).not.toContain("moodTag"); expect(linkSelected.length).toBeGreaterThan(0); + + expect(contextColumns.length).toBeGreaterThan(15); + expect(contextColumns).toContain("notesEncrypted"); + expect(contextColumns).toContain("eventAt"); + expect(contextColumns).not.toContain("moodEntry"); + expect(contextColumns).not.toContain("user"); + expect(contextSelected.length).toBeGreaterThan(15); + }); + + it("the backup select covers every MoodContext column", () => { + const missing = contextColumns.filter( + (c) => + !contextSelected.includes(c) && !(c in EXCLUDED_MOOD_CONTEXT_COLUMNS), + ); + + if (missing.length > 0) { + const report = missing.map((c) => ` ❌ ${c}`).join("\n"); + throw new Error( + `MOOD_CONTEXT_BACKUP_SELECT omits ${missing.length} column(s) of the MoodContext model:\n${report}\n\n` + + "A disaster-recovery restore brings these back as NULL, which reads " + + "as an answer nobody gave rather than as a lost one. Add each to " + + "the select and to both serialization arms, extend " + + "`moodContextSchema` in the backup validations and the restore " + + "write — or add it to EXCLUDED_MOOD_CONTEXT_COLUMNS with the reason " + + "the backup is correct to drop it.", + ); + } + }); + + it("every selected MoodContext column reaches a serialized payload", () => { + const emitted = serialisedFields(source, "moodContext"); + const dropped = contextSelected.filter((c) => !emitted.has(c)); + + if (dropped.length > 0) { + const report = dropped.map((c) => ` ❌ ${c}`).join("\n"); + throw new Error( + `${dropped.length} selected MoodContext column(s) reach neither serialization arm:\n${report}\n\n` + + "The query pays for them and no payload carries them, so a restore " + + "cannot put them back.", + ); + } }); it("the backup select covers every MoodEntry column", () => { @@ -290,5 +348,14 @@ describe("mood backup completeness", () => { ).toContain(column); expect(reason.length).toBeGreaterThan(20); } + for (const [column, reason] of Object.entries( + EXCLUDED_MOOD_CONTEXT_COLUMNS, + )) { + expect( + contextColumns, + `${column} is excluded but is no longer a MoodContext column`, + ).toContain(column); + expect(reason.length).toBeGreaterThan(20); + } }); }); diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index a6196cf0c..431bb15a2 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -858,6 +858,50 @@ const handler = apiHandler( update: restoredData, }); + // The day context, rebuilt after the entry so it can bind to + // whatever id that entry actually got. Delete-then-write rather + // than an upsert with a preserve-when-absent arm: the file + // states the whole context or states that there was none, and a + // restored entry carrying half a file's context and half a live + // row's would be a state neither of them describes. + await tx.moodContext.deleteMany({ + where: { moodEntryId: restored.id }, + }); + if (entry.context) { + const context = entry.context; + await tx.moodContext.create({ + data: { + moodEntryId: restored.id, + userId: ownerId, + workStatus: context.workStatus ?? null, + workMinutes: context.workMinutes ?? null, + overtimeMinutes: context.overtimeMinutes ?? null, + workLoad: context.workLoad ?? null, + workSatisfaction: context.workSatisfaction ?? null, + contactCircles: context.contactCircles ?? null, + contactForm: context.contactForm ?? null, + contactExtent: context.contactExtent ?? null, + contactQuality: context.contactQuality ?? null, + contactSupport: context.contactSupport ?? null, + leisureCategories: context.leisureCategories ?? null, + leisureMinutes: context.leisureMinutes ?? null, + leisureJoy: context.leisureJoy ?? null, + leisureRecovery: context.leisureRecovery ?? null, + eventType: context.eventType ?? null, + eventValence: context.eventValence ?? null, + eventAt: context.eventAt ? new Date(context.eventAt) : null, + // The note comes back the way every other dual-column + // note does: ciphertext verbatim when the file carries + // it, a portable file's plain text re-encrypted on the + // way in. + notesEncrypted: + context.notesEncrypted == null + ? encryptNote(context.note ?? null) + : decodeEncryptedBytes(context.notesEncrypted), + }, + }); + } + await tx.moodEntryTagLink.deleteMany({ where: { moodEntryId: restored.id }, }); diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 0d3375c99..8a64e91ef 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -148,6 +148,96 @@ const MOOD_ENTRY_TAG_LINK_SELECT = { moodTag: { select: { key: true, kind: true } }, } as const satisfies Prisma.MoodEntryTagLinkSelect; +/** + * Every restorable `MoodContext` scalar. + * + * `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. + * + * 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 + * point of the pair: a column added to `MoodContext` without a line here + * fails, and it fails in the release that added it. + */ +const MOOD_CONTEXT_BACKUP_SELECT = { + workStatus: true, + workMinutes: true, + overtimeMinutes: true, + workLoad: true, + workSatisfaction: true, + contactCircles: true, + contactForm: true, + contactExtent: true, + contactQuality: true, + contactSupport: true, + leisureCategories: true, + leisureMinutes: true, + leisureJoy: true, + leisureRecovery: true, + eventType: true, + eventValence: true, + eventAt: true, + notesEncrypted: true, +} as const satisfies Prisma.MoodContextSelect; + +type BackupMoodContext = Prisma.MoodContextGetPayload<{ + select: typeof MOOD_CONTEXT_BACKUP_SELECT; +}>; + +/** + * One context row, in whichever of the two wire shapes the caller asked for. + * + * The split is the same one `Measurement` makes: a disaster-recovery file + * carries the ciphertext verbatim, because the instance that reads it back + * holds the key; a portable export carries the plain text, because the point + * of a portable file is that somebody can read it. Every other column rides in + * both, since none of them is a storage detail — they are all answers somebody + * gave. + * + * The parameter is spelled `moodContext` rather than `c`, deliberately: the + * completeness guard proves a selected column reaches a payload by matching + * `moodContext.`, and a one-letter parameter shared with another + * mapper in this file would make that matcher unable to fail. + */ +function serialiseMoodContext( + moodContext: BackupMoodContext, + disasterRecovery: boolean, +) { + return { + ...(disasterRecovery + ? { + notesEncrypted: moodContext.notesEncrypted + ? Buffer.from(moodContext.notesEncrypted).toString("base64") + : null, + } + : { note: readNote(moodContext.notesEncrypted, null) }), + workStatus: moodContext.workStatus, + workMinutes: moodContext.workMinutes, + overtimeMinutes: moodContext.overtimeMinutes, + workLoad: moodContext.workLoad, + workSatisfaction: moodContext.workSatisfaction, + // The two multi-selects ride as the stored JSON string, unparsed. The + // restore writes the column back verbatim, so a file cannot arrive with a + // list the parser and the column disagree about. + contactCircles: moodContext.contactCircles, + contactForm: moodContext.contactForm, + contactExtent: moodContext.contactExtent, + contactQuality: moodContext.contactQuality, + contactSupport: moodContext.contactSupport, + leisureCategories: moodContext.leisureCategories, + leisureMinutes: moodContext.leisureMinutes, + leisureJoy: moodContext.leisureJoy, + leisureRecovery: moodContext.leisureRecovery, + eventType: moodContext.eventType, + eventValence: moodContext.eventValence, + eventAt: moodContext.eventAt?.toISOString() ?? null, + }; +} + /** * Build the canonical full-backup payload for `userId`. Portable exports omit * tombstones and document ciphertext; disaster-recovery payloads preserve @@ -259,6 +349,9 @@ export async function buildFullBackupPayload( // definition — so the present/absent tags, the kind most people pick, // were absent from the file and could not come back from it. tagLinks: { select: MOOD_ENTRY_TAG_LINK_SELECT }, + // The day context, when the entry has one. Sparse by design — most + // entries carry none, and the join answers null on those. + context: { select: MOOD_CONTEXT_BACKUP_SELECT }, }, }), // The account's OWN mood tags. The seeded catalogue is reference data every @@ -505,6 +598,12 @@ export async function buildFullBackupPayload( deletedAt: moodEntry.deletedAt?.toISOString() ?? null, factors, structuredTags, + // The day context, or null. Null rather than an empty object, because + // "no context" and "a context whose every field is empty" are + // different facts and the restore has to keep them apart. + context: moodEntry.context + ? serialiseMoodContext(moodEntry.context, disasterRecovery) + : null, }; }), customMoodTags: customMoodTags.map((tag) => ({ diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index b8f671448..1f18b39fa 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -287,6 +287,45 @@ 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([]), + // 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 + // optional for the same reason a context row's columns are nullable — + // only the sections somebody opened say anything. + // + // The two multi-selects ride as the stored JSON string rather than as + // arrays, so the restore writes the column back verbatim and a file cannot + // arrive with a list the parser and the column disagree about. Nothing is + // re-validated against the vocabulary here: a backup restores what the + // account had, and a key retired between the export and the restore is + // still that person's answer. + context: z + .object({ + workStatus: z.string().nullable().optional(), + workMinutes: z.number().int().nullable().optional(), + overtimeMinutes: z.number().int().nullable().optional(), + workLoad: z.number().int().nullable().optional(), + workSatisfaction: z.number().int().nullable().optional(), + contactCircles: z.string().nullable().optional(), + contactForm: z.string().nullable().optional(), + contactExtent: z.string().nullable().optional(), + contactQuality: z.number().int().nullable().optional(), + contactSupport: z.number().int().nullable().optional(), + leisureCategories: z.string().nullable().optional(), + leisureMinutes: z.number().int().nullable().optional(), + leisureJoy: z.number().int().nullable().optional(), + leisureRecovery: z.number().int().nullable().optional(), + eventType: z.string().nullable().optional(), + eventValence: z.number().int().nullable().optional(), + eventAt: isoDateTime.nullable().optional(), + // Same pair as the entry's own note: a portable file carries the + // plain text, a disaster-recovery file the ciphertext. + note: z.string().nullable().optional(), + notesEncrypted: base64BytesSchema.nullable().optional(), + }) + .passthrough() + .nullable() + .optional(), }) .passthrough(); diff --git a/tests/integration/mood-backup-round-trip.test.ts b/tests/integration/mood-backup-round-trip.test.ts index 71cbe1236..310e122c5 100644 --- a/tests/integration/mood-backup-round-trip.test.ts +++ b/tests/integration/mood-backup-round-trip.test.ts @@ -67,6 +67,8 @@ const LOGGED_AT = new Date("2026-07-19T23:40:00.000Z"); const CREATED_AT = new Date("2026-07-19T23:41:00.000Z"); const UPDATED_AT = new Date("2026-07-20T06:05:00.000Z"); const DELETED_AT = new Date("2026-07-20T07:00:00.000Z"); +const CONTEXT_NOTE = "the meeting overran and the evening went with it"; +const CONTEXT_EVENT_AT = new Date("2026-07-19T16:30:00.000Z"); beforeEach(async () => { await truncateAllTables(getPrismaClient()); @@ -214,6 +216,29 @@ describe("mood backup round trip", () => { { moodTagId: ownTag.id, rating: null }, ], }, + // The day context, with one value from each of the four sections plus + // its own encrypted note. A zero is in there for the same reason it is + // among the level-A values above: zero is a real answer and a carrier + // that read it as absence would round-trip it to NULL and pass every + // truthiness check on the way. + context: { + create: { + userId: OWNER_ID, + workStatus: "overtime", + workMinutes: 600, + workLoad: 8, + workSatisfaction: 0, + contactCircles: JSON.stringify(["partner", "friends"]), + contactForm: "phone", + contactQuality: 7, + leisureCategories: JSON.stringify(["reading"]), + leisureMinutes: 30, + eventType: "conflict", + eventValence: -4, + eventAt: CONTEXT_EVENT_AT, + notesEncrypted: encryptNote(CONTEXT_NOTE), + }, + }, }, }); await prisma.moodEntry.create({ @@ -316,6 +341,149 @@ describe("mood backup round trip", () => { expect(byKey.get("rt_headache")!.rating).toBeNull(); expect(byKey.get("custom:rt_long_walk")!.rating).toBeNull(); expect(byKey.get("rt_sleep_quality")!.rating).toBe(4); + + // The day context comes back as its own row, bound to the restored entry. + // Asserted as "the row is there" before any column is read, so a restore + // that stopped writing it fails by name instead of as a Prisma throw three + // frames down. + expect( + await prisma.moodContext.count({ + where: { moodEntryId: "mood-rt-entry" }, + }), + "the restored entry has no day context — the backup or the restore dropped it", + ).toBe(1); + const context = await prisma.moodContext.findUniqueOrThrow({ + where: { moodEntryId: "mood-rt-entry" }, + }); + expect(context.userId).toBe(OWNER_ID); + expect(context.workStatus).toBe("overtime"); + expect(context.workMinutes).toBe(600); + expect(context.workLoad).toBe(8); + // Zero, not NULL. + expect(context.workSatisfaction).toBe(0); + expect(context.contactCircles).toBe(JSON.stringify(["partner", "friends"])); + expect(context.contactForm).toBe("phone"); + expect(context.contactQuality).toBe(7); + // Untouched sections stay empty rather than arriving as a defaulted middle. + expect(context.contactSupport).toBeNull(); + expect(context.leisureCategories).toBe(JSON.stringify(["reading"])); + expect(context.leisureMinutes).toBe(30); + expect(context.leisureJoy).toBeNull(); + expect(context.eventType).toBe("conflict"); + expect(context.eventValence).toBe(-4); + expect(context.eventAt).toEqual(CONTEXT_EVENT_AT); + expect( + context.notesEncrypted, + "the restored context has no note ciphertext — the backup dropped it", + ).not.toBeNull(); + expect(decryptFromBytes(context.notesEncrypted!)).toBe(CONTEXT_NOTE); + + // The entry that never had a context still has none. A restore that minted + // an empty row for every entry would turn "never asked" into "asked and + // answered nothing" across the whole account. + expect( + await prisma.moodContext.count({ + where: { moodEntryId: "mood-rt-tombstone" }, + }), + ).toBe(0); + }); + + it("a file written before contexts existed restores without one", async () => { + const prisma = getPrismaClient(); + await seedAdminSession(prisma); + await seedOwner(prisma); + + // The compatibility floor for this change: no `context` key at all, and a + // live row that DOES have one. The restore wipes and rebuilds, so the row + // comes back exactly as the file describes it — which means without a + // context, not with the one that happened to be on disk. + await prisma.moodEntry.create({ + data: { + id: "mood-rt-legacy-context", + userId: OWNER_ID, + date: "2026-07-19", + mood: "OKAY", + score: 3, + source: "MANUAL", + moodLoggedAt: LOGGED_AT, + context: { create: { userId: OWNER_ID, workStatus: "regular" } }, + }, + }); + expect(await prisma.moodContext.count()).toBe(1); + + const legacyPayload = { + schemaVersion: "2", + exportedAt: "2026-07-20T00:00:00.000Z", + userId: OWNER_ID, + appSettings: null, + measurements: [], + medications: [], + intakeEvents: [], + moodEntries: [ + { + id: "mood-rt-legacy-context", + date: "2026-07-19", + mood: "OKAY", + score: 3, + tags: null, + source: "MANUAL", + loggedAt: LOGGED_AT.toISOString(), + externalId: null, + deletedAt: null, + factors: [], + }, + ], + customMoodTags: [], + nutrientDays: [], + }; + + const response = await restoreFromPayload( + prisma, + parseBackupPayload(legacyPayload), + "MOOD_CONTEXT_LEGACY", + ); + expect(response.status).toBe(200); + await prisma.moodEntry.findUniqueOrThrow({ + where: { id: "mood-rt-legacy-context" }, + }); + expect(await prisma.moodContext.count()).toBe(0); + }); + + it("carries the context through a portable export, in plain text", async () => { + const prisma = getPrismaClient(); + await seedOwner(prisma); + await prisma.moodEntry.create({ + data: { + id: "mood-rt-portable", + userId: OWNER_ID, + date: "2026-07-19", + mood: "GUT", + score: 4, + source: "MANUAL", + moodLoggedAt: LOGGED_AT, + context: { + create: { + userId: OWNER_ID, + leisureCategories: JSON.stringify(["music"]), + leisureJoy: 9, + notesEncrypted: encryptNote(CONTEXT_NOTE), + }, + }, + }, + }); + + const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { + purpose: "portable-export", + }); + const entries = (payload as { moodEntries: Array> }) + .moodEntries; + const context = entries[0].context as Record; + // A portable file is for a person to read: the note is plain text and no + // ciphertext rides along. + expect(context.note).toBe(CONTEXT_NOTE); + expect(context.notesEncrypted).toBeUndefined(); + expect(context.leisureJoy).toBe(9); + expect(context.leisureCategories).toBe(JSON.stringify(["music"])); }); it("wipes before it rebuilds, so a live row comes back exactly as the file describes it", async () => {