mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
chore(release): v1.4.40 — comprehensive Critical+High architecture closure plus iOS PB30 enablement
Eleven touch-disjoint waves landed on develop plus a six-axis QA reconcile round before tagging. See CHANGELOG.md for the user-facing prose; this commit message documents the wave + report inventory for release-management traceability. Waves shipped (each with a phase report under .planning/): - phase-W-POOL-v1440-report.md — p-limit(4) wrapper around the analytics 15-way fan-out + pg.Pool max raised 10 -> 20. Closes the v1.4.39 empirical-trace finding #1 (Prisma pool starvation). - phase-W-DELETED-v1440-report.md — 11 reader-tier helpers + 3 integration contracts close audit Critical Finding #3 (soft-delete invisibility) across dashboard, analytics, summaries, rollup tiers, and the Coach snapshot path. - phase-W-INSIGHTS-v1440-report.md — mood-rollup swap on the three /api/insights/{features,targets,cards} routes plus the compliance-rollup hook gap closure for bulk-projection mints; the post-QA reconcile patch closes the six remaining measurement.findMany sites in the cluster that still leaked tombstoned rows. - phase-W-INFRA-v1440-report.md — rollup helper umbrella consolidation under src/lib/rollups/* with zero orphan re-exports; lint, typecheck, knip, and 93/93 rollup tests green post-restructure. - phase-W-RSC-v1440-report.md — per-tile Suspense boundaries plus the queryKey factory-bypass walker test (extended to comparison/ in the reconcile pass). - phase-W-PRIVACY-v1440-report.md — bilingual /privacy page with 17 paired-section tests pinning the 9 SB-3 disclosure requirements. - phase-W-AASA-v1440-report.md — /.well-known/apple-app-site-association endpoint pinned to S8WDX4W5KX.dev.healthlog.app with app-ID parity assertions guarding against split rotation. - phase-W-APNS-NOTIFY-v1440-report.md — MEDICATION_REMINDER time-sensitive APNs payload (priority 10 + interruption-level) scoped via a parameterised test that pins all 6 other event-types do not bypass Focus. AP-2 .p8 install gates real delivery. - phase-W-CONSENT-v1440-report.md — AI consent receipts CRUD with append-only invariant pinned in test; revoke-then-regrant leaves both rows; idempotent DELETE returns 200 + receipt: null. - phase-W-WMY-WIRE-v1440-report.md — three previously-dead WMY readers (week/month/year) wired through summaries-slice and health-score-fast-path with a linear-composability parity test pinning the cross-consumer routing contract. - phase-W-GHOSTS-v1440-report.md — ~1 177 lines of dead code removed across 9 atomic commits; startOfUtcDay + wallClockInTz consolidation eliminates a sibling-drift risk; knip whitelist trimmed to three files pending follow-up. QA reconcile patches applied per the v1.4.40 QA round (six reports under .planning/round-v1440-QA-*-findings.md): - senior C1 — dashboard-suspense-boundaries regex aligned with the post-W-INFRA Thread 2 userTimezone-local lift. - senior H1 — soft-delete filter added to the six remaining insights measurement.findMany sites. - specialist H1 — Promise.allSettled around bulk-projection compliance recompute on both /api/medications/intake and /api/dashboard/summary. - ux H1 — compare-toggle migrated to queryKeys.dashboardWidgets factory; walker test extended to src/components/comparison/. - security M1 — consent artefact 64 KB cap enforced via Buffer.byteLength (UTF-8 bytes, not UTF-16 code units). Quality gates green at tag: pnpm typecheck, pnpm lint, pnpm knip --include files,dependencies,binaries,unlisted, pnpm test --run (4726 passing / 1 skipped). Tests 4525 -> 4726 unit + 1 skipped over the v1.4.39 -> v1.4.40 span. User-facing prose: see CHANGELOG.md.
This commit is contained in:
1 parent
f9996552ed
commit
1114cf7e63
146 files changed
+8207
-2279
No files matched your search
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* v1.4.39 W-SUM — unit tests for the cumulative-day-sum reader helpers.
|
||||
*
|
||||
* `prisma.measurementRollup.findMany` is mocked so the tests pin the
|
||||
* exact `where` shape the consumer sends (cumulative type set,
|
||||
* granularity, since-bound) and the legacy-NULL fallback semantics of
|
||||
* `resolveBucketSum`.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findMany: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurementRollup: {
|
||||
findMany: mocks.findMany,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
isCumulativeType,
|
||||
readCumulativeDaySums,
|
||||
resolveBucketSum,
|
||||
} from "../measurement-read-cumulative";
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.findMany.mockReset();
|
||||
});
|
||||
|
||||
describe("isCumulativeType", () => {
|
||||
it("recognises the five cumulative HK types", () => {
|
||||
expect(isCumulativeType("ACTIVITY_STEPS")).toBe(true);
|
||||
expect(isCumulativeType("ACTIVE_ENERGY_BURNED")).toBe(true);
|
||||
expect(isCumulativeType("FLIGHTS_CLIMBED")).toBe(true);
|
||||
expect(isCumulativeType("WALKING_RUNNING_DISTANCE")).toBe(true);
|
||||
expect(isCumulativeType("TIME_IN_DAYLIGHT")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects spot metrics", () => {
|
||||
expect(isCumulativeType("WEIGHT")).toBe(false);
|
||||
expect(isCumulativeType("BLOOD_PRESSURE_SYS")).toBe(false);
|
||||
expect(isCumulativeType("PULSE")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readCumulativeDaySums", () => {
|
||||
it("queries the rollup table with the cumulative type + DAY granularity", async () => {
|
||||
const since = new Date("2026-04-15T00:00:00.000Z");
|
||||
const rows = [
|
||||
{
|
||||
bucketStart: new Date("2026-04-15T00:00:00.000Z"),
|
||||
sumValue: 8120,
|
||||
count: 4,
|
||||
mean: 2030,
|
||||
},
|
||||
{
|
||||
bucketStart: new Date("2026-04-16T00:00:00.000Z"),
|
||||
sumValue: 12480,
|
||||
count: 5,
|
||||
mean: 2496,
|
||||
},
|
||||
];
|
||||
mocks.findMany.mockResolvedValueOnce(rows);
|
||||
|
||||
const out = await readCumulativeDaySums("user-1", "ACTIVITY_STEPS", since);
|
||||
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out[0]).toMatchObject({
|
||||
bucketStart: new Date("2026-04-15T00:00:00.000Z"),
|
||||
sumValue: 8120,
|
||||
count: 4,
|
||||
mean: 2030,
|
||||
});
|
||||
// Single round-trip — eliminates the legacy per-type chunked
|
||||
// findMany loop in analytics A2.
|
||||
expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1);
|
||||
const args = mocks.findMany.mock.calls[0][0];
|
||||
expect(args.where).toMatchObject({
|
||||
userId: "user-1",
|
||||
type: "ACTIVITY_STEPS",
|
||||
granularity: "DAY",
|
||||
bucketStart: { gte: since },
|
||||
});
|
||||
expect(args.orderBy).toEqual({ bucketStart: "asc" });
|
||||
});
|
||||
|
||||
it("propagates legacy NULL sum_value rows so the caller can fall back", async () => {
|
||||
mocks.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
bucketStart: new Date("2026-04-15T00:00:00.000Z"),
|
||||
sumValue: null,
|
||||
count: 3,
|
||||
mean: 1500,
|
||||
},
|
||||
]);
|
||||
|
||||
const [row] = await readCumulativeDaySums(
|
||||
"user-1",
|
||||
"ACTIVITY_STEPS",
|
||||
new Date("2026-04-15T00:00:00.000Z"),
|
||||
);
|
||||
expect(row.sumValue).toBeNull();
|
||||
// Algebraic fallback parity: pre-v1.4.39 buckets still surface
|
||||
// a usable daily total during the boot-backfill convergence
|
||||
// window.
|
||||
expect(resolveBucketSum(row)).toBe(4500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBucketSum", () => {
|
||||
it("returns sumValue when populated", () => {
|
||||
expect(
|
||||
resolveBucketSum({
|
||||
bucketStart: new Date(),
|
||||
sumValue: 7500,
|
||||
count: 3,
|
||||
mean: 2500,
|
||||
}),
|
||||
).toBe(7500);
|
||||
});
|
||||
|
||||
it("falls back to mean * count when sumValue is null (legacy row)", () => {
|
||||
expect(
|
||||
resolveBucketSum({
|
||||
bucketStart: new Date(),
|
||||
sumValue: null,
|
||||
count: 3,
|
||||
mean: 2500,
|
||||
}),
|
||||
).toBe(7500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* v1.4.39 W-WMY — unit tests for the WEEK / MONTH / YEAR rollup
|
||||
* readers and the auto-router that picks the largest granularity
|
||||
* that still resolves a requested window.
|
||||
*
|
||||
* `prisma.measurementRollup.findMany` is mocked at the module level
|
||||
* so the test pins:
|
||||
* - each per-granularity reader filters by the correct
|
||||
* `granularity` literal,
|
||||
* - empty rollups return `null` so the caller can branch on
|
||||
* coverage miss,
|
||||
* - the auto-router walks YEAR → MONTH → WEEK → DAY and stops at
|
||||
* the first granularity whose floor the window clears AND which
|
||||
* has coverage,
|
||||
* - `aggregateWmyBuckets` composes `count / min / max / mean / sum`
|
||||
* linearly across coarser buckets (the same compositional
|
||||
* contract `rollup-read.ts:aggregateBuckets` carries for DAY).
|
||||
*
|
||||
* Integration coverage against a real Postgres lives outside this
|
||||
* file — the writer's `STDDEV_POP / REGR_SLOPE` semantics are pinned
|
||||
* in `tests/integration/measurement-rollups.test.ts` already.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findMany: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurementRollup: {
|
||||
findMany: mocks.findMany,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
aggregateWmyBuckets,
|
||||
readBestGranularityRollups,
|
||||
readMonthRollups,
|
||||
readWeekRollups,
|
||||
readYearRollups,
|
||||
type RollupBucketRow,
|
||||
} from "../measurement-read-wmy";
|
||||
|
||||
const { findMany } = mocks;
|
||||
|
||||
function bucket(
|
||||
bucketStart: string,
|
||||
partial: Partial<RollupBucketRow> = {},
|
||||
): RollupBucketRow {
|
||||
return {
|
||||
bucketStart: new Date(bucketStart),
|
||||
count: 10,
|
||||
mean: 82,
|
||||
sd: 1,
|
||||
slope: -0.01,
|
||||
r2: 0.3,
|
||||
sumValue: null,
|
||||
minValue: 80,
|
||||
maxValue: 84,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
findMany.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("readWeekRollups", () => {
|
||||
it("returns the WEEK buckets for (userId, type, since)", async () => {
|
||||
const rows = [
|
||||
bucket("2026-04-27T00:00:00.000Z"),
|
||||
bucket("2026-05-04T00:00:00.000Z", { mean: 81 }),
|
||||
];
|
||||
findMany.mockResolvedValueOnce(rows);
|
||||
const since = new Date("2026-04-20T00:00:00.000Z");
|
||||
|
||||
const result = await readWeekRollups("user-1", "WEIGHT", since);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result?.[0].mean).toBe(82);
|
||||
expect(result?.[1].mean).toBe(81);
|
||||
const args = findMany.mock.calls[0][0];
|
||||
expect(args.where.granularity).toBe("WEEK");
|
||||
expect(args.where.userId).toBe("user-1");
|
||||
expect(args.where.type).toBe("WEIGHT");
|
||||
expect(args.where.bucketStart.gte).toBe(since);
|
||||
expect(args.orderBy).toEqual({ bucketStart: "asc" });
|
||||
});
|
||||
|
||||
it("returns null when the WEEK window has no coverage", async () => {
|
||||
findMany.mockResolvedValueOnce([]);
|
||||
const result = await readWeekRollups(
|
||||
"user-1",
|
||||
"WEIGHT",
|
||||
new Date("2026-04-20T00:00:00.000Z"),
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readMonthRollups", () => {
|
||||
it("returns the MONTH buckets and pins the granularity filter", async () => {
|
||||
findMany.mockResolvedValueOnce([bucket("2026-04-01T00:00:00.000Z")]);
|
||||
const result = await readMonthRollups(
|
||||
"user-2",
|
||||
"PULSE",
|
||||
new Date("2026-01-01T00:00:00.000Z"),
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(findMany.mock.calls[0][0].where.granularity).toBe("MONTH");
|
||||
});
|
||||
|
||||
it("returns null on coverage miss", async () => {
|
||||
findMany.mockResolvedValueOnce([]);
|
||||
const result = await readMonthRollups(
|
||||
"user-2",
|
||||
"PULSE",
|
||||
new Date("2026-01-01T00:00:00.000Z"),
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readYearRollups", () => {
|
||||
it("returns the YEAR buckets and pins the granularity filter", async () => {
|
||||
findMany.mockResolvedValueOnce([bucket("2025-01-01T00:00:00.000Z")]);
|
||||
const result = await readYearRollups(
|
||||
"user-3",
|
||||
"WEIGHT",
|
||||
new Date("2023-01-01T00:00:00.000Z"),
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(findMany.mock.calls[0][0].where.granularity).toBe("YEAR");
|
||||
});
|
||||
|
||||
it("returns null on coverage miss", async () => {
|
||||
findMany.mockResolvedValueOnce([]);
|
||||
const result = await readYearRollups(
|
||||
"user-3",
|
||||
"WEIGHT",
|
||||
new Date("2023-01-01T00:00:00.000Z"),
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readBestGranularityRollups", () => {
|
||||
it("returns null on a non-positive window", async () => {
|
||||
expect(await readBestGranularityRollups("user", "WEIGHT", 0)).toBeNull();
|
||||
expect(await readBestGranularityRollups("user", "WEIGHT", -10)).toBeNull();
|
||||
expect(await readBestGranularityRollups("user", "WEIGHT", NaN)).toBeNull();
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes a 90-day window to DAY (every coarser floor is too low)", async () => {
|
||||
findMany.mockResolvedValueOnce([bucket("2026-04-01T00:00:00.000Z")]);
|
||||
const result = await readBestGranularityRollups("user", "WEIGHT", 90);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.granularity).toBe("DAY");
|
||||
expect(findMany).toHaveBeenCalledTimes(1);
|
||||
expect(findMany.mock.calls[0][0].where.granularity).toBe("DAY");
|
||||
});
|
||||
|
||||
it("routes a 365-day window to MONTH (clears the 62-day MONTH floor)", async () => {
|
||||
findMany.mockResolvedValueOnce([bucket("2026-01-01T00:00:00.000Z")]);
|
||||
const result = await readBestGranularityRollups("user", "WEIGHT", 365);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.granularity).toBe("MONTH");
|
||||
expect(findMany).toHaveBeenCalledTimes(1);
|
||||
expect(findMany.mock.calls[0][0].where.granularity).toBe("MONTH");
|
||||
});
|
||||
|
||||
it("routes a 1095-day window to YEAR (clears the 730-day YEAR floor)", async () => {
|
||||
findMany.mockResolvedValueOnce([bucket("2024-01-01T00:00:00.000Z")]);
|
||||
const result = await readBestGranularityRollups("user", "WEIGHT", 1095);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.granularity).toBe("YEAR");
|
||||
expect(findMany).toHaveBeenCalledTimes(1);
|
||||
expect(findMany.mock.calls[0][0].where.granularity).toBe("YEAR");
|
||||
});
|
||||
|
||||
it("falls back to MONTH when YEAR has no coverage", async () => {
|
||||
// 1095-day window asks for YEAR first; coverage miss → MONTH.
|
||||
findMany
|
||||
.mockResolvedValueOnce([]) // YEAR miss
|
||||
.mockResolvedValueOnce([bucket("2024-01-01T00:00:00.000Z")]); // MONTH hit
|
||||
|
||||
const result = await readBestGranularityRollups("user", "WEIGHT", 1095);
|
||||
|
||||
expect(result?.granularity).toBe("MONTH");
|
||||
expect(findMany.mock.calls[0][0].where.granularity).toBe("YEAR");
|
||||
expect(findMany.mock.calls[1][0].where.granularity).toBe("MONTH");
|
||||
});
|
||||
|
||||
it("falls all the way through to DAY when every coarser tier misses", async () => {
|
||||
findMany
|
||||
.mockResolvedValueOnce([]) // YEAR miss
|
||||
.mockResolvedValueOnce([]) // MONTH miss
|
||||
.mockResolvedValueOnce([]) // WEEK miss
|
||||
.mockResolvedValueOnce([bucket("2024-01-01T00:00:00.000Z")]); // DAY hit
|
||||
|
||||
const result = await readBestGranularityRollups("user", "WEIGHT", 1095);
|
||||
|
||||
expect(result?.granularity).toBe("DAY");
|
||||
expect(findMany).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("returns null when no granularity carries any coverage", async () => {
|
||||
findMany.mockResolvedValue([]);
|
||||
const result = await readBestGranularityRollups("user", "WEIGHT", 1095);
|
||||
expect(result).toBeNull();
|
||||
// YEAR, MONTH, WEEK, DAY all probed.
|
||||
expect(findMany).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateWmyBuckets", () => {
|
||||
it("returns the empty-window shape on no rows", () => {
|
||||
expect(aggregateWmyBuckets([])).toEqual({
|
||||
count: 0,
|
||||
min: null,
|
||||
max: null,
|
||||
mean: null,
|
||||
sum: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("sums count + folds min/max + weights mean across MONTH buckets", () => {
|
||||
// Two MONTH buckets — same compositional contract as DAY because
|
||||
// `count / min / max / mean` are linearly composable across any
|
||||
// bucket granularity.
|
||||
// April: count=10, mean=82, min=79, max=84
|
||||
// May: count=20, mean=80, min=77, max=83
|
||||
// ⇒ totalCount=30, min=77, max=84,
|
||||
// mean = (10×82 + 20×80) / 30 = 80.6666…
|
||||
const rows: RollupBucketRow[] = [
|
||||
bucket("2026-04-01T00:00:00.000Z", {
|
||||
count: 10,
|
||||
mean: 82,
|
||||
minValue: 79,
|
||||
maxValue: 84,
|
||||
}),
|
||||
bucket("2026-05-01T00:00:00.000Z", {
|
||||
count: 20,
|
||||
mean: 80,
|
||||
minValue: 77,
|
||||
maxValue: 83,
|
||||
}),
|
||||
];
|
||||
const result = aggregateWmyBuckets(rows);
|
||||
expect(result.count).toBe(30);
|
||||
expect(result.min).toBe(77);
|
||||
expect(result.max).toBe(84);
|
||||
expect(result.mean).toBeCloseTo((10 * 82 + 20 * 80) / 30, 5);
|
||||
});
|
||||
|
||||
it("sums cumulative sumValue when every bucket carries one", () => {
|
||||
const rows: RollupBucketRow[] = [
|
||||
bucket("2026-04-01T00:00:00.000Z", { sumValue: 12_500 }),
|
||||
bucket("2026-05-01T00:00:00.000Z", { sumValue: 8_200 }),
|
||||
];
|
||||
const result = aggregateWmyBuckets(rows);
|
||||
expect(result.sum).toBe(20_700);
|
||||
});
|
||||
|
||||
it("returns null sum when no bucket carries sumValue (pre-W-SUM data)", () => {
|
||||
const rows: RollupBucketRow[] = [
|
||||
bucket("2026-04-01T00:00:00.000Z", { sumValue: null }),
|
||||
bucket("2026-05-01T00:00:00.000Z", { sumValue: null }),
|
||||
];
|
||||
expect(aggregateWmyBuckets(rows).sum).toBeNull();
|
||||
});
|
||||
|
||||
it("treats sumValue NaN/Infinity as missing", () => {
|
||||
const rows: RollupBucketRow[] = [
|
||||
bucket("2026-04-01T00:00:00.000Z", { sumValue: 100 }),
|
||||
bucket("2026-05-01T00:00:00.000Z", {
|
||||
// simulate a Postgres NaN slipping through serialisation.
|
||||
sumValue: Number.NaN,
|
||||
}),
|
||||
];
|
||||
// Only the finite sumValue contributes.
|
||||
expect(aggregateWmyBuckets(rows).sum).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.4.40 W-WMY-WIRE — granularity-routing parity across the v1.4.40
|
||||
* consumers (summaries-slice + health-score-fast-path). Both consumers
|
||||
* now call `readBestGranularityRollups` for their long-window probes;
|
||||
* this pins:
|
||||
* - 90 d → DAY (every coarser floor too high — DAY is canonical
|
||||
* for the trailing quarter regardless of consumer),
|
||||
* - 365 d → MONTH (clears MONTH floor 181 d, short of YEAR floor
|
||||
* 731 d) — the granularity health-score-fast-path's weight long-
|
||||
* window read and summaries-slice's year-ago baseline both
|
||||
* consume,
|
||||
* - 1095 d → YEAR (clears the 731-day YEAR floor) — the v1.5
|
||||
* multi-year trend card target.
|
||||
*
|
||||
* The parity assertion ensures the routing contract is a single
|
||||
* source of truth: any future floor adjustment forces both consumers
|
||||
* to re-pin in lock-step rather than silently diverging.
|
||||
*/
|
||||
describe("readBestGranularityRollups — cross-consumer routing parity", () => {
|
||||
it("pins the 90 / 365 / 1095 day routing targets the consumers depend on", async () => {
|
||||
// Three sequential probes with non-empty results so the router
|
||||
// stops at the first matching tier per window.
|
||||
findMany
|
||||
.mockResolvedValueOnce([bucket("2026-02-15T00:00:00.000Z")]) // 90d → DAY
|
||||
.mockResolvedValueOnce([bucket("2025-08-01T00:00:00.000Z")]) // 365d → MONTH
|
||||
.mockResolvedValueOnce([bucket("2024-01-01T00:00:00.000Z")]); // 1095d → YEAR
|
||||
|
||||
const ninety = await readBestGranularityRollups("user", "WEIGHT", 90);
|
||||
const yearLong = await readBestGranularityRollups("user", "WEIGHT", 365);
|
||||
const threeYear = await readBestGranularityRollups("user", "WEIGHT", 1095);
|
||||
|
||||
expect(ninety?.granularity).toBe("DAY");
|
||||
expect(yearLong?.granularity).toBe("MONTH");
|
||||
expect(threeYear?.granularity).toBe("YEAR");
|
||||
// Each consumer-relevant window only fires one round-trip on the
|
||||
// happy path; the per-tier walk is reserved for coverage-miss
|
||||
// scenarios already pinned above.
|
||||
expect(findMany).toHaveBeenCalledTimes(3);
|
||||
expect(findMany.mock.calls[0][0].where.granularity).toBe("DAY");
|
||||
expect(findMany.mock.calls[1][0].where.granularity).toBe("MONTH");
|
||||
expect(findMany.mock.calls[2][0].where.granularity).toBe("YEAR");
|
||||
});
|
||||
|
||||
it("aggregates MONTH buckets to a byte-identical mean compared to the underlying DAY buckets", async () => {
|
||||
// The compositional contract `count / mean` are linearly
|
||||
// composable across granularities — pin it by simulating "MONTH
|
||||
// routing for a 365-day window" vs "DAY routing for the same
|
||||
// window" against the same underlying counts/means and asserting
|
||||
// the count-weighted mean agrees.
|
||||
//
|
||||
// MONTH bucket (consolidated): count=30, mean=80
|
||||
// DAY buckets (per-day refresh): count=10/mean=82 + count=20/mean=79
|
||||
// ⇒ DAY-derived mean = (10*82 + 20*79) / 30 = 80.0
|
||||
// Both must agree numerically — the routing helper's choice is a
|
||||
// performance optimisation, not a math change.
|
||||
const monthBuckets: RollupBucketRow[] = [
|
||||
bucket("2025-08-01T00:00:00.000Z", { count: 30, mean: 80 }),
|
||||
];
|
||||
const dayBuckets: RollupBucketRow[] = [
|
||||
bucket("2025-08-05T00:00:00.000Z", { count: 10, mean: 82 }),
|
||||
bucket("2025-08-20T00:00:00.000Z", { count: 20, mean: 79 }),
|
||||
];
|
||||
const monthAgg = aggregateWmyBuckets(monthBuckets);
|
||||
const dayAgg = aggregateWmyBuckets(dayBuckets);
|
||||
|
||||
expect(monthAgg.count).toBe(dayAgg.count);
|
||||
expect(monthAgg.mean).toBeCloseTo(dayAgg.mean ?? Number.NaN, 5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* v1.5.0 — unit tests for the rollup-read aggregation helpers.
|
||||
*
|
||||
* Pins the `count` / `min` / `max` / `mean` re-aggregation across
|
||||
* DAY buckets — the linearly-composable subset of the
|
||||
* comprehensive-aggregator's `DataSummary` shape that survives
|
||||
* round-tripping through the rollup table.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { aggregateBuckets, type DailyMeanRow } from "../measurement-read";
|
||||
|
||||
const dayA: DailyMeanRow = {
|
||||
day: new Date("2026-05-08T00:00:00.000Z"),
|
||||
count: 2,
|
||||
mean: 80,
|
||||
minValue: 79,
|
||||
maxValue: 81,
|
||||
};
|
||||
const dayB: DailyMeanRow = {
|
||||
day: new Date("2026-05-09T00:00:00.000Z"),
|
||||
count: 3,
|
||||
mean: 82,
|
||||
minValue: 80,
|
||||
maxValue: 84,
|
||||
};
|
||||
const dayC: DailyMeanRow = {
|
||||
day: new Date("2026-05-10T00:00:00.000Z"),
|
||||
count: 5,
|
||||
mean: 83,
|
||||
minValue: 81,
|
||||
maxValue: 86,
|
||||
};
|
||||
|
||||
describe("aggregateBuckets", () => {
|
||||
it("returns the empty-window shape when given no rows", () => {
|
||||
expect(aggregateBuckets([])).toEqual({
|
||||
count: 0,
|
||||
min: null,
|
||||
max: null,
|
||||
mean: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("sums count and folds min / max across days", () => {
|
||||
const result = aggregateBuckets([dayA, dayB, dayC]);
|
||||
expect(result.count).toBe(10);
|
||||
expect(result.min).toBe(79);
|
||||
expect(result.max).toBe(86);
|
||||
});
|
||||
|
||||
it("weighs the mean by daily count (not equal-weight)", () => {
|
||||
const result = aggregateBuckets([dayA, dayB, dayC]);
|
||||
// Σ(count × mean) / Σcount = (2×80 + 3×82 + 5×83) / 10
|
||||
// = (160 + 246 + 415) / 10
|
||||
// = 82.1
|
||||
expect(result.mean).toBeCloseTo(82.1, 5);
|
||||
});
|
||||
|
||||
it("handles a single bucket without arithmetic drift", () => {
|
||||
const result = aggregateBuckets([dayB]);
|
||||
expect(result.count).toBe(3);
|
||||
expect(result.min).toBe(80);
|
||||
expect(result.max).toBe(84);
|
||||
expect(result.mean).toBe(82);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,591 @@
|
||||
/**
|
||||
* v1.5.0 — unit tests for the persistent measurement-rollup populator.
|
||||
*
|
||||
* `prisma` is mocked at the module level so we can pin:
|
||||
* - the DAY-bucket sync recompute writes through the upsert path,
|
||||
* - `collapseToTypeDayKeys` folds same-day entries,
|
||||
* - the bucket-span helper anchors WEEK on Monday, MONTH / YEAR on
|
||||
* calendar boundaries,
|
||||
* - `enqueueRollupRecompute` calls `boss.send` with the documented
|
||||
* queue name + singleton-key shape, and is a silent no-op when
|
||||
* no boss is attached.
|
||||
*
|
||||
* Integration coverage (real Postgres + real `STDDEV_POP` /
|
||||
* `REGR_SLOPE`) lives in `tests/integration/measurement-rollups.test.ts`.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// vi.mock factories run BEFORE module-level statements due to hoisting,
|
||||
// so the mock fns must live inside the factory closure. We re-export
|
||||
// them through `vi.hoisted` so the test body can reach them after.
|
||||
const mocks = vi.hoisted(() => ({
|
||||
queryRaw: vi.fn(),
|
||||
queryRawUnsafe: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findFirstMeasurement: vi.fn(),
|
||||
bossSend: vi.fn(),
|
||||
getGlobalBossMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
$queryRaw: mocks.queryRaw,
|
||||
$queryRawUnsafe: mocks.queryRawUnsafe,
|
||||
$transaction: mocks.transaction,
|
||||
measurementRollup: {
|
||||
upsert: mocks.upsert,
|
||||
deleteMany: mocks.deleteMany,
|
||||
findFirst: mocks.findFirst,
|
||||
findMany: mocks.findMany,
|
||||
},
|
||||
measurement: {
|
||||
findFirst: mocks.findFirstMeasurement,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: () => mocks.getGlobalBossMock(),
|
||||
}));
|
||||
|
||||
// v1.4.36 QA H3 — `ensureUserRollupsFresh` now annotates + console-
|
||||
// errors on populator failures so silent regressions show up in ops.
|
||||
// Mock the annotate boundary so the test can assert the meta payload.
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const {
|
||||
queryRaw,
|
||||
queryRawUnsafe,
|
||||
transaction,
|
||||
upsert,
|
||||
deleteMany,
|
||||
findFirst,
|
||||
findFirstMeasurement,
|
||||
bossSend,
|
||||
getGlobalBossMock,
|
||||
} = mocks;
|
||||
// `findMany` and `queryRaw` are wired into the mock object so any
|
||||
// indirect lookups via `prisma.measurementRollup.findMany` / `prisma.$queryRaw`
|
||||
// don't throw, but the populator tests below exercise the
|
||||
// `$queryRawUnsafe` + upsert paths so the references stay unused
|
||||
// inside this file.
|
||||
void mocks.findMany;
|
||||
void queryRaw;
|
||||
|
||||
import {
|
||||
ROLLUP_FULL_BACKFILL_QUEUE,
|
||||
ROLLUP_RECOMPUTE_QUEUE,
|
||||
_resetEnsureUserRollupsFreshInFlightForTests,
|
||||
collapseToTypeDayKeys,
|
||||
enqueueBootTimeRollupBackfill,
|
||||
enqueueRollupRecompute,
|
||||
ensureUserRollupsFresh,
|
||||
recomputeBucketsForMeasurement,
|
||||
} from "../measurement-rollups";
|
||||
|
||||
beforeEach(() => {
|
||||
queryRaw.mockReset();
|
||||
queryRawUnsafe.mockReset();
|
||||
transaction.mockReset();
|
||||
upsert.mockReset();
|
||||
deleteMany.mockReset();
|
||||
findFirst.mockReset();
|
||||
mocks.findMany.mockReset();
|
||||
findFirstMeasurement.mockReset();
|
||||
bossSend.mockReset();
|
||||
getGlobalBossMock.mockReset();
|
||||
// v1.4.38 — clear the per-userId in-flight map so a previous test's
|
||||
// resolved promise does not short-circuit the next test's recompute
|
||||
// assertion.
|
||||
_resetEnsureUserRollupsFreshInFlightForTests();
|
||||
// Default: $transaction takes an array of pre-built promises (the
|
||||
// populator passes `slice.map(prisma.measurementRollup.upsert(...))`)
|
||||
// and returns them awaited.
|
||||
transaction.mockImplementation(async (operations: unknown[]) => operations);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("collapseToTypeDayKeys", () => {
|
||||
it("folds same-day same-type entries into one key", () => {
|
||||
const morning = new Date("2026-05-10T07:30:00.000Z");
|
||||
const evening = new Date("2026-05-10T21:30:00.000Z");
|
||||
const nextDay = new Date("2026-05-11T07:30:00.000Z");
|
||||
const keys = collapseToTypeDayKeys([
|
||||
{ type: "WEIGHT", measuredAt: morning },
|
||||
{ type: "WEIGHT", measuredAt: evening },
|
||||
{ type: "WEIGHT", measuredAt: nextDay },
|
||||
{ type: "PULSE", measuredAt: morning },
|
||||
]);
|
||||
// 3 unique (type, day) pairs: WEIGHT×2026-05-10, WEIGHT×2026-05-11,
|
||||
// PULSE×2026-05-10.
|
||||
expect(keys).toHaveLength(3);
|
||||
const types = keys.map((k) => k.type).sort();
|
||||
expect(types).toEqual(["PULSE", "WEIGHT", "WEIGHT"]);
|
||||
// Every entry is anchored on UTC midnight so the worker enqueue
|
||||
// payload is deterministic.
|
||||
for (const k of keys) {
|
||||
expect(k.measuredAt.getUTCHours()).toBe(0);
|
||||
expect(k.measuredAt.getUTCMinutes()).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns an empty array when given no entries", () => {
|
||||
expect(collapseToTypeDayKeys([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recomputeBucketsForMeasurement", () => {
|
||||
it("upserts the DAY rollup synchronously and enqueues WEEK/MONTH/YEAR", async () => {
|
||||
queryRawUnsafe.mockResolvedValueOnce([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
bucket_start: new Date("2026-05-10T00:00:00.000Z"),
|
||||
count: BigInt(3),
|
||||
mean: 82.5,
|
||||
min_value: 80.0,
|
||||
max_value: 85.0,
|
||||
// v1.4.39 W-SUM — writer always populates `sum_value` (AVG
|
||||
// and SUM compose in the same $queryRaw aggregate). 3 * 82.5
|
||||
// = 247.5 so the round-trips line up with `mean * count`.
|
||||
sum_value: 247.5,
|
||||
sd: 2.0,
|
||||
slope: -0.1,
|
||||
r2: 0.9,
|
||||
},
|
||||
]);
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
bossSend.mockResolvedValue("job-id");
|
||||
|
||||
await recomputeBucketsForMeasurement(
|
||||
"user-1",
|
||||
"WEIGHT",
|
||||
new Date("2026-05-10T14:30:00.000Z"),
|
||||
);
|
||||
|
||||
// DAY pass — single upsert in a transaction.
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(upsert).toHaveBeenCalledTimes(1);
|
||||
const upsertArg = upsert.mock.calls[0][0];
|
||||
expect(upsertArg.where.userId_type_granularity_bucketStart.userId).toBe(
|
||||
"user-1",
|
||||
);
|
||||
expect(upsertArg.where.userId_type_granularity_bucketStart.type).toBe(
|
||||
"WEIGHT",
|
||||
);
|
||||
expect(
|
||||
upsertArg.where.userId_type_granularity_bucketStart.granularity,
|
||||
).toBe("DAY");
|
||||
expect(upsertArg.create.count).toBe(3);
|
||||
expect(upsertArg.create.mean).toBe(82.5);
|
||||
// v1.4.39 W-SUM — sumValue flows through on both create + update
|
||||
// halves of the upsert. Cumulative read paths (steps, flights,
|
||||
// distance, daylight, active-energy) consume this directly.
|
||||
expect(upsertArg.create.sumValue).toBe(247.5);
|
||||
expect(upsertArg.update.sumValue).toBe(247.5);
|
||||
|
||||
// WEEK / MONTH / YEAR — three enqueues against the worker queue.
|
||||
expect(bossSend).toHaveBeenCalledTimes(3);
|
||||
for (const call of bossSend.mock.calls) {
|
||||
expect(call[0]).toBe(ROLLUP_RECOMPUTE_QUEUE);
|
||||
expect(call[1].userId).toBe("user-1");
|
||||
expect(call[1].type).toBe("WEIGHT");
|
||||
expect(["WEEK", "MONTH", "YEAR"]).toContain(call[1].granularity);
|
||||
}
|
||||
});
|
||||
|
||||
it("writes sum_value for cumulative ACTIVITY_STEPS buckets", async () => {
|
||||
// 5 step samples in a day summing to 12480: emulates an iOS
|
||||
// batch of HealthKit slices reaching the rollup aggregator.
|
||||
queryRawUnsafe.mockResolvedValueOnce([
|
||||
{
|
||||
type: "ACTIVITY_STEPS",
|
||||
bucket_start: new Date("2026-05-10T00:00:00.000Z"),
|
||||
count: BigInt(5),
|
||||
mean: 2496,
|
||||
min_value: 100,
|
||||
max_value: 9000,
|
||||
// 100 + 2500 + 9000 + 500 + 380 = 12480
|
||||
sum_value: 12480,
|
||||
sd: 3200,
|
||||
slope: null,
|
||||
r2: null,
|
||||
},
|
||||
]);
|
||||
getGlobalBossMock.mockReturnValue(null);
|
||||
|
||||
await recomputeBucketsForMeasurement(
|
||||
"user-7",
|
||||
"ACTIVITY_STEPS",
|
||||
new Date("2026-05-10T14:30:00.000Z"),
|
||||
);
|
||||
|
||||
expect(upsert).toHaveBeenCalledTimes(1);
|
||||
const arg = upsert.mock.calls[0][0];
|
||||
expect(arg.create.sumValue).toBe(12480);
|
||||
// mean × count algebraic equivalence — the writer carries SUM
|
||||
// directly because the aggregator computed it once. Asserting
|
||||
// both gives parity coverage for the consumer-side fallback.
|
||||
expect(arg.create.mean * arg.create.count).toBe(12480);
|
||||
});
|
||||
|
||||
it("passes through null sum_value when the aggregator returns NULL", async () => {
|
||||
// Defensive: an empty WHERE-clause window would NEVER return a
|
||||
// row here (the route filters by day boundaries), but a future
|
||||
// aggregator change that adds a HAVING clause should still
|
||||
// surface NULL cleanly rather than coercing to 0.
|
||||
queryRawUnsafe.mockResolvedValueOnce([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
bucket_start: new Date("2026-05-10T00:00:00.000Z"),
|
||||
count: BigInt(1),
|
||||
mean: 82.5,
|
||||
min_value: 82.5,
|
||||
max_value: 82.5,
|
||||
sum_value: null,
|
||||
sd: null,
|
||||
slope: null,
|
||||
r2: null,
|
||||
},
|
||||
]);
|
||||
getGlobalBossMock.mockReturnValue(null);
|
||||
|
||||
await recomputeBucketsForMeasurement(
|
||||
"user-1",
|
||||
"WEIGHT",
|
||||
new Date("2026-05-10T14:30:00.000Z"),
|
||||
);
|
||||
|
||||
const arg = upsert.mock.calls[0][0];
|
||||
expect(arg.create.sumValue).toBeNull();
|
||||
expect(arg.update.sumValue).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes the DAY row when the post-mutation aggregate is empty", async () => {
|
||||
// Post-delete recompute: the day now has zero rows.
|
||||
queryRawUnsafe.mockResolvedValueOnce([]);
|
||||
getGlobalBossMock.mockReturnValue(null);
|
||||
|
||||
await recomputeBucketsForMeasurement(
|
||||
"user-1",
|
||||
"WEIGHT",
|
||||
new Date("2026-05-10T14:30:00.000Z"),
|
||||
);
|
||||
|
||||
expect(deleteMany).toHaveBeenCalledTimes(1);
|
||||
expect(deleteMany.mock.calls[0][0].where.userId).toBe("user-1");
|
||||
expect(deleteMany.mock.calls[0][0].where.granularity).toBe("DAY");
|
||||
expect(upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("enqueueRollupRecompute", () => {
|
||||
it("is a silent no-op when no boss is attached", async () => {
|
||||
getGlobalBossMock.mockReturnValue(null);
|
||||
await enqueueRollupRecompute({
|
||||
userId: "user-1",
|
||||
type: "WEIGHT",
|
||||
granularity: "WEEK",
|
||||
from: new Date("2026-05-04T00:00:00.000Z"),
|
||||
to: new Date("2026-05-11T00:00:00.000Z"),
|
||||
});
|
||||
expect(bossSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends with the documented queue + singleton-key shape", async () => {
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
bossSend.mockResolvedValue("job-1");
|
||||
const from = new Date("2026-05-04T00:00:00.000Z");
|
||||
await enqueueRollupRecompute({
|
||||
userId: "user-9",
|
||||
type: "PULSE",
|
||||
granularity: "WEEK",
|
||||
from,
|
||||
to: new Date("2026-05-11T00:00:00.000Z"),
|
||||
});
|
||||
expect(bossSend).toHaveBeenCalledTimes(1);
|
||||
const [queue, payload, opts] = bossSend.mock.calls[0];
|
||||
expect(queue).toBe(ROLLUP_RECOMPUTE_QUEUE);
|
||||
expect(payload.userId).toBe("user-9");
|
||||
expect(payload.type).toBe("PULSE");
|
||||
expect(payload.granularity).toBe("WEEK");
|
||||
expect(payload.from).toBe(from.toISOString());
|
||||
// The singleton key collapses repeat enqueues for the same bucket
|
||||
// so a multi-write minute doesn't fan out one worker run per row.
|
||||
expect(opts.singletonKey).toBe(
|
||||
`user-9|PULSE|WEEK|${from.toISOString()}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureUserRollupsFresh", () => {
|
||||
it("is a no-op when the user has no measurements", async () => {
|
||||
findFirst.mockResolvedValueOnce(null);
|
||||
findFirstMeasurement.mockResolvedValueOnce(null);
|
||||
const result = await ensureUserRollupsFresh("user-1");
|
||||
expect(result.recomputed).toBe(false);
|
||||
// No subsequent live SQL pass.
|
||||
expect(queryRawUnsafe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is a no-op when the rollup is already ahead of the newest measurement", async () => {
|
||||
const rollupAt = new Date("2026-05-10T12:00:00.000Z");
|
||||
const measurementAt = new Date("2026-05-10T11:00:00.000Z");
|
||||
findFirst.mockResolvedValueOnce({ computedAt: rollupAt });
|
||||
findFirstMeasurement.mockResolvedValueOnce({
|
||||
updatedAt: measurementAt,
|
||||
measuredAt: measurementAt,
|
||||
});
|
||||
const result = await ensureUserRollupsFresh("user-1");
|
||||
expect(result.recomputed).toBe(false);
|
||||
expect(queryRawUnsafe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("triggers a DAY-window recompute when stale", async () => {
|
||||
const rollupAt = new Date("2026-05-10T10:00:00.000Z");
|
||||
const measurementAt = new Date("2026-05-10T12:00:00.000Z");
|
||||
findFirst.mockResolvedValueOnce({ computedAt: rollupAt });
|
||||
findFirstMeasurement.mockResolvedValueOnce({
|
||||
updatedAt: measurementAt,
|
||||
measuredAt: measurementAt,
|
||||
});
|
||||
queryRawUnsafe.mockResolvedValueOnce([]);
|
||||
const result = await ensureUserRollupsFresh("user-1");
|
||||
expect(result.recomputed).toBe(true);
|
||||
expect(queryRawUnsafe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("swallows populator errors so the read path never fails", async () => {
|
||||
findFirst.mockRejectedValueOnce(new Error("pool exhausted"));
|
||||
findFirstMeasurement.mockRejectedValueOnce(new Error("pool exhausted"));
|
||||
const result = await ensureUserRollupsFresh("user-1");
|
||||
expect(result.recomputed).toBe(false);
|
||||
});
|
||||
|
||||
// v1.4.38 — concurrent callers for the same userId share one
|
||||
// in-flight promise so a cold fan-out can never queue N parallel
|
||||
// recompute runs against the same 90-day window.
|
||||
it("dedups concurrent callers for the same userId onto one in-flight promise", async () => {
|
||||
const rollupAt = new Date("2026-05-10T10:00:00.000Z");
|
||||
const measurementAt = new Date("2026-05-10T12:00:00.000Z");
|
||||
// Both round-trips inside the helper run twice (the helper issues
|
||||
// a parallel `Promise.all` for the rollup-most-recent + measurement
|
||||
// -most-recent). With dedup, those should fire ONCE across two
|
||||
// concurrent callers.
|
||||
findFirst.mockResolvedValue({ computedAt: rollupAt });
|
||||
findFirstMeasurement.mockResolvedValue({
|
||||
updatedAt: measurementAt,
|
||||
measuredAt: measurementAt,
|
||||
});
|
||||
queryRawUnsafe.mockResolvedValue([]);
|
||||
|
||||
const [a, b, c] = await Promise.all([
|
||||
ensureUserRollupsFresh("user-1"),
|
||||
ensureUserRollupsFresh("user-1"),
|
||||
ensureUserRollupsFresh("user-1"),
|
||||
]);
|
||||
expect(a.recomputed).toBe(true);
|
||||
expect(b.recomputed).toBe(true);
|
||||
expect(c.recomputed).toBe(true);
|
||||
// The Postgres-side recompute fires exactly once across the three
|
||||
// concurrent callers — proof the dedup map collapsed the fan-out.
|
||||
expect(queryRawUnsafe).toHaveBeenCalledTimes(1);
|
||||
// The findFirst probe fires exactly once per call type (rollup,
|
||||
// measurement) across all three concurrent callers.
|
||||
expect(findFirst).toHaveBeenCalledTimes(1);
|
||||
expect(findFirstMeasurement).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clears the in-flight slot after resolution so the next call runs fresh", async () => {
|
||||
const rollupAt = new Date("2026-05-10T10:00:00.000Z");
|
||||
const measurementAt = new Date("2026-05-10T12:00:00.000Z");
|
||||
findFirst.mockResolvedValue({ computedAt: rollupAt });
|
||||
findFirstMeasurement.mockResolvedValue({
|
||||
updatedAt: measurementAt,
|
||||
measuredAt: measurementAt,
|
||||
});
|
||||
queryRawUnsafe.mockResolvedValue([]);
|
||||
|
||||
await ensureUserRollupsFresh("user-1");
|
||||
await ensureUserRollupsFresh("user-1");
|
||||
// Two serial calls => two recompute runs (no dedup short-circuit
|
||||
// because the first call already resolved and dropped its slot).
|
||||
expect(queryRawUnsafe).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("clears the in-flight slot on rejection so the next call retries", async () => {
|
||||
findFirst.mockRejectedValueOnce(new Error("pool exhausted"));
|
||||
findFirstMeasurement.mockRejectedValueOnce(new Error("pool exhausted"));
|
||||
const first = await ensureUserRollupsFresh("user-1");
|
||||
expect(first.recomputed).toBe(false);
|
||||
|
||||
// Second call: probe succeeds, stale watermark triggers a recompute.
|
||||
const rollupAt = new Date("2026-05-10T10:00:00.000Z");
|
||||
const measurementAt = new Date("2026-05-10T12:00:00.000Z");
|
||||
findFirst.mockResolvedValueOnce({ computedAt: rollupAt });
|
||||
findFirstMeasurement.mockResolvedValueOnce({
|
||||
updatedAt: measurementAt,
|
||||
measuredAt: measurementAt,
|
||||
});
|
||||
queryRawUnsafe.mockResolvedValueOnce([]);
|
||||
const second = await ensureUserRollupsFresh("user-1");
|
||||
expect(second.recomputed).toBe(true);
|
||||
});
|
||||
|
||||
it("annotates the failure when the inner recompute throws (H3)", async () => {
|
||||
// Stale watermark — rollup older than the newest measurement so
|
||||
// the inner recompute branch fires.
|
||||
const rollupAt = new Date("2026-05-10T10:00:00.000Z");
|
||||
const measurementAt = new Date("2026-05-10T12:00:00.000Z");
|
||||
findFirst.mockResolvedValueOnce({ computedAt: rollupAt });
|
||||
findFirstMeasurement.mockResolvedValueOnce({
|
||||
updatedAt: measurementAt,
|
||||
measuredAt: measurementAt,
|
||||
});
|
||||
// The recompute aggregate query throws — simulates a populator
|
||||
// regression (pool exhausted, deadlock, etc).
|
||||
queryRawUnsafe.mockRejectedValueOnce(new Error("deadlock detected"));
|
||||
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const result = await ensureUserRollupsFresh("user-1");
|
||||
|
||||
// The read path still gets a clean `{ recomputed: false }`.
|
||||
expect(result.recomputed).toBe(false);
|
||||
// The annotate event fires with the documented shape so ops can
|
||||
// spot the silent populator regression in the wide-event pipeline.
|
||||
expect(annotate).toHaveBeenCalledWith({
|
||||
meta: {
|
||||
rollup_refresh_failed: true,
|
||||
rollup_refresh_error: "deadlock detected",
|
||||
},
|
||||
});
|
||||
// And the console.error fallback fires so the worker log path
|
||||
// surfaces the failure even when there's no request context.
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("enqueueBootTimeRollupBackfill", () => {
|
||||
it("is a silent no-op when no boss is attached", async () => {
|
||||
getGlobalBossMock.mockReturnValue(null);
|
||||
const result = await enqueueBootTimeRollupBackfill();
|
||||
expect(result).toEqual({ enqueued: 0, skipped: 0, error: null });
|
||||
expect(queryRaw).not.toHaveBeenCalled();
|
||||
expect(bossSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enqueues one full-fold job per user who has measurements but no rollups", async () => {
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
queryRaw.mockResolvedValueOnce([
|
||||
{ id: "user-a" },
|
||||
{ id: "user-b" },
|
||||
{ id: "user-c" },
|
||||
]);
|
||||
bossSend
|
||||
.mockResolvedValueOnce("job-a")
|
||||
.mockResolvedValueOnce("job-b")
|
||||
.mockResolvedValueOnce("job-c");
|
||||
|
||||
const result = await enqueueBootTimeRollupBackfill();
|
||||
|
||||
expect(result).toEqual({ enqueued: 3, skipped: 0, error: null });
|
||||
expect(bossSend).toHaveBeenCalledTimes(3);
|
||||
// The queue name is the boot-fold queue, not the per-bucket queue.
|
||||
for (const call of bossSend.mock.calls) {
|
||||
expect(call[0]).toBe(ROLLUP_FULL_BACKFILL_QUEUE);
|
||||
}
|
||||
// Singleton key per user — coalesces across rapid reboots.
|
||||
expect(bossSend.mock.calls[0][2].singletonKey).toBe("boot-backfill|user-a");
|
||||
expect(bossSend.mock.calls[1][2].singletonKey).toBe("boot-backfill|user-b");
|
||||
expect(bossSend.mock.calls[2][2].singletonKey).toBe("boot-backfill|user-c");
|
||||
});
|
||||
|
||||
it("counts a `boss.send` returning null as 'skipped' (singleton coalesce)", async () => {
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
queryRaw.mockResolvedValueOnce([{ id: "user-a" }, { id: "user-b" }]);
|
||||
bossSend.mockResolvedValueOnce(null).mockResolvedValueOnce("job-b");
|
||||
|
||||
const result = await enqueueBootTimeRollupBackfill();
|
||||
|
||||
expect(result).toEqual({ enqueued: 1, skipped: 1, error: null });
|
||||
});
|
||||
|
||||
it("returns the error message when the discovery query throws", async () => {
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
queryRaw.mockRejectedValueOnce(new Error("pool exhausted"));
|
||||
|
||||
const result = await enqueueBootTimeRollupBackfill();
|
||||
|
||||
expect(result.enqueued).toBe(0);
|
||||
expect(result.error).toBe("pool exhausted");
|
||||
expect(bossSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns { enqueued: 0 } when no users need backfill", async () => {
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
queryRaw.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await enqueueBootTimeRollupBackfill();
|
||||
|
||||
expect(result).toEqual({ enqueued: 0, skipped: 0, error: null });
|
||||
expect(bossSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// v1.4.39 W-SUM — the discovery query now unions in users whose
|
||||
// existing DAY rollup rows carry `sum_value IS NULL`. Re-folding
|
||||
// converges those rows because `persistRollupRows` always writes the
|
||||
// new column on upsert; the union keeps the per-day missing-coverage
|
||||
// gap (v1.4.39.1) and the legacy-NULL backfill on the same indexed
|
||||
// pass.
|
||||
it("includes the sum_value IS NULL branch in the discovery query", async () => {
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
queryRaw.mockResolvedValueOnce([{ id: "user-with-legacy-rollups" }]);
|
||||
bossSend.mockResolvedValueOnce("job-id");
|
||||
|
||||
const result = await enqueueBootTimeRollupBackfill();
|
||||
|
||||
expect(result).toEqual({ enqueued: 1, skipped: 0, error: null });
|
||||
// The discovery SQL surfaces both per-day missing coverage AND
|
||||
// legacy NULL sum_value rows under one UNION. The text-anchor is
|
||||
// the only durable assertion at the unit level — the integration
|
||||
// suite covers the planner shape on real Postgres.
|
||||
const sqlParts = queryRaw.mock.calls[0][0] as TemplateStringsArray;
|
||||
const sqlText = Array.isArray(sqlParts) ? sqlParts.join("?") : "";
|
||||
expect(sqlText).toContain("sum_value");
|
||||
expect(sqlText).toContain("UNION");
|
||||
});
|
||||
|
||||
// v1.4.39.1 — the discovery anchor moved from per-type to per-day so
|
||||
// accounts whose Withings sync / `/api/import` / admin restore wrote
|
||||
// measurements without firing the rollup write hook get re-enqueued
|
||||
// on the next worker boot. Pre-fix, the per-type LEFT JOIN matched
|
||||
// on `(user, type)` and the user dropped off the discovery list as
|
||||
// soon as ONE rollup row existed for the type — even if 27 OTHER
|
||||
// days for that type were missing rollup rows.
|
||||
it("anchors the missing-coverage join on (user, type, bucket_start) so per-day gaps surface", async () => {
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
queryRaw.mockResolvedValueOnce([{ id: "withings-stranded-user" }]);
|
||||
bossSend.mockResolvedValueOnce("job-id");
|
||||
|
||||
await enqueueBootTimeRollupBackfill();
|
||||
|
||||
const sqlParts = queryRaw.mock.calls[0][0] as TemplateStringsArray;
|
||||
const sqlText = Array.isArray(sqlParts) ? sqlParts.join("?") : "";
|
||||
// Inner DISTINCT widens to `(user_id, type, bucket_start)`.
|
||||
expect(sqlText).toContain('date_trunc(\'day\', m."measured_at")');
|
||||
// LEFT JOIN now compares bucket_start on both sides.
|
||||
expect(sqlText).toContain('r."bucket_start" = mt."bucket_start"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* v1.4.39 W-MED — unit tests for the medication-compliance rollup tier.
|
||||
*
|
||||
* `prisma` is mocked at the module boundary so the test can pin the
|
||||
* intake-event fan-out + the rollup upsert / read shape without
|
||||
* standing up a real Postgres. Integration coverage (real Postgres +
|
||||
* the FK cascade behaviour) lives next to the route test suite when
|
||||
* the W-MED phase grows them; this file proves the helpers'
|
||||
* contract.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
intakeFindMany: vi.fn(),
|
||||
rollupUpsert: vi.fn(),
|
||||
rollupDeleteMany: vi.fn(),
|
||||
rollupFindMany: vi.fn(),
|
||||
rollupFindFirst: vi.fn(),
|
||||
queryRaw: vi.fn(),
|
||||
executeRaw: vi.fn(),
|
||||
bossSend: vi.fn(),
|
||||
getGlobalBossMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medicationIntakeEvent: {
|
||||
findMany: mocks.intakeFindMany,
|
||||
},
|
||||
medicationComplianceRollup: {
|
||||
upsert: mocks.rollupUpsert,
|
||||
deleteMany: mocks.rollupDeleteMany,
|
||||
findMany: mocks.rollupFindMany,
|
||||
findFirst: mocks.rollupFindFirst,
|
||||
},
|
||||
$queryRaw: mocks.queryRaw,
|
||||
$executeRaw: mocks.executeRaw,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: () => mocks.getGlobalBossMock(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
dayKeyForScheduledFor,
|
||||
recomputeMedicationComplianceForDay,
|
||||
recomputeMedicationComplianceForEvent,
|
||||
readMedicationCompliance,
|
||||
hasMedicationComplianceCoverage,
|
||||
recomputeUserMedicationCompliance,
|
||||
enqueueBootTimeMedicationComplianceBackfill,
|
||||
enqueueUserMedicationComplianceBackfill,
|
||||
MEDICATION_COMPLIANCE_BACKFILL_QUEUE,
|
||||
} from "../medication-compliance-rollups";
|
||||
|
||||
const {
|
||||
intakeFindMany,
|
||||
rollupUpsert,
|
||||
rollupDeleteMany,
|
||||
rollupFindMany,
|
||||
rollupFindFirst,
|
||||
queryRaw,
|
||||
executeRaw,
|
||||
bossSend,
|
||||
getGlobalBossMock,
|
||||
} = mocks;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
intakeFindMany.mockResolvedValue([]);
|
||||
rollupUpsert.mockResolvedValue({});
|
||||
rollupDeleteMany.mockResolvedValue({ count: 0 });
|
||||
rollupFindMany.mockResolvedValue([]);
|
||||
rollupFindFirst.mockResolvedValue(null);
|
||||
queryRaw.mockResolvedValue([]);
|
||||
executeRaw.mockResolvedValue(0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("dayKeyForScheduledFor", () => {
|
||||
it("anchors the dayKey on the user's tz wall clock", () => {
|
||||
// 2026-05-17T23:30:00Z — Berlin local is 2026-05-18 01:30 (CEST).
|
||||
const ts = new Date("2026-05-17T23:30:00.000Z");
|
||||
expect(dayKeyForScheduledFor(ts, "Europe/Berlin")).toBe("2026-05-18");
|
||||
// Los Angeles is UTC-7 (PDT) — still 2026-05-17 16:30 local.
|
||||
expect(dayKeyForScheduledFor(ts, "America/Los_Angeles")).toBe("2026-05-17");
|
||||
});
|
||||
|
||||
it("falls back to the server default when tz is null", () => {
|
||||
const ts = new Date("2026-05-17T22:00:00.000Z");
|
||||
expect(dayKeyForScheduledFor(ts, null)).toBe(
|
||||
dayKeyForScheduledFor(ts, "Europe/Berlin"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recomputeMedicationComplianceForDay", () => {
|
||||
/**
|
||||
* Helper: pull the bind-variable values out of a Prisma tagged-template
|
||||
* call so the test can inspect the user-id / day / window bounds the
|
||||
* SQL was rendered with. Prisma's `$executeRaw` tag passes the
|
||||
* template strings as the first arg and the substituted values as the
|
||||
* remaining positional args.
|
||||
*/
|
||||
function bindValues(call: unknown[]): unknown[] {
|
||||
return call.slice(1);
|
||||
}
|
||||
|
||||
it("runs an atomic INSERT … ON CONFLICT upsert with the (user, medication, day) tuple", async () => {
|
||||
// QA F-H-02 (v1.4.39): the recompute is now a single SQL statement
|
||||
// that re-aggregates inside the upsert; the JS pre-fold from the
|
||||
// previous tier is gone so the test inspects the bind values
|
||||
// instead of the intake fold.
|
||||
await recomputeMedicationComplianceForDay(
|
||||
"user-1",
|
||||
"med-1",
|
||||
"2026-05-18",
|
||||
"Europe/Berlin",
|
||||
);
|
||||
|
||||
// Two statements fire: the INSERT … ON CONFLICT upsert and the
|
||||
// companion DELETE for the "all events removed" branch.
|
||||
expect(executeRaw).toHaveBeenCalledTimes(2);
|
||||
const insertBinds = bindValues(executeRaw.mock.calls[0]);
|
||||
expect(insertBinds).toContain("user-1");
|
||||
expect(insertBinds).toContain("med-1");
|
||||
expect(insertBinds).toContain("2026-05-18");
|
||||
const deleteBinds = bindValues(executeRaw.mock.calls[1]);
|
||||
expect(deleteBinds).toContain("user-1");
|
||||
expect(deleteBinds).toContain("med-1");
|
||||
expect(deleteBinds).toContain("2026-05-18");
|
||||
});
|
||||
|
||||
it("emits a paired DELETE so a fully-cleared day removes its row", async () => {
|
||||
// The DELETE fires unconditionally and gates on NOT EXISTS — when
|
||||
// the day still has events the predicate matches zero rows. The
|
||||
// test pins that the DELETE statement was issued so a future
|
||||
// refactor that drops the second statement breaks here.
|
||||
await recomputeMedicationComplianceForDay(
|
||||
"user-1",
|
||||
"med-1",
|
||||
"2026-05-18",
|
||||
"Europe/Berlin",
|
||||
);
|
||||
|
||||
expect(executeRaw).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("is idempotent across repeated invocations", async () => {
|
||||
await recomputeMedicationComplianceForDay(
|
||||
"user-1",
|
||||
"med-1",
|
||||
"2026-05-18",
|
||||
"Europe/Berlin",
|
||||
);
|
||||
await recomputeMedicationComplianceForDay(
|
||||
"user-1",
|
||||
"med-1",
|
||||
"2026-05-18",
|
||||
"Europe/Berlin",
|
||||
);
|
||||
|
||||
expect(executeRaw).toHaveBeenCalledTimes(4);
|
||||
expect(bindValues(executeRaw.mock.calls[0])).toEqual(
|
||||
bindValues(executeRaw.mock.calls[2]),
|
||||
);
|
||||
expect(bindValues(executeRaw.mock.calls[1])).toEqual(
|
||||
bindValues(executeRaw.mock.calls[3]),
|
||||
);
|
||||
});
|
||||
|
||||
it("binds a tz-anchored UTC window into the upsert", async () => {
|
||||
await recomputeMedicationComplianceForDay(
|
||||
"user-1",
|
||||
"med-1",
|
||||
"2026-05-18",
|
||||
"Europe/Berlin",
|
||||
);
|
||||
|
||||
const insertBinds = bindValues(executeRaw.mock.calls[0]);
|
||||
const insertDates = insertBinds.filter((b): b is Date => b instanceof Date);
|
||||
// Berlin 2026-05-18 00:00 → UTC 2026-05-17 22:00 (CEST is UTC+2).
|
||||
expect(insertDates[0].toISOString()).toBe("2026-05-17T22:00:00.000Z");
|
||||
// Window closes 24h later — Berlin 2026-05-19 00:00 → UTC 2026-05-18 22:00.
|
||||
expect(insertDates[1].toISOString()).toBe("2026-05-18T22:00:00.000Z");
|
||||
});
|
||||
|
||||
it("buckets the same UTC instant on different days for Berlin vs LA", async () => {
|
||||
await recomputeMedicationComplianceForDay(
|
||||
"user-1",
|
||||
"med-1",
|
||||
"2026-05-18",
|
||||
"Europe/Berlin",
|
||||
);
|
||||
const berlinInsert = bindValues(executeRaw.mock.calls[0]).filter(
|
||||
(b): b is Date => b instanceof Date,
|
||||
);
|
||||
|
||||
executeRaw.mockClear();
|
||||
|
||||
await recomputeMedicationComplianceForDay(
|
||||
"user-2",
|
||||
"med-2",
|
||||
"2026-05-17",
|
||||
"America/Los_Angeles",
|
||||
);
|
||||
const laInsert = bindValues(executeRaw.mock.calls[0]).filter(
|
||||
(b): b is Date => b instanceof Date,
|
||||
);
|
||||
|
||||
// Berlin 2026-05-18 starts at UTC 2026-05-17 22:00.
|
||||
expect(berlinInsert[0].toISOString()).toBe("2026-05-17T22:00:00.000Z");
|
||||
// LA 2026-05-17 starts at UTC 2026-05-17 07:00 (PDT is UTC-7).
|
||||
expect(laInsert[0].toISOString()).toBe("2026-05-17T07:00:00.000Z");
|
||||
});
|
||||
|
||||
it("commits the strictest aggregate under concurrent recompute calls", async () => {
|
||||
// QA F-H-02 race pin: two concurrent recomputes for the same
|
||||
// (user, medication, day) must both go through the atomic SQL
|
||||
// path. The earlier `findMany`-then-`upsert` pattern could
|
||||
// interleave A-SELECT → B-SELECT → B-UPSERT (correct) → A-UPSERT
|
||||
// (stale N-1); the atomic upsert serialises on the row lock so
|
||||
// each statement re-aggregates a snapshot taken after the prior
|
||||
// commit. The mock can't reproduce real Postgres concurrency, but
|
||||
// the test pins that BOTH callers issue the atomic statement (not
|
||||
// the legacy read-aggregate-then-upsert) so a regression to the
|
||||
// racy pattern would fail this assertion.
|
||||
await Promise.all([
|
||||
recomputeMedicationComplianceForDay(
|
||||
"user-1",
|
||||
"med-1",
|
||||
"2026-05-18",
|
||||
"Europe/Berlin",
|
||||
),
|
||||
recomputeMedicationComplianceForDay(
|
||||
"user-1",
|
||||
"med-1",
|
||||
"2026-05-18",
|
||||
"Europe/Berlin",
|
||||
),
|
||||
]);
|
||||
// 2 callers × 2 statements (INSERT + DELETE) each.
|
||||
expect(executeRaw).toHaveBeenCalledTimes(4);
|
||||
// No legacy intake-findMany was issued — the atomic path replaces it.
|
||||
expect(intakeFindMany).not.toHaveBeenCalled();
|
||||
// No legacy rollupUpsert / rollupDeleteMany either.
|
||||
expect(rollupUpsert).not.toHaveBeenCalled();
|
||||
expect(rollupDeleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("recomputeMedicationComplianceForEvent", () => {
|
||||
it("derives the dayKey from scheduledFor + tz then dispatches", async () => {
|
||||
// 2026-05-17T23:30:00Z → Berlin day 2026-05-18.
|
||||
await recomputeMedicationComplianceForEvent({
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
scheduledFor: new Date("2026-05-17T23:30:00.000Z"),
|
||||
tz: "Europe/Berlin",
|
||||
});
|
||||
// The atomic upsert binds the dayKey as one of the positional
|
||||
// arguments; assert "2026-05-18" reached the SQL layer.
|
||||
const insertBinds = executeRaw.mock.calls[0].slice(1);
|
||||
expect(insertBinds).toContain("2026-05-18");
|
||||
});
|
||||
|
||||
it("swallows recompute errors so the parent write never blocks", async () => {
|
||||
executeRaw.mockRejectedValueOnce(new Error("DB melted"));
|
||||
|
||||
await expect(
|
||||
recomputeMedicationComplianceForEvent({
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
scheduledFor: new Date("2026-05-17T12:00:00.000Z"),
|
||||
tz: "Europe/Berlin",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readMedicationCompliance", () => {
|
||||
it("returns a trailing-window zero-filled bucket array", async () => {
|
||||
rollupFindMany.mockResolvedValue([
|
||||
{ day: "2026-05-18", scheduled: 2, taken: 1, skipped: 1 },
|
||||
]);
|
||||
|
||||
const now = new Date("2026-05-18T12:00:00.000Z");
|
||||
const buckets = await readMedicationCompliance(
|
||||
"user-1",
|
||||
3,
|
||||
"Europe/Berlin",
|
||||
now,
|
||||
);
|
||||
|
||||
expect(buckets).toHaveLength(3);
|
||||
expect(buckets[buckets.length - 1]).toEqual({
|
||||
date: "2026-05-18",
|
||||
scheduled: 2,
|
||||
taken: 1,
|
||||
});
|
||||
expect(buckets[0].scheduled).toBe(0);
|
||||
expect(buckets[0].taken).toBe(0);
|
||||
expect(buckets.map((b) => b.date)).toEqual([
|
||||
"2026-05-16",
|
||||
"2026-05-17",
|
||||
"2026-05-18",
|
||||
]);
|
||||
});
|
||||
|
||||
it("folds per-medication rows into one per-day total", async () => {
|
||||
rollupFindMany.mockResolvedValue([
|
||||
{ day: "2026-05-18", scheduled: 1, taken: 1, skipped: 0 },
|
||||
{ day: "2026-05-18", scheduled: 2, taken: 0, skipped: 1 },
|
||||
]);
|
||||
const now = new Date("2026-05-18T12:00:00.000Z");
|
||||
const buckets = await readMedicationCompliance(
|
||||
"user-1",
|
||||
1,
|
||||
"Europe/Berlin",
|
||||
now,
|
||||
);
|
||||
expect(buckets[0].scheduled).toBe(3);
|
||||
expect(buckets[0].taken).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasMedicationComplianceCoverage", () => {
|
||||
it("returns true when rolled-day count meets the event-day count in window", async () => {
|
||||
queryRaw.mockResolvedValue([
|
||||
{ rolled_days: BigInt(7), event_days: BigInt(7) },
|
||||
]);
|
||||
await expect(
|
||||
hasMedicationComplianceCoverage(
|
||||
"user-1",
|
||||
7,
|
||||
"Europe/Berlin",
|
||||
new Date("2026-05-18T12:00:00.000Z"),
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when the user has zero events in window (trivially covered)", async () => {
|
||||
// Zero events → the read path returns a trailing-window zero-fill
|
||||
// from an empty rollup table; covered semantically.
|
||||
queryRaw.mockResolvedValue([
|
||||
{ rolled_days: BigInt(0), event_days: BigInt(0) },
|
||||
]);
|
||||
await expect(
|
||||
hasMedicationComplianceCoverage("user-1", 7, "Europe/Berlin"),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("returns false on partial coverage (boot backfill mid-fold)", async () => {
|
||||
// QA F-H-01 (v1.4.39): the legacy "any row exists" probe would
|
||||
// flip true here and serve zero-filled buckets for days N..days-1
|
||||
// that the backfill hasn't reached yet. With the tightened probe
|
||||
// partial coverage forces the route into the legacy aggregator
|
||||
// until the fold completes.
|
||||
queryRaw.mockResolvedValue([
|
||||
{ rolled_days: BigInt(2), event_days: BigInt(7) },
|
||||
]);
|
||||
await expect(
|
||||
hasMedicationComplianceCoverage(
|
||||
"user-1",
|
||||
7,
|
||||
"Europe/Berlin",
|
||||
new Date("2026-05-18T12:00:00.000Z"),
|
||||
),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("returns false on zero rollups when events exist (legacy account cold start)", async () => {
|
||||
queryRaw.mockResolvedValue([
|
||||
{ rolled_days: BigInt(0), event_days: BigInt(7) },
|
||||
]);
|
||||
await expect(
|
||||
hasMedicationComplianceCoverage("user-1", 7, "Europe/Berlin"),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recomputeUserMedicationCompliance", () => {
|
||||
it("upserts one rollup per (medication, day) pair returned by discovery", async () => {
|
||||
queryRaw.mockResolvedValue([
|
||||
{ medication_id: "med-1", day: "2026-05-17" },
|
||||
{ medication_id: "med-1", day: "2026-05-18" },
|
||||
{ medication_id: "med-2", day: "2026-05-18" },
|
||||
]);
|
||||
|
||||
const result = await recomputeUserMedicationCompliance(
|
||||
"user-1",
|
||||
30,
|
||||
"Europe/Berlin",
|
||||
);
|
||||
|
||||
expect(result.rowsUpserted).toBe(3);
|
||||
// Each (medication, day) pair issues two atomic SQL statements:
|
||||
// the INSERT … ON CONFLICT upsert and the companion DELETE that
|
||||
// gates on `NOT EXISTS`.
|
||||
expect(executeRaw).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enqueueBootTimeMedicationComplianceBackfill", () => {
|
||||
it("is a no-op when no boss is attached", async () => {
|
||||
getGlobalBossMock.mockReturnValue(null);
|
||||
const result = await enqueueBootTimeMedicationComplianceBackfill();
|
||||
expect(result).toEqual({ enqueued: 0, skipped: 0, error: null });
|
||||
expect(queryRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enqueues one job per uncovered user", async () => {
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
queryRaw.mockResolvedValue([{ id: "user-1" }, { id: "user-2" }]);
|
||||
bossSend.mockResolvedValueOnce("job-1");
|
||||
bossSend.mockResolvedValueOnce(null); // coalesced
|
||||
const result = await enqueueBootTimeMedicationComplianceBackfill();
|
||||
expect(result.enqueued).toBe(1);
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(result.error).toBeNull();
|
||||
expect(bossSend).toHaveBeenCalledWith(
|
||||
MEDICATION_COMPLIANCE_BACKFILL_QUEUE,
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
expect.objectContaining({
|
||||
singletonKey: "medication-compliance-boot-backfill|user-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enqueueUserMedicationComplianceBackfill", () => {
|
||||
it("is a no-op when no boss is attached", async () => {
|
||||
getGlobalBossMock.mockReturnValue(null);
|
||||
const result = await enqueueUserMedicationComplianceBackfill("user-1");
|
||||
expect(result).toEqual({ enqueued: false, error: null });
|
||||
// Critically: no `$queryRaw` cluster-wide LEFT JOIN scan.
|
||||
expect(queryRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enqueues exactly one job for the caller's user and never touches the cluster-wide LEFT JOIN", async () => {
|
||||
// QA F-SEC-M-01 (v1.4.39): pre-fix the request-path coverage-miss
|
||||
// path called `enqueueBootTimeMedicationComplianceBackfill` which
|
||||
// issued a cluster-wide `LEFT JOIN medication_intake_events ×
|
||||
// medication_compliance_rollups` on every hit. The user-scoped
|
||||
// helper sends one targeted job and never runs the discovery scan.
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
bossSend.mockResolvedValueOnce("job-1");
|
||||
const result = await enqueueUserMedicationComplianceBackfill("user-1");
|
||||
expect(result).toEqual({ enqueued: true, error: null });
|
||||
expect(queryRaw).not.toHaveBeenCalled();
|
||||
expect(bossSend).toHaveBeenCalledTimes(1);
|
||||
expect(bossSend).toHaveBeenCalledWith(
|
||||
MEDICATION_COMPLIANCE_BACKFILL_QUEUE,
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
expect.objectContaining({
|
||||
singletonKey: "medication-compliance-boot-backfill|user-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a coalesced enqueue as `enqueued:false`", async () => {
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
bossSend.mockResolvedValueOnce(null);
|
||||
const result = await enqueueUserMedicationComplianceBackfill("user-2");
|
||||
expect(result).toEqual({ enqueued: false, error: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* v1.4.39 W-MOOD — unit tests for the persistent mood-rollup
|
||||
* populator.
|
||||
*
|
||||
* `prisma` is mocked at the module level so we can pin:
|
||||
* - the DAY-bucket sync recompute writes through the upsert path,
|
||||
* - `recomputeMoodBucketsForEntry` is idempotent under re-run,
|
||||
* - `enqueueMoodRollupRecompute` calls `boss.send` with the
|
||||
* documented queue + singleton-key shape, and is a silent no-op
|
||||
* when no boss is attached,
|
||||
* - `ensureUserMoodRollupsFresh` short-circuits on fresh data and
|
||||
* dedups concurrent callers onto one in-flight promise,
|
||||
* - the boot-time backfill discovers users with mood entries but
|
||||
* no rollup coverage and enqueues one job per uncovered user.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// vi.mock factories run BEFORE module-level statements due to hoisting,
|
||||
// so the mock fns must live inside the factory closure. We re-export
|
||||
// them through `vi.hoisted` so the test body can reach them after.
|
||||
const mocks = vi.hoisted(() => ({
|
||||
queryRaw: vi.fn(),
|
||||
queryRawUnsafe: vi.fn(),
|
||||
executeRaw: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findFirstMoodEntry: vi.fn(),
|
||||
bossSend: vi.fn(),
|
||||
getGlobalBossMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
$queryRaw: mocks.queryRaw,
|
||||
$queryRawUnsafe: mocks.queryRawUnsafe,
|
||||
$executeRaw: mocks.executeRaw,
|
||||
moodEntryRollup: {
|
||||
upsert: mocks.upsert,
|
||||
deleteMany: mocks.deleteMany,
|
||||
findFirst: mocks.findFirst,
|
||||
findMany: mocks.findMany,
|
||||
},
|
||||
moodEntry: {
|
||||
findFirst: mocks.findFirstMoodEntry,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: () => mocks.getGlobalBossMock(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const {
|
||||
queryRaw,
|
||||
queryRawUnsafe,
|
||||
executeRaw,
|
||||
upsert,
|
||||
deleteMany,
|
||||
findFirst,
|
||||
findFirstMoodEntry,
|
||||
bossSend,
|
||||
getGlobalBossMock,
|
||||
} = mocks;
|
||||
// `findMany` is wired in so indirect lookups don't throw; the
|
||||
// populator tests below exercise the `$queryRawUnsafe` + upsert
|
||||
// paths so the reference stays unused inside this file.
|
||||
void mocks.findMany;
|
||||
void queryRaw;
|
||||
|
||||
import {
|
||||
MOOD_ROLLUP_FULL_BACKFILL_QUEUE,
|
||||
MOOD_ROLLUP_RECOMPUTE_QUEUE,
|
||||
_resetEnsureUserMoodRollupsFreshInFlightForTests,
|
||||
enqueueBootTimeMoodRollupBackfill,
|
||||
enqueueMoodRollupRecompute,
|
||||
ensureUserMoodRollupsFresh,
|
||||
recomputeMoodBucketsForEntry,
|
||||
} from "../mood-rollups";
|
||||
|
||||
beforeEach(() => {
|
||||
queryRaw.mockReset();
|
||||
queryRawUnsafe.mockReset();
|
||||
executeRaw.mockReset();
|
||||
executeRaw.mockResolvedValue(0);
|
||||
upsert.mockReset();
|
||||
deleteMany.mockReset();
|
||||
findFirst.mockReset();
|
||||
mocks.findMany.mockReset();
|
||||
findFirstMoodEntry.mockReset();
|
||||
bossSend.mockReset();
|
||||
getGlobalBossMock.mockReset();
|
||||
// v1.4.39 — clear the per-userId in-flight map so a previous
|
||||
// test's resolved promise does not short-circuit the next test.
|
||||
_resetEnsureUserMoodRollupsFreshInFlightForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("recomputeMoodBucketsForEntry", () => {
|
||||
function bindValues(call: unknown[]): unknown[] {
|
||||
return call.slice(1);
|
||||
}
|
||||
|
||||
it("runs the atomic DAY upsert + DELETE pair and fires WEEK/MONTH/YEAR enqueues", async () => {
|
||||
// QA F-H-02 (v1.4.39): the DAY pass is now a single atomic SQL
|
||||
// statement (INSERT … SELECT … ON CONFLICT) plus a paired DELETE
|
||||
// that gates on NOT EXISTS. No JS-side aggregate, no per-row
|
||||
// upsert through the Prisma client.
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
bossSend.mockResolvedValue("job-id");
|
||||
|
||||
await recomputeMoodBucketsForEntry(
|
||||
"user-1",
|
||||
new Date("2026-05-10T14:30:00.000Z"),
|
||||
);
|
||||
|
||||
expect(executeRaw).toHaveBeenCalledTimes(2);
|
||||
const insertBinds = bindValues(executeRaw.mock.calls[0]);
|
||||
expect(insertBinds).toContain("user-1");
|
||||
// bucket_start is bound twice (INSERT row + INSERT SELECT predicate
|
||||
// are both inside the same CTE shape via the same parameter).
|
||||
const dayStart = new Date("2026-05-10T00:00:00.000Z");
|
||||
expect(
|
||||
(insertBinds.filter((b) => b instanceof Date) as Date[]).some(
|
||||
(d) => d.getTime() === dayStart.getTime(),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// QA F-H-03 — the enqueue is fire-and-forget; the response path
|
||||
// never awaits the boss.send call. Wait a tick for the
|
||||
// floating promise to flush before asserting.
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
expect(bossSend).toHaveBeenCalledTimes(3);
|
||||
for (const call of bossSend.mock.calls) {
|
||||
expect(call[0]).toBe(MOOD_ROLLUP_RECOMPUTE_QUEUE);
|
||||
expect(call[1].userId).toBe("user-1");
|
||||
expect(["WEEK", "MONTH", "YEAR"]).toContain(call[1].granularity);
|
||||
}
|
||||
});
|
||||
|
||||
it("issues the paired DELETE so a fully-cleared day removes its row", async () => {
|
||||
getGlobalBossMock.mockReturnValue(null);
|
||||
|
||||
await recomputeMoodBucketsForEntry(
|
||||
"user-1",
|
||||
new Date("2026-05-10T14:30:00.000Z"),
|
||||
);
|
||||
|
||||
// The DELETE always fires; its NOT EXISTS predicate matches zero
|
||||
// rows on the populated-day branch and matches the row on the
|
||||
// emptied-day branch. We can only assert the second statement
|
||||
// fired here.
|
||||
expect(executeRaw).toHaveBeenCalledTimes(2);
|
||||
expect(deleteMany).not.toHaveBeenCalled();
|
||||
expect(upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is idempotent under re-run for the same (user, day)", async () => {
|
||||
getGlobalBossMock.mockReturnValue(null);
|
||||
|
||||
await recomputeMoodBucketsForEntry(
|
||||
"user-1",
|
||||
new Date("2026-05-10T14:30:00.000Z"),
|
||||
);
|
||||
await recomputeMoodBucketsForEntry(
|
||||
"user-1",
|
||||
new Date("2026-05-10T14:30:00.000Z"),
|
||||
);
|
||||
|
||||
expect(executeRaw).toHaveBeenCalledTimes(4);
|
||||
// Bind values for the INSERT statement match across both runs.
|
||||
expect(bindValues(executeRaw.mock.calls[0])).toEqual(
|
||||
bindValues(executeRaw.mock.calls[2]),
|
||||
);
|
||||
// Bind values for the DELETE statement match across both runs.
|
||||
expect(bindValues(executeRaw.mock.calls[1])).toEqual(
|
||||
bindValues(executeRaw.mock.calls[3]),
|
||||
);
|
||||
});
|
||||
|
||||
it("commits the strictest aggregate under concurrent recompute calls", async () => {
|
||||
// QA F-H-02 race pin: two concurrent recomputes for the same
|
||||
// (user, day) must both go through the atomic SQL path so each
|
||||
// statement re-aggregates a snapshot taken after the prior commit
|
||||
// released its row lock.
|
||||
getGlobalBossMock.mockReturnValue(null);
|
||||
|
||||
await Promise.all([
|
||||
recomputeMoodBucketsForEntry(
|
||||
"user-1",
|
||||
new Date("2026-05-10T14:30:00.000Z"),
|
||||
),
|
||||
recomputeMoodBucketsForEntry(
|
||||
"user-1",
|
||||
new Date("2026-05-10T14:30:00.000Z"),
|
||||
),
|
||||
]);
|
||||
// 2 callers × 2 statements (INSERT + DELETE) each.
|
||||
expect(executeRaw).toHaveBeenCalledTimes(4);
|
||||
// No legacy JS-side aggregate ($queryRawUnsafe) fired on the DAY
|
||||
// pass; no Prisma-API upsert / deleteMany were issued either.
|
||||
expect(queryRawUnsafe).not.toHaveBeenCalled();
|
||||
expect(upsert).not.toHaveBeenCalled();
|
||||
expect(deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not await the WEEK/MONTH/YEAR enqueue on the write-path response", async () => {
|
||||
// QA F-H-03 (v1.4.39): the outer Promise.all is fire-and-forget so
|
||||
// a slow pg-boss send never serialises the user's mood-write
|
||||
// response. Pin that the helper resolves before the enqueue
|
||||
// promise settles.
|
||||
let resolveEnqueue: (() => void) | null = null;
|
||||
const enqueueGate = new Promise<string>((resolve) => {
|
||||
resolveEnqueue = () => resolve("job-late");
|
||||
});
|
||||
getGlobalBossMock.mockReturnValue({ send: () => enqueueGate });
|
||||
|
||||
await recomputeMoodBucketsForEntry(
|
||||
"user-1",
|
||||
new Date("2026-05-10T14:30:00.000Z"),
|
||||
);
|
||||
|
||||
// The helper has resolved even though the boss.send promise is
|
||||
// still pending. Resolve it after the assertion so the test does
|
||||
// not leak a pending promise across the suite.
|
||||
resolveEnqueue!();
|
||||
await enqueueGate;
|
||||
});
|
||||
});
|
||||
|
||||
describe("enqueueMoodRollupRecompute", () => {
|
||||
it("is a silent no-op when no boss is attached", async () => {
|
||||
getGlobalBossMock.mockReturnValue(null);
|
||||
await enqueueMoodRollupRecompute({
|
||||
userId: "user-1",
|
||||
granularity: "WEEK",
|
||||
from: new Date("2026-05-04T00:00:00.000Z"),
|
||||
to: new Date("2026-05-11T00:00:00.000Z"),
|
||||
});
|
||||
expect(bossSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends with the documented queue + singleton-key shape", async () => {
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
bossSend.mockResolvedValue("job-1");
|
||||
const from = new Date("2026-05-04T00:00:00.000Z");
|
||||
await enqueueMoodRollupRecompute({
|
||||
userId: "user-9",
|
||||
granularity: "WEEK",
|
||||
from,
|
||||
to: new Date("2026-05-11T00:00:00.000Z"),
|
||||
});
|
||||
expect(bossSend).toHaveBeenCalledTimes(1);
|
||||
const [queue, payload, opts] = bossSend.mock.calls[0];
|
||||
expect(queue).toBe(MOOD_ROLLUP_RECOMPUTE_QUEUE);
|
||||
expect(payload.userId).toBe("user-9");
|
||||
expect(payload.granularity).toBe("WEEK");
|
||||
expect(payload.from).toBe(from.toISOString());
|
||||
expect(opts.singletonKey).toBe(`user-9|WEEK|${from.toISOString()}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureUserMoodRollupsFresh", () => {
|
||||
it("is a no-op when the user has no mood entries", async () => {
|
||||
findFirst.mockResolvedValueOnce(null);
|
||||
findFirstMoodEntry.mockResolvedValueOnce(null);
|
||||
const result = await ensureUserMoodRollupsFresh("user-1");
|
||||
expect(result.recomputed).toBe(false);
|
||||
expect(queryRawUnsafe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is a no-op when the rollup is already ahead of the newest mood entry", async () => {
|
||||
const rollupAt = new Date("2026-05-10T12:00:00.000Z");
|
||||
const entryAt = new Date("2026-05-10T11:00:00.000Z");
|
||||
findFirst.mockResolvedValueOnce({ computedAt: rollupAt });
|
||||
findFirstMoodEntry.mockResolvedValueOnce({
|
||||
updatedAt: entryAt,
|
||||
moodLoggedAt: entryAt,
|
||||
});
|
||||
const result = await ensureUserMoodRollupsFresh("user-1");
|
||||
expect(result.recomputed).toBe(false);
|
||||
expect(queryRawUnsafe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("triggers a DAY-window recompute when stale", async () => {
|
||||
const rollupAt = new Date("2026-05-10T10:00:00.000Z");
|
||||
const entryAt = new Date("2026-05-10T12:00:00.000Z");
|
||||
findFirst.mockResolvedValueOnce({ computedAt: rollupAt });
|
||||
findFirstMoodEntry.mockResolvedValueOnce({
|
||||
updatedAt: entryAt,
|
||||
moodLoggedAt: entryAt,
|
||||
});
|
||||
queryRawUnsafe.mockResolvedValueOnce([]);
|
||||
const result = await ensureUserMoodRollupsFresh("user-1");
|
||||
expect(result.recomputed).toBe(true);
|
||||
expect(queryRawUnsafe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("swallows populator errors so the read path never fails", async () => {
|
||||
findFirst.mockRejectedValueOnce(new Error("pool exhausted"));
|
||||
findFirstMoodEntry.mockRejectedValueOnce(new Error("pool exhausted"));
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const result = await ensureUserMoodRollupsFresh("user-1");
|
||||
expect(result.recomputed).toBe(false);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("dedups concurrent callers for the same userId onto one in-flight promise", async () => {
|
||||
const rollupAt = new Date("2026-05-10T10:00:00.000Z");
|
||||
const entryAt = new Date("2026-05-10T12:00:00.000Z");
|
||||
findFirst.mockResolvedValue({ computedAt: rollupAt });
|
||||
findFirstMoodEntry.mockResolvedValue({
|
||||
updatedAt: entryAt,
|
||||
moodLoggedAt: entryAt,
|
||||
});
|
||||
queryRawUnsafe.mockResolvedValue([]);
|
||||
|
||||
const [a, b, c] = await Promise.all([
|
||||
ensureUserMoodRollupsFresh("user-1"),
|
||||
ensureUserMoodRollupsFresh("user-1"),
|
||||
ensureUserMoodRollupsFresh("user-1"),
|
||||
]);
|
||||
expect(a.recomputed).toBe(true);
|
||||
expect(b.recomputed).toBe(true);
|
||||
expect(c.recomputed).toBe(true);
|
||||
expect(queryRawUnsafe).toHaveBeenCalledTimes(1);
|
||||
expect(findFirst).toHaveBeenCalledTimes(1);
|
||||
expect(findFirstMoodEntry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("annotates the failure when the inner recompute throws", async () => {
|
||||
const rollupAt = new Date("2026-05-10T10:00:00.000Z");
|
||||
const entryAt = new Date("2026-05-10T12:00:00.000Z");
|
||||
findFirst.mockResolvedValueOnce({ computedAt: rollupAt });
|
||||
findFirstMoodEntry.mockResolvedValueOnce({
|
||||
updatedAt: entryAt,
|
||||
moodLoggedAt: entryAt,
|
||||
});
|
||||
queryRawUnsafe.mockRejectedValueOnce(new Error("deadlock detected"));
|
||||
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const result = await ensureUserMoodRollupsFresh("user-1");
|
||||
|
||||
expect(result.recomputed).toBe(false);
|
||||
expect(annotate).toHaveBeenCalledWith({
|
||||
meta: {
|
||||
mood_rollup_refresh_failed: true,
|
||||
mood_rollup_refresh_error: "deadlock detected",
|
||||
},
|
||||
});
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("enqueueBootTimeMoodRollupBackfill", () => {
|
||||
it("is a silent no-op when no boss is attached", async () => {
|
||||
getGlobalBossMock.mockReturnValue(null);
|
||||
const result = await enqueueBootTimeMoodRollupBackfill();
|
||||
expect(result).toEqual({ enqueued: 0, skipped: 0, error: null });
|
||||
expect(queryRaw).not.toHaveBeenCalled();
|
||||
expect(bossSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enqueues one full-fold job per user with mood entries but no rollups", async () => {
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
queryRaw.mockResolvedValueOnce([
|
||||
{ id: "user-a" },
|
||||
{ id: "user-b" },
|
||||
{ id: "user-c" },
|
||||
]);
|
||||
bossSend
|
||||
.mockResolvedValueOnce("job-a")
|
||||
.mockResolvedValueOnce("job-b")
|
||||
.mockResolvedValueOnce("job-c");
|
||||
|
||||
const result = await enqueueBootTimeMoodRollupBackfill();
|
||||
|
||||
expect(result).toEqual({ enqueued: 3, skipped: 0, error: null });
|
||||
expect(bossSend).toHaveBeenCalledTimes(3);
|
||||
for (const call of bossSend.mock.calls) {
|
||||
expect(call[0]).toBe(MOOD_ROLLUP_FULL_BACKFILL_QUEUE);
|
||||
}
|
||||
expect(bossSend.mock.calls[0][2].singletonKey).toBe(
|
||||
"mood-boot-backfill|user-a",
|
||||
);
|
||||
expect(bossSend.mock.calls[1][2].singletonKey).toBe(
|
||||
"mood-boot-backfill|user-b",
|
||||
);
|
||||
expect(bossSend.mock.calls[2][2].singletonKey).toBe(
|
||||
"mood-boot-backfill|user-c",
|
||||
);
|
||||
});
|
||||
|
||||
it("counts a `boss.send` returning null as 'skipped' (singleton coalesce)", async () => {
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
queryRaw.mockResolvedValueOnce([{ id: "user-a" }, { id: "user-b" }]);
|
||||
bossSend.mockResolvedValueOnce(null).mockResolvedValueOnce("job-b");
|
||||
|
||||
const result = await enqueueBootTimeMoodRollupBackfill();
|
||||
|
||||
expect(result).toEqual({ enqueued: 1, skipped: 1, error: null });
|
||||
});
|
||||
|
||||
it("returns the error message when the discovery query throws", async () => {
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
queryRaw.mockRejectedValueOnce(new Error("pool exhausted"));
|
||||
|
||||
const result = await enqueueBootTimeMoodRollupBackfill();
|
||||
|
||||
expect(result.enqueued).toBe(0);
|
||||
expect(result.error).toBe("pool exhausted");
|
||||
expect(bossSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns { enqueued: 0 } when no users need backfill", async () => {
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
queryRaw.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await enqueueBootTimeMoodRollupBackfill();
|
||||
|
||||
expect(result).toEqual({ enqueued: 0, skipped: 0, error: null });
|
||||
expect(bossSend).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* v1.4.36 — per-type rollup coverage probe.
|
||||
*
|
||||
* The v1.4.35/v1.4.36 read-swap gates on whether the DAY rollup table
|
||||
* carries buckets for a user. The first iteration checked a single
|
||||
* `COUNT(*) > 0` against `measurement_rollups`. That returns `true` as
|
||||
* soon as *any* type has buckets, which breaks the "first measurement
|
||||
* for a brand-new type" case: the per-write hook upserts one DAY bucket
|
||||
* for the new type → the global probe stays >0 → the read path picks
|
||||
* the bucket-derived branch for *every* type → the brand-new type's
|
||||
* narrow-aggregate windowed columns come back fine but the seed loop
|
||||
* only iterates bucket-bearing types, so a type that has live
|
||||
* measurements but no buckets falls out of the response entirely.
|
||||
*
|
||||
* The probe below joins `measurements`-distinct-types against
|
||||
* `measurement_rollups` so the caller learns, per type, whether the
|
||||
* bucket table covers it. Types with `hasBuckets=false` fall back to
|
||||
* the live aggregator branch; types with `hasBuckets=true` ride the
|
||||
* cheap composed branch. The result is a single round-trip indexed
|
||||
* read regardless of how many types the user has logged.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Per-type coverage map. `true` means at least one DAY rollup row
|
||||
* exists for this `(user, type)` pair; the caller can safely compose
|
||||
* `count / min / max / mean` from the bucket table for this type.
|
||||
* `false` means the bucket table is empty for this type and the caller
|
||||
* must fall back to the live aggregate.
|
||||
*
|
||||
* Types the user has never logged are absent from the map — callers
|
||||
* never need to ask about a type with zero measurements.
|
||||
*/
|
||||
export type RollupCoverageMap = Map<string, boolean>;
|
||||
|
||||
/**
|
||||
* Probe DAY-bucket coverage for every type the user has measurements
|
||||
* for. The join is anchored on the smaller `DISTINCT type FROM
|
||||
* measurements` set so the planner picks the per-type
|
||||
* `(user_id, type, measured_at)` index path; the LEFT JOIN onto the
|
||||
* rollup table uses the `(user_id, type, granularity, bucket_start)`
|
||||
* composite primary key. A `COUNT > 0` per partition keeps the result
|
||||
* shape stable even when a type has zero buckets.
|
||||
*/
|
||||
export async function probeRollupCoverage(
|
||||
userId: string,
|
||||
): Promise<RollupCoverageMap> {
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{ type: string; has_buckets: boolean }>
|
||||
>`
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
COUNT(r.*) > 0 AS has_buckets
|
||||
FROM (
|
||||
SELECT DISTINCT "type"
|
||||
FROM measurements
|
||||
WHERE user_id = ${userId}
|
||||
AND "deleted_at" IS NULL
|
||||
) m
|
||||
LEFT JOIN measurement_rollups r
|
||||
ON r.user_id = ${userId}
|
||||
AND r."type" = m."type"
|
||||
AND r.granularity = 'DAY'
|
||||
GROUP BY m."type"
|
||||
`;
|
||||
const coverage: RollupCoverageMap = new Map();
|
||||
for (const row of rows) {
|
||||
coverage.set(row.type, Boolean(row.has_buckets));
|
||||
}
|
||||
return coverage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience — `true` when the user has at least one measurement and
|
||||
* every type with measurements also has DAY-bucket coverage. The read
|
||||
* path can skip the live aggregate entirely.
|
||||
*/
|
||||
export function isFullyCovered(coverage: RollupCoverageMap): boolean {
|
||||
if (coverage.size === 0) return false;
|
||||
for (const hasBuckets of coverage.values()) {
|
||||
if (!hasBuckets) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* v1.4.39 W-SUM — read helpers for cumulative-metric daily sums.
|
||||
*
|
||||
* The v1.4.36 read path consumes the rollup table for spot-metric means
|
||||
* via `rollup-read.ts`. Cumulative HK types (steps, flights, distance,
|
||||
* daylight, active-energy) need the per-day SUM rather than the MEAN —
|
||||
* every row is a partial-day slice from HealthKit, so MEAN understates
|
||||
* the daily total by the per-bucket sample count.
|
||||
*
|
||||
* The v1.4.39 schema scaffolding (migration 0072) added `sum_value` to
|
||||
* `measurement_rollups`. The writer in `rollups.ts` populates it on
|
||||
* every fold (including for non-cumulative types — the cost is one
|
||||
* extra `SUM` in the existing aggregator). The legacy-NULL backfill
|
||||
* runs through `enqueueBootTimeRollupBackfill` so existing rows
|
||||
* converge on the next worker boot.
|
||||
*
|
||||
* Callers fall back to `mean * count` when `sumValue` is null. The
|
||||
* shortcut is mathematically exact for a single source per day:
|
||||
*
|
||||
* sum = mean × count = (Σvalue / count) × count = Σvalue
|
||||
*
|
||||
* Multiple sources for the same `(user, type, day)` mix into the same
|
||||
* bucket today — the source-priority resolution that
|
||||
* `pickCanonicalSourceRows` runs in JS happens AFTER the rollup
|
||||
* aggregate is built. The W8c per-source rollup deferred to v1.5 fixes
|
||||
* that; until then, the cumulative read paths trust the bucket sum as
|
||||
* the canonical daily total and the iOS drain (which collapses per
|
||||
* source via `dailyStatsExternalId`) keeps Marc's tenant on the
|
||||
* single-source-per-day path.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
|
||||
|
||||
/**
|
||||
* Cumulative measurement types the rollup `sum_value` column serves.
|
||||
* Re-exported from the canonical Apple Health mapping so the
|
||||
* consumer-side gate stays in one place.
|
||||
*
|
||||
* Audit anchor: `.planning/round-v1438-perf-analysis.md` §3
|
||||
* "Cumulative daily sums" + §5 P3.
|
||||
*/
|
||||
export type CumulativeType =
|
||||
| "ACTIVITY_STEPS"
|
||||
| "ACTIVE_ENERGY_BURNED"
|
||||
| "FLIGHTS_CLIMBED"
|
||||
| "WALKING_RUNNING_DISTANCE"
|
||||
| "TIME_IN_DAYLIGHT";
|
||||
|
||||
/**
|
||||
* `true` when `type` is one of the cumulative HK types whose daily
|
||||
* total the rollup `sum_value` column carries.
|
||||
*/
|
||||
export function isCumulativeType(type: MeasurementType): type is CumulativeType {
|
||||
return CUMULATIVE_HK_TYPES.has(type);
|
||||
}
|
||||
|
||||
/** One DAY-bucket row read from `measurement_rollups`. */
|
||||
export interface CumulativeDaySumRow {
|
||||
bucketStart: Date;
|
||||
/**
|
||||
* Per-bucket SUM. `null` only when the bucket pre-dates the v1.4.39
|
||||
* writer change AND the boot-time backfill has not yet converged on
|
||||
* this user. Callers fall back to `mean * count` for the null case.
|
||||
*/
|
||||
sumValue: number | null;
|
||||
count: number;
|
||||
mean: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every DAY bucket for `(userId, type)` since `since`. Returns
|
||||
* rows ascending by `bucketStart`. Pre-v1.4.39 rows surface here with
|
||||
* `sumValue = null`; the caller decides whether to fall back to
|
||||
* `mean * count` or skip them.
|
||||
*
|
||||
* Bounded to the cumulative-type set so a misuse (passing a spot
|
||||
* metric) is a type error rather than a silent SUM on the wrong
|
||||
* column. The query is a plain `findMany` against the
|
||||
* `(userId, type, granularity, bucketStart)` composite primary key —
|
||||
* single round-trip, fully indexed.
|
||||
*/
|
||||
export async function readCumulativeDaySums(
|
||||
userId: string,
|
||||
type: CumulativeType,
|
||||
since: Date,
|
||||
): Promise<CumulativeDaySumRow[]> {
|
||||
const rows = await prisma.measurementRollup.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: type as MeasurementType,
|
||||
granularity: "DAY",
|
||||
bucketStart: { gte: since },
|
||||
},
|
||||
orderBy: { bucketStart: "asc" },
|
||||
select: {
|
||||
bucketStart: true,
|
||||
sumValue: true,
|
||||
count: true,
|
||||
mean: true,
|
||||
},
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
bucketStart: r.bucketStart,
|
||||
sumValue: r.sumValue,
|
||||
count: r.count,
|
||||
mean: r.mean,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a per-bucket cumulative total. Reads `sumValue` when the
|
||||
* v1.4.39 writer has populated it; falls back to `mean * count` for
|
||||
* legacy NULL rows so the chart never paints a hole during the boot-
|
||||
* backfill convergence window.
|
||||
*/
|
||||
export function resolveBucketSum(row: CumulativeDaySumRow): number {
|
||||
if (row.sumValue !== null) return row.sumValue;
|
||||
return row.mean * row.count;
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* v1.4.39 W-WMY — WEEK / MONTH / YEAR rollup readers.
|
||||
*
|
||||
* Background — the read-side gap
|
||||
* ------------------------------
|
||||
* Every measurement write fans out via `recomputeBucketsForMeasurement`
|
||||
* into a synchronous DAY upsert plus pg-boss-queued WEEK / MONTH / YEAR
|
||||
* recomputes. The boot-time backfill (`enqueueBootTimeRollupBackfill`)
|
||||
* mints the full WEEK / MONTH / YEAR history once per uncovered user.
|
||||
*
|
||||
* As of v1.4.38 the only reader that consults `measurement_rollups`
|
||||
* (`rollup-read.ts`, plus the per-fast-path branches in
|
||||
* `summaries-slice`, `comprehensive-aggregator`, etc.) caps at DAY and
|
||||
* a trailing 90-day window. The WEEK / MONTH / YEAR buckets sit in
|
||||
* Postgres as pure write amplification — populated every write,
|
||||
* surfaced nowhere on read. The v1.4.38 perf audit
|
||||
* (`.planning/round-v1438-perf-analysis.md` §2 + §5 P6) calls this out
|
||||
* as the largest unused investment in the rollup tier.
|
||||
*
|
||||
* What this module adds
|
||||
* ---------------------
|
||||
* Helpers that read the trailing N-bucket window for a single
|
||||
* `(userId, type)` pair at a chosen granularity, plus an auto-router
|
||||
* that picks the largest granularity that still resolves the requested
|
||||
* window. The shape returned is identical to `readRollupBuckets` so
|
||||
* callers can interleave WEEK / MONTH / YEAR rows with DAY rows
|
||||
* downstream without branching on the source granularity.
|
||||
*
|
||||
* Compositional contract
|
||||
* ----------------------
|
||||
* `count / min / max / mean / sumValue` are linearly composable across
|
||||
* any granularity — aggregating these stats over WEEK buckets returns
|
||||
* the same numbers as aggregating the underlying DAY buckets or the
|
||||
* raw measurements. `sd / slope / r2` are NOT linearly composable; the
|
||||
* routed callers either fall back to live SQL for those or accept the
|
||||
* per-bucket stat as-is. The auto-router is a granularity selector
|
||||
* only — it does not attempt to reconstruct slope across coarser
|
||||
* buckets.
|
||||
*
|
||||
* Coverage-miss semantics
|
||||
* -----------------------
|
||||
* Each reader returns `null` when the requested granularity yields
|
||||
* zero rows for `(userId, type, since)`. The caller decides whether
|
||||
* that is a real "no data" case (the user has not logged this type in
|
||||
* the window) or a coverage miss the boot-backfill / worker has not
|
||||
* caught up on. The default policy is to fall back to a finer
|
||||
* granularity (MONTH → WEEK → DAY) before giving up — see
|
||||
* `readBestGranularityRollups`.
|
||||
*
|
||||
* Scope vs. `rollup-read.ts`
|
||||
* --------------------------
|
||||
* `rollup-read.ts` carries the existing DAY-only aggregator. This
|
||||
* module is deliberately separate so the WEEK / MONTH / YEAR wiring
|
||||
* is reviewable in isolation and so the v1.4.39 W-SUM agent's
|
||||
* concurrent edits to the writer side don't collide with the reader
|
||||
* additions.
|
||||
*/
|
||||
import type {
|
||||
MeasurementType,
|
||||
RollupGranularity,
|
||||
} from "@/generated/prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Normalised bucket row returned by every WMY reader. Mirrors the
|
||||
* shape `readRollupBuckets` returns plus the `sumValue` column the
|
||||
* v1.4.39 W-SUM agent added to `MeasurementRollup`. `sumValue` is
|
||||
* `null` for rows that pre-date the column's introduction; the boot
|
||||
* backfill fills it in alongside the other stats.
|
||||
*/
|
||||
export interface RollupBucketRow {
|
||||
bucketStart: Date;
|
||||
count: number;
|
||||
mean: number;
|
||||
sd: number | null;
|
||||
slope: number | null;
|
||||
r2: number | null;
|
||||
sumValue: number | null;
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-granularity "this is the smallest window the granularity can
|
||||
* meaningfully resolve" floor. Used by `readBestGranularityRollups`
|
||||
* to route a requested window into the coarsest tier that still
|
||||
* carries enough buckets for the caller to do something useful.
|
||||
*
|
||||
* - DAY → any window — 90 daily buckets for a 90-day window
|
||||
* is already trivially cheap and the trend resolution
|
||||
* is canonical
|
||||
* - WEEK → > 90 days (~13 weekly buckets for a quarter)
|
||||
* - MONTH → > 180 days (~6 monthly buckets, enough trend signal
|
||||
* for a half-year view; smaller windows benefit more
|
||||
* from DAY-bucket granularity than coarse-grained
|
||||
* averaging)
|
||||
* - YEAR → > 730 days (≥ 2 yearly buckets — anything below 2 y
|
||||
* collapses to one bucket and carries no slope signal)
|
||||
*
|
||||
* The pinned routing the v1.5 multi-year trend card relies on:
|
||||
* 90 d → DAY (90 buckets)
|
||||
* 365 d → MONTH (12 buckets)
|
||||
* 1095 d → YEAR (3 buckets)
|
||||
*
|
||||
* The floors are conservative on purpose — coarser tiers only
|
||||
* activate when the row-count savings actually justify trading the
|
||||
* finer trend resolution.
|
||||
*/
|
||||
const GRANULARITY_FLOORS: Array<{
|
||||
granularity: RollupGranularity;
|
||||
minWindowDays: number;
|
||||
}> = [
|
||||
{ granularity: "YEAR", minWindowDays: 731 },
|
||||
{ granularity: "MONTH", minWindowDays: 181 },
|
||||
{ granularity: "WEEK", minWindowDays: 91 },
|
||||
{ granularity: "DAY", minWindowDays: 0 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Read the trailing WEEK buckets for `(userId, type)` since `since`.
|
||||
* Returns `null` when no buckets exist in the window — the caller
|
||||
* decides whether to fall back to a finer granularity or to live SQL.
|
||||
*/
|
||||
export async function readWeekRollups(
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
since: Date,
|
||||
): Promise<RollupBucketRow[] | null> {
|
||||
return readGranularity(userId, type, "WEEK", since);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the trailing MONTH buckets for `(userId, type)` since `since`.
|
||||
* Returns `null` on coverage miss.
|
||||
*/
|
||||
export async function readMonthRollups(
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
since: Date,
|
||||
): Promise<RollupBucketRow[] | null> {
|
||||
return readGranularity(userId, type, "MONTH", since);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the trailing YEAR buckets for `(userId, type)` since `since`.
|
||||
* Returns `null` on coverage miss.
|
||||
*/
|
||||
export async function readYearRollups(
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
since: Date,
|
||||
): Promise<RollupBucketRow[] | null> {
|
||||
return readGranularity(userId, type, "YEAR", since);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the largest granularity that can still resolve the requested
|
||||
* `windowDays` window — WEEK for >14 d, MONTH for >62 d, YEAR for
|
||||
* >730 d. Falls back to a finer granularity on coverage miss so a
|
||||
* user with WEEK / MONTH coverage but no YEAR buckets (e.g. a tenant
|
||||
* who joined less than two years ago) still gets a usable trend
|
||||
* series.
|
||||
*
|
||||
* Returns the granularity the helper resolved against plus the row
|
||||
* shape `readWeekRollups` / `readMonthRollups` / `readYearRollups`
|
||||
* return. `null` when no granularity yields any buckets in the
|
||||
* window — the caller is expected to short-circuit to "no data" or
|
||||
* to live SQL.
|
||||
*/
|
||||
export async function readBestGranularityRollups(
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
windowDays: number,
|
||||
): Promise<{
|
||||
granularity: RollupGranularity;
|
||||
rows: RollupBucketRow[];
|
||||
} | null> {
|
||||
if (!Number.isFinite(windowDays) || windowDays <= 0) return null;
|
||||
const since = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000);
|
||||
// Walk the floors from coarsest to finest and return the first
|
||||
// granularity whose floor the window clears AND which has coverage
|
||||
// for `(userId, type, since)`. The `if rows == null` fall-through
|
||||
// is what makes the helper resilient to partial coverage.
|
||||
for (const floor of GRANULARITY_FLOORS) {
|
||||
if (windowDays < floor.minWindowDays) continue;
|
||||
const rows = await readGranularity(userId, type, floor.granularity, since);
|
||||
if (rows && rows.length > 0) {
|
||||
return { granularity: floor.granularity, rows };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Linearly compose `count / min / max / mean / sum` across an array
|
||||
* of bucket rows. Mirrors `rollup-read.ts:aggregateBuckets` but
|
||||
* carries `sumValue` so cumulative-metric callers (steps, energy,
|
||||
* distance) can read the window total without re-deriving from
|
||||
* `mean * count`. SD / slope / r2 are intentionally omitted — those
|
||||
* stats do not compose across coarser buckets and the consumers that
|
||||
* need them stay on live SQL.
|
||||
*/
|
||||
export function aggregateWmyBuckets(rows: RollupBucketRow[]): {
|
||||
count: number;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
mean: number | null;
|
||||
sum: number | null;
|
||||
} {
|
||||
if (rows.length === 0) {
|
||||
return { count: 0, min: null, max: null, mean: null, sum: null };
|
||||
}
|
||||
let totalCount = 0;
|
||||
let sumWeighted = 0;
|
||||
let sumCumulative = 0;
|
||||
let sawSum = false;
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const row of rows) {
|
||||
totalCount += row.count;
|
||||
sumWeighted += row.count * row.mean;
|
||||
if (row.sumValue !== null && Number.isFinite(row.sumValue)) {
|
||||
sumCumulative += row.sumValue;
|
||||
sawSum = true;
|
||||
}
|
||||
if (row.minValue < min) min = row.minValue;
|
||||
if (row.maxValue > max) max = row.maxValue;
|
||||
}
|
||||
if (totalCount === 0) {
|
||||
return { count: 0, min: null, max: null, mean: null, sum: null };
|
||||
}
|
||||
return {
|
||||
count: totalCount,
|
||||
min: Number.isFinite(min) ? min : null,
|
||||
max: Number.isFinite(max) ? max : null,
|
||||
mean: sumWeighted / totalCount,
|
||||
sum: sawSum ? sumCumulative : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal — shared `findMany` projection. Returns `null` when the
|
||||
* window has zero rows so the caller can branch on coverage miss
|
||||
* without a separate count round-trip.
|
||||
*/
|
||||
async function readGranularity(
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
granularity: RollupGranularity,
|
||||
since: Date,
|
||||
): Promise<RollupBucketRow[] | null> {
|
||||
// Bounded `findMany`: `(userId, type, granularity, bucketStart)`
|
||||
// is the composite primary key so the planner picks the index path
|
||||
// every time. `bucketStart >= since` is the same shape
|
||||
// `readRollupBuckets` uses; we don't carry an upper bound because
|
||||
// the helpers are always reading "trailing window to now".
|
||||
const rows = await prisma.measurementRollup.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type,
|
||||
granularity,
|
||||
bucketStart: { gte: since },
|
||||
},
|
||||
orderBy: { bucketStart: "asc" },
|
||||
select: {
|
||||
bucketStart: true,
|
||||
count: true,
|
||||
mean: true,
|
||||
sd: true,
|
||||
slope: true,
|
||||
r2: true,
|
||||
sumValue: true,
|
||||
minValue: true,
|
||||
maxValue: true,
|
||||
},
|
||||
});
|
||||
if (rows.length === 0) return null;
|
||||
return rows.map((r) => ({
|
||||
bucketStart: r.bucketStart,
|
||||
count: r.count,
|
||||
mean: r.mean,
|
||||
sd: r.sd,
|
||||
slope: r.slope,
|
||||
r2: r.r2,
|
||||
sumValue: r.sumValue,
|
||||
minValue: r.minValue,
|
||||
maxValue: r.maxValue,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* v1.5.0 — read-side helpers that aggregate the persistent rollup
|
||||
* table into the same `DataSummary` shape the live aggregator
|
||||
* returns. The reader-side surfaces (`summaries-slice`,
|
||||
* `comprehensive-aggregator`, the analytics route) call
|
||||
* `readDataSummariesFromRollups` before falling through to live SQL;
|
||||
* on rollup miss / stale they call `recomputeUserRollups` once to
|
||||
* persist the buckets, then re-read.
|
||||
*
|
||||
* The 90-day window is reconstructed from DAY buckets by:
|
||||
* - sum(count_i) → window count
|
||||
* - min(min_i) → window min
|
||||
* - max(max_i) → window max
|
||||
* - sum(count_i × mean_i) / Σcount_i → window mean (weighted by daily count)
|
||||
*
|
||||
* That re-aggregation is mathematically exact for `count`, `min`,
|
||||
* `max`, and `mean` — they are linearly composable across DAY buckets.
|
||||
* SD / slope / R² are NOT exact: aggregating across DAY buckets is not
|
||||
* the same as the population stats over the raw rows. For those, the
|
||||
* rollup-read path delegates to live SQL so the byte-shape parity with
|
||||
* the v1.4.34.1 / 4.5 aggregator survives.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
|
||||
|
||||
export interface DailyMeanRow {
|
||||
day: Date;
|
||||
count: number;
|
||||
mean: number;
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the trailing DAY buckets for `(userId, type)` over `[from, to)`
|
||||
* and return them as a normalised list.
|
||||
*/
|
||||
export async function readDailyMeans(
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
from: Date,
|
||||
to: Date,
|
||||
): Promise<DailyMeanRow[]> {
|
||||
const rows = await readRollupBuckets(userId, type, "DAY", from, to);
|
||||
return rows.map((r) => ({
|
||||
day: r.bucketStart,
|
||||
count: r.count,
|
||||
mean: r.mean,
|
||||
minValue: r.minValue,
|
||||
maxValue: r.maxValue,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine DAY buckets into the linearly-composable window stats —
|
||||
* `count`, `min`, `max`, `mean`. SD / slope / R² are intentionally
|
||||
* NOT computed here because they don't compose across DAY rollups.
|
||||
*/
|
||||
export function aggregateBuckets(rows: DailyMeanRow[]): {
|
||||
count: number;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
mean: number | null;
|
||||
} {
|
||||
if (rows.length === 0) {
|
||||
return { count: 0, min: null, max: null, mean: null };
|
||||
}
|
||||
let totalCount = 0;
|
||||
let sumWeighted = 0;
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const r of rows) {
|
||||
totalCount += r.count;
|
||||
sumWeighted += r.count * r.mean;
|
||||
if (r.minValue < min) min = r.minValue;
|
||||
if (r.maxValue > max) max = r.maxValue;
|
||||
}
|
||||
if (totalCount === 0) {
|
||||
return { count: 0, min: null, max: null, mean: null };
|
||||
}
|
||||
return {
|
||||
count: totalCount,
|
||||
min: Number.isFinite(min) ? min : null,
|
||||
max: Number.isFinite(max) ? max : null,
|
||||
mean: sumWeighted / totalCount,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,839 @@
|
||||
/**
|
||||
* v1.5.0 — persistent measurement-rollup populator.
|
||||
*
|
||||
* Sits behind the 60-s in-process LRU as the second cache tier
|
||||
* (per `.planning/research/v1434-r-cache-aggregation.md` §9). One row
|
||||
* per `(userId, type, granularity, bucketStart)` in
|
||||
* `measurement_rollups`, carrying the count / min / max / mean / sd /
|
||||
* slope / r2 stats Postgres folds via `STDDEV_POP` / `REGR_SLOPE` /
|
||||
* `REGR_R2`. The stats semantics mirror the v1.4.34.1 comprehensive
|
||||
* aggregator so cached + live aggregates agree byte-for-byte.
|
||||
*
|
||||
* Write hooks:
|
||||
* - The measurement create / update / delete endpoints call
|
||||
* `recomputeBucketsForMeasurement` after their successful commit.
|
||||
* The DAY bucket recompute runs inline (cheap, scoped to a single
|
||||
* `(user, type, day)` tuple) so the next read is correct.
|
||||
* WEEK / MONTH / YEAR recomputes enqueue onto pg-boss for the
|
||||
* worker to fold.
|
||||
* - The Apple Health import worker calls `recomputeUserRollups`
|
||||
* ONCE at the end of the import, scoped to the import's date
|
||||
* range, to avoid per-row write-hook overhead on 100k-row
|
||||
* backfills.
|
||||
*
|
||||
* Reads:
|
||||
* - `readRollupBuckets` returns the persisted rows. The reader
|
||||
* side (`analytics`, `comprehensive`, `summaries-slice`) calls
|
||||
* `isRollupFresh` first to decide whether to fall through to the
|
||||
* live aggregator + persist-on-miss. Cheap heuristic — one
|
||||
* indexed query for the newest measurement's `measuredAt`.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { startOfUtcDay } from "@/lib/tz/start-of-utc-day";
|
||||
import type {
|
||||
MeasurementType,
|
||||
RollupGranularity,
|
||||
} from "@/generated/prisma/client";
|
||||
|
||||
/** All granularities the rollup table tracks. */
|
||||
export const ALL_GRANULARITIES: RollupGranularity[] = [
|
||||
"DAY",
|
||||
"WEEK",
|
||||
"MONTH",
|
||||
"YEAR",
|
||||
];
|
||||
|
||||
/** Granularities that recompute asynchronously via pg-boss. */
|
||||
const ASYNC_GRANULARITIES: RollupGranularity[] = ["WEEK", "MONTH", "YEAR"];
|
||||
|
||||
/** pg-boss queue name. */
|
||||
export const ROLLUP_RECOMPUTE_QUEUE = "rollup-recompute";
|
||||
|
||||
/** Worker concurrency cap — recomputes are read-heavy on Postgres. */
|
||||
export const ROLLUP_RECOMPUTE_CONCURRENCY = 2;
|
||||
|
||||
/**
|
||||
* Payload `boss.send` carries onto the `rollup-recompute` queue. The
|
||||
* worker hands the values straight to `recomputeUserRollups`.
|
||||
*/
|
||||
export interface RollupRecomputePayload {
|
||||
userId: string;
|
||||
type: MeasurementType;
|
||||
granularity: RollupGranularity;
|
||||
/** ISO-8601 string — the worker parses through `new Date(value)`. */
|
||||
from: string;
|
||||
/** ISO-8601 string — the worker parses through `new Date(value)`. */
|
||||
to: string;
|
||||
/** Wall-clock kick-off for debugging only. */
|
||||
enqueuedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.35.1 — boot-time full-fold queue. The write-path hooks keep
|
||||
* the DAY bucket current per measurement, and `ensureUserRollupsFresh`
|
||||
* warm-on-read covers the trailing 90-day DAY window on cold mount,
|
||||
* but neither path folds the deep WEEK / MONTH / YEAR history for an
|
||||
* account that has measurements pre-dating the v1.4.35 deploy. This
|
||||
* queue exists so the worker boot can enqueue one full
|
||||
* `recomputeUserRollups(userId)` per uncovered user — no operator
|
||||
* action needed on self-hosted instances.
|
||||
*/
|
||||
export const ROLLUP_FULL_BACKFILL_QUEUE = "rollup-full-backfill";
|
||||
|
||||
/** Boot-fold worker concurrency. Serial so we don't crowd the pool. */
|
||||
export const ROLLUP_FULL_BACKFILL_CONCURRENCY = 1;
|
||||
|
||||
/** Payload the boot-time backfill helper enqueues. */
|
||||
export interface RollupFullBackfillPayload {
|
||||
userId: string;
|
||||
/** Wall-clock kick-off for debugging only. */
|
||||
enqueuedAt: string;
|
||||
}
|
||||
|
||||
/** Postgres `date_trunc` unit per granularity. */
|
||||
const DATE_TRUNC_UNIT: Record<RollupGranularity, string> = {
|
||||
DAY: "day",
|
||||
WEEK: "week",
|
||||
MONTH: "month",
|
||||
YEAR: "year",
|
||||
};
|
||||
|
||||
/** Raw row shape returned by the aggregate `$queryRaw`. */
|
||||
interface RollupRow {
|
||||
type: string;
|
||||
bucket_start: Date;
|
||||
count: bigint;
|
||||
mean: number | null;
|
||||
min_value: number | null;
|
||||
max_value: number | null;
|
||||
sum_value: number | null;
|
||||
sd: number | null;
|
||||
slope: number | null;
|
||||
r2: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute the rollup buckets for one user. Bounded by the optional
|
||||
* `types` / `granularities` / `from` / `to` filters; defaults cover
|
||||
* every type, every granularity, and the trailing 5 years.
|
||||
*
|
||||
* Idempotent: rows are upserted under the
|
||||
* `(userId, type, granularity, bucketStart)` composite primary key,
|
||||
* so re-running the call is a no-op for unchanged buckets and a
|
||||
* refresh for changed ones.
|
||||
*/
|
||||
export async function recomputeUserRollups(
|
||||
userId: string,
|
||||
opts: {
|
||||
types?: MeasurementType[];
|
||||
granularities?: RollupGranularity[];
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
} = {},
|
||||
): Promise<{ rowsUpserted: number; durationMs: number }> {
|
||||
const startedAt = Date.now();
|
||||
const granularities = opts.granularities ?? ALL_GRANULARITIES;
|
||||
const fiveYearsAgo = new Date(Date.now() - 5 * 365 * 24 * 60 * 60 * 1000);
|
||||
const from = opts.from ?? fiveYearsAgo;
|
||||
const to = opts.to ?? new Date();
|
||||
|
||||
let rowsUpserted = 0;
|
||||
|
||||
for (const granularity of granularities) {
|
||||
const rows = await runRollupAggregate({
|
||||
userId,
|
||||
types: opts.types,
|
||||
granularity,
|
||||
from,
|
||||
to,
|
||||
});
|
||||
if (rows.length === 0) continue;
|
||||
|
||||
rowsUpserted += await persistRollupRows(userId, granularity, rows);
|
||||
}
|
||||
|
||||
return { rowsUpserted, durationMs: Date.now() - startedAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous DAY bucket recompute for the write-path hooks plus an
|
||||
* async enqueue of the WEEK / MONTH / YEAR recomputes. The DAY pass
|
||||
* runs inline so the next read of `comprehensive` / `analytics`
|
||||
* reflects the new row without waiting on pg-boss.
|
||||
*
|
||||
* Scoped to the affected `(userId, type, day)` tuple — cheap, and
|
||||
* avoids invalidating buckets that haven't changed.
|
||||
*/
|
||||
export async function recomputeBucketsForMeasurement(
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
measuredAt: Date,
|
||||
): Promise<void> {
|
||||
// DAY pass — synchronous. Pull the rollup for the affected day
|
||||
// and upsert it. The aggregate is scoped to the single day so the
|
||||
// SQL is bounded even for users with multi-year history.
|
||||
const dayStart = startOfUtcDay(measuredAt);
|
||||
const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);
|
||||
const rows = await runRollupAggregate({
|
||||
userId,
|
||||
types: [type],
|
||||
granularity: "DAY",
|
||||
from: dayStart,
|
||||
to: dayEnd,
|
||||
});
|
||||
if (rows.length === 0) {
|
||||
// No rows in this day after the mutation (e.g. the DELETE path
|
||||
// removed the last reading) — drop the rollup row if it exists.
|
||||
await prisma.measurementRollup.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
type,
|
||||
granularity: "DAY",
|
||||
bucketStart: dayStart,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await persistRollupRows(userId, "DAY", rows);
|
||||
}
|
||||
|
||||
// WEEK / MONTH / YEAR — enqueue. Each granularity covers a range
|
||||
// around the affected day so the worker only re-aggregates the
|
||||
// bucket(s) the write could have changed.
|
||||
//
|
||||
// v1.4.38 — the three `enqueueRollupRecompute` calls are independent
|
||||
// (different singletonKey per granularity inside `enqueueRollupRecompute`)
|
||||
// so fan them out via `Promise.all` instead of sequencing them with
|
||||
// `await`. Halves the post-write critical path on every measurement
|
||||
// create / update / delete; matters on iOS batch imports that fire
|
||||
// hundreds of hooks back-to-back.
|
||||
await Promise.all(
|
||||
ASYNC_GRANULARITIES.map((granularity) => {
|
||||
const { from, to } = bucketSpan(measuredAt, granularity);
|
||||
return enqueueRollupRecompute({
|
||||
userId,
|
||||
type,
|
||||
granularity,
|
||||
from,
|
||||
to,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort pg-boss enqueue. Silent no-op when no boss is attached
|
||||
* (route is running in a context without the worker — typically the
|
||||
* test suite). The reader-side fall-through still produces fresh
|
||||
* data on a cache miss, so missing the enqueue is at-most a one-read
|
||||
* cost rather than a correctness bug.
|
||||
*/
|
||||
export async function enqueueRollupRecompute(input: {
|
||||
userId: string;
|
||||
type: MeasurementType;
|
||||
granularity: RollupGranularity;
|
||||
from: Date;
|
||||
to: Date;
|
||||
}): Promise<void> {
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) return;
|
||||
const payload: RollupRecomputePayload = {
|
||||
userId: input.userId,
|
||||
type: input.type,
|
||||
granularity: input.granularity,
|
||||
from: input.from.toISOString(),
|
||||
to: input.to.toISOString(),
|
||||
enqueuedAt: new Date().toISOString(),
|
||||
};
|
||||
await boss.send(ROLLUP_RECOMPUTE_QUEUE, payload, {
|
||||
retryLimit: 3,
|
||||
retryDelay: 30,
|
||||
retryBackoff: true,
|
||||
// Coalesce duplicate enqueues for the same bucket key — multiple
|
||||
// measurements landing on the same day shouldn't each spawn a
|
||||
// separate worker run. The singleton key uses bucket-start so
|
||||
// two writes inside the same week share one queued job.
|
||||
singletonKey: `${input.userId}|${input.type}|${input.granularity}|${input.from.toISOString()}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read rollup rows for `(userId, type, granularity)` in `[from, to)`.
|
||||
* Returns rows sorted ascending by `bucketStart`.
|
||||
*/
|
||||
export async function readRollupBuckets(
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
granularity: RollupGranularity,
|
||||
from: Date,
|
||||
to: Date,
|
||||
): Promise<
|
||||
Array<{
|
||||
bucketStart: Date;
|
||||
count: number;
|
||||
mean: number;
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
sd: number | null;
|
||||
slope: number | null;
|
||||
r2: number | null;
|
||||
computedAt: Date;
|
||||
}>
|
||||
> {
|
||||
const rows = await prisma.measurementRollup.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type,
|
||||
granularity,
|
||||
bucketStart: { gte: from, lt: to },
|
||||
},
|
||||
orderBy: { bucketStart: "asc" },
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
bucketStart: r.bucketStart,
|
||||
count: r.count,
|
||||
mean: r.mean,
|
||||
minValue: r.minValue,
|
||||
maxValue: r.maxValue,
|
||||
sd: r.sd,
|
||||
slope: r.slope,
|
||||
r2: r.r2,
|
||||
computedAt: r.computedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap freshness heuristic — one indexed query against
|
||||
* `measurements` for the newest row per (user, type). Returns true
|
||||
* when the persisted rollup covers every measurement currently in
|
||||
* the DB, false when there's a newer measurement than the freshest
|
||||
* rollup row (the rollup needs a recompute).
|
||||
*
|
||||
* `null` newest measurement means the user has no rows of this type,
|
||||
* in which case the rollups are trivially fresh (empty == empty).
|
||||
*/
|
||||
export async function isRollupFresh(
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
granularity: RollupGranularity,
|
||||
newestComputedAt: Date | null,
|
||||
): Promise<boolean> {
|
||||
const newest = await prisma.measurement.findFirst({
|
||||
where: { userId, type, deletedAt: null },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { measuredAt: true, updatedAt: true },
|
||||
});
|
||||
if (!newest) {
|
||||
// No measurements at all — empty rollup set is fresh by definition.
|
||||
return true;
|
||||
}
|
||||
if (!newestComputedAt) {
|
||||
// We have measurements but no rollup rows — definitely stale.
|
||||
return false;
|
||||
}
|
||||
// Stale when the latest row was written / updated after the most
|
||||
// recent rollup `computed_at` for this `(user, type, granularity)`.
|
||||
const watermark =
|
||||
newest.updatedAt > newest.measuredAt ? newest.updatedAt : newest.measuredAt;
|
||||
return watermark <= newestComputedAt;
|
||||
}
|
||||
|
||||
// ─── internals ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pull the raw aggregates from Postgres. Groups by
|
||||
* `date_trunc(<granularity>, measured_at)` and folds the stats with
|
||||
* `STDDEV_POP` / `REGR_SLOPE` / `REGR_R2`. The slope X-axis is days
|
||||
* (epoch / 86400) so the slope is in "units per day", matching the
|
||||
* comprehensive-aggregator + summaries-slice convention.
|
||||
*/
|
||||
async function runRollupAggregate(input: {
|
||||
userId: string;
|
||||
types: MeasurementType[] | undefined;
|
||||
granularity: RollupGranularity;
|
||||
from: Date;
|
||||
to: Date;
|
||||
}): Promise<RollupRow[]> {
|
||||
if (input.types && input.types.length === 0) return [];
|
||||
|
||||
// Postgres rejects `date_trunc($1::text, m."measured_at")` in a
|
||||
// GROUP BY because the planner sees the parameterised expression
|
||||
// and can't match the SELECT-list to the GROUP-BY expression. The
|
||||
// unit literal must be inlined. The value comes from a closed enum
|
||||
// (DATE_TRUNC_UNIT) so there's no injection surface — we still
|
||||
// assert against the whitelist before splicing.
|
||||
const truncUnit = DATE_TRUNC_UNIT[input.granularity];
|
||||
if (!["day", "week", "month", "year"].includes(truncUnit)) {
|
||||
throw new Error(`unexpected granularity: ${input.granularity}`);
|
||||
}
|
||||
const dateTrunc = `date_trunc('${truncUnit}', m."measured_at")`;
|
||||
|
||||
if (input.types && input.types.length > 0) {
|
||||
// Build the type-list literal carefully. The enum values come
|
||||
// from `MeasurementType` (Prisma-generated TS enum); we whitelist
|
||||
// them with a strict regex before splicing into SQL.
|
||||
for (const t of input.types) {
|
||||
if (!/^[A-Z_]+$/.test(t)) {
|
||||
throw new Error(`invalid measurement type: ${t}`);
|
||||
}
|
||||
}
|
||||
const typeList = input.types
|
||||
.map((t) => `'${t}'::"measurement_type"`)
|
||||
.join(",");
|
||||
const sql = `
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
${dateTrunc} AS bucket_start,
|
||||
COUNT(*) AS count,
|
||||
AVG(m."value")::double precision AS mean,
|
||||
MIN(m."value")::double precision AS min_value,
|
||||
MAX(m."value")::double precision AS max_value,
|
||||
SUM(m."value")::double precision AS sum_value,
|
||||
STDDEV_POP(m."value")::double precision AS sd,
|
||||
REGR_SLOPE(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
)::double precision AS slope,
|
||||
REGR_R2(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
)::double precision AS r2
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = $1
|
||||
AND m."type" IN (${typeList})
|
||||
AND m."measured_at" >= $2
|
||||
AND m."measured_at" < $3
|
||||
AND m."deleted_at" IS NULL
|
||||
GROUP BY m."type", ${dateTrunc}
|
||||
`;
|
||||
return prisma.$queryRawUnsafe<RollupRow[]>(
|
||||
sql,
|
||||
input.userId,
|
||||
input.from,
|
||||
input.to,
|
||||
);
|
||||
}
|
||||
|
||||
const sql = `
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
${dateTrunc} AS bucket_start,
|
||||
COUNT(*) AS count,
|
||||
AVG(m."value")::double precision AS mean,
|
||||
MIN(m."value")::double precision AS min_value,
|
||||
MAX(m."value")::double precision AS max_value,
|
||||
SUM(m."value")::double precision AS sum_value,
|
||||
STDDEV_POP(m."value")::double precision AS sd,
|
||||
REGR_SLOPE(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
)::double precision AS slope,
|
||||
REGR_R2(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
)::double precision AS r2
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = $1
|
||||
AND m."measured_at" >= $2
|
||||
AND m."measured_at" < $3
|
||||
AND m."deleted_at" IS NULL
|
||||
GROUP BY m."type", ${dateTrunc}
|
||||
`;
|
||||
return prisma.$queryRawUnsafe<RollupRow[]>(
|
||||
sql,
|
||||
input.userId,
|
||||
input.from,
|
||||
input.to,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* UPSERT the aggregate rows into `measurement_rollups`. Returns the
|
||||
* number of rows touched. Uses a single chunked `$executeRaw` insert
|
||||
* with `ON CONFLICT DO UPDATE` so the round-trip count is bounded
|
||||
* regardless of fan-out width.
|
||||
*/
|
||||
async function persistRollupRows(
|
||||
userId: string,
|
||||
granularity: RollupGranularity,
|
||||
rows: RollupRow[],
|
||||
): Promise<number> {
|
||||
if (rows.length === 0) return 0;
|
||||
// Chunk to keep the parameter count below Postgres' 65k cap and
|
||||
// the transaction round-trip count bounded for large backfills.
|
||||
// For the typical hot path (a single DAY recompute = 1 row, a
|
||||
// multi-week WEEK recompute = a few rows) the round-trip count is
|
||||
// negligible.
|
||||
const CHUNK = 500;
|
||||
let touched = 0;
|
||||
for (let i = 0; i < rows.length; i += CHUNK) {
|
||||
const slice = rows.slice(i, i + CHUNK);
|
||||
await prisma.$transaction(
|
||||
slice.map((row) =>
|
||||
prisma.measurementRollup.upsert({
|
||||
where: {
|
||||
userId_type_granularity_bucketStart: {
|
||||
userId,
|
||||
type: row.type as MeasurementType,
|
||||
granularity,
|
||||
bucketStart: row.bucket_start,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
type: row.type as MeasurementType,
|
||||
granularity,
|
||||
bucketStart: row.bucket_start,
|
||||
count: Number(row.count),
|
||||
mean: row.mean ?? 0,
|
||||
minValue: row.min_value ?? 0,
|
||||
maxValue: row.max_value ?? 0,
|
||||
// v1.4.39 W-SUM — populate for every type. Cumulative read
|
||||
// paths (steps, flights, distance, daylight, active-energy)
|
||||
// consume this column directly; spot metrics carry it for
|
||||
// free because the underlying SUM aggregator runs in the
|
||||
// same query as AVG / MIN / MAX.
|
||||
sumValue: row.sum_value ?? null,
|
||||
sd: row.sd,
|
||||
slope: row.slope,
|
||||
r2: row.r2,
|
||||
computedAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
count: Number(row.count),
|
||||
mean: row.mean ?? 0,
|
||||
minValue: row.min_value ?? 0,
|
||||
maxValue: row.max_value ?? 0,
|
||||
sumValue: row.sum_value ?? null,
|
||||
sd: row.sd,
|
||||
slope: row.slope,
|
||||
r2: row.r2,
|
||||
computedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
touched += slice.length;
|
||||
}
|
||||
return touched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Span of the bucket containing `measuredAt` at the given granularity.
|
||||
* Postgres `date_trunc` is the canonical truncator; we mirror its
|
||||
* Monday-anchored ISO week and calendar-month / calendar-year
|
||||
* semantics in JS so the worker re-aggregation queries select the
|
||||
* same set of rows the read-side `date_trunc` would group on.
|
||||
*/
|
||||
function bucketSpan(
|
||||
measuredAt: Date,
|
||||
granularity: RollupGranularity,
|
||||
): { from: Date; to: Date } {
|
||||
switch (granularity) {
|
||||
case "DAY": {
|
||||
const from = startOfUtcDay(measuredAt);
|
||||
return { from, to: new Date(from.getTime() + 24 * 60 * 60 * 1000) };
|
||||
}
|
||||
case "WEEK": {
|
||||
const day = startOfUtcDay(measuredAt);
|
||||
// Postgres ISO week: Monday is day 1. JS getUTCDay(): Sunday=0..Saturday=6.
|
||||
const dayOfWeek = day.getUTCDay(); // 0=Sun
|
||||
const mondayOffset = (dayOfWeek + 6) % 7; // 0 for Mon … 6 for Sun
|
||||
const from = new Date(
|
||||
day.getTime() - mondayOffset * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
return { from, to: new Date(from.getTime() + 7 * 24 * 60 * 60 * 1000) };
|
||||
}
|
||||
case "MONTH": {
|
||||
const from = new Date(
|
||||
Date.UTC(measuredAt.getUTCFullYear(), measuredAt.getUTCMonth(), 1),
|
||||
);
|
||||
const to = new Date(
|
||||
Date.UTC(
|
||||
measuredAt.getUTCFullYear(),
|
||||
measuredAt.getUTCMonth() + 1,
|
||||
1,
|
||||
),
|
||||
);
|
||||
return { from, to };
|
||||
}
|
||||
case "YEAR": {
|
||||
const from = new Date(Date.UTC(measuredAt.getUTCFullYear(), 0, 1));
|
||||
const to = new Date(Date.UTC(measuredAt.getUTCFullYear() + 1, 0, 1));
|
||||
return { from, to };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.38 — per-userId in-flight dedup. v1.4.37.1 fire-and-forgets the
|
||||
* `ensureUserRollupsFresh` call from the read path so a stale 90-day
|
||||
* recompute can no longer stall the response, but if a request DOES
|
||||
* end up awaiting a synchronous recompute (worker code path, future
|
||||
* caller without the fire-and-forget) and the same user has a cold
|
||||
* fan-out hitting analytics + comprehensive + summaries within the
|
||||
* same tick, the legacy shape would queue N parallel
|
||||
* `recomputeUserRollups` invocations against the same 90-day window.
|
||||
* The map below collapses those into one shared promise — every
|
||||
* caller after the first re-uses the in-flight resolution and only
|
||||
* one Postgres aggregate runs. Mirrors the `buildCoachSnapshot` dedup
|
||||
* pattern that lives one layer up. Cleared in `finally` so a slow
|
||||
* promise that eventually rejects does not poison the next request.
|
||||
*/
|
||||
const ensureUserRollupsFreshInFlight = new Map<
|
||||
string,
|
||||
Promise<{ recomputed: boolean }>
|
||||
>();
|
||||
|
||||
/**
|
||||
* Cheap read-side warm-up. Called from the analytics + comprehensive
|
||||
* read paths so the rollup table stays current for downstream
|
||||
* consumers without each read paying the full live-aggregation cost
|
||||
* twice.
|
||||
*
|
||||
* Contract:
|
||||
* - If the user has at least one DAY rollup row that is current
|
||||
* (newest measurement ≤ newest rollup `computed_at`), this is a
|
||||
* no-op.
|
||||
* - Otherwise the function kicks off a `recomputeUserRollups` over
|
||||
* the trailing 90-day window (the dashboard hot path) for the
|
||||
* DAY granularity only. WEEK / MONTH / YEAR follow on the next
|
||||
* write hook or the explicit backfill — we don't pay for them
|
||||
* here.
|
||||
* - Best-effort: thrown errors are swallowed so the read path
|
||||
* never fails because of a populator hiccup.
|
||||
* - v1.4.38: concurrent callers for the same `userId` share one
|
||||
* in-flight promise so a cold fan-out can never queue N parallel
|
||||
* `recomputeUserRollups` runs against the same 90-day window.
|
||||
*/
|
||||
export async function ensureUserRollupsFresh(
|
||||
userId: string,
|
||||
): Promise<{ recomputed: boolean }> {
|
||||
// v1.4.38 — share the in-flight promise across concurrent callers
|
||||
// for the same userId. The map is keyed on `userId` because the
|
||||
// helper has no other parameters; the next caller hits the
|
||||
// resolved promise as soon as it lands. We delete the slot in
|
||||
// `finally` so a slow rejected promise does not stay cached and
|
||||
// poison the next request.
|
||||
const existing = ensureUserRollupsFreshInFlight.get(userId);
|
||||
if (existing) return existing;
|
||||
const inflight = ensureUserRollupsFreshImpl(userId).finally(() => {
|
||||
ensureUserRollupsFreshInFlight.delete(userId);
|
||||
});
|
||||
ensureUserRollupsFreshInFlight.set(userId, inflight);
|
||||
return inflight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only handle on the in-flight map. Mirrors the
|
||||
* `_resetTrustViolationWarningForTests` pattern in api-response.ts —
|
||||
* production code never calls this.
|
||||
*/
|
||||
export function _resetEnsureUserRollupsFreshInFlightForTests(): void {
|
||||
ensureUserRollupsFreshInFlight.clear();
|
||||
}
|
||||
|
||||
async function ensureUserRollupsFreshImpl(
|
||||
userId: string,
|
||||
): Promise<{ recomputed: boolean }> {
|
||||
try {
|
||||
// Single round-trip — the most-recent DAY rollup for the user
|
||||
// and the most-recent measurement updatedAt. If the rollup is
|
||||
// ahead of the measurement, we're done.
|
||||
const [newestRollup, newestMeasurement] = await Promise.all([
|
||||
prisma.measurementRollup.findFirst({
|
||||
where: { userId, granularity: "DAY" },
|
||||
orderBy: { computedAt: "desc" },
|
||||
select: { computedAt: true },
|
||||
}),
|
||||
prisma.measurement.findFirst({
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
select: { updatedAt: true, measuredAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!newestMeasurement) {
|
||||
// User has no measurements — rollups are trivially fresh.
|
||||
return { recomputed: false };
|
||||
}
|
||||
const watermark =
|
||||
newestMeasurement.updatedAt > newestMeasurement.measuredAt
|
||||
? newestMeasurement.updatedAt
|
||||
: newestMeasurement.measuredAt;
|
||||
if (newestRollup && watermark <= newestRollup.computedAt) {
|
||||
return { recomputed: false };
|
||||
}
|
||||
|
||||
// Stale — refresh the trailing 90-day DAY window. Synchronous so
|
||||
// the next read of `comprehensive` / `analytics` benefits even on
|
||||
// the first cold-mount fan-out. Bounded to DAY granularity so the
|
||||
// read path doesn't accidentally pay the WEEK / MONTH / YEAR
|
||||
// cost; those are handled by write hooks + the backfill script.
|
||||
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
||||
await recomputeUserRollups(userId, {
|
||||
granularities: ["DAY"],
|
||||
from: ninetyDaysAgo,
|
||||
to: new Date(),
|
||||
});
|
||||
return { recomputed: true };
|
||||
} catch (err) {
|
||||
// Best-effort — never block the read path on a populator failure.
|
||||
// v1.4.36 QA H3 — surface the failure so ops can spot a silent
|
||||
// populator regression. `annotate` is a no-op when there's no
|
||||
// request context (worker calls, tests); `console.error` carries
|
||||
// the same shape through the worker log pipeline so the regression
|
||||
// is visible regardless of caller surface.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
annotate({
|
||||
meta: {
|
||||
rollup_refresh_failed: true,
|
||||
rollup_refresh_error: message,
|
||||
},
|
||||
});
|
||||
console.error("[rollups] ensureUserRollupsFresh failed:", message);
|
||||
return { recomputed: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.35.1 — boot-time enqueue helper. Finds users with measurements
|
||||
* but zero rollup coverage and enqueues one full-fold job per account
|
||||
* onto `ROLLUP_FULL_BACKFILL_QUEUE`. Designed to be called once at
|
||||
* worker boot so every self-hosted instance auto-converges on the
|
||||
* persistent rollup tier without operator intervention.
|
||||
*
|
||||
* Idempotent across reboots:
|
||||
* - the discovery query matches accounts that either have zero
|
||||
* rollup coverage at all OR have at least one measurement type
|
||||
* whose DAY-rollup partition is empty. Once every type the user
|
||||
* has logged has at least one DAY bucket, the user drops off the
|
||||
* list and subsequent boots skip them. The v1.4.35.1 shape (zero-
|
||||
* rollups only) silently stranded users who logged a brand-new
|
||||
* type after the initial fold — their `isFullyCovered` probe stays
|
||||
* `false`, every read fans out to the live SQL aggregator, and
|
||||
* `/api/analytics` cold-mounts in tens of seconds instead of
|
||||
* 1.5-3 s. v1.4.38.5 widens discovery to per-type missing coverage.
|
||||
* - pg-boss `singletonKey` coalesces duplicate sends within the
|
||||
* queue, so a fast restart while a backfill is queued doesn't
|
||||
* double up
|
||||
*
|
||||
* Best-effort: errors are swallowed and reported through the return
|
||||
* value so the worker boot never fails because of a backfill miss.
|
||||
*/
|
||||
export async function enqueueBootTimeRollupBackfill(): Promise<{
|
||||
enqueued: number;
|
||||
skipped: number;
|
||||
error: string | null;
|
||||
}> {
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) {
|
||||
return { enqueued: 0, skipped: 0, error: null };
|
||||
}
|
||||
|
||||
try {
|
||||
// Users where at least one (type, day) pair in `measurements`
|
||||
// has no matching DAY rollup row.
|
||||
//
|
||||
// v1.4.39.1 — tightened from per-type to per-day. The legacy shape
|
||||
// matched users where ANY type had zero rollup rows. That stranded
|
||||
// accounts whose Withings sync / `/api/import` / admin restore
|
||||
// wrote measurements without firing the rollup write hook: as soon
|
||||
// as ONE rollup row existed for the type (even from a prior boot
|
||||
// backfill), the LEFT JOIN found a match and the user dropped off
|
||||
// the discovery list — leaving the dashboard chart's
|
||||
// `source=rollup` fast-path under-counting the missing days.
|
||||
// Switching the join to `(user, type, bucketStart)` surfaces every
|
||||
// per-day gap. Cost: the inner DISTINCT widens from
|
||||
// `(user_id, type)` (≈15 rows/user) to
|
||||
// `(user_id, type, day)` (≈5 k rows/user on a 5-year power user),
|
||||
// which is still bounded by the `(user_id, type, measured_at)`
|
||||
// index path. The LEFT JOIN walks `measurement_rollups` against
|
||||
// its composite primary key.
|
||||
//
|
||||
// v1.4.39 W-SUM — the discovery also surfaces users whose existing
|
||||
// DAY rollup rows carry `sum_value IS NULL`. Those rows pre-date
|
||||
// the v1.4.39 writer change; re-folding the user converges them
|
||||
// because `persistRollupRows` always writes the new column on
|
||||
// upsert. The union runs in one query so the per-day coverage gap
|
||||
// and the legacy-NULL backfill share a single planner pass.
|
||||
const users = await prisma.$queryRaw<Array<{ id: string }>>`
|
||||
SELECT DISTINCT id FROM (
|
||||
SELECT DISTINCT mt."user_id" AS id
|
||||
FROM (
|
||||
SELECT DISTINCT
|
||||
m."user_id",
|
||||
m."type",
|
||||
date_trunc('day', m."measured_at") AS bucket_start
|
||||
FROM measurements m
|
||||
WHERE m."deleted_at" IS NULL
|
||||
) mt
|
||||
LEFT JOIN measurement_rollups r
|
||||
ON r."user_id" = mt."user_id"
|
||||
AND r."type" = mt."type"
|
||||
AND r."granularity" = 'DAY'
|
||||
AND r."bucket_start" = mt."bucket_start"
|
||||
WHERE r."bucket_start" IS NULL
|
||||
UNION
|
||||
SELECT DISTINCT r2."user_id" AS id
|
||||
FROM measurement_rollups r2
|
||||
WHERE r2."granularity" = 'DAY'
|
||||
AND r2."sum_value" IS NULL
|
||||
) discovery
|
||||
`;
|
||||
|
||||
if (users.length === 0) {
|
||||
return { enqueued: 0, skipped: 0, error: null };
|
||||
}
|
||||
|
||||
let enqueued = 0;
|
||||
let skipped = 0;
|
||||
for (const { id } of users) {
|
||||
const payload: RollupFullBackfillPayload = {
|
||||
userId: id,
|
||||
enqueuedAt: new Date().toISOString(),
|
||||
};
|
||||
const jobId = await boss.send(ROLLUP_FULL_BACKFILL_QUEUE, payload, {
|
||||
retryLimit: 3,
|
||||
retryDelay: 60,
|
||||
retryBackoff: true,
|
||||
// Coalesce: if a backfill for this user is already queued and
|
||||
// we restart, pg-boss returns null instead of duplicating.
|
||||
singletonKey: `boot-backfill|${id}`,
|
||||
});
|
||||
if (jobId) {
|
||||
enqueued += 1;
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
return { enqueued, skipped, error: null };
|
||||
} catch (err) {
|
||||
return {
|
||||
enqueued: 0,
|
||||
skipped: 0,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a list of `(type, day)` keys to the set of distinct
|
||||
* `(type, dayStart)` pairs. Used by the batch-ingest hooks so a 500-
|
||||
* row batch fires at most N DAY recomputes (typically <10 for a
|
||||
* single-day batch).
|
||||
*/
|
||||
export function collapseToTypeDayKeys(
|
||||
rows: Array<{ type: MeasurementType; measuredAt: Date }>,
|
||||
): Array<{ type: MeasurementType; measuredAt: Date }> {
|
||||
const seen = new Map<string, { type: MeasurementType; measuredAt: Date }>();
|
||||
for (const row of rows) {
|
||||
const dayStart = startOfUtcDay(row.measuredAt);
|
||||
const key = `${row.type}|${dayStart.toISOString()}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.set(key, { type: row.type, measuredAt: dayStart });
|
||||
}
|
||||
}
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
/**
|
||||
* v1.4.39 W-MED — persistent medication-compliance rollup tier.
|
||||
*
|
||||
* Replaces the unbounded `MedicationIntakeEvent.findMany` walk on
|
||||
* `/api/medications/intake?scope=compliance` with a ledger keyed
|
||||
* `(userId, medicationId, day)` where `day` is `YYYY-MM-DD` anchored
|
||||
* to the user's IANA timezone.
|
||||
*
|
||||
* Write hooks fire from every `MedicationIntakeEvent` mutation site
|
||||
* (create / update / delete / bulk import) plus the reminder-worker's
|
||||
* mint path that creates `takenAt:null` rows on RED-phase reminders.
|
||||
* Reads collapse the trailing N-day window into a single bounded
|
||||
* `findMany` against `medication_compliance_rollups`; the route-level
|
||||
* coverage probe falls back to live + fires a boot backfill when a
|
||||
* user has intake events but zero rollup rows.
|
||||
*
|
||||
* Audit anchor: `.planning/round-v1438-perf-analysis.md` §2.5 + §5 P4.
|
||||
*/
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
DEFAULT_TIMEZONE,
|
||||
isValidTimezone,
|
||||
userDayKey,
|
||||
} from "@/lib/tz/format";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
|
||||
/**
|
||||
* pg-boss queue name for the boot-time medication compliance backfill.
|
||||
* Mirrors the `rollup-full-backfill` v1.4.35.1 pattern: discovery is
|
||||
* idempotent across reboots and the worker upserts every
|
||||
* `(user, medication, day)` row for the trailing window.
|
||||
*/
|
||||
export const MEDICATION_COMPLIANCE_BACKFILL_QUEUE =
|
||||
"medication-compliance-full-backfill";
|
||||
|
||||
/** Worker concurrency cap — backfill stays serial to spare the pool. */
|
||||
export const MEDICATION_COMPLIANCE_BACKFILL_CONCURRENCY = 1;
|
||||
|
||||
/** Trailing window the boot backfill folds per uncovered user. */
|
||||
export const MEDICATION_COMPLIANCE_BACKFILL_DAYS = 90;
|
||||
|
||||
/** Payload `boss.send` carries onto the backfill queue. */
|
||||
export interface MedicationComplianceBackfillPayload {
|
||||
userId: string;
|
||||
/** Wall-clock kick-off for debugging only. */
|
||||
enqueuedAt: string;
|
||||
}
|
||||
|
||||
/** Reader-result row shape — matches `buildComplianceBuckets` output. */
|
||||
export interface ComplianceBucket {
|
||||
/** YYYY-MM-DD in the user's tz. */
|
||||
date: string;
|
||||
scheduled: number;
|
||||
taken: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prisma client surface the helpers accept. Either the regular
|
||||
* `prisma` singleton or a `$transaction(tx)` argument — both expose
|
||||
* the `medicationIntakeEvent.findMany` + `medicationComplianceRollup.*`
|
||||
* methods we touch.
|
||||
*/
|
||||
type PrismaLike = typeof prisma | Prisma.TransactionClient;
|
||||
|
||||
/**
|
||||
* UTC offset (in minutes) of `tz` at the given instant. Positive east
|
||||
* of UTC. Honours DST via `Intl.DateTimeFormat`.
|
||||
*/
|
||||
function tzOffsetMinutes(date: Date, tz: string): number {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
}).formatToParts(date);
|
||||
const get = (type: Intl.DateTimeFormatPartTypes): string =>
|
||||
parts.find((p) => p.type === type)?.value ?? "0";
|
||||
let hour = Number(get("hour"));
|
||||
if (hour === 24) hour = 0;
|
||||
const asIfUtc = Date.UTC(
|
||||
Number(get("year")),
|
||||
Number(get("month")) - 1,
|
||||
Number(get("day")),
|
||||
hour,
|
||||
Number(get("minute")),
|
||||
Number(get("second")),
|
||||
);
|
||||
return Math.round((asIfUtc - date.getTime()) / 60000);
|
||||
}
|
||||
|
||||
/**
|
||||
* The UTC instant corresponding to local-midnight on `dayKey` in `tz`.
|
||||
* Two-pass convergence handles DST transitions correctly.
|
||||
*/
|
||||
function startOfDayUtcInTz(dayKey: string, tz: string): Date {
|
||||
const [yearStr, monthStr, dayStr] = dayKey.split("-");
|
||||
const year = Number(yearStr);
|
||||
const month = Number(monthStr);
|
||||
const day = Number(dayStr);
|
||||
let guess = new Date(Date.UTC(year, month - 1, day, 0, 0, 0, 0));
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const offsetMin = tzOffsetMinutes(guess, tz);
|
||||
guess = new Date(
|
||||
Date.UTC(year, month - 1, day, 0, 0, 0, 0) - offsetMin * 60_000,
|
||||
);
|
||||
}
|
||||
return guess;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a safe tz string — fall back to the server default when the
|
||||
* supplied value is missing or not an IANA zone the host recognises.
|
||||
*/
|
||||
function safeTimezone(tz: string | null | undefined): string {
|
||||
if (tz && isValidTimezone(tz)) return tz;
|
||||
return DEFAULT_TIMEZONE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a `scheduledFor` UTC instant to the user's `YYYY-MM-DD`
|
||||
* day-key. Re-exposed so the write-hook caller can derive the same
|
||||
* `dayKey` the rollup row is anchored on.
|
||||
*/
|
||||
export function dayKeyForScheduledFor(
|
||||
scheduledFor: Date,
|
||||
tz: string | null | undefined,
|
||||
): string {
|
||||
return userDayKey(scheduledFor, safeTimezone(tz));
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute the rollup row for one `(userId, medicationId, dayKey)`
|
||||
* tuple. Reads every `MedicationIntakeEvent` whose `scheduledFor` lands
|
||||
* in `[startOfDayInTz, startOfNextDayInTz)` then upserts the
|
||||
* `medication_compliance_rollups` row.
|
||||
*
|
||||
* The hook is idempotent: re-running for the same key produces the
|
||||
* same row. When the day window holds zero events the row is deleted
|
||||
* so the read path returns the trailing-window zero-default rather
|
||||
* than a stale stub.
|
||||
*
|
||||
* `tx` accepts the `prisma.$transaction(tx)` client argument when the
|
||||
* parent write is transactional — passing the transaction client keeps
|
||||
* the rollup write inside the same transactional boundary.
|
||||
*/
|
||||
export async function recomputeMedicationComplianceForDay(
|
||||
userId: string,
|
||||
medicationId: string,
|
||||
dayKey: string,
|
||||
tz: string | null | undefined,
|
||||
tx?: PrismaLike,
|
||||
): Promise<void> {
|
||||
const client = (tx ?? prisma) as PrismaLike;
|
||||
const safeTz = safeTimezone(tz);
|
||||
const start = startOfDayUtcInTz(dayKey, safeTz);
|
||||
const end = new Date(start.getTime() + 86_400_000);
|
||||
// Re-derive end via the day-after boundary to honour DST 23 / 25-hour
|
||||
// days. `start + 86_400_000` is correct for non-transition days; on
|
||||
// a fall-back day we add one calendar day in the local zone.
|
||||
const nextDayKey = (() => {
|
||||
const [y, m, d] = dayKey.split("-").map(Number);
|
||||
const probe = new Date(Date.UTC(y, m - 1, d, 12, 0, 0));
|
||||
const probeNext = new Date(probe.getTime() + 86_400_000);
|
||||
const parts = wallClockInTz(probeNext, safeTz);
|
||||
return `${String(parts.year).padStart(4, "0")}-${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")}`;
|
||||
})();
|
||||
const dstSafeEnd = startOfDayUtcInTz(nextDayKey, safeTz);
|
||||
const windowEnd = dstSafeEnd.getTime() > start.getTime() ? dstSafeEnd : end;
|
||||
|
||||
// v1.4.39 hotfix (QA F-H-02): atomic upsert closes the race window
|
||||
// between the prior `SELECT … aggregate then UPSERT` pattern. Two
|
||||
// concurrent writes for the same (user, medication, day) used to
|
||||
// interleave A-SELECT → B-SELECT → B-UPSERT (correct) → A-UPSERT
|
||||
// (stale N-1). The INSERT … SELECT re-aggregates inside the upsert
|
||||
// statement so each write commits with a snapshot taken after the
|
||||
// prior commit released its row lock; the trailing DELETE handles
|
||||
// the "all events for the day were removed" case in the same shot.
|
||||
//
|
||||
// Both statements key on (user_id, medication_id, day) so they
|
||||
// serialise behind the row lock the rollup table's primary key
|
||||
// provides. The DELETE runs second so it cannot clobber a row the
|
||||
// INSERT just wrote — when the day still has events the
|
||||
// `WHERE NOT EXISTS` predicate matches zero rows.
|
||||
await client.$executeRaw`
|
||||
WITH aggregate AS (
|
||||
SELECT
|
||||
COUNT(*)::int AS scheduled,
|
||||
SUM(CASE WHEN "skipped" THEN 0 WHEN "taken_at" IS NOT NULL THEN 1 ELSE 0 END)::int AS taken,
|
||||
SUM(CASE WHEN "skipped" THEN 1 ELSE 0 END)::int AS skipped
|
||||
FROM "medication_intake_events"
|
||||
WHERE "user_id" = ${userId}
|
||||
AND "medication_id" = ${medicationId}
|
||||
AND "scheduled_for" >= ${start}
|
||||
AND "scheduled_for" < ${windowEnd}
|
||||
)
|
||||
INSERT INTO "medication_compliance_rollups"
|
||||
("user_id", "medication_id", "day", "scheduled", "taken", "skipped", "computed_at")
|
||||
SELECT
|
||||
${userId},
|
||||
${medicationId},
|
||||
${dayKey},
|
||||
aggregate.scheduled,
|
||||
aggregate.taken,
|
||||
aggregate.skipped,
|
||||
NOW()
|
||||
FROM aggregate
|
||||
WHERE aggregate.scheduled > 0
|
||||
ON CONFLICT ("user_id", "medication_id", "day") DO UPDATE
|
||||
SET "scheduled" = EXCLUDED."scheduled",
|
||||
"taken" = EXCLUDED."taken",
|
||||
"skipped" = EXCLUDED."skipped",
|
||||
"computed_at" = EXCLUDED."computed_at"
|
||||
`;
|
||||
await client.$executeRaw`
|
||||
DELETE FROM "medication_compliance_rollups"
|
||||
WHERE "user_id" = ${userId}
|
||||
AND "medication_id" = ${medicationId}
|
||||
AND "day" = ${dayKey}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "medication_intake_events"
|
||||
WHERE "user_id" = ${userId}
|
||||
AND "medication_id" = ${medicationId}
|
||||
AND "scheduled_for" >= ${start}
|
||||
AND "scheduled_for" < ${windowEnd}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience hook for write-paths that have the `scheduledFor`
|
||||
* instant in hand. Derives `dayKey` in the user's tz then dispatches
|
||||
* to `recomputeMedicationComplianceForDay`. Best-effort: surfaces
|
||||
* failures through `annotate` + `console.error` so a silent populator
|
||||
* regression shows up in ops without blocking the parent write.
|
||||
*/
|
||||
export async function recomputeMedicationComplianceForEvent(input: {
|
||||
userId: string;
|
||||
medicationId: string;
|
||||
scheduledFor: Date;
|
||||
tz: string | null | undefined;
|
||||
tx?: PrismaLike;
|
||||
}): Promise<void> {
|
||||
const dayKey = dayKeyForScheduledFor(input.scheduledFor, input.tz);
|
||||
try {
|
||||
await recomputeMedicationComplianceForDay(
|
||||
input.userId,
|
||||
input.medicationId,
|
||||
dayKey,
|
||||
input.tz,
|
||||
input.tx,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
annotate({
|
||||
meta: {
|
||||
medication_compliance_rollup_failed: true,
|
||||
medication_compliance_rollup_error: message,
|
||||
},
|
||||
});
|
||||
console.error(
|
||||
"[medication-compliance-rollups] recompute failed:",
|
||||
message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read trailing `days` of compliance buckets for the user, oldest day
|
||||
* first. Returns the same `{date, scheduled, taken}` shape the legacy
|
||||
* `buildComplianceBuckets` helper produced so the route swap is
|
||||
* byte-identical.
|
||||
*
|
||||
* Days without a rollup row land as zero-filled buckets. The caller's
|
||||
* cache wrapper carries the user-tz in its key so a tz change forces
|
||||
* a fresh read.
|
||||
*/
|
||||
export async function readMedicationCompliance(
|
||||
userId: string,
|
||||
days: number,
|
||||
tz: string | null | undefined,
|
||||
now: Date = new Date(),
|
||||
): Promise<ComplianceBucket[]> {
|
||||
const safeTz = safeTimezone(tz);
|
||||
|
||||
// Build the expected day-key window oldest → newest so the response
|
||||
// is sorted and zero-filled regardless of which days hold rollups.
|
||||
const todayKey = userDayKey(now, safeTz);
|
||||
const expectedKeys: string[] = [];
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const probe = new Date(now.getTime() - i * 86_400_000);
|
||||
expectedKeys.push(userDayKey(probe, safeTz));
|
||||
}
|
||||
// Guard against a DST day-of-the-year landing the loop on the same
|
||||
// dayKey twice — dedup by Map insertion order.
|
||||
const dedup = new Set<string>();
|
||||
const orderedKeys: string[] = [];
|
||||
for (const key of expectedKeys) {
|
||||
if (!dedup.has(key)) {
|
||||
dedup.add(key);
|
||||
orderedKeys.push(key);
|
||||
}
|
||||
}
|
||||
// Always include today even if the trailing window dropped it (e.g.
|
||||
// days===1 on a DST boundary). Cheap defensive insert.
|
||||
if (!dedup.has(todayKey)) {
|
||||
orderedKeys.push(todayKey);
|
||||
}
|
||||
|
||||
const oldestKey = orderedKeys[0];
|
||||
const newestKey = orderedKeys[orderedKeys.length - 1];
|
||||
|
||||
const rows = await prisma.medicationComplianceRollup.findMany({
|
||||
where: {
|
||||
userId,
|
||||
day: { gte: oldestKey, lte: newestKey },
|
||||
},
|
||||
select: { day: true, scheduled: true, taken: true, skipped: true },
|
||||
});
|
||||
|
||||
// Fold per-medication rows into one per-day total. The legacy route
|
||||
// shape was aggregated across all the user's medications; preserving
|
||||
// that surface keeps the front-end byte-stable.
|
||||
const totals = new Map<string, { scheduled: number; taken: number }>();
|
||||
for (const key of orderedKeys) {
|
||||
totals.set(key, { scheduled: 0, taken: 0 });
|
||||
}
|
||||
for (const row of rows) {
|
||||
const bucket = totals.get(row.day);
|
||||
if (!bucket) continue;
|
||||
bucket.scheduled += row.scheduled;
|
||||
bucket.taken += row.taken;
|
||||
}
|
||||
|
||||
return orderedKeys.map((date) => {
|
||||
const bucket = totals.get(date) ?? { scheduled: 0, taken: 0 };
|
||||
return { date, scheduled: bucket.scheduled, taken: bucket.taken };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the rollup tier covers every day inside the
|
||||
* trailing `days` window that has at least one intake event. Coverage
|
||||
* is full when the count of DISTINCT rolled days equals the count of
|
||||
* DISTINCT days that hold a `medication_intake_events` row in window —
|
||||
* partial coverage (boot backfill mid-fold on a multi-medication
|
||||
* account) returns false so the route falls through to the legacy
|
||||
* aggregator instead of serving zero-filled tiles for un-rolled days.
|
||||
*
|
||||
* v1.4.39 hotfix (QA F-H-01): the previous "any row exists" probe
|
||||
* could short-circuit to the rollup path while only the first few days
|
||||
* had been backfilled, exposing zero-filled tiles for the un-rolled
|
||||
* days until the boot fold completed.
|
||||
*/
|
||||
export async function hasMedicationComplianceCoverage(
|
||||
userId: string,
|
||||
days: number,
|
||||
tz: string | null | undefined,
|
||||
now: Date = new Date(),
|
||||
): Promise<boolean> {
|
||||
const safeTz = safeTimezone(tz);
|
||||
const oldestKey = userDayKey(
|
||||
new Date(now.getTime() - (days - 1) * 86_400_000),
|
||||
safeTz,
|
||||
);
|
||||
const oldestStart = startOfDayUtcInTz(oldestKey, safeTz);
|
||||
|
||||
// Single SQL aggregate: count DISTINCT rolled days vs DISTINCT
|
||||
// event-days in window. The event side is anchored on the same
|
||||
// user-tz day-key (`to_char(... AT TIME ZONE $tz, 'YYYY-MM-DD')`) so
|
||||
// the comparison stays apples-to-apples even on DST boundaries.
|
||||
const result = await prisma.$queryRaw<
|
||||
Array<{ rolled_days: bigint; event_days: bigint }>
|
||||
>`
|
||||
SELECT
|
||||
(
|
||||
SELECT COUNT(DISTINCT "day")::bigint
|
||||
FROM "medication_compliance_rollups"
|
||||
WHERE "user_id" = ${userId}
|
||||
AND "day" >= ${oldestKey}
|
||||
) AS rolled_days,
|
||||
(
|
||||
SELECT COUNT(DISTINCT to_char("scheduled_for" AT TIME ZONE ${safeTz}, 'YYYY-MM-DD'))::bigint
|
||||
FROM "medication_intake_events"
|
||||
WHERE "user_id" = ${userId}
|
||||
AND "scheduled_for" >= ${oldestStart}
|
||||
) AS event_days
|
||||
`;
|
||||
if (result.length === 0) return true;
|
||||
const { rolled_days, event_days } = result[0];
|
||||
const rolled = Number(rolled_days);
|
||||
const events = Number(event_days);
|
||||
// Zero events in window → trivially covered (the read path produces
|
||||
// a zero-filled trailing window from the empty rollup table).
|
||||
if (events === 0) return true;
|
||||
return rolled >= events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-fold helper. Walks every `(medicationId, day)` pair the user
|
||||
* has logged across the trailing `days` window and upserts each
|
||||
* rollup row. The boot-time backfill worker calls this once per
|
||||
* uncovered user; the route's lazy fallback enqueues it when the
|
||||
* coverage probe returns false.
|
||||
*
|
||||
* Bounded SQL: one `DISTINCT (medicationId, dayKey)` $queryRaw scan
|
||||
* over the trailing window, then one upsert per row. The dayKey is
|
||||
* computed in Postgres via `to_char(... AT TIME ZONE $tz)` so we
|
||||
* don't have to ship millions of rows back to Node.
|
||||
*/
|
||||
export async function recomputeUserMedicationCompliance(
|
||||
userId: string,
|
||||
days: number = MEDICATION_COMPLIANCE_BACKFILL_DAYS,
|
||||
tz: string | null | undefined = null,
|
||||
): Promise<{ rowsUpserted: number; durationMs: number }> {
|
||||
const startedAt = Date.now();
|
||||
const safeTz = safeTimezone(tz);
|
||||
|
||||
const now = new Date();
|
||||
const oldestKey = userDayKey(
|
||||
new Date(now.getTime() - (days - 1) * 86_400_000),
|
||||
safeTz,
|
||||
);
|
||||
const oldestStart = startOfDayUtcInTz(oldestKey, safeTz);
|
||||
|
||||
// Distinct (medication_id, dayKey) pairs the user has any intake
|
||||
// event for inside the trailing window. `to_char(... AT TIME ZONE
|
||||
// $tz)` keeps the day-bucket on the user's wall-clock.
|
||||
const pairs = await prisma.$queryRaw<
|
||||
Array<{ medication_id: string; day: string }>
|
||||
>`
|
||||
SELECT DISTINCT
|
||||
"medication_id",
|
||||
to_char("scheduled_for" AT TIME ZONE ${safeTz}, 'YYYY-MM-DD') AS "day"
|
||||
FROM "medication_intake_events"
|
||||
WHERE "user_id" = ${userId}
|
||||
AND "scheduled_for" >= ${oldestStart}
|
||||
`;
|
||||
|
||||
let rowsUpserted = 0;
|
||||
for (const pair of pairs) {
|
||||
await recomputeMedicationComplianceForDay(
|
||||
userId,
|
||||
pair.medication_id,
|
||||
pair.day,
|
||||
safeTz,
|
||||
);
|
||||
rowsUpserted += 1;
|
||||
}
|
||||
|
||||
return { rowsUpserted, durationMs: Date.now() - startedAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget warm-up — kept around for symmetry with
|
||||
* `ensureUserRollupsFresh` even though the on-write hook fully owns
|
||||
* coverage in steady state. The route currently doesn't call this
|
||||
* directly; the coverage-probe + boot-backfill cascade handles the
|
||||
* cold-mount case.
|
||||
*/
|
||||
export async function ensureUserMedicationComplianceFresh(
|
||||
userId: string,
|
||||
days: number = MEDICATION_COMPLIANCE_BACKFILL_DAYS,
|
||||
tz: string | null | undefined = null,
|
||||
): Promise<{ recomputed: boolean }> {
|
||||
try {
|
||||
if (await hasMedicationComplianceCoverage(userId, days, tz)) {
|
||||
return { recomputed: false };
|
||||
}
|
||||
await recomputeUserMedicationCompliance(userId, days, tz);
|
||||
return { recomputed: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
annotate({
|
||||
meta: {
|
||||
medication_compliance_rollup_refresh_failed: true,
|
||||
medication_compliance_rollup_refresh_error: message,
|
||||
},
|
||||
});
|
||||
console.error(
|
||||
"[medication-compliance-rollups] ensure-fresh failed:",
|
||||
message,
|
||||
);
|
||||
return { recomputed: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.39 hotfix (QA F-SEC-M-01) — user-scoped enqueue helper for the
|
||||
* request-path coverage-miss fallback.
|
||||
*
|
||||
* Mirrors `ensureUserMoodRollupsFresh`: the route fires this when its
|
||||
* coverage probe returned false so the caller's account picks up a
|
||||
* targeted boot-backfill job, instead of running a cluster-wide
|
||||
* `LEFT JOIN` over `medication_intake_events × medication_compliance_rollups`
|
||||
* on every authenticated coverage-miss request. The cluster-wide scan
|
||||
* was a soft DoS amplifier — an authenticated user iterating
|
||||
* coverage-miss requests could drive a multi-tenant table scan on
|
||||
* every hit.
|
||||
*
|
||||
* The boot-time discovery helper below stays cluster-wide (it runs
|
||||
* once per worker boot, not per request).
|
||||
*/
|
||||
export async function enqueueUserMedicationComplianceBackfill(
|
||||
userId: string,
|
||||
): Promise<{ enqueued: boolean; error: string | null }> {
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) {
|
||||
return { enqueued: false, error: null };
|
||||
}
|
||||
try {
|
||||
const payload: MedicationComplianceBackfillPayload = {
|
||||
userId,
|
||||
enqueuedAt: new Date().toISOString(),
|
||||
};
|
||||
const jobId = await boss.send(
|
||||
MEDICATION_COMPLIANCE_BACKFILL_QUEUE,
|
||||
payload,
|
||||
{
|
||||
retryLimit: 3,
|
||||
retryDelay: 60,
|
||||
retryBackoff: true,
|
||||
singletonKey: `medication-compliance-boot-backfill|${userId}`,
|
||||
},
|
||||
);
|
||||
return { enqueued: jobId !== null && jobId !== undefined, error: null };
|
||||
} catch (err) {
|
||||
return {
|
||||
enqueued: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot-time enqueue helper — mirrors v1.4.35.1
|
||||
* `enqueueBootTimeRollupBackfill`. Finds users with intake events but
|
||||
* zero compliance-rollup coverage for the trailing window and enqueues
|
||||
* one full-fold per account onto `MEDICATION_COMPLIANCE_BACKFILL_QUEUE`.
|
||||
*
|
||||
* Idempotent across reboots: once an account has at least one rollup
|
||||
* row inside the trailing window the discovery query drops them.
|
||||
* pg-boss `singletonKey` coalesces duplicate sends.
|
||||
*/
|
||||
export async function enqueueBootTimeMedicationComplianceBackfill(): Promise<{
|
||||
enqueued: number;
|
||||
skipped: number;
|
||||
error: string | null;
|
||||
}> {
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) {
|
||||
return { enqueued: 0, skipped: 0, error: null };
|
||||
}
|
||||
|
||||
try {
|
||||
// Trailing window cutoff for the existence probe — `scheduled_for`
|
||||
// older than this is not what the read path consults.
|
||||
const cutoff = new Date(
|
||||
Date.now() - MEDICATION_COMPLIANCE_BACKFILL_DAYS * 86_400_000,
|
||||
);
|
||||
const users = await prisma.$queryRaw<Array<{ id: string }>>`
|
||||
SELECT DISTINCT mie."user_id" AS id
|
||||
FROM "medication_intake_events" mie
|
||||
LEFT JOIN "medication_compliance_rollups" r
|
||||
ON r."user_id" = mie."user_id"
|
||||
AND r."medication_id" = mie."medication_id"
|
||||
WHERE mie."scheduled_for" >= ${cutoff}
|
||||
AND r."day" IS NULL
|
||||
`;
|
||||
|
||||
if (users.length === 0) {
|
||||
return { enqueued: 0, skipped: 0, error: null };
|
||||
}
|
||||
|
||||
let enqueued = 0;
|
||||
let skipped = 0;
|
||||
for (const { id } of users) {
|
||||
const payload: MedicationComplianceBackfillPayload = {
|
||||
userId: id,
|
||||
enqueuedAt: new Date().toISOString(),
|
||||
};
|
||||
const jobId = await boss.send(
|
||||
MEDICATION_COMPLIANCE_BACKFILL_QUEUE,
|
||||
payload,
|
||||
{
|
||||
retryLimit: 3,
|
||||
retryDelay: 60,
|
||||
retryBackoff: true,
|
||||
singletonKey: `medication-compliance-boot-backfill|${id}`,
|
||||
},
|
||||
);
|
||||
if (jobId) {
|
||||
enqueued += 1;
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
return { enqueued, skipped, error: null };
|
||||
} catch (err) {
|
||||
return {
|
||||
enqueued: 0,
|
||||
skipped: 0,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
/**
|
||||
* v1.4.39 W-MOOD — persistent mood-rollup populator.
|
||||
*
|
||||
* Sits behind the 60-s `caches.moodAnalytics` LRU as the second cache
|
||||
* tier on `/api/mood/analytics`. One row per
|
||||
* `(userId, granularity, bucketStart)` in `mood_entry_rollups`,
|
||||
* carrying `count` / `mean` / `min_score` / `max_score` / `sd`
|
||||
* (population) folded via Postgres `AVG` / `MIN` / `MAX` / `STDDEV_POP`.
|
||||
*
|
||||
* The audit at `.planning/round-v1438-perf-analysis.md` §2.3 traced
|
||||
* the 12.7 s cold mount on `/api/mood/analytics` to the unbounded
|
||||
* `prisma.moodEntry.findMany({ where: { userId } })` that walked
|
||||
* every mood row the user has ever written into Node only to
|
||||
* bucket-average them in JS. This module mirrors the well-trodden
|
||||
* `src/lib/measurements/rollups.ts` pattern so the read path can swap
|
||||
* to a bounded `prisma.moodEntryRollup.findMany` with the daily mean
|
||||
* already materialised.
|
||||
*
|
||||
* Write hooks:
|
||||
* - Every mood-entry create/update/delete fires
|
||||
* `recomputeMoodBucketsForEntry`. The DAY pass runs inline
|
||||
* (cheap — single `(user, day)` aggregate) so the next read sees
|
||||
* the fresh row without waiting on pg-boss.
|
||||
* - WEEK / MONTH / YEAR are enqueued onto `mood-rollup-recompute`
|
||||
* for the worker to fold. No current reader consumes them; the
|
||||
* buckets exist so future cross-granularity readers ship without
|
||||
* a backfill step.
|
||||
* - Bulk endpoints (`/api/mood-entries/bulk`, restore) call
|
||||
* `recomputeUserMoodRollups(userId)` once at the end of the
|
||||
* batch instead of firing N per-row hooks.
|
||||
*
|
||||
* Reads:
|
||||
* - `readMoodDayRollups(userId, since)` returns the persisted DAY
|
||||
* rows. The reader-side fallback in `/api/mood/analytics` checks
|
||||
* the rollup count and falls through to the legacy live findMany
|
||||
* once when the user has mood entries but zero rollup rows;
|
||||
* `enqueueBootTimeMoodRollupBackfill` mints the rollups on the
|
||||
* next worker boot so subsequent reads converge on the fast path.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { startOfUtcDay } from "@/lib/tz/start-of-utc-day";
|
||||
import type {
|
||||
Prisma,
|
||||
PrismaClient,
|
||||
RollupGranularity,
|
||||
} from "@/generated/prisma/client";
|
||||
|
||||
/** Granularities that recompute asynchronously via pg-boss. */
|
||||
const ASYNC_GRANULARITIES: RollupGranularity[] = ["WEEK", "MONTH", "YEAR"];
|
||||
|
||||
/** All granularities the rollup table tracks. */
|
||||
export const ALL_MOOD_GRANULARITIES: RollupGranularity[] = [
|
||||
"DAY",
|
||||
"WEEK",
|
||||
"MONTH",
|
||||
"YEAR",
|
||||
];
|
||||
|
||||
/** pg-boss queue name for per-bucket recompute jobs. */
|
||||
export const MOOD_ROLLUP_RECOMPUTE_QUEUE = "mood-rollup-recompute";
|
||||
|
||||
/** Worker concurrency cap — recomputes are read-heavy on Postgres. */
|
||||
export const MOOD_ROLLUP_RECOMPUTE_CONCURRENCY = 2;
|
||||
|
||||
/** pg-boss queue name for the boot-time backfill discovery. */
|
||||
export const MOOD_ROLLUP_FULL_BACKFILL_QUEUE = "mood-rollup-full-backfill";
|
||||
|
||||
/** Boot-fold worker concurrency. Serial so we don't crowd the pool. */
|
||||
export const MOOD_ROLLUP_FULL_BACKFILL_CONCURRENCY = 1;
|
||||
|
||||
/** Payload the worker queue carries. */
|
||||
export interface MoodRollupRecomputePayload {
|
||||
userId: string;
|
||||
granularity: RollupGranularity;
|
||||
/** ISO-8601 — the worker parses through `new Date(value)`. */
|
||||
from: string;
|
||||
/** ISO-8601 — the worker parses through `new Date(value)`. */
|
||||
to: string;
|
||||
/** Wall-clock kick-off for debugging only. */
|
||||
enqueuedAt: string;
|
||||
}
|
||||
|
||||
/** Payload the boot-time backfill helper enqueues. */
|
||||
export interface MoodRollupFullBackfillPayload {
|
||||
userId: string;
|
||||
/** Wall-clock kick-off for debugging only. */
|
||||
enqueuedAt: string;
|
||||
}
|
||||
|
||||
/** Postgres `date_trunc` unit per granularity. */
|
||||
const DATE_TRUNC_UNIT: Record<RollupGranularity, string> = {
|
||||
DAY: "day",
|
||||
WEEK: "week",
|
||||
MONTH: "month",
|
||||
YEAR: "year",
|
||||
};
|
||||
|
||||
/** Raw row shape returned by the aggregate `$queryRawUnsafe`. */
|
||||
interface MoodRollupRow {
|
||||
bucket_start: Date;
|
||||
count: bigint;
|
||||
mean: number | null;
|
||||
min_score: number | null;
|
||||
max_score: number | null;
|
||||
sd: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tx-or-client surface the writer accepts. Mirrors the
|
||||
* `measurement-rollups` write-path contract: when called from inside a
|
||||
* `prisma.$transaction(async tx => …)` block, callers pass the `tx`
|
||||
* client so the rollup write commits atomically with the parent
|
||||
* mutation.
|
||||
*/
|
||||
type PrismaTxOrClient = Pick<
|
||||
PrismaClient,
|
||||
"$queryRawUnsafe" | "$executeRaw" | "moodEntryRollup"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Recompute the rollup buckets for one user. Bounded by the optional
|
||||
* `granularities` / `from` / `to` filters; defaults cover every
|
||||
* granularity and the trailing 5 years.
|
||||
*
|
||||
* Idempotent: rows are upserted under the
|
||||
* `(userId, granularity, bucketStart)` composite primary key, so
|
||||
* re-running the call is a no-op for unchanged buckets and a refresh
|
||||
* for changed ones.
|
||||
*/
|
||||
export async function recomputeUserMoodRollups(
|
||||
userId: string,
|
||||
opts: {
|
||||
granularities?: RollupGranularity[];
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
} = {},
|
||||
): Promise<{ rowsUpserted: number; durationMs: number }> {
|
||||
const startedAt = Date.now();
|
||||
const granularities = opts.granularities ?? ALL_MOOD_GRANULARITIES;
|
||||
const fiveYearsAgo = new Date(Date.now() - 5 * 365 * 24 * 60 * 60 * 1000);
|
||||
const from = opts.from ?? fiveYearsAgo;
|
||||
const to = opts.to ?? new Date();
|
||||
|
||||
let rowsUpserted = 0;
|
||||
|
||||
for (const granularity of granularities) {
|
||||
const rows = await runMoodRollupAggregate(prisma, {
|
||||
userId,
|
||||
granularity,
|
||||
from,
|
||||
to,
|
||||
});
|
||||
if (rows.length === 0) continue;
|
||||
|
||||
rowsUpserted += await persistMoodRollupRows(prisma, userId, granularity, rows);
|
||||
}
|
||||
|
||||
return { rowsUpserted, durationMs: Date.now() - startedAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous DAY-bucket recompute for the write-path hooks plus an
|
||||
* async enqueue of the WEEK / MONTH / YEAR recomputes.
|
||||
*
|
||||
* Scoped to the affected `(userId, day)` tuple — cheap, and avoids
|
||||
* invalidating buckets that haven't changed.
|
||||
*
|
||||
* Pass `tx` when called from inside a `prisma.$transaction` block so
|
||||
* the rollup write commits atomically with the parent mutation.
|
||||
* Otherwise the global `prisma` client is used.
|
||||
*/
|
||||
export async function recomputeMoodBucketsForEntry(
|
||||
userId: string,
|
||||
moodLoggedAt: Date,
|
||||
tx?: PrismaTxOrClient,
|
||||
): Promise<void> {
|
||||
const client = tx ?? prisma;
|
||||
// DAY pass — synchronous. v1.4.39 hotfix (QA F-H-02): one atomic SQL
|
||||
// statement re-aggregates inside the upsert subquery, closing the
|
||||
// race window where two concurrent writes for the same (user, day)
|
||||
// could interleave A-SELECT → B-SELECT → B-UPSERT (correct) →
|
||||
// A-UPSERT (stale N-1). The trailing DELETE handles the "all entries
|
||||
// for the day were removed" case in the same shot.
|
||||
const dayStart = startOfUtcDay(moodLoggedAt);
|
||||
const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);
|
||||
await client.$executeRaw`
|
||||
WITH aggregate AS (
|
||||
SELECT
|
||||
COUNT(*)::int AS count,
|
||||
AVG(m."score")::double precision AS mean,
|
||||
MIN(m."score")::integer AS min_score,
|
||||
MAX(m."score")::integer AS max_score,
|
||||
STDDEV_POP(m."score")::double precision AS sd
|
||||
FROM "mood_entries" m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."mood_logged_at" >= ${dayStart}
|
||||
AND m."mood_logged_at" < ${dayEnd}
|
||||
)
|
||||
INSERT INTO "mood_entry_rollups"
|
||||
("user_id", "granularity", "bucket_start", "count", "mean", "min_score", "max_score", "sd", "computed_at")
|
||||
SELECT
|
||||
${userId},
|
||||
'DAY',
|
||||
${dayStart},
|
||||
aggregate.count,
|
||||
aggregate.mean,
|
||||
aggregate.min_score,
|
||||
aggregate.max_score,
|
||||
aggregate.sd,
|
||||
NOW()
|
||||
FROM aggregate
|
||||
WHERE aggregate.count > 0
|
||||
ON CONFLICT ("user_id", "granularity", "bucket_start") DO UPDATE
|
||||
SET "count" = EXCLUDED."count",
|
||||
"mean" = EXCLUDED."mean",
|
||||
"min_score" = EXCLUDED."min_score",
|
||||
"max_score" = EXCLUDED."max_score",
|
||||
"sd" = EXCLUDED."sd",
|
||||
"computed_at" = EXCLUDED."computed_at"
|
||||
`;
|
||||
await client.$executeRaw`
|
||||
DELETE FROM "mood_entry_rollups"
|
||||
WHERE "user_id" = ${userId}
|
||||
AND "granularity" = 'DAY'
|
||||
AND "bucket_start" = ${dayStart}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "mood_entries" m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."mood_logged_at" >= ${dayStart}
|
||||
AND m."mood_logged_at" < ${dayEnd}
|
||||
)
|
||||
`;
|
||||
|
||||
// WEEK / MONTH / YEAR — fire-and-forget enqueue. QA F-H-03 (v1.4.39):
|
||||
// the outer await on this Promise.all used to serialise the write-path
|
||||
// response on pg-boss latency. The DAY pass the read path depends on
|
||||
// has already committed synchronously above; the queue is a hint, not
|
||||
// a write-path invariant, so dropping the await keeps the response
|
||||
// off the pg-boss critical path. Errors stay caught inside
|
||||
// `enqueueMoodRollupRecompute` so the unhandled-rejection logger
|
||||
// captures any pg-boss failure.
|
||||
void Promise.all(
|
||||
ASYNC_GRANULARITIES.map((granularity) => {
|
||||
const { from, to } = bucketSpan(moodLoggedAt, granularity);
|
||||
return enqueueMoodRollupRecompute({ userId, granularity, from, to }).catch(
|
||||
(err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
annotate({
|
||||
meta: {
|
||||
mood_rollup_enqueue_failed: true,
|
||||
mood_rollup_enqueue_error: message,
|
||||
},
|
||||
});
|
||||
console.error("[mood-rollups] async enqueue failed:", message);
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort pg-boss enqueue. Silent no-op when no boss is attached
|
||||
* (route running in a context without the worker — typically the test
|
||||
* suite). The reader-side fall-through still produces fresh data on a
|
||||
* cache miss, so missing the enqueue is at-most a one-read cost
|
||||
* rather than a correctness bug.
|
||||
*/
|
||||
export async function enqueueMoodRollupRecompute(input: {
|
||||
userId: string;
|
||||
granularity: RollupGranularity;
|
||||
from: Date;
|
||||
to: Date;
|
||||
}): Promise<void> {
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) return;
|
||||
const payload: MoodRollupRecomputePayload = {
|
||||
userId: input.userId,
|
||||
granularity: input.granularity,
|
||||
from: input.from.toISOString(),
|
||||
to: input.to.toISOString(),
|
||||
enqueuedAt: new Date().toISOString(),
|
||||
};
|
||||
await boss.send(MOOD_ROLLUP_RECOMPUTE_QUEUE, payload, {
|
||||
retryLimit: 3,
|
||||
retryDelay: 30,
|
||||
retryBackoff: true,
|
||||
// Coalesce duplicate enqueues for the same bucket key — two writes
|
||||
// inside the same week share one queued job.
|
||||
singletonKey: `${input.userId}|${input.granularity}|${input.from.toISOString()}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read DAY-granularity rollup rows for one user, sorted ascending by
|
||||
* `bucketStart`. The read-path swap in `/api/mood/analytics` consumes
|
||||
* these directly without any extra Node-side aggregation.
|
||||
*
|
||||
* `since` is an inclusive lower bound. Pass a 5-year-old `Date` from
|
||||
* the route to mirror the legacy "unbounded" semantics; future
|
||||
* consumers can narrow the window cheaply because the rollup table
|
||||
* carries one row per day rather than one per entry.
|
||||
*/
|
||||
export async function readMoodDayRollups(
|
||||
userId: string,
|
||||
since: Date,
|
||||
): Promise<
|
||||
Array<{
|
||||
bucketStart: Date;
|
||||
count: number;
|
||||
mean: number;
|
||||
minScore: number;
|
||||
maxScore: number;
|
||||
sd: number | null;
|
||||
computedAt: Date;
|
||||
}>
|
||||
> {
|
||||
const rows = await prisma.moodEntryRollup.findMany({
|
||||
where: {
|
||||
userId,
|
||||
granularity: "DAY",
|
||||
bucketStart: { gte: since },
|
||||
},
|
||||
orderBy: { bucketStart: "asc" },
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
bucketStart: r.bucketStart,
|
||||
count: r.count,
|
||||
mean: r.mean,
|
||||
minScore: r.minScore,
|
||||
maxScore: r.maxScore,
|
||||
sd: r.sd,
|
||||
computedAt: r.computedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.39 — fire-and-forget warm-up for the DAY tier.
|
||||
*
|
||||
* Contract:
|
||||
* - If at least one DAY rollup row exists for the user and it is
|
||||
* fresh (newest rollup `computed_at` ≥ newest mood entry
|
||||
* `updated_at`), this is a no-op.
|
||||
* - Otherwise the helper kicks off a full
|
||||
* `recomputeUserMoodRollups` over the trailing 5-year window so
|
||||
* the next read of `/api/mood/analytics` lands on the rollup
|
||||
* tier. Mood entries are typically 1/day so the recompute is
|
||||
* bounded to a few thousand rows even for power users — cheaper
|
||||
* than the 12.7 s legacy live walk by orders of magnitude.
|
||||
* - Best-effort: thrown errors are swallowed so the read path
|
||||
* never fails because of a populator hiccup.
|
||||
* - Concurrent callers for the same `userId` share one in-flight
|
||||
* promise so a cold fan-out can never queue N parallel
|
||||
* recompute runs.
|
||||
*/
|
||||
const ensureUserMoodRollupsFreshInFlight = new Map<
|
||||
string,
|
||||
Promise<{ recomputed: boolean }>
|
||||
>();
|
||||
|
||||
export async function ensureUserMoodRollupsFresh(
|
||||
userId: string,
|
||||
): Promise<{ recomputed: boolean }> {
|
||||
const existing = ensureUserMoodRollupsFreshInFlight.get(userId);
|
||||
if (existing) return existing;
|
||||
const inflight = ensureUserMoodRollupsFreshImpl(userId).finally(() => {
|
||||
ensureUserMoodRollupsFreshInFlight.delete(userId);
|
||||
});
|
||||
ensureUserMoodRollupsFreshInFlight.set(userId, inflight);
|
||||
return inflight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only handle on the in-flight map. Mirrors the
|
||||
* `_resetEnsureUserRollupsFreshInFlightForTests` pattern in
|
||||
* `measurements/rollups.ts` — production code never calls this.
|
||||
*/
|
||||
export function _resetEnsureUserMoodRollupsFreshInFlightForTests(): void {
|
||||
ensureUserMoodRollupsFreshInFlight.clear();
|
||||
}
|
||||
|
||||
async function ensureUserMoodRollupsFreshImpl(
|
||||
userId: string,
|
||||
): Promise<{ recomputed: boolean }> {
|
||||
try {
|
||||
const [newestRollup, newestEntry] = await Promise.all([
|
||||
prisma.moodEntryRollup.findFirst({
|
||||
where: { userId, granularity: "DAY" },
|
||||
orderBy: { computedAt: "desc" },
|
||||
select: { computedAt: true },
|
||||
}),
|
||||
prisma.moodEntry.findFirst({
|
||||
where: { userId },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
select: { updatedAt: true, moodLoggedAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!newestEntry) {
|
||||
// No mood entries — rollups are trivially fresh.
|
||||
return { recomputed: false };
|
||||
}
|
||||
const watermark =
|
||||
newestEntry.updatedAt > newestEntry.moodLoggedAt
|
||||
? newestEntry.updatedAt
|
||||
: newestEntry.moodLoggedAt;
|
||||
if (newestRollup && watermark <= newestRollup.computedAt) {
|
||||
return { recomputed: false };
|
||||
}
|
||||
|
||||
// Stale — full 5-year DAY fold. Bounded because mood entries are
|
||||
// typically 1/day, so even a multi-year power user materialises a
|
||||
// few thousand rows tops.
|
||||
await recomputeUserMoodRollups(userId, { granularities: ["DAY"] });
|
||||
return { recomputed: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
annotate({
|
||||
meta: {
|
||||
mood_rollup_refresh_failed: true,
|
||||
mood_rollup_refresh_error: message,
|
||||
},
|
||||
});
|
||||
console.error("[mood-rollups] ensureUserMoodRollupsFresh failed:", message);
|
||||
return { recomputed: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.39 — boot-time enqueue helper. Finds users with mood entries
|
||||
* but zero `mood_entry_rollups` rows and enqueues one full-fold per
|
||||
* account onto `MOOD_ROLLUP_FULL_BACKFILL_QUEUE`. Designed to be
|
||||
* called once at worker boot so every self-hosted instance auto-
|
||||
* converges on the persistent rollup tier without operator action.
|
||||
*
|
||||
* Idempotent across reboots:
|
||||
* - the discovery query matches accounts with mood entries but
|
||||
* zero rollup rows; once the fold completes the user drops off
|
||||
* the list
|
||||
* - pg-boss `singletonKey` coalesces duplicate sends within the
|
||||
* queue, so a fast restart while a backfill is queued doesn't
|
||||
* double up
|
||||
*
|
||||
* Best-effort: errors are swallowed and reported through the return
|
||||
* value so the worker boot never fails because of a backfill miss.
|
||||
*/
|
||||
export async function enqueueBootTimeMoodRollupBackfill(): Promise<{
|
||||
enqueued: number;
|
||||
skipped: number;
|
||||
error: string | null;
|
||||
}> {
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) {
|
||||
return { enqueued: 0, skipped: 0, error: null };
|
||||
}
|
||||
|
||||
try {
|
||||
// Users with mood entries but no rollup coverage. Anchored on the
|
||||
// per-user `DISTINCT user_id` set so the planner uses the
|
||||
// `(user_id, date)` index path; the LEFT JOIN onto
|
||||
// `mood_entry_rollups` uses the
|
||||
// `(user_id, granularity, bucket_start)` composite primary key.
|
||||
// `r."bucket_start" IS NULL` (any column from the right side of
|
||||
// the LEFT JOIN) marks the unmatched users.
|
||||
const users = await prisma.$queryRaw<Array<{ id: string }>>`
|
||||
SELECT DISTINCT mt."user_id" AS id
|
||||
FROM (
|
||||
SELECT DISTINCT m."user_id"
|
||||
FROM mood_entries m
|
||||
) mt
|
||||
LEFT JOIN mood_entry_rollups r
|
||||
ON r."user_id" = mt."user_id"
|
||||
AND r."granularity" = 'DAY'
|
||||
WHERE r."bucket_start" IS NULL
|
||||
`;
|
||||
|
||||
if (users.length === 0) {
|
||||
return { enqueued: 0, skipped: 0, error: null };
|
||||
}
|
||||
|
||||
let enqueued = 0;
|
||||
let skipped = 0;
|
||||
for (const { id } of users) {
|
||||
const payload: MoodRollupFullBackfillPayload = {
|
||||
userId: id,
|
||||
enqueuedAt: new Date().toISOString(),
|
||||
};
|
||||
const jobId = await boss.send(MOOD_ROLLUP_FULL_BACKFILL_QUEUE, payload, {
|
||||
retryLimit: 3,
|
||||
retryDelay: 60,
|
||||
retryBackoff: true,
|
||||
// Coalesce: if a backfill for this user is already queued and
|
||||
// we restart, pg-boss returns null instead of duplicating.
|
||||
singletonKey: `mood-boot-backfill|${id}`,
|
||||
});
|
||||
if (jobId) {
|
||||
enqueued += 1;
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
return { enqueued, skipped, error: null };
|
||||
} catch (err) {
|
||||
return {
|
||||
enqueued: 0,
|
||||
skipped: 0,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── internals ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pull the raw aggregates from Postgres. Groups by
|
||||
* `date_trunc(<granularity>, mood_logged_at)` and folds the stats
|
||||
* with `STDDEV_POP`. The `date_trunc` unit literal must be inlined
|
||||
* because Postgres rejects a parameterised expression in a GROUP BY;
|
||||
* the value comes from a closed enum (DATE_TRUNC_UNIT) so there's no
|
||||
* injection surface — we still assert against the whitelist before
|
||||
* splicing.
|
||||
*/
|
||||
async function runMoodRollupAggregate(
|
||||
client: PrismaTxOrClient,
|
||||
input: {
|
||||
userId: string;
|
||||
granularity: RollupGranularity;
|
||||
from: Date;
|
||||
to: Date;
|
||||
},
|
||||
): Promise<MoodRollupRow[]> {
|
||||
const truncUnit = DATE_TRUNC_UNIT[input.granularity];
|
||||
if (!["day", "week", "month", "year"].includes(truncUnit)) {
|
||||
throw new Error(`unexpected granularity: ${input.granularity}`);
|
||||
}
|
||||
const dateTrunc = `date_trunc('${truncUnit}', m."mood_logged_at")`;
|
||||
|
||||
const sql = `
|
||||
SELECT
|
||||
${dateTrunc} AS bucket_start,
|
||||
COUNT(*) AS count,
|
||||
AVG(m."score")::double precision AS mean,
|
||||
MIN(m."score")::integer AS min_score,
|
||||
MAX(m."score")::integer AS max_score,
|
||||
STDDEV_POP(m."score")::double precision AS sd
|
||||
FROM mood_entries m
|
||||
WHERE m."user_id" = $1
|
||||
AND m."mood_logged_at" >= $2
|
||||
AND m."mood_logged_at" < $3
|
||||
GROUP BY ${dateTrunc}
|
||||
`;
|
||||
return client.$queryRawUnsafe<MoodRollupRow[]>(
|
||||
sql,
|
||||
input.userId,
|
||||
input.from,
|
||||
input.to,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* UPSERT the aggregate rows into `mood_entry_rollups`. Returns the
|
||||
* number of rows touched. Chunked so a multi-year fold doesn't blow
|
||||
* the parameter cap on a single round-trip.
|
||||
*/
|
||||
async function persistMoodRollupRows(
|
||||
client: PrismaTxOrClient,
|
||||
userId: string,
|
||||
granularity: RollupGranularity,
|
||||
rows: MoodRollupRow[],
|
||||
): Promise<number> {
|
||||
if (rows.length === 0) return 0;
|
||||
const CHUNK = 500;
|
||||
let touched = 0;
|
||||
for (let i = 0; i < rows.length; i += CHUNK) {
|
||||
const slice = rows.slice(i, i + CHUNK);
|
||||
// Run each upsert sequentially through the supplied client (which
|
||||
// may be a transactional handle). Wrapping in `$transaction` here
|
||||
// would crash when the caller already holds an interactive
|
||||
// transaction, so we rely on the parent caller (or atomicity of
|
||||
// the single upsert) for crash safety. The hot path is a single
|
||||
// row per DAY recompute, so the round-trip cost is negligible.
|
||||
for (const row of slice) {
|
||||
const payload = {
|
||||
count: Number(row.count),
|
||||
mean: row.mean ?? 0,
|
||||
minScore: row.min_score ?? 0,
|
||||
maxScore: row.max_score ?? 0,
|
||||
sd: row.sd,
|
||||
computedAt: new Date(),
|
||||
} satisfies Prisma.MoodEntryRollupUpdateInput;
|
||||
await client.moodEntryRollup.upsert({
|
||||
where: {
|
||||
userId_granularity_bucketStart: {
|
||||
userId,
|
||||
granularity,
|
||||
bucketStart: row.bucket_start,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
granularity,
|
||||
bucketStart: row.bucket_start,
|
||||
...payload,
|
||||
},
|
||||
update: payload,
|
||||
});
|
||||
}
|
||||
touched += slice.length;
|
||||
}
|
||||
return touched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Span of the bucket containing `moodLoggedAt` at the given
|
||||
* granularity. Mirrors `measurement-rollups` so the WEEK / MONTH /
|
||||
* YEAR fold queries select the same set of rows the read-side
|
||||
* `date_trunc` would group on.
|
||||
*/
|
||||
function bucketSpan(
|
||||
moodLoggedAt: Date,
|
||||
granularity: RollupGranularity,
|
||||
): { from: Date; to: Date } {
|
||||
switch (granularity) {
|
||||
case "DAY": {
|
||||
const from = startOfUtcDay(moodLoggedAt);
|
||||
return { from, to: new Date(from.getTime() + 24 * 60 * 60 * 1000) };
|
||||
}
|
||||
case "WEEK": {
|
||||
const day = startOfUtcDay(moodLoggedAt);
|
||||
// Postgres ISO week: Monday is day 1. JS getUTCDay(): Sunday=0.
|
||||
const dayOfWeek = day.getUTCDay();
|
||||
const mondayOffset = (dayOfWeek + 6) % 7;
|
||||
const from = new Date(
|
||||
day.getTime() - mondayOffset * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
return { from, to: new Date(from.getTime() + 7 * 24 * 60 * 60 * 1000) };
|
||||
}
|
||||
case "MONTH": {
|
||||
const from = new Date(
|
||||
Date.UTC(moodLoggedAt.getUTCFullYear(), moodLoggedAt.getUTCMonth(), 1),
|
||||
);
|
||||
const to = new Date(
|
||||
Date.UTC(
|
||||
moodLoggedAt.getUTCFullYear(),
|
||||
moodLoggedAt.getUTCMonth() + 1,
|
||||
1,
|
||||
),
|
||||
);
|
||||
return { from, to };
|
||||
}
|
||||
case "YEAR": {
|
||||
const from = new Date(Date.UTC(moodLoggedAt.getUTCFullYear(), 0, 1));
|
||||
const to = new Date(Date.UTC(moodLoggedAt.getUTCFullYear() + 1, 0, 1));
|
||||
return { from, to };
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user