fix(backup): restore the mood note, zone, counter and binary tags

`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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 21:37:06 +02:00
1 parent 222047e0d4
commit 18edd34c33
10 files changed
+107 -5

No files matched your search

+1
View File
@@ -6701,6 +6701,7 @@
"restoreSkippedCycleSymptom": "Zyklussymptom",
"restoreSkippedIllnessSymptom": "Krankheitssymptom",
"restoreSkippedMoodFactor": "Stimmungsfaktor",
"restoreSkippedMoodTag": "Stimmungs-Tag",
"docsLink": "Dokumentation"
},
"danger-zone": {
+1
View File
@@ -6701,6 +6701,7 @@
"restoreSkippedCycleSymptom": "Cycle symptom",
"restoreSkippedIllnessSymptom": "Illness symptom",
"restoreSkippedMoodFactor": "Mood factor",
"restoreSkippedMoodTag": "Mood tag",
"docsLink": "Documentation"
},
"danger-zone": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -6701,6 +6701,7 @@
"restoreSkippedCycleSymptom": "Sintomo del ciclo",
"restoreSkippedIllnessSymptom": "Sintomo di malattia",
"restoreSkippedMoodFactor": "Fattore dell'umore",
"restoreSkippedMoodTag": "Etichetta dell'umore",
"docsLink": "Documentazione"
},
"danger-zone": {
+1
View File
@@ -6701,6 +6701,7 @@
"restoreSkippedCycleSymptom": "Objaw cyklu",
"restoreSkippedIllnessSymptom": "Objaw choroby",
"restoreSkippedMoodFactor": "Czynnik nastroju",
"restoreSkippedMoodTag": "Tag nastroju",
"docsLink": "Dokumentacja"
},
"danger-zone": {
@@ -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 });
}
}
}
+3
View File
@@ -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");
}
+5 -3
View File
@@ -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 {
+18
View File
@@ -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();