mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
merge: s5-export into release/v1.34.0
This commit is contained in:
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user