* fix(metadata): merge duplicate people on refresh instead of looping on 23505
The background person-refresh worker re-selected the same people every cycle.
When a refresh resolved an external id (tmdb_id/imdb_id) already held by another
people row, the UPDATE violated a partial-unique index and raised SQLSTATE
23505; the tx rolled back so updated_at never advanced and FindCandidates
re-qualified the row forever. The underlying cause is two people rows for the
same human created from credits ingested with disjoint id sets (one tmdb-only,
one imdb-only) that BatchFindOrCreate never reconciled.
PersonRepository.Update now reconciles the collision instead of failing:
- The common no-collision path stays a single plain transaction (no added cost).
- On a 23505, updateResolvingConflicts runs the whole reconciliation in ONE
transaction, retrying the write via savepoints so it commits atomically: it
locks both rows FOR UPDATE in id order, then either merges the partner into
the survivor (repoint item_people skipping duplicate credits, fold the
partner's ids/fields onto the survivor, delete the partner) or, when the rows
are not confidently the same human, drops just the conflicting id.
- canMergePeople requires compatible ids AND matching names, so a provider that
hands the same id to two genuinely different people cannot trigger a
destructive delete; that case falls to the non-destructive drop.
- A row merged away concurrently surfaces as pgx.ErrNoRows, which the refresh
service maps to ErrPersonNotFound.
Existing stuck rows self-heal: they are still re-selected each cycle and now
merge (or drop) instead of looping, draining the warning population to zero.
Adds unit tests for the merge-decision logic (guard, compatibility,
field-folding, constraint mapping).
AI-use disclosure: implemented and adversarially reviewed with AI assistance.
* fix(metadata): preserve survivor's existing id when declining a person merge
The non-mergeable branch of resolveExternalIDConflict blanked the
conflicting external-id field before retrying the write. Because
execPersonUpdate is a full-row UPDATE, the retry persisted an empty
string and returned success, silently dropping a previously-valid
provider id (e.g. the admin PATCH path that mutates an existing id into
a colliding value) instead of leaving the row unchanged as the 23505
did.
Restore the locked survivor's currently-persisted value for the field so
the retried write is a no-op on that column: it commits without looping,
without deleting a possibly-distinct person, and without blanking an id
the survivor already held. Writing a row's own current value back can
never violate the unique index, so the field will not re-trigger the
conflict. The refresh-worker path is unchanged (survivor value is empty).
Convert clearExternalIDField into a general setExternalIDField setter and
extend its unit test to cover set-to-value and set-to-empty.