Skip to main content

Backend handoff: Live Hub, phase L0

Audience: backend developer (enode_backend_v1). Status: requested, nothing delivered yet. Date: 2026-10-06.

The Live Hub is being overhauled. The full analysis and concept are in LIVE_HUB_CONCEPT.html at the repository root; the feature itself is described in live-performance-hub.md. This document is the server work for the first phase, L0: stop the losses. It changes no interface. It makes the existing endpoints safe to retry, closes two open doors, and gives a session an end. A last section lists what later phases will ask for, so L0 can be built with it in mind.

Line references are to the branch bugfix/loading_factor_api_technique (1ffdcaec, 2026-10-05). The deployed backdev build may differ.

What is observed, and what is read​

ClaimBasis
A create sent twice stores a second set, without valuesObserved on backdev, 2026-10-06 (the probe below)
reduced returns the set-level measurements and the exercise, and no repsObserved on backdev, 2026-10-06
A session created with participatingDeviceIDs has those devices linkedObserved on backdev, 2026-10-06
Everything else in this documentRead in the code, not executed

The probe is a live test with a disposable account:

npx vitest run --config tests/live/vitest.config.ts tests/live/offline/12-live-outbox

It logs repeated create … storedTwice. That line reads true today and should read false once item 1 is in.

The one rule: additive only​

Tablets in gyms do not update on release day. An app build that is in the stores today must keep working against the new server, with every request it sends now getting the answer it gets now.

  • No existing endpoint changes its path, its required fields, or the meaning of a status code it already returns.
  • New request fields are optional. New response fields are additions.
  • The native app builds send their version in the app-version header on every request (a browser build does not; the header fails the CORS preflight). Please store the last seen value on user_device_sessions and return it on the device-session DTO, so the portal can mark devices that run an older app.
  • The device stays the unit that takes part. A phone or tablet is linked to a session, and whoever lifts on it is in. That model is kept; nothing here replaces participatingDeviceIDs.

0. Check first: can live staging remove data from a saved workout?​

This is a suspicion from reading the code, not an observed bug. It comes first because it would affect the saved workout, not the live view.

  • Live sets share the measurements and data_packages tables with recorded sessions (client-supplied ids, nullable live FKs).
  • The purge in WorkoutLiveSessionService.swift:217-239 soft-deletes those rows. It runs on session delete (:185-197), on every tracked or external PUT (WorkoutLiveSessionSetService.swift:172-176, 198-202, 326-330, 396-400) and on set or rep delete.
  • The finish-time upload matches existing rows by id including soft-deleted ones and calls update (WorkoutSessionService.swift:676-680, 845-881). Fluent's update skips soft-deleted rows.

Scenario to test: push a set live, then edit it live (or delete the live session), then finish the training in the app. Are the set's measurements and data packages present on the saved workout?

Ask: a test for this path. If it fails: restore the rows on the finish upload, or stop soft-deleting rows that a recorded session will claim.

1. Make set upload idempotent​

Today: every POST /workout_live_session_sets creates a new row and never looks at currentPersistentSetID (WorkoutLiveSessionSetService.swift:29-32). Measurements are deduplicated by client id, so a repeated request leaves a second set with reps and no values. The portal shows a value-less work set as a failed attempt. Observed on backdev: two identical POSTs, two sets, the second with no load.

Ask:

  • A unique index on (workout_live_session_id, current_persistent_set_id) where the latter is not null, plus an index for the lookup.
  • POST with a currentPersistentSetID that already exists in that session replaces that set and answers as it does today (201, empty body).
  • Existing duplicate rows need a cleanup in the migration (keep the newest per key).

The new tracking app sends from a durable queue and repeats a request whose answer it did not receive. That is only safe with this change. The app build in the stores today already sends currentPersistentSetID on every create, so it benefits without an update.

2. Make update an upsert, and allow it from the set's own device​

Today (WorkoutLiveSessionSetService.swift:280-306):

  • PUT /workout_live_session_sets/tracked/:currentPersistentSetID requires the requester to own the session. A device signed in as the athlete can POST and DELETE but gets 404 on every edit.
  • When no set matches, it answers 200 and does nothing.

Ask:

  • Allow the update when the request comes from a device that is linked to the session (the same gate POST uses), not only from the owner.
  • When no set matches the key, create it from the body, in the session the device is linked to. The body already carries everything a create needs. A device that is in no session gets the same answer POST gives it (204).
  • Reps: replace the set's reps with the ones in the body. Today they are matched by position and surplus rows stay (:344-383), so removing one rep leaves a stale row with another rep's values.

3. Close the open dashboard endpoints​

Today: /api/competition_dashboard/* is registered without any middleware (CompetitionDashboardController.swift:12-23).

  • PUT /:liveSessionID/state lets anyone who knows a session id rewrite the board.
  • POST /:displayToken/leave deletes a UserDeviceSession row by id with no category check, which signs that device out.

Ask, without breaking the standalone dashboard app:

  • PUT …/state: require the session owner (JWT), or a display token that belongs to that session.
  • leave: only delete rows of the dashboard device category.
  • join stays unauthenticated for now. It is the one thing a display without a login needs. Please read the pin it already accepts, once the session has one (a later phase adds it).

4. Give a session an end​

Today: there is no status. expirationDate is stored and never read. A device linked once keeps posting every later training into the old session, and PUT refuses to move it to a new one (WorkoutLiveSessionService.swift:423-440). PUT also ignores name and expirationDate (:107-133).

Ask:

  • Apply name and expirationDate on PUT.
  • Treat a session whose expirationDate has passed as ended: unlink its devices, and answer a set POST from such a device like "not in a session" (204, as today).
  • An explicit end: POST /workout_live_sessions/:id/end (owner). It sets an endedAt, unlinks the devices, and keeps sets and reps. Return endedAt on the session DTO.
  • Make create and update agree on a device that is linked elsewhere. Today create takes it silently (:59-69) and update fails the whole request with idMissing. Proposed: both move the device, since sessions now end. A device is in one session at a time.

5. One date encoding on realtime frames​

Today: SSE payloads use a default JSONEncoder() (RealtimeEvent.swift:60-73), so dates go out as seconds since 2001. REST sends milliseconds since 1970, and the client reads any number as that. A set that arrives by push would carry a 1970 timestamp until the next snapshot.

Ask: encode realtime payloads with the same date strategy as REST. The portal ships together with this change; the tracking app does not read the stream.

6. Reads: who may read, and what the clients now depend on​

Today the set reads (complete, reduced, /:setID) answer any signed-in user who knows a session id (WorkoutLiveSessionSetService.swift:456-556).

Ask: gate them, but not to the owner alone. Allowed are:

  • the session's owner, and users with access to the owner (the rule the realtime authorizer already uses, configure.swift:457-475);
  • any device that is linked to the session, whoever is signed in on it.

The second point is new and it matters: the tracking app now shows the session's standings on the training device (a Live tab and each athlete's place). It reads GET /workout_live_session_sets/reduced/:id after each set it delivers. On a platform tablet the signed-in user is usually the owner, on an athlete's phone it is not.

Contract the app relies on. Please keep it:

  • reduced returns every set with its set-level measurements (with metric inlined as today) and exercise.exerciseDefinitionID, and without reps.
  • complete keeps returning the reps with their data packages. The portal's stream, attempt inspector and bar-path views read them.

Also in this area:

  • The session's name for a linked device. GET /workout_live_sessions/:id is owner-only, so an athlete's phone cannot say which session it is in. Either allow that read for a linked device, or put the session's name on the device-session DTO next to workoutLiveSessionID. Additive either way.
  • One unknown exercise breaks the snapshot: a set whose exercise definition the viewer has no row for makes the whole response 404 (ExerciseService.swift:567-584). Return that set with a null exercise instead.
  • Return what the client needs to order and correlate: add currentPersistentSetID, order and the server's createdAt / updatedAt to the set return DTO. Additive. Today every displayed time comes from the client's created, which is stamped when the workout starts.
  • Tests: nothing under Tests/ covers live sessions, the dashboard or the realtime hub. Items 0 to 4 each want one.

Two questions back​

  1. Is the set topic authorized on backdev? The code authorizes workout_live_session:sets:<id> for the owner and for users with access to the owner (configure.swift:457-475), and publishes live_session_set.created|updated|deleted on it (WorkoutLiveSessionSetRealtimePublisher.swift:22-43). The portal last saw 403 on that topic (July) and works around it by joining /competition_dashboard as a display, which adds a device-session row under the coach for every open browser tab. If the topic works on the deployed build, the portal drops that workaround.
  2. Upload path cost. Each set POST waits for an attempt-board build and a full re-read of the set (WorkoutLiveSessionSetService.swift:107-118), with one query per measurement in the DTO mapping. With twenty lifters finishing together this is the likeliest cause of a lagging board. Not asked for in L0. A measurement would help: tests/live/load/replay.mjs can drive it.

What the clients do in L0​

All of it is in the working tree of enode-tracking, branch feature/announcements, not yet committed.

  • Tracking app — durable outbox (packages/core/src/offline/live-queue.ts). Each create, update and delete is written to IndexedDB before it is sent. One record per set; the queue knows whether the create went out, so it never sends an update before its create and works against the current server. It repeats a request after a lost answer, which is where item 1 matters. A 204 on POST counts as delivered. A 4xx drops the operation.
  • Tracking app — standings on the device (packages/core/src/live-session/use-live-standings.ts): reads reduced, ranks with the same function the portal uses. See item 6.
  • Portal: create now sends participatingDeviceIDs; the device picker stays, because an app from today cannot join any other way. A failed feed load is shown as an error.

Acceptance​

ScenarioExpected
The same set POSTed twiceOne set, with its values
PUT for a set whose POST never arrivedThe set exists afterwards
PUT from a device signed in as the athlete200, set updated
Edit that removes one rep of threeTwo reps, each with its own values
PUT …/state without credentialsRefused
leave with the id of a phone's device sessionRefused, the phone stays signed in
Set POST after the session ended or expired204, nothing stored
PUT session with a new nameThe name is changed
reduced read from a linked device signed in as an athlete200, sets with measurements
reduced read by a signed-in user with no relation to the sessionRefused
Live edit, then finish the trainingSaved workout has all measurements
An app build from today, unchangedEvery request answers as before

After L0: what later phases will ask for​

Not requested now. Listed so that L0's schema and naming leave room for it. Details are in the concept document, section "What changes, per layer".

PhaseServer work
L1 · truth on screenPresence: last seen per linked device and per display, readable by the owner. A "session updated" event on the realtime hub when name or config change: the portal now keeps the ranking in config and re-reads the session list once a minute so a wall display picks up a change; with an event it stops asking. The list and the single read also eager-load every set and then return none (WorkoutLiveSessionService.swift:312, 345), which that minute makes worth removing
L2 · set-up and lobbySession states lobby → live → ended, with timestamps (builds on item 4). Join by code: a short code per session; a signed-in device of the owner's organisation links itself with it. The portal's device picker stays as the second way in. Push to the device when it is linked or unlinked, so it does not have to re-read its device session
L3 · displayPaired display: a screen without a login is linked by a number the coach confirms and gets a read-only token for that session; this replaces today's open join. Platform state: which athlete is on the bar on a device, with the planned load (switch_athlete exists as a starting point)
L4 · competition rulesA valid flag on the live rep. Rules, look and the optional start list stay in the opaque config blob (16 KiB today), so nothing else is needed
L5 · deeper insightsRunning set: accept a set that is still growing (the upsert of item 2 makes this possible). Personal best before today per athlete and exercise on the live wire. Numbered events and resume (Last-Event-ID or a read "since number"), so viewers stop re-reading the whole session on every change
L6 · wrap-upStored results of an ended session, readable after the devices are released