diff --git a/src/live/device-event-map.ts b/src/live/device-event-map.ts index 969c8b4..9635f31 100644 --- a/src/live/device-event-map.ts +++ b/src/live/device-event-map.ts @@ -60,9 +60,14 @@ export function createDeviceEventMap( async function refresh(): Promise { const start = performance.now(); try { + // Cache keys must be IMEIs because broadcast.ts looks up by + // position.device_id (the IMEI). entry_devices.device_id is a uuid FK + // to devices.id, so we hop through the devices table and alias + // d.imei AS device_id to keep the row shape stable. const result = await pool.query( - `SELECT ed.device_id, e.event_id + `SELECT d.imei AS device_id, e.event_id FROM entry_devices ed + JOIN devices d ON d.id = ed.device_id JOIN entries e ON e.id = ed.entry_id`, ); diff --git a/src/live/snapshot.ts b/src/live/snapshot.ts index 85e04fc..cd5562e 100644 --- a/src/live/snapshot.ts +++ b/src/live/snapshot.ts @@ -52,6 +52,12 @@ export function createSnapshotProvider( * - the registered devices have no positions yet. * - all positions for a device are faulty. * + * Type bridge: positions.device_id stores the IMEI (text), while + * entry_devices.device_id is a uuid FK to devices.id. The join translates + * via devices.imei = positions.device_id (text=text) → devices.id = + * entry_devices.device_id (uuid=uuid). Without this hop Postgres rejects + * the comparison with `operator does not exist: uuid = text` (42883). + * * Never throws — the caller (registry.fetchSnapshot) already wraps in a * try/catch that falls back to an empty snapshot. */ @@ -67,7 +73,8 @@ export function createSnapshotProvider( p.speed, p.angle FROM positions p - JOIN entry_devices ed ON ed.device_id = p.device_id + JOIN devices d ON d.imei = p.device_id + JOIN entry_devices ed ON ed.device_id = d.id JOIN entries e ON e.id = ed.entry_id WHERE e.event_id = $1 AND p.faulty = false diff --git a/test/fixtures/test-schema.sql b/test/fixtures/test-schema.sql index 14fc1e0..eb63fdb 100644 --- a/test/fixtures/test-schema.sql +++ b/test/fixtures/test-schema.sql @@ -1,39 +1,45 @@ -- test/fixtures/test-schema.sql -- -- Minimum subset of the production schema required by live.integration.test.ts. --- This is intentionally a simplified version — NOT the full Directus-managed schema. +-- Intentionally a simplified subset of the Directus-managed schema — keeps only +-- the columns the Processor's live-broadcast queries actually read. -- --- Maintenance note: keep in sync with the real schema when column types change on --- these tables. Specifically: entries.event_id, entry_devices.device_id (Phase 1 --- uses IMEI text; Phase 2 introduces UUID-based devices table). --- --- Phase 1 deviation: entry_devices.device_id is TEXT (IMEI) here, matching --- positions.device_id. The real Directus schema uses a UUID FK to devices.id. --- The integration test uses the real queries from device-event-map.ts and --- snapshot.ts, so this simplified schema must satisfy those joins. +-- Maintenance note: keep in sync with the real schema when join shapes change. +-- The integration test runs the real queries from device-event-map.ts and +-- snapshot.ts unmodified, so the column types here must match production. --- events — the container for entries --- The Processor reads events.id (used in snapshot WHERE e.event_id = $1). +-- events — the container for entries. +-- Processor reads events.id (snapshot WHERE e.event_id = $1). CREATE TABLE IF NOT EXISTS events ( id uuid PRIMARY KEY DEFAULT gen_random_uuid() -- Real schema also has: organization_id FK, name, slug, discipline, starts_at, ends_at. - -- Only columns the Processor queries are included here. ); --- entries — race entries belonging to an event --- The Processor reads entries.id and entries.event_id. +-- entries — race entries belonging to an event. +-- Processor reads entries.id and entries.event_id. CREATE TABLE IF NOT EXISTS entries ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), event_id uuid NOT NULL REFERENCES events (id) ON DELETE CASCADE - -- Real schema also has: vehicle_id, class_id, number, etc. + -- Real schema also has: vehicle_id, class_id, race_number, status, etc. ); --- entry_devices — maps a device (IMEI) to an entry. --- Phase 1: device_id is IMEI text, matching positions.device_id. --- Real schema: device_id is UUID FK to devices.id, joined via devices.imei. --- This simplified form is intentional for the integration test fixture. +-- devices — durable hardware catalog. +-- Processor reads devices.id and devices.imei (joined to positions.device_id). +-- positions.device_id stores the IMEI text; entry_devices.device_id stores the +-- devices.id uuid. This table is the bridge that lets snapshot.ts and +-- device-event-map.ts translate between the two without `uuid = text` errors. +CREATE TABLE IF NOT EXISTS devices ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + imei text NOT NULL UNIQUE + -- Real schema also has: model, serial_number, notes, date_created, date_updated. +); + +-- entry_devices — maps a device (uuid FK) to an entry. +-- Real schema (and this fixture): device_id is uuid FK to devices.id. +-- Live-broadcast joins translate to/from positions.device_id (IMEI text) +-- via the devices table. CREATE TABLE IF NOT EXISTS entry_devices ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), entry_id uuid NOT NULL REFERENCES entries (id) ON DELETE CASCADE, - device_id text NOT NULL -- IMEI in Phase 1 + device_id uuid NOT NULL REFERENCES devices (id) ON DELETE CASCADE ); diff --git a/test/live.integration.test.ts b/test/live.integration.test.ts index f4c5a7b..2ba7bf5 100644 --- a/test/live.integration.test.ts +++ b/test/live.integration.test.ts @@ -63,9 +63,11 @@ const BROADCAST_GROUP_PREFIX = 'live-broadcast'; const EVENT_ID = 'ee000000-0000-0000-0000-000000000001'; const OTHER_EVENT_ID = 'ee000000-0000-0000-0000-000000000002'; const ENTRY_ID = 'aa000000-0000-0000-0000-000000000001'; -const DEVICE_1 = '111111111111111'; // IMEI -const DEVICE_2 = '222222222222222'; // IMEI -const DEVICE_ORPHAN = '999999999999999'; // not in entry_devices +const DEVICE_1_ID = 'dd000000-0000-0000-0000-000000000001'; +const DEVICE_2_ID = 'dd000000-0000-0000-0000-000000000002'; +const DEVICE_1 = '111111111111111'; // IMEI for DEVICE_1_ID +const DEVICE_2 = '222222222222222'; // IMEI for DEVICE_2_ID +const DEVICE_ORPHAN = '999999999999999'; // not registered to any entry const USER_A: FakeUser = { id: 'user-aaaa-0000-0000-0000-000000000001', @@ -220,12 +222,20 @@ async function seedDatabase(pool: pg.Pool): Promise { [ENTRY_ID, EVENT_ID], ); - // entry_devices — Phase 1 uses IMEI as device_id + // devices — durable catalog. positions.device_id stores the IMEI text; + // entry_devices.device_id is a uuid FK to devices.id. The live-broadcast + // joins translate via devices.imei. + await pool.query( + `INSERT INTO devices (id, imei) VALUES ($1, $2), ($3, $4)`, + [DEVICE_1_ID, DEVICE_1, DEVICE_2_ID, DEVICE_2], + ); + + // entry_devices — uuid FK to devices.id (matches production schema). await pool.query( `INSERT INTO entry_devices (id, entry_id, device_id) VALUES (gen_random_uuid(), $1, $2), (gen_random_uuid(), $1, $3)`, - [ENTRY_ID, DEVICE_1, DEVICE_2], + [ENTRY_ID, DEVICE_1_ID, DEVICE_2_ID], ); // positions for DEVICE_1 (two: one non-faulty, one faulty)