fix(addons): merge duplicate Ring devices' entities instead of dropping (R15)

Codex R15: duplicate Ring rows from the pre-index race can own different
entities (one got battery, another motion); deleting the extras cascaded away
already-ingested state. The migration now MERGES — reparents each duplicate's
entities and their state onto the earliest row, keeping the survivor's copy on
a (device_id,key) collision — then removes the emptied duplicates.

Verified on Postgres: survivor keeps battery=90 and gains the reparented
motion entity (disjoint data preserved); suite 43 green on SQLite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/phase-1-bootstrap
HomeOS Bootstrap 2026-07-18 02:07:50 +02:00
parent ca98e21c18
commit 68e2708ff6
1 changed files with 27 additions and 15 deletions

View File

@ -8,21 +8,33 @@ return new class extends Migration
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);
// CREATE UNIQUE INDEX below would abort on existing data. Two duplicate rows for one Ring
// device can own DIFFERENT entities (e.g. one got battery, the other motion), so we
// MERGE rather than drop: reparent each duplicate's entities (and their cascaded state)
// onto the earliest row, keeping the survivor's copy on a key collision. Then remove the
// now-empty duplicates. Runs against ~0 rows on a fresh install; portable Postgres/SQLite.
$rows = DB::table('devices')
->whereRaw("config->>'ring_id' IS NOT NULL")
->selectRaw("id, config->>'ring_id' as ring_id")
->orderBy('id')
->get();
foreach (collect($rows)->groupBy('ring_id') as $group) {
if ($group->count() < 2) {
continue;
}
$survivorId = $group->first()->id; // ordered by id → earliest row wins
$survivorKeys = DB::table('entities')->where('device_id', $survivorId)->pluck('key')->all();
foreach ($group->slice(1) as $dup) {
// Collisions: keep the survivor's entity (+state); drop the duplicate's.
DB::table('entities')->where('device_id', $dup->id)->whereIn('key', $survivorKeys)->delete();
// Disjoint entities: move them (and their state, via entity_id) onto the survivor.
DB::table('entities')->where('device_id', $dup->id)->update(['device_id' => $survivorId]);
DB::table('devices')->where('id', $dup->id)->delete();
}
}
// 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