Make imported-event dedup bulletproof (server-side, id-based)

Repeated Contacts syncs must never create duplicate birthdays. Enforce it on
the server, independent of the client's reconcile:

- POST /local/events is now idempotent on external_uid: if an event with the
  same (calendar_id, external_uid) exists, update it in place instead of
  inserting a new row
- startup cleanup removes existing duplicates sharing the same
  (calendar_id, external_uid), keeping the earliest — auto-heals old data
- unique DB index on (calendar_id, external_uid) as a hard guarantee

external_uid is the stable "contact:<deviceId>:<contactId>", so the same contact
from the same device always maps to exactly one event.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-13 20:53:11 +02:00
parent 1a30b4066a
commit 16ff434bef
2 changed files with 76 additions and 18 deletions

View File

@@ -282,6 +282,33 @@ def _migrate():
except Exception:
pass
# One-time cleanup of duplicate imported events sharing the same
# (calendar_id, external_uid) — e.g. birthdays created repeatedly by an
# older build without idempotent upsert. Keep the earliest row.
# Idempotent: after cleanup there is nothing left to delete.
try:
conn.execute(text(
"DELETE FROM local_events WHERE external_uid IS NOT NULL AND id NOT IN "
"(SELECT MIN(id) FROM local_events WHERE external_uid IS NOT NULL "
"GROUP BY calendar_id, external_uid)"
))
conn.commit()
logging.info("Migration: de-duplicated local_events by external_uid")
except Exception:
pass
# Hard guarantee: the DB itself forbids two events with the same
# external_uid in one calendar (imported entries only; NULLs unconstrained).
try:
conn.execute(text(
"CREATE UNIQUE INDEX IF NOT EXISTS ux_local_events_calendar_external "
"ON local_events(calendar_id, external_uid) WHERE external_uid IS NOT NULL"
))
conn.commit()
logging.info("Migration: unique index on (calendar_id, external_uid)")
except Exception:
pass
_migrate()
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)