fix(backup): carry every mood column and both tag arms

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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 21:13:42 +02:00
1 parent 1bdc5ea4ee
commit 222047e0d4
3 files changed
+204 -29

No files matched your search

+40 -9
View File
@@ -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<string> {
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"),
@@ -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<Record<string, unknown>> })
.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<Record<string, unknown>> })
.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) },
}),
}),
);
});
});
+60 -19
View File
@@ -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,