mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Ten client mutation sites forgot a consumer: a coach-accepted checkup stayed invisible for up to five minutes, a threshold or target edit left the dashboard bands stale, a score-config save marked the daily reads stale but never refetched them, a booked visit missed the Today rail, a source-priority reorder kept every chart on the old canonical picks, an adopted self-context note never reached the Settings read. Each site now invalidates what its write changed and forces the unmounted snapshot + digest reads to refetch through refetchInactiveDailyReads. New illnessDependentKeys bundle carries the daily reads (Rest Mode frames both), dailyDigest joins encounterDependentKeys, and the chart-data prefix gains a factory accessor for whole-cache flushes. The daily-reads guard learns the cycle / encounter / vaccination / illness bundle families and a second matcher for direct snapshot/digest invalidateQueries without a forced refetch, each with a frozen reasoned allowlist. Both matchers were proven live by reverting a fix and watching the guard go red (see the test doc comment).
256 lines
13 KiB
TypeScript
256 lines
13 KiB
TypeScript
/**
|
|
* Structural guard on the "stale Today hero / dashboard after a write" class.
|
|
*
|
|
* The daily reads — `dailyDigest()` (Today hero) and `dashboardSnapshot()`
|
|
* (dashboard) — run with `refetchOnMount: false` and a 120 s poll. A mutation
|
|
* that invalidates one of the daily-reads-bearing bundles
|
|
* (`medicationDependentKeys` / `measurementDependentKeys` / `moodDependentKeys`)
|
|
* with the DEFAULT `refetchType: "active"` only refetches MOUNTED queries — so a
|
|
* write made from a surface where the daily reads are UNMOUNTED (the medications
|
|
* page, a detail page, the mood/measurement forms) marks them stale but never
|
|
* refetches them. Returning to `/` then serves the pre-write cache until the
|
|
* poll ticks or the tab is hard-reloaded.
|
|
*
|
|
* The blessed contract is `refetchInactiveDailyReads(queryClient)` (or its
|
|
* wrapper `invalidateMedicationReads`), which forces the two inactive reads to
|
|
* refetch. This class has recurred THREE times (v1.16.11 snapshot, v1.29.1
|
|
* digest, v1.32.19 "Take all due") because the bundle cannot express
|
|
* `refetchType`, so every new call site re-decides it by hand. This guard makes
|
|
* a fourth recurrence fail CI.
|
|
*
|
|
* Like the Bearer-scope guard, this is a tripwire, not a proof. It cannot show
|
|
* an allowlisted exception is correct — only that no NEW site invalidates a
|
|
* daily bundle without the paired refetch, and no PAIRED site loses its refetch,
|
|
* without someone editing this file.
|
|
*
|
|
* Both matcher families were proven live by breaking a real fix and watching
|
|
* the guard go red: removing the `refetchInactiveDailyReads` pairing from the
|
|
* illness invalidator (`components/illness/use-illness.ts`) failed the bundle
|
|
* assertion, and reverting the score-config save
|
|
* (`components/settings/score-section.tsx`) to its former bare
|
|
* snapshot/digest `invalidateQueries` pair failed the direct-invalidation
|
|
* assertion. Both fixes were then restored and the guard re-ran green.
|
|
*/
|
|
import { describe, it, expect } from "vitest";
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
import { walkSourceFiles } from "./helpers/source-files";
|
|
|
|
const SRC = join(process.cwd(), "src");
|
|
|
|
/** The factory itself DEFINES the bundles + helpers; it is never a call site. */
|
|
const FACTORY = "lib/query-keys/index.ts";
|
|
|
|
/**
|
|
* A direct-bundle invalidation call site: `invalidateKeys(<qc>, <bundle>)` or
|
|
* `invalidateKeys(<qc>, [...<bundle>, <localKey>])`. Captures every form the
|
|
* codebase actually uses (bare bundle, or spread into an array with an extra
|
|
* per-surface key). Sites routed through `invalidateMedicationReads` do NOT
|
|
* match — the wrapper carries the refetch internally, so they are compliant by
|
|
* construction and correctly invisible here.
|
|
*
|
|
* The cycle / encounter / vaccination / illness families joined the matcher
|
|
* alongside the original three: the encounter and illness bundles now carry a
|
|
* daily-read key directly, the vaccination bundle's reminder root feeds the
|
|
* Today rail, and cycle rides along so a future daily-read key added to its
|
|
* bundle cannot ship an unpaired site silently (its current sites are
|
|
* allowlisted below with the reason).
|
|
*/
|
|
const INVALIDATION =
|
|
/invalidateKeys\(\s*[^,]+,\s*\[?\s*(?:\.\.\.)?\s*(?:medicationDependentKeys|measurementDependentKeys|moodDependentKeys|cycleDependentKeys|encounterDependentKeys|vaccinationDependentKeys|illnessDependentKeys)\b/g;
|
|
// Match the CALL form only (open paren), never the bare import specifier —
|
|
// otherwise the `import { refetchInactiveDailyReads }` line would inflate the
|
|
// count and let a file drop one paired call while still "passing".
|
|
// `invalidateReminderReads` counts as a paired refetch: it carries
|
|
// `refetchInactiveDailyReads` internally (see `hooks/use-measurement-reminders`),
|
|
// and the encounter / vaccination mutation sites pair through it.
|
|
const REFETCH = /(?:refetchInactiveDailyReads|invalidateReminderReads)\(/g;
|
|
|
|
/**
|
|
* Second matcher family — a DIRECT `invalidateQueries` on a daily-read key.
|
|
* Captures `<qc>.invalidateQueries({ queryKey: queryKeys.dashboardSnapshot() })`
|
|
* / `dailyDigest()` in every observed shape (single line, prettier-broken,
|
|
* with or without a `refetchType`). Group 1 carries the `refetchType` value
|
|
* when present: `"inactive"` and `"all"` both force the unmounted daily reads
|
|
* to refetch and are compliant; a bare call (or `"active"`) marks the
|
|
* typically-unmounted daily reads stale without refetching them — exactly the
|
|
* v1.16.11/v1.29.1/v1.32.19 class, one call site at a time instead of via a
|
|
* bundle. Known limit (mirrors the bundle matcher): a call spelling the key
|
|
* through a local alias rather than `queryKeys.<key>()` would slip past.
|
|
*/
|
|
const DIRECT_DAILY_INVALIDATION =
|
|
/\.invalidateQueries\(\s*\{\s*queryKey:\s*queryKeys\.(?:dashboardSnapshot|dailyDigest)\(\)\s*,?\s*(?:refetchType:\s*"(\w+)"\s*,?\s*)?\}\s*,?\s*\)/g;
|
|
|
|
function sourceFiles(): string[] {
|
|
return walkSourceFiles(SRC, { floor: 3000 })
|
|
.filter((p) => !p.startsWith("generated/"))
|
|
.filter((p) => !p.includes("__tests__"))
|
|
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
|
.sort();
|
|
}
|
|
|
|
function read(rel: string): string {
|
|
return readFileSync(join(SRC, rel), "utf8");
|
|
}
|
|
|
|
function count(s: string, re: RegExp): number {
|
|
return (s.match(re) ?? []).length;
|
|
}
|
|
|
|
/**
|
|
* Files that invalidate a daily bundle WITHOUT pairing the inactive refetch,
|
|
* deliberately. Frozen: a new entry is a conscious decision that must land in a
|
|
* reviewed diff, exactly like the Bearer-scope mint allowlist. Each entry is a
|
|
* surface where the missing refetch is intentional, grouped by reason.
|
|
*/
|
|
const NO_REFETCH_ALLOWLIST = [
|
|
// Injection-site metadata saved AFTER an already-recorded (and already
|
|
// refetched) take — it does not change any dose's due/taken state.
|
|
"components/medications/glp1-medication-card.tsx",
|
|
"components/medications/medication-card.tsx",
|
|
// Pull-to-refresh: a current-surface read gesture on the medications page,
|
|
// not a hero-affecting write (audit B, B-5).
|
|
"app/medications/page-client.tsx",
|
|
// Medication configuration / metadata surfaces — rename, dose, schedule
|
|
// times, phase config, push preferences, API tokens, the create wizard.
|
|
// None records an intake; a schedule edit's next-due shift converges via the
|
|
// standard poll from a deep-config surface. Tighten in a later pass if a
|
|
// config surface is shown to strand the rail.
|
|
"components/medications/scheduling/schedule-times-editor.tsx",
|
|
"components/medications/sections/api-tokens-row.tsx",
|
|
"components/medications/sections/notifications-section.tsx",
|
|
"components/medications/sections/phase-config-sheet.tsx",
|
|
"components/medications/sections/settings-section.tsx",
|
|
"components/medications/wizard/medication-wizard-dialog.tsx",
|
|
// Background integration-sync completion on the Settings surface — the user
|
|
// is configuring an integration, not acting on the hero; convergence via the
|
|
// daily reads' own poll/focus refetch is the intended behaviour.
|
|
"components/settings/integrations/fitbit-card.tsx",
|
|
"components/settings/integrations/google-health-card.tsx",
|
|
"components/settings/integrations/nightscout-card.tsx",
|
|
"components/settings/integrations/oauth-provider-card.tsx",
|
|
"components/settings/integrations/whoop-card.tsx",
|
|
"components/settings/integrations/withings-card.tsx",
|
|
// Cycle writes: `cycleDependentKeys` carries no daily-read key and neither
|
|
// the dashboard snapshot nor the Today digest reads cycle rows today, so
|
|
// the forced refetch would be a no-op round-trip. The bundle is in the
|
|
// matcher so the day a daily-read key joins it, these sites surface here
|
|
// and must take the pairing.
|
|
"components/cycle/use-cycle.ts",
|
|
].sort();
|
|
|
|
/**
|
|
* Files allowed a DIRECT daily-read `invalidateQueries` without an inactive
|
|
* refetch, deliberately. Frozen like the bundle allowlist above.
|
|
*/
|
|
const DIRECT_NO_REFETCH_ALLOWLIST = [
|
|
// Background sync completion on the Settings surface — same reason as the
|
|
// integration cards in the bundle allowlist: the user is configuring, not
|
|
// acting on the hero; poll/focus convergence is intended.
|
|
"components/settings/integrations/google-health-card.tsx",
|
|
// The check-in card and the priority-item dismissal both render INSIDE the
|
|
// Today digest, so the digest query is mounted and the default
|
|
// `refetchType: "active"` refetches it immediately — the inactive arm has
|
|
// nothing to cover.
|
|
"hooks/use-coach-checkin.ts",
|
|
"hooks/use-priority-item-dismiss.ts",
|
|
].sort();
|
|
|
|
describe("daily-reads refetch guard", () => {
|
|
// A sweep that finds nothing agrees with every allowlist, so the size of
|
|
// the tree being read is asserted before anything is concluded from it.
|
|
// Pinned below the real source-file count with headroom, not at one.
|
|
it("reads the tree it claims to sweep", () => {
|
|
expect(sourceFiles().length).toBeGreaterThan(1500);
|
|
});
|
|
|
|
it("the blessed helper pairs the bundle invalidation with the inactive refetch", () => {
|
|
const src = read(FACTORY);
|
|
// The one canonical entry point every intake surface routes through.
|
|
expect(src).toMatch(/export async function invalidateMedicationReads\(/);
|
|
// It must do BOTH — invalidate the bundle AND force the inactive refetch.
|
|
const body = src.slice(
|
|
src.indexOf("export async function invalidateMedicationReads("),
|
|
);
|
|
expect(body).toMatch(
|
|
/invalidateKeys\(\s*queryClient,\s*medicationDependentKeys\s*\)/,
|
|
);
|
|
expect(body).toMatch(/refetchInactiveDailyReads\(queryClient\)/);
|
|
});
|
|
|
|
it("every bundle-invalidation site pairs a refetch, except the frozen allowlist", () => {
|
|
// For each file that invalidates a daily bundle, the count of paired
|
|
// refetches must be at least the count of invalidations. A count rule (not
|
|
// a line-proximity heuristic) catches BOTH a new unpaired file AND a new
|
|
// unpaired call site slipped into an already-compliant file (its refetch
|
|
// count would then fall below its invalidation count).
|
|
const nonCompliant: string[] = [];
|
|
for (const rel of sourceFiles()) {
|
|
if (rel === FACTORY) continue;
|
|
const s = read(rel);
|
|
const invalidations = count(s, INVALIDATION);
|
|
if (invalidations === 0) continue;
|
|
const refetches = count(s, REFETCH);
|
|
if (refetches < invalidations) nonCompliant.push(rel);
|
|
}
|
|
|
|
// The set of deliberately-unpaired sites must be EXACTLY the allowlist:
|
|
// a new intake mutation that forgets the refetch appears here but not in
|
|
// the allowlist → fail; a paired site that loses its refetch drops into
|
|
// this set → fail; a deliberate new exception must be added above.
|
|
expect(nonCompliant.sort()).toEqual(NO_REFETCH_ALLOWLIST);
|
|
});
|
|
|
|
it("every direct daily-read invalidation forces a refetch, except the frozen allowlist", () => {
|
|
// A direct `invalidateQueries(dashboardSnapshot|dailyDigest)` is the
|
|
// single-key form of the same class the bundle matcher pins. Compliant
|
|
// forms: `refetchType: "inactive"` / `"all"` on the call itself, or a
|
|
// paired `refetchInactiveDailyReads` elsewhere in the file (then the
|
|
// direct call is the mounted-arm half of an explicit pair).
|
|
const nonCompliant: string[] = [];
|
|
let totalMatches = 0;
|
|
for (const rel of sourceFiles()) {
|
|
if (rel === FACTORY) continue;
|
|
const s = read(rel);
|
|
let bare = 0;
|
|
for (const m of s.matchAll(DIRECT_DAILY_INVALIDATION)) {
|
|
totalMatches++;
|
|
const refetchType = m[1];
|
|
if (refetchType !== "inactive" && refetchType !== "all") bare++;
|
|
}
|
|
if (bare > 0 && count(s, REFETCH) === 0) nonCompliant.push(rel);
|
|
}
|
|
// The matcher must be alive: the tree has known compliant call sites
|
|
// (the dashboard-layout section's `refetchType: "all"` pair among them).
|
|
// Zero matches would mean the regex rotted, not that the class is gone —
|
|
// the lesson of the 2026-08-01 bearer-guard blind spot.
|
|
expect(totalMatches).toBeGreaterThan(3);
|
|
expect(nonCompliant.sort()).toEqual(DIRECT_NO_REFETCH_ALLOWLIST);
|
|
});
|
|
|
|
it("the fixed intake surfaces route through the blessed helper or pair the refetch", () => {
|
|
// Positive pin: the v1.32.19 seed sites must carry the fix, so a revert to
|
|
// a bare `invalidateKeys(medicationDependentKeys)` is caught here as well
|
|
// as by the count assertion above.
|
|
const routedThroughHelper = [
|
|
"components/medications/take-all-due.ts",
|
|
"components/medications/intake-edit-dialog.tsx",
|
|
"components/medications/intake-history-editable.tsx",
|
|
"components/medications/intake-import-dialog.tsx",
|
|
"components/medications/sections/destructive-zone-section.tsx",
|
|
"components/medications/use-medication-intake.ts",
|
|
];
|
|
for (const rel of routedThroughHelper) {
|
|
expect(read(rel)).toMatch(/invalidateMedicationReads\(/);
|
|
}
|
|
const pairInPlace = [
|
|
"components/medications/dose-history-ledger.tsx",
|
|
"components/medications/dose-history-add-dialog.tsx",
|
|
];
|
|
for (const rel of pairInPlace) {
|
|
expect(read(rel)).toMatch(REFETCH);
|
|
}
|
|
});
|
|
});
|