Compare commits

4 Commits

Author SHA1 Message Date
julian ffced44bfb fix(live): translate IMEI↔UUID in snapshot + device-event-map joins
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.
2026-05-03 20:34:18 +02:00
julian f7eed33a5b Trigger Komodo Stack redeploy on successful build
Build and Push processor / build (push) Successful in 58s
2026-05-03 16:44:48 +00:00
julian 79089aeb70 Trigger workflow on dev branch; tag image :dev
Build and Push processor / build (push) Successful in 58s
2026-05-03 15:49:12 +00:00
julian d6bb8cb3f8 Switch CI to new Gitea registry; drop Portainer trigger 2026-05-03 15:44:59 +00:00
6 changed files with 71 additions and 33 deletions
+15 -5
View File
@@ -2,7 +2,7 @@ name: Build and Push processor
on: on:
push: push:
branches: [main] branches: [dev]
paths: paths:
- 'src/**' - 'src/**'
- 'test/**' - 'test/**'
@@ -52,7 +52,7 @@ jobs:
- name: Login to Gitea Registry - name: Login to Gitea Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: git.dev.microservices.al registry: git.dev.trmtracking.org
username: ${{ secrets.REGISTRY_USERNAME }} username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }} password: ${{ secrets.REGISTRY_PASSWORD }}
@@ -61,8 +61,18 @@ jobs:
with: with:
context: . context: .
push: true push: true
tags: git.dev.microservices.al/trm/processor:main tags: git.dev.trmtracking.org/trm/processor:dev
- name: Trigger Portainer Deploy - name: Trigger Komodo Stack redeploy
if: success() if: success()
run: curl -X POST "${{ secrets.PORTAINER_WEBHOOK_URL }}" env:
URL: ${{ secrets.KOMODO_STACK_WEBHOOK_URL }}
SECRET: ${{ secrets.KOMODO_WEBHOOK_SECRET }}
run: |
body='{"ref":"refs/heads/dev"}'
sig=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -fsS -X POST \
-H 'Content-Type: application/json' \
-H "X-Hub-Signature-256: sha256=$sig" \
-d "$body" \
"$URL"
+1 -1
View File
@@ -5,7 +5,7 @@
# day-to-day development, run `pnpm dev` directly against host-exposed services. # day-to-day development, run `pnpm dev` directly against host-exposed services.
# #
# For STAGE and PRODUCTION deployment, use the multi-service compose in # For STAGE and PRODUCTION deployment, use the multi-service compose in
# the sibling `deploy/` repo (https://git.dev.microservices.al/trm/deploy), # the sibling `deploy/` repo (https://git.dev.trmtracking.org/trm/deploy),
# which references this service by its registry image tag instead of # which references this service by its registry image tag instead of
# building locally. # building locally.
# #
+6 -1
View File
@@ -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`,
); );
+8 -1
View File
@@ -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
+26 -20
View File
@@ -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
); );
+15 -5
View File
@@ -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)