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
parent
ca98e21c18
commit
68e2708ff6
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue