merge: s5-export into release/v1.34.0

This commit is contained in:
Marc-André Bombeck committed 2026-07-27 23:41:33 +02:00
commit fbdadae0ce
20 files changed
+1573 -38

No files matched your search

@@ -129,3 +129,169 @@ describe("every schema model has a backup verdict", () => {
).toEqual([]);
});
});
/**
* The second half of the guard: a `BACKED_UP` model either travels BOTH ways or
* is named as debt.
*
* The first half above proves every model has an opinion. It would pass
* unchanged while a model classified as carried was read into the payload and
* never written back — which is not a hypothetical, it is what happened to the
* nutrient day totals, to the custom mood tags, and to the custom cycle
* symptoms, three releases running.
*
* The check reads the declared writer and restore files rather than grepping
* for a route, BECAUSE the route delegates. A review that grepped only
* `restore/route.ts` concluded the cycle data was never restored and had to be
* retracted; `restoreCycleData` and `restoreProfileData` both live elsewhere.
* Following the helpers is the entire difficulty of this question, so the file
* list is declared next to the verdicts and checked to exist.
*/
import {
BACKUP_RESTORE_FILES,
BACKUP_WRITER_FILES,
COVERAGE_PENDING,
TWO_ENDED_MODELS,
} from "@/lib/export/backup-plan";
const REPO_ROOT = resolve(__dirname, "../..");
function read(relativePath: string): string {
return readFileSync(resolve(REPO_ROOT, relativePath), "utf8");
}
/** `Measurement` → `measurement`, the Prisma delegate name. */
function delegateName(model: string): string {
return model[0].toLowerCase() + model.slice(1);
}
/**
* Relation fields on OTHER models whose type is `model`.
*
* A child rides its parent — `include: { schedules: true }` on the way out,
* `schedules: { create: [...] }` on the way back — so a delegate-only search
* would report every nested child as uncarried and the guard would be noise.
*/
function relationFieldNames(model: string): string[] {
const source = readFileSync(SCHEMA_PATH, "utf8");
const names = new Set<string>();
for (const [, body] of source.matchAll(/^model\s+\w+\s*\{([\s\S]*?)^\}/gm)) {
for (const line of body.split("\n")) {
const match = /^\s*(\w+)\s+(\w+)(\[\])?\??\s*(@|$)/.exec(line);
if (match && match[2] === model) names.add(match[1]);
}
}
return [...names];
}
const READ_OPS = /(findMany|findUnique|findFirst|findUniqueOrThrow|groupBy)/;
const WRITE_OPS = /(create|createMany|upsert|update|updateMany)/;
/** Does any of `files` touch `model` with an operation matching `ops`? */
function touches(
files: readonly string[],
model: string,
ops: RegExp,
): boolean {
const delegate = delegateName(model);
const relations = relationFieldNames(model);
return files.some((file) => {
// Whitespace-flattened so a call broken across lines still matches.
const source = read(file).replace(/\s+/g, " ");
for (const match of source.matchAll(
new RegExp(`\\.${delegate}\\s*\\.\\s*(\\w+)`, "g"),
)) {
if (ops.test(match[1])) return true;
}
// A nested relation key: `schedules: {`, `dayLogs: [`, `entries: true`.
return relations.some((relation) =>
new RegExp(`\\b${relation}\\s*:\\s*[{[t]`).test(source),
);
});
}
describe("every backed-up model travels both ways, or is named as debt", () => {
it("declares writer and restore files that exist", () => {
for (const file of [...BACKUP_WRITER_FILES, ...BACKUP_RESTORE_FILES]) {
expect(() => read(file), `${file} is declared but missing`).not.toThrow();
}
// A grep of the restore ROUTE alone is the mistake this list prevents, so
// the list has to name more than the route.
expect(BACKUP_RESTORE_FILES.length).toBeGreaterThan(1);
});
it("splits BACKED_UP into exactly two-ended and pending, with no overlap", () => {
const backedUp = new Set<string>(BACKED_UP_MODELS);
const twoEnded = new Set(TWO_ENDED_MODELS);
const pending = new Set(Object.keys(COVERAGE_PENDING));
const both = [...twoEnded].filter((m) => pending.has(m));
expect(
both,
"a model cannot be both proven and pending — that is how a gap hides",
).toEqual([]);
const unaccounted = [...backedUp].filter(
(m) => !twoEnded.has(m) && !pending.has(m),
);
expect(
unaccounted,
"add each to TWO_ENDED_MODELS once its reader AND restore branch land, " +
"or to COVERAGE_PENDING with what an account loses meanwhile",
).toEqual([]);
const foreign = [...twoEnded, ...pending].filter((m) => !backedUp.has(m));
expect(
foreign,
"only a BACKED_UP model has two ends to have; these are not on that list",
).toEqual([]);
});
it("finds a payload reader for every model claimed two-ended", () => {
const missing = TWO_ENDED_MODELS.filter(
(model) => !touches(BACKUP_WRITER_FILES, model, READ_OPS),
);
expect(
missing,
"claimed carried, but no backup writer reads it — the restore branch has " +
"nothing feeding it and a restore silently rebuilds the account without it",
).toEqual([]);
});
it("finds a restore branch for every model claimed two-ended", () => {
const missing = TWO_ENDED_MODELS.filter(
(model) => !touches(BACKUP_RESTORE_FILES, model, WRITE_OPS),
);
expect(
missing,
"claimed carried, but nothing writes it back — the export is a file the " +
"restore reads past, which is the worst of both: the data is in the " +
"backup and not in the account",
).toEqual([]);
});
it("gives every pending model a reason that says what is lost", () => {
const thin = Object.entries(COVERAGE_PENDING)
.filter(([, reason]) => reason.trim().length < 60)
.map(([model]) => model);
expect(
thin,
"'not yet' is not a reason — say what an account loses, so the debt can " +
"be ordered by cost to a person rather than by ease of writing",
).toEqual([]);
});
it("proves the two named this release are on the covered side", () => {
// Prerequisites for the structured-profile work: both were classified
// BACKED_UP and carried by nothing, and the custom metric was live loss.
for (const model of [
"UserHealthProfile",
"CustomMetric",
"CustomMetricEntry",
]) {
expect(TWO_ENDED_MODELS).toContain(model);
expect(COVERAGE_PENDING).not.toHaveProperty(model);
}
});
});