fix(live): translate IMEI↔UUID in snapshot + device-event-map joins
Build and Push processor / build (push) Successful in 58s
Build and Push processor / build (push) Successful in 58s
positions.device_id stores the IMEI (text); entry_devices.device_id is a
uuid FK to devices.id. snapshot.ts and device-event-map.ts joined the two
columns directly, causing:
- snapshot.ts: Postgres rejected with `operator does not exist: uuid =
text` (42883). The registry caught the error and returned an empty
snapshot, masking the failure.
- device-event-map.ts: cache keyed on entry_devices.device_id (uuid),
but broadcast.ts:141 looks up by position.device_id (imei). Cache
missed every record → no live frames fanned out, silently.
Both queries now hop through the devices table (devices.imei =
positions.device_id, devices.id = entry_devices.device_id). The
device-event-map cache aliases d.imei AS device_id so cache keys stay
IMEI strings — broadcast.ts is unchanged.
The integration-test fixture schema previously had entry_devices.device_id
as text (IMEI) — a deliberate simplification that hid the production type
mismatch. Now matches production: adds a devices table and changes the FK
to uuid. seedDatabase inserts devices first.
178/178 unit tests pass. Integration test exercises the corrected join
shape.
This commit is contained in:
@@ -60,9 +60,14 @@ export function createDeviceEventMap(
|
|||||||
async function refresh(): Promise<void> {
|
async function refresh(): Promise<void> {
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
try {
|
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<DeviceEventRow>(
|
const result = await pool.query<DeviceEventRow>(
|
||||||
`SELECT ed.device_id, e.event_id
|
`SELECT d.imei AS device_id, e.event_id
|
||||||
FROM entry_devices ed
|
FROM entry_devices ed
|
||||||
|
JOIN devices d ON d.id = ed.device_id
|
||||||
JOIN entries e ON e.id = ed.entry_id`,
|
JOIN entries e ON e.id = ed.entry_id`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ export function createSnapshotProvider(
|
|||||||
* - the registered devices have no positions yet.
|
* - the registered devices have no positions yet.
|
||||||
* - all positions for a device are faulty.
|
* - 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
|
* Never throws — the caller (registry.fetchSnapshot) already wraps in a
|
||||||
* try/catch that falls back to an empty snapshot.
|
* try/catch that falls back to an empty snapshot.
|
||||||
*/
|
*/
|
||||||
@@ -67,7 +73,8 @@ export function createSnapshotProvider(
|
|||||||
p.speed,
|
p.speed,
|
||||||
p.angle
|
p.angle
|
||||||
FROM positions p
|
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
|
JOIN entries e ON e.id = ed.entry_id
|
||||||
WHERE e.event_id = $1
|
WHERE e.event_id = $1
|
||||||
AND p.faulty = false
|
AND p.faulty = false
|
||||||
|
|||||||
Vendored
+26
-20
@@ -1,39 +1,45 @@
|
|||||||
-- test/fixtures/test-schema.sql
|
-- test/fixtures/test-schema.sql
|
||||||
--
|
--
|
||||||
-- Minimum subset of the production schema required by live.integration.test.ts.
|
-- 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
|
-- Maintenance note: keep in sync with the real schema when join shapes change.
|
||||||
-- these tables. Specifically: entries.event_id, entry_devices.device_id (Phase 1
|
-- The integration test runs the real queries from device-event-map.ts and
|
||||||
-- uses IMEI text; Phase 2 introduces UUID-based devices table).
|
-- snapshot.ts unmodified, so the column types here must match production.
|
||||||
--
|
|
||||||
-- 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.
|
|
||||||
|
|
||||||
-- events — the container for entries
|
-- events — the container for entries.
|
||||||
-- The Processor reads events.id (used in snapshot WHERE e.event_id = $1).
|
-- Processor reads events.id (snapshot WHERE e.event_id = $1).
|
||||||
CREATE TABLE IF NOT EXISTS events (
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid()
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid()
|
||||||
-- Real schema also has: organization_id FK, name, slug, discipline, starts_at, ends_at.
|
-- 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
|
-- entries — race entries belonging to an event.
|
||||||
-- The Processor reads entries.id and entries.event_id.
|
-- Processor reads entries.id and entries.event_id.
|
||||||
CREATE TABLE IF NOT EXISTS entries (
|
CREATE TABLE IF NOT EXISTS entries (
|
||||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
event_id uuid NOT NULL REFERENCES events (id) ON DELETE CASCADE
|
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.
|
-- devices — durable hardware catalog.
|
||||||
-- Phase 1: device_id is IMEI text, matching positions.device_id.
|
-- Processor reads devices.id and devices.imei (joined to positions.device_id).
|
||||||
-- Real schema: device_id is UUID FK to devices.id, joined via devices.imei.
|
-- positions.device_id stores the IMEI text; entry_devices.device_id stores the
|
||||||
-- This simplified form is intentional for the integration test fixture.
|
-- 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 (
|
CREATE TABLE IF NOT EXISTS entry_devices (
|
||||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
entry_id uuid NOT NULL REFERENCES entries (id) ON DELETE CASCADE,
|
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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -63,9 +63,11 @@ const BROADCAST_GROUP_PREFIX = 'live-broadcast';
|
|||||||
const EVENT_ID = 'ee000000-0000-0000-0000-000000000001';
|
const EVENT_ID = 'ee000000-0000-0000-0000-000000000001';
|
||||||
const OTHER_EVENT_ID = 'ee000000-0000-0000-0000-000000000002';
|
const OTHER_EVENT_ID = 'ee000000-0000-0000-0000-000000000002';
|
||||||
const ENTRY_ID = 'aa000000-0000-0000-0000-000000000001';
|
const ENTRY_ID = 'aa000000-0000-0000-0000-000000000001';
|
||||||
const DEVICE_1 = '111111111111111'; // IMEI
|
const DEVICE_1_ID = 'dd000000-0000-0000-0000-000000000001';
|
||||||
const DEVICE_2 = '222222222222222'; // IMEI
|
const DEVICE_2_ID = 'dd000000-0000-0000-0000-000000000002';
|
||||||
const DEVICE_ORPHAN = '999999999999999'; // not in entry_devices
|
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 = {
|
const USER_A: FakeUser = {
|
||||||
id: 'user-aaaa-0000-0000-0000-000000000001',
|
id: 'user-aaaa-0000-0000-0000-000000000001',
|
||||||
@@ -220,12 +222,20 @@ async function seedDatabase(pool: pg.Pool): Promise<void> {
|
|||||||
[ENTRY_ID, EVENT_ID],
|
[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(
|
await pool.query(
|
||||||
`INSERT INTO entry_devices (id, entry_id, device_id) VALUES
|
`INSERT INTO entry_devices (id, entry_id, device_id) VALUES
|
||||||
(gen_random_uuid(), $1, $2),
|
(gen_random_uuid(), $1, $2),
|
||||||
(gen_random_uuid(), $1, $3)`,
|
(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)
|
// positions for DEVICE_1 (two: one non-faulty, one faulty)
|
||||||
|
|||||||
Reference in New Issue
Block a user