mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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.
This commit is contained in:
1 parent
b90c82cba3
commit
41de0ca126
71 files changed
+314
-2027
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, string | null>();
|
||||
async function userTimezone(userId: string): Promise<string | null> {
|
||||
if (!tzCache.has(userId)) {
|
||||
@@ -474,10 +373,6 @@ async function main(): Promise<void> {
|
||||
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<string>();
|
||||
|
||||
try {
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// 1. Slot duplicates — exact (user, medication, scheduled_for)
|
||||
@@ -568,12 +463,6 @@ async function main(): Promise<void> {
|
||||
[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<void> {
|
||||
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<void> {
|
||||
[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<void> {
|
||||
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<void> {
|
||||
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) => {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user