mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(prisma): add unique constraint to medication_intake_events for projection race
Migration 0073 adds a UNIQUE constraint on (user_id, medication_id, scheduled_for, source) for medication_intake_events. Pre-fix the table only had an INDEX on those three columns, so the two newly-projecting routes — the intake route (PR #191) and the dashboard summary route (v1.4.39.4) — could pass their existence probes simultaneously and mint the same REMINDER row twice when the iOS app hydrates both endpoints in parallel. `source` is in the key so the reminder worker's REMINDER row doesn't collide with manual user inserts (WEB / TELEGRAM / IMPORT) on the same slot — distinct intake sources at the same scheduled instant are a legitimate shape (Telegram-bot logged take + a Web-UI correction on the same dose). The legacy (user_id, medication_id, scheduled_for) index stays in place because most reads filter on those three columns without caring about source. The new constraint becomes the structural backstop; the createMany skipDuplicates: true flag added in the two routes is the defense-in-depth layer. Additive only. DO $$ … EXCEPTION-on-duplicate guard mirrors 0067 / 0071 so re-runs are idempotent.
This commit is contained in:
1 parent
341483de3f
commit
61f2dca421
2 files changed
+66
No files matched your search
@@ -0,0 +1,57 @@
|
||||
-- v1.4.39 W-SERVER-FIX-2 — unique constraint on
|
||||
-- (user_id, medication_id, scheduled_for, source) for
|
||||
-- medication_intake_events so the projection-then-backfill code path
|
||||
-- can't mint duplicate REMINDER rows under a concurrent two-route
|
||||
-- hit.
|
||||
--
|
||||
-- Background. Two read routes now project active schedules through
|
||||
-- `expandTodayIntakes` and idempotently `createMany`-backfill any
|
||||
-- missing rows:
|
||||
--
|
||||
-- GET /api/medications/intake?scope=today
|
||||
-- GET /api/dashboard/summary
|
||||
--
|
||||
-- The iOS Dashboard mounts both at roughly the same wall-clock when
|
||||
-- the compliance ring + Erfassen sheet hydrate together. Pre-fix the
|
||||
-- table carried only an INDEX on `(user_id, medication_id,
|
||||
-- scheduled_for)`, NOT a UNIQUE constraint, so both routes could pass
|
||||
-- their existence probes simultaneously and `createMany` the same
|
||||
-- `(medicationId, scheduledFor, REMINDER)` row twice — corrupting the
|
||||
-- compliance bucket counts on the next read.
|
||||
--
|
||||
-- `source` is part of the key so the reminder worker's REMINDER row
|
||||
-- doesn't collide with manual user inserts (WEB / TELEGRAM / IMPORT)
|
||||
-- on the same slot. Distinct intake sources at the same scheduled
|
||||
-- instant are a legitimate shape — e.g. a Telegram-bot logged take +
|
||||
-- a Web-UI correction on the same dose.
|
||||
--
|
||||
-- The query-side `(user_id, medication_id, scheduled_for)` index
|
||||
-- stays in place — most reads filter on those three columns without
|
||||
-- caring about `source`, so the legacy index keeps its read-path
|
||||
-- value even after the unique constraint creates its own
|
||||
-- (user_id, medication_id, scheduled_for, source) covering index.
|
||||
--
|
||||
-- Production data check. The reminder worker is the only writer that
|
||||
-- ever produces REMINDER-source rows pre-this-fix; it queries pending
|
||||
-- rows by `(medicationId, scheduledFor)` before each insert so a
|
||||
-- self-double-mint is structurally impossible. The new projection
|
||||
-- code paths (intake route + dashboard route) are the only new
|
||||
-- sources of potential duplicates, and both ship with the
|
||||
-- `skipDuplicates: true` flag alongside this constraint. If
|
||||
-- `migrate deploy` reports a unique-constraint violation, a follow-up
|
||||
-- one-shot data-cleanup pass would dedup the offending rows by
|
||||
-- (user_id, medication_id, scheduled_for, source) keeping the
|
||||
-- lowest-id row; that cleanup is intentionally NOT bundled here so
|
||||
-- the migration stays additive + reviewable.
|
||||
--
|
||||
-- Additive only. IF NOT EXISTS / EXCEPTION-on-duplicate guards
|
||||
-- mirror the patterns in 0067 / 0071.
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "medication_intake_events"
|
||||
ADD CONSTRAINT "medication_intake_events_user_id_medication_id_scheduled_for_source_key"
|
||||
UNIQUE ("user_id", "medication_id", "scheduled_for", "source");
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
WHEN duplicate_table THEN NULL;
|
||||
END $$;
|
||||
@@ -838,6 +838,15 @@ model MedicationIntakeEvent {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// v1.4.39 W-SERVER-FIX-2 — both the intake route and the dashboard
|
||||
/// summary route project active schedules through `expandTodayIntakes`
|
||||
/// and idempotently `createMany`-backfill missing rows. Without a
|
||||
/// unique constraint the two reads could race a duplicate REMINDER
|
||||
/// row in for the same `(medicationId, scheduledFor)` between the
|
||||
/// existence probe and the insert. `source` is in the key so the
|
||||
/// reminder worker (REMINDER) doesn't collide with manual user
|
||||
/// inserts (WEB / TELEGRAM / IMPORT) on the same slot.
|
||||
@@unique([userId, medicationId, scheduledFor, source])
|
||||
@@index([userId, medicationId, scheduledFor])
|
||||
@@map("medication_intake_events")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user