Files
HealthLog/prisma/migrations/0121_v1121_partial_unique_live_rows/migration.sql
T
Marc-André Bombeck 9c4e4de927 fix(db): live-row partial unique for intake re-take, mood external-id dedup key, perf indexes
Migration 0121 replaces the MedicationIntakeEvent unique constraint with a
partial unique index `WHERE deleted_at IS NULL`. A slot the user previously
deleted (tombstoned) no longer occupies the unique slot, so a re-take
re-creates the row cleanly instead of P2002-ing against the tombstone and
500-ing. The migration dedups any pre-existing duplicate live rows first
(keep newest, tombstone the rest) so the unique build can't fail on real
data.

Measurement and Workout deliberately stay full uniques: Measurement's
compound-key writes use prisma.upsert, which Prisma 7 compiles to native
INSERT ... ON CONFLICT and Postgres cannot arbiter against a partial unique
(and consolidate-daily-mean intentionally resurrects a tombstoned canonical
row, which needs the full unique); Workout has no deleted_at column. The
rationale is documented in the migration header and the schema comments.

Migration 0122 adds MoodEntry.externalId plus a NULL-distinct
`@@unique([userId, source, externalId])`. The moodLog webhook, the pull sync,
and the JSON import now carry the source's stable id into externalId and
upsert on the new key when present, so a re-import is idempotent even when
moodLoggedAt re-rounds or re-zones. Native/MANUAL entries keep their NULL
externalId and stay on the legacy (userId, date, moodLoggedAt) path.

Migration 0123 adds AuditLog(userId, action, createdAt desc) and
MoodEntry(userId, moodLoggedAt) composite indexes for the status-cache and
mood-insights read paths.

Also corrects the stale "v1.6.0 drops this column" note on
MedicationSchedule.daysOfWeek (the column is still dual-written and read by
live paths, so it is retained) and documents the UTC-only timezone storage
invariant in a schema header comment.

Integration tests cover the intake re-take after delete (no resurrection /
no 500) and mood re-import idempotency (with and without an external id) on
real Postgres.

(cherry picked from commit 9a6723f469bdec063a99cd08df132e07ec3c95a5)
2026-06-05 03:01:36 +02:00

80 lines
4.3 KiB
SQL

-- v1.12.1 — partial unique index scoped to live rows
-- (`WHERE deleted_at IS NULL`) on MedicationIntakeEvent.
--
-- Problem. `MedicationIntakeEvent` carries a `deleted_at` tombstone, but
-- its unique constraint (0073) covers every row regardless of tombstone
-- state. A soft-deleted slot row therefore keeps occupying its unique
-- slot, which breaks delete-then-retake: `applyCanonicalSlotWrite`
-- (`src/lib/medications/scheduling/slot-upsert.ts`) finds no LIVE row for
-- a slot the user previously deleted, tries to CREATE, and P2002s against
-- the tombstone on `(user_id, medication_id, scheduled_for, source)`; the
-- catch re-finds only live rows, finds none, and re-throws — the re-take
-- 500s. The slot-dedup job already has to catch-and-skip this exact
-- collision (`intake-slot-dedup.ts:257-298`).
--
-- Fix. Replace the full unique CONSTRAINT with a PARTIAL unique INDEX
-- predicated on `deleted_at IS NULL`. Live-row uniqueness still holds;
-- tombstones drop out of the index entirely, so a re-take re-creates the
-- slot row cleanly instead of P2002-ing against the tombstone. Prisma
-- can't express a partial-predicate unique, so this is hand-written (same
-- reason as the step-consolidation / dense-intraday partial indexes in
-- 0087 / 0108). The schema keeps the full `@@unique` (which generates the
-- `userId_medicationId_scheduledFor_source` compound where-key the slot
-- finder uses) with a comment pointing here.
--
-- Why intake and NOT Measurement / Workout here:
-- * Measurement — its compound-key write path uses `prisma.upsert`
-- (consolidate-daily-mean, drain-per-sample-cumulative, the Fitbit /
-- WHOOP / Apple-Health sync, Apple-export import). Prisma 7 compiles
-- a compound-key `upsert` to native `INSERT ... ON CONFLICT (cols)`,
-- and Postgres rejects an `ON CONFLICT` arbiter that targets a
-- PARTIAL unique without the matching predicate (which Prisma does
-- not emit). Several of those upserts also intentionally RESURRECT a
-- tombstoned canonical row (`update: { deletedAt: null }` in
-- consolidate-daily-mean) — a behaviour that depends on the full
-- unique covering tombstoned rows. Converting Measurement to a
-- partial unique would break every one of those upserts. The
-- Measurement batch-ingest resurrection gap is instead closed at the
-- application layer (the existence probe filters `deletedAt: null`).
-- * Workout — has no `deleted_at` column (workout deletes are hard
-- deletes), so there is no tombstone to collide with.
-- MedicationIntakeEvent has NO `upsert` call site (every writer is
-- `create` / `createMany` + a P2002 catch, and `createMany skipDuplicates`
-- compiles to `ON CONFLICT DO NOTHING` without a target, which a partial
-- index satisfies), so the partial unique is safe here and nowhere else.
--
-- DEDUP-FIRST. A partial unique build fails if two LIVE rows already
-- share the tuple. Before the `CREATE UNIQUE INDEX` the migration
-- collapses any existing duplicate live rows, keeping the newest
-- (`updated_at` then `id` as the tie-break) and tombstoning the rest
-- (soft-delete, not hard-delete, so the audit/sync trail is preserved and
-- a paired client still reconciles the collapse). Idempotent: the dedup
-- is a no-op on a clean table and the index builds with `IF NOT EXISTS`.
--
-- Lock note (conscious trade-off): this is a plain, non-`CONCURRENTLY`
-- index build, so it runs inside Prisma's migration transaction and
-- briefly holds ACCESS EXCLUSIVE on the table. The duplicate-live-row
-- DELETE-then-CREATE pair is sub-second on every normal deployment.
WITH ranked AS (
SELECT
"id",
ROW_NUMBER() OVER (
PARTITION BY "user_id", "medication_id", "scheduled_for", "source"
ORDER BY "updated_at" DESC, "id" DESC
) AS rn
FROM "medication_intake_events"
WHERE "deleted_at" IS NULL
)
UPDATE "medication_intake_events" e
SET "deleted_at" = NOW(), "sync_version" = e."sync_version" + 1
FROM ranked r
WHERE e."id" = r."id" AND r.rn > 1;
ALTER TABLE "medication_intake_events"
DROP CONSTRAINT IF EXISTS "medication_intake_events_user_id_medication_id_scheduled_for_source_key";
CREATE UNIQUE INDEX IF NOT EXISTS "medication_intake_events_user_med_slot_source_live_key"
ON "medication_intake_events" ("user_id", "medication_id", "scheduled_for", "source")
WHERE "deleted_at" IS NULL;