From b90c82cba3bb99f120f26a88d34d066b749fccfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 12:48:17 +0200 Subject: [PATCH 1/7] fix(migrate): run migrate deploy in a UTC session Prisma ignores PGTZ, so a database whose default zone is not UTC ran every migration in that zone, and a migration mixing timestamp and timestamptz wrote shifted values. The boot wrapper now appends -c TimeZone=UTC to the options parameter of DATABASE_URL for its Prisma calls, honouring DATABASE_SESSION_OPTIONS_DISABLED like the app does. --- docs/ops/deploy.md | 9 ++ scripts/__tests__/migrate-deploy.test.ts | 49 ++++++ scripts/migrate-deploy.mjs | 55 +++++++ tests/integration/migrate-deploy-utc.test.ts | 151 +++++++++++++++++++ 4 files changed, 264 insertions(+) create mode 100644 tests/integration/migrate-deploy-utc.test.ts diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index 89ac3d380..36e78047e 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -40,6 +40,15 @@ not reachable". The entrypoint also runs the migrations on every boot, so step 2 is optional; it is there for when you want to see the migration result before the new container takes traffic. +The entrypoint runs every migration in a UTC session, the zone the app's own +connections use, whatever the database's default `TimeZone` is: it adds +`-c TimeZone=UTC` to the `options` parameter of `DATABASE_URL` for the Prisma +calls (an `options` value you set yourself is kept, with the pin after it). +Behind a connection pooler that refuses `options`, the +`DATABASE_SESSION_OPTIONS_DISABLED` switch turns this off along with the +app's own session settings; set the database or role default to UTC there +(`ALTER DATABASE healthlog SET timezone = 'UTC'`). + ### A migration that timed out on a lock Some migrations bound how long they wait for a table lock, so that a diff --git a/scripts/__tests__/migrate-deploy.test.ts b/scripts/__tests__/migrate-deploy.test.ts index a63edc96b..46b7b7915 100644 --- a/scripts/__tests__/migrate-deploy.test.ts +++ b/scripts/__tests__/migrate-deploy.test.ts @@ -24,7 +24,9 @@ import { afterEach, describe, expect, it } from "vitest"; import { decideLockRetry, lockTimeoutInReport, + prismaEnv, retryDelays, + utcSessionUrl, } from "../migrate-deploy.mjs"; const P3018_LOCK = `Applying migration \`0412_measurement_hot_updates\` @@ -167,6 +169,53 @@ describe("which failure is retried", () => { }); }); +describe("the session time zone Prisma migrates in", () => { + const options = (url: string | undefined) => + new URL(url ?? "").searchParams.get("options"); + + it("pins TimeZone=UTC through the libpq options parameter", () => { + const url = utcSessionUrl( + "postgresql://u:p@db:5432/healthlog?schema=public", + ); + expect(options(url)).toBe("-c TimeZone=UTC"); + expect(new URL(url ?? "").searchParams.get("schema")).toBe("public"); + }); + + it("appends to options the operator set, so a later -c wins", () => { + const url = utcSessionUrl( + "postgresql://u:p@db/h?options=-c%20TimeZone%3DEurope%2FBerlin", + ); + expect(options(url)).toBe("-c TimeZone=Europe/Berlin -c TimeZone=UTC"); + // Running it again adds nothing. + expect(utcSessionUrl(url)).toBe(url); + }); + + it("leaves a URL alone when the startup options are switched off", () => { + const raw = "postgresql://u:p@db/h?schema=public"; + expect(utcSessionUrl(raw, { optionsDisabled: true })).toBe(raw); + expect( + prismaEnv({ DATABASE_URL: raw, DATABASE_SESSION_OPTIONS_DISABLED: "1" }) + .DATABASE_URL, + ).toBe(raw); + }); + + it("passes an unset or unparseable URL through untouched", () => { + expect(utcSessionUrl(undefined)).toBeUndefined(); + expect(utcSessionUrl("")).toBe(""); + expect(utcSessionUrl("not a url")).toBe("not a url"); + expect(prismaEnv({}).DATABASE_URL).toBeUndefined(); + }); + + it("hands Prisma the pinned URL and keeps the rest of the environment", () => { + const env = prismaEnv({ + DATABASE_URL: "postgresql://u:p@db/h", + HEALTHLOG_X: "1", + }); + expect(options(env.DATABASE_URL)).toBe("-c TimeZone=UTC"); + expect(env.HEALTHLOG_X).toBe("1"); + }); +}); + describe("the retry loop", () => { const dirs: string[] = []; afterEach(() => { diff --git a/scripts/migrate-deploy.mjs b/scripts/migrate-deploy.mjs index 23abb5f9a..4f06202c2 100644 --- a/scripts/migrate-deploy.mjs +++ b/scripts/migrate-deploy.mjs @@ -30,6 +30,16 @@ * after a failed one (P3009), from the `logs` Prisma stored on the failed * row in `_prisma_migrations`. * + * Every Prisma call runs with its session pinned to UTC, the way the app's + * own connections are (`src/lib/db-session.ts`). Almost every `DateTime` + * column is `timestamp` without a time zone and holds UTC, and Postgres + * converts between that and `timestamptz` / `now()` through the session time + * zone. A migration that mixes the two (0372 does) therefore wrote hours-off + * values on a database whose default zone is not UTC. Prisma ignores `PGTZ`, + * so the pin rides the connection URL as the libpq `options` parameter, which + * Prisma passes to the server. `DATABASE_SESSION_OPTIONS_DISABLED` (a pooler + * that refuses `options`) turns it off, as it does for the app. + * * Environment: * HEALTHLOG_PRISMA_CLI Prisma's CLI entry (default: the image's copy) * HEALTHLOG_MIGRATIONS_DIR where the migration files are (default: @@ -49,6 +59,7 @@ const DEFAULT_RETRY_DELAYS_S = [15, 30, 60, 120, 240]; export const RETRY_MARKER = /^--\s*@retry-on-lock-timeout\s*$/m; const LOCK_TIMEOUT = "55P03"; +const TIME_ZONE_OPTION = "-c TimeZone=UTC"; const MIGRATION_NAME = /^[A-Za-z0-9_]+$/; /** @@ -116,10 +127,54 @@ export function retryDelays(raw) { : DEFAULT_RETRY_DELAYS_S; } +/** + * `DATABASE_URL` with the session time zone pinned to UTC, for Prisma. + * + * Appends to an `options` value the operator set rather than replacing it; + * a later `-c` wins, so an operator's own `TimeZone` gives way to UTC. A URL + * that does not parse, or a host that disabled the startup options, is + * returned unchanged. + * + * @param {string | undefined} raw + * @param {{ optionsDisabled?: boolean }} [opts] + * @returns {string | undefined} + */ +export function utcSessionUrl(raw, { optionsDisabled = false } = {}) { + if (!raw || optionsDisabled) return raw; + let url; + try { + url = new URL(raw); + } catch { + return raw; + } + const existing = url.searchParams.get("options")?.trim(); + if (existing && existing.endsWith(TIME_ZONE_OPTION)) return raw; + url.searchParams.set( + "options", + existing ? `${existing} ${TIME_ZONE_OPTION}` : TIME_ZONE_OPTION, + ); + return url.toString(); +} + +/** The same switch the app reads in `src/lib/db-session.ts`. */ +function sessionOptionsDisabled(env) { + const raw = env.DATABASE_SESSION_OPTIONS_DISABLED?.trim(); + return raw !== undefined && /^(1|true|yes)$/i.test(raw); +} + +/** The environment every Prisma call runs in. */ +export function prismaEnv(env = process.env) { + const url = utcSessionUrl(env.DATABASE_URL, { + optionsDisabled: sessionOptionsDisabled(env), + }); + return url === undefined ? { ...env } : { ...env, DATABASE_URL: url }; +} + function runPrisma(cli, args) { return new Promise((resolve) => { const child = spawn(process.execPath, [cli, ...args], { stdio: ["ignore", "pipe", "pipe"], + env: prismaEnv(), }); let output = ""; child.stdout.on("data", (chunk) => { diff --git a/tests/integration/migrate-deploy-utc.test.ts b/tests/integration/migrate-deploy-utc.test.ts new file mode 100644 index 000000000..84b0e1b94 --- /dev/null +++ b/tests/integration/migrate-deploy-utc.test.ts @@ -0,0 +1,151 @@ +/** + * The boot's `migrate deploy` (`scripts/migrate-deploy.mjs`) runs every + * migration in a UTC session, whatever the database's own default zone. + * + * A role whose default `TimeZone` is Europe/Berlin stands in for a shared + * Postgres configured that way. One migration records the session zone and + * the instant a `timestamp` → `timestamptz` cast lands on, which is what a + * migration mixing the two types (0372) depends on. Through the wrapper both + * read UTC; through the bare Prisma CLI, the counter-check, they read Berlin, + * which proves the role default is what the wrapper overrode. + */ +import { spawn } from "node:child_process"; +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Client } from "pg"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const ROLE = "hl_tz_probe"; +const PASSWORD = "tz-probe"; +const ROOT = process.cwd(); +const PRISMA_CLI = join(ROOT, "node_modules", "prisma", "build", "index.js"); + +const dirs: string[] = []; + +function adminUrl(): URL { + const raw = process.env.DATABASE_URL; + if (!raw) throw new Error("DATABASE_URL is not set for the integration run"); + const url = new URL(raw); + url.searchParams.delete("schema"); + return url; +} + +async function admin(): Promise { + const client = new Client({ connectionString: adminUrl().toString() }); + await client.connect(); + return client; +} + +/** A URL that logs in as the Berlin-default role, into its own schema. */ +function probeUrl(schema: string): string { + const url = new URL(process.env.DATABASE_URL ?? ""); + url.username = ROLE; + url.password = PASSWORD; + url.searchParams.set("schema", schema); + return url.toString(); +} + +beforeAll(async () => { + const db = await admin(); + await db.query(`DROP OWNED BY ${ROLE}`).catch(() => {}); + await db.query(`DROP ROLE IF EXISTS ${ROLE}`); + await db.query(`CREATE ROLE ${ROLE} LOGIN SUPERUSER PASSWORD '${PASSWORD}'`); + await db.query(`ALTER ROLE ${ROLE} SET timezone = 'Europe/Berlin'`); + await db.end(); +}); + +afterAll(async () => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true }); + const db = await admin(); + for (const schema of ["tz_probe_wrapper", "tz_probe_bare"]) { + await db.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`); + } + await db.query(`DROP OWNED BY ${ROLE}`).catch(() => {}); + await db.query(`DROP ROLE IF EXISTS ${ROLE}`); + await db.end(); +}); + +function migrationsDir(): string { + const dir = mkdtempSync(join(tmpdir(), "hl-migrate-utc-")); + dirs.push(dir); + writeFileSync( + join(dir, "schema.prisma"), + 'datasource db {\n provider = "postgresql"\n}\n', + ); + mkdirSync(join(dir, "migrations", "0001_zone_probe"), { recursive: true }); + cpSync( + join(ROOT, "prisma", "migrations", "migration_lock.toml"), + join(dir, "migrations", "migration_lock.toml"), + ); + writeFileSync( + join(dir, "migrations", "0001_zone_probe", "migration.sql"), + `CREATE TABLE "zone_probe" AS + SELECT current_setting('TimeZone') AS zone, + ('2026-01-15 12:00:00'::timestamp)::timestamptz + = '2026-01-15 12:00:00+00'::timestamptz AS cast_is_utc;`, + ); + return dir; +} + +function run( + args: string[], + databaseUrl: string, +): Promise<{ code: number; stderr: string }> { + return new Promise((resolve) => { + const child = spawn(process.execPath, args, { + cwd: ROOT, + env: { + ...process.env, + DATABASE_URL: databaseUrl, + DATABASE_SESSION_OPTIONS_DISABLED: "", + HEALTHLOG_PRISMA_CLI: PRISMA_CLI, + }, + }); + let stderr = ""; + child.stdout.on("data", () => {}); + child.stderr.on("data", (chunk) => (stderr += chunk)); + child.on("close", (code) => resolve({ code: code ?? 1, stderr })); + }); +} + +async function probe(schema: string) { + const db = await admin(); + const { rows } = await db.query( + `SELECT zone, cast_is_utc FROM ${schema}.zone_probe`, + ); + await db.end(); + return rows[0] as { zone: string; cast_is_utc: boolean }; +} + +describe("migrate deploy session time zone", () => { + it("runs the migration in UTC through the boot wrapper", async () => { + const dir = migrationsDir(); + const result = await run( + [ + join(ROOT, "scripts", "migrate-deploy.mjs"), + "--schema", + join(dir, "schema.prisma"), + ], + probeUrl("tz_probe_wrapper"), + ); + expect(result.code, result.stderr).toBe(0); + expect(await probe("tz_probe_wrapper")).toEqual({ + zone: "UTC", + cast_is_utc: true, + }); + }, 60_000); + + it("counter-check: the bare CLI inherits the database's own zone", async () => { + const dir = migrationsDir(); + const result = await run( + [PRISMA_CLI, "migrate", "deploy", "--schema", join(dir, "schema.prisma")], + probeUrl("tz_probe_bare"), + ); + expect(result.code, result.stderr).toBe(0); + expect(await probe("tz_probe_bare")).toEqual({ + zone: "Europe/Berlin", + cast_is_utc: false, + }); + }, 60_000); +}); From 41de0ca1260d1b5788bdf895ed4b5d5b3756e49c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 13:01:45 +0200 Subject: [PATCH 2/7] refactor(medications): drop the compliance rollup table that nothing reads medication_compliance_rollups lost its last reader when compliance moved to the schedule-anchored computation, yet every dose write still upserted it, a boot backfill refolded it, and the wipe and backup plans carried it. The per-dose hook keeps the one derived row that still hangs off a dose day, the supplement nutrients, under dose-day-refresh. The pending-row mints (today projection, reminder) no longer call it, since an untaken dose adds no nutrients. The import job finishes without its per-day recompute phase; its progress keeps touchedDays and rollupProcessed on the wire, inert and documented as deprecated. The backfill queue is retired on boot and migration 0414 drops the table under a bounded lock wait. --- docs/api/openapi.yaml | 14 +- docs/ops/intake-repair.md | 16 +- .../migration.sql | 32 ++ prisma/schema.prisma | 74 +-- scripts/migrate-demo-edge01.README.md | 4 +- scripts/migrate-demo-edge01.sh | 4 +- scripts/repair-intake-anomalies.ts | 165 +------ scripts/seed-demo-v15.sql | 1 - src/__tests__/sql-now-utc-guard.test.ts | 4 - src/__tests__/tz-day-paths-harness.test.ts | 2 +- .../auth/me/timezone/__tests__/route.test.ts | 20 +- src/app/api/auth/me/timezone/route.ts | 22 +- .../ingest/medication/__tests__/route.test.ts | 4 +- src/app/api/ingest/medication/route.ts | 4 +- .../medications/[id]/__tests__/route.test.ts | 40 +- .../intake/[eventId]/__tests__/route.test.ts | 4 +- .../[id]/intake/[eventId]/route.ts | 8 +- .../[id]/intake/__tests__/route.test.ts | 4 +- .../__tests__/source-provenance.test.ts | 4 +- .../bulk-delete/__tests__/route.test.ts | 12 +- .../[id]/intake/bulk-delete/route.ts | 27 +- .../[id]/intake/purge/__tests__/route.test.ts | 8 +- .../medications/[id]/intake/purge/route.ts | 11 +- src/app/api/medications/[id]/intake/route.ts | 4 +- src/app/api/medications/[id]/route.ts | 40 +- .../schedule-revisions/[revisionId]/route.ts | 7 - .../__tests__/route.test.ts | 20 - .../[id]/schedule-revisions/route.ts | 5 - .../intake/__tests__/route.test.ts | 15 - .../intake/bulk/__tests__/route.test.ts | 4 +- src/app/api/medications/intake/bulk/route.ts | 43 +- src/app/api/medications/intake/route.ts | 6 +- .../telegram/webhook/__tests__/route.test.ts | 4 +- src/lib/data-wipe/wipe-plan.ts | 1 - .../restore-backup-owner-mismatch.test.ts | 2 +- .../restore-backup-round-trip.test.ts | 2 +- src/lib/export/backup-plan.ts | 2 - src/lib/export/restore-backup.ts | 31 -- .../jobs/__tests__/intake-auto-take.test.ts | 8 +- .../medication-intake-import.test.ts | 66 +-- .../medication-reminder-check.test.ts | 3 - .../medication-reminder-dedup.test.ts | 3 - .../__tests__/queue-policy-tables.test.ts | 1 - src/lib/jobs/intake-auto-take.ts | 6 +- src/lib/jobs/medication-intake-import.ts | 127 +---- src/lib/jobs/queue-runtime.ts | 3 - .../reminder/medication-reminder-check.ts | 10 - src/lib/jobs/reminder/register-rollup.ts | 91 +--- src/lib/jobs/subsystem-surface.ts | 1 - .../intake-slot-dedup-converge.test.ts | 4 +- .../intake-slot-dedup-inventory.test.ts | 4 +- .../intake-slot-dedup-isolation.test.ts | 8 +- .../__tests__/intake-slot-dedup-pin.test.ts | 4 +- src/lib/medications/dose-day-refresh.ts | 62 +++ src/lib/medications/intake-slot-dedup.ts | 4 +- .../scheduling/project-today-intakes.ts | 36 -- src/lib/openapi/routes/medications/schemas.ts | 6 +- src/lib/openapi/routes/profile.ts | 2 +- .../medication-compliance-rollups.test.ts | 403 ---------------- .../rollups/medication-compliance-rollups.ts | 438 ------------------ src/lib/telegram-webhook-handlers.ts | 10 +- src/lib/tz/wall-clock.ts | 4 +- tests/integration/intake-slot-dedup.test.ts | 10 - tests/integration/local-day-keys.test.ts | 29 -- .../medication-cadence-worker.test.ts | 8 +- .../medication-compliance-rollup-slot.test.ts | 225 --------- .../medication-dose-history-import.test.ts | 20 - .../medication-intake-import-job.test.ts | 40 +- ...medication-reminder-cross-midnight.test.ts | 3 - .../sharing-delegable-writes.test.ts | 26 -- .../integration/supplement-nutrients.test.ts | 6 +- 71 files changed, 314 insertions(+), 2027 deletions(-) create mode 100644 prisma/migrations/0414_drop_medication_compliance_rollups/migration.sql create mode 100644 src/lib/medications/dose-day-refresh.ts delete mode 100644 src/lib/rollups/__tests__/medication-compliance-rollups.test.ts delete mode 100644 src/lib/rollups/medication-compliance-rollups.ts delete mode 100644 tests/integration/medication-compliance-rollup-slot.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 89c03a66b..19745220b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -9544,10 +9544,9 @@ paths: defend against a poisoned column. Audit-logged with the old and new zone. - A change is not only a display preference. The medication-compliance rollup keys its rows by the LOCAL day a - dose fell on, minted under whatever zone was in force at write time, so moving the zone re-buckets that history - and the stored keys go stale — and the coverage probe compares counts rather than keys, so it cannot notice. The - route therefore enqueues a re-fold of the trailing window whenever the zone actually changes. Best-effort and + A change is not only a display preference. The supplement nutrient rows key on the LOCAL day a dose fell on, + minted under whatever zone was in force at write time, so moving the zone re-buckets them. The route therefore + enqueues a recompute of the account's supplement nutrients whenever the zone actually changes. Best-effort and de-duplicated, so rapid re-picks collapse into one. requestBody: required: true @@ -52993,12 +52992,15 @@ components: type: array items: type: string - description: Local calendar days (YYYY-MM-DD) touched so far, driving the chunked rollup recompute behind the write. + description: "Deprecated and inert: always `[]` on a job admitted from v1.43.8, when the per-day compliance rollup it + drove was removed. A job admitted earlier keeps the days it recorded. Kept for clients that decode it; do + not read it." rollupProcessed: type: integer minimum: 0 maximum: 9007199254740991 - description: Of `touchedDays`, how many have had their rollup recomputed. + description: "Deprecated and inert: always `0` on a job admitted from v1.43.8. Kept for clients that decode it; do not + read it." required: - processed - total diff --git a/docs/ops/intake-repair.md b/docs/ops/intake-repair.md index c1a16cdc8..3c528d5c3 100644 --- a/docs/ops/intake-repair.md +++ b/docs/ops/intake-repair.md @@ -33,9 +33,9 @@ metadata (`medication_schedules`): - After upgrading to v1.15.19+, when a user reports a compliance rate above 100 %, duplicate entries in the medication history, or a dose shown as taken days away from its slot. -- The compliance rollup counts DISTINCT slots since v1.15.19, so the - percentages self-correct on recompute even without running this script. - Running it additionally clears the phantom rows from the history view. +- Compliance counts DISTINCT slots since v1.15.19, so the percentages are + right even without running this script. Running it additionally clears + the phantom rows from the history view. - The `intake-slot-dedup` worker already collapses canonical-slot duplicates at boot and on a daily cron; this script is the manual, immediately-observable fallback and the only path that surfaces the @@ -104,13 +104,9 @@ healthlog-tsx scripts/repair-intake-anomalies.ts --fix as the duplicate losers). A medication without any derivable anchor (PRN-only / unscheduled) is skipped — there is nothing to compare against. -- **Affected compliance rollups are recomputed** for every touched - `(user, medication, day)`. The script executes the same DISTINCT-slot - aggregation SQL the shared helper runs (a verbatim twin of - `recomputeMedicationComplianceForDay` in - `src/lib/rollups/medication-compliance-rollups.ts` — kept inline so - the script needs no app imports), so the scheduled/taken counts and - the rate self-correct immediately. +- **No separate recompute is needed.** Every compliance reader folds the + rows that share one slot, so the scheduled/taken counts and the rate are + right as soon as the duplicates are tombstoned. The script is idempotent: a second `--fix` run finds zero duplicate groups and changes nothing. Exit code is 0 on success (including a clean diff --git a/prisma/migrations/0414_drop_medication_compliance_rollups/migration.sql b/prisma/migrations/0414_drop_medication_compliance_rollups/migration.sql new file mode 100644 index 000000000..e5894b517 --- /dev/null +++ b/prisma/migrations/0414_drop_medication_compliance_rollups/migration.sql @@ -0,0 +1,32 @@ +-- Drop `medication_compliance_rollups`. +-- +-- The per-(user, medication, day) coverage ledger lost its last reader when +-- the dashboard tile and the compliance views moved to the schedule-anchored +-- computation; every dose write still upserted a row into it, a boot backfill +-- refolded it, and it travelled through the data wipe for nothing. The write +-- hook now refreshes only what still derives from a dose day (the supplement +-- nutrients), and the boot retires the backfill queue. +-- +-- Nothing is lost: the table held counts derived from +-- `medication_intake_events`, which stay. +-- +-- Lock wait. Dropping the table removes its two foreign keys, and removing a +-- foreign key's triggers takes ACCESS EXCLUSIVE on the referenced tables, +-- `users` and `medications`, for the instant the catalog changes. Waiting for +-- that lock behind a long reader would queue every login and every +-- medication read behind the migration, so the wait is bounded at five +-- seconds inside one `DO` block (one statement, so it commits or rolls back +-- whole; `migrate deploy` runs a file statement by statement). A lock +-- timeout changes nothing and the file is safe to apply again, which the +-- marker declares (docs/ops/deploy.md, "A migration that timed out on a +-- lock"). +-- +-- @retry-on-lock-timeout + +DO $migration$ +BEGIN + PERFORM set_config('lock_timeout', '5s', true); + + DROP TABLE IF EXISTS "medication_compliance_rollups"; +END +$migration$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 05b4527d1..e1975b2fc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -118,61 +118,61 @@ model User { environmentAqHistoryJson Json? @map("environment_aq_history_json") // Codex OAuth (ChatGPT subscription) - codexAccessTokenEncrypted String? @map("codex_access_token_encrypted") - codexRefreshTokenEncrypted String? @map("codex_refresh_token_encrypted") + codexAccessTokenEncrypted String? @map("codex_access_token_encrypted") + codexRefreshTokenEncrypted String? @map("codex_refresh_token_encrypted") /// @internal: dead mirror of the expiry inside the encrypted Codex creds — /// the refresh path reads the decrypted blob's own `expiresAt` /// (`src/lib/ai/provider.ts`), never this column. Kept (and kept written) /// for wire/backup compatibility rather than dropped. - codexTokenExpiresAt DateTime? @map("codex_token_expires_at") - codexConnectedAt DateTime? @map("codex_connected_at") - codexConnectionStatus String @default("disconnected") @map("codex_connection_status") + codexTokenExpiresAt DateTime? @map("codex_token_expires_at") + codexConnectedAt DateTime? @map("codex_connected_at") + codexConnectionStatus String @default("disconnected") @map("codex_connection_status") // Per-user opt-in to the operator's shared central Codex (ChatGPT // subscription) connection stored on `AppSettings.adminCodex*`. OFF by // default; only takes effect when the operator has actually connected the // central Codex. Routing through it is external egress on a shared, // train-by-default account bound by the operator's rate limits — consent-gated // exactly like any other server-managed egress. - useCentralCodex Boolean @default(false) @map("use_central_codex") - insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw" - insightsCachedAt DateTime? @map("insights_cached_at") + useCentralCodex Boolean @default(false) @map("use_central_codex") + insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw" + insightsCachedAt DateTime? @map("insights_cached_at") /// @internal: legacy plaintext cache of the AI insights payload, read only /// as a fallback for rows the free-text encryption backfill has not reached /// yet (v1.43.7). Every write since then goes to `insightsCachedTextEncrypted` /// and leaves this NULL; the backfill seals and nulls it on the old rows. /// DROP this column in a later release, once the backfill reports zero /// remaining rows on every instance. - insightsCachedText String? @map("insights_cached_text") + insightsCachedText String? @map("insights_cached_text") /// v1.43.7 — the cached AI insights payload (briefing, per-domain readings, /// recommendations; JSON), AES-256-GCM binary layout under /// `INSIGHTS_CACHED_TEXT_AAD`. Read and written only through /// `src/lib/insights/cached-text-cipher.ts`. - insightsCachedTextEncrypted Bytes? @map("insights_cached_text_encrypted") + insightsCachedTextEncrypted Bytes? @map("insights_cached_text_encrypted") // Language the cached text was generated in. The cache is one slot per // user, so a reader in another language must not be served it: the // snapshot briefing lift and the advisor read treat a tag that differs // from the reader's locale as an empty cache and re-warm in the reader's. // NULL on rows written before the tag existed (treated as matching; the // next write tags it). - insightsCachedLocale String? @map("insights_cached_locale") + insightsCachedLocale String? @map("insights_cached_locale") // v1.16.8 — SHA-256 fingerprint of the compacted feature snapshot the // cached comprehensive text was generated from. Nightly / forced / lazy // regenerations compare the fresh snapshot's hash against this and skip // the provider call (refreshing only `insightsCachedAt`) when nothing // the prompt sees has changed. - insightsSnapshotHash String? @map("insights_snapshot_hash") + insightsSnapshotHash String? @map("insights_snapshot_hash") // v1.16.8 — last time a forced full warm's comprehensive step failed or // timed out. The forced warm path backs off for an hour after a failure // so a persistently broken provider chain cannot be re-driven into a // full generation attempt by every page-open. - insightsWarmFailedAt DateTime? @map("insights_warm_failed_at") + insightsWarmFailedAt DateTime? @map("insights_warm_failed_at") // v1.18.7 — UTC YYYY-MM-DD of the last daily-briefing phrasing re-roll. // The content-hash gate keeps the FINDINGS byte-stable on unchanged data, // but the daily-briefing PARAGRAPH is re-rolled once per calendar day at a // higher, seedless temperature so the prose reads fresh day-over-day // without re-paying for the findings. This stamps the day a re-roll ran so // the gate stays at most one extra call per day. - insightsBriefingRerollDate String? @map("insights_briefing_reroll_date") + insightsBriefingRerollDate String? @map("insights_briefing_reroll_date") // Morning-freshness marker: the user-local YYYY-MM-DD (their profile // timezone) for which the event-driven morning refresh has run — i.e. last // night's sleep has arrived AND the sleep-dependent insight/score cache was @@ -181,13 +181,13 @@ model User { // `morning-digest-refresh` job the sleep-arrival trigger enqueues; the // string form makes the "same local morning" comparison timezone-honest // without a second timestamp read. - morningDigestRefreshedOn String? @map("morning_digest_refreshed_on") + morningDigestRefreshedOn String? @map("morning_digest_refreshed_on") // v1.4.36 W3 T3 — per-user AI Insights exclude-metrics list, mirrors // `coachPrefsJson.excludeMetrics`. The /api/insights/generate route // filters matching keys off `features` before JSON.stringify so the // model never sees data the user opted out of. Default `[]` = legacy // behaviour for every existing row. - insightsExcludeMetrics String[] @default([]) @map("insights_exclude_metrics") + insightsExcludeMetrics String[] @default([]) @map("insights_exclude_metrics") // v1.8.5 — user-level injection-site deny-list. Sites listed here are // never offered for ANY medication's injection-site picker and the @@ -897,11 +897,6 @@ model User { // read serves from this table instead of the unbounded // MoodEntry.findMany walk. moodEntryRollups MoodEntryRollup[] - // v1.4.39 W-MED — per-(user, medication, day) compliance rollup. - // Per-write hook on MedicationIntakeEvent mutations + the scheduler - // keep the row in sync so `/api/medications/intake?scope=compliance` - // and `computeUserHealthScoreFastPath` skip the per-event findMany. - medicationComplianceRollups MedicationComplianceRollup[] // v1.4.40 SB-10 — AI consent receipts the iOS app POSTs when the user // grants AI processing during onboarding. Persisted audit trail // covers App-Store Guideline 5.1.2(i) ("data-collection consent must @@ -2972,10 +2967,6 @@ model Medication { /// roll-up (GI / METABOLIC / INJECTION_SITE / COGNITIVE / /// GLP1_SPECIFIC) and 1-5 Likert severity per entry. sideEffects MedicationSideEffect[] - /// v1.4.39 W-MED — per-(medication, day) compliance rollup. Mutations - /// on the linked MedicationIntakeEvent rows refresh the row inline; - /// the read paths consume this instead of the per-event findMany. - complianceRollups MedicationComplianceRollup[] /// v1.16.3 — archived schedule eras (effective dating). Superseded /// schedule states land here when a wholesale schedule replace changes /// cadence-relevant fields; the band minter segments historical minting @@ -5038,39 +5029,6 @@ model MoodEntryRollup { @@map("mood_entry_rollups") } -// ─── Medication Compliance Rollup (v1.4.39 W-MED) ───────────── -// -// Per-(userId, medicationId, day) compliance ledger. Replaces the -// unbounded MedicationIntakeEvent findMany on -// `/api/medications/intake?scope=compliance` and the equivalent -// fan-out inside `computeUserHealthScoreFastPath`. -// -// Day-key is anchored to the user's IANA timezone (TEXT, encoded as -// `YYYY-MM-DD`) so the read path can collapse a date range into a -// straight WHERE clause without re-deriving the boundary per row. -// -// Write path: every MedicationIntakeEvent create / update / delete -// (and the scheduler that mints fresh `scheduledFor` rows) fires -// `recomputeMedicationComplianceForDay` which upserts the -// `(user, medication, day)` row. Worker handles weekly / monthly -// re-fold off the hot path. -model MedicationComplianceRollup { - userId String @map("user_id") - medicationId String @map("medication_id") - day String // YYYY-MM-DD anchored to user.timezone - scheduled Int - taken Int - skipped Int - computedAt DateTime @default(now()) @map("computed_at") @db.Timestamptz(3) - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) - - @@id([userId, medicationId, day]) - @@index([userId, day(sort: Desc)]) - @@map("medication_compliance_rollups") -} - // ─── Integration Status (v1.4.15) ───────────────────────── // // Per-(user, integration) row that captures the most recent sync attempt diff --git a/scripts/migrate-demo-edge01.README.md b/scripts/migrate-demo-edge01.README.md index 35dd6cd15..f21a6414d 100644 --- a/scripts/migrate-demo-edge01.README.md +++ b/scripts/migrate-demo-edge01.README.md @@ -107,8 +107,8 @@ acknowledgement). `audit_logs` rows (the text lives in the `details` JSON) instead. - **Transient / regenerable:** idempotency_keys, rate_limits, data_backups, import_jobs, host_metrics, feedback, recommendation_feedback, and all rollup - tables (measurement_rollups, mood_entry_rollups, - medication_compliance_rollups, strain_trimp_cache, cycle_predictions) — these + tables (measurement_rollups, mood_entry_rollups, strain_trimp_cache, + cycle_predictions) — these rebuild on edge01. - **No rows:** workouts / workout_routes / workout_samples (demo has none). - **Instance / global:** app_settings; global catalogues (mood_tags, diff --git a/scripts/migrate-demo-edge01.sh b/scripts/migrate-demo-edge01.sh index af4918879..228e94714 100755 --- a/scripts/migrate-demo-edge01.sh +++ b/scripts/migrate-demo-edge01.sh @@ -65,8 +65,7 @@ # - Ops / transient / regenerable: idempotency_keys, rate_limits, # data_backups, import_jobs, host_metrics, feedback, # recommendation_feedback, all rollup tables (measurement_rollups, -# mood_entry_rollups, medication_compliance_rollups, strain_trimp_cache, -# cycle_predictions) — these REGENERATE on edge01. +# mood_entry_rollups, strain_trimp_cache, cycle_predictions) — these REGENERATE on edge01. # - workouts / workout_routes / workout_samples — demo has none. # - app_settings — instance-level, not user data. # - Global catalogues (mood_tags, cycle_symptoms) — pre-exist on edge01; @@ -299,7 +298,6 @@ DELETE FROM audit_logs WHERE user_id = '$EDGE_DEMO_ID'; -- regenerable / transient tables that may carry FK rows blocking the user delete DELETE FROM measurement_rollups WHERE user_id = '$EDGE_DEMO_ID'; DELETE FROM mood_entry_rollups WHERE user_id = '$EDGE_DEMO_ID'; -DELETE FROM medication_compliance_rollups WHERE user_id = '$EDGE_DEMO_ID'; DELETE FROM strain_trimp_cache WHERE user_id = '$EDGE_DEMO_ID'; DELETE FROM cycle_predictions WHERE user_id = '$EDGE_DEMO_ID'; DELETE FROM measurements WHERE user_id = '$EDGE_DEMO_ID'; diff --git a/scripts/repair-intake-anomalies.ts b/scripts/repair-intake-anomalies.ts index 70ef1c500..bc851be5e 100644 --- a/scripts/repair-intake-anomalies.ts +++ b/scripts/repair-intake-anomalies.ts @@ -50,14 +50,9 @@ * CREATE/UPDATE only with `--backfill-eras`. Medications that * already have a revision are skipped (idempotent). * - * After any mutation the affected `(user, medication, day)` compliance - * rollups are recomputed with the SAME slot-level DISTINCT aggregation the - * shared helper runs — see `recomputeComplianceDay` below, a verbatim - * SQL twin of `recomputeMedicationComplianceForDay` in - * `src/lib/rollups/medication-compliance-rollups.ts`. The rollup - * aggregates DISTINCT slots since v1.15.19, so a recompute alone already - * heals the counts; the tombstones additionally clear the phantom rows - * from the history view. + * The tombstones clear the phantom rows from the history view; every + * compliance reader folds rows sharing a slot, so the counts need no + * separate recompute. * * Default is a DRY-RUN that only reports. Idempotent: a second `--fix` * run finds zero groups and changes nothing. @@ -211,8 +206,8 @@ function pickWinner(rows: IntakeRow[]): IntakeRow { // Timezone / day-key helpers — self-contained twins of the app helpers // the previous revision imported. Reference implementations: // - `userDayKey` / `DEFAULT_TIMEZONE`: `src/lib/tz/format.ts` -// - `tzOffsetMinutes` / `startOfDayUtcInTz` / the DST-safe day-after -// boundary: `src/lib/rollups/medication-compliance-rollups.ts` +// - `tzOffsetMinutes` / `startOfDayUtcInTz`: `startOfLocalDayKey` in +// `src/lib/tz/local-day.ts` // ───────────────────────────────────────────────────────────────────── const DEFAULT_TIMEZONE = "Europe/Berlin"; @@ -287,100 +282,6 @@ function startOfDayUtcInTz(dayKey: string, tz: string): Date { return guess; } -/** - * `[start, windowEnd)` UTC window of the local calendar day — the same - * DST-safe boundary derivation `recomputeMedicationComplianceForDay` - * runs (23 / 25-hour days fold correctly because the end anchors on - * the NEXT day's local midnight, not `start + 24 h`). - */ -function dayWindowUtc(dayKey: string, tz: string): { start: Date; end: Date } { - const start = startOfDayUtcInTz(dayKey, tz); - const fallbackEnd = new Date(start.getTime() + 86_400_000); - const [y, m, d] = dayKey.split("-").map(Number); - const probeNext = new Date(Date.UTC(y, m - 1, d, 12, 0, 0) + 86_400_000); - const nextDayKey = dayKeyForScheduledFor(probeNext, tz); - const dstSafeEnd = startOfDayUtcInTz(nextDayKey, tz); - return { - start, - end: dstSafeEnd.getTime() > start.getTime() ? dstSafeEnd : fallbackEnd, - }; -} - -/** - * Recompute one `(userId, medicationId, dayKey)` compliance-rollup row. - * - * VERBATIM SQL TWIN of `recomputeMedicationComplianceForDay` in - * `src/lib/rollups/medication-compliance-rollups.ts` — keep the two in - * sync when the rollup contract changes. v1.15.19 semantics: the inner - * CTE folds rows sharing one `scheduled_for` instant into one DISTINCT - * slot with taken-beats-skipped priority; the outer aggregate counts - * slots, not rows, so a duplicated slot can never inflate `scheduled`. - * The upsert re-aggregates inside the statement (atomic against - * concurrent app writers); the trailing DELETE clears the row when the - * day no longer holds any live event. - */ -async function recomputeComplianceDay( - client: Client, - userId: string, - medicationId: string, - dayKey: string, - tz: string | null | undefined, -): Promise { - const { start, end } = dayWindowUtc(dayKey, safeTimezone(tz)); - await client.query( - ` - WITH slot AS ( - SELECT - BOOL_OR("taken_at" IS NOT NULL AND NOT "skipped") AS slot_taken, - BOOL_OR("skipped") AS slot_skipped - FROM "medication_intake_events" - WHERE "user_id" = $1 - AND "medication_id" = $2 - AND "deleted_at" IS NULL - AND "scheduled_for" >= $4::timestamp - AND "scheduled_for" < $5::timestamp - GROUP BY "scheduled_for" - ), - aggregate AS ( - SELECT - COUNT(*)::int AS scheduled, - COALESCE(SUM(CASE WHEN "slot_taken" THEN 1 ELSE 0 END), 0)::int AS taken, - COALESCE(SUM(CASE WHEN NOT "slot_taken" AND "slot_skipped" THEN 1 ELSE 0 END), 0)::int AS skipped - FROM slot - ) - INSERT INTO "medication_compliance_rollups" - ("user_id", "medication_id", "day", "scheduled", "taken", "skipped", "computed_at") - SELECT $1, $2, $3, 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" - `, - [userId, medicationId, dayKey, utcParam(start), utcParam(end)], - ); - await client.query( - ` - DELETE FROM "medication_compliance_rollups" - WHERE "user_id" = $1 - AND "medication_id" = $2 - AND "day" = $3 - AND NOT EXISTS ( - SELECT 1 - FROM "medication_intake_events" - WHERE "user_id" = $1 - AND "medication_id" = $2 - AND "deleted_at" IS NULL - AND "scheduled_for" >= $4::timestamp - AND "scheduled_for" < $5::timestamp - ) - `, - [userId, medicationId, dayKey, utcParam(start), utcParam(end)], - ); -} - interface Summary { duplicateGroups: number; rowsTombstoned: number; @@ -394,7 +295,6 @@ interface Summary { eraRevisionsCreated: number; startsOnCandidates: number; startsOnAdjusted: number; - rollupsRecomputed: number; } const HHMM_RE = /^([01]\d|2[0-3]):([0-5]\d)$/; @@ -453,7 +353,6 @@ async function main(): Promise { eraRevisionsCreated: 0, startsOnCandidates: 0, startsOnAdjusted: 0, - rollupsRecomputed: 0, }; // Optional per-user scope. The fragment is a fixed string with a @@ -461,7 +360,7 @@ async function main(): Promise { const userFilter = opts.userId ? `AND e."user_id" = $1` : ""; const userParams = opts.userId ? [opts.userId] : []; - /** `userId -> timezone` cache for the rollup day-key derivation. */ + /** `userId -> timezone` cache for the local-day derivations. */ const tzCache = new Map(); async function userTimezone(userId: string): Promise { if (!tzCache.has(userId)) { @@ -474,10 +373,6 @@ async function main(): Promise { return tzCache.get(userId) ?? null; } - // `(userId, medicationId, dayKey)` tuples whose compliance rollup needs - // a recompute after the tombstones. Deduped so a busy day folds once. - const daysToRecompute = new Set(); - try { // ───────────────────────────────────────────────────────────────── // 1. Slot duplicates — exact (user, medication, scheduled_for) @@ -568,12 +463,6 @@ async function main(): Promise { [losers.map((r) => r.id)], ); summary.rowsTombstoned += res.rowCount ?? 0; - - const tz = await userTimezone(group.user_id); - const dayKey = dayKeyForScheduledFor(group.scheduled_for, tz); - daysToRecompute.add( - `${group.user_id}|${group.medication_id}|${dayKey}`, - ); } } @@ -651,12 +540,6 @@ async function main(): Promise { console.log( `${TAG} tombstoned ${res.rowCount ?? 0} implausible row(s) (operator opt-in)`, ); - - for (const row of implausible) { - const tz = await userTimezone(row.user_id); - const dayKey = dayKeyForScheduledFor(row.scheduled_for, tz); - daysToRecompute.add(`${row.user_id}|${row.medication_id}|${dayKey}`); - } } // ───────────────────────────────────────────────────────────────── @@ -822,12 +705,6 @@ async function main(): Promise { [staleRows.map((r) => r.id)], ); summary.staleAnchorTombstoned = res.rowCount ?? 0; - - for (const row of staleRows) { - const tz = await userTimezone(row.user_id); - const dayKey = dayKeyForScheduledFor(row.scheduled_for, tz); - daysToRecompute.add(`${row.user_id}|${row.medication_id}|${dayKey}`); - } } // ───────────────────────────────────────────────────────────────── @@ -1057,35 +934,6 @@ async function main(): Promise { summary.startsOnAdjusted += res.rowCount ?? 0; } } - - // ───────────────────────────────────────────────────────────────── - // 6. Recompute the affected compliance rollups so scheduled/taken - // counts self-correct. Same DISTINCT-slot SQL the shared helper - // runs (see `recomputeComplianceDay` above). - // ───────────────────────────────────────────────────────────────── - if (opts.fix && daysToRecompute.size > 0) { - console.log( - `\n${TAG} 6) recomputing ${daysToRecompute.size} compliance rollup day(s)`, - ); - for (const key of daysToRecompute) { - const [userId, medicationId, dayKey] = key.split("|"); - try { - await recomputeComplianceDay( - client, - userId, - medicationId, - dayKey, - await userTimezone(userId), - ); - summary.rollupsRecomputed += 1; - } catch (err) { - console.error( - `${TAG} rollup recompute failed for ${key}: ` + - `${err instanceof Error ? err.message : String(err)}`, - ); - } - } - } } finally { await client.end(); } @@ -1112,7 +960,6 @@ async function main(): Promise { console.log(` era revisions created: ${summary.eraRevisionsCreated}`); console.log(` startsOn candidates: ${summary.startsOnCandidates}`); console.log(` startsOn adjusted: ${summary.startsOnAdjusted}`); - console.log(` rollup days recomputed: ${summary.rollupsRecomputed}`); } main().catch((err) => { diff --git a/scripts/seed-demo-v15.sql b/scripts/seed-demo-v15.sql index 3fdfe2f7a..a15ca2411 100644 --- a/scripts/seed-demo-v15.sql +++ b/scripts/seed-demo-v15.sql @@ -37,7 +37,6 @@ END $$; -- ── Wipe per-user state ─────────────────────────────────────────── DELETE FROM medication_intake_events WHERE user_id IN (SELECT user_id FROM _demo); -DELETE FROM medication_compliance_rollups WHERE user_id IN (SELECT user_id FROM _demo); DELETE FROM medication_schedules WHERE medication_id IN ( SELECT id FROM medications WHERE user_id IN (SELECT user_id FROM _demo) ); diff --git a/src/__tests__/sql-now-utc-guard.test.ts b/src/__tests__/sql-now-utc-guard.test.ts index cf7b5baa3..00df946cb 100644 --- a/src/__tests__/sql-now-utc-guard.test.ts +++ b/src/__tests__/sql-now-utc-guard.test.ts @@ -30,10 +30,6 @@ const ALLOWED: Record = { hits: 1, why: "computed_at is timestamptz", }, - "src/lib/rollups/medication-compliance-rollups.ts": { - hits: 1, - why: "computed_at is timestamptz", - }, "src/lib/jobs/job-failures.ts": { hits: 2, why: "pg-boss tables are timestamptz", diff --git a/src/__tests__/tz-day-paths-harness.test.ts b/src/__tests__/tz-day-paths-harness.test.ts index 7adf653da..598ea1108 100644 --- a/src/__tests__/tz-day-paths-harness.test.ts +++ b/src/__tests__/tz-day-paths-harness.test.ts @@ -85,7 +85,7 @@ import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; import { serialiseDocument } from "@/lib/documents/store"; import { resolveIntakeActionability } from "@/lib/medications/intake-actionable"; import { moodDateKey } from "@/lib/mood/date-key"; -import { dayKeyForScheduledFor } from "@/lib/rollups/medication-compliance-rollups"; +import { dayKeyForScheduledFor } from "@/lib/medications/dose-day-refresh"; import { dateOnlyAtNoonUtc, dayKeyAsUtcMidnight, diff --git a/src/app/api/auth/me/timezone/__tests__/route.test.ts b/src/app/api/auth/me/timezone/__tests__/route.test.ts index 183b1fa9b..ca3670583 100644 --- a/src/app/api/auth/me/timezone/__tests__/route.test.ts +++ b/src/app/api/auth/me/timezone/__tests__/route.test.ts @@ -23,14 +23,6 @@ vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); -// v1.37.19 — the zone change enqueues a compliance re-fold. -const refoldMock = vi.hoisted(() => - vi.fn(async () => ({ enqueued: true, error: null })), -); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - enqueueUserMedicationComplianceBackfill: refoldMock, -})); - // The supplement nutrient rows are keyed by the same local day. const nutrientMock = vi.hoisted(() => vi.fn(async () => ({ queued: true }))); vi.mock("@/lib/medications/ingredients/recompute-job", () => ({ @@ -110,11 +102,9 @@ describe("PUT /api/auth/me/timezone", () => { expect(res.status).toBe(422); }); - // Watched red: with the re-fold enqueue removed from the route (the - // pre-v1.37.19 write), the changed-zone case fails — the compliance - // rollup's day keys were minted under the old zone and nothing ever - // re-bucketed them (the coverage probe compares counts, not keys). - it("enqueues a compliance re-fold when the zone actually changes", async () => { + // The supplement nutrient rows' day keys were minted under the old zone; + // without the enqueue nothing re-buckets them. + it("enqueues the nutrient recompute when the zone actually changes", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ timezone: "Europe/Berlin", @@ -123,11 +113,10 @@ describe("PUT /api/auth/me/timezone", () => { const res = await PUT(mkReq({ timezone: "America/New_York" })); expect(res.status).toBe(200); - expect(refoldMock).toHaveBeenCalledWith("user-1"); expect(nutrientMock).toHaveBeenCalledWith("user-1"); }); - it("does not re-fold when the zone is unchanged", async () => { + it("does not recompute when the zone is unchanged", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ timezone: "Europe/Berlin", @@ -136,7 +125,6 @@ describe("PUT /api/auth/me/timezone", () => { const res = await PUT(mkReq({ timezone: "Europe/Berlin" })); expect(res.status).toBe(200); - expect(refoldMock).not.toHaveBeenCalled(); expect(nutrientMock).not.toHaveBeenCalled(); }); diff --git a/src/app/api/auth/me/timezone/route.ts b/src/app/api/auth/me/timezone/route.ts index 729760646..254a2b4c9 100644 --- a/src/app/api/auth/me/timezone/route.ts +++ b/src/app/api/auth/me/timezone/route.ts @@ -30,7 +30,6 @@ import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { invalidateUserTimezone, isValidTimezone } from "@/lib/tz/resolver"; import { enqueueSupplementNutrientRecompute } from "@/lib/medications/ingredients/recompute-job"; -import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; export const PUT = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -62,24 +61,13 @@ export const PUT = apiHandler(async (request: NextRequest) => { invalidateUserTimezone(user.id); - // v1.37.19 — the medication-compliance rollup keys its rows by - // the LOCAL day the dose fell on, minted under the timezone in force at - // write time. A zone change re-buckets those days, so the stored keys go - // stale (and the coverage probe compares counts, not keys — it cannot - // notice). Re-fold the trailing window through the ordinary backfill - // job; best-effort, the singleton key collapses rapid re-picks. + // The supplement nutrient rows are keyed by the LOCAL day the dose fell + // on, minted under the timezone in force at write time. A zone change + // re-buckets those days, so the account's rows are recomputed through + // the ordinary job; best-effort, the singleton key collapses rapid + // re-picks. if ((before?.timezone ?? null) !== tz) { - const refold = await enqueueUserMedicationComplianceBackfill(user.id); - // The supplement nutrient rows are keyed by the same local day. await enqueueSupplementNutrientRecompute(user.id); - annotate({ - meta: { - timezone_compliance_refold_enqueued: refold.enqueued, - ...(refold.error - ? { timezone_compliance_refold_error: refold.error } - : {}), - }, - }); } await auditLog("user.timezone.update", { diff --git a/src/app/api/ingest/medication/__tests__/route.test.ts b/src/app/api/ingest/medication/__tests__/route.test.ts index 6d441ff39..421dee241 100644 --- a/src/app/api/ingest/medication/__tests__/route.test.ts +++ b/src/app/api/ingest/medication/__tests__/route.test.ts @@ -38,8 +38,8 @@ vi.mock("@/lib/rate-limit", () => ({ vi.mock("@/lib/app-settings", () => ({ isApiGloballyEnabled: vi.fn(), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/medications/dose-day-refresh", () => ({ + refreshDoseSlot: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/medications/inventory/consumption", () => ({ consumeForIntake: vi.fn().mockResolvedValue(undefined), diff --git a/src/app/api/ingest/medication/route.ts b/src/app/api/ingest/medication/route.ts index 33fc0d326..e91694740 100644 --- a/src/app/api/ingest/medication/route.ts +++ b/src/app/api/ingest/medication/route.ts @@ -23,7 +23,7 @@ import { unstableExternalIdShape, } from "@/lib/validations/external-id"; import { isApiGloballyEnabled } from "@/lib/app-settings"; -import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; import { applyCanonicalSlotWrite, resolveSlotForWriteByBand, @@ -337,7 +337,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // v1.4.39 — refresh the compliance rollup for the ingested // event's user-day. - await recomputeMedicationComplianceForEvent({ + await refreshDoseSlot({ userId: apiToken.userId, medicationId: medication.id, scheduledFor: event.scheduledFor, diff --git a/src/app/api/medications/[id]/__tests__/route.test.ts b/src/app/api/medications/[id]/__tests__/route.test.ts index 0713a9094..8cc13470d 100644 --- a/src/app/api/medications/[id]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/__tests__/route.test.ts @@ -51,9 +51,9 @@ vi.mock("@/lib/cache/invalidate", () => ({ // v1.16.1 — the schedule-replace branch recomputes the compliance // rollups of the tombstoned slots; stub the rollup module so the route // test never reaches `$executeRaw`. -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ +vi.mock("@/lib/medications/dose-day-refresh", () => ({ dayKeyForScheduledFor: vi.fn(() => "2026-06-10"), - recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined), + refreshDoseDay: vi.fn().mockResolvedValue(undefined), })); // v1.40 (#1024) — the course writer and reader run against their own // integration suite; here they answer "no course, nothing refused". @@ -99,8 +99,8 @@ import { import { auditLog } from "@/lib/auth/audit"; import { dayKeyForScheduledFor, - recomputeMedicationComplianceForDay, -} from "@/lib/rollups/medication-compliance-rollups"; + refreshDoseDay, +} from "@/lib/medications/dose-day-refresh"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -297,7 +297,7 @@ describe("PUT /api/medications/[id] — schedule replace archives a revision (v1 vi.mocked(getMedicationCategories).mockResolvedValue({}); vi.mocked(auditLog).mockResolvedValue(undefined); vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10"); - vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined); + vi.mocked(refreshDoseDay).mockResolvedValue(undefined); vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([ PREVIOUS_ROW, ] as never); @@ -447,19 +447,19 @@ describe("PUT /api/medications/[id] — schedule replace migrates open slots", ( ROUTE_CTX, ); expect(res.status).toBe(200); - expect(recomputeMedicationComplianceForDay).toHaveBeenCalledTimes(2); - expect(recomputeMedicationComplianceForDay).toHaveBeenCalledWith( - "user-1", - "m1", - "2026-06-10", - "Europe/Berlin", - ); - expect(recomputeMedicationComplianceForDay).toHaveBeenCalledWith( - "user-1", - "m1", - "2026-06-11", - "Europe/Berlin", - ); + expect(refreshDoseDay).toHaveBeenCalledTimes(2); + expect(refreshDoseDay).toHaveBeenCalledWith({ + userId: "user-1", + medicationId: "m1", + dayKey: "2026-06-10", + tz: "Europe/Berlin", + }); + expect(refreshDoseDay).toHaveBeenCalledWith({ + userId: "user-1", + medicationId: "m1", + dayKey: "2026-06-11", + tz: "Europe/Berlin", + }); }); it("does NOT touch intake rows when the body carries no schedules array", async () => { @@ -865,7 +865,7 @@ describe("PUT /api/medications/[id] — intake tracking switch (#1033)", () => { vi.mocked(getMedicationCategories).mockResolvedValue({}); vi.mocked(auditLog).mockResolvedValue(undefined); vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10"); - vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined); + vi.mocked(refreshDoseDay).mockResolvedValue(undefined); vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([ LIVE_ROW, ] as never); @@ -1055,7 +1055,7 @@ describe("PUT /api/medications/[id] — shipped client on a record-only medicati vi.mocked(getMedicationCategories).mockResolvedValue({}); vi.mocked(auditLog).mockResolvedValue(undefined); vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10"); - vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined); + vi.mocked(refreshDoseDay).mockResolvedValue(undefined); vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([ STORED_ROW, ] as never); diff --git a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts index 9e92263a7..cedb34685 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts @@ -37,8 +37,8 @@ vi.mock("@/lib/medications/inventory/consumption", () => ({ consumeForIntake: vi.fn().mockResolvedValue(undefined), restoreForIntake: vi.fn().mockResolvedValue(undefined), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/medications/dose-day-refresh", () => ({ + refreshDoseSlot: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index 615bbac81..4eaafb54d 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -19,7 +19,7 @@ import { import { reconcileOneShotState } from "@/lib/medications/lifecycle"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; -import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz"; import { applyCanonicalSlotWrite, @@ -392,14 +392,14 @@ export const PUT = apiHandler( // scheduledFor day-keys when the dose moved across a day boundary // (band re-attribution can move it as well as an explicit edit); if the // slot is unchanged the second call collapses to a no-op upsert. - await recomputeMedicationComplianceForEvent({ + await refreshDoseSlot({ userId: user.id, medicationId: id, scheduledFor: event.scheduledFor, tz: user.timezone, }); if (slotMoved) { - await recomputeMedicationComplianceForEvent({ + await refreshDoseSlot({ userId: user.id, medicationId: id, scheduledFor: targetScheduledFor, @@ -488,7 +488,7 @@ export const DELETE = apiHandler( // v1.4.39 — refresh the rollup row for the day the deleted // event sat on. When that day now holds zero events the helper // drops the rollup row entirely. - await recomputeMedicationComplianceForEvent({ + await refreshDoseSlot({ userId: user.id, medicationId: id, scheduledFor: event.scheduledFor, diff --git a/src/app/api/medications/[id]/intake/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/__tests__/route.test.ts index 354ab4780..42b3eaf50 100644 --- a/src/app/api/medications/[id]/intake/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/route.test.ts @@ -78,8 +78,8 @@ vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserMedications: vi.fn(), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/medications/dose-day-refresh", () => ({ + refreshDoseSlot: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); diff --git a/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts b/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts index 053deba20..759d6ff64 100644 --- a/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts @@ -86,8 +86,8 @@ vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserMedications: vi.fn(), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/medications/dose-day-refresh", () => ({ + refreshDoseSlot: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/notifications/medication-intake-sync", () => ({ diff --git a/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts index eb99f97bc..a42c9fa5b 100644 --- a/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts @@ -46,12 +46,12 @@ vi.mock("@/lib/rate-limit", () => ({ rateLimitHeaders: vi.fn(() => ({ "X-RateLimit-Remaining": "0" })), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ +vi.mock("@/lib/medications/dose-day-refresh", () => ({ dayKeyForScheduledFor: vi.fn((d: Date) => { const iso = d.toISOString(); return iso.slice(0, 10); }), - recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined), + refreshDoseDay: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/medications/inventory/consumption", () => ({ @@ -98,8 +98,8 @@ import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { checkRateLimit } from "@/lib/rate-limit"; import { dayKeyForScheduledFor, - recomputeMedicationComplianceForDay, -} from "@/lib/rollups/medication-compliance-rollups"; + refreshDoseDay, +} from "@/lib/medications/dose-day-refresh"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -174,7 +174,7 @@ describe("POST /api/medications/[id]/intake/bulk-delete", () => { const json = await res.json(); expect(json.data).toEqual({ deleted: 0 }); expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled(); - expect(recomputeMedicationComplianceForDay).not.toHaveBeenCalled(); + expect(refreshDoseDay).not.toHaveBeenCalled(); // (#22) — a no-op delete queues no cross-device sync fan-out. expect(queueMedicationIntakeSync).not.toHaveBeenCalled(); }); @@ -201,7 +201,7 @@ describe("POST /api/medications/[id]/intake/bulk-delete", () => { expect(dayKeyForScheduledFor).toHaveBeenCalledTimes(3); // Three rows, two unique dayKeys → two recompute calls. - expect(recomputeMedicationComplianceForDay).toHaveBeenCalledTimes(2); + expect(refreshDoseDay).toHaveBeenCalledTimes(2); // (#22) — the whole bulk delete queues exactly ONE cross-device // sync fan-out, not one per removed row. diff --git a/src/app/api/medications/[id]/intake/bulk-delete/route.ts b/src/app/api/medications/[id]/intake/bulk-delete/route.ts index 948db196b..75b0c5f98 100644 --- a/src/app/api/medications/[id]/intake/bulk-delete/route.ts +++ b/src/app/api/medications/[id]/intake/bulk-delete/route.ts @@ -15,8 +15,8 @@ import { restoreForIntake } from "@/lib/medications/inventory/consumption"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { dayKeyForScheduledFor, - recomputeMedicationComplianceForDay, -} from "@/lib/rollups/medication-compliance-rollups"; + refreshDoseDay, +} from "@/lib/medications/dose-day-refresh"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { NextRequest } from "next/server"; @@ -159,26 +159,11 @@ export const POST = apiHandler( }, }); - // Best-effort rollup refresh — one trip per unique dayKey so a - // 14-row delete spanning two days closes in two SQL trips. The - // helper is idempotent and the catch swallows transient - // populator failures (same contract as the single-event delete). + // Best-effort dose-day refresh, one per unique dayKey, so a 14-row + // delete spanning two days refreshes two days (same contract as the + // single-event delete). for (const dayKey of dayKeysToRefresh) { - try { - await recomputeMedicationComplianceForDay(user.id, id, dayKey, tz); - } catch (err) { - annotate({ - action: { - name: "medications.intake.bulk-delete.recompute-failed", - entity_type: "medication", - entity_id: id, - }, - meta: { - day_key: dayKey, - error: err instanceof Error ? err.message : String(err), - }, - }); - } + await refreshDoseDay({ userId: user.id, medicationId: id, dayKey, tz }); } await auditLog("medication.intake.bulk_delete", { diff --git a/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts index 3af9531b4..06e1dd824 100644 --- a/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts @@ -2,9 +2,9 @@ * v1.5.5 — purge route fires the medication cache bundle. * * Pins the contract the detail-page Verlauf-löschen cascade depends - * on: dropping the rollup rows is necessary but not sufficient. The + * on: tombstoning the rows is necessary but not sufficient. The * analytics + iOS today-tally + dashboard tiles all read off cached - * shapes that survive the rollup delete for their TTL; the bundle + * shapes that survive the tombstoning for their TTL; the bundle * invalidation forces every downstream reader to converge on the * post-purge counts in the same tick the user sees the toast. */ @@ -16,7 +16,6 @@ vi.mock("@/lib/db", () => ({ prisma: { medication: { findUnique: vi.fn() }, medicationIntakeEvent: { updateMany: vi.fn() }, - medicationComplianceRollup: { deleteMany: vi.fn() }, // v1.43 — no composition, so no supplement recompute is queued. medicationIngredient: { findFirst: vi.fn().mockResolvedValue(null) }, }, @@ -87,9 +86,6 @@ beforeEach(() => { vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({ count: 7, } as never); - vi.mocked(prisma.medicationComplianceRollup.deleteMany).mockResolvedValue({ - count: 5, - } as never); }); describe("DELETE /api/medications/[id]/intake/purge", () => { diff --git a/src/app/api/medications/[id]/intake/purge/route.ts b/src/app/api/medications/[id]/intake/purge/route.ts index 2885701d7..1737eac0c 100644 --- a/src/app/api/medications/[id]/intake/purge/route.ts +++ b/src/app/api/medications/[id]/intake/purge/route.ts @@ -17,8 +17,8 @@ type RouteParams = { params: Promise<{ id: string }> }; export const DELETE = apiHandler( async (request: NextRequest, { params }: RouteParams) => { // v1.37.0 — MANAGE. It tombstones rather than deletes (since the - // delegated-write release) and drops only rollups that recompute, so the - // history it clears is reconstructable from the rows themselves. + // delegated-write release), so the history it clears is reconstructable + // from the rows themselves. const { user } = await requireRecordAuth("manage", "medications"); const { id } = await params; @@ -60,13 +60,6 @@ export const DELETE = apiHandler( data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, }); - // v1.4.39 — drop every rollup row for this medication so the - // next compliance read returns zero-filled buckets rather than the - // last-known scheduled / taken totals for now-deleted events. - await prisma.medicationComplianceRollup.deleteMany({ - where: { medicationId: id, userId: user.id }, - }); - // v1.43 — the purged doses no longer count toward the micronutrients. if (await medicationHasComposition(user.id, id)) { await enqueueSupplementNutrientRecompute(user.id); diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 0ecd6c9d1..5ea078dba 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -36,7 +36,7 @@ import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear"; import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses"; import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake"; -import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; import { applyCanonicalSlotWrite, findPinConflict, @@ -608,7 +608,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { // v1.4.39 — refresh the persistent compliance rollup for the // affected day. The hook is best-effort; failures annotate but never // block the user's POST response. - await recomputeMedicationComplianceForEvent({ + await refreshDoseSlot({ userId: user.id, medicationId: id, scheduledFor: event.scheduledFor, diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index a1821e376..e711a713d 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -48,8 +48,8 @@ import { } from "@/lib/medications/scheduling/next-due"; import { dayKeyForScheduledFor, - recomputeMedicationComplianceForDay, -} from "@/lib/rollups/medication-compliance-rollups"; + refreshDoseDay, +} from "@/lib/medications/dose-day-refresh"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { effectiveUnitsPerDose, @@ -716,9 +716,9 @@ export const PUT = apiHandler( // Tombstone today's and future open pending rows of this medication. // Runs on a schedule replace (the old anchors are stale) and when intake // tracking is switched off (nothing is due any more). - // The rollup days the tombstoned slots sat on, recomputed once the + // The dose days the tombstoned slots sat on, refreshed once the // transaction below has committed the tombstones. - const staleRollupDays = new Set(); + const staleDoseDays = new Set(); const tombstoneOpenSlots = async ( db: Prisma.TransactionClient, ): Promise => { @@ -768,7 +768,7 @@ export const PUT = apiHandler( }); // Deduped via the user-tz day key. for (const row of tombstoned) { - staleRollupDays.add(dayKeyForScheduledFor(row.scheduledFor, userTz)); + staleDoseDays.add(dayKeyForScheduledFor(row.scheduledFor, userTz)); } }; @@ -1085,26 +1085,16 @@ export const PUT = apiHandler( throw err; } - // Recompute each rollup day the tombstoned slots sat on, now that the - // tombstones are committed. Best-effort like every other rollup - // write-hook: a failure is annotated, never fails the edit. - for (const dayKey of staleRollupDays) { - try { - await recomputeMedicationComplianceForDay( - user.id, - id, - dayKey, - user.timezone || DEFAULT_TIMEZONE, - ); - } catch (err) { - annotate({ - meta: { - medication_compliance_rollup_failed: true, - medication_compliance_rollup_error: - err instanceof Error ? err.message : String(err), - }, - }); - } + // Refresh each dose day the tombstoned slots sat on, now that the + // tombstones are committed. Best-effort like every dose write-hook: a + // failure is reported, never fails the edit. + for (const dayKey of staleDoseDays) { + await refreshDoseDay({ + userId: user.id, + medicationId: id, + dayKey, + tz: user.timezone || DEFAULT_TIMEZONE, + }); } // Apply the v1.5.5 primary-schedule grace bridge after the diff --git a/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts index e5d2109ff..afe537197 100644 --- a/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts +++ b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts @@ -45,7 +45,6 @@ import { toRevisionPayloadEntry, type ScheduleRevisionEntry, } from "@/lib/medications/scheduling/schedule-eras"; -import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import type { Prisma } from "@/generated/prisma/client"; @@ -286,13 +285,10 @@ export const PATCH = apiHandler( }, }); - // The corrected era re-segments history; refresh the pre-aggregated - // compliance rollups asynchronously (best-effort). // v1.16.9 — an era write re-segments the bands every cached payload // (list next-due, compliance cells, dashboard tally) was built on; // hard-evict so the next read reflects the new history immediately. invalidateUserMedications(user.id, { evict: true }); - await enqueueUserMedicationComplianceBackfill(user.id); return apiSuccess({ id: outcome.revision.id, @@ -404,13 +400,10 @@ export const DELETE = apiHandler( meta: { revision_id: revisionId }, }); - // History re-segments without the era; refresh the pre-aggregated - // compliance rollups asynchronously (best-effort). // v1.16.9 — an era write re-segments the bands every cached payload // (list next-due, compliance cells, dashboard tally) was built on; // hard-evict so the next read reflects the new history immediately. invalidateUserMedications(user.id, { evict: true }); - await enqueueUserMedicationComplianceBackfill(user.id); return apiSuccess({ deleted: true }); }, diff --git a/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts b/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts index 93986e9e1..a54c4b903 100644 --- a/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts @@ -42,11 +42,6 @@ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - enqueueUserMedicationComplianceBackfill: vi - .fn() - .mockResolvedValue({ enqueued: true, error: null }), -})); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), @@ -64,7 +59,6 @@ import { GET, POST } from "../route"; import { DELETE, PATCH } from "../[revisionId]/route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; -import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -349,11 +343,6 @@ describe("POST /api/medications/[id]/schedule-revisions", () => { expect(createArgs.data.payload[0].rrule).toBe("FREQ=DAILY"); expect(createArgs.data.payload[0].scheduleType).toBe("SCHEDULED"); - // History re-segmented — the rollup backfill must be enqueued. - expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith( - "user-1", - ); - // Check-then-insert ran inside the transaction with the // per-medication `FOR UPDATE` lock taken first. expect(prisma.$transaction).toHaveBeenCalled(); @@ -432,9 +421,6 @@ describe("DELETE /api/medications/[id]/schedule-revisions/[revisionId]", () => { where: { medicationId: "m1", supersededByRevisionId: "rev-m" }, data: { supersededByRevisionId: null }, }); - expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith( - "user-1", - ); }); }); @@ -533,9 +519,6 @@ describe("PATCH /api/medications/[id]/schedule-revisions/[revisionId]", () => { expect(updateArgs.data.payload[0].rrule).toBe("FREQ=DAILY"); // In-place — no superseding row is minted. expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled(); - expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith( - "user-1", - ); // Check-then-write ran inside the locked transaction. expect(prisma.$transaction).toHaveBeenCalled(); expect(prisma.$queryRaw).toHaveBeenCalled(); @@ -573,8 +556,5 @@ describe("PATCH /api/medications/[id]/schedule-revisions/[revisionId]", () => { where: { id: "rev-t" }, data: { supersededByRevisionId: "rev-fix" }, }); - expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith( - "user-1", - ); }); }); diff --git a/src/app/api/medications/[id]/schedule-revisions/route.ts b/src/app/api/medications/[id]/schedule-revisions/route.ts index 20994aa84..8f27bb9c6 100644 --- a/src/app/api/medications/[id]/schedule-revisions/route.ts +++ b/src/app/api/medications/[id]/schedule-revisions/route.ts @@ -40,7 +40,6 @@ import { toRevisionPayloadEntry, type ScheduleRevisionEntry, } from "@/lib/medications/scheduling/schedule-eras"; -import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import type { Prisma } from "@/generated/prisma/client"; @@ -282,14 +281,10 @@ export const POST = apiHandler( }, }); - // The new era re-segments history the compliance rollups already - // pre-aggregated; refresh them asynchronously. Best-effort like - // every other rollup write-hook. // v1.16.9 — an era write re-segments the bands every cached payload // (list next-due, compliance cells, dashboard tally) was built on; // hard-evict so the next read reflects the new history immediately. invalidateUserMedications(user.id, { evict: true }); - await enqueueUserMedicationComplianceBackfill(user.id); return apiSuccess( { diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts index c1ff368b0..87d35347f 100644 --- a/src/app/api/medications/intake/__tests__/route.test.ts +++ b/src/app/api/medications/intake/__tests__/route.test.ts @@ -34,12 +34,6 @@ vi.mock("@/lib/db", () => ({ // v1.15.18 — the band resolver loads the medication via findFirst. findFirst: vi.fn(), }, - medicationComplianceRollup: { - findFirst: vi.fn(), - findMany: vi.fn(), - upsert: vi.fn(), - deleteMany: vi.fn(), - }, // v1.4.43 — audit-ledger breadcrumb for validation-failed paths. auditLog: { create: vi.fn() }, $transaction: vi.fn(), @@ -119,15 +113,6 @@ beforeEach(() => { vi.mocked(prisma.medicationIntakeEvent.createMany).mockResolvedValue({ count: 0, } as never); - // v1.4.39 — default the coverage probe to "uncovered" so the - // legacy compliance test still exercises the live-fallback branch - // and finds the legacy mocked intake events. - vi.mocked(prisma.medicationComplianceRollup.findFirst).mockResolvedValue( - null, - ); - vi.mocked(prisma.medicationComplianceRollup.findMany).mockResolvedValue( - [] as never, - ); // v1.4.39 — the coverage probe is now a single `$queryRaw` // aggregate returning `{ rolled_days, event_days }`. Default to // "zero rollups, zero events" (covered/trivial-empty) so tests that diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts index f72e787a3..bd530c53c 100644 --- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts +++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts @@ -51,8 +51,8 @@ vi.mock("@/lib/medications/inventory/consumption", () => ({ consumeForIntake: vi.fn().mockResolvedValue(undefined), restoreForIntake: vi.fn().mockResolvedValue(undefined), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/medications/dose-day-refresh", () => ({ + refreshDoseDay: vi.fn().mockResolvedValue(undefined), dayKeyForScheduledFor: vi.fn().mockReturnValue("2026-01-01"), })); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 7b9847940..8e8e244bb 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -65,9 +65,9 @@ import { } from "@/lib/integrations/healthkit-type-sync"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { - recomputeMedicationComplianceForDay, dayKeyForScheduledFor, -} from "@/lib/rollups/medication-compliance-rollups"; + refreshDoseDay, +} from "@/lib/medications/dose-day-refresh"; import { applyCanonicalSlotWrite, findPinConflict, @@ -365,10 +365,9 @@ async function postBulk(request: NextRequest): Promise { let extraDosesUnscheduled = 0; const skipped: Array<{ index: number; reason: string }> = []; const unstableShapes: UnstableExternalIdShape[] = []; - // v1.4.39 — collect distinct `(medicationId, dayKey)` pairs - // touched by the batch so one rollup recompute fires per pair after - // all inserts complete. Per-row recompute would balloon a 500-entry - // batch into 500 sequential rollup hits. + // Distinct `(medicationId, dayKey)` pairs touched by the batch, so one + // dose-day refresh fires per pair after all inserts complete. A per-row + // refresh would balloon a 500-entry batch into 500 sequential refreshes. const touchedDays = new Map< string, { medicationId: string; dayKey: string } @@ -949,30 +948,16 @@ async function postBulk(request: NextRequest): Promise { invalidateUserMedications(user.id, { evict: true }); } - // v1.4.39 — refresh one rollup row per distinct - // `(medicationId, dayKey)` touched by the batch. Best-effort: failures - // are swallowed by the helper's `recomputeMedicationComplianceForEvent` - // wrapper, but bulk callers wrap recompute directly to keep the - // tight ingest loop unaffected by populator issues. + // Refresh the derived rows of each distinct `(medicationId, dayKey)` the + // batch touched. Best-effort: the refresh reports its own failures and + // never fails the ingest. for (const { medicationId, dayKey } of touchedDays.values()) { - try { - await recomputeMedicationComplianceForDay( - user.id, - medicationId, - dayKey, - user.timezone, - ); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - annotate({ - meta: { - medication_compliance_rollup_bulk_failed: true, - medication_compliance_rollup_bulk_error: message, - medication_compliance_rollup_medication: medicationId, - medication_compliance_rollup_day: dayKey, - }, - }); - } + await refreshDoseDay({ + userId: user.id, + medicationId, + dayKey, + tz: user.timezone, + }); } // v1.17.1 (#22) — silent cross-device intake sync. ONE queued fan-out diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 98d92f114..89881c390 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -28,7 +28,7 @@ import { auditLog } from "@/lib/auth/audit"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; -import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; import { buildScheduleAnchoredComplianceBuckets, type ScheduleAnchoredComplianceBucket, @@ -573,7 +573,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // the affected day so the next read after the cache miss returns // the up-to-date `(scheduled, taken, skipped)` tuple. Best-effort: // a populator failure annotates ops without blocking the response. - await recomputeMedicationComplianceForEvent({ + await refreshDoseSlot({ userId: user.id, medicationId: existing.medicationId, scheduledFor: existing.scheduledFor, @@ -584,7 +584,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // source day self-corrects while the destination keeps its stale tuple. const movedTo = updated?.scheduledFor; if (movedTo && movedTo.getTime() !== existing.scheduledFor.getTime()) { - await recomputeMedicationComplianceForEvent({ + await refreshDoseSlot({ userId: user.id, medicationId: existing.medicationId, scheduledFor: movedTo, diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts index 0516e8480..b9ccdb6ab 100644 --- a/src/app/api/telegram/webhook/__tests__/route.test.ts +++ b/src/app/api/telegram/webhook/__tests__/route.test.ts @@ -93,8 +93,8 @@ vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserMedications: vi.fn(), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/medications/dose-day-refresh", () => ({ + refreshDoseSlot: vi.fn().mockResolvedValue(undefined), })); import { POST, GET } from "../route"; diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 447f8f180..436278879 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -75,7 +75,6 @@ export const WIPE_MODELS = [ "MedicationSideEffect", "MedicationInventoryItem", "MedicationIntakeImportJob", - "MedicationComplianceRollup", // The account's own category names (v1.40, #1041). No child rows; the // medications that name one go with their own entry. "MedicationCategoryLabel", diff --git a/src/lib/export/__tests__/restore-backup-owner-mismatch.test.ts b/src/lib/export/__tests__/restore-backup-owner-mismatch.test.ts index f23c35dea..99735016f 100644 --- a/src/lib/export/__tests__/restore-backup-owner-mismatch.test.ts +++ b/src/lib/export/__tests__/restore-backup-owner-mismatch.test.ts @@ -44,7 +44,7 @@ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); vi.mock("@/lib/rollups/mood-rollups", () => ({ recomputeUserMoodRollups: vi.fn(), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ +vi.mock("@/lib/medications/dose-day-refresh", () => ({ recomputeUserMedicationCompliance: vi.fn(), MEDICATION_COMPLIANCE_BACKFILL_DAYS: 30, })); diff --git a/src/lib/export/__tests__/restore-backup-round-trip.test.ts b/src/lib/export/__tests__/restore-backup-round-trip.test.ts index bffce80d4..fc61a2cbe 100644 --- a/src/lib/export/__tests__/restore-backup-round-trip.test.ts +++ b/src/lib/export/__tests__/restore-backup-round-trip.test.ts @@ -66,7 +66,7 @@ vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserData: vi.fn() })); vi.mock("@/lib/rollups/mood-rollups", () => ({ recomputeUserMoodRollups: vi.fn(), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ +vi.mock("@/lib/medications/dose-day-refresh", () => ({ recomputeUserMedicationCompliance: vi.fn(), MEDICATION_COMPLIANCE_BACKFILL_DAYS: 30, })); diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 91e5dc74c..b3c9ddef3 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -655,8 +655,6 @@ export const COVERAGE_PENDING: Readonly> = { export const DERIVED_MODELS: Readonly> = { MeasurementRollup: "A per-day, per-metric reconstruction of `Measurement`. The boot-time rollup backfill rebuilds it for an account that has none.", - MedicationComplianceRollup: - "Recomputed from `MedicationIntakeEvent` against the schedule by the compliance analytics tier.", MoodEntryRollup: "A per-day reconstruction of `MoodEntry`, rebuilt by the same rollup tier that rebuilds the measurement one.", MoodPrediction: diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index 12387f67f..5b6c12e94 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -80,10 +80,6 @@ import { type BackupSummary, } from "@/lib/validations/backup-summary"; import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups"; -import { - recomputeUserMedicationCompliance, - MEDICATION_COMPLIANCE_BACKFILL_DAYS, -} from "@/lib/rollups/medication-compliance-rollups"; import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; import { restoreCycleData } from "@/lib/cycle/backup"; import { @@ -3058,33 +3054,6 @@ export async function restoreBackup( } } - // v1.4.39 — re-fold the medication-compliance rollup tier - // from the restored intake events. The medication delete inside the - // transaction already cascaded the existing rollup partition for - // this owner via the FK, so the fold mints fresh rows. Boot-time - // backfill is the safety net if this best-effort call fails. - if (payload.intakeEvents.length > 0) { - try { - const restoreUser = await prisma.user.findUnique({ - where: { id: ownerId }, - select: { timezone: true }, - }); - await recomputeUserMedicationCompliance( - ownerId, - MEDICATION_COMPLIANCE_BACKFILL_DAYS, - restoreUser?.timezone ?? null, - ); - } catch (err) { - annotate({ - meta: { - medication_compliance_rollup_restore_failed: true, - medication_compliance_rollup_restore_error: - err instanceof Error ? err.message : String(err), - }, - }); - } - } - const summary = { ...summarizeBackup(payload), measurements: measurementsRestored, diff --git a/src/lib/jobs/__tests__/intake-auto-take.test.ts b/src/lib/jobs/__tests__/intake-auto-take.test.ts index dbcb3e0a9..db93d7c97 100644 --- a/src/lib/jobs/__tests__/intake-auto-take.test.ts +++ b/src/lib/jobs/__tests__/intake-auto-take.test.ts @@ -20,8 +20,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@/lib/medications/inventory/consumption", () => ({ consumeForIntake: vi.fn(async () => undefined), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn(async () => undefined), +vi.mock("@/lib/medications/dose-day-refresh", () => ({ + refreshDoseSlot: vi.fn(async () => undefined), dayKeyForScheduledFor: (at: Date, tz: string) => at.toLocaleDateString("sv-SE", { timeZone: tz }), })); @@ -36,7 +36,7 @@ vi.mock("@/lib/medications/lifecycle", () => ({ })); import { consumeForIntake } from "@/lib/medications/inventory/consumption"; -import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { INTAKE_AUTO_TAKE_CRON, @@ -590,7 +590,7 @@ function row(partial: Partial & { id: string; scheduledFor: Date }) { describe("runIntakeAutoTakePass — over a fake client", () => { beforeEach(() => { vi.mocked(consumeForIntake).mockClear(); - vi.mocked(recomputeMedicationComplianceForEvent).mockClear(); + vi.mocked(refreshDoseSlot).mockClear(); vi.mocked(queueMedicationIntakeSync).mockClear(); }); diff --git a/src/lib/jobs/__tests__/medication-intake-import.test.ts b/src/lib/jobs/__tests__/medication-intake-import.test.ts index fae356cd7..671fcf8d4 100644 --- a/src/lib/jobs/__tests__/medication-intake-import.test.ts +++ b/src/lib/jobs/__tests__/medication-intake-import.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest"; import { MEDICATION_IMPORT_SKIP_DETAIL_LIMIT, MEDICATION_INTAKE_IMPORT_CHUNK_SIZE, - MEDICATION_INTAKE_IMPORT_ROLLUP_CHUNK_SIZE, _setMedicationIntakeImportPrismaForTests, advanceMedicationImportProgress, appendMedicationImportSkipDetails, @@ -48,21 +47,18 @@ describe("medication intake import worker bounds", () => { processed: 100, imported: 98, skippedByReason: { duplicate_in_file: 2 }, - touchedDays: ["2026-07-20", "2026-07-21"], }); const second = advanceMedicationImportProgress(first, { from: 100, processed: 100, imported: 97, skippedByReason: { duplicate_in_file: 1, already_recorded: 2 }, - touchedDays: ["2026-07-21", "2026-07-22"], }); const replay = advanceMedicationImportProgress(second, { from: 0, processed: 100, imported: 98, skippedByReason: { duplicate_in_file: 2 }, - touchedDays: ["2026-07-20"], }); expect(first.processed).toBe(100); @@ -71,7 +67,7 @@ describe("medication intake import worker bounds", () => { total: 1_000, imported: 195, skippedByReason: { duplicate_in_file: 3, already_recorded: 2 }, - touchedDays: ["2026-07-20", "2026-07-21", "2026-07-22"], + touchedDays: [], rollupProcessed: 0, }); expect(replay).toBe(second); @@ -89,7 +85,11 @@ describe("medication intake import worker bounds", () => { expect(failure.length).toBeLessThanOrEqual(1_000); }); - it("checkpoints bounded rollup transactions across many touched days", async () => { + it("finishes a job admitted with pending rollup days without recomputing them", async () => { + // A job admitted before the compliance rollup was dropped carries the + // days it touched and how many of them were recomputed. It now finishes + // on its next pass, writes nothing per day, and keeps its stored + // progress shape. const touchedDays = Array.from({ length: 1_000 }, (_, index) => new Date(Date.UTC(2020, 0, index + 1)).toISOString().slice(0, 10), ); @@ -99,32 +99,27 @@ describe("medication intake import worker bounds", () => { imported: 0, skippedByReason: {}, touchedDays, + rollupProcessed: 40, }; let status = "running"; - const executeRawCounts: number[] = []; + let executeRawCount = 0; + let transactions = 0; let auditCount = 0; - let transactionNumber = 0; - let rejectRollup = true; const client = { $transaction: async ( operation: (tx: Record) => Promise, ) => { - transactionNumber += 1; - let executeRawCount = 0; + transactions += 1; const tx = { - $queryRaw: async () => [{ id: "job-many-days" }], + $queryRaw: async () => [{ id: "job-legacy-days" }], $executeRaw: async () => { executeRawCount += 1; - if (rejectRollup && transactionNumber === 3) { - rejectRollup = false; - throw new Error("rollup transaction failed"); - } return 1; }, medicationIntakeImportJob: { findUnique: async () => ({ - id: "job-many-days", + id: "job-legacy-days", recordUserId: "user-1", actorUserId: "user-1", medicationId: "medication-1", @@ -132,7 +127,6 @@ describe("medication intake import worker bounds", () => { payload: { entries: [] }, progress: persistedProgress, startedAt: new Date(), - recordUser: { timezone: "UTC" }, }), update: async (args: { data: { progress?: unknown; status?: string }; @@ -151,43 +145,25 @@ describe("medication intake import worker bounds", () => { }, }, }; - try { - return await operation(tx); - } finally { - executeRawCounts.push(executeRawCount); - } + return operation(tx); }, }; _setMedicationIntakeImportPrismaForTests(client as never); try { - await expect( - processMedicationIntakeImportJob("job-many-days"), - ).rejects.toThrow("rollup transaction failed"); - expect(persistedProgress).toMatchObject({ - rollupProcessed: MEDICATION_INTAKE_IMPORT_ROLLUP_CHUNK_SIZE * 2, - }); - expect(status).toBe("running"); - expect(auditCount).toBe(0); - - await processMedicationIntakeImportJob("job-many-days"); + await processMedicationIntakeImportJob("job-legacy-days"); } finally { _setMedicationIntakeImportPrismaForTests(null); } - expect(executeRawCounts).toHaveLength( - Math.ceil( - touchedDays.length / MEDICATION_INTAKE_IMPORT_ROLLUP_CHUNK_SIZE, - ) + 1, - ); - expect(Math.max(...executeRawCounts)).toBeLessThanOrEqual( - MEDICATION_INTAKE_IMPORT_ROLLUP_CHUNK_SIZE * 2, - ); expect(status).toBe("done"); - expect(persistedProgress).toMatchObject({ - rollupProcessed: touchedDays.length, - }); + expect(transactions).toBe(1); + expect(executeRawCount).toBe(0); expect(auditCount).toBe(1); + expect(persistedProgress).toMatchObject({ + touchedDays, + rollupProcessed: 40, + }); }); }); @@ -229,14 +205,12 @@ describe("medication import skip reporting (#650)", () => { processed: 100, imported: 90, skippedByReason: { duplicate_in_file: 10 }, - touchedDays: [], }); const second = advanceMedicationImportProgress(first, { from: 100, processed: 100, imported: 95, skippedByReason: { already_recorded: 5 }, - touchedDays: [], }); expect(second.skippedByReason).toEqual({ diff --git a/src/lib/jobs/__tests__/medication-reminder-check.test.ts b/src/lib/jobs/__tests__/medication-reminder-check.test.ts index 0f09aeb47..f4a9fa453 100644 --- a/src/lib/jobs/__tests__/medication-reminder-check.test.ts +++ b/src/lib/jobs/__tests__/medication-reminder-check.test.ts @@ -99,9 +99,6 @@ vi.mock("@/lib/notifications/dispatcher", () => ({ })); vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "token") })); vi.mock("@/lib/telegram", () => ({ deleteMessage: vi.fn() })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), -})); vi.mock("@/lib/notifications/reminder-dedup", () => ({ claimNotificationEvent: vi.fn().mockResolvedValue(true), medicationReminderDedupKey: vi.fn(() => "dedup-key"), diff --git a/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts b/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts index 914e854f5..75f250ab7 100644 --- a/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts +++ b/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts @@ -124,9 +124,6 @@ vi.mock("@/lib/notifications/dispatcher", () => ({ })); vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "token") })); vi.mock("@/lib/telegram", () => ({ deleteMessage: vi.fn() })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), -})); import { handleReminderCheck } from "@/lib/jobs/reminder/medication-reminder-check"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; diff --git a/src/lib/jobs/__tests__/queue-policy-tables.test.ts b/src/lib/jobs/__tests__/queue-policy-tables.test.ts index 6e3232928..128c73dad 100644 --- a/src/lib/jobs/__tests__/queue-policy-tables.test.ts +++ b/src/lib/jobs/__tests__/queue-policy-tables.test.ts @@ -60,7 +60,6 @@ describe("rollup registrar queue policies", () => { it.each([ ["ROLLUP_FULL_BACKFILL_QUEUE", "exclusive"], ["MOOD_ROLLUP_FULL_BACKFILL_QUEUE", "exclusive"], - ["MEDICATION_COMPLIANCE_BACKFILL_QUEUE", "exclusive"], ["STEP_CONSOLIDATION_QUEUE", "exclusive"], ["STEP_CONSOLIDATION_REPAIR_QUEUE", "exclusive"], ["CUMULATIVE_PR_REDERIVE_QUEUE", "exclusive"], diff --git a/src/lib/jobs/intake-auto-take.ts b/src/lib/jobs/intake-auto-take.ts index 6915edc2c..e26a42dca 100644 --- a/src/lib/jobs/intake-auto-take.ts +++ b/src/lib/jobs/intake-auto-take.ts @@ -85,8 +85,8 @@ import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake import { isP2002 } from "@/lib/prisma-errors"; import { dayKeyForScheduledFor, - recomputeMedicationComplianceForEvent, -} from "@/lib/rollups/medication-compliance-rollups"; + refreshDoseSlot, +} from "@/lib/medications/dose-day-refresh"; import { DEFAULT_TIMEZONE, validTimezoneOr } from "@/lib/tz/format"; import { getEvent } from "@/lib/logging/context"; @@ -375,7 +375,7 @@ async function autoTakeOne( const dayKey = dayKeyForScheduledFor(slot.at, userTz); if (days.has(dayKey)) continue; days.add(dayKey); - await recomputeMedicationComplianceForEvent({ + await refreshDoseSlot({ userId: medication.userId, medicationId: medication.id, scheduledFor: slot.at, diff --git a/src/lib/jobs/medication-intake-import.ts b/src/lib/jobs/medication-intake-import.ts index 16e1d80fc..3fda3d1b2 100644 --- a/src/lib/jobs/medication-intake-import.ts +++ b/src/lib/jobs/medication-intake-import.ts @@ -9,15 +9,10 @@ import { jobBudget } from "@/lib/jobs/job-budget"; import { enqueueSupplementNutrientRecompute } from "@/lib/medications/ingredients/recompute-job"; import { consumeImportedIntakesBatch } from "@/lib/medications/inventory/consumption"; import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; -import { - dayKeyForScheduledFor, - recomputeMedicationComplianceForDay, -} from "@/lib/rollups/medication-compliance-rollups"; export const MEDICATION_INTAKE_IMPORT_QUEUE = "medication-intake-import"; export const MEDICATION_INTAKE_IMPORT_CONCURRENCY = 2; export const MEDICATION_INTAKE_IMPORT_CHUNK_SIZE = 100; -export const MEDICATION_INTAKE_IMPORT_ROLLUP_CHUNK_SIZE = 20; export const MEDICATION_INTAKE_IMPORT_STALE_AFTER_MS = 15 * 60_000; export const MEDICATION_INTAKE_IMPORT_SEND_OPTIONS = { @@ -136,6 +131,11 @@ export interface MedicationImportProgress { skipDetails?: MedicationImportSkipDetail[]; /** Details beyond the hard cap, counted without retaining their contents. */ skippedDetailsOmitted?: number; + /** + * Wire fields of the dropped compliance rollup's recompute phase. A job + * admitted now carries `[]` and `0` and the worker never advances them; + * they stay on the published progress shape for clients that decode it. + */ touchedDays: string[]; rollupProcessed: number; } @@ -218,7 +218,6 @@ export interface MedicationImportChunkResult { imported: number; skippedByReason: MedicationImportSkipCounts; skipDetails?: MedicationImportSkipDetail[]; - touchedDays: string[]; } let workerPrismaSingleton: PrismaClient | null = null; @@ -251,15 +250,6 @@ export function advanceMedicationImportProgress( previous.total, previous.processed + Math.max(0, Math.trunc(chunk.processed)), ); - const touchedDays = [...previous.touchedDays]; - const seenDays = new Set(touchedDays); - for (const day of chunk.touchedDays) { - if (!seenDays.has(day)) { - seenDays.add(day); - touchedDays.push(day); - } - } - const skippedByReason: MedicationImportSkipCounts = { ...previous.skippedByReason, }; @@ -293,7 +283,7 @@ export function advanceMedicationImportProgress( } : {}), rollupProcessed: previous.rollupProcessed, - touchedDays, + touchedDays: previous.touchedDays, }; } @@ -503,67 +493,6 @@ interface ProcessChunkOutcome { userId: string | null; } -/** - * A day whose compliance rollup an import invalidated, as one string. - * - * A job can now span a whole regimen, so a bare day key no longer identifies - * what to recompute — two medications touched on the same day are two rollup - * rows. The medication is folded in ahead of a single space, which occurs in - * neither a cuid nor a `YYYY-MM-DD` key. Not a NUL byte: this value is persisted - * in the progress blob and Postgres rejects a NUL byte inside a jsonb string. - */ -const TOUCHED_DAY_SEPARATOR = " "; - -function encodeTouchedDay(medicationId: string, dayKey: string): string { - return `${medicationId}${TOUCHED_DAY_SEPARATOR}${dayKey}`; -} - -/** - * Split a stored touched-day back into its parts. - * - * A value with no separator was written before a job could span more than one - * medication, so it is a bare day key belonging to the job's own medication. - * Returns null when neither reading yields a medication — a per-medication job - * that lost its medication cannot be resolved and the day is left alone rather - * than recomputed against a guess. - */ -function decodeTouchedDay( - value: string, - fallbackMedicationId: string | null, -): { medicationId: string; dayKey: string } | null { - const separator = value.indexOf(TOUCHED_DAY_SEPARATOR); - if (separator === -1) { - if (!fallbackMedicationId) return null; - return { medicationId: fallbackMedicationId, dayKey: value }; - } - const medicationId = value.slice(0, separator); - const dayKey = value.slice(separator + 1); - if (medicationId.length === 0 || dayKey.length === 0) return null; - return { medicationId, dayKey }; -} - -async function recomputeTouchedDays( - client: Pick, - userId: string, - jobMedicationId: string | null, - timezone: string, - days: readonly string[], -): Promise { - await Promise.all( - days.map((day) => { - const decoded = decodeTouchedDay(day, jobMedicationId); - if (!decoded) return Promise.resolve(); - return recomputeMedicationComplianceForDay( - userId, - decoded.medicationId, - decoded.dayKey, - timezone, - client, - ); - }), - ); -} - async function processNextChunk( client: PrismaClient, jobId: string, @@ -587,7 +516,6 @@ async function processNextChunk( const row = await tx.medicationIntakeImportJob.findUnique({ where: { id: jobId }, - include: { recordUser: { select: { timezone: true } } }, }); if (!row || row.status === "done" || row.status === "failed") { return { @@ -601,40 +529,6 @@ async function processNextChunk( const payload = parsePayload(row.payload); const progress = parseProgress(row.progress, payload.entries.length); if (progress.processed >= progress.total) { - const rollupDays = progress.touchedDays.slice( - progress.rollupProcessed, - progress.rollupProcessed + MEDICATION_INTAKE_IMPORT_ROLLUP_CHUNK_SIZE, - ); - await recomputeTouchedDays( - tx, - row.recordUserId, - row.medicationId, - row.recordUser.timezone, - rollupDays, - ); - const finalizedProgress: MedicationImportProgress = { - ...progress, - rollupProcessed: progress.rollupProcessed + rollupDays.length, - }; - if (finalizedProgress.rollupProcessed < progress.touchedDays.length) { - const heartbeatAt = new Date(); - await tx.medicationIntakeImportJob.update({ - where: { id: row.id }, - data: { - status: "running", - progress: toJson(finalizedProgress), - heartbeatAt, - startedAt: row.startedAt ?? heartbeatAt, - failureReason: null, - }, - }); - return { - terminal: false, - finalized: false, - result: null, - userId: row.recordUserId, - }; - } const result: MedicationImportResult = { imported: progress.imported, skipped: totalMedicationImportSkips(progress.skippedByReason), @@ -665,7 +559,7 @@ async function processNextChunk( data: { status: "done", result: toJson(result), - progress: toJson(finalizedProgress), + progress: toJson(progress), failureReason: null, heartbeatAt: completedAt, completedAt, @@ -782,12 +676,6 @@ async function processNextChunk( }); } - const touchedDays = created.map((event) => - encodeTouchedDay( - event.medicationId, - dayKeyForScheduledFor(event.scheduledFor, row.recordUser.timezone), - ), - ); // Two different facts, counted apart. The Map collapse above is entries // of this submission that resolve to the same instant; `skipDuplicates` // then drops whatever the ledger already holds for that instant, from an @@ -803,7 +691,6 @@ async function processNextChunk( already_recorded: uniqueEntries.length - created.length, }, skipDetails: [...duplicateDetails, ...alreadyRecordedDetails], - touchedDays, }); const heartbeatAt = new Date(); await tx.medicationIntakeImportJob.update({ diff --git a/src/lib/jobs/queue-runtime.ts b/src/lib/jobs/queue-runtime.ts index a5546c376..14a722a49 100644 --- a/src/lib/jobs/queue-runtime.ts +++ b/src/lib/jobs/queue-runtime.ts @@ -203,9 +203,6 @@ export const QUEUE_RUNTIME: Readonly> = { "mood-rollup-full-backfill": short( "One account's mood entries, a few a day.", ), - "medication-compliance-full-backfill": short( - "One account's 90-day compliance window.", - ), "step-consolidation": short( "One account's legacy step rows, one transaction per day.", ), diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts index 8a0fb1eb4..efe367658 100644 --- a/src/lib/jobs/reminder/medication-reminder-check.ts +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -8,7 +8,6 @@ import { type Job } from "pg-boss"; import type { Locale } from "@/lib/i18n/config"; import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { recordError, recordReminderCheck } from "@/lib/jobs/worker-status"; -import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; import { decrypt } from "@/lib/crypto"; import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; import { withBackgroundEvent } from "@/lib/logging/background"; @@ -708,15 +707,6 @@ export async function handleReminderCheck( missedDosesMinted++; evt.addMeta("missed_dose", `${med.name}:${slotTime}`); - - // v1.4.39 — refresh the compliance rollup so the - // per-day `scheduled` count increments before any read. - await recomputeMedicationComplianceForEvent({ - userId: med.user.id, - medicationId: med.id, - scheduledFor: slotScheduledFor, - tz: med.user.timezone, - }); } } diff --git a/src/lib/jobs/reminder/register-rollup.ts b/src/lib/jobs/reminder/register-rollup.ts index 27534267c..f4ed00269 100644 --- a/src/lib/jobs/reminder/register-rollup.ts +++ b/src/lib/jobs/reminder/register-rollup.ts @@ -38,13 +38,6 @@ import { type MoodRollupFullBackfillPayload, type MoodRollupRecomputePayload, } from "@/lib/rollups/mood-rollups"; -import { - MEDICATION_COMPLIANCE_BACKFILL_QUEUE, - MEDICATION_COMPLIANCE_BACKFILL_CONCURRENCY, - recomputeUserMedicationCompliance, - enqueueBootTimeMedicationComplianceBackfill, - type MedicationComplianceBackfillPayload, -} from "@/lib/rollups/medication-compliance-rollups"; import { SUPPLEMENT_NUTRIENT_RECOMPUTE_QUEUE, SUPPLEMENT_NUTRIENT_RECOMPUTE_CONCURRENCY, @@ -114,6 +107,7 @@ import { createAndSchedule, createAndWork, cronIsTheRetry, + retireQueues, type QueuePolicyTable, type ScheduleEntry, } from "./registrar-shared"; @@ -154,10 +148,6 @@ const allQueues = [ MOOD_ROLLUP_RECOMPUTE_QUEUE, // v1.4.39 — boot-time fold queue for the mood rollup tier. MOOD_ROLLUP_FULL_BACKFILL_QUEUE, - // v1.4.39 — boot-time fold for the medication-compliance - // rollup tier. Discovery enqueues one job per user with intake - // events but no rollup coverage; idempotent across reboots. - MEDICATION_COMPLIANCE_BACKFILL_QUEUE, // v1.5.6 — boot-time legacy step consolidation. Discovery enqueues // one job per user still holding live pre-v1.5.0 granular step rows. STEP_CONSOLIDATION_QUEUE, @@ -248,11 +238,6 @@ const queuePolicies: QueuePolicyTable = { reason: "Per-user mood backfill; boot-discovery driven and self-converging on rollup coverage.", }, - [MEDICATION_COMPLIANCE_BACKFILL_QUEUE]: { - policy: "exclusive", - reason: - "Per-user compliance backfill; boot-discovery driven and self-converging on rollup coverage.", - }, [STEP_CONSOLIDATION_QUEUE]: { policy: "exclusive", reason: @@ -289,6 +274,13 @@ const queuePolicies: QueuePolicyTable = { // empty key and buy nothing. }; +/** + * The boot backfill of the per-day medication-compliance rollup, whose table + * had no reader left and was dropped. Its queue, and any job still waiting in + * it, is removed on an instance that still has it. + */ +const RETIRED_ROLLUP_QUEUES = ["medication-compliance-full-backfill"] as const; + /** * Register every rollup / consolidation queue. Returns the queue names created * (for the boot-level aggregate assertion). @@ -297,6 +289,7 @@ export async function registerRollupQueues( boss: PgBoss, ): Promise { await createAndSchedule(boss, allQueues, schedules, queuePolicies); + await retireQueues(boss, RETIRED_ROLLUP_QUEUES); // v1.5.0 — persistent measurement rollup worker. Folds the // WEEK / MONTH / YEAR buckets that the write-path hooks enqueue; @@ -722,44 +715,6 @@ export async function registerRollupQueues( }, ); - // v1.4.39 — medication-compliance boot-backfill worker. The - // discovery helper below sends one job per user with intake events - // but zero rollup coverage; this handler folds the trailing 90-day - // window per account. Concurrency-1 so the populator never crowds - // the request pool. - await createAndWork( - boss, - MEDICATION_COMPLIANCE_BACKFILL_QUEUE, - { localConcurrency: MEDICATION_COMPLIANCE_BACKFILL_CONCURRENCY }, - async (jobs) => { - let rowsUpsertedTotal = 0; - for (const job of jobs) { - const { userId } = job.data; - try { - const { rowsUpserted, durationMs } = - await recomputeUserMedicationCompliance(userId); - rowsUpsertedTotal += rowsUpserted; - workerLog( - "info", - `[medication-compliance-backfill] user=${userId} rows=${rowsUpserted} duration=${durationMs}ms`, - ); - } catch (err) { - recordError(); - workerLog( - "error", - `[medication-compliance-backfill] user=${userId} failed`, - err, - ); - throw err; - } - } - return jobDone({ - users: jobs.length, - rows_upserted: rowsUpsertedTotal, - }); - }, - ); - // v1.4.37 — nightly drain worker. Walks every user × cumulative // type and folds per-sample APPLE_HEALTH rows older than the cutoff // into one `stats:…` row per calendar day. Idempotent — a second run @@ -1138,34 +1093,6 @@ export async function enqueueRollupBootDiscovery(): Promise { ); } - // v1.4.39 — medication compliance rollup tier. Mirrors the v1.4.35.1 - // pattern: one job per user with intake events but no rollup coverage. - // Idempotent across reboots and singleton-keyed inside pg-boss. - try { - // Stage 4. - const { enqueued, skipped, error } = - await enqueueBootTimeMedicationComplianceBackfill( - BOOT_BACKFILL_STAGGER_SECONDS * 4, - ); - if (error) { - workerLog( - "error", - `[medication-compliance-backfill] boot discovery failed: ${error}`, - ); - } else { - workerLog( - "info", - `[medication-compliance-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`, - ); - } - } catch (err) { - workerLog( - "error", - "[medication-compliance-backfill] boot discovery threw an unexpected error", - err, - ); - } - // v1.28.37 — step-consolidation repair. Finds every account still holding // a resurrectable tombstoned GOOGLE_HEALTH / FITBIT daily-total step row // (swept by the pre-fix consolidation) and enqueues one repair job per diff --git a/src/lib/jobs/subsystem-surface.ts b/src/lib/jobs/subsystem-surface.ts index 2e1d10a9e..1aa67653b 100644 --- a/src/lib/jobs/subsystem-surface.ts +++ b/src/lib/jobs/subsystem-surface.ts @@ -72,7 +72,6 @@ const subsystemSurface = { "rollup-full-backfill": { audience: "system" }, "mood-rollup-recompute": { audience: "system" }, "mood-rollup-full-backfill": { audience: "system" }, - "medication-compliance-full-backfill": { audience: "system" }, "step-consolidation": { audience: "system" }, "step-consolidation-repair": { audience: "system" }, "cumulative-pr-rederive": { audience: "system" }, diff --git a/src/lib/medications/__tests__/intake-slot-dedup-converge.test.ts b/src/lib/medications/__tests__/intake-slot-dedup-converge.test.ts index e9b0ba44b..76269a333 100644 --- a/src/lib/medications/__tests__/intake-slot-dedup-converge.test.ts +++ b/src/lib/medications/__tests__/intake-slot-dedup-converge.test.ts @@ -35,8 +35,8 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() })); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/medications/dose-day-refresh", () => ({ + refreshDoseSlot: vi.fn().mockResolvedValue(undefined), })); import { dedupeUserIntakeSlots } from "../intake-slot-dedup"; diff --git a/src/lib/medications/__tests__/intake-slot-dedup-inventory.test.ts b/src/lib/medications/__tests__/intake-slot-dedup-inventory.test.ts index e453e804c..a21410b50 100644 --- a/src/lib/medications/__tests__/intake-slot-dedup-inventory.test.ts +++ b/src/lib/medications/__tests__/intake-slot-dedup-inventory.test.ts @@ -27,8 +27,8 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() })); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/medications/dose-day-refresh", () => ({ + refreshDoseSlot: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserMedications: vi.fn(), diff --git a/src/lib/medications/__tests__/intake-slot-dedup-isolation.test.ts b/src/lib/medications/__tests__/intake-slot-dedup-isolation.test.ts index 0afdeba11..b8b8ddd54 100644 --- a/src/lib/medications/__tests__/intake-slot-dedup-isolation.test.ts +++ b/src/lib/medications/__tests__/intake-slot-dedup-isolation.test.ts @@ -25,13 +25,13 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() })); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/medications/dose-day-refresh", () => ({ + refreshDoseSlot: vi.fn().mockResolvedValue(undefined), })); import { dedupeUserIntakeSlots } from "../intake-slot-dedup"; import { prisma } from "@/lib/db"; -import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; const TZ = "Europe/Berlin"; @@ -156,7 +156,7 @@ describe("dedupeUserIntakeSlots — per-slot error isolation", () => { // succeeded → exactly one normalised row counted. expect(summary.rowsNormalised).toBe(1); // Both canonical days recomputed despite the collision. - expect(recomputeMedicationComplianceForEvent).toHaveBeenCalledTimes(2); + expect(refreshDoseSlot).toHaveBeenCalledTimes(2); }); it("isolates a non-P2002 error per slot without dead-lettering the job", async () => { diff --git a/src/lib/medications/__tests__/intake-slot-dedup-pin.test.ts b/src/lib/medications/__tests__/intake-slot-dedup-pin.test.ts index 701d337a4..03d115bad 100644 --- a/src/lib/medications/__tests__/intake-slot-dedup-pin.test.ts +++ b/src/lib/medications/__tests__/intake-slot-dedup-pin.test.ts @@ -45,8 +45,8 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() })); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/medications/dose-day-refresh", () => ({ + refreshDoseSlot: vi.fn().mockResolvedValue(undefined), })); import { dedupeUserIntakeSlots } from "../intake-slot-dedup"; diff --git a/src/lib/medications/dose-day-refresh.ts b/src/lib/medications/dose-day-refresh.ts new file mode 100644 index 000000000..6c75af0b8 --- /dev/null +++ b/src/lib/medications/dose-day-refresh.ts @@ -0,0 +1,62 @@ +/** + * The write-path hook every dose mutation funnels through: after a dose of a + * medication was taken, skipped, corrected, deleted, undone or recorded + * automatically, the rows derived from that dose's local day are recomputed. + * + * Today that is one derived row family, the micronutrients a taken + * supplement dose adds (`refreshSupplementNutrientsForDose`). The hook used to + * upsert a per-day compliance rollup as well; that table had no reader left + * and was dropped, so this is what remains of it, under a name that says what + * it does. + * + * Best-effort, like every derived row: a failure is reported by the refresh + * itself and never fails the dose write. + */ +import { refreshSupplementNutrientsForDose } from "@/lib/medications/ingredients/recompute"; +import { DEFAULT_TIMEZONE, isValidTimezone, userDayKey } from "@/lib/tz/format"; + +function safeTimezone(tz: string | null | undefined): string { + return tz && isValidTimezone(tz) ? tz : DEFAULT_TIMEZONE; +} + +/** + * The account-local day (`YYYY-MM-DD`) a dose slot belongs to: the local day + * of its `scheduledFor` in the account's timezone, falling back to the server + * default for a missing or unknown zone. + */ +export function dayKeyForScheduledFor( + scheduledFor: Date, + tz: string | null | undefined, +): string { + return userDayKey(scheduledFor, safeTimezone(tz)); +} + +/** Refresh the derived rows of one medication's local day. */ +export async function refreshDoseDay(input: { + userId: string; + medicationId: string; + dayKey: string; + tz: string | null | undefined; +}): Promise { + await refreshSupplementNutrientsForDose({ + userId: input.userId, + medicationId: input.medicationId, + dayKey: input.dayKey, + timezone: safeTimezone(input.tz), + }); +} + +/** Refresh the derived rows of the local day a dose slot sits on. */ +export async function refreshDoseSlot(input: { + userId: string; + medicationId: string; + scheduledFor: Date; + tz: string | null | undefined; +}): Promise { + await refreshDoseDay({ + userId: input.userId, + medicationId: input.medicationId, + dayKey: dayKeyForScheduledFor(input.scheduledFor, input.tz), + tz: input.tz, + }); +} diff --git a/src/lib/medications/intake-slot-dedup.ts b/src/lib/medications/intake-slot-dedup.ts index 446d42a1d..2644489f6 100644 --- a/src/lib/medications/intake-slot-dedup.ts +++ b/src/lib/medications/intake-slot-dedup.ts @@ -59,7 +59,7 @@ import type { WorkerMedicationRow, WorkerScheduleRow, } from "@/lib/medications/scheduling/worker-helpers"; -import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; export const INTAKE_SLOT_DEDUP_QUEUE = "intake-slot-dedup"; @@ -462,7 +462,7 @@ export async function dedupeUserIntakeSlots( const sep = key.indexOf("|"); const medicationId = key.slice(0, sep); const instantIso = key.slice(sep + 1); - await recomputeMedicationComplianceForEvent({ + await refreshDoseSlot({ userId, medicationId, scheduledFor: new Date(instantIso), diff --git a/src/lib/medications/scheduling/project-today-intakes.ts b/src/lib/medications/scheduling/project-today-intakes.ts index 1ab61da58..f7c1863f7 100644 --- a/src/lib/medications/scheduling/project-today-intakes.ts +++ b/src/lib/medications/scheduling/project-today-intakes.ts @@ -18,9 +18,6 @@ * 2. Reads existing rows in the today-window. * 3. Inserts the missing rows (`skipDuplicates: true` for the * `(userId, medicationId, scheduledFor, source)` unique index). - * 4. Fires one compliance-rollup recompute per distinct - * `(medicationId, dayKey)` so the rollup matches the new - * `scheduled` count before the next read. * * The caller owns its own typed re-read of `MedicationIntakeEvent` * afterwards — the two routes return different projections of those @@ -35,14 +32,12 @@ import { scheduleEmitsInWindow, } from "@/lib/medications/scheduling/worker-helpers"; import { localHmAsUtc } from "@/lib/tz/local-day"; -import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; import { isBeforeLiveEra, LIVE_ERA_REVISION_ARGS, liveEraStart, } from "@/lib/medications/scheduling/live-era"; -import { dayKeyForScheduledFor } from "@/lib/rollups/medication-compliance-rollups"; export interface ProjectTodayIntakesResult { projected: number; @@ -224,36 +219,5 @@ export async function projectTodayIntakesAndRecompute(input: { skipDuplicates: true, }); - // Close the v1.4.39.4 compliance-rollup hook gap: a bulk projection - // mints fresh `(medicationId, scheduledFor)` rows in PENDING state; - // without this hook the rollup row stays at its previous - // `scheduled` count, which inflates compliance % until the user - // actually logs against the new row. Coalesce by `(medicationId, - // dayKey)` so we fire one recompute per distinct day-tuple instead - // of one per row. - // - // `Promise.allSettled` keeps the best-effort contract explicit at - // the call site: the helper swallows internally today, but a - // future refactor that lets a throw escape would otherwise turn - // the parent POST into a 5xx. - const seenDayKeys = new Set(); - const recomputeJobs: Array> = []; - for (const m of missing) { - // The compliance rollup's own day for the slot: two slots on different - // local days that share a UTC date must each get their recompute. - const key = `${m.medicationId}|${dayKeyForScheduledFor(m.scheduledFor, userTz)}`; - if (seenDayKeys.has(key)) continue; - seenDayKeys.add(key); - recomputeJobs.push( - recomputeMedicationComplianceForEvent({ - userId, - medicationId: m.medicationId, - scheduledFor: m.scheduledFor, - tz: userTz, - }), - ); - } - await Promise.allSettled(recomputeJobs); - return { projected: projected.length, backfilled: missing.length }; } diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index 96705ee7b..a3c4352b7 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -1355,13 +1355,15 @@ export const medicationImportProgress = z touchedDays: z .array(z.string()) .describe( - "Local calendar days (YYYY-MM-DD) touched so far, driving the chunked rollup recompute behind the write.", + "Deprecated and inert: always `[]` on a job admitted from v1.43.8, when the per-day compliance rollup it drove was removed. A job admitted earlier keeps the days it recorded. Kept for clients that decode it; do not read it.", ), rollupProcessed: z .number() .int() .min(0) - .describe("Of `touchedDays`, how many have had their rollup recomputed."), + .describe( + "Deprecated and inert: always `0` on a job admitted from v1.43.8. Kept for clients that decode it; do not read it.", + ), }) .meta({ id: "MedicationImportProgress", diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index 8c55acee5..f912151aa 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -2326,7 +2326,7 @@ export const profilePaths: NonNullable = { summary: "Set the account timezone", description: "Lifted out of the profile patch so the picker can send one field without round-tripping the rest. Accepts any zone the running engine's `Intl` knows; anything else is 422 rather than stored, so the resolver never has to defend against a poisoned column. Audit-logged with the old and new zone.\n\n" + - "A change is not only a display preference. The medication-compliance rollup keys its rows by the LOCAL day a dose fell on, minted under whatever zone was in force at write time, so moving the zone re-buckets that history and the stored keys go stale — and the coverage probe compares counts rather than keys, so it cannot notice. The route therefore enqueues a re-fold of the trailing window whenever the zone actually changes. Best-effort and de-duplicated, so rapid re-picks collapse into one.", + "A change is not only a display preference. The supplement nutrient rows key on the LOCAL day a dose fell on, minted under whatever zone was in force at write time, so moving the zone re-buckets them. The route therefore enqueues a recompute of the account's supplement nutrients whenever the zone actually changes. Best-effort and de-duplicated, so rapid re-picks collapse into one.", requestBody: { required: true, content: { "application/json": { schema: timezonePutRequest } }, diff --git a/src/lib/rollups/__tests__/medication-compliance-rollups.test.ts b/src/lib/rollups/__tests__/medication-compliance-rollups.test.ts deleted file mode 100644 index 4c88585ef..000000000 --- a/src/lib/rollups/__tests__/medication-compliance-rollups.test.ts +++ /dev/null @@ -1,403 +0,0 @@ -/** - * v1.4.39 — 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, - 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("aggregates at SLOT level so cross-source duplicate rows cannot inflate scheduled", async () => { - // v1.15.19 — the partial unique index carries `source`, so a pending - // REMINDER row and a taken API row can share one `scheduled_for` - // instant. A row-level COUNT(*) counted that slot twice. Pin that the - // statement groups by `scheduled_for` and folds each slot with - // taken-beats-skipped priority (BOOL_OR) before counting. Semantics - // (two live rows, one slot, different source → scheduled=1, taken=1) - // are proven on real Postgres in - // `tests/integration/medication-compliance-rollup-slot.test.ts`. - await recomputeMedicationComplianceForDay( - "user-1", - "med-1", - "2026-05-18", - "Europe/Berlin", - ); - - const insertSql = (executeRaw.mock.calls[0][0] as readonly string[]).join( - "?", - ); - expect(insertSql).toMatch(/GROUP BY\s+"scheduled_for"/); - expect(insertSql).toMatch( - /BOOL_OR\("taken_at" IS NOT NULL AND NOT "skipped"\)/, - ); - expect(insertSql).toMatch(/BOOL_OR\("skipped"\)/); - // Slot-level taken beats skipped: a slot is `skipped` only when no row - // in it is taken. - expect(insertSql).toMatch(/NOT "slot_taken" AND "slot_skipped"/); - // Scheduled counts slots (the grouped CTE), not raw event rows. - expect(insertSql).toMatch(/FROM slot/); - }); - - 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("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 }); - }); -}); diff --git a/src/lib/rollups/medication-compliance-rollups.ts b/src/lib/rollups/medication-compliance-rollups.ts deleted file mode 100644 index 552b709d1..000000000 --- a/src/lib/rollups/medication-compliance-rollups.ts +++ /dev/null @@ -1,438 +0,0 @@ -/** - * v1.4.39 — 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. - * The dashboard tile it was built for reads the schedule-anchored - * compliance (`src/lib/analytics/schedule-anchored-compliance.ts`) since - * v1.26, and the per-day recompute below is now what keeps a day's - * supplement nutrients in step with its doses; the table itself has no - * reader left. - * - * v1.15.18 DESIGN DECISION — this tier stays a RAW per-day COVERAGE count, - * NOT the band-attributed compliance %. It aggregates, across ALL of a - * user's medications, "of the intake rows whose `scheduled_for` lands on - * this day, how many carry a non-null `taken_at`" — a coarse daily - * cross-medication coverage strip for the dashboard mini-tiles. The - * unified band-membership attribution (the `tallyComplianceFromLedger` - * keystone) is per-medication and per-cadence: it depends on the slot - * bands of each schedule and the user timezone, which cannot be expressed - * in a single cross-medication SQL `SUM(CASE …)`. Re-attributing every - * intake against its cadence's bands before the tally would mean running - * the band minter per `(medication, day)` inside the rollup, which defeats - * the rollup's whole purpose (bounded SQL, no per-row Node walk). The - * detail page + the medication card already read the band-attributed % via - * `calculateCompliance` / `complianceChips`; this tier deliberately remains - * the cheaper coverage signal and is documented as such so the two are not - * confused. The `scheduled` / `taken` column names reflect this: a day's - * `taken` is "slots with an intake logged taken", not "doses inside their - * on-time band". - * - * v1.15.19 — the count is SLOT-level, not row-level. The partial unique - * index on `(user, medication, scheduled_for, source)` lets two live rows - * share one slot instant when their `source` differs (e.g. a pending - * REMINDER row plus a taken API row for the same dose); a row-level - * `COUNT(*)` double-counted that slot as two scheduled doses. The - * aggregate now groups by `scheduled_for` first: `scheduled` counts - * distinct slot instants, and each slot folds its rows with - * taken-beats-skipped priority (`BOOL_OR` of taken, then skipped only when - * no row in the slot is taken). This neutralises cross-source duplicate - * rows retroactively — any recompute over pre-existing duplicates yields - * the deduplicated counts without a data migration. - * - * Audit anchor: `.planning/round-v1438-perf-analysis.md` §2.5 + §5 P4. - */ -import type { PrismaClient } from "@/generated/prisma/client"; -import { prisma } from "@/lib/db"; -import { getGlobalBoss } from "@/lib/jobs/boss-instance"; -import { refreshSupplementNutrientsForDose } from "@/lib/medications/ingredients/recompute"; -import { annotate } from "@/lib/logging/context"; -import { DEFAULT_TIMEZONE, isValidTimezone, userDayKey } from "@/lib/tz/format"; -import { localDayWindow, startOfLocalDayKey } from "@/lib/tz/local-day"; - -/** - * 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; -} - -/** - * 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)); -} - -type MedicationComplianceRollupClient = Pick; - -/** - * 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. - * - */ -export async function recomputeMedicationComplianceForDay( - userId: string, - medicationId: string, - dayKey: string, - tz: string | null | undefined, - client: MedicationComplianceRollupClient = prisma, -): Promise { - const safeTz = safeTimezone(tz); - // The local day's own bounds: 23, 24 or 25 hours on DST days. - const { dayStart: start, dayEnd: windowEnd } = localDayWindow(dayKey, safeTz); - - // v1.4.39: 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. - // v1.15.19 — slot-level aggregation. The inner CTE folds the rows that - // share one `scheduled_for` instant (cross-source duplicates: pending - // REMINDER + taken API) into one slot with taken-beats-skipped priority; - // the outer aggregate then counts slots, not rows, so a duplicated slot - // can never inflate `scheduled`. Pure SQL — re-running the recompute - // over historic duplicates self-corrects the counts. - await client.$executeRaw` - WITH slot AS ( - SELECT - BOOL_OR("taken_at" IS NOT NULL AND NOT "skipped") AS slot_taken, - BOOL_OR("skipped") AS slot_skipped - FROM "medication_intake_events" - WHERE "user_id" = ${userId} - AND "medication_id" = ${medicationId} - AND "deleted_at" IS NULL - AND "scheduled_for" >= ${start} - AND "scheduled_for" < ${windowEnd} - GROUP BY "scheduled_for" - ), - aggregate AS ( - SELECT - COUNT(*)::int AS scheduled, - COALESCE(SUM(CASE WHEN "slot_taken" THEN 1 ELSE 0 END), 0)::int AS taken, - COALESCE(SUM(CASE WHEN NOT "slot_taken" AND "slot_skipped" THEN 1 ELSE 0 END), 0)::int AS skipped - FROM slot - ) - 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 "deleted_at" IS NULL - AND "scheduled_for" >= ${start} - AND "scheduled_for" < ${windowEnd} - ) - `; - - // v1.43 — the day's other derived row: the micronutrients its taken - // supplement doses add (`source = SUPPLEMENT`). Every dose write path - // already funnels through this recompute, so this is the one place the - // hook needs to live. Only on the base client: a caller inside its own - // transaction (the dose import) has not committed the doses yet and - // queues the account's full recompute once it has. - if (client === prisma) { - await refreshSupplementNutrientsForDose({ - userId, - medicationId, - dayKey, - timezone: safeTz, - }); - } -} - -/** - * 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; -}): Promise { - const dayKey = dayKeyForScheduledFor(input.scheduledFor, input.tz); - try { - await recomputeMedicationComplianceForDay( - input.userId, - input.medicationId, - dayKey, - input.tz, - ); - } 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); - } -} - -/** - * 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 in the user's zone 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 = startOfLocalDayKey(oldestKey, safeTz); - - // Distinct (medication_id, dayKey) pairs the user has any intake - // event for inside the trailing window. `scheduled_for` is a naive UTC - // timestamp: it is read as UTC first and only then shifted into the - // user's zone. A single `AT TIME ZONE $tz` reads it as local time and - // shifts it the wrong way, which keyed every slot within the zone's - // offset of midnight onto the neighbouring day. - const pairs = await prisma.$queryRaw< - Array<{ medication_id: string; day: string }> - >` - SELECT DISTINCT - "medication_id", - to_char( - ("scheduled_for" AT TIME ZONE 'UTC') AT TIME ZONE ${safeTz}, - 'YYYY-MM-DD' - ) AS "day" - FROM "medication_intake_events" - WHERE "user_id" = ${userId} - AND "deleted_at" IS NULL - 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 }; -} - -/** - * v1.4.39 — 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( - // Optional boot-storm stagger. When > 0 the per-user sends carry a - // `startAfter` delay (seconds) so this full-history fold does not drain in - // parallel with the other boot backfills onto one heavy tenant. Default 0 - // keeps immediate semantics for any non-boot caller. - startAfterSeconds: number = 0, -): 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>` - 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 mie."deleted_at" IS NULL - 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}`, - ...(startAfterSeconds > 0 ? { startAfter: startAfterSeconds } : {}), - }, - ); - 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), - }; - } -} diff --git a/src/lib/telegram-webhook-handlers.ts b/src/lib/telegram-webhook-handlers.ts index 1cc5bb11d..32e010557 100644 --- a/src/lib/telegram-webhook-handlers.ts +++ b/src/lib/telegram-webhook-handlers.ts @@ -9,7 +9,7 @@ import { import { getServerTranslator } from "@/lib/i18n/server-translator"; import { locales, type Locale } from "@/lib/i18n/config"; import { foldForMatch } from "@/lib/i18n/fold-for-match"; -import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; import { applyCanonicalSlotWrite, resolveForcedSlotForWrite, @@ -212,8 +212,8 @@ async function findTelegramUser(chatId: string) { id: true, telegramBotToken: true, locale: true, - // v1.4.39 — pulled into the select so the post-write - // compliance-rollup hook can anchor the user-day bucket. + // Pulled into the select so the post-write dose-day refresh can + // anchor the user's local day. timezone: true, }, }); @@ -428,7 +428,7 @@ async function markMedicationTaken( // affected day so the next read after the cache miss reflects the // new dose. Only fires when the write landed a row. if (scheduledFor) { - await recomputeMedicationComplianceForEvent({ + await refreshDoseSlot({ userId, medicationId: medication.id, scheduledFor, @@ -690,7 +690,7 @@ export async function handleCallback(update: TelegramUpdate) { // event's user-day so the next read sees the (scheduled, skipped) // counts incremented. if (skippedScheduledFor) { - await recomputeMedicationComplianceForEvent({ + await refreshDoseSlot({ userId: user.id, medicationId: medication.id, scheduledFor: skippedScheduledFor, diff --git a/src/lib/tz/wall-clock.ts b/src/lib/tz/wall-clock.ts index 7ec7f1617..79e4922be 100644 --- a/src/lib/tz/wall-clock.ts +++ b/src/lib/tz/wall-clock.ts @@ -1,6 +1,6 @@ /** * Wall-clock-in-timezone helper shared by the medication scheduling - * + compliance-rollup paths. + * paths. * * Decomposes a `Date` instant into the year/month/day/hour/minute/ * second/weekday tuple an observer in `tz` would read off a wall @@ -12,7 +12,7 @@ * per-user timezone work keep their original shape. * * v1.4.40 W-GHOSTS consolidated two file-local copies - * (`compliance-rollups.ts` returning `{year,month,day}` only + + * (a compliance-rollup copy returning `{year,month,day}` only + * `scheduling/cadence.ts` returning the full tuple) into this single * canonical export. The cross-tz ±3 h guard hinges on these helpers * staying in lock-step; keeping them as separate definitions made diff --git a/tests/integration/intake-slot-dedup.test.ts b/tests/integration/intake-slot-dedup.test.ts index c3f69246a..57da5c2a0 100644 --- a/tests/integration/intake-slot-dedup.test.ts +++ b/tests/integration/intake-slot-dedup.test.ts @@ -164,16 +164,6 @@ describe("intake-slot-dedup — duplicate slot collapse", () => { }); expect(eveningRow.deletedAt).toBeNull(); expect(eveningRow.scheduledFor.toISOString()).toBe(slot1900.toISOString()); - - // The compliance rollup reflects the corrected counts: the 07:00 day - // now has scheduled=2 (07:00 taken + 19:00 pending), taken=1 — not the - // inflated scheduled=3 the duplicate produced. - const rollups = await prisma.medicationComplianceRollup.findMany({ - where: { userId: TEST_USER_ID, medicationId: medId }, - }); - expect(rollups).toHaveLength(1); - expect(rollups[0]?.scheduled).toBe(2); - expect(rollups[0]?.taken).toBe(1); }); it("never collapses a PRN med's multiple rows", async () => { diff --git a/tests/integration/local-day-keys.test.ts b/tests/integration/local-day-keys.test.ts index 04b891ea6..5f9d2e5ef 100644 --- a/tests/integration/local-day-keys.test.ts +++ b/tests/integration/local-day-keys.test.ts @@ -12,7 +12,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { getPrismaClient, truncateAllTables } from "./setup"; import { readActivityDayKeys } from "@/lib/dashboard/summary-reads"; -import { recomputeUserMedicationCompliance } from "@/lib/rollups/medication-compliance-rollups"; import { userDayKey } from "@/lib/tz/format"; vi.mock("@/lib/db-compat", () => ({ @@ -94,31 +93,3 @@ describe("readActivityDayKeys", () => { ); }); }); - -describe("recomputeUserMedicationCompliance", () => { - it("keys a late dose onto the person's own day", async () => { - const med = await prisma.medication.create({ - data: { userId: USER, name: "Ramipril", dose: "5mg", active: true }, - }); - await prisma.medicationIntakeEvent.create({ - data: { - userId: USER, - medicationId: med.id, - scheduledFor: LATE, - takenAt: LATE, - skipped: false, - source: "API", - }, - }); - - await recomputeUserMedicationCompliance(USER, 30, "Europe/Berlin"); - - const rows = await prisma.medicationComplianceRollup.findMany({ - where: { userId: USER }, - select: { day: true, scheduled: true, taken: true }, - }); - expect(rows).toEqual([ - { day: key(D + 86_400_000), scheduled: 1, taken: 1 }, - ]); - }); -}); diff --git a/tests/integration/medication-cadence-worker.test.ts b/tests/integration/medication-cadence-worker.test.ts index 24c6b7a01..f0c6b6600 100644 --- a/tests/integration/medication-cadence-worker.test.ts +++ b/tests/integration/medication-cadence-worker.test.ts @@ -76,13 +76,13 @@ vi.mock("@/lib/db-compat", () => ({ vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserMedications: vi.fn(), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", async () => { +vi.mock("@/lib/medications/dose-day-refresh", async () => { const actual = await vi.importActual< - typeof import("@/lib/rollups/medication-compliance-rollups") - >("@/lib/rollups/medication-compliance-rollups"); + typeof import("@/lib/medications/dose-day-refresh") + >("@/lib/medications/dose-day-refresh"); return { ...actual, - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), + refreshDoseSlot: vi.fn().mockResolvedValue(undefined), }; }); diff --git a/tests/integration/medication-compliance-rollup-slot.test.ts b/tests/integration/medication-compliance-rollup-slot.test.ts deleted file mode 100644 index f80bbe07c..000000000 --- a/tests/integration/medication-compliance-rollup-slot.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -/** - * v1.15.19 — slot-level medication-compliance rollup aggregation. - * - * The partial unique index on `(user, medication, scheduled_for, source)` - * lets two LIVE intake rows share one slot instant when their `source` - * differs — the production shape: the reminder worker pre-mints a pending - * REMINDER row on the slot, and a client write that the resolver refused - * to snap (future-slot guard) used to land a standalone API row on the - * exact same instant. The rollup previously counted rows (`COUNT(*)`), so - * a two-dose day with one duplicated slot reported `scheduled=3..4`. - * - * These tests pin the corrected semantics on real Postgres: - * 1. Two live rows on one slot, different source, one taken → - * scheduled=1, taken=1. - * 2. Taken beats skipped inside a slot. - * 3. The recompute self-corrects historic duplicates with no data - * migration (pure SQL, retroactive). - */ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { getPrismaClient, truncateAllTables } from "./setup"; - -import { - dayKeyForScheduledFor, - recomputeMedicationComplianceForDay, -} from "@/lib/rollups/medication-compliance-rollups"; - -const TEST_USER_ID = "user-compliance-rollup-slot"; -const TZ = "Europe/Berlin"; - -vi.mock("@/lib/db-compat", () => ({ - ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), -})); - -let medId = ""; - -beforeEach(async () => { - const prisma = getPrismaClient(); - await truncateAllTables(prisma); - await prisma.user.create({ - data: { - id: TEST_USER_ID, - username: "compliance-rollup-slot", - email: "compliance-rollup-slot@example.test", - timezone: TZ, - }, - }); - const med = await prisma.medication.create({ - data: { - userId: TEST_USER_ID, - name: "Ramipril", - dose: "5mg", - active: true, - }, - }); - medId = med.id; -}); - -// Fixed non-DST-boundary day so the slot instants are deterministic. -// 2026-03-10 07:00 / 19:00 Europe/Berlin (CET, UTC+1). -const SLOT_0700 = new Date("2026-03-10T06:00:00.000Z"); -const SLOT_1900 = new Date("2026-03-10T18:00:00.000Z"); - -async function readRollup() { - const prisma = getPrismaClient(); - const dayKey = dayKeyForScheduledFor(SLOT_0700, TZ); - await recomputeMedicationComplianceForDay(TEST_USER_ID, medId, dayKey, TZ); - return prisma.medicationComplianceRollup.findFirst({ - where: { userId: TEST_USER_ID, medicationId: medId, day: dayKey }, - }); -} - -describe("medication-compliance rollup — slot-level aggregation (real Postgres)", () => { - it("counts a cross-source duplicate slot once: scheduled=1, taken=1", async () => { - const prisma = getPrismaClient(); - // The pending REMINDER row the worker minted on the slot… - await prisma.medicationIntakeEvent.create({ - data: { - userId: TEST_USER_ID, - medicationId: medId, - scheduledFor: SLOT_0700, - takenAt: null, - skipped: false, - source: "REMINDER", - }, - }); - // …and the standalone API row a pre-fix client write parked on the - // exact same instant (the unique index tolerates it: source differs). - await prisma.medicationIntakeEvent.create({ - data: { - userId: TEST_USER_ID, - medicationId: medId, - scheduledFor: SLOT_0700, - takenAt: new Date("2026-03-10T06:02:00.000Z"), - skipped: false, - source: "API", - }, - }); - - const rollup = await readRollup(); - expect(rollup).not.toBeNull(); - expect(rollup?.scheduled).toBe(1); - expect(rollup?.taken).toBe(1); - expect(rollup?.skipped).toBe(0); - }); - - it("keeps a two-dose day at scheduled=2 when one slot is duplicated", async () => { - const prisma = getPrismaClient(); - await prisma.medicationIntakeEvent.createMany({ - data: [ - // Duplicated morning slot: pending REMINDER + taken API. - { - userId: TEST_USER_ID, - medicationId: medId, - scheduledFor: SLOT_0700, - takenAt: null, - skipped: false, - source: "REMINDER", - }, - { - userId: TEST_USER_ID, - medicationId: medId, - scheduledFor: SLOT_0700, - takenAt: new Date("2026-03-10T06:01:00.000Z"), - skipped: false, - source: "API", - }, - // Clean evening slot, still pending. - { - userId: TEST_USER_ID, - medicationId: medId, - scheduledFor: SLOT_1900, - takenAt: null, - skipped: false, - source: "REMINDER", - }, - ], - }); - - const rollup = await readRollup(); - // Pre-fix this day reported scheduled=3 (row count). Slot-level it is 2. - expect(rollup?.scheduled).toBe(2); - expect(rollup?.taken).toBe(1); - expect(rollup?.skipped).toBe(0); - }); - - it("taken beats skipped inside one slot", async () => { - const prisma = getPrismaClient(); - await prisma.medicationIntakeEvent.createMany({ - data: [ - { - userId: TEST_USER_ID, - medicationId: medId, - scheduledFor: SLOT_0700, - takenAt: null, - skipped: true, - source: "REMINDER", - }, - { - userId: TEST_USER_ID, - medicationId: medId, - scheduledFor: SLOT_0700, - takenAt: new Date("2026-03-10T06:05:00.000Z"), - skipped: false, - source: "API", - }, - ], - }); - - const rollup = await readRollup(); - expect(rollup?.scheduled).toBe(1); - expect(rollup?.taken).toBe(1); - expect(rollup?.skipped).toBe(0); - }); - - it("counts a purely skipped slot as skipped, not taken", async () => { - const prisma = getPrismaClient(); - await prisma.medicationIntakeEvent.create({ - data: { - userId: TEST_USER_ID, - medicationId: medId, - scheduledFor: SLOT_0700, - takenAt: null, - skipped: true, - source: "WEB", - }, - }); - - const rollup = await readRollup(); - expect(rollup?.scheduled).toBe(1); - expect(rollup?.taken).toBe(0); - expect(rollup?.skipped).toBe(1); - }); - - it("ignores soft-deleted rows when folding a slot", async () => { - const prisma = getPrismaClient(); - await prisma.medicationIntakeEvent.createMany({ - data: [ - { - userId: TEST_USER_ID, - medicationId: medId, - scheduledFor: SLOT_0700, - takenAt: new Date("2026-03-10T06:02:00.000Z"), - skipped: false, - source: "API", - deletedAt: new Date(), - }, - { - userId: TEST_USER_ID, - medicationId: medId, - scheduledFor: SLOT_0700, - takenAt: null, - skipped: false, - source: "REMINDER", - }, - ], - }); - - const rollup = await readRollup(); - // The tombstoned taken row must not paint the live pending slot taken. - expect(rollup?.scheduled).toBe(1); - expect(rollup?.taken).toBe(0); - expect(rollup?.skipped).toBe(0); - }); -}); diff --git a/tests/integration/medication-dose-history-import.test.ts b/tests/integration/medication-dose-history-import.test.ts index ddcc2ec54..7d438f990 100644 --- a/tests/integration/medication-dose-history-import.test.ts +++ b/tests/integration/medication-dose-history-import.test.ts @@ -286,24 +286,4 @@ describe("dose-history import — the reported export end to end", () => { }); expect(stillInactive).toBe(16); }, 240_000); - - it("re-folds the compliance rollup for every medication and day it touched", async () => { - const prisma = getPrismaClient(); - const user = await seedRegimen(); - const { jobId } = await enqueue(user.id); - await processMedicationIntakeImportJob(jobId); - - const rollups = await prisma.medicationComplianceRollup.findMany({ - where: { userId: user.id }, - select: { medicationId: true, day: true }, - }); - // One rollup row per (medication, local day) the import wrote into. The job - // spans sixteen medications, so a bare day key could not have addressed - // them — two medications on one day are two rows. - expect(rollups.length).toBeGreaterThan(3000); - expect(new Set(rollups.map((row) => row.medicationId)).size).toBe(16); - expect( - new Set(rollups.map((row) => `${row.medicationId} ${row.day}`)).size, - ).toBe(rollups.length); - }, 480_000); }); diff --git a/tests/integration/medication-intake-import-job.test.ts b/tests/integration/medication-intake-import-job.test.ts index d438fbe3b..38f0bb1ab 100644 --- a/tests/integration/medication-intake-import-job.test.ts +++ b/tests/integration/medication-intake-import-job.test.ts @@ -106,7 +106,7 @@ describe("medication intake import job — concurrent retry", () => { ]); await processMedicationIntakeImportJob(job.id); - const [events, refreshedInventory, rollups, refreshedJob, audits] = + const [events, refreshedInventory, refreshedJob, audits] = await Promise.all([ prisma.medicationIntakeEvent.findMany({ where: { userId: user.id, medicationId: medication.id }, @@ -115,10 +115,6 @@ describe("medication intake import job — concurrent retry", () => { prisma.medicationInventoryItem.findUniqueOrThrow({ where: { id: inventory.id }, }), - prisma.medicationComplianceRollup.findMany({ - where: { userId: user.id, medicationId: medication.id }, - orderBy: { day: "asc" }, - }), prisma.medicationIntakeImportJob.findUniqueOrThrow({ where: { id: job.id }, }), @@ -144,16 +140,6 @@ describe("medication intake import job — concurrent retry", () => { ), ).toBe(true); expect(Number(refreshedInventory.unitsRemaining)).toBe(7); - expect( - rollups.map(({ day, scheduled, taken }) => ({ - day, - scheduled, - taken, - })), - ).toEqual([ - { day: "2026-07-20", scheduled: 2, taken: 2 }, - { day: "2026-07-21", scheduled: 1, taken: 1 }, - ]); expect(refreshedJob.status).toBe("done"); // The two entries sharing an instant collapse inside the submission; that // is `duplicate_in_file`, and it is reported under its own name rather @@ -168,14 +154,10 @@ describe("medication intake import job — concurrent retry", () => { total: 4, imported: 3, skippedByReason: { duplicate_in_file: 1 }, - // A job can now cover a whole regimen, so a day whose rollup needs - // re-folding is addressed by medication AND day — two medications touched - // on one day are two rollup rows, which a bare day key cannot name. - touchedDays: [ - `${medication.id} 2026-07-20`, - `${medication.id} 2026-07-21`, - ], - rollupProcessed: 2, + // Inert since the compliance rollup was dropped: written at admission + // and never advanced. + touchedDays: [], + rollupProcessed: 0, }); expect(audits).toHaveLength(1); expect(invalidateUserMedications).toHaveBeenCalledTimes(1); @@ -270,9 +252,6 @@ describe("medication intake import job — concurrent retry", () => { prisma.medicationIntakeEvent.count({ where: { medicationId: medication.id }, }), - prisma.medicationComplianceRollup.count({ - where: { medicationId: medication.id }, - }), prisma.auditLog.count({ where: { userId: user.id, @@ -285,20 +264,16 @@ describe("medication intake import job — concurrent retry", () => { ]); expect(afterFailedFinalization[0]).toBe(1); expect(afterFailedFinalization[1]).toBe(0); - expect(afterFailedFinalization[2]).toBe(0); - expect(afterFailedFinalization[3].status).toBe("running"); + expect(afterFailedFinalization[2].status).toBe("running"); _setMedicationIntakeImportPrismaForTests(prisma); await processMedicationIntakeImportJob(job.id); await processMedicationIntakeImportJob(job.id); - const [events, rollups, audits, completedJob] = await Promise.all([ + const [events, audits, completedJob] = await Promise.all([ prisma.medicationIntakeEvent.findMany({ where: { medicationId: medication.id }, }), - prisma.medicationComplianceRollup.findMany({ - where: { medicationId: medication.id }, - }), prisma.auditLog.findMany({ where: { userId: user.id, @@ -310,7 +285,6 @@ describe("medication intake import job — concurrent retry", () => { }), ]); expect(events).toHaveLength(1); - expect(rollups).toHaveLength(1); expect(audits).toHaveLength(1); expect(completedJob.status).toBe("done"); }); diff --git a/tests/integration/medication-reminder-cross-midnight.test.ts b/tests/integration/medication-reminder-cross-midnight.test.ts index 64a4072d7..b354d179a 100644 --- a/tests/integration/medication-reminder-cross-midnight.test.ts +++ b/tests/integration/medication-reminder-cross-midnight.test.ts @@ -14,9 +14,6 @@ vi.mock("@/lib/notifications/dispatcher", () => ({ channelsSucceeded: 1, }), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), -})); const { dispatchNotification } = await import("@/lib/notifications/dispatcher"); const { handleReminderCheck } = diff --git a/tests/integration/sharing-delegable-writes.test.ts b/tests/integration/sharing-delegable-writes.test.ts index 735a99f86..5d698aade 100644 --- a/tests/integration/sharing-delegable-writes.test.ts +++ b/tests/integration/sharing-delegable-writes.test.ts @@ -1337,30 +1337,4 @@ describe("marking a dose — what the owner is told, and what the rollup recompu status: "taken", }); }); - - it("recomputes the compliance rollup under the OWNER's id", async () => { - // The dose landed in the owner's record; a rollup row keyed on the - // delegate would leave the owner's compliance reading stale and give the - // delegate a row about a medication they do not have. - const owner = await makeUser("owner"); - const delegate = await makeUser("delegate"); - const { intakeId } = await seedPendingDose(owner.id); - await switchInto(owner.id, delegate.id, "WRITE"); - - const { POST } = await import("@/app/api/medications/intake/route"); - expect( - ( - await post(POST as Handler, "/api/medications/intake", { - intakeId, - status: "taken", - }) - ).status, - ).toBe(200); - - const rollups = await getPrismaClient().medicationComplianceRollup.findMany( - { select: { userId: true } }, - ); - expect(rollups.length).toBeGreaterThan(0); - expect([...new Set(rollups.map((r) => r.userId))]).toEqual([owner.id]); - }); }); diff --git a/tests/integration/supplement-nutrients.test.ts b/tests/integration/supplement-nutrients.test.ts index 2a6f68ad0..75f698b0e 100644 --- a/tests/integration/supplement-nutrients.test.ts +++ b/tests/integration/supplement-nutrients.test.ts @@ -494,9 +494,9 @@ describe("taken doses become the day's SUPPLEMENT micronutrients", () => { takenAt: twoDaysAgo, }, }); - const { recomputeMedicationComplianceForEvent } = - await import("@/lib/rollups/medication-compliance-rollups"); - await recomputeMedicationComplianceForEvent({ + const { refreshDoseSlot } = + await import("@/lib/medications/dose-day-refresh"); + await refreshDoseSlot({ userId, medicationId, scheduledFor: twoDaysAgo, From 03aec3ec12cd1695f509e0f6d4e7e6085e01efe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 13:07:24 +0200 Subject: [PATCH 3/7] perf(db): index every acting foreign key and drop prefix indexes Twenty-one foreign keys with ON DELETE CASCADE or SET NULL had no index leading with their column, so deleting a parent scanned the child table once per row; medication_intake_events.medication_id was the one an account delete paid for. Seventeen small-table indexes and the measurements (user_id, type, measured_at) index repeated the leading columns of another index and only cost writes. Migration 0415 adds and drops the small-table ones in one DO block under a bounded lock wait; 0416 drops the measurements index concurrently, in a file of its own. An integration test holds the schema to both rules from the catalog, each with a counter-check, and compares the plans of the user-and-type query shapes on a seeded table with and without the dropped index. The lock-timeout guard now also requires a CONCURRENTLY statement to stand alone in its file. --- .../migration.sql | 101 +++++ .../migration.sql | 17 + prisma/schema.prisma | 57 ++- .../migration-lock-timeout-guard.test.ts | 54 ++- .../personal-records/pr-detection-worker.ts | 4 +- tests/integration/index-hygiene.test.ts | 360 ++++++++++++++++++ 6 files changed, 572 insertions(+), 21 deletions(-) create mode 100644 prisma/migrations/0415_foreign_key_and_prefix_indexes/migration.sql create mode 100644 prisma/migrations/0416_drop_measurements_type_prefix_index/migration.sql create mode 100644 tests/integration/index-hygiene.test.ts diff --git a/prisma/migrations/0415_foreign_key_and_prefix_indexes/migration.sql b/prisma/migrations/0415_foreign_key_and_prefix_indexes/migration.sql new file mode 100644 index 000000000..1844f11a7 --- /dev/null +++ b/prisma/migrations/0415_foreign_key_and_prefix_indexes/migration.sql @@ -0,0 +1,101 @@ +-- Index hygiene: a leading index for every foreign key that cascades, and +-- no index that is a prefix of another. +-- +-- 1. Twenty-one foreign keys with ON DELETE CASCADE or SET NULL had no index +-- leading with their column. Deleting the parent row (a medication, a +-- schedule, a practitioner, an account) then scans the whole child table +-- once per deleted parent: `medication_intake_events.medication_id` is the +-- one that hurts, scanned in full for every medication an account delete +-- removes. Each gets a plain btree on the column. +-- +-- 2. Seventeen indexes repeated the leading columns of another index on the +-- same table, in the same order and direction, mostly a `(user_id)` index +-- beside a `(user_id, …)` unique key. The longer index answers every +-- lookup the shorter one did, so the shorter one only cost writes and +-- space. They go. `measurements` has its own migration (0416), because a +-- lock on that table weighs differently. +-- +-- `tests/integration/index-hygiene.test.ts` holds the schema to both rules +-- from the catalog. +-- +-- Lock wait. `CREATE INDEX` takes SHARE on its table (reads go on, writes +-- wait while it builds; every table here is small, so the build is quick) +-- and `DROP INDEX` takes ACCESS EXCLUSIVE for the instant the catalog +-- changes. `CREATE INDEX CONCURRENTLY` cannot share a migration file with +-- any other statement (0416), so the whole set runs in one `DO` block: one +-- statement that commits or rolls back whole, with the lock wait bounded at +-- five seconds so a long reader cannot queue the tables' writes behind the +-- migration. Every statement is `IF [NOT] EXISTS`, so a lock timeout leaves +-- the file safe to apply again, which the marker declares +-- (docs/ops/deploy.md, "A migration that timed out on a lock"). +-- +-- @retry-on-lock-timeout + +DO $migration$ +BEGIN + PERFORM set_config('lock_timeout', '5s', true); + + -- 1. Foreign keys without a leading index. + CREATE INDEX IF NOT EXISTS "passkeys_user_id_idx" + ON "passkeys" ("user_id"); + CREATE INDEX IF NOT EXISTS "medication_schedules_medication_id_idx" + ON "medication_schedules" ("medication_id"); + CREATE INDEX IF NOT EXISTS "medication_intake_events_medication_id_idx" + ON "medication_intake_events" ("medication_id"); + CREATE INDEX IF NOT EXISTS "telegram_reminder_messages_schedule_id_idx" + ON "telegram_reminder_messages" ("schedule_id"); + CREATE INDEX IF NOT EXISTS "medication_inventory_items_medication_id_idx" + ON "medication_inventory_items" ("medication_id"); + CREATE INDEX IF NOT EXISTS "medication_side_effects_medication_id_idx" + ON "medication_side_effects" ("medication_id"); + CREATE INDEX IF NOT EXISTS "withings_oauth_states_user_id_idx" + ON "withings_oauth_states" ("user_id"); + CREATE INDEX IF NOT EXISTS "whoop_oauth_states_user_id_idx" + ON "whoop_oauth_states" ("user_id"); + CREATE INDEX IF NOT EXISTS "fitbit_oauth_states_user_id_idx" + ON "fitbit_oauth_states" ("user_id"); + CREATE INDEX IF NOT EXISTS "google_health_oauth_states_user_id_idx" + ON "google_health_oauth_states" ("user_id"); + CREATE INDEX IF NOT EXISTS "whoop_connect_tickets_user_id_idx" + ON "whoop_connect_tickets" ("user_id"); + CREATE INDEX IF NOT EXISTS "cycle_day_logs_cycle_id_idx" + ON "cycle_day_logs" ("cycle_id"); + CREATE INDEX IF NOT EXISTS "invite_tokens_created_by_idx" + ON "invite_tokens" ("created_by"); + CREATE INDEX IF NOT EXISTS "invite_tokens_used_by_idx" + ON "invite_tokens" ("used_by"); + CREATE INDEX IF NOT EXISTS "invite_redemptions_user_id_idx" + ON "invite_redemptions" ("user_id"); + CREATE INDEX IF NOT EXISTS "custom_metric_entries_custom_metric_id_idx" + ON "custom_metric_entries" ("custom_metric_id"); + CREATE INDEX IF NOT EXISTS "encounters_reminder_id_idx" + ON "encounters" ("reminder_id"); + CREATE INDEX IF NOT EXISTS "vaccination_records_encounter_id_idx" + ON "vaccination_records" ("encounter_id"); + CREATE INDEX IF NOT EXISTS "vaccination_records_practitioner_id_idx" + ON "vaccination_records" ("practitioner_id"); + CREATE INDEX IF NOT EXISTS "vaccination_records_reminder_id_idx" + ON "vaccination_records" ("reminder_id"); + CREATE INDEX IF NOT EXISTS "managed_profile_handovers_created_by_id_idx" + ON "managed_profile_handovers" ("created_by_id"); + + -- 2. Indexes that are a prefix of another index on the same table. + DROP INDEX IF EXISTS "biomarkers_user_id_idx"; + DROP INDEX IF EXISTS "clinician_share_link_documents_share_link_id_idx"; + DROP INDEX IF EXISTS "coach_usage_user_id_date_key_idx"; + DROP INDEX IF EXISTS "custom_metrics_user_id_idx"; + DROP INDEX IF EXISTS "cycle_day_logs_user_id_date_idx"; + DROP INDEX IF EXISTS "data_backups_user_id_idx"; + DROP INDEX IF EXISTS "environment_contexts_user_id_date_idx"; + DROP INDEX IF EXISTS "illness_day_logs_episode_id_idx"; + DROP INDEX IF EXISTS "integration_statuses_user_id_idx"; + DROP INDEX IF EXISTS "medications_user_id_idx"; + DROP INDEX IF EXISTS "menstrual_cycles_user_id_start_date_idx"; + DROP INDEX IF EXISTS "mood_entries_user_id_date_idx"; + DROP INDEX IF EXISTS "notification_channels_user_id_idx"; + DROP INDEX IF EXISTS "push_subscriptions_user_id_idx"; + DROP INDEX IF EXISTS "refresh_tokens_user_id_idx"; + DROP INDEX IF EXISTS "user_achievements_user_id_idx"; + DROP INDEX IF EXISTS "user_known_devices_user_id_idx"; +END +$migration$; diff --git a/prisma/migrations/0416_drop_measurements_type_prefix_index/migration.sql b/prisma/migrations/0416_drop_measurements_type_prefix_index/migration.sql new file mode 100644 index 000000000..ff86f6c2c --- /dev/null +++ b/prisma/migrations/0416_drop_measurements_type_prefix_index/migration.sql @@ -0,0 +1,17 @@ +-- Drop `measurements_user_id_type_measured_at_idx`. +-- +-- `(user_id, type, measured_at)` is the leading part of the unique key +-- `(user_id, type, measured_at, source, sleep_stage)`, and live reads have +-- `measurements_live_covering_idx` besides. Every query shape that used the +-- shorter index plans the same way on the unique key, at a cost within a few +-- percent (`tests/integration/index-hygiene.test.ts` compares the plans on a +-- seeded table). The index cost every write to the densest table in the +-- schema and, on a bloated instance, more space than the table itself. +-- +-- `DROP INDEX CONCURRENTLY` takes no lock that blocks reads or writes: it +-- waits for the transactions already using the index to finish and then +-- removes it. It refuses to run inside a transaction block, and under +-- `migrate deploy` it does so whenever the file holds any other statement +-- (Prisma 7 on Postgres 16 reports 25001), so this file holds exactly this +-- one statement. `IF EXISTS` makes a re-run a no-op. +DROP INDEX CONCURRENTLY IF EXISTS "measurements_user_id_type_measured_at_idx"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e1975b2fc..63fa6d26a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1248,7 +1248,6 @@ model EnvironmentContext { updatedAt DateTime @updatedAt @map("updated_at") @@unique([userId, date]) - @@index([userId, date]) // `environment_contexts_aq_gap_idx` (user_id, date) WHERE aq_fetched_at IS // NULL is a partial index from migration 0381 (raw SQL only). @@map("environment_contexts") @@ -1300,6 +1299,8 @@ model Passkey { user User @relation(fields: [userId], references: [id], onDelete: Cascade) + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([userId]) @@map("passkeys") } @@ -1484,7 +1485,6 @@ model UserKnownDevice { // twice for one account, so the "first sighting → alert" decision is a // single unique-constrained upsert. @@unique([userId, deviceHash]) - @@index([userId]) @@map("user_known_devices") } @@ -2088,7 +2088,6 @@ model Measurement { // v1.4.23 — Apple Health batch dedup key. NULL externalId is distinct // (Postgres semantics) so manual entries don't collide here. @@unique([userId, type, source, externalId]) - @@index([userId, type, measuredAt]) // v1.25 — type-less `(userId, measuredAt)` range scan. The bulk feature read // (`extractFeatures`) and consolidation scans walk a user's measurements by // time with no `type` predicate; `(userId, type, measuredAt)` can't serve @@ -3003,7 +3002,6 @@ model Medication { /// re-mirror of the same concept id returns the existing row (the /// create route pre-queries the triple and catches the P2002 race). @@unique([userId, externalSource, externalId]) - @@index([userId]) @@index([treatmentClass]) @@map("medications") } @@ -3213,6 +3211,8 @@ model MedicationSchedule { medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) telegramMessages TelegramReminderMessage[] + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([medicationId]) @@map("medication_schedules") } @@ -3491,6 +3491,8 @@ model MedicationIntakeEvent { // without this one the purge planned a sequential scan and could not finish // inside statement_timeout. Raw SQL only (partial predicate not expressible // in Prisma). + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([medicationId]) @@map("medication_intake_events") } @@ -3655,6 +3657,8 @@ model MedicationInventoryItem { // `(user_id) WHERE notes IS NOT NULL AND notes_encrypted IS NULL` — see // migration 0243 (v1.28.46 H4) for the med-notes boot-discovery scan. Raw // SQL only (partial predicate not expressible in Prisma). + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([medicationId]) @@map("medication_inventory_items") } @@ -3718,6 +3722,8 @@ model MedicationSideEffect { // `(user_id) WHERE notes IS NOT NULL AND notes_encrypted IS NULL` — see // migration 0243 (v1.28.46 H4) for the med-notes boot-discovery scan. Raw // SQL only (partial predicate not expressible in Prisma). + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([medicationId]) @@map("medication_side_effects") } @@ -3809,6 +3815,8 @@ model TelegramReminderMessage { @@unique([recipientUserId, medicationId, scheduleId, date, phase, timeOfDay], map: "telegram_reminder_messages_recipient_slot_key") @@index([medicationId, date]) + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([scheduleId]) @@map("telegram_reminder_messages") } @@ -4004,6 +4012,9 @@ model InviteToken { redemptions InviteRedemption[] @@index([expiresAt]) + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([createdBy]) + @@index([usedBy]) @@map("invite_tokens") } @@ -4047,6 +4058,8 @@ model ManagedProfileHandover { @@index([profileId]) // `managed_profile_handovers_open_idx` UNIQUE (profile_id) WHERE used_at IS // NULL AND revoked_at IS NULL is a partial index from migration 0383. + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([createdById]) @@map("managed_profile_handovers") } @@ -4100,6 +4113,8 @@ model InviteRedemption { user User? @relation("InviteRedemptions", fields: [userId], references: [id], onDelete: SetNull) @@index([inviteId]) + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([userId]) @@map("invite_redemptions") } @@ -4136,7 +4151,6 @@ model RefreshToken { user User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@index([userId]) /// Composite index — every reuse-detection / device-scoped lookup filters /// on (userId, deviceId), so a single-column device_id index would never /// be used while costing writes. Multiple live rows per (userId, deviceId) @@ -4267,6 +4281,8 @@ model WithingsOAuthState { user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([expiresAt]) + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([userId]) @@map("withings_oauth_states") } @@ -4348,6 +4364,8 @@ model WhoopOAuthState { user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([expiresAt]) + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([userId]) @@map("whoop_oauth_states") } @@ -4380,6 +4398,8 @@ model WhoopConnectTicket { user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([expiresAt]) + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([userId]) @@map("whoop_connect_tickets") } @@ -4434,6 +4454,8 @@ model FitbitOAuthState { user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([expiresAt]) + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([userId]) @@map("fitbit_oauth_states") } @@ -4512,6 +4534,8 @@ model GoogleHealthOAuthState { user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([expiresAt]) + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([userId]) @@map("google_health_oauth_states") } @@ -4642,7 +4666,6 @@ model MoodEntry { // that carry a source-stable id upsert on this; native/MANUAL entries // (NULL externalId) are distinct here and stay on the legacy key above. @@unique([userId, source, externalId]) - @@index([userId, date]) // v1.12.1 (P1-3) — mood-insights reads filter `{ userId, deletedAt: // null, moodLoggedAt: { gte } }` ordered by `moodLoggedAt desc`; this // index turns that into a range scan. See migration 0123. @@ -5098,7 +5121,6 @@ model IntegrationStatus { user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, integration]) - @@index([userId]) @@index([state]) @@map("integration_statuses") } @@ -5451,7 +5473,6 @@ model NotificationChannel { preferences NotificationPreference[] @@unique([userId, type]) - @@index([userId]) @@map("notification_channels") } @@ -5479,7 +5500,6 @@ model PushSubscription { user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, endpoint]) - @@index([userId]) @@map("push_subscriptions") } @@ -5526,7 +5546,6 @@ model DataBackup { chunks DataBackupChunk[] @@unique([userId, type], map: "data_backups_user_id_type_key") - @@index([userId]) @@map("data_backups") } @@ -6369,7 +6388,6 @@ model Biomarker { mergesInto BiomarkerMerge[] @@unique([userId, name]) - @@index([userId]) @@map("biomarkers") } @@ -6412,7 +6430,6 @@ model CustomMetric { entries CustomMetricEntry[] @@unique([userId, name]) - @@index([userId]) @@map("custom_metrics") } @@ -6450,6 +6467,8 @@ model CustomMetricEntry { // `(user_id) WHERE note IS NOT NULL` — see // migration 0357 for the free-text encryption boot-discovery scan. Raw SQL // only (partial predicate not expressible in Prisma). + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([customMetricId]) @@map("custom_metric_entries") } @@ -6497,7 +6516,6 @@ model UserAchievement { user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, achievementId]) - @@index([userId]) @@map("user_achievements") } @@ -7189,7 +7207,6 @@ model CoachUsage { user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, dateKey]) - @@index([userId, dateKey]) @@map("coach_usage") } @@ -7440,7 +7457,6 @@ model ClinicianShareLinkDocument { document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade) @@unique([shareLinkId, documentId]) - @@index([shareLinkId]) @@index([documentId]) @@map("clinician_share_link_documents") } @@ -8031,7 +8047,6 @@ model MenstrualCycle { dayLogs CycleDayLog[] @@unique([userId, startDate]) - @@index([userId, startDate]) // Keyset support for the `/api/sync/changes` cycle feed // (`where userId` ordered by `(updatedAt asc, id asc)`). @@index([userId, updatedAt, id]) @@ -8131,9 +8146,10 @@ model CycleDayLog { // NULL-distinct external dedup key for HealthKit re-ingest (the // MoodEntry dual-unique precedent). @@unique([userId, source, externalId]) - @@index([userId, date]) // Keyset support for the `/api/sync/changes` cycle-day feed. @@index([userId, updatedAt, id]) + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([cycleId]) @@map("cycle_day_logs") } @@ -8325,7 +8341,6 @@ model IllnessDayLog { @@unique([episodeId, date]) @@index([userId, date]) - @@index([episodeId]) @@map("illness_day_logs") } @@ -9332,6 +9347,8 @@ model Encounter { @@index([userId, deletedAt, occurredAt]) @@index([userId, status, occurredAt]) @@index([practitionerId]) + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([reminderId]) @@map("encounters") } @@ -9592,6 +9609,10 @@ model VaccinationRecord { @@index([userId, deletedAt, occurredAt]) @@index([userId, antigenSlug, occurredAt]) @@index([customVaccineId]) + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([encounterId]) + @@index([practitionerId]) + @@index([reminderId]) @@map("vaccination_records") } diff --git a/src/__tests__/migration-lock-timeout-guard.test.ts b/src/__tests__/migration-lock-timeout-guard.test.ts index 6afe05a6e..45c7595bf 100644 --- a/src/__tests__/migration-lock-timeout-guard.test.ts +++ b/src/__tests__/migration-lock-timeout-guard.test.ts @@ -89,6 +89,56 @@ function violations(name: string, sql: string): string[] { return found; } +/** + * `CREATE INDEX CONCURRENTLY` / `DROP INDEX CONCURRENTLY` refuse to run in a + * transaction block, and under `migrate deploy` they do so whenever the file + * holds any other statement (SQLSTATE 25001, seen against Prisma 7 and + * Postgres 16). Such a statement therefore stands alone in its file. + */ +function concurrentViolations(name: string, sql: string): string[] { + const statements = topLevelStatements(sql); + const concurrent = statements.filter((statement) => + /\bCONCURRENTLY\b/i.test(statement), + ); + if (concurrent.length === 0 || statements.length === 1) return []; + return [ + `${name}: a CONCURRENTLY statement must be the only statement in its file`, + ]; +} + +describe("concurrent index statements", () => { + it("stand alone in their migration file", () => { + const found = migrations().flatMap(({ name, sql }) => + concurrentViolations(name, sql), + ); + expect(found, found.join("\n")).toEqual([]); + }); + + it("sees the concurrent migrations there are", () => { + const concurrent = migrations() + .filter(({ sql }) => + /\bCONCURRENTLY\b/i.test(topLevelStatements(sql).join(";")), + ) + .map(({ name }) => name); + expect(concurrent).toContain("0416_drop_measurements_type_prefix_index"); + }); + + it("refuses a concurrent statement beside another one", () => { + expect( + concurrentViolations( + "a", + 'DROP INDEX CONCURRENTLY IF EXISTS "x";\nDROP INDEX CONCURRENTLY IF EXISTS "y";', + ), + ).toHaveLength(1); + expect( + concurrentViolations( + "b", + '-- CONCURRENTLY in a comment\nDROP INDEX CONCURRENTLY IF EXISTS "x";', + ), + ).toEqual([]); + }); +}); + describe("migration lock waits", () => { it("bounds lock waits where they take effect and marks only repeatable files", () => { const found = migrations().flatMap(({ name, sql }) => @@ -97,11 +147,13 @@ describe("migration lock waits", () => { expect(found, found.join("\n")).toEqual([]); }); - it("sees the one migration that bounds its wait today", () => { + it("sees the migrations that bound their wait", () => { const marked = migrations() .filter(({ sql }) => RETRY_MARKER.test(sql)) .map(({ name }) => name); expect(marked).toContain("0412_measurement_hot_updates"); + expect(marked).toContain("0414_drop_medication_compliance_rollups"); + expect(marked).toContain("0415_foreign_key_and_prefix_indexes"); }); it("catches each shape it exists to refuse", () => { diff --git a/src/lib/personal-records/pr-detection-worker.ts b/src/lib/personal-records/pr-detection-worker.ts index a6e468b77..e2839662a 100644 --- a/src/lib/personal-records/pr-detection-worker.ts +++ b/src/lib/personal-records/pr-detection-worker.ts @@ -19,8 +19,8 @@ * - All-time scan rather than a rolling window. The * `personal_records_user_metric_idx` index makes * `ORDER BY value LIMIT 1` constant-time; reading the worst-case - * ~30k measurement rows per metric is ~50 ms with the typed - * `measurements_user_id_type_measured_at_idx` index. + * ~30k measurement rows per metric is ~50 ms over the + * `(user_id, type, measured_at, …)` unique index. * - Tie handling: an equal-value reading writes a row but the caller * suppresses the push notification (the user matched, not beat * their best). The notification suppression lives on the job diff --git a/tests/integration/index-hygiene.test.ts b/tests/integration/index-hygiene.test.ts new file mode 100644 index 000000000..370eb8df5 --- /dev/null +++ b/tests/integration/index-hygiene.test.ts @@ -0,0 +1,360 @@ +/** + * Index hygiene, read from the catalog of the migrated database. + * + * Two rules, each with its counter-check: + * + * 1. Every foreign key whose delete action does work in the child table + * (CASCADE, SET NULL, SET DEFAULT) has an index leading with its columns. + * Without one, deleting a parent row scans the child table once per + * deleted parent; an account delete runs hundreds of them. + * 2. No btree index is a prefix of another btree index on the same table + * (same leading columns, operator classes, collations and directions, + * same predicate). The longer index answers every lookup the shorter one + * did, so the shorter one only costs writes and space. A unique index is + * never counted as the redundant one: it enforces something. + * + * And the evidence for migration 0416: on a seeded `measurements` table of + * production size, every query shape that used + * `measurements_user_id_type_measured_at_idx` plans the same way without it, + * on an index, at a cost within a few percent. + */ +import { Client } from "pg"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +/** + * Foreign keys allowed to go without a leading index, with the reason. + * Empty: every one has its index. + */ +const FK_EXCEPTIONS: Record = {}; + +/** Prefix indexes allowed to stay, with the reason. Empty. */ +const PREFIX_EXCEPTIONS: Record = {}; + +async function connect(): Promise { + const raw = process.env.DATABASE_URL; + if (!raw) throw new Error("DATABASE_URL is not set for the integration run"); + const url = new URL(raw); + url.searchParams.delete("schema"); + url.searchParams.delete("pgbouncer"); + const client = new Client({ connectionString: url.toString() }); + await client.connect(); + return client; +} + +const FK_WITHOUT_LEADING_INDEX = ` + SELECT c.conrelid::regclass::text AS tbl, c.conname AS name + FROM pg_constraint c + WHERE c.contype = 'f' + AND c.connamespace = 'public'::regnamespace + AND c.confdeltype IN ('c', 'n', 'd') + AND NOT EXISTS ( + SELECT 1 FROM pg_index i + WHERE i.indrelid = c.conrelid + AND i.indpred IS NULL + AND i.indexprs IS NULL + AND (string_to_array(i.indkey::text, ' ')::int2[])[1:array_length(c.conkey, 1)] @> c.conkey + AND (string_to_array(i.indkey::text, ' ')::int2[])[1:array_length(c.conkey, 1)] <@ c.conkey + ) + ORDER BY 1, 2`; + +const ACTING_FK_COUNT = ` + SELECT count(*)::int AS n FROM pg_constraint + WHERE contype = 'f' AND connamespace = 'public'::regnamespace + AND confdeltype IN ('c', 'n', 'd')`; + +/* + * `indkey`, `indclass`, `indcollation` and `indoption` are vectors whose array + * casts start at index 0; going through text gives ordinary 1-based arrays. + */ +const PREFIX_INDEXES = ` + WITH idx AS ( + SELECT i.indexrelid, i.indrelid, c.relname AS index_name, + t.relname AS table_name, i.indisunique, i.indisprimary, + string_to_array(i.indkey::text, ' ')::int[] AS cols, + string_to_array(i.indclass::text, ' ')::oid[] AS opc, + string_to_array(i.indcollation::text, ' ')::oid[] AS coll, + string_to_array(i.indoption::text, ' ')::int[] AS opt, + pg_get_expr(i.indpred, i.indrelid) AS pred, + i.indexprs IS NOT NULL AS has_exprs, am.amname + FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + JOIN pg_class t ON t.oid = i.indrelid + JOIN pg_am am ON am.oid = c.relam + WHERE t.relnamespace = 'public'::regnamespace + ) + SELECT a.table_name AS tbl, a.index_name AS redundant, b.index_name AS covered_by + FROM idx a + JOIN idx b ON a.indrelid = b.indrelid AND a.indexrelid <> b.indexrelid + WHERE a.amname = 'btree' AND b.amname = 'btree' + AND NOT a.indisprimary AND NOT a.has_exprs AND NOT b.has_exprs + AND a.pred IS NOT DISTINCT FROM b.pred + AND cardinality(a.cols) <= cardinality(b.cols) + AND a.cols = b.cols[1:cardinality(a.cols)] + AND a.opc = b.opc[1:cardinality(a.cols)] + AND a.coll = b.coll[1:cardinality(a.cols)] + AND a.opt = b.opt[1:cardinality(a.cols)] + AND (NOT a.indisunique OR (b.indisunique AND a.cols = b.cols)) + AND (cardinality(a.cols) < cardinality(b.cols) + OR (NOT a.indisunique AND b.indisunique) + OR (a.indisunique = b.indisunique AND a.indexrelid > b.indexrelid)) + ORDER BY 1, 2`; + +const BTREE_INDEX_COUNT = ` + SELECT count(*)::int AS n FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_am am ON am.oid = c.relam + WHERE c.relnamespace = 'public'::regnamespace AND am.amname = 'btree'`; + +describe("index hygiene", () => { + let db: Client; + beforeAll(async () => { + db = await connect(); + }); + afterAll(async () => { + await db.end(); + }); + + it("gives every acting foreign key a leading index", async () => { + const examined = (await db.query(ACTING_FK_COUNT)).rows[0].n as number; + // The matcher has to have something to match: the schema has well over a + // hundred cascading foreign keys. + expect(examined).toBeGreaterThan(100); + const { rows } = await db.query(FK_WITHOUT_LEADING_INDEX); + const offenders = rows + .map((r) => `${r.tbl}.${r.name}`) + .filter((key) => !(key in FK_EXCEPTIONS)); + expect(offenders, offenders.join("\n")).toEqual([]); + }); + + it("counter-check: flags a cascading foreign key without an index", async () => { + await db.query("BEGIN"); + try { + await db.query( + `CREATE TABLE "zz_fk_probe" ("id" text PRIMARY KEY, + "user_id" text REFERENCES "users"("id") ON DELETE CASCADE)`, + ); + const flagged = (await db.query(FK_WITHOUT_LEADING_INDEX)).rows.map( + (r) => r.tbl, + ); + expect(flagged).toContain("zz_fk_probe"); + await db.query(`CREATE INDEX ON "zz_fk_probe" ("user_id", "id")`); + const after = (await db.query(FK_WITHOUT_LEADING_INDEX)).rows.map( + (r) => r.tbl, + ); + expect(after).not.toContain("zz_fk_probe"); + } finally { + await db.query("ROLLBACK"); + } + }); + + it("keeps no btree index that is a prefix of another", async () => { + const examined = (await db.query(BTREE_INDEX_COUNT)).rows[0].n as number; + expect(examined).toBeGreaterThan(300); + const { rows } = await db.query(PREFIX_INDEXES); + const offenders = rows + .filter((r) => !(r.redundant in PREFIX_EXCEPTIONS)) + .map((r) => `${r.tbl}: ${r.redundant} is a prefix of ${r.covered_by}`); + expect(offenders, offenders.join("\n")).toEqual([]); + }); + + it("counter-check: flags a prefix and a duplicate, but not a reversed direction", async () => { + await db.query("BEGIN"); + try { + await db.query( + `CREATE INDEX "zz_prefix_probe" ON "measurements" ("user_id", "type")`, + ); + await db.query( + `CREATE INDEX "zz_duplicate_probe" ON "users" ("username")`, + ); + await db.query( + `CREATE INDEX "zz_direction_probe" ON "measurements" ("user_id", "type", "measured_at" DESC, "source")`, + ); + const flagged = (await db.query(PREFIX_INDEXES)).rows.map( + (r) => r.redundant, + ); + expect(flagged).toContain("zz_prefix_probe"); + expect(flagged).toContain("zz_duplicate_probe"); + // `users_username_key` is unique: it enforces something, so it is + // never the one reported, even beside a plain twin. + expect(flagged).not.toContain("users_username_key"); + // The live-covering index runs `measured_at DESC` too, but carries a + // predicate, so it is not a prefix of the probe either way. + expect(flagged).not.toContain("zz_direction_probe"); + } finally { + await db.query("ROLLBACK"); + } + }); +}); + +describe("measurements without (user_id, type, measured_at)", () => { + const DROPPED = "measurements_user_id_type_measured_at_idx"; + const USERS = 5; + const ROWS = 130_000; + let db: Client; + let userId: string; + + beforeAll(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + const ids: string[] = []; + for (let i = 0; i < USERS; i++) { + const user = await prisma.user.create({ + data: { + username: `plan-probe-${i}`, + email: `plan-probe-${i}@example.test`, + role: "USER", + }, + }); + ids.push(user.id); + } + userId = ids[2]; + db = await connect(); + // Production's mix: dense pulse and steps, a long tail of manual types, + // three sources, one row in twenty a tombstone, a year of history. + await db.query( + `WITH t(type, unit, weight) AS (VALUES + ('PULSE'::measurement_type, 'bpm', 50), ('ACTIVITY_STEPS', 'count', 10), + ('HEART_RATE_VARIABILITY', 'ms', 10), ('SLEEP_DURATION', 'h', 8), + ('BLOOD_GLUCOSE', 'mg/dL', 5), ('OXYGEN_SATURATION', '%', 4), + ('WEIGHT', 'kg', 3), ('BLOOD_PRESSURE_SYS', 'mmHg', 3), + ('BLOOD_PRESSURE_DIA', 'mmHg', 3), ('RESTING_HEART_RATE', 'bpm', 2), + ('BODY_FAT', '%', 2)), + slots AS (SELECT row_number() OVER () AS n, type, unit + FROM t, generate_series(1, t.weight)) + INSERT INTO "measurements" + ("id", "user_id", "type", "value", "unit", "source", "measured_at", + "updated_at", "deleted_at") + SELECT 'plan-' || g, ($1::text[])[1 + g % $2], s.type, random() * 100, s.unit, + (ARRAY['APPLE_HEALTH', 'MANUAL', 'WITHINGS']::measurement_source[])[1 + g % 3], + timestamp '2026-10-20' - (g * interval '241 seconds'), + now(), CASE WHEN g % 20 = 0 THEN now() END + FROM generate_series(1, $3) g + JOIN slots s ON s.n = 1 + (g * 7 % 100)`, + [ids, USERS, ROWS], + ); + await db.query(`ANALYZE "measurements"`); + }, 120_000); + + afterAll(async () => { + await db.end(); + await truncateAllTables(getPrismaClient()); + }); + + /** The query shapes the read and write paths send with a user and a type. */ + const QUERIES: Array<[string, string]> = [ + [ + "live range, newest first", + `SELECT * FROM measurements WHERE user_id = $1 AND type = 'WEIGHT' + AND measured_at >= '2026-07-20' AND deleted_at IS NULL + ORDER BY measured_at DESC`, + ], + [ + "range including tombstones", + `SELECT * FROM measurements WHERE user_id = $1 AND type = 'WEIGHT' + AND measured_at >= '2026-07-20' ORDER BY measured_at DESC`, + ], + [ + "latest of a type", + `SELECT max(measured_at) FROM measurements WHERE user_id = $1 AND type = 'PULSE'`, + ], + [ + "count of a type", + `SELECT count(*) FROM measurements WHERE user_id = $1 AND type = 'BLOOD_GLUCOSE'`, + ], + [ + "point lookup without source", + `SELECT id FROM measurements WHERE user_id = $1 AND type = 'PULSE' + AND measured_at = '2026-10-01 10:00'`, + ], + [ + "day aggregate", + `SELECT date_trunc('day', measured_at), avg(value) FROM measurements + WHERE user_id = $1 AND type = 'PULSE' AND measured_at >= '2026-10-01' + AND measured_at < '2026-10-08' AND deleted_at IS NULL GROUP BY 1`, + ], + [ + "two types over a month", + `SELECT * FROM measurements WHERE user_id = $1 + AND type IN ('BLOOD_PRESSURE_SYS', 'BLOOD_PRESSURE_DIA') + AND measured_at BETWEEN '2026-09-01' AND '2026-10-01'`, + ], + [ + "last before a date", + `SELECT * FROM measurements WHERE user_id = $1 AND type = 'SLEEP_DURATION' + AND measured_at < '2026-10-01' ORDER BY measured_at DESC LIMIT 1`, + ], + [ + "tombstone a day", + `UPDATE measurements SET deleted_at = now() WHERE user_id = $1 + AND type = 'HEART_RATE_VARIABILITY' AND measured_at >= '2026-10-10' + AND measured_at < '2026-10-11'`, + ], + ]; + + interface PlanNode { + "Node Type": string; + "Relation Name"?: string; + "Index Name"?: string; + "Total Cost": number; + Plans?: PlanNode[]; + } + + function walk(node: PlanNode, out: PlanNode[] = []): PlanNode[] { + out.push(node); + for (const child of node.Plans ?? []) walk(child, out); + return out; + } + + async function plans(): Promise> { + const result = []; + for (const [, sql] of QUERIES) { + const { rows } = await db.query(`EXPLAIN (FORMAT JSON) ${sql}`, [userId]); + const root = rows[0]["QUERY PLAN"][0].Plan as PlanNode; + result.push({ cost: root["Total Cost"], nodes: walk(root) }); + } + return result; + } + + it("is gone after the migrations", async () => { + const { rowCount } = await db.query( + `SELECT 1 FROM pg_indexes WHERE indexname = $1`, + [DROPPED], + ); + expect(rowCount).toBe(0); + }); + + it("plans every user-and-type query on an index, at the cost it had with it", async () => { + // The state before migration 0416, rebuilt inside a transaction. + await db.query("BEGIN"); + let before; + try { + await db.query( + `CREATE INDEX "${DROPPED}" ON "measurements" ("user_id", "type", "measured_at")`, + ); + before = await plans(); + } finally { + await db.query("ROLLBACK"); + } + const after = await plans(); + + // The comparison means something only if the old index was in use. + const usedBefore = before.filter((p) => + p.nodes.some((n) => n["Index Name"] === DROPPED), + ).length; + expect(usedBefore).toBeGreaterThanOrEqual(3); + + QUERIES.forEach(([label], i) => { + const seqScan = after[i].nodes.find( + (n) => + n["Node Type"] === "Seq Scan" && + n["Relation Name"] === "measurements", + ); + expect(seqScan, `${label}: sequential scan`).toBeUndefined(); + expect( + after[i].cost, + `${label}: cost ${after[i].cost} against ${before[i].cost}`, + ).toBeLessThanOrEqual(before[i].cost * 1.05 + 1); + }); + }, 60_000); +}); From 8e07cb179f0493aad83b4b3936b3d4c054d27c4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 13:17:41 +0200 Subject: [PATCH 4/7] feat(jobs): give every soft-delete model a tombstone retention Twenty of the twenty-three soft-delete models were never purged: a deleted lab result, visit, allergy or cycle stayed in the database, in every backup and in every key rotation for as long as the instance ran. soft-delete-retention.ts holds one entry per model that has a deletedAt column, derived from the schema by a guard with a counter-check. The sync domains and the document vault keep their purges; every other model is purged by a new nightly soft-delete-purge job after the sync feed's 75-day horizon, per account under the restore lock and in bounded batches. A cycle start folded into a live cycle stays while that cycle does, and a removed coach fact stays for the coach message retention, since stored trails are withheld against it. --- docs/self-hosting/reproductive-health-data.md | 7 +- .../soft-delete-retention-register.test.ts | 128 +++++++++++++ src/lib/jobs/queue-runtime.ts | 3 + src/lib/jobs/reminder/register-maintenance.ts | 20 ++ src/lib/jobs/soft-delete-purge.ts | 63 +++++++ src/lib/jobs/soft-delete-retention.ts | 178 ++++++++++++++++++ src/lib/jobs/subsystem-surface.ts | 1 + tests/integration/soft-delete-purge.test.ts | 165 ++++++++++++++++ 8 files changed, 563 insertions(+), 2 deletions(-) create mode 100644 src/lib/jobs/__tests__/soft-delete-retention-register.test.ts create mode 100644 src/lib/jobs/soft-delete-purge.ts create mode 100644 src/lib/jobs/soft-delete-retention.ts create mode 100644 tests/integration/soft-delete-purge.test.ts diff --git a/docs/self-hosting/reproductive-health-data.md b/docs/self-hosting/reproductive-health-data.md index 557f96633..128dbc6d3 100644 --- a/docs/self-hosting/reproductive-health-data.md +++ b/docs/self-hosting/reproductive-health-data.md @@ -90,8 +90,11 @@ different things depending on which button you press. it out and it is gone from the app, from exports, and from the doctor report. The row itself stays in PostgreSQL as a tombstone, which is what lets a phone that was offline at delete time learn that the entry - went away. No job removes cycle tombstones on a schedule, so the row - stays until one of the two actions below. + went away. A nightly job removes a tombstone 75 days after the delete + (the same horizon as the sync feed's); until then, or until one of the + two actions below, the row stays. A start that was folded into a later + cycle stays for as long as that cycle does, because deleting the later + cycle gives it back. - **Settings → Delete All Data** removes the rows outright, tombstones included. Everything the account owns goes except what you sign in with; the confirmation dialog lists it. diff --git a/src/lib/jobs/__tests__/soft-delete-retention-register.test.ts b/src/lib/jobs/__tests__/soft-delete-retention-register.test.ts new file mode 100644 index 000000000..70b86563b --- /dev/null +++ b/src/lib/jobs/__tests__/soft-delete-retention-register.test.ts @@ -0,0 +1,128 @@ +/** + * Structural guard: every model that soft-deletes has exactly one retention + * entry, and every entry names a model that soft-deletes. + * + * Twenty of the twenty-three soft-delete models had no purge at all: their + * tombstones stayed for as long as the instance ran. The set is derived from + * `prisma/schema.prisma` (a `deletedAt` field), so a model that starts + * soft-deleting cannot ship without saying how long its tombstones live. + */ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + readDeclaredSchema, + type DeclaredSchema, +} from "@/__tests__/helpers/prisma-schema-names"; +import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client"; +import { + SOFT_DELETE_RETENTION, + purgeModels, + type SoftDeleteRetention, +} from "@/lib/jobs/soft-delete-retention"; + +function problems( + schema: DeclaredSchema, + register: Readonly>, +): string[] { + const found: string[] = []; + const softDeleting = schema.models.filter((model) => + model.fields.some((f) => f.name === "deletedAt"), + ); + for (const model of softDeleting) { + const entry = register[model.name]; + if (!entry) { + found.push(`${model.name}: soft-deletes but has no retention entry`); + continue; + } + if (entry.kind !== "purge") continue; + if (entry.table !== model.physicalName) { + found.push( + `${model.name}: entry purges ${entry.table}, the model is ${model.physicalName}`, + ); + } + for (const column of ["user_id", "deleted_at"]) { + if (!model.fields.some((f) => f.physicalName === column)) { + found.push(`${model.name}: the purge needs a ${column} column`); + } + } + } + const names = new Set(softDeleting.map((m) => m.name)); + for (const name of Object.keys(register)) { + if (!names.has(name)) { + found.push(`${name}: has a retention entry but does not soft-delete`); + } + } + return found; +} + +describe("soft-delete retention register", () => { + const schema = readDeclaredSchema(); + + it("covers every soft-delete model exactly once", () => { + const softDeleting = schema.models.filter((model) => + model.fields.some((f) => f.name === "deletedAt"), + ); + // A reader that stopped matching would find nothing and pass. + expect(softDeleting.length).toBeGreaterThanOrEqual(23); + const found = problems(schema, SOFT_DELETE_RETENTION); + expect(found, found.join("\n")).toEqual([]); + }); + + it("keeps every tombstone at least as long as the sync feed's horizon", () => { + for (const [model, entry] of Object.entries(SOFT_DELETE_RETENTION)) { + if (entry.kind !== "purge") continue; + expect(entry.days(), model).toBeGreaterThanOrEqual( + TOMBSTONE_RETENTION_DAYS, + ); + } + expect(purgeModels()).toContain("MenstrualCycle"); + expect(purgeModels()).toContain("CycleDayLog"); + }); + + it("explains every entry that departs from the default", () => { + for (const [model, entry] of Object.entries(SOFT_DELETE_RETENTION)) { + if (entry.kind !== "purge") continue; + const departs = + entry.keep !== undefined || entry.days() !== TOMBSTONE_RETENTION_DAYS; + if (departs) expect(entry.why?.length ?? 0, model).toBeGreaterThan(40); + } + }); + + it("counter-check: names a new soft-delete model, a stale entry and a wrong table", () => { + const dir = mkdtempSync(join(tmpdir(), "hl-retention-")); + try { + const path = join(dir, "schema.prisma"); + writeFileSync( + path, + `model Widget { + id String @id + userId String @map("user_id") + deletedAt DateTime? @map("deleted_at") + @@map("widgets") +} +model Gadget { + id String @id + userId String @map("user_id") + deletedAt DateTime? @map("deleted_at") + @@map("gadgets") +} +`, + ); + const synthetic = readDeclaredSchema(path); + const found = problems(synthetic, { + Gadget: { kind: "purge", table: "gadget", days: () => 75 }, + Gone: { kind: "purge", table: "gone", days: () => 75 }, + }); + expect(found).toEqual([ + "Widget: soft-deletes but has no retention entry", + "Gadget: entry purges gadget, the model is gadgets", + "Gone: has a retention entry but does not soft-delete", + ]); + } finally { + rmSync(dir, { recursive: true }); + } + }); +}); diff --git a/src/lib/jobs/queue-runtime.ts b/src/lib/jobs/queue-runtime.ts index 14a722a49..56a12bda8 100644 --- a/src/lib/jobs/queue-runtime.ts +++ b/src/lib/jobs/queue-runtime.ts @@ -313,6 +313,9 @@ export const QUEUE_RUNTIME: Readonly> = { "At most 40 batches of 5 000 rows per leg.", ), "document-tombstone-purge": short("One deleteMany."), + "soft-delete-purge": short( + "At most 40 batches of 5 000 rows per table, over a few small tables.", + ), "document-summary-reaper": short("One updateMany."), "cycle-prediction-refresh": short( "Arithmetic per eligible account, no provider call.", diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index ef5f655b2..3b2215244 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -290,6 +290,12 @@ import { handleDocumentPurge, type DocumentPurgePayload, } from "@/lib/jobs/document-purge"; +import { + SOFT_DELETE_PURGE_QUEUE, + SOFT_DELETE_PURGE_CRON, + handleSoftDeletePurge, + type SoftDeletePurgePayload, +} from "@/lib/jobs/soft-delete-purge"; import { DOCUMENT_SUMMARY_REAPER_QUEUE, DOCUMENT_SUMMARY_REAPER_CRON, @@ -580,6 +586,9 @@ const allQueues = [ // the daily schedule silently no-ops and "deleted" documents hold backup // weight forever. DOCUMENT_PURGE_QUEUE, + // Nightly purge of expired tombstones for every soft-delete model the + // retention register hands to it (`soft-delete-retention.ts`). + SOFT_DELETE_PURGE_QUEUE, // Document vault — hourly reaper for summary rows stuck on PENDING (a // job dead past its retry budget). Without this entry the schedule // silently no-ops and a dead "wird generiert" claim never converges to @@ -779,6 +788,9 @@ const schedules: ScheduleEntry[] = [ // Document vault — daily 04:10 Europe/Berlin purge for tombstoned // documents past the 30-day undo grace. [DOCUMENT_PURGE_QUEUE, DOCUMENT_PURGE_CRON, cronIsTheRetry], + // Daily 03:42 Europe/Berlin, after the sync-domain tombstone cleanup. The + // next night is the retry: the purge picks up whatever a failed run left. + [SOFT_DELETE_PURGE_QUEUE, SOFT_DELETE_PURGE_CRON, cronIsTheRetry], // Document vault — hourly heal of summary rows stuck on PENDING. The next // tick is the retry: the updateMany is idempotent and the TTL predicate // re-selects anything a failed tick left behind. @@ -1140,6 +1152,14 @@ export async function registerMaintenanceQueues( { localConcurrency: 1 }, handleDocumentPurge, ); + // Single-flight like every other cleanup queue; both deletes are bounded + // and idempotent, so a duplicate tick finds nothing. + await createAndWork( + boss, + SOFT_DELETE_PURGE_QUEUE, + { localConcurrency: 1 }, + handleSoftDeletePurge, + ); // v1.40 — background document AI runs. Each job is one run, under the lock // `lockedPass` takes per run id, so a redelivery never reads beside the // first delivery; the claim inside is conditional as well. diff --git a/src/lib/jobs/soft-delete-purge.ts b/src/lib/jobs/soft-delete-purge.ts new file mode 100644 index 000000000..6699b6ce7 --- /dev/null +++ b/src/lib/jobs/soft-delete-purge.ts @@ -0,0 +1,63 @@ +/** + * Nightly purge of expired tombstones for every soft-delete model the + * register hands to it (`SOFT_DELETE_RETENTION`, kind `purge`). + * + * One leg per table, one table after the other: the tables are small next to + * the sync domains, and the job has the night. A leg that stops at its batch + * cap, or leaves an account under restore for the next night, says so in the + * run report rather than looking like a night with nothing to do. + */ +import type { Job } from "pg-boss"; + +import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { getWorkerPrisma } from "@/lib/jobs/reminder/shared"; +import { purgeSoftDeletedRows } from "@/lib/jobs/soft-delete-retention"; +import { withBackgroundEvent } from "@/lib/logging/background"; + +export const SOFT_DELETE_PURGE_QUEUE = "soft-delete-purge"; +/** Daily at 03:42 Europe/Berlin, after the sync-domain tombstone cleanup. */ +export const SOFT_DELETE_PURGE_CRON = "42 3 * * *"; + +export interface SoftDeletePurgePayload { + triggeredAt?: string; +} + +export async function handleSoftDeletePurge( + jobs: Job[], +): Promise { + void jobs; + return withBackgroundEvent("job.soft_delete_purge", async (evt) => { + try { + const legs = await purgeSoftDeletedRows(getWorkerPrisma()); + const deleted = legs.reduce((sum, leg) => sum + leg.deleted, 0); + const drained = legs.every((leg) => leg.drained); + const deferredAccounts = Math.max( + 0, + ...legs.map((leg) => leg.deferredAccounts), + ); + evt.addMeta("soft_delete_purge_deleted", deleted); + evt.addMeta("soft_delete_purge_drained", drained); + evt.addMeta("soft_delete_purge_deferred_accounts", deferredAccounts); + for (const leg of legs) { + if (leg.deleted > 0) { + evt.addMeta(`soft_delete_purge_${leg.table}`, leg.deleted); + } + } + if (!drained) { + evt.addWarning( + "soft-delete-purge stopped at the batch cap; a backlog remains for the next run", + ); + } + return jobDone({ + deleted, + drained, + deferred_accounts: deferredAccounts, + }); + } catch (err) { + // Rethrow, as the sync-domain purge does: a retention purge that fails + // quietly every night looks exactly like one with nothing to do. + evt.addWarning(`soft-delete-purge failed: ${err}`); + throw err; + } + }); +} diff --git a/src/lib/jobs/soft-delete-retention.ts b/src/lib/jobs/soft-delete-retention.ts new file mode 100644 index 000000000..056f54c78 --- /dev/null +++ b/src/lib/jobs/soft-delete-retention.ts @@ -0,0 +1,178 @@ +/** + * How long a soft-deleted row is kept, for every model that soft-deletes. + * + * A delete in HealthLog tombstones the row (`deletedAt`) so the undo and the + * restore endpoints can bring it back and the `/api/sync/changes` feed can + * tell an offline phone about it. Without a horizon the tombstone is forever: + * a deleted lab result, visit or allergy stayed in the database, in every + * backup and in every key rotation, for as long as the instance ran. + * + * Every model with a `deletedAt` column has exactly one entry here + * (`src/lib/jobs/__tests__/soft-delete-retention-register.test.ts` derives the + * set from the schema). An entry says which job removes the tombstones and + * after how long: + * + * - `sync-feed`: the three dense sync domains, purged by the nightly + * `measurement-tombstone-cleanup` job after `TOMBSTONE_RETENTION_DAYS`. + * - `document-grace`: the document vault, purged by `document-tombstone-purge` + * after its 30-day undo grace (it also carries the import keys over). + * - `purge`: everything else, purged here by `soft-delete-purge`. The horizon + * is `TOMBSTONE_RETENTION_DAYS` unless the entry says otherwise: the same as + * the sync feed's (two of these models ride it, and a cursor older than the + * horizon is already answered with `cursorExpired`), and long past any undo. + * A restore endpoint asked for a purged row finds nothing, as it does for a + * purged measurement. + * + * The delete runs per account under the restore lock, in bounded batches + * (`purgeTombstonesByAccount`). Foreign keys into these tables cascade or set + * null (none restricts), so a purged visit takes its link rows with it and a + * live vaccination that named it simply loses the pointer. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client"; +import { getCoachMessageRetentionDays } from "@/lib/jobs/coach-message-cleanup"; +import { DOCUMENT_PURGE_GRACE_DAYS } from "@/lib/jobs/document-purge"; +import { + purgeTombstonesByAccount, + type TombstonePurgeOutcome, +} from "@/lib/jobs/measurement-tombstone-cleanup"; + +export type SoftDeleteRetention = + | { kind: "sync-feed"; days: number } + | { kind: "document-grace"; days: number } + | { + kind: "purge"; + /** The physical table; a literal, spliced into the purge SQL. */ + table: string; + /** Days a tombstone is kept. */ + days: () => number; + /** + * Extra condition a purgeable row must meet, a literal SQL fragment over + * the alias `t`. For a tombstone something live still depends on. + */ + keep?: string; + /** Why the horizon or the condition differs from the default. */ + why?: string; + }; + +const standard = (table: string): SoftDeleteRetention => ({ + kind: "purge", + table, + days: () => TOMBSTONE_RETENTION_DAYS, +}); + +export const SOFT_DELETE_RETENTION: Readonly< + Record +> = { + Measurement: { kind: "sync-feed", days: TOMBSTONE_RETENTION_DAYS }, + MoodEntry: { kind: "sync-feed", days: TOMBSTONE_RETENTION_DAYS }, + MedicationIntakeEvent: { kind: "sync-feed", days: TOMBSTONE_RETENTION_DAYS }, + InboundDocument: { kind: "document-grace", days: DOCUMENT_PURGE_GRACE_DAYS }, + + // Both ride the sync feed; the default horizon is the feed's. + CycleDayLog: standard("cycle_day_logs"), + MenstrualCycle: { + kind: "purge", + table: "menstrual_cycles", + days: () => TOMBSTONE_RETENTION_DAYS, + keep: `NOT EXISTS (SELECT 1 FROM "menstrual_cycles" a + WHERE a."id" = t."absorbed_into_id" + AND a."deleted_at" IS NULL)`, + why: "A start folded into a later cycle is tombstoned with `absorbedIntoId` and given back when that cycle is deleted (`cycle-boundaries.ts`), so it stays while the cycle that absorbed it is live.", + }, + CoachFact: { + kind: "purge", + table: "coach_facts", + days: getCoachMessageRetentionDays, + why: "A stored answer's trail can name a removed medication fact, and the trail is withheld against the fact rows (`trail-read.ts`); the fact therefore outlives every message that may name it, which is the coach message retention.", + }, + + MentalHealthAssessment: standard("mental_health_assessments"), + MeasurementReminder: standard("measurement_reminders"), + LabResult: standard("lab_results"), + CustomMetric: standard("custom_metrics"), + CustomMetricEntry: standard("custom_metric_entries"), + CoachPlan: standard("coach_plans"), + CoachReminder: standard("coach_reminders"), + IllnessEpisode: standard("illness_episodes"), + IllnessDayLog: standard("illness_day_logs"), + Allergy: standard("allergies"), + FamilyHistoryEntry: standard("family_history_entries"), + Encounter: standard("encounters"), + Practitioner: standard("practitioners"), + VaccinationRecord: standard("vaccination_records"), + CustomVaccine: standard("custom_vaccines"), + LifeEvent: standard("life_events"), +}; + +const TABLE = /^[a-z_]+$/; +const DAY_MS = 86_400_000; + +/** One table's purge, for the run report. */ +export interface SoftDeletePurgeLeg extends TombstonePurgeOutcome { + model: string; + table: string; +} + +/** + * Purge one register entry's expired tombstones. The table name and the extra + * condition come from the register's literals, never from input; the name is + * asserted against a closed pattern before it is spliced. + */ +export async function purgeSoftDeletedTable( + prisma: PrismaClient, + model: string, + now: Date = new Date(), +): Promise { + const entry = SOFT_DELETE_RETENTION[model]; + if (!entry || entry.kind !== "purge" || !TABLE.test(entry.table)) { + throw new Error(`No purge entry for ${model}`); + } + const table = `"${entry.table}"`; + const cutoff = new Date(now.getTime() - entry.days() * DAY_MS); + const keep = entry.keep ? `AND ${entry.keep}` : ""; + const outcome = await purgeTombstonesByAccount({ + prisma, + findRows: (take, skipUserIds) => + prisma.$queryRawUnsafe>( + `SELECT t."id", t."user_id" AS "userId" FROM ${table} t + WHERE t."deleted_at" IS NOT NULL AND t."deleted_at" < $1 + AND NOT (t."user_id" = ANY($2::text[])) ${keep} + LIMIT $3`, + cutoff, + skipUserIds, + take, + ), + // The row is re-checked inside the delete: a restore between the read and + // the delete un-tombstones it, and then it stays. + deleteIds: (tx, ids, userId) => + tx.$executeRawUnsafe( + `DELETE FROM ${table} t + WHERE t."id" = ANY($1::text[]) AND t."user_id" = $2 + AND t."deleted_at" IS NOT NULL AND t."deleted_at" < $3 ${keep}`, + ids, + userId, + cutoff, + ), + }); + return { ...outcome, model, table: entry.table }; +} + +/** Every `purge` entry of the register, in declaration order. */ +export function purgeModels(): string[] { + return Object.entries(SOFT_DELETE_RETENTION) + .filter(([, entry]) => entry.kind === "purge") + .map(([model]) => model); +} + +/** Run every `purge` entry, one table after the other. */ +export async function purgeSoftDeletedRows( + prisma: PrismaClient, + now: Date = new Date(), +): Promise { + const legs: SoftDeletePurgeLeg[] = []; + for (const model of purgeModels()) { + legs.push(await purgeSoftDeletedTable(prisma, model, now)); + } + return legs; +} diff --git a/src/lib/jobs/subsystem-surface.ts b/src/lib/jobs/subsystem-surface.ts index 1aa67653b..ebc49012e 100644 --- a/src/lib/jobs/subsystem-surface.ts +++ b/src/lib/jobs/subsystem-surface.ts @@ -127,6 +127,7 @@ const subsystemSurface = { "mood-prognosis-refresh": { audience: "system" }, "achievement-unlock-sweep": { audience: "system" }, "measurement-tombstone-cleanup": { audience: "system" }, + "soft-delete-purge": { audience: "system" }, "coach-message-cleanup": { audience: "system" }, "workout-insight-claim-cleanup": { audience: "system" }, "note-encryption-backfill": { audience: "system" }, diff --git a/tests/integration/soft-delete-purge.test.ts b/tests/integration/soft-delete-purge.test.ts new file mode 100644 index 000000000..bd14bbb06 --- /dev/null +++ b/tests/integration/soft-delete-purge.test.ts @@ -0,0 +1,165 @@ +/** + * The nightly purge of expired tombstones for the soft-delete models the + * retention register hands to `soft-delete-purge`. + * + * Proves, against the migrated schema: every table's purge statement runs; an + * expired tombstone goes while a recent one and a live row stay; a purged + * visit cascades its links and leaves a live vaccination that named it in + * place; a start folded into a live cycle stays; the coach facts keep their + * longer horizon. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client"; +import { + purgeModels, + purgeSoftDeletedRows, + purgeSoftDeletedTable, +} from "@/lib/jobs/soft-delete-retention"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const USER = "soft-delete-purge-user"; +const NOW = new Date("2026-10-20T03:42:00.000Z"); +const DAY = 86_400_000; +const EXPIRED = new Date(NOW.getTime() - (TOMBSTONE_RETENTION_DAYS + 5) * DAY); +const RECENT = new Date(NOW.getTime() - 10 * DAY); + +const prisma = getPrismaClient(); + +beforeEach(async () => { + await truncateAllTables(prisma); + await prisma.user.create({ + data: { id: USER, username: USER, role: "USER", timezone: "UTC" }, + }); +}); + +describe("soft-delete purge", () => { + it("runs every table's purge against the real schema", async () => { + const legs = await purgeSoftDeletedRows(prisma, NOW); + expect(legs.map((leg) => leg.model)).toEqual(purgeModels()); + expect(legs.length).toBeGreaterThanOrEqual(19); + for (const leg of legs) { + expect(leg, leg.model).toMatchObject({ deleted: 0, drained: true }); + } + }); + + it("removes an expired tombstone and keeps a recent one and a live row", async () => { + const lab = (id: string, deletedAt: Date | null) => + prisma.labResult.create({ + data: { + id, + userId: USER, + analyte: "LDL", + unit: "mg/dL", + value: 100, + takenAt: new Date("2026-01-01T08:00:00.000Z"), + deletedAt, + }, + }); + await lab("lab-expired", EXPIRED); + await lab("lab-recent", RECENT); + await lab("lab-live", null); + + const leg = await purgeSoftDeletedTable(prisma, "LabResult", NOW); + + expect(leg).toMatchObject({ deleted: 1, drained: true }); + const left = await prisma.labResult.findMany({ + select: { id: true }, + orderBy: { id: "asc" }, + }); + expect(left.map((r) => r.id)).toEqual(["lab-live", "lab-recent"]); + }); + + it("cascades a purged visit's links and unhooks a live vaccination", async () => { + await prisma.encounter.create({ + data: { + id: "visit-expired", + userId: USER, + occurredAt: new Date("2026-01-01T08:00:00.000Z"), + deletedAt: EXPIRED, + }, + }); + await prisma.vaccinationRecord.create({ + data: { + id: "vacc-live", + userId: USER, + occurredAt: new Date("2026-01-01T08:00:00.000Z"), + encounterId: "visit-expired", + }, + }); + + const leg = await purgeSoftDeletedTable(prisma, "Encounter", NOW); + + expect(leg.deleted).toBe(1); + expect(await prisma.encounter.count()).toBe(0); + expect( + await prisma.vaccinationRecord.findUniqueOrThrow({ + where: { id: "vacc-live" }, + select: { encounterId: true, deletedAt: true }, + }), + ).toEqual({ encounterId: null, deletedAt: null }); + }); + + it("keeps a start folded into a live cycle until that cycle goes", async () => { + const cycle = ( + id: string, + startDate: string, + deletedAt: Date | null, + absorbedIntoId: string | null = null, + ) => + prisma.menstrualCycle.create({ + data: { id, userId: USER, startDate, deletedAt, absorbedIntoId }, + }); + await cycle("cycle-live", "2026-01-01", null); + await cycle("cycle-folded", "2026-01-03", EXPIRED, "cycle-live"); + await cycle("cycle-gone-absorber", "2025-12-01", EXPIRED); + await cycle( + "cycle-folded-orphan", + "2025-12-03", + EXPIRED, + "cycle-gone-absorber", + ); + await cycle("cycle-plain", "2025-11-01", EXPIRED); + + const leg = await purgeSoftDeletedTable(prisma, "MenstrualCycle", NOW); + + expect(leg.deleted).toBe(3); + const left = await prisma.menstrualCycle.findMany({ + select: { id: true }, + orderBy: { id: "asc" }, + }); + expect(left.map((r) => r.id)).toEqual(["cycle-folded", "cycle-live"]); + }); + + it("keeps a removed coach fact for the coach message retention", async () => { + const fact = (id: string, deletedAt: Date) => + prisma.coachFact.create({ + data: { + id, + userId: USER, + category: "medication", + factEncrypted: Buffer.from("opaque"), + deletedAt, + }, + }); + await fact("fact-past-sync-horizon", EXPIRED); + await fact("fact-past-a-year", new Date(NOW.getTime() - 400 * DAY)); + + const leg = await purgeSoftDeletedTable(prisma, "CoachFact", NOW); + + expect(leg.deleted).toBe(1); + const left = await prisma.coachFact.findMany({ select: { id: true } }); + expect(left.map((r) => r.id)).toEqual(["fact-past-sync-horizon"]); + }); + + it("counter-check: refuses a model the register does not hand to it", async () => { + await expect( + purgeSoftDeletedTable(prisma, "Measurement", NOW), + ).rejects.toThrow("No purge entry for Measurement"); + }); +}); From 566fd8c6b364131a409973845d407864f5ccbd28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 13:17:41 +0200 Subject: [PATCH 5/7] feat(jobs): clean expired sessions, tokens and challenges nightly Sessions, refresh tokens, access tokens, trusted devices and the second-factor and passkey challenges were never removed after they expired, so the sign-in tables were mostly dead rows. auth-ledger-cleanup deletes a session, token or trusted device 30 days after it expired, which keeps every row the login throttle's 30-day known-address check and refresh reuse detection can still read; a revoked token without an expiry stays listed. Challenges go a day after expiry. Security activity reads the audit log and loses nothing. --- src/lib/jobs/auth-ledger-cleanup.ts | 163 +++++++++++++++ src/lib/jobs/job-outcome.ts | 5 + src/lib/jobs/queue-runtime.ts | 1 + src/lib/jobs/reminder/register-maintenance.ts | 17 ++ src/lib/jobs/subsystem-surface.ts | 1 + tests/integration/auth-ledger-cleanup.test.ts | 185 ++++++++++++++++++ 6 files changed, 372 insertions(+) create mode 100644 src/lib/jobs/auth-ledger-cleanup.ts create mode 100644 tests/integration/auth-ledger-cleanup.test.ts diff --git a/src/lib/jobs/auth-ledger-cleanup.ts b/src/lib/jobs/auth-ledger-cleanup.ts new file mode 100644 index 000000000..063ea6028 --- /dev/null +++ b/src/lib/jobs/auth-ledger-cleanup.ts @@ -0,0 +1,163 @@ +/** + * Nightly cleanup of the dead rows of the sign-in ledgers. + * + * Every sign-in writes a session, every native sign-in and refresh writes a + * refresh token and an access token, every second-factor prompt and passkey + * ceremony writes a challenge, and "remember this device" writes a trusted + * device. Nothing removed them once they expired, so the tables were mostly + * dead rows: on a long-running instance nine in ten sessions and access + * tokens had expired. + * + * What still reads a dead row decides how long it stays: + * + * - Sessions and refresh tokens: the login throttle recognises a source by a + * session or refresh token created from the same address in the last 30 + * days (`KNOWN_ADDRESS_WINDOW_MS`), and by any refresh token of the device. + * Reuse detection reads a consumed refresh token until it expires (an + * expired one is refused before reuse is considered). So a row goes 30 days + * after it expired: it was created before that, so the address window + * cannot reach it, and a device that has not signed in for that long is a + * new source again. A revoked token waits for its expiry like any other. + * - Access tokens (`api_tokens`): the token list shows revoked and expired + * tokens so their owner can see what ended. One goes 30 days after it + * expired; a revoked token without an expiry stays, since nothing records + * when it was revoked. Connector tokens have their own sweep + * (`mcp-token-cleanup`). + * - Second-factor and passkey challenges: nothing reads one past its expiry. + * A day of slack keeps a ceremony in flight at midnight out of the way. + * - Trusted devices: an expired one no longer skips the second factor and is + * not listed; it goes 30 days after it expired. + * + * Security activity is read from the audit log, never from these tables, so + * it loses nothing. Each table is deleted in bounded batches. + */ +import type { Job } from "pg-boss"; + +import type { PrismaClient } from "@/generated/prisma/client"; +import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { PURGE_BATCH_SIZE, PURGE_MAX_BATCHES } from "@/lib/jobs/purge-batch"; +import { getWorkerPrisma } from "@/lib/jobs/reminder/shared"; +import { withBackgroundEvent } from "@/lib/logging/background"; + +export const AUTH_LEDGER_CLEANUP_QUEUE = "auth-ledger-cleanup"; +/** Daily at 03:35 Europe/Berlin, inside the 03:xx maintenance window. */ +export const AUTH_LEDGER_CLEANUP_CRON = "35 3 * * *"; + +const DAY_MS = 86_400_000; + +/** Days an expired session, token or trusted device is kept. */ +export const AUTH_LEDGER_EXPIRED_GRACE_DAYS = 30; +/** Days an expired challenge is kept. */ +export const AUTH_CHALLENGE_GRACE_DAYS = 1; + +export interface AuthLedgerCleanupPayload { + triggeredAt?: string; +} + +/** + * The tables and the column each one expires by. Literals, spliced into the + * delete; `api_tokens.expires_at` is nullable and a null never matches. + */ +const LEDGERS = [ + { + key: "sessions", + table: "sessions", + graceDays: AUTH_LEDGER_EXPIRED_GRACE_DAYS, + }, + { + key: "refresh_tokens", + table: "refresh_tokens", + graceDays: AUTH_LEDGER_EXPIRED_GRACE_DAYS, + }, + { + key: "api_tokens", + table: "api_tokens", + graceDays: AUTH_LEDGER_EXPIRED_GRACE_DAYS, + }, + { + key: "trusted_devices", + table: "trusted_devices", + graceDays: AUTH_LEDGER_EXPIRED_GRACE_DAYS, + }, + { + key: "mfa_challenges", + table: "mfa_challenges", + graceDays: AUTH_CHALLENGE_GRACE_DAYS, + }, + { + key: "auth_challenges", + table: "auth_challenges", + graceDays: AUTH_CHALLENGE_GRACE_DAYS, + }, +] as const; + +export type AuthLedger = (typeof LEDGERS)[number]["key"]; + +export interface AuthLedgerCleanupResult { + deleted: Record; + /** False when a table stopped at the batch cap with rows left. */ + drained: boolean; +} + +export async function cleanupAuthLedgers( + prisma: PrismaClient, + now: Date = new Date(), + { batchSize = PURGE_BATCH_SIZE, maxBatches = PURGE_MAX_BATCHES } = {}, +): Promise { + const deleted = {} as Record; + let drained = true; + for (const ledger of LEDGERS) { + const cutoff = new Date(now.getTime() - ledger.graceDays * DAY_MS); + let total = 0; + let finished = false; + for (let batch = 0; batch < maxBatches; batch++) { + // `ledger.table` is one of the literals above, never input. + const removed = await prisma.$executeRawUnsafe( + `DELETE FROM "${ledger.table}" WHERE "id" IN ( + SELECT "id" FROM "${ledger.table}" + WHERE "expires_at" < $1 LIMIT $2)`, + cutoff, + batchSize, + ); + total += removed; + if (removed < batchSize) { + finished = true; + break; + } + } + deleted[ledger.key] = total; + if (!finished) drained = false; + } + return { deleted, drained }; +} + +export async function handleAuthLedgerCleanup( + jobs: Job[], +): Promise { + void jobs; + return withBackgroundEvent("job.auth_ledger_cleanup", async (evt) => { + try { + const { deleted, drained } = await cleanupAuthLedgers(getWorkerPrisma()); + for (const [key, count] of Object.entries(deleted)) { + evt.addMeta(`auth_ledger_cleanup_${key}`, count); + } + evt.addMeta("auth_ledger_cleanup_drained", drained); + if (!drained) { + evt.addWarning( + "auth-ledger-cleanup stopped at the batch cap; a backlog remains for the next run", + ); + } + return jobDone({ + sessions_deleted: deleted.sessions, + refresh_tokens_deleted: deleted.refresh_tokens, + access_tokens_deleted: deleted.api_tokens, + trusted_devices_deleted: deleted.trusted_devices, + challenges_deleted: deleted.mfa_challenges + deleted.auth_challenges, + drained, + }); + } catch (err) { + evt.addWarning(`auth-ledger-cleanup failed: ${err}`); + throw err; + } + }); +} diff --git a/src/lib/jobs/job-outcome.ts b/src/lib/jobs/job-outcome.ts index 672cc8ba3..d75c1b29f 100644 --- a/src/lib/jobs/job-outcome.ts +++ b/src/lib/jobs/job-outcome.ts @@ -64,6 +64,8 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet = new Set([ "budget_blocked", "cached", "candidates_scanned", + // The nightly sign-in ledger cleanup (`auth-ledger-cleanup`). + "challenges_deleted", "connect_tickets_deleted", "connections_deleted", "considered", @@ -208,6 +210,7 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet = new Set([ "records_oversized", "records_read", "refreshed", + "refresh_tokens_deleted", "refused", "reminders_due", "removed", @@ -251,6 +254,7 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet = new Set([ "scheduled", "sent", "served", + "sessions_deleted", "side_effects_migrated", // v1.42.4 — integration watchdog: sources newly flagged as silent. "silences_flagged", @@ -277,6 +281,7 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet = new Set([ "tls_pin_monitor_known_count", "tls_pin_monitor_outcome", "total", + "trusted_devices_deleted", "unchanged", "updated", "users", diff --git a/src/lib/jobs/queue-runtime.ts b/src/lib/jobs/queue-runtime.ts index 56a12bda8..3c3d007ff 100644 --- a/src/lib/jobs/queue-runtime.ts +++ b/src/lib/jobs/queue-runtime.ts @@ -316,6 +316,7 @@ export const QUEUE_RUNTIME: Readonly> = { "soft-delete-purge": short( "At most 40 batches of 5 000 rows per table, over a few small tables.", ), + "auth-ledger-cleanup": short("At most 40 batches of 5 000 rows per table."), "document-summary-reaper": short("One updateMany."), "cycle-prediction-refresh": short( "Arithmetic per eligible account, no provider call.", diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index 3b2215244..4b32a0724 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -296,6 +296,12 @@ import { handleSoftDeletePurge, type SoftDeletePurgePayload, } from "@/lib/jobs/soft-delete-purge"; +import { + AUTH_LEDGER_CLEANUP_QUEUE, + AUTH_LEDGER_CLEANUP_CRON, + handleAuthLedgerCleanup, + type AuthLedgerCleanupPayload, +} from "@/lib/jobs/auth-ledger-cleanup"; import { DOCUMENT_SUMMARY_REAPER_QUEUE, DOCUMENT_SUMMARY_REAPER_CRON, @@ -589,6 +595,9 @@ const allQueues = [ // Nightly purge of expired tombstones for every soft-delete model the // retention register hands to it (`soft-delete-retention.ts`). SOFT_DELETE_PURGE_QUEUE, + // Nightly removal of expired sessions, tokens, challenges and trusted + // devices. + AUTH_LEDGER_CLEANUP_QUEUE, // Document vault — hourly reaper for summary rows stuck on PENDING (a // job dead past its retry budget). Without this entry the schedule // silently no-ops and a dead "wird generiert" claim never converges to @@ -791,6 +800,8 @@ const schedules: ScheduleEntry[] = [ // Daily 03:42 Europe/Berlin, after the sync-domain tombstone cleanup. The // next night is the retry: the purge picks up whatever a failed run left. [SOFT_DELETE_PURGE_QUEUE, SOFT_DELETE_PURGE_CRON, cronIsTheRetry], + // Daily 03:35 Europe/Berlin. Same: the next night deletes what is left. + [AUTH_LEDGER_CLEANUP_QUEUE, AUTH_LEDGER_CLEANUP_CRON, cronIsTheRetry], // Document vault — hourly heal of summary rows stuck on PENDING. The next // tick is the retry: the updateMany is idempotent and the TTL predicate // re-selects anything a failed tick left behind. @@ -1160,6 +1171,12 @@ export async function registerMaintenanceQueues( { localConcurrency: 1 }, handleSoftDeletePurge, ); + await createAndWork( + boss, + AUTH_LEDGER_CLEANUP_QUEUE, + { localConcurrency: 1 }, + handleAuthLedgerCleanup, + ); // v1.40 — background document AI runs. Each job is one run, under the lock // `lockedPass` takes per run id, so a redelivery never reads beside the // first delivery; the claim inside is conditional as well. diff --git a/src/lib/jobs/subsystem-surface.ts b/src/lib/jobs/subsystem-surface.ts index ebc49012e..39fa03847 100644 --- a/src/lib/jobs/subsystem-surface.ts +++ b/src/lib/jobs/subsystem-surface.ts @@ -128,6 +128,7 @@ const subsystemSurface = { "achievement-unlock-sweep": { audience: "system" }, "measurement-tombstone-cleanup": { audience: "system" }, "soft-delete-purge": { audience: "system" }, + "auth-ledger-cleanup": { audience: "system" }, "coach-message-cleanup": { audience: "system" }, "workout-insight-claim-cleanup": { audience: "system" }, "note-encryption-backfill": { audience: "system" }, diff --git a/tests/integration/auth-ledger-cleanup.test.ts b/tests/integration/auth-ledger-cleanup.test.ts new file mode 100644 index 000000000..8de6fe9bd --- /dev/null +++ b/tests/integration/auth-ledger-cleanup.test.ts @@ -0,0 +1,185 @@ +/** + * The nightly cleanup of the sign-in ledgers (`auth-ledger-cleanup`). + * + * A row goes once it has been expired for longer than its grace (30 days for + * sessions, tokens and trusted devices, a day for challenges). A live row, a + * recently expired one, a revoked token that has not expired, and a revoked + * token without an expiry all stay. The delete is batched and says when it + * stopped with rows left. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cleanupAuthLedgers } from "@/lib/jobs/auth-ledger-cleanup"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const USER = "auth-ledger-user"; +const NOW = new Date("2026-10-20T03:35:00.000Z"); +const DAY = 86_400_000; +const LONG_EXPIRED = new Date(NOW.getTime() - 40 * DAY); +const JUST_EXPIRED = new Date(NOW.getTime() - 10 * DAY); +const LIVE = new Date(NOW.getTime() + 10 * DAY); + +const prisma = getPrismaClient(); + +beforeEach(async () => { + await truncateAllTables(prisma); + await prisma.user.create({ + data: { id: USER, username: USER, role: "USER", timezone: "UTC" }, + }); +}); + +async function ids(rows: Promise>): Promise { + return (await rows).map((r) => r.id).sort(); +} + +describe("auth ledger cleanup", () => { + it("removes rows expired past their grace and keeps everything else", async () => { + for (const [id, expiresAt] of [ + ["session-old", LONG_EXPIRED], + ["session-recent", JUST_EXPIRED], + ["session-live", LIVE], + ] as const) { + await prisma.session.create({ data: { id, userId: USER, expiresAt } }); + } + for (const [id, expiresAt, revokedAt, usedAt] of [ + ["refresh-old", LONG_EXPIRED, null, LONG_EXPIRED], + ["refresh-recent", JUST_EXPIRED, null, null], + // Revoked but not yet expired: reuse detection may still need it. + ["refresh-revoked-live", LIVE, NOW, null], + ["refresh-used-live", LIVE, null, NOW], + ] as const) { + await prisma.refreshToken.create({ + data: { + id, + userId: USER, + tokenHash: `hash-${id}`, + deviceId: "device-1", + expiresAt, + revokedAt, + usedAt, + }, + }); + } + for (const [id, expiresAt, revoked] of [ + ["token-old", LONG_EXPIRED, false], + ["token-old-revoked", LONG_EXPIRED, true], + ["token-recent", JUST_EXPIRED, false], + ["token-live", LIVE, false], + // No expiry, revoked: nothing says when, so it stays listed. + ["token-revoked-forever", null, true], + ["token-forever", null, false], + ] as const) { + await prisma.apiToken.create({ + data: { + id, + userId: USER, + name: id, + tokenHash: `hash-${id}`, + expiresAt, + revoked, + }, + }); + } + for (const [id, expiresAt] of [ + ["trusted-old", LONG_EXPIRED], + ["trusted-recent", JUST_EXPIRED], + ] as const) { + await prisma.trustedDevice.create({ + data: { id, userId: USER, tokenHash: `hash-${id}`, expiresAt }, + }); + } + for (const [id, expiresAt] of [ + ["mfa-old", new Date(NOW.getTime() - 2 * DAY)], + ["mfa-recent", new Date(NOW.getTime() - 60 * 60_000)], + ] as const) { + await prisma.mfaChallenge.create({ + data: { + id, + userId: USER, + kind: "totp", + ticketHash: `hash-${id}`, + expiresAt, + }, + }); + } + for (const [id, expiresAt] of [ + ["challenge-old", new Date(NOW.getTime() - 2 * DAY)], + ["challenge-recent", new Date(NOW.getTime() - 60 * 60_000)], + ] as const) { + await prisma.authChallenge.create({ + data: { + id, + userId: null, + challenge: id, + type: "registration", + expiresAt, + }, + }); + } + + const result = await cleanupAuthLedgers(prisma, NOW); + + expect(result).toEqual({ + deleted: { + sessions: 1, + refresh_tokens: 1, + api_tokens: 2, + trusted_devices: 1, + mfa_challenges: 1, + auth_challenges: 1, + }, + drained: true, + }); + expect( + await ids(prisma.session.findMany({ select: { id: true } })), + ).toEqual(["session-live", "session-recent"]); + expect( + await ids(prisma.refreshToken.findMany({ select: { id: true } })), + ).toEqual(["refresh-recent", "refresh-revoked-live", "refresh-used-live"]); + expect( + await ids(prisma.apiToken.findMany({ select: { id: true } })), + ).toEqual([ + "token-forever", + "token-live", + "token-recent", + "token-revoked-forever", + ]); + expect( + await ids(prisma.trustedDevice.findMany({ select: { id: true } })), + ).toEqual(["trusted-recent"]); + expect( + await ids(prisma.mfaChallenge.findMany({ select: { id: true } })), + ).toEqual(["mfa-recent"]); + expect( + await ids(prisma.authChallenge.findMany({ select: { id: true } })), + ).toEqual(["challenge-recent"]); + }); + + it("deletes in batches and reports a table it could not finish", async () => { + for (let i = 0; i < 5; i++) { + await prisma.session.create({ + data: { id: `old-${i}`, userId: USER, expiresAt: LONG_EXPIRED }, + }); + } + + const capped = await cleanupAuthLedgers(prisma, NOW, { + batchSize: 2, + maxBatches: 2, + }); + expect(capped.deleted.sessions).toBe(4); + expect(capped.drained).toBe(false); + + const rest = await cleanupAuthLedgers(prisma, NOW, { + batchSize: 2, + maxBatches: 2, + }); + expect(rest.deleted.sessions).toBe(1); + expect(rest.drained).toBe(true); + expect(await prisma.session.count()).toBe(0); + }); +}); From 03f62c330bf67e520b75f393afc9327f1b4762fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 13:18:13 +0200 Subject: [PATCH 6/7] chore(schema): drop the stale pending-consumer markers on LifeEvent The timeline lane, the day view and the life-events service read every LifeEvent column; the column-reader sweep counts all six as consumed once the markers are gone. --- prisma/schema.prisma | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 63fa6d26a..c38d79ad9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9845,21 +9845,21 @@ model DocumentAiRun { model LifeEvent { id String @id @default(cuid()) userId String @map("user_id") - /// @pending-consumer(#613) The kind of event, from a deliberately short list. + /// The kind of event, from a deliberately short list. category LifeEventCategory - /// @pending-consumer(#613) First day of the event, `YYYY-MM-DD`. At MONTH or + /// First day of the event, `YYYY-MM-DD`. At MONTH or /// YEAR precision the first day of that month or year. startDate String @map("start_date") - /// @pending-consumer(#613) Last day, `YYYY-MM-DD`, for an event that spans a + /// Last day, `YYYY-MM-DD`, for an event that spans a /// period (parental leave, a year abroad). NULL = a single date. endDate String? @map("end_date") - /// @pending-consumer(#613) How much of the date is known. Applies to both + /// How much of the date is known. Applies to both /// ends. precision LifeEventPrecision - /// @pending-consumer(#613) What happened, in the person's own words, + /// What happened, in the person's own words, /// AES-256-GCM encrypted at rest. titleEncrypted Bytes @map("title_encrypted") - /// @pending-consumer(#613) An optional note, AES-256-GCM encrypted at rest. + /// An optional note, AES-256-GCM encrypted at rest. noteEncrypted Bytes? @map("note_encrypted") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") From f6cf7c5e6b3b138ef05a06a7e22b19959295bb86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 13:19:48 +0200 Subject: [PATCH 7/7] fix(docker): give the health check room for a slow migration The entrypoint migrates before the server listens, and a migration that waits out a held lock retries for up to about eight minutes, while the health check marked the container unhealthy after two. The start period is now ten minutes in the compose file and the image; the first answering probe still ends it, so a normal boot reports healthy as before. --- Dockerfile | 4 +++- docker-compose.yml | 8 +++++++- docs/ops/deploy.md | 6 ++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 625feff6d..6f58628aa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -318,7 +318,9 @@ EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ +# Start period: the entrypoint migrates before the server listens, and a +# migration waiting out a held lock retries for up to about eight minutes. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10m --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health || exit 1 ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/docker-compose.yml b/docker-compose.yml index 72a75c0c3..fb778bee4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -516,7 +516,13 @@ services: interval: 30s timeout: 5s retries: 3 - start_period: 30s + # The entrypoint applies migrations before the server listens. A + # migration that waits out a held lock retries for up to about eight + # minutes (docs/ops/deploy.md), and failed probes in that time must not + # read as a dead container. The first probe that answers still ends + # the start period, so a normal boot is healthy within 30 seconds of + # listening. + start_period: 10m # Optional dedicated worker container — uncomment + set # HEALTHLOG_PROCESS_TYPE=web on `app` to split the deployment. diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index 36e78047e..d4a828b9e 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -79,6 +79,12 @@ does the same. Nothing else is retried: any other failure, and a lock timeout in a migration that does not carry the marker, stops the boot exactly as before. +The retries fit inside the container's health check: its start period is ten +minutes (`docker-compose.yml` and the image's own `HEALTHCHECK`), so failed +probes while the entrypoint is still migrating do not mark the container +unhealthy. The first probe that answers ends the start period, so a normal +boot is reported healthy within half a minute of the server listening. + If all five retries time out, the log ends with `HealthLog: ERROR — migration … timed out on a lock 6 times in a row; giving up.` and the container exits. Something has held the table for about eight