mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
A rollup band outlives the rows it was folded from. The read-swap falls back to live SQL only when a band is EMPTY, so a bucket that is merely wrong is served as current; `ensureUserRollupsFresh` repairs the DAY tier over the trailing 90 days only, and keys off `Measurement.updatedAt`, which a hard delete does not bump. The WHOOP body sync hard-deletes a WEIGHT row with no invalidation, so a MONTH bucket keeps reporting a value the record no longer holds. The block was handing the model two irreconcilable accounts of the same record and marking neither: the coarse tail said one thing, the all-time extremes read live beside it said another, and `asOf` stamped the whole thing current because it is derived from raw reading age and knows nothing about rollup age. Reconcile them where they are assembled. A bucket mean is an average of real rows, so it cannot fall outside those rows' own all-time extremes — when it does, the band is describing readings the live read cannot see. That is an invariant rather than a heuristic, so the disputed tail is dropped and `asOf.coarseHistoryWithheld` records that it went. Narrating a ghost is worse than narrating less. `currentForTodayClaims` stays keyed to raw recency, which is what it means. The surviving readings really are from today, and suppressing a true present-tense statement to punish a stale history band would trade one dishonesty for another. The bucket on disk is still wrong, and a wide event now says so. The deeper fix is to make the tier notice — compare a bucket's `computedAt` against the type's in-window MAX(`updatedAt`), or invalidate on the hard-delete path — which is a larger change than this repair.
141 lines
5.6 KiB
TypeScript
141 lines
5.6 KiB
TypeScript
/**
|
|
* REGRESSION — a rollup bucket must not narrate rows that no longer exist.
|
|
*
|
|
* The read-swap in `tiered-context.ts` falls back to live SQL only when a band
|
|
* is EMPTY, so a bucket that is merely WRONG is served as though it were
|
|
* current. The one staleness repair, `ensureUserRollupsFresh`, recomputes the
|
|
* DAY tier over the trailing 90 days only, and keys off `Measurement.updatedAt`
|
|
* — which a HARD delete does not bump. `src/lib/whoop/sync-body.ts` performs
|
|
* exactly such a hard `measurement.deleteMany` on a WEIGHT row with no rollup
|
|
* invalidation, so this reproduces that shape against a MONTH-band bucket.
|
|
*
|
|
* Before the fix the Coach snapshot carried two irreconcilable accounts of the
|
|
* same record with nothing marking which to believe: `coarse.monthly` reported
|
|
* the deleted value while `aggregate.allTimeMin/Max` beside it reported only
|
|
* the surviving one, under an `asOf` stamped current because it is derived from
|
|
* raw reading age and knows nothing about rollup age.
|
|
*
|
|
* `annotateSnapshotFreshness` now reconciles the two where they are assembled.
|
|
* A bucket mean is an average of real rows, so it must lie within those rows'
|
|
* all-time extremes; a mean outside them proves the bucket outlived its rows.
|
|
* The disputed coarse tail is dropped and `asOf.coarseHistoryWithheld` records
|
|
* that it was — narrating a ghost is worse than narrating less.
|
|
*
|
|
* `currentForTodayClaims` deliberately stays true here. It means "the freshest
|
|
* READING is recent enough for present tense", and it is: the surviving rows
|
|
* are from today. Suppressing it would silence a true statement to punish a
|
|
* stale history band.
|
|
*
|
|
* The row on disk is still wrong. The deeper fix is to make the tier notice —
|
|
* either compare a bucket's `computedAt` against the type's in-window
|
|
* MAX(`updatedAt`), or invalidate on the hard-delete path — and that is a
|
|
* larger change than this honesty repair.
|
|
*/ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
|
|
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
|
|
|
|
const prisma = getPrismaClient();
|
|
const DAY = 24 * 60 * 60 * 1000;
|
|
|
|
/** The value only ever present in the rows we delete. */
|
|
const GHOST_KG = 95;
|
|
/** The value the record still holds afterwards. */
|
|
const LIVE_KG = 80;
|
|
|
|
interface WeightBlock {
|
|
aggregate: { allTimeMin: number; allTimeMax: number };
|
|
timeline: { coarse?: { monthly: Array<[string, number, number, number]> } };
|
|
asOf: { currentForTodayClaims: boolean; coarseHistoryWithheld?: true };
|
|
}
|
|
|
|
describe("coach snapshot — a rollup bucket that outlived its rows", () => {
|
|
beforeEach(async () => {
|
|
await truncateAllTables(prisma);
|
|
});
|
|
|
|
it("withholds the coarse tail whose buckets the live record cannot account for", async () => {
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
email: "stale-rollup@example.test",
|
|
username: "stale-rollup",
|
|
passwordHash: "x",
|
|
},
|
|
});
|
|
const userId = user.id;
|
|
|
|
// Rows in the MONTH band (90 days to a year), plus recent rows so the
|
|
// snapshot renders a weight block at all.
|
|
await prisma.measurement.createMany({
|
|
data: [
|
|
...Array.from({ length: 12 }, (_, i) => ({
|
|
userId,
|
|
type: "WEIGHT" as const,
|
|
value: GHOST_KG,
|
|
unit: "kg",
|
|
source: "WHOOP" as const,
|
|
externalId: `stale-${i}`,
|
|
measuredAt: new Date(Date.now() - (200 + i) * DAY),
|
|
})),
|
|
...Array.from({ length: 6 }, (_, i) => ({
|
|
userId,
|
|
type: "WEIGHT" as const,
|
|
value: LIVE_KG,
|
|
unit: "kg",
|
|
measuredAt: new Date(Date.now() - i * DAY),
|
|
})),
|
|
],
|
|
});
|
|
|
|
await recomputeUserRollups(userId);
|
|
|
|
// The hard delete `whoop/sync-body.ts` performs — no rollup invalidation,
|
|
// and no `updatedAt` bump for the freshness probe to notice.
|
|
const deleted = await prisma.measurement.deleteMany({
|
|
where: { userId, type: "WEIGHT", source: "WHOOP" },
|
|
});
|
|
expect(deleted.count).toBe(12);
|
|
|
|
const remaining = await prisma.measurement.findMany({
|
|
where: { userId, type: "WEIGHT" },
|
|
select: { value: true },
|
|
});
|
|
expect(remaining.every((r) => r.value === LIVE_KG)).toBe(true);
|
|
|
|
// The MONTH buckets are untouched by the delete.
|
|
const buckets = await prisma.measurementRollup.findMany({
|
|
where: { userId, type: "WEIGHT", granularity: "MONTH", source: "WHOOP" },
|
|
select: { mean: true, count: true },
|
|
});
|
|
expect(buckets.length).toBeGreaterThan(0);
|
|
expect(buckets.every((b) => b.mean === GHOST_KG)).toBe(true);
|
|
|
|
const snap = await buildCoachSnapshot(userId);
|
|
const weight = (snap.sections as Record<string, unknown>)
|
|
.weight as WeightBlock;
|
|
|
|
// The all-time extremes, read live, know only the surviving value.
|
|
expect(weight.aggregate.allTimeMin).toBe(LIVE_KG);
|
|
expect(weight.aggregate.allTimeMax).toBe(LIVE_KG);
|
|
|
|
// The coarse tail could not be reconciled with them, so it is gone…
|
|
expect(weight.timeline.coarse).toBeUndefined();
|
|
expect(weight.asOf.coarseHistoryWithheld).toBe(true);
|
|
|
|
// …and the deleted value reaches no part of what the model is shown.
|
|
expect(snap.snapshotJson).not.toContain(String(GHOST_KG));
|
|
|
|
// The freshest reading is still from today, so present tense is still fair.
|
|
expect(weight.asOf.currentForTodayClaims).toBe(true);
|
|
});
|
|
});
|