-- =========================================================================== -- Migration: P1-9 (event dedup) + P3-2b (site-scoped access control) -- + retention / rate-limit reap indexes -- -- Apply ONCE to an EXISTING move_detect database. A FRESH install already has -- all of this via schema.sql — do NOT run this on a database created from the -- current schema.sql. -- -- RUN IN THREE PARTS, IN ORDER. Part C (the events.site backfill) MUST run -- AFTER Part B has set devices.site — otherwise it matches 0 rows and every -- historical event stays site=NULL and DISAPPEARS from a scoped operator's -- dashboard the moment you scope a user. Do not skip Part C. -- -- How to run: phpMyAdmin -> your DB -> "SQL" tab. MySQL (unlike MariaDB) has no -- "ADD COLUMN IF NOT EXISTS": if a column/index already exists, that ONE -- statement errors — skip it and run the rest. -- =========================================================================== -- =========================================================================== -- PART A — schema changes (safe to run as-is) -- =========================================================================== -- A1) devices.site — the access-control site each device belongs to. ALTER TABLE devices ADD COLUMN site VARCHAR(64) NULL AFTER name, ADD KEY ix_devices_site (site); -- A2) events.site — denormalized from devices.site at ingest, for the dashboard's -- site-scoped event list. ALTER TABLE events ADD COLUMN site VARCHAR(64) NULL AFTER device_id, ADD KEY ix_events_site_occurred_at (site, occurred_at); -- A3) events.event_uuid — edge-generated idempotency key (P1-9). The UNIQUE key -- makes a re-sent event collide (dedup) instead of duplicating. MySQL allows -- multiple NULLs in a UNIQUE index, so legacy rows without a uuid are fine. ALTER TABLE events ADD COLUMN event_uuid CHAR(32) NULL AFTER event_id, ADD UNIQUE KEY uq_events_uuid (event_uuid); -- A4) event_snapshots.created_at index — PdoSnapshotRepository::purgeOlderThan() -- runs "SELECT ... WHERE created_at < ?" inside live ingest requests. Without -- this index it full-scans the snapshot table on every triggered purge. ALTER TABLE event_snapshots ADD KEY ix_event_snapshots_created (created_at); -- A5) event_snapshots (event_id, content_hash) UNIQUE — lets the ingest dedup path -- re-store an event's MISSING snapshots idempotently (a re-sent event carries -- the same re-encoded bytes = same content_hash, so a duplicate/racing store -- collides instead of writing a second copy). If this fails on "Duplicate -- entry", the table already has duplicate (event_id, content_hash) rows — -- remove the dups, then re-run this one statement. ALTER TABLE event_snapshots ADD UNIQUE KEY uq_event_snapshots_event_hash (event_id, content_hash); -- A6) rate_limits.window_start index — the reap "DELETE ... WHERE window_start < ?" -- cannot use the (rate_key, window_start) PRIMARY KEY (window_start is the -- trailing column), so it full-scans and locks rows on the pre-auth hot path. ALTER TABLE rate_limits ADD KEY ix_rate_limits_window (window_start); -- =========================================================================== -- PART B — provisioning (EDIT the values for your deployment, then run). -- Assign every device to its site here; the backfill in Part C reads it. -- =========================================================================== -- UPDATE devices SET site = 'factory-a' WHERE device_id = 'pi-1'; -- UPDATE devices SET site = 'factory-b' WHERE device_id = 'pi-2'; -- -- ... one row per device ... -- =========================================================================== -- PART C — backfill historical events from their device's site. -- RUN THIS ONLY AFTER Part B. Re-run it whenever you add/relabel a -- device's site so its past events reappear for scoped operators. -- =========================================================================== UPDATE events e JOIN devices d ON d.device_id = e.device_id SET e.site = d.site WHERE e.site IS NULL AND d.site IS NOT NULL; -- =========================================================================== -- PART D — scope dashboard users (run AFTER Part C, so scoped users immediately -- see their backfilled history). NULL = global operator; '' is NOT -- global (matches nothing — an accidental blank fails closed). -- =========================================================================== -- UPDATE users SET site_scope = 'factory-a' WHERE username = 'operator-a'; -- ---- Verification (run these; each must confirm the object/state) ----------- -- SHOW COLUMNS FROM devices LIKE 'site'; -- SHOW COLUMNS FROM events LIKE 'site'; -- SHOW COLUMNS FROM events LIKE 'event_uuid'; -- SHOW INDEX FROM events WHERE Key_name = 'uq_events_uuid'; -- SHOW INDEX FROM event_snapshots WHERE Key_name = 'ix_event_snapshots_created'; -- SHOW INDEX FROM event_snapshots WHERE Key_name = 'uq_event_snapshots_event_hash'; -- SHOW INDEX FROM rate_limits WHERE Key_name = 'ix_rate_limits_window'; -- Events still unscoped after Part C (expect 0, or only rows whose device has no -- site assigned yet — go back and finish Part B for those devices): -- SELECT COUNT(*) FROM events e JOIN devices d ON d.device_id = e.device_id -- WHERE e.site IS NULL AND d.site IS NOT NULL;