mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
Merge branch 'main' into fix/sync-range-validation
This commit is contained in:
20 files changed
+1106
-53
No files matched your search
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* 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<string, string> = {
|
||||
userId: "user-scoped backup; owner is re-derived from the restoring account",
|
||||
};
|
||||
|
||||
const EXCLUDED_TAG_LINK_COLUMNS: Record<string, string> = {
|
||||
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<string> {
|
||||
const out = new Set<string>();
|
||||
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>): 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 <NAME> = {` 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],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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<string> {
|
||||
const matches = [
|
||||
...source.matchAll(
|
||||
new RegExp(`[:(,]\\s*${variable}\\.([a-zA-Z_$][\\w$]*)`, "g"),
|
||||
),
|
||||
];
|
||||
expect(
|
||||
matches.length,
|
||||
`no \`${variable}.<column>\` 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 = stripCommentLines(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 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 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(
|
||||
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"),
|
||||
"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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
@@ -409,7 +427,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(
|
||||
@@ -485,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: [
|
||||
@@ -1148,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) },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,14 +247,13 @@ export async function buildFullBackupPayload(
|
||||
prisma.moodEntry.findMany({
|
||||
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
include: {
|
||||
tagLinks: {
|
||||
where: { rating: { not: null } },
|
||||
select: {
|
||||
rating: true,
|
||||
moodTag: { select: { key: true } },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
...MOOD_ENTRY_BACKUP_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
|
||||
@@ -394,21 +433,66 @@ 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) => {
|
||||
// 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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user