mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(mood): add the prediction table, with one writer and no writer yet
`MoodPrediction` holds the expected value of a day beside the day's own rating: one row per account per local day, with a band and the number of days the fit used. Two tables rather than four columns on the entry, because the point of the second value is that the person cannot correct it into agreement with the first — the distance between them is the finding, and a value the edit form can write is not a second reading. The table stays out of the disaster-recovery backup and the reason is written where the completeness guard reads it: every row is recomputable from the entries, the contexts and the linked modules, and carrying one would restore a forecast made by a model version that may no longer exist against data the restore has just moved. It is in the wipe plan, because a derived row is still a day-by-day statement about somebody. The write-surface guard lands red on purpose and names what is missing: `lib/jobs/mood-prognosis-refresh.ts` does not exist yet, so the writer set is empty where it should hold exactly that one file. The job closes it. The rest of the guard is green now — no route, page or component writes the table, and the matcher proves itself against a call broken across lines, an aliased client, a read and a different model.
This commit is contained in:
1 parent
779b7bcaf1
commit
a118d4810d
5 files changed
+274
No files matched your search
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* `MoodPrediction` has exactly one writer, and it is not a request.
|
||||
*
|
||||
* The product rule underneath this guard: a day carries two readings — the
|
||||
* one the person gave it and the one their own past days imply — and they are
|
||||
* never merged. The self-assessment leads and is stored exactly as it was
|
||||
* given; the prognosis is computed, carries a band and a case count, and the
|
||||
* user cannot overwrite it. A route that wrote `moodPrediction` would make the
|
||||
* second value editable, and an editable prognosis is not a prognosis: the
|
||||
* deviation between the two, which is the only thing either number is for,
|
||||
* would quietly become a number somebody could tune to zero.
|
||||
*
|
||||
* The second failure the single writer prevents is the one the cycle module
|
||||
* already paid for: a forecast written as a side effect of a page view makes
|
||||
* the output a function of browsing rather than of data
|
||||
* (`src/lib/cycle/prediction-cache.ts`, `src/lib/jobs/cycle-prediction-refresh.ts`).
|
||||
*
|
||||
* **Every matcher here asserts a non-zero match count.** Legs (a) and (b)
|
||||
* expect an EMPTY offender set, so "found nothing" and "nothing is wrong" look
|
||||
* identical from the outside — which is precisely how the bearer-scope guard
|
||||
* stayed green for months while matching nothing at all. Each leg therefore
|
||||
* pins a separate positive: the number of files SCANNED, and the write calls
|
||||
* the legitimate writer really does make. `describe("the matcher sees what it
|
||||
* looks for")` proves the regex against synthetic sources, including the
|
||||
* broken-across-lines form that defeated an earlier guard in this repository.
|
||||
*/
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { join, sep } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
/**
|
||||
* A write against the `moodPrediction` delegate, on any receiver.
|
||||
*
|
||||
* Whitespace-tolerant in every joint: Prettier breaks a long call across
|
||||
* lines, and a matcher demanding `prisma.moodPrediction.upsert(` on one line
|
||||
* is a matcher that stops matching the day the line gets long enough. The
|
||||
* receiver is left open (`prisma`, `tx`, `db`, the worker client) because
|
||||
* pinning one name would let a rename walk straight past.
|
||||
*/
|
||||
const WRITE_CALL =
|
||||
/\bmoodPrediction\s*\.\s*(createMany|createManyAndReturn|create|upsert|updateMany|update|deleteMany|delete)\s*\(/g;
|
||||
|
||||
/** The one file allowed to write, relative to `src/`. */
|
||||
const SOLE_WRITER = join("lib", "jobs", "mood-prognosis-refresh.ts");
|
||||
|
||||
function sourceFiles(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "generated" || entry.name === "node_modules") continue;
|
||||
out.push(...sourceFiles(full));
|
||||
continue;
|
||||
}
|
||||
if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function writeCallsIn(source: string): string[] {
|
||||
return [...source.matchAll(WRITE_CALL)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
const ALL_FILES = sourceFiles(SRC);
|
||||
const PRODUCTION_FILES = ALL_FILES.filter(
|
||||
(f) => !f.includes(`${sep}__tests__${sep}`) && !f.endsWith(".test.ts"),
|
||||
);
|
||||
|
||||
describe("MoodPrediction is written by one file", () => {
|
||||
it("scans a plausible number of source files", () => {
|
||||
// The positive control for legs (a) and (b) below, both of which pass on
|
||||
// an empty result. If this collapses, they are asserting nothing.
|
||||
expect(ALL_FILES.length).toBeGreaterThan(1000);
|
||||
expect(PRODUCTION_FILES.length).toBeGreaterThan(800);
|
||||
});
|
||||
|
||||
it("(a) no route, page or component writes it", () => {
|
||||
const offenders = PRODUCTION_FILES.filter(
|
||||
(f) =>
|
||||
f.startsWith(join(SRC, "app")) || f.startsWith(join(SRC, "components")),
|
||||
).filter((f) => writeCallsIn(readFileSync(f, "utf8")).length > 0);
|
||||
|
||||
expect(
|
||||
offenders.map((f) => f.slice(SRC.length + 1)),
|
||||
"a request-time surface writes the prognosis — it is computed by the nightly job and the user cannot overwrite it",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("(b) the writer set is exactly the refresh job", () => {
|
||||
const writers = PRODUCTION_FILES.filter(
|
||||
(f) => writeCallsIn(readFileSync(f, "utf8")).length > 0,
|
||||
).map((f) => f.slice(SRC.length + 1));
|
||||
|
||||
expect(writers.sort()).toEqual([SOLE_WRITER]);
|
||||
});
|
||||
|
||||
it("(c) that writer really does write — the matcher is not matching nothing", () => {
|
||||
expect(
|
||||
existsSync(join(SRC, SOLE_WRITER)),
|
||||
`${SOLE_WRITER} does not exist — the table has a guard and no writer`,
|
||||
).toBe(true);
|
||||
const source = readFileSync(join(SRC, SOLE_WRITER), "utf8");
|
||||
const calls = writeCallsIn(source);
|
||||
expect(
|
||||
calls.length,
|
||||
"the sole writer makes no write call the matcher can see; leg (b) is then satisfied by an empty set rather than by the truth",
|
||||
).toBeGreaterThan(0);
|
||||
// It replaces rather than accumulates: a pass writes the current window
|
||||
// and clears whatever fell out of it.
|
||||
expect(calls).toContain("deleteMany");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the matcher sees what it looks for", () => {
|
||||
it("matches a call Prettier broke across lines", () => {
|
||||
const wrapped = [
|
||||
"await prisma.moodPrediction",
|
||||
" .upsert(",
|
||||
" { where: { userId_date: { userId, date } } },",
|
||||
" );",
|
||||
].join("\n");
|
||||
expect(writeCallsIn(wrapped)).toEqual(["upsert"]);
|
||||
});
|
||||
|
||||
it("matches through an aliased client", () => {
|
||||
expect(writeCallsIn("tx.moodPrediction.deleteMany({ where })")).toEqual([
|
||||
"deleteMany",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not match a read", () => {
|
||||
expect(
|
||||
writeCallsIn("const rows = await prisma.moodPrediction.findMany({})"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not match a different model", () => {
|
||||
expect(writeCallsIn("prisma.moodEntry.create({ data })")).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user