mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
1bdc5ea4ee
commit
222047e0d4
3 files changed
+204
-29
No files matched your search
@@ -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) },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user