fix(addons): dedup existing Ring devices before the unique index (R15)

Codex R15: on an upgrade where the pre-index race already produced duplicate
config.ring_id rows, CREATE UNIQUE INDEX would abort the migration. Now the
migration first collapses duplicates (keep the earliest id per ring_id; the
rest cascade-delete their entities/state) using portable subquery SQL that
runs on Postgres (prod) and SQLite (tests) alike.

Verified: 3 duplicates → 1 on Postgres, index then created; suite 43 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/phase-1-bootstrap
HomeOS Bootstrap 2026-07-18 02:05:04 +02:00
parent 8086a9647b
commit ca98e21c18
1 changed files with 17 additions and 0 deletions

View File

@ -7,6 +7,23 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
// Reconcile any duplicates the pre-index race may already have created, otherwise the
// CREATE UNIQUE INDEX below would abort on existing data. Keep the earliest row per
// ring_id; the rest cascade-delete their entities/state (both FKs cascadeOnDelete).
// Portable subquery form (works on Postgres in prod and SQLite in tests).
DB::statement(<<<'SQL'
DELETE FROM devices
WHERE config->>'ring_id' IS NOT NULL
AND id NOT IN (
SELECT min_id FROM (
SELECT MIN(id) AS min_id
FROM devices
WHERE config->>'ring_id' IS NOT NULL
GROUP BY config->>'ring_id'
) keep
)
SQL);
// Concurrent first-sight Ring messages (retained info + motion on bridge startup) must not // Concurrent first-sight Ring messages (retained info + motion on bridge startup) must not
// create two rows for one device. A partial unique index on the JSON ring_id lets the // create two rows for one device. A partial unique index on the JSON ring_id lets the
// second concurrent insert fail so the ingest job can re-fetch the winner (race-safe). // second concurrent insert fail so the ingest job can re-fetch the winner (race-safe).