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
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 07:14:04 +02:00
1 parent a25ec211cd
commit 8eb36da228
5 files changed
+420 -3

No files matched your search

+70 -3
View File
@@ -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<string, string> = {
userId: "user-scoped backup; owner is re-derived from the restoring account",
};
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:
@@ -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);
}
});
});
@@ -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 },
});
+99
View File
@@ -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.<column>`, 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) => ({
+39
View File
@@ -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();
@@ -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<Record<string, unknown>> })
.moodEntries;
const context = entries[0].context as Record<string, unknown>;
// 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 () => {