Files
HealthLog/prisma/migrations/0122_v1121_mood_external_id/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

32 lines
1.6 KiB
SQL

-- v1.12.1 — stable external dedup key for MoodEntry.
--
-- Problem. Unlike `Measurement`, `MoodEntry` carries no `external_id`.
-- The moodLog webhook + pull sync + JSON import all upsert/insert on
-- `(user_id, date, mood_logged_at)`. If the upstream re-emits the same
-- entry with a re-rounded or re-zoned `mood_logged_at` (a Daylio export
-- re-import, or a tz change moving `date`), the composite key differs
-- and a SECOND row lands for the same physical mood entry. Measurements
-- solved exactly this with `external_id`; mood did not.
--
-- Fix. Add a nullable `external_id` column + a NULL-DISTINCT unique on
-- `(user_id, source, external_id)`. Postgres treats NULLs as distinct in
-- a unique index by default, so every MANUAL/native entry (which carries
-- a NULL `external_id`) never collides on the new key and keeps the
-- legacy `(user_id, date, mood_logged_at)` path. Importers that receive
-- a source-stable id (the moodLog upstream entry id, a Daylio row id)
-- write it into `external_id` and upsert on the new key, making a
-- re-import idempotent regardless of `mood_logged_at` drift.
--
-- Strictly additive: a new nullable column + a NULL-distinct unique
-- index. No backfill (legacy rows stay NULL → distinct → untouched), no
-- rewrite of `mood_entries`.
ALTER TABLE "mood_entries"
ADD COLUMN IF NOT EXISTS "external_id" TEXT;
-- NULL-distinct unique: only entries that carry a real source id are
-- constrained; the long tail of native/MANUAL rows (NULL external_id)
-- never collides here.
CREATE UNIQUE INDEX IF NOT EXISTS "mood_entries_user_id_source_external_id_key"
ON "mood_entries" ("user_id", "source", "external_id");