From 68e2708ff68d149940a502cd3829c376b2470843 Mon Sep 17 00:00:00 2001 From: HomeOS Bootstrap Date: Sat, 18 Jul 2026 02:07:50 +0200 Subject: [PATCH] fix(addons): merge duplicate Ring devices' entities instead of dropping (R15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...01_add_ring_id_unique_index_to_devices.php | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/database/migrations/2026_07_18_060001_add_ring_id_unique_index_to_devices.php b/database/migrations/2026_07_18_060001_add_ring_id_unique_index_to_devices.php index ad1cd4c..4c7487c 100644 --- a/database/migrations/2026_07_18_060001_add_ring_id_unique_index_to_devices.php +++ b/database/migrations/2026_07_18_060001_add_ring_id_unique_index_to_devices.php @@ -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