From 1bdc5ea4eefdc5d5e85bd2f3b353ba85915cc957 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 20:57:58 +0200 Subject: [PATCH 1/8] test(backup): bind MoodEntry to a named backup select MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mood read was an inline `include`, so nothing could compare it to the model the way `measurement-backup-completeness.test.ts` compares the measurement select. Name it — `MOOD_ENTRY_BACKUP_SELECT` and `MOOD_ENTRY_TAG_LINK_SELECT` — and add the sibling guard that binds both to `prisma/schema.prisma`. The mapper's parameter is spelled `moodEntry` rather than `e` because the guard proves a column reaches a payload by matching `: moodEntry.`, and `e` is also the intake-event mapper's parameter one section above: a matcher on `: e.` would match across both and could never fail. The guard is red as it stands, and names the defect: `note`, `noteEncrypted`, `tz`, `syncedAt`, `syncVersion`, `createdAt` and `updatedAt` are selected and written to no payload. The fix follows. --- .../mood-backup-completeness.test.ts | 263 ++++++++++++++++++ .../__tests__/full-backup-payload.test.ts | 8 +- src/lib/export/full-backup-payload.ts | 79 ++++-- 3 files changed, 331 insertions(+), 19 deletions(-) create mode 100644 src/__tests__/mood-backup-completeness.test.ts diff --git a/src/__tests__/mood-backup-completeness.test.ts b/src/__tests__/mood-backup-completeness.test.ts new file mode 100644 index 000000000..ca7eb3b0f --- /dev/null +++ b/src/__tests__/mood-backup-completeness.test.ts @@ -0,0 +1,263 @@ +/** + * Pair guard binding `prisma/schema.prisma`'s `MoodEntry` and + * `MoodEntryTagLink` 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 + * note asks for model-by-model growth and one file per model keeps every + * failure message about one model. + * + * The failure it exists to catch, stated by the defect that prompted it: the + * mood payload carried `id, date, mood, score, tags, source, loggedAt, + * externalId, deletedAt, factors` and nothing else. The free-text note, the + * row's timezone and the last-writer-wins counter were selected by the query + * and written to no payload, so a restored entry came back without its text, + * read its day boundaries under the legacy Europe/Berlin assumption, and lost + * the next sync round to a paired device still holding a higher counter. The + * backup validation schema is `.passthrough()`, so nothing complained at parse + * time either. Nothing here was a wrong value; every one of them was silence. + * + * Both ends of the comparison are literals — a Prisma model block and a named + * select object — so the matcher is exact. Every matcher also asserts a + * non-zero match count: an empty match set fails rather than passing quietly, + * which is how the bearer-scope guard stayed green for months while matching + * nothing at all. + * + * Mutation check: comment out `tz: true` in `MOOD_ENTRY_BACKUP_SELECT` and + * 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. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, it, expect } from "vitest"; + +const ROOT = process.cwd(); +const SCHEMA = join(ROOT, "prisma", "schema.prisma"); +const BACKUP_BUILDER = join( + ROOT, + "src", + "lib", + "export", + "full-backup-payload.ts", +); + +/** + * Columns the backup is correct to omit, each with the reason. The reasons are + * length-checked below: an exclusion is a claim, and a claim needs saying. + */ +const EXCLUDED_MOOD_ENTRY_COLUMNS: Record = { + userId: "user-scoped backup; owner is re-derived from the restoring account", +}; + +const EXCLUDED_TAG_LINK_COLUMNS: Record = { + 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 { + const start = schema.indexOf(`model ${model} {`); + expect(start, `model ${model} not found`).toBeGreaterThan(-1); + const end = schema.indexOf("\n}", start); + expect(end).toBeGreaterThan(start); + return schema.slice(start, end); +} + +/** Names of every `model` block, so relation fields can be told apart. */ +function declaredModels(schema: string): Set { + const out = new Set(); + for (const m of schema.matchAll(/^model\s+([A-Za-z_]\w*)\s*\{/gm)) { + out.add(m[1]); + } + return out; +} + +/** + * Scalar (column-backed) fields of a model block: everything that is not a + * relation to another model. Enums and native scalars both map to columns and + * therefore both belong in a backup. + */ +function scalarFields(block: string, models: Set): string[] { + const fields: string[] = []; + for (const rawLine of block.split("\n").slice(1)) { + const line = rawLine.trim(); + if (!line || line.startsWith("//") || line.startsWith("@@")) continue; + const m = /^([a-zA-Z_]\w*)\s+([A-Za-z_]\w*)(\[\])?(\?)?/.exec(line); + if (!m) continue; + const [, name, type, isList] = m; + if (isList) continue; + if (models.has(type)) continue; + if (line.includes("@relation")) continue; + fields.push(name); + } + return fields; +} + +/** Keys of a named `const = {` select literal. */ +function selectFields(source: string, constant: string): string[] { + const start = source.indexOf(`const ${constant} = {`); + expect(start, `${constant} not found`).toBeGreaterThan(-1); + const end = source.indexOf("\n}", start); + expect(end).toBeGreaterThan(start); + const body = source.slice(start, end); + return [...body.matchAll(/^ {2}([a-zA-Z_$][\w$]*): true,$/gm)].map( + (m) => m[1], + ); +} + +/** + * Columns either serialization arm actually reads off the mapped row. + * + * The capture is the RIGHT-hand side — the column — not the payload key, which + * matters here in a way it did not for `Measurement`: the mood payload renames + * `moodLoggedAt` to `loggedAt` on the wire, so a left-hand matcher would report + * the column as dropped while the value rides perfectly well. + * + * The two arms share one restorable wire format, so this is a union and + * therefore a weaker claim than the select comparison: it catches a column that + * is selected and then written to neither payload, not one carried by only a + * single arm. + */ +function serialisedFields(source: string, variable: string): Set { + const matches = [ + ...source.matchAll( + new RegExp(`[:(,]\\s*${variable}\\.([a-zA-Z_$][\\w$]*)`, "g"), + ), + ]; + expect( + matches.length, + `no \`${variable}.\` reads found — the mapper parameter was renamed and this matcher now proves nothing`, + ).toBeGreaterThan(0); + return new Set(matches.map((m) => m[1])); +} + +describe("mood backup completeness", () => { + const schema = readFileSync(SCHEMA, "utf8"); + const source = readFileSync(BACKUP_BUILDER, "utf8"); + const models = declaredModels(schema); + + const entryColumns = scalarFields(modelBlock(schema, "MoodEntry"), models); + const entrySelected = selectFields(source, "MOOD_ENTRY_BACKUP_SELECT"); + const linkColumns = scalarFields( + modelBlock(schema, "MoodEntryTagLink"), + models, + ); + const linkSelected = selectFields(source, "MOOD_ENTRY_TAG_LINK_SELECT"); + + it("parses both ends into a plausible shape", () => { + expect(models.size).toBeGreaterThan(50); + expect(entryColumns.length).toBeGreaterThan(10); + expect(entryColumns).toContain("noteEncrypted"); + expect(entryColumns).toContain("syncVersion"); + expect(entryColumns).not.toContain("user"); + expect(entryColumns).not.toContain("tagLinks"); + expect(entrySelected.length).toBeGreaterThan(10); + + expect(linkColumns).toEqual( + expect.arrayContaining([ + "moodEntryId", + "moodTagId", + "createdAt", + "rating", + ]), + ); + expect(linkColumns).not.toContain("moodEntry"); + expect(linkColumns).not.toContain("moodTag"); + expect(linkSelected.length).toBeGreaterThan(0); + }); + + it("the backup select covers every MoodEntry column", () => { + const missing = entryColumns.filter( + (c) => !entrySelected.includes(c) && !(c in EXCLUDED_MOOD_ENTRY_COLUMNS), + ); + + if (missing.length > 0) { + const report = missing.map((c) => ` ❌ ${c}`).join("\n"); + throw new Error( + `MOOD_ENTRY_BACKUP_SELECT omits ${missing.length} column(s) of the MoodEntry model:\n${report}\n\n` + + "A disaster-recovery restore brings these back at their column " + + "default. Add each to the select and to both serialization arms, " + + "extend `moodEntrySchema` and the restore write — or add it to " + + "EXCLUDED_MOOD_ENTRY_COLUMNS with the reason the backup is correct " + + "to drop it.", + ); + } + }); + + it("every selected MoodEntry column reaches a serialized payload", () => { + const emitted = serialisedFields(source, "moodEntry"); + const dropped = entrySelected.filter((c) => !emitted.has(c)); + + if (dropped.length > 0) { + const report = dropped.map((c) => ` ❌ ${c}`).join("\n"); + throw new Error( + `${dropped.length} selected MoodEntry 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. Emit each from the disaster-recovery arm " + + "(and, where it is readable and not a tombstone, from the portable " + + "arm too).", + ); + } + }); + + it("the tag-link select covers every MoodEntryTagLink column", () => { + const missing = linkColumns.filter( + (c) => !linkSelected.includes(c) && !(c in EXCLUDED_TAG_LINK_COLUMNS), + ); + expect( + missing, + `MOOD_ENTRY_TAG_LINK_SELECT omits MoodEntryTagLink column(s): ${missing.join(", ")}`, + ).toEqual([]); + }); + + it("every selected tag-link column reaches a serialized payload", () => { + const emitted = serialisedFields(source, "tagLink"); + const dropped = linkSelected.filter((c) => !emitted.has(c)); + expect( + dropped, + `selected but serialized by neither arm: ${dropped.join(", ")}`, + ).toEqual([]); + }); + + it("both structured-tag arms are carried, not just the rated one", () => { + // The defect this file was written for: the tag-link read filtered + // `rating: { not: null }`, and a BINARY link carries a NULL rating by + // definition. Every present/absent tag — the kind most people pick — was + // excluded from the file and could not come back. + // + // Asserted on a boolean rather than on the file contents so a failure + // reads as one line instead of printing the whole builder back. + expect( + source.includes("rating: { not: null }"), + "the tag-link read filters out NULL ratings again; BINARY links are being dropped from the backup", + ).toBe(false); + expect( + source.includes("structuredTags"), + "no `structuredTags` key in the payload — the BINARY links have no carrier", + ).toBe(true); + }); + + it("the exclusion lists carry no stale entry", () => { + for (const [column, reason] of Object.entries( + EXCLUDED_MOOD_ENTRY_COLUMNS, + )) { + expect( + entryColumns, + `${column} is excluded but is no longer a MoodEntry column`, + ).toContain(column); + expect(reason.length).toBeGreaterThan(20); + } + for (const [column, reason] of Object.entries(EXCLUDED_TAG_LINK_COLUMNS)) { + expect( + linkColumns, + `${column} is excluded but is no longer a MoodEntryTagLink column`, + ).toContain(column); + expect(reason.length).toBeGreaterThan(20); + } + }); +}); diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 4d76bc57e..3632d9042 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -409,7 +409,13 @@ describe("buildFullBackupPayload disaster-recovery mode", () => { expect(prisma.moodEntry.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { userId: "user-1" }, - include: expect.any(Object), + select: expect.objectContaining({ + id: true, + noteEncrypted: true, + tz: true, + syncVersion: true, + deletedAt: true, + }), }), ); expect(mocks.buildRecordsBackupSection).toHaveBeenCalledWith( diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 57ef86451..08298bf65 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -103,6 +103,46 @@ const MEASUREMENT_BACKUP_SELECT = { updatedAt: true, } as const satisfies Prisma.MeasurementSelect; +/** + * Every restorable `MoodEntry` scalar. `userId` is absent for the same reason + * it is absent above: the backup is user-scoped and the owner is re-derived + * from the restoring account. + * + * Named rather than inlined so `mood-backup-completeness.test.ts` can compare + * it field-by-field against the model in `prisma/schema.prisma`, exactly as the + * measurement guard does for the select above. + */ +const MOOD_ENTRY_BACKUP_SELECT = { + id: true, + date: true, + mood: true, + score: true, + tags: true, + note: true, + noteEncrypted: true, + source: true, + externalId: true, + moodLoggedAt: true, + tz: true, + syncedAt: true, + syncVersion: true, + deletedAt: true, + createdAt: true, + updatedAt: true, +} as const satisfies Prisma.MoodEntrySelect; + +/** + * A structured-tag link, as it rides the wire. The link is carried by the + * tag's KEY, not by its id: a restore resolves the key against the target + * instance's catalogue, where the source instance's row id may not exist at + * all. `kind` comes along so the mapper can split RATED factors from BINARY + * tags without a second query. + */ +const MOOD_ENTRY_TAG_LINK_SELECT = { + rating: true, + moodTag: { select: { key: true, kind: true } }, +} as const satisfies Prisma.MoodEntryTagLinkSelect; + /** * Build the canonical full-backup payload for `userId`. Portable exports omit * tombstones and document ciphertext; disaster-recovery payloads preserve @@ -207,13 +247,11 @@ export async function buildFullBackupPayload( prisma.moodEntry.findMany({ where: disasterRecovery ? { userId } : { userId, deletedAt: null }, orderBy: { moodLoggedAt: "desc" }, - include: { + select: { + ...MOOD_ENTRY_BACKUP_SELECT, tagLinks: { where: { rating: { not: null } }, - select: { - rating: true, - moodTag: { select: { key: true } }, - }, + select: MOOD_ENTRY_TAG_LINK_SELECT, }, }, }), @@ -394,19 +432,24 @@ export async function buildFullBackupPayload( skipped: e.skipped, source: e.source, })), - moodEntries: moodEntries.map((e) => ({ - id: e.id, - date: e.date, - mood: e.mood, - score: e.score, - tags: e.tags, - source: e.source, - loggedAt: e.moodLoggedAt.toISOString(), - externalId: e.externalId, - deletedAt: e.deletedAt?.toISOString() ?? null, - factors: e.tagLinks.map((link) => ({ - key: link.moodTag.key, - rating: link.rating!, + // The parameter is spelled out rather than the `e` every other mapper here + // uses, and deliberately: `mood-backup-completeness.test.ts` proves a + // selected column reaches a payload by matching `: moodEntry.`. A matcher + // on `: e.` would also match the intake-event mapper above it and could + // therefore never fail. + moodEntries: moodEntries.map((moodEntry) => ({ + id: moodEntry.id, + date: moodEntry.date, + mood: moodEntry.mood, + score: moodEntry.score, + tags: moodEntry.tags, + source: moodEntry.source, + loggedAt: moodEntry.moodLoggedAt.toISOString(), + externalId: moodEntry.externalId, + deletedAt: moodEntry.deletedAt?.toISOString() ?? null, + factors: moodEntry.tagLinks.map((tagLink) => ({ + key: tagLink.moodTag.key, + rating: tagLink.rating!, })), })), customMoodTags: customMoodTags.map((tag) => ({ From 222047e0d48b355235705c9bb29e6e0ee990ea41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 21:13:42 +0200 Subject: [PATCH 2/8] fix(backup): carry every mood column and both tag arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The disaster-recovery payload dropped the free-text note, the row's timezone and the last-writer-wins counter, and the tag-link read filtered `rating: { not: null }` — which is the definition of a BINARY link, so every present/absent tag a person picked was missing from the file too. Both arms now mirror the measurement pair: the recovery arm carries the note ciphertext verbatim plus `tz`, `syncedAt`, `syncVersion`, `createdAt` and `updatedAt`; the portable arm carries the decrypted note, `tz` and no ciphertext. BINARY keys ride in their own `structuredTags` array rather than as a nullable rating inside `factors`, because a restore resolves `factors` against RATED tags and names what it cannot resolve — a rating-less entry there would collide with both. Absent means empty, so a file written before this parses and restores exactly as it did. The two link arms partition the links exhaustively: a malformed link comes back named as unresolvable instead of falling between them. --- .../mood-backup-completeness.test.ts | 49 ++++++-- .../__tests__/full-backup-payload.test.ts | 105 +++++++++++++++++- src/lib/export/full-backup-payload.ts | 79 +++++++++---- 3 files changed, 204 insertions(+), 29 deletions(-) diff --git a/src/__tests__/mood-backup-completeness.test.ts b/src/__tests__/mood-backup-completeness.test.ts index ca7eb3b0f..ef483325d 100644 --- a/src/__tests__/mood-backup-completeness.test.ts +++ b/src/__tests__/mood-backup-completeness.test.ts @@ -110,6 +110,31 @@ function selectFields(source: string, constant: string): string[] { ); } +/** + * The builder with its comment lines removed. + * + * Every matcher below runs against this rather than against the raw file, + * because prose describing the defect reads exactly like the defect: the first + * draft of this guard went red on its own explanation of the filter it exists + * to forbid. Whole comment lines only — a trailing comment after code is left + * alone, which is safe here because the only risk it carries is a matcher + * accepting a column named in prose, and the select comparison beside it is + * literal either way. + */ +function stripCommentLines(source: string): string { + return source + .split("\n") + .filter((line) => { + const trimmed = line.trim(); + return !( + trimmed.startsWith("//") || + trimmed.startsWith("/*") || + trimmed.startsWith("*") + ); + }) + .join("\n"); +} + /** * Columns either serialization arm actually reads off the mapped row. * @@ -138,7 +163,7 @@ function serialisedFields(source: string, variable: string): Set { describe("mood backup completeness", () => { const schema = readFileSync(SCHEMA, "utf8"); - const source = readFileSync(BACKUP_BUILDER, "utf8"); + const source = stripCommentLines(readFileSync(BACKUP_BUILDER, "utf8")); const models = declaredModels(schema); const entryColumns = scalarFields(modelBlock(schema, "MoodEntry"), models); @@ -225,16 +250,22 @@ describe("mood backup completeness", () => { }); it("both structured-tag arms are carried, not just the rated one", () => { - // The defect this file was written for: the tag-link read filtered - // `rating: { not: null }`, and a BINARY link carries a NULL rating by - // definition. Every present/absent tag — the kind most people pick — was - // excluded from the file and could not come back. + // The defect this file was written for: the tag-link read carried a + // filter, and a BINARY link's rating is NULL by definition, so every + // present/absent tag — the kind most people pick — was excluded from the + // file and could not come back from it. The claim is therefore about the + // read itself: it takes the links whole, unfiltered. // - // Asserted on a boolean rather than on the file contents so a failure - // reads as one line instead of printing the whole builder back. + // Asserted on booleans rather than on the file contents so a failure reads + // as one line instead of printing the whole builder back. + const tagLinkRead = /tagLinks:\s*\{([\s\S]*?)\}/.exec(source); expect( - source.includes("rating: { not: null }"), - "the tag-link read filters out NULL ratings again; BINARY links are being dropped from the backup", + tagLinkRead, + "no `tagLinks:` read found in the backup builder — this matcher proves nothing", + ).not.toBeNull(); + expect( + /where/.test(tagLinkRead![1]), + "the tag-link read filters again; a filter here is what dropped every BINARY link from the backup", ).toBe(false); expect( source.includes("structuredTags"), diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 3632d9042..e55bf7b69 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -38,6 +38,8 @@ import { restoreProfileData } from "../profile-backup"; const deletedAt = new Date("2026-07-19T12:00:00.000Z"); const measurementNote = encryptToBytes("canonical measurement note"); const sideEffectNote = encryptToBytes("nausea after the evening dose"); +const moodNotePlaintext = "quiet evening, slept badly"; +const moodNote = encryptToBytes(moodNotePlaintext); const appSettings = { id: "singleton", @@ -193,11 +195,27 @@ function makePrisma() { mood: "OKAY", score: 3, tags: null, + note: null, + noteEncrypted: moodNote, source: "MOODLOG", externalId: "moodlog-42", moodLoggedAt: new Date("2026-07-19T08:00:00.000Z"), + tz: "America/New_York", + syncedAt: new Date("2026-07-19T08:00:01.000Z"), + syncVersion: 3, deletedAt, - tagLinks: [{ rating: 4, moodTag: { key: "sleep_quality" } }], + createdAt: new Date("2026-07-19T08:00:00.000Z"), + updatedAt: new Date("2026-07-19T09:00:00.000Z"), + tagLinks: [ + { + rating: 4, + moodTag: { key: "sleep_quality", kind: "RATED" }, + }, + { + rating: null, + moodTag: { key: "headache", kind: "BINARY" }, + }, + ], }, ]), }, @@ -491,7 +509,16 @@ describe("buildFullBackupPayload disaster-recovery mode", () => { id: "mood-tombstone", externalId: "moodlog-42", deletedAt: deletedAt.toISOString(), + tz: "America/New_York", + syncedAt: "2026-07-19T08:00:01.000Z", + syncVersion: 3, + createdAt: "2026-07-19T08:00:00.000Z", + updatedAt: "2026-07-19T09:00:00.000Z", + note: null, + noteEncrypted: Buffer.from(moodNote).toString("base64"), factors: [{ key: "sleep_quality", rating: 4 }], + // The BINARY link the old `rating: { not: null }` filter excluded. + structuredTags: ["headache"], }, ], nutrientDays: [ @@ -1154,3 +1181,79 @@ describe("buildFullBackupPayload — profile and custom metrics", () => { expect(counts.intradayProfiles).toBe(1); }); }); + +describe("buildFullBackupPayload — mood arms", () => { + it("carries the ciphertext and the whole row on the disaster-recovery arm", async () => { + installSectionMocks(); + const prisma = makePrisma(); + + const { payload } = await buildFullBackupPayload( + prisma as never, + "user-1", + { + purpose: "disaster-recovery", + exportedAt: new Date("2026-07-20T00:00:00.000Z"), + }, + ); + + const [entry] = (payload as { moodEntries: Array> }) + .moodEntries; + expect(entry).toEqual( + expect.objectContaining({ + id: "mood-tombstone", + note: null, + noteEncrypted: Buffer.from(moodNote).toString("base64"), + tz: "America/New_York", + syncedAt: "2026-07-19T08:00:01.000Z", + syncVersion: 3, + createdAt: "2026-07-19T08:00:00.000Z", + updatedAt: "2026-07-19T09:00:00.000Z", + deletedAt: deletedAt.toISOString(), + factors: [{ key: "sleep_quality", rating: 4 }], + structuredTags: ["headache"], + }), + ); + }); + + it("carries the readable note and no ciphertext on the portable arm", async () => { + installSectionMocks(); + const prisma = makePrisma(); + + const { payload } = await buildFullBackupPayload( + prisma as never, + "user-1", + { + purpose: "portable-export", + exportedAt: new Date("2026-07-20T00:00:00.000Z"), + }, + ); + + const [entry] = (payload as { moodEntries: Array> }) + .moodEntries; + // Same split the measurement arm makes: the readable artefact carries the + // decrypted text and nothing an operator could not read, and the counters + // that only mean something to this instance stay behind. + expect(entry).toEqual( + expect.objectContaining({ + note: moodNotePlaintext, + tz: "America/New_York", + structuredTags: ["headache"], + }), + ); + expect(entry).not.toHaveProperty("noteEncrypted"); + expect(entry).not.toHaveProperty("syncVersion"); + expect(entry).not.toHaveProperty("syncedAt"); + expect(JSON.stringify(entry)).not.toContain( + Buffer.from(moodNote).toString("base64"), + ); + // The tag-link read is unfiltered on both arms; a BINARY link is the one + // most people have and it was the one the file never carried. + expect(prisma.moodEntry.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + select: expect.objectContaining({ + tagLinks: { select: expect.any(Object) }, + }), + }), + ); + }); +}); diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 08298bf65..9f4506de5 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -249,10 +249,11 @@ export async function buildFullBackupPayload( orderBy: { moodLoggedAt: "desc" }, select: { ...MOOD_ENTRY_BACKUP_SELECT, - tagLinks: { - where: { rating: { not: null } }, - select: MOOD_ENTRY_TAG_LINK_SELECT, - }, + // Every link, not only the rated ones. This read used to filter + // `rating: { not: null }`, and a BINARY link carries a NULL rating by + // 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 account's OWN mood tags. The seeded catalogue is reference data every @@ -437,21 +438,61 @@ export async function buildFullBackupPayload( // selected column reaches a payload by matching `: moodEntry.`. A matcher // on `: e.` would also match the intake-event mapper above it and could // therefore never fail. - moodEntries: moodEntries.map((moodEntry) => ({ - id: moodEntry.id, - date: moodEntry.date, - mood: moodEntry.mood, - score: moodEntry.score, - tags: moodEntry.tags, - source: moodEntry.source, - loggedAt: moodEntry.moodLoggedAt.toISOString(), - externalId: moodEntry.externalId, - deletedAt: moodEntry.deletedAt?.toISOString() ?? null, - factors: moodEntry.tagLinks.map((tagLink) => ({ - key: tagLink.moodTag.key, - rating: tagLink.rating!, - })), - })), + moodEntries: moodEntries.map((moodEntry) => { + // The two link arms partition the entry's links exhaustively: a link is + // a rated factor when its tag is RATED and it actually carries a score, + // and a structured tag otherwise. Total on purpose — a malformed link + // (a RATED tag whose rating went missing) rides as a tag key and comes + // back NAMED as unresolvable on restore, rather than falling between the + // two arms and disappearing the way every BINARY link used to. + const isRatedFactor = (link: (typeof moodEntry.tagLinks)[number]) => + link.moodTag.kind === "RATED" && link.rating !== null; + + const factors = moodEntry.tagLinks + .filter(isRatedFactor) + .map((tagLink) => ({ + key: tagLink.moodTag.key, + rating: tagLink.rating as number, + })); + // BINARY link keys. Their own array rather than a nullable rating inside + // `factors`: the restore resolves `factors` against `kind: "RATED"` and + // reports what it cannot resolve, so a rating-less entry in that array + // would collide with both. A separate key leaves every backup file + // written before this parsing and restoring exactly as it did. + const structuredTags = moodEntry.tagLinks + .filter((link) => !isRatedFactor(link)) + .map((tagLink) => tagLink.moodTag.key); + + return { + ...(disasterRecovery + ? { + note: moodEntry.note, + noteEncrypted: moodEntry.noteEncrypted + ? Buffer.from(moodEntry.noteEncrypted).toString("base64") + : null, + syncedAt: moodEntry.syncedAt.toISOString(), + syncVersion: moodEntry.syncVersion, + createdAt: moodEntry.createdAt.toISOString(), + updatedAt: moodEntry.updatedAt.toISOString(), + } + : { note: readNote(moodEntry.noteEncrypted, moodEntry.note) }), + id: moodEntry.id, + date: moodEntry.date, + mood: moodEntry.mood, + score: moodEntry.score, + tags: moodEntry.tags, + source: moodEntry.source, + loggedAt: moodEntry.moodLoggedAt.toISOString(), + externalId: moodEntry.externalId, + // The zone the `date` string is anchored to. Without it a restored row + // falls back to the legacy Europe/Berlin reading and an account that + // logged elsewhere gets its day boundaries quietly moved. + tz: moodEntry.tz, + deletedAt: moodEntry.deletedAt?.toISOString() ?? null, + factors, + structuredTags, + }; + }), customMoodTags: customMoodTags.map((tag) => ({ ...(disasterRecovery ? { id: tag.id } : {}), key: tag.key, From 18edd34c33dc1aac8127b77e60ec4475a1ddae7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 21:37:06 +0200 Subject: [PATCH 3/8] fix(backup): restore the mood note, zone, counter and binary tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `moodEntrySchema` gains `note`, `noteEncrypted`, `tz`, `syncedAt`, `syncVersion`, `createdAt`, `updatedAt` and `structuredTags`; every one of them optional or defaulted, so a backup file written before this parses unchanged and restores exactly as it did. The restore writes them. The note follows the measurement pair: ciphertext verbatim when the file carries it, a portable file's plaintext re-encrypted on the way in, legacy column null either way. `tz` absent still means the legacy Europe/Berlin reading, which is what those rows meant. `syncVersion` comes back at the value the file recorded — restoring at the default handed the next round to any paired device holding a higher number, and the device would then overwrite the recovered entry with what it had. The link rebuild re-creates both arms: rated factors from `factors`, ticked tags from `structuredTags` with a NULL rating. An unresolvable tag key is reported under its own `moodTag` family, beside the rated one, because an operator needs to know which of the two an entry lost. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + .../api/admin/backups/[id]/restore/route.ts | 77 ++++++++++++++++++- src/components/admin/backups-section.tsx | 3 + src/lib/export/restore-skips.ts | 8 +- src/lib/validations/backup.ts | 18 +++++ 10 files changed, 107 insertions(+), 5 deletions(-) diff --git a/messages/de.json b/messages/de.json index 7468d4f41..455bad74f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6701,6 +6701,7 @@ "restoreSkippedCycleSymptom": "Zyklussymptom", "restoreSkippedIllnessSymptom": "Krankheitssymptom", "restoreSkippedMoodFactor": "Stimmungsfaktor", + "restoreSkippedMoodTag": "Stimmungs-Tag", "docsLink": "Dokumentation" }, "danger-zone": { diff --git a/messages/en.json b/messages/en.json index 1b9ce1268..2041766c0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6701,6 +6701,7 @@ "restoreSkippedCycleSymptom": "Cycle symptom", "restoreSkippedIllnessSymptom": "Illness symptom", "restoreSkippedMoodFactor": "Mood factor", + "restoreSkippedMoodTag": "Mood tag", "docsLink": "Documentation" }, "danger-zone": { diff --git a/messages/es.json b/messages/es.json index 35193f614..bafa72e6e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6701,6 +6701,7 @@ "restoreSkippedCycleSymptom": "Síntoma del ciclo", "restoreSkippedIllnessSymptom": "Síntoma de enfermedad", "restoreSkippedMoodFactor": "Factor de ánimo", + "restoreSkippedMoodTag": "Etiqueta de ánimo", "docsLink": "Documentación" }, "danger-zone": { diff --git a/messages/fr.json b/messages/fr.json index b3df467d1..5324f563a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6701,6 +6701,7 @@ "restoreSkippedCycleSymptom": "Symptôme de cycle", "restoreSkippedIllnessSymptom": "Symptôme de maladie", "restoreSkippedMoodFactor": "Facteur d'humeur", + "restoreSkippedMoodTag": "Étiquette d'humeur", "docsLink": "Documentation" }, "danger-zone": { diff --git a/messages/it.json b/messages/it.json index 7f96ce248..38af4fc16 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6701,6 +6701,7 @@ "restoreSkippedCycleSymptom": "Sintomo del ciclo", "restoreSkippedIllnessSymptom": "Sintomo di malattia", "restoreSkippedMoodFactor": "Fattore dell'umore", + "restoreSkippedMoodTag": "Etichetta dell'umore", "docsLink": "Documentazione" }, "danger-zone": { diff --git a/messages/pl.json b/messages/pl.json index 2da87ee8b..702ad81c5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6701,6 +6701,7 @@ "restoreSkippedCycleSymptom": "Objaw cyklu", "restoreSkippedIllnessSymptom": "Objaw choroby", "restoreSkippedMoodFactor": "Czynnik nastroju", + "restoreSkippedMoodTag": "Tag nastroju", "docsLink": "Dokumentacja" }, "danger-zone": { diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 3c22417f3..8ede74cac 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -684,6 +684,10 @@ const handler = apiHandler( entry.factors.map((factor) => factor.key), ); const factorKeys = [...new Set(referencedFactorKeys)]; + const referencedTagKeys = payload.moodEntries.flatMap( + (entry) => entry.structuredTags, + ); + const tagKeys = [...new Set(referencedTagKeys)]; // Re-create the account's own tag definitions first. The lookup // below used to ask only for the seeded catalogue (`userId: null`), // so a single custom rated tag made the whole restore throw @@ -740,6 +744,30 @@ const handler = apiHandler( referencedFactorKeys, ); + // The BINARY half of the same taxonomy, looked up the same way and + // reported the same way. It gets its own family because an + // operator reading the report needs to know whether a rated + // factor's score or a ticked tag went missing; they are different + // losses on the same entry. + const tagRows = + tagKeys.length === 0 + ? [] + : await tx.moodTag.findMany({ + where: { + key: { in: tagKeys }, + kind: "BINARY", + OR: [{ userId: null }, { userId: ownerId }], + }, + select: { id: true, key: true }, + }); + const tagByKey = new Map(tagRows.map((tag) => [tag.key, tag.id])); + recordUnknownKeys( + skips, + "moodTag", + tagKeys.filter((key) => !tagByKey.has(key)), + referencedTagKeys, + ); + for (const entry of payload.moodEntries) { const moodLoggedAt = new Date(entry.loggedAt); const restoredData = { @@ -747,10 +775,44 @@ const handler = apiHandler( mood: entry.mood, score: entry.score, tags: entry.tags ?? null, + // The note comes back the way every other dual-column note + // does: ciphertext verbatim when the file carries it, and a + // portable file's plaintext re-encrypted on the way in, with + // the legacy column left null either way. + note: null, + noteEncrypted: + entry.noteEncrypted == null + ? encryptNote(entry.note ?? null) + : decodeEncryptedBytes(entry.noteEncrypted), source: entry.source ?? "MOODLOG", externalId: entry.externalId ?? null, moodLoggedAt, + // The zone the `date` string is anchored to. A file written + // before this rode the payload carries nothing, and NULL is + // what such a row meant anyway: the legacy Europe/Berlin + // reading. So an old file keeps its old day boundaries and a + // new one keeps the zone the person actually logged in. + tz: entry.tz ?? null, + // Mood reconciles last-writer-wins by `syncVersion`. Restoring + // at the schema default would hand the next sync round to any + // paired device still holding a higher number, and the device + // would overwrite the recovered row with what it had — a + // restore that loses to the client is worse than one that + // fails. An old file carries nothing here and keeps the + // default, which is the value those rows were written with. + ...(entry.syncVersion === undefined + ? {} + : { syncVersion: entry.syncVersion }), deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null, + ...(entry.syncedAt + ? { syncedAt: new Date(entry.syncedAt) } + : {}), + ...(entry.createdAt + ? { createdAt: new Date(entry.createdAt) } + : {}), + ...(entry.updatedAt + ? { updatedAt: new Date(entry.updatedAt) } + : {}), }; const createData = { userId: ownerId, ...restoredData }; const restored = entry.id @@ -798,8 +860,19 @@ const handler = apiHandler( ] : []; }); - if (factorLinks.length > 0) { - await tx.moodEntryTagLink.createMany({ data: factorLinks }); + // The other arm. A BINARY link carries no score — the presence + // of the row is the signal — so `rating` is NULL by definition, + // which is exactly what the backup's old tag-link filter took to + // mean "nothing here". + const tagLinks = entry.structuredTags.flatMap((key) => { + const moodTagId = tagByKey.get(key); + return moodTagId + ? [{ moodEntryId: restored.id, moodTagId, rating: null }] + : []; + }); + const links = [...factorLinks, ...tagLinks]; + if (links.length > 0) { + await tx.moodEntryTagLink.createMany({ data: links }); } } } diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 2df37779f..76c3acede 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -194,6 +194,9 @@ function catalogueLabel( if (catalogue === "illnessSymptom") { return t("admin.section.backups.restoreSkippedIllnessSymptom"); } + if (catalogue === "moodTag") { + return t("admin.section.backups.restoreSkippedMoodTag"); + } return t("admin.section.backups.restoreSkippedMoodFactor"); } diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index e57756edd..326ea093b 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -1,8 +1,9 @@ /** * What a restore could not put back, and how much of it there was. * - * Three of the restore's lookups resolve a key against a SEEDED CATALOGUE — - * cycle symptoms, illness symptoms, mood factors. The catalogue is reference + * Four of the restore's lookups resolve a key against a SEEDED CATALOGUE — + * cycle symptoms, illness symptoms, rated mood factors and the mood tags a + * person ticks present or absent. The catalogue is reference * data the instance owns, not data the file carries, so it legitimately drifts * between the day a backup is written and the day it is read: a key gets * renamed, a symptom is retired, the file is a year old, the instance is three @@ -28,7 +29,8 @@ */ /** Which seeded catalogue a key failed to resolve against. */ -export type SkippedCatalogue = "cycleSymptom" | "illnessSymptom" | "moodFactor"; +export type SkippedCatalogue = + "cycleSymptom" | "illnessSymptom" | "moodFactor" | "moodTag"; /** One key this instance does not know, and the links it cost. */ export interface SkippedCatalogueKey { diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index b4afaf650..3f1a981e1 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -255,11 +255,29 @@ const moodEntrySchema = z mood: z.string().min(1), score: z.number().int().min(0).max(10), tags: moodEntryTagsSchema, + // The free text, in both storage shapes. A portable export carries the + // decrypted `note`; a disaster-recovery file carries `noteEncrypted` plus + // whatever legacy plaintext the row still holds. Same pair, and same + // handling on restore, as `Measurement.notes` / `notesEncrypted`. + note: z.string().nullable().optional(), + noteEncrypted: base64BytesSchema.nullable().optional(), source: z.string().min(1).optional(), externalId: z.string().nullable().optional(), loggedAt: isoDateTime, + // The IANA zone the `date` string is anchored to. Absent means the legacy + // Europe/Berlin reading, which is what a row written before v1.4.25 means + // by a NULL here — so an old file keeps its old meaning exactly. + tz: z.string().nullable().optional(), + syncedAt: isoDateTime.optional(), + syncVersion: z.number().int().optional(), deletedAt: isoDateTime.nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), factors: z.array(moodFactorSchema).default([]), + // BINARY structured-tag keys. Separate from `factors`, which keeps its + // 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([]), }) .passthrough(); From 9388f1d032671dc4a06adcefff87fd37536c88ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 21:44:01 +0200 Subject: [PATCH 4/8] test(backup): round-trip a mood entry through the real restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seeds an entry with a ticked tag, a rated factor, an encrypted note, a non-Berlin timezone and a sync counter above zero, exports through the real builder, restores through the real `POST /api/admin/backups/[id]/restore`, and reads the row back out of Postgres. Values, not a payload shape: this repository has already proved that asserting a `moodEntries` key stays green with the restore call excised. Two more cases beside it. A hand-written payload in the shape files were written in before the note, zone, counter and tag keys rode along — the compatibility floor, and the only way to know it holds is to send one. And a ticked tag this instance cannot resolve, which has to come back named in the skip report rather than silently absent. Seen red three times on the way in: re-adding the tag-link filter loses the ticked tags, dropping `tz` from the restore write loses the zone, dropping the note ciphertext from the payload loses the text. Each reverted and the file's hash checked back to its recorded value. --- .../mood-backup-round-trip.test.ts | 439 ++++++++++++++++++ 1 file changed, 439 insertions(+) create mode 100644 tests/integration/mood-backup-round-trip.test.ts diff --git a/tests/integration/mood-backup-round-trip.test.ts b/tests/integration/mood-backup-round-trip.test.ts new file mode 100644 index 000000000..42c43e088 --- /dev/null +++ b/tests/integration/mood-backup-round-trip.test.ts @@ -0,0 +1,439 @@ +/** + * A mood entry, out through the real backup and back in through the real + * restore, asserted on the rows. + * + * Deliberately not a classification test. Asserting that the payload has a + * `moodEntries` key proves nothing this repository has not already been burned + * by: excise the restore call entirely and a shape check still reports green. + * So this seeds an account, exports through `buildFullBackupPayload`, parses + * through `parseBackupPayload`, restores through the real + * `POST /api/admin/backups/[id]/restore`, and then reads the row back out of + * Postgres and compares values. + * + * The fixture is the entry that used to come back wrong in four ways at once: + * a ticked BINARY tag and a rated factor on the same entry, a note in the + * encrypted column, a timezone that is not the legacy Europe/Berlin default, + * and a sync counter above zero. The old payload carried none of them. + * + * The second case is the compatibility floor: a hand-written payload in the + * shape files were written in BEFORE this change, restored through the same + * route. Every field this adds is optional or defaulted precisely so that file + * keeps working, and the only way to know is to send one. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import type { PrismaClient } from "@/generated/prisma/client"; +import { encrypt } from "@/lib/crypto"; +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { encryptNote } from "@/lib/crypto/note-cipher"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { parseBackupPayload } from "@/lib/validations/backup"; +import { POST } from "@/app/api/admin/backups/[id]/restore/route"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserData: vi.fn(), +})); + +const OWNER_ID = "mood-round-trip-owner"; +const NOTE = "slept badly, long walk in the afternoon"; +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"); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +async function seedAdminSession(prisma: PrismaClient) { + const admin = await prisma.user.create({ + data: { + username: "mood-round-trip-admin", + email: "mood-round-trip-admin@example.test", + role: "ADMIN", + }, + }); + const session = await prisma.session.create({ + data: { userId: admin.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return admin; +} + +async function seedOwner(prisma: PrismaClient) { + await prisma.user.create({ + data: { + id: OWNER_ID, + username: "mood-round-trip-owner", + email: "mood-round-trip-owner@example.test", + }, + }); +} + +/** + * The taxonomy the entry links to: one seeded BINARY tag (`userId: null`, the + * reference data every instance has), one BINARY tag the account made itself, + * and one RATED factor. The account's own tag matters — it travels in the file + * as a `customMoodTags` row, and a restore that could not re-create it would + * lose the link even with everything else correct. + */ +async function seedTaxonomy(prisma: PrismaClient) { + const category = await prisma.moodTagCategory.create({ + data: { + id: "mood-rt-category", + key: "mood_round_trip", + labelKey: "mood.tagCategory.roundTrip", + }, + }); + const seededTag = await prisma.moodTag.create({ + data: { + id: "mood-rt-seeded-tag", + categoryId: category.id, + key: "rt_headache", + labelKey: "mood.tag.rtHeadache", + kind: "BINARY", + }, + }); + const ownTag = await prisma.moodTag.create({ + data: { + id: "mood-rt-own-tag", + userId: OWNER_ID, + categoryId: category.id, + key: "custom:rt_long_walk", + labelKey: "mood.tag.custom", + kind: "BINARY", + }, + }); + const factor = await prisma.moodTag.create({ + data: { + id: "mood-rt-factor", + categoryId: category.id, + key: "rt_sleep_quality", + labelKey: "mood.tag.rtSleepQuality", + kind: "RATED", + scaleMin: 1, + scaleMax: 5, + }, + }); + return { category, seededTag, ownTag, factor }; +} + +function makeRestoreRequest(id: string) { + return new Request(`http://localhost/api/admin/backups/${id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }); +} + +async function restoreFromPayload( + prisma: PrismaClient, + payload: unknown, + type: string, +) { + const backup = await prisma.dataBackup.create({ + data: { + userId: OWNER_ID, + type, + data: encrypt(JSON.stringify(payload)), + }, + }); + return POST(makeRestoreRequest(backup.id) as never, { + params: Promise.resolve({ id: backup.id }), + }); +} + +describe("mood backup round trip", () => { + it("brings the note, the timezone, the sync counter and both tag arms back", async () => { + const prisma = getPrismaClient(); + await seedAdminSession(prisma); + await seedOwner(prisma); + const { seededTag, ownTag, factor } = await seedTaxonomy(prisma); + + await prisma.moodEntry.create({ + data: { + id: "mood-rt-entry", + userId: OWNER_ID, + date: "2026-07-19", + mood: "OKAY", + score: 3, + tags: JSON.stringify(["free-text-tag"]), + note: null, + noteEncrypted: encryptNote(NOTE), + source: "MANUAL", + externalId: null, + moodLoggedAt: LOGGED_AT, + // Not Europe/Berlin: a restore that drops `tz` moves this entry's day + // boundary by six hours and nothing else in the row shows it. + tz: "America/New_York", + syncVersion: 3, + createdAt: CREATED_AT, + updatedAt: UPDATED_AT, + tagLinks: { + create: [ + { moodTagId: factor.id, rating: 4 }, + { moodTagId: seededTag.id, rating: null }, + { moodTagId: ownTag.id, rating: null }, + ], + }, + }, + }); + await prisma.moodEntry.create({ + data: { + id: "mood-rt-tombstone", + userId: OWNER_ID, + date: "2026-07-18", + mood: "SCHLECHT", + score: 2, + source: "MANUAL", + moodLoggedAt: new Date("2026-07-18T22:00:00.000Z"), + tz: "America/New_York", + syncVersion: 5, + deletedAt: DELETED_AT, + }, + }); + + const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { + purpose: "disaster-recovery", + }); + const parsed = parseBackupPayload(payload); + + // Wipe what the restore is supposed to bring back. The tag rows stay: the + // instance's catalogue is reference data, and the account's own tag rides + // in the file and is re-created by the restore either way. + await prisma.moodEntry.deleteMany({ where: { userId: OWNER_ID } }); + await prisma.moodTag.deleteMany({ where: { userId: OWNER_ID } }); + expect(await prisma.moodEntry.count({ where: { userId: OWNER_ID } })).toBe( + 0, + ); + + const response = await restoreFromPayload( + prisma, + parsed, + "MOOD_ROUND_TRIP", + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { + data: { skipped: { links: number } }; + }; + // Nothing was dropped. A restore that lost a link and still answered 200 + // is exactly what the skip report exists to make visible. + expect(body.data.skipped.links).toBe(0); + + const restored = await prisma.moodEntry.findUniqueOrThrow({ + where: { id: "mood-rt-entry" }, + }); + // Asserted as "there is a note" before it is decrypted, so a payload that + // stopped carrying the ciphertext fails by name rather than as a TypeError + // three frames inside the cipher. + expect( + restored.noteEncrypted, + "the restored entry has no note ciphertext — the backup dropped it", + ).not.toBeNull(); + expect(decryptFromBytes(restored.noteEncrypted!)).toBe(NOTE); + expect(restored.note).toBeNull(); + expect(restored.tz).toBe("America/New_York"); + expect(restored.syncVersion).toBe(3); + expect(restored.moodLoggedAt).toEqual(LOGGED_AT); + expect(restored.createdAt).toEqual(CREATED_AT); + expect(restored.updatedAt).toEqual(UPDATED_AT); + expect(restored.date).toBe("2026-07-19"); + expect(restored.score).toBe(3); + expect(restored.tags).toBe(JSON.stringify(["free-text-tag"])); + expect(restored.deletedAt).toBeNull(); + + const tombstone = await prisma.moodEntry.findUniqueOrThrow({ + where: { id: "mood-rt-tombstone" }, + }); + expect(tombstone.deletedAt).toEqual(DELETED_AT); + expect(tombstone.syncVersion).toBe(5); + + const links = await prisma.moodEntryTagLink.findMany({ + where: { moodEntryId: "mood-rt-entry" }, + select: { rating: true, moodTag: { select: { key: true, kind: true } } }, + orderBy: { moodTagId: "asc" }, + }); + const byKey = new Map(links.map((l) => [l.moodTag.key, l])); + expect([...byKey.keys()].sort()).toEqual([ + "custom:rt_long_walk", + "rt_headache", + "rt_sleep_quality", + ]); + // The rating is what tells the two arms apart in the row: a ticked tag + // carries NULL, and that NULL is what the old backup filter read as + // "nothing to carry". + 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); + }); + + it("restores a file written in the shape that predates these fields", async () => { + const prisma = getPrismaClient(); + await seedAdminSession(prisma); + await seedOwner(prisma); + const { factor } = await seedTaxonomy(prisma); + + // Verbatim the shape the builder emitted before this change: no `note`, no + // `noteEncrypted`, no `tz`, no `syncedAt`, no `syncVersion`, no + // `createdAt`/`updatedAt`, no `structuredTags`. Hand-written rather than + // generated, because a fixture built from today's builder cannot describe + // yesterday's file. + const legacyPayload = { + schemaVersion: "2", + exportedAt: "2026-07-20T00:00:00.000Z", + userId: OWNER_ID, + appSettings: null, + measurements: [], + medications: [], + intakeEvents: [], + moodEntries: [ + { + id: "mood-rt-legacy", + date: "2026-07-19", + mood: "GUT", + score: 4, + tags: null, + source: "MOODLOG", + loggedAt: LOGGED_AT.toISOString(), + externalId: "moodlog-legacy", + deletedAt: null, + factors: [{ key: factor.key, rating: 5 }], + }, + ], + customMoodTags: [], + nutrientDays: [], + }; + + const parsed = parseBackupPayload(legacyPayload); + // Defaults, not values: absence has to stay absence through the parse. + expect(parsed.moodEntries[0].structuredTags).toEqual([]); + expect(parsed.moodEntries[0].tz).toBeUndefined(); + expect(parsed.moodEntries[0].syncVersion).toBeUndefined(); + + const response = await restoreFromPayload( + prisma, + parsed, + "MOOD_LEGACY_SHAPE", + ); + expect(response.status).toBe(200); + + const restored = await prisma.moodEntry.findUniqueOrThrow({ + where: { id: "mood-rt-legacy" }, + }); + // Identical to what the old restore wrote for the same file: the note + // columns empty, `tz` NULL — which is what such a row meant, the legacy + // Europe/Berlin reading — and the counter at the schema default. + expect(restored.note).toBeNull(); + expect(restored.noteEncrypted).toBeNull(); + expect(restored.tz).toBeNull(); + expect(restored.syncVersion).toBe(0); + expect(restored.mood).toBe("GUT"); + expect(restored.score).toBe(4); + expect(restored.externalId).toBe("moodlog-legacy"); + expect(restored.deletedAt).toBeNull(); + + const links = await prisma.moodEntryTagLink.findMany({ + where: { moodEntryId: "mood-rt-legacy" }, + select: { rating: true, moodTagId: true }, + }); + expect(links).toEqual([{ moodTagId: factor.id, rating: 5 }]); + }); + + it("names a ticked tag this instance cannot resolve instead of losing it quietly", async () => { + const prisma = getPrismaClient(); + await seedAdminSession(prisma); + await seedOwner(prisma); + await seedTaxonomy(prisma); + + const payload = { + schemaVersion: "2", + exportedAt: "2026-07-20T00:00:00.000Z", + userId: OWNER_ID, + appSettings: null, + measurements: [], + medications: [], + intakeEvents: [], + moodEntries: [ + { + id: "mood-rt-unknown-tag", + date: "2026-07-19", + mood: "OKAY", + score: 3, + tags: null, + source: "MANUAL", + loggedAt: LOGGED_AT.toISOString(), + externalId: null, + deletedAt: null, + factors: [], + structuredTags: ["rt_headache", "rt_retired_tag"], + }, + ], + customMoodTags: [], + nutrientDays: [], + }; + + const response = await restoreFromPayload( + prisma, + parseBackupPayload(payload), + "MOOD_UNKNOWN_TAG", + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { + data: { + skipped: { + links: number; + catalogueKeys: Array<{ + catalogue: string; + key: string; + links: number; + }>; + }; + }; + }; + expect(body.data.skipped.catalogueKeys).toEqual([ + { catalogue: "moodTag", key: "rt_retired_tag", links: 1 }, + ]); + expect(body.data.skipped.links).toBe(1); + + // The entry itself came back whole. One key that has nowhere to attach + // costs the link, never the record. + const restored = await prisma.moodEntry.findUniqueOrThrow({ + where: { id: "mood-rt-unknown-tag" }, + }); + expect(restored.score).toBe(3); + const links = await prisma.moodEntryTagLink.findMany({ + where: { moodEntryId: restored.id }, + select: { moodTag: { select: { key: true } } }, + }); + expect(links).toEqual([{ moodTag: { key: "rt_headache" } }]); + }); +}); From 8c5ed74bc0ebdf927bedebeac4968370af4a6369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 21:51:30 +0200 Subject: [PATCH 5/8] docs(changelog): note the mood restore fix --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f94ce3040..540c25071 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,19 @@ reliably the one that ran. It is selected by name now, and a copy that never arrived fails the build instead of quietly leaving another one in its place. +### Fixed + +- Mood entries now survive a restore whole. The disaster-recovery backup was + carrying only the rated factors of an entry, so every tag ticked present or + absent was missing from the file, and it was dropping the note, the entry's + own timezone and the sync counter as well. A restored entry came back without + its text, read its day boundaries under the old Berlin assumption whatever + zone it had been logged in, and could lose the next sync round to a paired + phone still holding a higher counter. The backup carries every column now, + both halves of the tag taxonomy come back, and a structural check fails the + build if a future column goes missing the same way. Backup files written + before this restore exactly as they did. + ## [1.37.0] — 2026-08-07 ### Added From c2f67c96d4b48e6a241534cebc3fb2ca1a8f3525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 23:58:34 +0200 Subject: [PATCH 6/8] fix(deps): float nanoid past an infinite-loop flaw A fresh advisory names nanoid below 3.3.17: a custom generator can loop forever when asked for a zero-size id. It reaches the tree only through next and postcss, but the audit gate rightly blocks on a known HIGH with a fix available. Bounded override in the established pattern; the audit drops from one high to none. --- pnpm-lock.yaml | 20 +++++++------------- pnpm-workspace.yaml | 1 + 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92dc14bec..4f5efaea5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,7 @@ settings: overrides: dompurify@<=3.4.10: ^3.4.11 postcss@<8.5.10: ^8.5.10 + nanoid@<3.3.17: ^3.3.17 '@hono/node-server@<1.19.13': ^1.19.13 hono@<4.12.25: ^4.12.25 picomatch@<4.0.4: ^4.0.4 @@ -5556,13 +5557,8 @@ packages: nan@2.27.0: resolution: {integrity: sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==} - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + nanoid@3.3.17: + resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -12636,9 +12632,7 @@ snapshots: nan@2.27.0: optional: true - nanoid@3.3.12: {} - - nanoid@3.3.16: {} + nanoid@3.3.17: {} napi-postinstall@0.3.4: {} @@ -13045,19 +13039,19 @@ snapshots: postcss@8.5.14: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.17 picocolors: 1.1.1 source-map-js: 1.2.1 postcss@8.5.20: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.17 picocolors: 1.1.1 source-map-js: 1.2.1 postcss@8.5.25: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.17 picocolors: 1.1.1 source-map-js: 1.2.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c7fabca7c..3fa8ec2d3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ overrides: "dompurify@<=3.4.10": "^3.4.11" "postcss@<8.5.10": "^8.5.10" + "nanoid@<3.3.17": "^3.3.17" "@hono/node-server@<1.19.13": "^1.19.13" "hono@<4.12.25": "^4.12.25" "picomatch@<4.0.4": "^4.0.4" From 4bbf4c6d1050a8bb0d9c752b2b661735fecd0ab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 00:38:51 +0200 Subject: [PATCH 7/8] ci: three integration shards, not two Two shards cleared in about fourteen minutes on a quiet day, but with six pull requests running side by side the larger shard hit the same 25-minute guard the split was meant to escape. Three shards keep the biggest slice around sixty files, which clears the guard with real headroom on the slowest runner observed. The gate job is unchanged. --- .github/workflows/integration.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index ceb9557f5..a4add73d3 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -10,21 +10,21 @@ permissions: contents: read jobs: - # The suite outgrew a single runner: 128 files took ~13 minutes when the - # 25-minute guard was written, and the v1.37 sharing work took it to 173 - # files and past 25 minutes of wall clock, so every run died at the timeout - # with every test to that point green. The guard's own rule applies — split, - # don't raise — so the files run as two vitest shards. Each shard boots its - # own Postgres testcontainer; the timeout stays a hang detector, sized so a - # healthy half-suite clears it on a slow runner. + # The suite outgrew a single runner, and then a half-suite outgrew a slow + # one: two shards cleared in ~14 minutes on a quiet day, but with six pull + # requests running side by side the larger shard hit the same 25-minute + # guard the split was meant to escape. Three shards keep the biggest slice + # around 60 files, which clears the guard with real headroom even on the + # slowest runner observed. Each shard boots its own Postgres testcontainer; + # the timeout stays a hang detector, not a performance budget. shard: runs-on: ubuntu-latest timeout-minutes: 25 strategy: fail-fast: false matrix: - shard: [1, 2] - name: integration shard ${{ matrix.shard }}/2 + shard: [1, 2, 3] + name: integration shard ${{ matrix.shard }}/3 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -44,7 +44,7 @@ jobs: # vitest a stray positional argument and the shard flag never applies — # both "shards" then run the full suite. Proven by a paired local run # before this workflow shipped. - - run: pnpm exec vitest run --config vitest.integration.config.mts --shard=${{ matrix.shard }}/2 + - run: pnpm exec vitest run --config vitest.integration.config.mts --shard=${{ matrix.shard }}/3 env: # The integration suite boots a Postgres testcontainer and points # Prisma at it via DATABASE_URL set inside `tests/integration/setup.ts`. From 0849d59ecf445b4b1fed4d6e1b747919cdf601f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 01:02:08 +0200 Subject: [PATCH 8/8] test(cycle): pin the fixture clock to the suite's clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cycle suites compute their relative day keys with local Date arithmetic under TZ=UTC while the routes resolve today in the user's zone, falling back to Europe/Berlin when the fixture sets none. For two hours every evening the two clocks disagree about the date and every day-of-cycle assertion is off by one — the suite was red from 22:00 UTC to midnight and green the rest of the day. The fixtures now carry an explicit UTC timezone so the test's clock and the route's clock are the same clock at any hour. Reproduced red at 23:07 UTC, green with the pin at 23:19 UTC. --- tests/integration/cycle-flow-opens-cycle.test.ts | 8 ++++++++ tests/integration/cycle-routes.test.ts | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/tests/integration/cycle-flow-opens-cycle.test.ts b/tests/integration/cycle-flow-opens-cycle.test.ts index 5e81fda56..7a9fe7d84 100644 --- a/tests/integration/cycle-flow-opens-cycle.test.ts +++ b/tests/integration/cycle-flow-opens-cycle.test.ts @@ -70,6 +70,14 @@ beforeEach(async () => { username: "cycle-flow", email: "cycle-flow@example.test", gender: "FEMALE", + // The suite runs under TZ=UTC and computes its relative day keys with + // local Date arithmetic, i.e. in UTC. The route resolves "today" in the + // USER's zone and falls back to Europe/Berlin when none is set - one + // hour past 22:00 UTC the two clocks disagree about the date and every + // day-of-cycle assertion is off by one. Pinning the fixture zone to UTC + // makes the test's clock and the route's clock the same clock at any + // hour of the day. + timezone: "UTC", }, }); await loginAs(USER_ID); diff --git a/tests/integration/cycle-routes.test.ts b/tests/integration/cycle-routes.test.ts index 8dc05e92b..d75c8822f 100644 --- a/tests/integration/cycle-routes.test.ts +++ b/tests/integration/cycle-routes.test.ts @@ -69,6 +69,14 @@ beforeEach(async () => { username: "cycle-female", email: "cycle-female@example.test", gender: "FEMALE", + // The suite runs under TZ=UTC and computes its relative day keys with + // local Date arithmetic, i.e. in UTC. The route resolves "today" in the + // USER's zone and falls back to Europe/Berlin when none is set - one + // hour past 22:00 UTC the two clocks disagree about the date and every + // day-of-cycle assertion is off by one. Pinning the fixture zone to UTC + // makes the test's clock and the route's clock the same clock at any + // hour of the day. + timezone: "UTC", }, }); await prisma.user.create({ @@ -77,6 +85,7 @@ beforeEach(async () => { username: "cycle-male", email: "cycle-male@example.test", gender: "MALE", + timezone: "UTC", }, }); });