One-shot prettier pass so the new format:check gate starts from a clean baseline. Formatting only — no behaviour change. Generator-owned and vendored artifacts stay untouched via .prettierignore.
7.0 KiB
Medication intake repair
scripts/repair-intake-anomalies.ts repairs the known historic defects in
the medication intake ledger (medication_intake_events) and its schedule
metadata (medication_schedules):
- Duplicate dose-slot rows. More than one live row on the same exact
(user, medication, scheduled_for)tuple — cross-source duplicates the pre-v1.15.19 write path could mint (e.g. a pending REMINDER row plus a taken API row for the same slot). The duplicate inflates the per-day scheduled count and paints a phantom entry in the history view. - Implausible
taken_at. Live rows whosetaken_atlands more than 7 days beforescheduled_foror more than 1 day in the future — typically the residue of a mis-attributed edit before the v1.15.19taken_atvalidation landed. - Window / times drift (v1.16.1). Schedule rows whose legacy
window_start/window_endno longer contains the canonicaltimes_of_day— typically a degenerate07:00 / 07:00point window left behind by an old write path while the dose times moved on (e.g. to09:00 / 21:00). The dose-band model never reads the window oncetimes_of_dayexists, but the stale pair kept feeding every legacy read (the pre-v1.16.1 cards painted "next intake 07:00" from it). - Stale-anchor pending rows (v1.16.1). Live pending rows
(
taken_at IS NULL, not skipped, not auto-missed) whosescheduled_forwall-clock HH:mm — evaluated in the user's timezone — matches no current dose anchor of the medication (any schedule'stimes_of_dayentry;window_startfor legacy rows without times). These are reminder-minted slots on retired schedule times that linger as phantom open doses in the ledger.
When to use it
- 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.
- The
intake-slot-dedupworker 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 implausible-taken_atrows.
Run a dry-run first
Always. The default mode only reports and never mutates:
# inside the app container (or any checkout) — DATABASE_URL must point
# at the target database
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts
# scoped to one account
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --user <userId>
The script is self-contained: its only import is pg and it reads
DATABASE_URL straight from the environment (no dotenv, no @/ path
alias, no src/ imports). That matters in the production container —
the standalone image ships no project node_modules, so the previous
revision died there with Cannot find module 'dotenv/config'. Inside
the image pg resolves via NODE_PATH=/opt/pg-boss/node_modules; in a
checkout it resolves from the project node_modules; the
--package pg --package tsx pins cover any environment with neither.
Use pnpm dlx, not bare pnpm tsx — the standalone image also strips
tsx.
The dry-run prints every duplicate group (which row would be kept, which
would be tombstoned), a table of implausible rows (id, medication,
scheduledFor, takenAt, source, timestamps), every schedule whose
window has drifted off its times_of_day (with the reconciled bounds it
would write), and a table of stale-anchor pending rows.
Applying the fix
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
--fix does five things:
-
Duplicate groups are collapsed to one winner per slot. Precedence: an actioned row (taken or skipped) beats a pending one; between two taken rows the earlier
taken_atwins; ties break deterministically oncreated_at, thenid. Losers are soft-deleted (deleted_atset,sync_versionincremented) — the same tombstone shape the delete route writes, so connected clients drop the rows on their next delta-sync. Nothing is ever hard-deleted. -
Implausible
taken_atrows are NOT touched. The recorded intent is unknowable, so the operator (or the user) corrects or deletes them in the medication history tab. To soft-delete them wholesale instead, opt in explicitly:pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible--tombstone-implausibleis refused without--fix. -
Drifted schedule windows are reconciled to
min/max(times_of_day)so the legacy pair stops contradicting the canonical dose times. Overnight windows (window_end < window_start) encode a deliberate wrap and are never touched. -
Stale-anchor pending rows are tombstoned (same soft-delete shape 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 ofrecomputeMedicationComplianceForDayinsrc/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.
The script is idempotent: a second --fix run finds zero duplicate groups
and changes nothing. Exit code is 0 on success (including a clean
zero-findings run), non-zero on bad arguments or a fatal error.
Era backfill (--backfill-eras, v1.16.3)
Schedule edits made before v1.16.3 replaced the schedule rows without archiving the old state, so history reads past days against the current times. Section 5 of the script infers the lost era from the recorded slot anchors:
- A medication qualifies when its anchor-shaped rows (user-tz
HH:mmon a 5-minute grid) deviate from the currenttimes_of_dayfor at least 7 consecutive recorded days before the current times first appear, and it has no existingmedication_schedule_revisionsrow (re-runs are idempotent). - The proposal is one revision row:
valid_from= the first deviating row's instant,valid_until= local midnight of the first day on the current times, payload = oneFREQ=DAILYschedule carrying the observed old times. - A
starts_onthat postdates the first recorded row is flagged and pulled back to that row's instant.
The default run only reports the proposals. Apply them explicitly:
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --backfill-eras
Review the dry-run table first — the inference is a heuristic. A wrongly
created revision can be deleted from medication_schedule_revisions
without side effects (the read paths fall back to live-only minting).