PhoenixKit.Migrations.Postgres.V188 (phoenix_kit v2.26.0)

Copy Markdown View Source

V188: the three uniqueness guarantees phoenix_kit_user_connections already believed it had.

Why

The module's schemas each declare a unique_constraint/3 naming an index — phoenix_kit_user_follows_unique_idx, phoenix_kit_user_blocks_unique_idx, phoenix_kit_user_connections_requester_recipient_uidx — and none of the three has ever existed. unique_constraint/3 only translates a database violation into a changeset error; with no index there is no violation, so the constraints were inert and the only guard was the module's read-then-write pre-check. Two concurrent follows, blocks or requests both pass that check and both insert, leaving a duplicate relationship no code path can produce deliberately and which every count then double-reports.

What it does

Removes existing duplicates, then creates the three unique indexes under exactly the names the schemas name, so those unique_constraint/3 calls start working with no change to the module.

Directed vs undirected, and why they differ

Follows and blocks are DIRECTED. "A follows B" and "B follows A" are two different relationships, so their indexes are on the ordered pair and only an exact repeat of one direction is a duplicate.

Connections are UNDIRECTED — one row represents one relationship, stored in whichever direction it was asked — so the index is on the unordered pair:

(LEAST(requester_uuid, recipient_uuid),
 GREATEST(requester_uuid, recipient_uuid))

An ordered index here would leave the race that actually matters open. Two users clicking "connect" on each other at the same moment both pass request_connection/2's pre-check (connected?/2 finds no accepted row, and each direction-specific pending lookup finds nothing) and both insert — one A→B row and one B→A row, which an ordered index permits. The damage is not cosmetic: the next request auto-accepts ONE of them and leaves the other as a live pending request between two already-connected users; remove_connection/2 then deletes only the accepted row and leaves that ghost behind; and get_accepted_connection/2 uses Repo.one/1, so a pair that ends up accepted twice raises Ecto.MultipleResultsError.

The expression index is still reported under its own name in a 23505, so the schema's existing unique_constraint/3 keeps working unchanged — the field list only decides where the error is attached.

Nothing legitimately needs both directions at once: a mutual request UPDATES the existing row to "accepted" rather than inserting a reverse one, and a removed connection deletes its row, freeing the pair for a later request.

De-duplication

Existing duplicates are removed first, or CREATE UNIQUE INDEX would fail. For follows and blocks the surviving row is the smallest uuid, which under UUIDv7's time ordering is the one written first. Connections rank "accepted" above "pending" before falling back to that rule, because the two can only coexist through the race being closed and the accepted row is the live relationship — dropping it for an older pending request would disconnect two connected users. The *_history tables are untouched and keep the full record either way.

Rolling back drops the three indexes. It cannot bring back removed duplicates, which is correct: they were never valid.

Summary

Functions

down(opts)

up(opts)