mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
fix(insights): skip tombstoned rows in the correlation channel and the cache gate
A deleted reading is kept with a `deletedAt` stamp, and the schema says in as many words that normal reads filter it. Three did not. The custom-metric correlation channel filtered the metric's own tombstone but not its entries', so a reading the person deleted kept shaping the correlation they were shown. The status input hash — which decides whether a cached AI assessment is still current — filtered its measurement arm but not its mood aggregate and not its custom-metric count or newest-entry probe. A deletion left that fingerprint unchanged, so the gate re-dated yesterday's text and served it as today's over data that was gone. Pinned in the existing soft-delete guard, whose matchers were checked by removing each filter again.
This commit is contained in:
1 parent
4d98a273b1
commit
156f7e1523
3 files changed
+52
-3
No files matched your search
@@ -50,4 +50,44 @@ describe("prompt reads exclude tombstoned rows", () => {
|
||||
expect(intakeRead).not.toBeNull();
|
||||
expect(intakeRead![0]).toContain("deletedAt: null");
|
||||
});
|
||||
// A soft delete has to be able to reach the CACHE GATE as well as the
|
||||
// prompt. `buildStatusInputHash` fingerprints "how much data is there and
|
||||
// how new is it"; a tombstoned row that still counts leaves the fingerprint
|
||||
// unchanged, the gate re-dates yesterday's assessment, and text reasoning
|
||||
// over a deleted reading is served as today's. The measurement arm of that
|
||||
// same query already filters, which is what makes the two that did not so
|
||||
// easy to miss.
|
||||
it("the status input hash filters its mood aggregate", () => {
|
||||
const src = read("../status-cache.ts");
|
||||
const moodRead = src.match(
|
||||
/moodEntry\s*\n?\s*\.aggregate\(\{[\s\S]{0,800}?where:\s*\{[^}]*\}/,
|
||||
);
|
||||
expect(moodRead).not.toBeNull();
|
||||
expect(moodRead![0]).toContain("deletedAt: null");
|
||||
});
|
||||
|
||||
it("the status input hash filters its custom-metric entry reads", () => {
|
||||
const src = read("../status-cache.ts");
|
||||
// Both the count and the newest-entry probe: either one left unfiltered
|
||||
// keeps the fingerprint still after a delete.
|
||||
expect(src).toMatch(
|
||||
/_count:\s*\{\s*select:\s*\{\s*entries:\s*\{[\s\S]{0,200}?deletedAt:\s*null/,
|
||||
);
|
||||
const newest = src.match(
|
||||
/entries:\s*\{\s*where:\s*\{[^}]*\}[\s\S]{0,200}?take:\s*1/,
|
||||
);
|
||||
expect(newest).not.toBeNull();
|
||||
expect(newest![0]).toContain("deletedAt: null");
|
||||
});
|
||||
|
||||
it("the custom-metric correlation channel filters its entry read", () => {
|
||||
// The parent metric's own tombstone is filtered; its entries' were not,
|
||||
// so a reading the person deleted still shaped the correlation cards.
|
||||
const src = read("../correlation-channel-series.ts");
|
||||
const entryRead = src.match(
|
||||
/entries:\s*\{[\s\S]{0,400}?where:\s*\{[^}]*\}/,
|
||||
);
|
||||
expect(entryRead).not.toBeNull();
|
||||
expect(entryRead![0]).toContain("deletedAt: null");
|
||||
});
|
||||
});
|
||||
@@ -288,7 +288,10 @@ export async function fetchCustomMetricBehaviourSeries(
|
||||
name: true,
|
||||
unit: true,
|
||||
entries: {
|
||||
where: { measuredAt: { gte: since } },
|
||||
// The parent metric's tombstone is filtered above; the entries' own
|
||||
// one has to be too, or a reading the person deleted keeps shaping
|
||||
// the correlation they are shown.
|
||||
where: { deletedAt: null, measuredAt: { gte: since } },
|
||||
orderBy: [{ measuredAt: "desc" }, { id: "desc" }],
|
||||
take: 2000,
|
||||
select: { value: true, unit: true, measuredAt: true },
|
||||
|
||||
@@ -324,7 +324,12 @@ export async function computeStatusInputFingerprint(args: {
|
||||
}),
|
||||
args.includeMood
|
||||
? prisma.moodEntry.aggregate({
|
||||
where: { userId: args.userId },
|
||||
// Tombstoned rows are excluded here for the same reason they are in
|
||||
// the measurement arm above: this aggregate IS the freshness
|
||||
// fingerprint, so a deletion that leaves the count and the newest
|
||||
// timestamp untouched cannot invalidate the cached assessment, and
|
||||
// yesterday's text gets re-dated as today's over data that is gone.
|
||||
where: { userId: args.userId, deletedAt: null },
|
||||
_count: { _all: true },
|
||||
_max: { moodLoggedAt: true },
|
||||
})
|
||||
@@ -342,8 +347,9 @@ export async function computeStatusInputFingerprint(args: {
|
||||
id: true,
|
||||
unit: true,
|
||||
updatedAt: true,
|
||||
_count: { select: { entries: true } },
|
||||
_count: { select: { entries: { where: { deletedAt: null } } } },
|
||||
entries: {
|
||||
where: { deletedAt: null },
|
||||
orderBy: [{ measuredAt: "desc" }, { id: "desc" }],
|
||||
take: 1,
|
||||
select: { measuredAt: true, unit: true },
|
||||
|
||||
Reference in new issue
Block a user