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:
Marc-André Bombeck committed 2026-05-21 11:19:57 +02:00
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 $$;
+9
View File
@@ -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")
}